243 lines
9.2 KiB
Bash
Executable File
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"
|