feature/generador-instaladores-linux-windows

This commit is contained in:
2026-07-30 07:34:17 -06:00
parent cafe3f1b87
commit c3f1d70e23
34 changed files with 3498 additions and 252 deletions

View File

@@ -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,

View File

@@ -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"

View 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"