#!/usr/bin/env bash # Empaqueta artefactos publicables en dist/release/. # # Salida: # CloudRestoreAS--linux-x86_64.tar.gz binario + install.sh + unit + LEEME # CloudRestoreAS--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" 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 --------------------------------------------------------------- # 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"]) # 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(f" zip Windows: {out.name}") PY fi # --- 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. PKG="$RELEASE/pkg-linux/CloudRestoreAS" rm -rf "$RELEASE/pkg-linux" mkdir -p "$PKG/packaging/linux" 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/$LINUX_PKG" -C "$RELEASE/pkg-linux" CloudRestoreAS rm -rf "$RELEASE/pkg-linux" echo " tar.gz Linux: $LINUX_PKG" fi # --- 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"