feature/generador-instaladores-linux-windows
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""PyInstaller spec — onefile portable (ejecutar en Windows o Linux según destino)."""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -8,9 +9,27 @@ ROOT = Path(SPECPATH).resolve().parent
|
||||
platform = "windows" if sys.platform == "win32" else "linux"
|
||||
bundled = ROOT / "packaging" / "bundled" / platform
|
||||
|
||||
|
||||
def _read_app_version() -> str:
|
||||
"""
|
||||
Lee __version__ de app/__init__.py sin importar el paquete: el spec corre en el
|
||||
intérprete de PyInstaller y no debe cargar las dependencias de la app.
|
||||
"""
|
||||
text = (ROOT / "app" / "__init__.py").read_text(encoding="utf-8")
|
||||
match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE)
|
||||
if not match:
|
||||
raise SystemExit("No se pudo leer __version__ de app/__init__.py")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
APP_VERSION = _read_app_version()
|
||||
|
||||
_assets = ROOT / "packaging" / "assets"
|
||||
datas = [
|
||||
(str(ROOT / "packaging" / "templates" / "env.default"), "packaging/templates"),
|
||||
# bundled-versions.json va embebido para que el bootstrap sepa qué versiones de
|
||||
# 7-Zip/ODBC trae este build y re-despliegue config/ cuando cambien.
|
||||
(str(ROOT / "packaging" / "bundled-versions.json"), "packaging"),
|
||||
]
|
||||
if _assets.is_dir():
|
||||
for _icon in _assets.iterdir():
|
||||
@@ -89,6 +108,82 @@ pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
_icon_file = ROOT / "packaging" / "assets" / "tray-icon.ico"
|
||||
_icon = str(_icon_file) if _icon_file.is_file() else None
|
||||
|
||||
|
||||
def _version_quad(version: str) -> tuple:
|
||||
"""VSVersionInfo exige exactamente 4 enteros; se rellena o recorta lo que haga falta."""
|
||||
parts = []
|
||||
for chunk in version.split(".")[:4]:
|
||||
digits = "".join(c for c in chunk if c.isdigit())
|
||||
parts.append(int(digits) if digits else 0)
|
||||
while len(parts) < 4:
|
||||
parts.append(0)
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def _version_info_dir():
|
||||
"""
|
||||
Directorio donde escribir el recurso de versión.
|
||||
|
||||
Se prefiere `workpath`, el directorio de trabajo que PyInstaller inyecta en el namespace
|
||||
del spec, por dos razones concretas de este proyecto:
|
||||
1. build.ps1 lo redirige a %LOCALAPPDATA%\\Temp cuando el repo está en una ruta UNC
|
||||
(\\\\wsl.localhost\\...), que es el caso al compilar Windows desde WSL.
|
||||
2. `build/` en la raíz del repo lo crea el build de Linux DENTRO DE DOCKER, así que
|
||||
queda propiedad de root; el Python de Windows escribe por SMB como el usuario
|
||||
normal y recibe PermissionError.
|
||||
"""
|
||||
candidate = globals().get("workpath")
|
||||
if candidate:
|
||||
return Path(candidate)
|
||||
return ROOT / "build"
|
||||
|
||||
|
||||
def _write_version_info(version: str) -> str:
|
||||
"""
|
||||
Genera el recurso de versión de Windows. Sin esto el .exe sale sin metadatos y las
|
||||
Propiedades del archivo no muestran nada, lo que complica auditar qué versión está
|
||||
instalada en un servidor.
|
||||
"""
|
||||
quad = _version_quad(version)
|
||||
out = _version_info_dir() / "version_info.txt"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(
|
||||
f"""VSVersionInfo(
|
||||
ffi=FixedFileInfo(
|
||||
filevers={quad},
|
||||
prodvers={quad},
|
||||
mask=0x3f,
|
||||
flags=0x0,
|
||||
OS=0x40004,
|
||||
fileType=0x1,
|
||||
subtype=0x0,
|
||||
date=(0, 0),
|
||||
),
|
||||
kids=[
|
||||
StringFileInfo([
|
||||
StringTable('080904B0', [
|
||||
StringStruct('CompanyName', 'Aduanasoft'),
|
||||
StringStruct('FileDescription', 'CloudRestoreAS - Restauracion automatica SQL Server'),
|
||||
StringStruct('FileVersion', '{version}'),
|
||||
StringStruct('InternalName', 'CloudRestoreAS'),
|
||||
StringStruct('LegalCopyright', 'Aduanasoft'),
|
||||
StringStruct('OriginalFilename', 'CloudRestoreAS.exe'),
|
||||
StringStruct('ProductName', 'CloudRestoreAS'),
|
||||
StringStruct('ProductVersion', '{version}'),
|
||||
])
|
||||
]),
|
||||
VarFileInfo([VarStruct('Translation', [0x0809, 1200])]),
|
||||
],
|
||||
)
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return str(out)
|
||||
|
||||
|
||||
# El recurso de versión solo existe en PE/Windows; en Linux PyInstaller lo ignoraría.
|
||||
_version_info = _write_version_info(APP_VERSION) if platform == "windows" else None
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
@@ -98,6 +193,7 @@ exe = EXE(
|
||||
[],
|
||||
name="CloudRestoreAS",
|
||||
icon=_icon,
|
||||
version=_version_info,
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
|
||||
@@ -1,47 +1,242 @@
|
||||
#!/usr/bin/env bash
|
||||
# Empaqueta artefactos en dist/release/
|
||||
# Empaqueta artefactos publicables en dist/release/.
|
||||
#
|
||||
# Salida:
|
||||
# CloudRestoreAS-<version>-linux-x86_64.tar.gz binario + install.sh + unit + LEEME
|
||||
# CloudRestoreAS-<version>-win-x86_64.zip .exe + install.ps1 + LEEME
|
||||
# SHA256SUMS checksums de los paquetes
|
||||
# release.json manifiesto que consume publish-release.sh
|
||||
#
|
||||
# Además deja copias crudas sin versión (CloudRestoreAS.exe, CloudRestoreAS-linux) para
|
||||
# la verificación local de autocontención descrita en BUILD.md §8. Esas NO se publican:
|
||||
# publish-release.sh solo sube lo que aparece en release.json.
|
||||
#
|
||||
# Variables:
|
||||
# CLOUDRESTORE_TARGET_ARCH=x86_64 # arquitectura declarada en los nombres/manifiesto
|
||||
# CLOUDRESTORE_MIN_BINARY_MB=25 # piso de tamaño para detectar un build truncado
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
RELEASE="$ROOT/dist/release"
|
||||
mkdir -p "$RELEASE"
|
||||
|
||||
[[ -f "$ROOT/dist/CloudRestoreAS.exe" ]] && cp -f "$ROOT/dist/CloudRestoreAS.exe" "$RELEASE/"
|
||||
[[ -f "$ROOT/dist/CloudRestoreAS" ]] && cp -f "$ROOT/dist/CloudRestoreAS" "$RELEASE/CloudRestoreAS-linux" && chmod +x "$RELEASE/CloudRestoreAS-linux"
|
||||
RED='\033[31m'; NC='\033[0m'
|
||||
err() { echo -e "${RED}ERROR:${NC} $*" >&2; }
|
||||
|
||||
# --- Versión: fuente única en app/__init__.py --------------------------------------
|
||||
VERSION="$(
|
||||
python3 - "$ROOT/app/__init__.py" <<'PY'
|
||||
import re, sys
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
m = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', text, re.M)
|
||||
if not m:
|
||||
sys.exit("No se pudo leer __version__ de app/__init__.py")
|
||||
version = m.group(1)
|
||||
# El PANEL ordena versiones comparándolas como tuplas de enteros; un formato distinto
|
||||
# rompería la detección de "hay versión nueva".
|
||||
if not re.fullmatch(r"\d+(\.\d+){1,3}", version):
|
||||
sys.exit(f"__version__ inválida: '{version}' (se espera puntos y números, p.ej. 1.2.0)")
|
||||
print(version)
|
||||
PY
|
||||
)"
|
||||
|
||||
# Los builds del org corren en amd64 (Docker para Linux, host de desarrollo para
|
||||
# Windows); no hay forma de inferir el arch del .exe desde aquí, así que se declara.
|
||||
ARCH="${CLOUDRESTORE_TARGET_ARCH:-x86_64}"
|
||||
|
||||
LINUX_PKG="CloudRestoreAS-${VERSION}-linux-${ARCH}.tar.gz"
|
||||
WIN_PKG="CloudRestoreAS-${VERSION}-win-${ARCH}.zip"
|
||||
|
||||
echo "Empaquetando CloudRestoreAS ${VERSION} (${ARCH})"
|
||||
|
||||
# Los paquetes de esta versión se regeneran; los de otras versiones se conservan.
|
||||
rm -f "$RELEASE/$LINUX_PKG" "$RELEASE/$WIN_PKG" "$RELEASE/SHA256SUMS" "$RELEASE/release.json"
|
||||
|
||||
# Las copias crudas se borran SIEMPRE antes de recrearlas. Si no, una corrida anterior deja
|
||||
# un binario en dist/release/ que se empaqueta como si fuera de esta versión aunque el build
|
||||
# de ahora haya fallado: así se generó una vez un zip "1.1.0" con un .exe parcial de 4.9 MB,
|
||||
# con su sha256 y su release.json perfectamente consistentes — el peor tipo de fallo, porque
|
||||
# la verificación de integridad no lo detecta.
|
||||
rm -f "$RELEASE/CloudRestoreAS.exe" "$RELEASE/CloudRestoreAS-linux"
|
||||
|
||||
# --- Validación de los binarios recién compilados ---------------------------------
|
||||
# Piso de tamaño: un onefile real lleva Qt + driver ODBC + 7-Zip embebidos, así que pesa
|
||||
# cientos de MB. Cualquier cosa por debajo de esto es un build truncado, no un binario.
|
||||
MIN_MB="${CLOUDRESTORE_MIN_BINARY_MB:-25}"
|
||||
VERSION_FILE="$ROOT/app/__init__.py"
|
||||
|
||||
check_binary() {
|
||||
# $1 = ruta del binario, $2 = etiqueta para los mensajes
|
||||
local path="$1" label="$2"
|
||||
[[ -f "$path" ]] || return 1
|
||||
|
||||
local size_mb
|
||||
size_mb=$(( $(stat -c %s "$path") / 1024 / 1024 ))
|
||||
if [[ "$size_mb" -lt "$MIN_MB" ]]; then
|
||||
err "el binario $label pesa ${size_mb} MB (mínimo esperado ${MIN_MB} MB): build truncado"
|
||||
err " $path"
|
||||
return 2
|
||||
fi
|
||||
|
||||
# Un binario anterior al último cambio de __version__ pertenece a otra versión. Empaquetarlo
|
||||
# con el nombre de la versión actual publicaría una mentira.
|
||||
if [[ "$VERSION_FILE" -nt "$path" ]]; then
|
||||
err "el binario $label es MÁS VIEJO que app/__init__.py: quedó de una versión anterior"
|
||||
err " recompila esa plataforma antes de empaquetar, o borra $path"
|
||||
return 2
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
HAVE_LINUX=0
|
||||
HAVE_WINDOWS=0
|
||||
FATAL=0
|
||||
|
||||
# El origen de verdad es dist/, que es donde escribe PyInstaller. Nunca dist/release/.
|
||||
# El `|| rc=$?` es necesario: bajo `set -e`, una función que devuelve != 0 como comando
|
||||
# suelto aborta el script de inmediato, sin llegar a evaluar FATAL ni a empaquetar la
|
||||
# plataforma que sí compiló.
|
||||
rc=0; check_binary "$ROOT/dist/CloudRestoreAS" "Linux" || rc=$?
|
||||
case "$rc" in
|
||||
0) HAVE_LINUX=1 ;;
|
||||
2) FATAL=1 ;;
|
||||
esac
|
||||
rc=0; check_binary "$ROOT/dist/CloudRestoreAS.exe" "Windows" || rc=$?
|
||||
case "$rc" in
|
||||
0) HAVE_WINDOWS=1 ;;
|
||||
2) FATAL=1 ;;
|
||||
esac
|
||||
|
||||
if [[ "$FATAL" -eq 1 ]]; then
|
||||
err "empaquetado abortado para no publicar un artefacto que no corresponde a $VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Copias crudas (verificación local, no se publican) ---------------------------
|
||||
if [[ "$HAVE_WINDOWS" -eq 1 ]]; then
|
||||
cp -f "$ROOT/dist/CloudRestoreAS.exe" "$RELEASE/"
|
||||
fi
|
||||
if [[ "$HAVE_LINUX" -eq 1 ]]; then
|
||||
cp -f "$ROOT/dist/CloudRestoreAS" "$RELEASE/CloudRestoreAS-linux"
|
||||
chmod +x "$RELEASE/CloudRestoreAS-linux"
|
||||
fi
|
||||
cp -f "$ROOT/packaging/LEEME.txt" "$RELEASE/"
|
||||
|
||||
# Paquete Windows: carpeta raíz CloudRestoreAS/ con .exe + install.ps1 + LEEME
|
||||
# (paridad con el .tar.gz de Linux).
|
||||
if [[ -f "$RELEASE/CloudRestoreAS.exe" ]]; then
|
||||
ROOT="$ROOT" RELEASE="$RELEASE" python3 <<'PY'
|
||||
# --- Paquete Windows ---------------------------------------------------------------
|
||||
# install.ps1 es el instalador de despliegue (autocontenido). El script de entorno de
|
||||
# desarrollo vive en scripts/dev-setup.ps1 y NO va en el paquete: antes se empaquetaba
|
||||
# por error, dejando en el zip un instalador que pedía Python y creaba un venv.
|
||||
if [[ "$HAVE_WINDOWS" -eq 1 ]]; then
|
||||
ROOT="$ROOT" RELEASE="$RELEASE" WIN_PKG="$WIN_PKG" python3 <<'PY'
|
||||
import os, zipfile
|
||||
from pathlib import Path
|
||||
root = Path(os.environ["ROOT"]); release = Path(os.environ["RELEASE"])
|
||||
items = [(release / "CloudRestoreAS.exe", "CloudRestoreAS/CloudRestoreAS.exe"),
|
||||
(root / "packaging" / "LEEME.txt", "CloudRestoreAS/LEEME.txt")]
|
||||
if (root / "install.ps1").exists():
|
||||
items.append((root / "install.ps1", "CloudRestoreAS/install.ps1"))
|
||||
with zipfile.ZipFile(release / "CloudRestoreAS-win.zip", "w", zipfile.ZIP_DEFLATED) as z:
|
||||
|
||||
root = Path(os.environ["ROOT"])
|
||||
release = Path(os.environ["RELEASE"])
|
||||
# El .exe se toma de dist/, que es donde escribe PyInstaller. Tomarlo de dist/release/
|
||||
# permitía empaquetar la copia de una corrida anterior.
|
||||
items = [
|
||||
(root / "dist" / "CloudRestoreAS.exe", "CloudRestoreAS/CloudRestoreAS.exe"),
|
||||
(root / "install.ps1", "CloudRestoreAS/install.ps1"),
|
||||
(root / "packaging" / "LEEME.txt", "CloudRestoreAS/LEEME.txt"),
|
||||
]
|
||||
missing = [str(src) for src, _ in items if not src.is_file()]
|
||||
if missing:
|
||||
raise SystemExit("Faltan archivos para el paquete Windows: " + ", ".join(missing))
|
||||
|
||||
out = release / os.environ["WIN_PKG"]
|
||||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for src, arc in items:
|
||||
z.write(src, arc)
|
||||
print(" zip Windows:", release / "CloudRestoreAS-win.zip")
|
||||
print(f" zip Windows: {out.name}")
|
||||
PY
|
||||
fi
|
||||
|
||||
if [[ -f "$RELEASE/CloudRestoreAS-linux" ]]; then
|
||||
# Paquete de despliegue completo: binario + instalador + unit systemd + LEEME,
|
||||
# bajo una carpeta raíz para que extraiga limpio. install.sh encuentra el binario
|
||||
# --- Paquete Linux -----------------------------------------------------------------
|
||||
if [[ "$HAVE_LINUX" -eq 1 ]]; then
|
||||
# Paquete de despliegue completo: binario + instalador + unit systemd + LEEME, bajo
|
||||
# una carpeta raíz para que extraiga limpio. install.sh encuentra el binario
|
||||
# (CloudRestoreAS-linux) y la unit (packaging/linux/) relativos a su ubicación.
|
||||
STAGE="$RELEASE/CloudRestoreAS-linux"
|
||||
PKG="$RELEASE/pkg-linux/CloudRestoreAS"
|
||||
rm -rf "$RELEASE/pkg-linux"
|
||||
mkdir -p "$PKG/packaging/linux"
|
||||
cp -f "$STAGE" "$PKG/CloudRestoreAS-linux"; chmod +x "$PKG/CloudRestoreAS-linux"
|
||||
cp -f "$ROOT/install.sh" "$PKG/install.sh"; chmod +x "$PKG/install.sh"
|
||||
cp -f "$ROOT/dist/CloudRestoreAS" "$PKG/CloudRestoreAS-linux"
|
||||
chmod +x "$PKG/CloudRestoreAS-linux"
|
||||
cp -f "$ROOT/install.sh" "$PKG/install.sh"
|
||||
chmod +x "$PKG/install.sh"
|
||||
cp -f "$ROOT/packaging/linux/cloudrestoreas.service" "$PKG/packaging/linux/"
|
||||
cp -f "$ROOT/packaging/LEEME.txt" "$PKG/LEEME.txt"
|
||||
tar czf "$RELEASE/CloudRestoreAS-linux.tar.gz" -C "$RELEASE/pkg-linux" CloudRestoreAS
|
||||
tar czf "$RELEASE/$LINUX_PKG" -C "$RELEASE/pkg-linux" CloudRestoreAS
|
||||
rm -rf "$RELEASE/pkg-linux"
|
||||
echo " tar.gz Linux: $LINUX_PKG"
|
||||
fi
|
||||
|
||||
echo "Release en: $RELEASE"
|
||||
# --- Checksums y manifiesto ---------------------------------------------------------
|
||||
cd "$RELEASE"
|
||||
PUBLISHABLE=()
|
||||
[[ -f "$LINUX_PKG" ]] && PUBLISHABLE+=("$LINUX_PKG")
|
||||
[[ -f "$WIN_PKG" ]] && PUBLISHABLE+=("$WIN_PKG")
|
||||
|
||||
if [[ "${#PUBLISHABLE[@]}" -eq 0 ]]; then
|
||||
echo "ERROR: no se generó ningún paquete; ¿corriste build.sh / build.ps1?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sha256sum "${PUBLISHABLE[@]}" > SHA256SUMS
|
||||
echo " SHA256SUMS: ${#PUBLISHABLE[@]} paquete(s)"
|
||||
|
||||
VERSION="$VERSION" ARCH="$ARCH" ROOT="$ROOT" RELEASE="$RELEASE" \
|
||||
LINUX_PKG="$LINUX_PKG" WIN_PKG="$WIN_PKG" python3 <<'PY'
|
||||
import hashlib, json, os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
release = Path(os.environ["RELEASE"])
|
||||
root = Path(os.environ["ROOT"])
|
||||
version = os.environ["VERSION"]
|
||||
arch = os.environ["ARCH"]
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
artifacts = []
|
||||
for file_name, platform in ((os.environ["LINUX_PKG"], "linux"), (os.environ["WIN_PKG"], "windows")):
|
||||
path = release / file_name
|
||||
if not path.is_file():
|
||||
continue
|
||||
artifacts.append(
|
||||
{
|
||||
"file_name": file_name,
|
||||
"platform": platform,
|
||||
"arch": arch,
|
||||
"size": path.stat().st_size,
|
||||
"sha256": sha256(path),
|
||||
}
|
||||
)
|
||||
|
||||
# Se registra qué versiones de 7-Zip/ODBC quedaron embebidas: es lo que distingue dos
|
||||
# builds de la misma versión de app y lo que dispara el re-despliegue de config/.
|
||||
bundled = {}
|
||||
manifest_path = root / "packaging" / "bundled-versions.json"
|
||||
if manifest_path.is_file():
|
||||
bundled = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
payload = {
|
||||
"product": "CloudRestoreAS",
|
||||
"version": version,
|
||||
"built_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"bundled": bundled,
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
(release / "release.json").write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
print(f" release.json: {len(artifacts)} artefacto(s) para publicar")
|
||||
PY
|
||||
|
||||
echo ""
|
||||
echo "Release ${VERSION} en: $RELEASE"
|
||||
ls -lh "$RELEASE"
|
||||
|
||||
221
packaging/scripts/publish-release.sh
Executable file
221
packaging/scripts/publish-release.sh
Executable file
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publica los artefactos de dist/release/ en el registro de paquetes GENÉRICOS de Gitea.
|
||||
#
|
||||
# Gitea calcula y expone el sha256 de cada archivo, así que se convierte en la fuente de
|
||||
# verdad de los binarios: el PANEL descubre las versiones leyendo su API y verifica la
|
||||
# integridad contra ese hash. Aquí, tras subir, se compara lo que reporta Gitea contra
|
||||
# release.json para no dejar una publicación a medias pasando por buena.
|
||||
#
|
||||
# Uso:
|
||||
# GITEA_TOKEN=... ./packaging/scripts/publish-release.sh
|
||||
# ... --dry-run # lista qué subiría, sin subir nada
|
||||
# ... --force # reemplaza archivos ya publicados de esta versión
|
||||
# ... --notify-panel # avisa al PANEL para que sincronice de inmediato
|
||||
#
|
||||
# Variables:
|
||||
# GITEA_TOKEN (requerida) PAT de Gitea con scope write:package
|
||||
# GITEA_BASE_URL default https://git.aduanasoft.com
|
||||
# GITEA_OWNER default ADUANASOFT
|
||||
# CRAS_PACKAGE default cloudrestoreas
|
||||
# PANEL_API_URL (solo --notify-panel) URL base del PANEL
|
||||
# CLOUDRESTORE_API_TOKEN (solo --notify-panel) token de servicio del PANEL
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
RELEASE="$ROOT/dist/release"
|
||||
MANIFEST="$RELEASE/release.json"
|
||||
|
||||
GITEA_BASE_URL="${GITEA_BASE_URL:-https://git.aduanasoft.com}"
|
||||
GITEA_OWNER="${GITEA_OWNER:-ADUANASOFT}"
|
||||
CRAS_PACKAGE="${CRAS_PACKAGE:-cloudrestoreas}"
|
||||
|
||||
DRY_RUN=0
|
||||
FORCE=0
|
||||
NOTIFY_PANEL=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--force) FORCE=1 ;;
|
||||
--notify-panel) NOTIFY_PANEL=1 ;;
|
||||
-h|--help)
|
||||
grep '^#' "$0" | grep -v '^#!' | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "Opción desconocida: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
BLUE='\033[36m'; GREEN='\033[32m'; YELLOW='\033[33m'; RED='\033[31m'; NC='\033[0m'
|
||||
say() { echo -e "${BLUE}==>${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}OK:${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}AVISO:${NC} $*" >&2; }
|
||||
err() { echo -e "${RED}ERROR:${NC} $*" >&2; }
|
||||
|
||||
if [[ ! -f "$MANIFEST" ]]; then
|
||||
err "no existe $MANIFEST; corre packaging/scripts/package-release.sh primero"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$MANIFEST")"
|
||||
|
||||
# Los paquetes del manifiesto más los archivos de verificación, que se publican para
|
||||
# poder auditar una versión sin depender del panel.
|
||||
mapfile -t FILES < <(
|
||||
python3 - "$MANIFEST" <<'PY'
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
for artifact in data["artifacts"]:
|
||||
print(artifact["file_name"])
|
||||
print("SHA256SUMS")
|
||||
print("release.json")
|
||||
PY
|
||||
)
|
||||
|
||||
if [[ "${#FILES[@]}" -le 2 ]]; then
|
||||
err "release.json no lista artefactos; ¿falló el build de ambas plataformas?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PKG_BASE="$GITEA_BASE_URL/api/packages/$GITEA_OWNER/generic/$CRAS_PACKAGE/$VERSION"
|
||||
API_BASE="$GITEA_BASE_URL/api/v1/packages/$GITEA_OWNER/generic/$CRAS_PACKAGE/$VERSION"
|
||||
|
||||
say "Publicando CloudRestoreAS $VERSION"
|
||||
echo " Destino : $PKG_BASE"
|
||||
echo " Archivos: ${#FILES[@]}"
|
||||
|
||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||
for f in "${FILES[@]}"; do
|
||||
if [[ -f "$RELEASE/$f" ]]; then
|
||||
printf ' [dry-run] PUT %s (%s)\n' "$f" "$(du -h "$RELEASE/$f" | cut -f1)"
|
||||
else
|
||||
warn "[dry-run] falta $f"
|
||||
fi
|
||||
done
|
||||
ok "dry-run completado; no se subió nada"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
err "falta GITEA_TOKEN (PAT de Gitea con scope write:package)"
|
||||
echo " Ejemplo: GITEA_TOKEN=xxxx $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Subida ------------------------------------------------------------------------
|
||||
for f in "${FILES[@]}"; do
|
||||
path="$RELEASE/$f"
|
||||
if [[ ! -f "$path" ]]; then
|
||||
warn "no existe $f; se omite"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$FORCE" -eq 1 ]]; then
|
||||
# Los paquetes genéricos son inmutables: para reemplazar hay que borrar primero.
|
||||
curl -sS -o /dev/null -X DELETE \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
"$PKG_BASE/$f" || true
|
||||
fi
|
||||
|
||||
say "Subiendo $f ($(du -h "$path" | cut -f1))"
|
||||
# -# manda la barra de progreso a stderr y -w el código HTTP a stdout, así se puede
|
||||
# ver el avance de una subida de cientos de MB y a la vez capturar el resultado.
|
||||
code="$(
|
||||
curl -S -# -o /dev/null -w '%{http_code}' \
|
||||
--retry 2 --retry-delay 3 --retry-connrefused \
|
||||
-X PUT \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
--upload-file "$path" \
|
||||
"$PKG_BASE/$f"
|
||||
)"
|
||||
|
||||
case "$code" in
|
||||
201|200) ok "$f publicado" ;;
|
||||
409)
|
||||
err "$f ya está publicado en la versión $VERSION"
|
||||
echo " Los paquetes genéricos son inmutables. Sube una versión nueva (recomendado)" >&2
|
||||
echo " o reemplaza esta a propósito con --force." >&2
|
||||
exit 1 ;;
|
||||
401|403)
|
||||
err "Gitea rechazó el token ($code); revisa GITEA_TOKEN y su scope write:package"
|
||||
exit 1 ;;
|
||||
*)
|
||||
err "fallo subiendo $f (HTTP $code)"
|
||||
exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Verificación contra la API que consume el PANEL ---------------------------------
|
||||
say "Verificando en Gitea (misma API que lee el PANEL)"
|
||||
files_json="$(
|
||||
curl -sS -H "Authorization: token $GITEA_TOKEN" -H 'Accept: application/json' \
|
||||
"$API_BASE/files"
|
||||
)"
|
||||
|
||||
if ! MANIFEST="$MANIFEST" FILES_JSON="$files_json" python3 <<'PY'
|
||||
import json, os, sys
|
||||
|
||||
manifest = json.load(open(os.environ["MANIFEST"], encoding="utf-8"))
|
||||
try:
|
||||
remote = json.loads(os.environ["FILES_JSON"])
|
||||
except json.JSONDecodeError:
|
||||
sys.exit("Gitea no devolvió JSON al listar los archivos del paquete")
|
||||
if not isinstance(remote, list):
|
||||
sys.exit(f"Respuesta inesperada de Gitea: {remote!r}")
|
||||
|
||||
by_name = {entry.get("name"): entry for entry in remote}
|
||||
problems = []
|
||||
for artifact in manifest["artifacts"]:
|
||||
name = artifact["file_name"]
|
||||
entry = by_name.get(name)
|
||||
if entry is None:
|
||||
problems.append(f"{name}: no aparece en Gitea")
|
||||
continue
|
||||
# Gitea calcula el sha256 del lado servidor: si coincide con el local, los bytes
|
||||
# llegaron completos y el PANEL podrá verificar la descarga con ese mismo hash.
|
||||
remote_sha = (entry.get("sha256") or "").lower()
|
||||
if remote_sha != artifact["sha256"].lower():
|
||||
problems.append(f"{name}: sha256 local {artifact['sha256'][:12]}… != remoto {remote_sha[:12] or '(vacío)'}…")
|
||||
elif int(entry.get("size") or -1) != artifact["size"]:
|
||||
problems.append(f"{name}: tamaño local {artifact['size']} != remoto {entry.get('size')}")
|
||||
else:
|
||||
print(f" {name}: sha256 y tamaño coinciden")
|
||||
|
||||
if problems:
|
||||
print("\n".join(f" {p}" for p in problems), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
PY
|
||||
then
|
||||
err "la verificación contra Gitea falló; la publicación quedó incompleta"
|
||||
echo " Revisa el paquete y reintenta con --force." >&2
|
||||
exit 1
|
||||
fi
|
||||
ok "verificación correcta"
|
||||
|
||||
# --- Aviso al PANEL ------------------------------------------------------------------
|
||||
if [[ "$NOTIFY_PANEL" -eq 1 ]]; then
|
||||
if [[ -z "${PANEL_API_URL:-}" || -z "${CLOUDRESTORE_API_TOKEN:-}" ]]; then
|
||||
warn "--notify-panel requiere PANEL_API_URL y CLOUDRESTORE_API_TOKEN; se omite el aviso"
|
||||
warn "El PANEL igual descubrirá la versión al sincronizar desde su UI."
|
||||
else
|
||||
say "Avisando al PANEL para que sincronice"
|
||||
base="${PANEL_API_URL%/}"
|
||||
code="$(
|
||||
curl -sS -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: Bearer $CLOUDRESTORE_API_TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{}' \
|
||||
"$base/api/restore/agent-sync"
|
||||
)"
|
||||
if [[ "$code" == "200" ]]; then
|
||||
ok "PANEL sincronizado"
|
||||
else
|
||||
# No es fatal: la publicación en Gitea ya está firme y el panel puede sincronizar
|
||||
# después desde su UI. Solo se informa.
|
||||
warn "el PANEL respondió $code al sincronizar; hazlo desde /versiones-cras"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
ok "CloudRestoreAS $VERSION publicado en Gitea"
|
||||
echo " Paquete: $GITEA_BASE_URL/-/packages/$GITEA_OWNER/generic/$CRAS_PACKAGE/$VERSION"
|
||||
echo " Siguiente: en el PANEL, /versiones-cras → Sincronizar con Gitea → Activar"
|
||||
Reference in New Issue
Block a user