goon/scripts/publish_update.py
goon-foss 3b061a5807 feat(mobile): ekran "zaktualizuj" dla wersji starszych niz 0.4.x
Osobny punkt wejscia (update-required.tsx) z samym ekranem blokady, bez
reszty aplikacji i bez przycisku "Pozniej". Publikowany tylko na kanaly OTA
starych runtime'ow 1.0-1.3 (0.3.0 to dzis ~8% ruchu): stare APK nie dostaja
poprawek natywnych, a ich bledy wracaly w zgloszeniach. Uzywa tylko modulow
obecnych w kazdym APK. Runtime 1.0/1.1 (stary klucz podpisu) dostaje kroki
z kodem synchronizacji, od 1.2 nowa wersja instaluje sie na wierzchu.

publish_update.py --entry podmienia pole main w package.json na czas
eksportu i odmawia publikacji na biezacy runtime.

Emulator: prawdziwe APK 0.3.0 i 0.2.1 z ta paczka pokazuja ekran, pobieranie
otwiera APK w przegladarce, kopiowanie kodu dziala, Wstecz zamyka apke,
0.4.2 instaluje sie na 0.3.0 i startuje normalnie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:06:21 +02:00

284 lines
12 KiB
Python

"""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/<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.4" # == 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/")
ap.add_argument(
"--entry",
help="inny punkt wejscia niz App.tsx, np. update-required.tsx (ekran blokady starych wersji)",
)
args = ap.parse_args()
# Ekran blokady na biezacym kanale zablokowalby wszystkich uzytkownikow.
if args.entry and args.runtime == RUNTIME_DEFAULT:
print(f"ERROR: --entry {args.entry} na biezacym runtime {RUNTIME_DEFAULT} zablokowalby wszystkich. Przerwane.")
return 1
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()`.
# `--entry`: expo export bierze punkt wejscia z pola `main` w package.json, wiec
# podmieniamy je na czas eksportu i zawsze przywracamy.
pkg_path = MOBILE / "package.json"
pkg_orig = pkg_path.read_text(encoding="utf-8")
if args.entry:
if not (MOBILE / args.entry).exists():
print(f"ERROR: brak pliku mobile/{args.entry}")
return 1
pkg = json.loads(pkg_orig)
pkg["main"] = args.entry
pkg_path.write_text(json.dumps(pkg, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
try:
run(["npx", "expo", "export", "--platform", "android", "--source-maps"], cwd=MOBILE)
finally:
if args.entry:
pkg_path.write_text(pkg_orig, encoding="utf-8")
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())