diff --git a/scripts/publish_update.py b/scripts/publish_update.py index b2609a5..f97b2a5 100644 --- a/scripts/publish_update.py +++ b/scripts/publish_update.py @@ -1,7 +1,9 @@ """Publish a new Expo Updates JS bundle do VPS-a (silent OTA update). Workflow: - 1. `npx expo export --platform android` -> produkuje `mobile/dist/` + 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///` @@ -63,7 +65,7 @@ def sha256_b64url(data: bytes) -> str: return base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode() -def run(cmd: list[str], cwd: Path | None = None) -> None: +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 @@ -71,13 +73,70 @@ def run(cmd: list[str], cwd: Path | None = None) -> None: # 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"} + 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) @@ -88,7 +147,12 @@ def main() -> int: if DIST.exists(): import shutil shutil.rmtree(DIST) - run(["npx", "expo", "export", "--platform", "android"], cwd=MOBILE) + # `--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():