goon/scripts/publish_update.py
jtrzupek 64506690df fix(ota+mobile): strip expo-font from bundle, runtime back to 1.0
DIAGNOZA NA EMULATORZE (emulator-5554, goon-v0.1.9.apk):
Dwa błędne założenia z poprzednich sesji obalone empirycznie:

1. RUNTIME: APK ma EXPO_RUNTIME_VERSION="1.0" (NIE 0.1.9 — pomyliłem versionName
   z runtime). App akceptuje TYLKO manifest runtime 1.0. Mój wcześniejszy
   "fix" na 0.1.9 (c19da51) był wstecz — app go ignorował. Cofnięte: app.json
   + publish_update RUNTIME_DEFAULT z powrotem na "1.0".

2. CRASH: prawdziwa przyczyna "nic się nie pojawia" — OTA bundle z expo-font
   crashował: "Cannot find native module 'ExpoFontLoader'" → expo-updates
   ErrorRecovery rollback. APK (build 22-maja) nie ma natywnego ExpoFontLoader
   (expo-font dodany 30-maja, PO buildzie APK). OTA NIE MOŻE dostarczyć native
   modułu. Potwierdzone: embedded bundle + served bundle grep = 0 ExpoFontLoader;
   stary font-bundle crashował, font-stripped NIE.

FIX: usunięto useFonts z App.tsx + expo-font import; theme.fonts → undefined
(system font); SceneTile/MoviePosterCard/navigation/GoonWordmark fontFamily →
fontWeight. Wszystko inne (2-col grid, oxblood, logo SVG-RNSVG-jest-w-APK)
zostaje. Custom fonty wrócą przy rebuildzie APK z expo-font (option B).

ZWERYFIKOWANE: bundle d5b87e5c (runtime 1.0, 0 ttf) — emulator launch:
`ReactNativeJS: Running "main"`, zero JS errors, brak ExpoFontLoader crash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 11:41:29 +02:00

173 lines
6.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
from datetime import UTC, datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MOBILE = ROOT / "mobile"
DIST = MOBILE / "dist"
RUNTIME_DEFAULT = "1.0" # == EXPO_RUNTIME_VERSION w APK (zweryfikowane emulatorem 2026-05-31: app akceptuje TYLKO runtime 1.0).
# 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"
)
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")
if shell:
subprocess.run(" ".join(cmd), cwd=cwd, check=True, shell=True)
else:
subprocess.run(cmd, cwd=cwd, check=True)
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}"])
run(["scp", "-r", f"{DIST}/.", f"{VPS}:{remote_dir}/"])
# 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",
str(current_local),
f"{VPS}:{VPS_BASE}/{args.runtime}/current.json",
])
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())