Files
CloudRecoveryAS/packaging/scripts/package-release.sh

243 lines
9.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# 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"
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"