"""Publish a new Expo Updates JS bundle do VPS-a (silent OTA update). Workflow: 1. `npx expo export --platform android --source-maps` -> produkuje `mobile/dist/` 1b. Mapy lecą do Sentry (`upload_sourcemaps`), a potem znikają z `dist` (`strip_sourcemaps`), żeby nie pojechały na serwer razem z paczką 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///` 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.2" # == EXPO_RUNTIME_VERSION w AndroidManifest + app.json runtimeVersion. # Historia kanalow: 1.0 -> 1.1 (2026-06) -> 1.2 (2026-08-11, APK 0.2.2 z expo-keep-awake). # 1.1 wciaz ZYJE: siedza tam instalacje sprzed 0.2.2, ktore nie moga sie zaktualizowac in-place # (stary klucz podpisujacy przepadl) i dostaly baner migracji. Poprawki dla nich publikuj # jawnie przez --runtime 1.1, dopoki ktos tam jeszcze jest. # 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, extra_env: dict | 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", **(extra_env or {})} if shell: subprocess.run(" ".join(cmd), cwd=cwd, check=True, shell=True, env=env) else: subprocess.run(cmd, cwd=cwd, check=True, env=env) SENTRY_UPLOADER = ( MOBILE / "node_modules" / "@sentry" / "react-native" / "scripts" / "expo-upload-sourcemaps.js" ) def upload_sourcemaps() -> None: """Wysyła mapy tej paczki do Sentry, żeby ślady JS dało się odczytać. Po co: mapy z buildu APK opisują WYŁĄCZNIE paczkę wbudowaną w plik instalacyjny, a praktycznie każdy użytkownik dociąga OTA przy pierwszym uruchomieniu i od tego momentu w Sentry widać `app:///index.android.bundle` zamiast nazw plików i numerów linii. Dopasowanie idzie po `debug id` zaszytym w paczce, więc nazwa release'u ani dist nie są do niczego potrzebne. Brak `SENTRY_AUTH_TOKEN` NIE jest błędem. Publikacja ma działać także u kogoś, kto stawia własną instancję i nie ma naszego Sentry; wtedy tylko o tym mówimy. U nas token wstrzykuje wrapper `iclaude` (workspace `goon-app`), więc publikuj przez niego. """ if not os.environ.get("SENTRY_AUTH_TOKEN"): print("Sentry: brak SENTRY_AUTH_TOKEN, pomijam wysyłkę map (slady JS beda nieczytelne)") return if not SENTRY_UPLOADER.exists(): print(f"Sentry: nie znalazlem {SENTRY_UPLOADER}, pomijam wysyłkę map") return # Skrypt Sentry sięga po konfigurację Expo, gdy brakuje KTÓREGOKOLWIEK z org, project # i url, i wtedy kończy błędem. Dla sentry.io ten adres jest stały, więc podstawiamy # domyślny i zostajemy przy zmiennych środowiskowych. # Musi też działać z katalogu `mobile`, bo zakłada, że stoi w korzeniu projektu. try: run( ["node", str(SENTRY_UPLOADER), str(DIST)], cwd=MOBILE, extra_env={"SENTRY_URL": os.environ.get("SENTRY_URL") or "https://sentry.io"}, ) except subprocess.CalledProcessError as e: # Nieudana wysyłka map NIE MOŻE zablokować publikacji: gorsza jest apka bez # poprawki niż nieczytelny ślad w Sentry. print(f"Sentry: wysyłka map nie powiodła się ({e}), publikuję mimo to") def strip_sourcemaps() -> None: """Kasuje mapy z `dist`, zanim cokolwiek pojedzie na serwer. Mapa waży około 10,7 MB przy 3 MB paczki i zawiera cały kod źródłowy. `metadata.json` jej nie wymienia, więc manifest bez niej działa, ale `scp -r .` wysłałby ją razem z resztą i leżałaby publicznie obok paczki. """ removed = 0 freed = 0 for path in DIST.rglob("*.map"): freed += path.stat().st_size path.unlink() removed += 1 if removed: print(f"Usunięto {removed} map(y) z dist ({freed / 1e6:.1f} MB), nie ida na serwer") 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) # `--source-maps` dokłada `.hbc.map`, potrzebną do odczytania śladów JS # w Sentry. Mapa NIE jedzie na serwer, kasuje ją `strip_sourcemaps()`. run(["npx", "expo", "export", "--platform", "android", "--source-maps"], cwd=MOBILE) upload_sourcemaps() strip_sourcemaps() 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())