goon/scripts/publish_update.py
jtrzupek 576a424615 fix(scripts): force UTF-8 stdout in publish_update — stop false exit-1
Final Polish-char print crashed with UnicodeEncodeError on Windows cp1252 stdout
AFTER a successful publish, making exit code 1 misleading. Reconfigure stdout/stderr
to UTF-8 up front.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:58:43 +02:00

192 lines
7.6 KiB
Python

"""Publish a new Expo Updates JS bundle do VPS-a (silent OTA update).
Workflow:
1. `npx expo export --platform android` -> produkuje `mobile/dist/`
2. Czyta `dist/metadata.json` (file->hash mapping wygenerowany przez Expo)
3. Buduje manifest JSON wg Expo Updates v1 protocol
4. scp dist/ + manifest.json -> `/root/goon/app/static/expo-updates/<runtime>/<update_id>/`
5. Updatuje `current.json` żeby wskazać nowy update_id
6. Mobile przy następnym launchu zauważy zmianę manifestu i pobierze bundle
UWAGA: runtimeVersion MUSI pasować do `EXPO_RUNTIME_VERSION` z AndroidManifest
embedowanego APK. Aktualnie `1.0`. Bumpować TYLKO gdy native code change wymusza
incompat z poprzednim bundle (wtedy nowy APK trzeba przez PackageInstaller).
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import mimetypes
import os
import subprocess
import sys
import uuid
# Windows console domyślnie cp1252 → polskie znaki w finalnych printach rzucały
# UnicodeEncodeError PO udanym publishu (exit 1 mylił "czy się udało"). Wymuś UTF-8.
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
from datetime import UTC, datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MOBILE = ROOT / "mobile"
DIST = MOBILE / "dist"
RUNTIME_DEFAULT = "1.1" # == EXPO_RUNTIME_VERSION w APK (bump 1.0→1.1 2026-06; aktywny kanał + app.json runtimeVersion=1.1).
# Operator config — set in your shell / .env.local before running this script.
# Defaults are placeholders intended to fail loudly if you forgot to configure.
VPS = os.environ.get("GOON_VPS_SSH", "root@your-vps.example.com")
VPS_BASE = os.environ.get("GOON_VPS_UPDATES_DIR", "/root/goon/app/static/expo-updates")
PUBLIC_BASE = os.environ.get(
"GOON_PUBLIC_UPDATES_URL", "https://your-vps.example.com:8443/expo-updates/asset"
)
# git-bash (MSYS) na Windows konwertuje POSIX-owe ścieżki w env (`/root/...`) na
# `C:/Program Files/Git/root/...` ZANIM python je zobaczy → upload trafiał w złą ścieżkę
# (bug 2026-06-02). Odkręcamy częsty przypadek. Alternatywnie: uruchom z MSYS_NO_PATHCONV=1.
if "/Git/root/" in VPS_BASE:
VPS_BASE = VPS_BASE[VPS_BASE.index("/Git") + len("/Git"):]
def sha256_b64url(data: bytes) -> str:
"""SHA-256 -> base64url bez padding (Expo Updates wymaga tego formatu)."""
return base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode()
def run(cmd: list[str], cwd: Path | None = None) -> None:
print(f"$ {' '.join(cmd)}")
# Windows: `npx` to `.cmd` więc subprocess wymaga `shell=True` (CreateProcess
# nie wie jak rozwiązać .cmd extension bez shella). Linux/Mac: shell=False
# jest bezpieczniejsze (brak shell interpolation), ale my podajemy list[str]
# więc i tak nie ma shell injection risk.
shell = sys.platform == "win32" and cmd[0] in ("npx", "npm", "scp", "ssh")
# MSYS_NO_PATHCONV=1 — blokuje konwersję `/root/...` argów ssh/scp przez git-bash sh.
env = {**os.environ, "MSYS_NO_PATHCONV": "1"}
if shell:
subprocess.run(" ".join(cmd), cwd=cwd, check=True, shell=True, env=env)
else:
subprocess.run(cmd, cwd=cwd, check=True, env=env)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--runtime", default=RUNTIME_DEFAULT)
ap.add_argument("--skip-export", action="store_true", help="reuse existing dist/")
args = ap.parse_args()
if not args.skip_export:
if DIST.exists():
import shutil
shutil.rmtree(DIST)
run(["npx", "expo", "export", "--platform", "android"], cwd=MOBILE)
metadata_path = DIST / "metadata.json"
if not metadata_path.exists():
print(f"ERROR: {metadata_path} not found — czy expo export się powiodło?")
return 1
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
android_meta = metadata["fileMetadata"]["android"]
bundle_rel = android_meta["bundle"]
bundle_path = DIST / bundle_rel
bundle_data = bundle_path.read_bytes()
bundle_hash = sha256_b64url(bundle_data)
update_id = str(uuid.uuid4())
created_at = datetime.now(UTC).isoformat().replace("+00:00", "Z")
def asset_url(rel_path: str) -> str:
# rel_path = "_expo/static/js/android/abc.hbc" lub "assets/abc".
# Windows: Expo metadata.json używa os.sep (`\`) w assets[].path. Normalizujemy
# do `/` żeby URL był poprawny path-side (Linux backend nie traktuje `\` jako
# separatora — bez tego mobile dostaje 404 na każdy asset i odrzuca update).
rel_path = rel_path.replace("\\", "/")
return f"{PUBLIC_BASE}?asset={update_id}/{rel_path}&runtimeVersion={args.runtime}&platform=android"
launch_asset = {
"hash": bundle_hash,
"key": Path(bundle_rel).stem,
"contentType": "application/javascript",
"url": asset_url(bundle_rel),
}
assets: list[dict] = []
for a in android_meta.get("assets", []):
ap_path = DIST / a["path"]
ap_data = ap_path.read_bytes()
ext = a.get("ext") or ""
if not ext.startswith("."):
ext = "." + ext if ext else ""
ctype = mimetypes.types_map.get(ext, "application/octet-stream")
assets.append({
"hash": sha256_b64url(ap_data),
"key": Path(a["path"]).name,
"contentType": ctype,
"fileExtension": ext,
"url": asset_url(a["path"]),
})
# `extra.expoClient` musi zawierać zawartość app.json (sekcja `expo`) — bez tego
# `Constants.expoConfig` w mobile zwraca null po reload OTA, więc np. version
# czyta się jako '0.0.0' (zgłoszone w bug-report 97adff93 — fake "Update available"
# dialog 0.1.8 vs 0.0.0). Patrz `mobile/src/lib/appVersion.ts`.
app_json_path = MOBILE / "app.json"
expo_client_config: dict = {}
if app_json_path.exists():
app_json = json.loads(app_json_path.read_text(encoding="utf-8"))
expo_client_config = app_json.get("expo", {})
manifest = {
"id": update_id,
"createdAt": created_at,
"runtimeVersion": args.runtime,
"launchAsset": launch_asset,
"assets": assets,
"metadata": {},
"extra": {
"expoClient": expo_client_config,
},
}
manifest_local = DIST / "manifest.json"
manifest_local.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
print(f"Manifest: {manifest_local}")
print(f" update_id: {update_id}")
print(f" assets: {len(assets)}")
print(f" bundle: {bundle_rel} ({len(bundle_data) // 1024} KB)")
# Upload do VPS
remote_dir = f"{VPS_BASE}/{args.runtime}/{update_id}"
print(f"\nUpload -> {remote_dir}")
run(["ssh", VPS, f"mkdir -p {remote_dir}"])
# Źródło jako "." z cwd=DIST — Windows scp traktuje `C:\...` jako `host:path`
# (litera dysku = host), więc nie podajemy ścieżki z literą dysku (bug 2026-06-02).
run(["scp", "-r", ".", f"{VPS}:{remote_dir}/"], cwd=DIST)
# current.json wskazuje na nowy update_id
current = {
"update_id": update_id,
"runtime_version": args.runtime,
"created_at": created_at,
}
current_local = DIST / "current.json"
current_local.write_text(json.dumps(current, indent=2), encoding="utf-8")
run([
"scp",
"current.json",
f"{VPS}:{VPS_BASE}/{args.runtime}/current.json",
], cwd=DIST)
print(f"\nOK Update {update_id} live na runtime {args.runtime}")
print(f" Klienci dostaną bundle przy następnym launchu (lub Updates.checkForUpdate()).")
return 0
if __name__ == "__main__":
sys.exit(main())