From 2c92fab2a93c2409008cedf26cd522673d6121eb Mon Sep 17 00:00:00 2001 From: goon-foss Date: Wed, 26 Aug 2026 10:23:09 +0200 Subject: [PATCH] feat(ota): wysylaj source mapy paczek OTA do Sentry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bez tego mapy mial WYLACZNIE bundle wbudowany w APK, a praktycznie kazdy uzytkownik dociaga paczke OTA przy pierwszym uruchomieniu i od tego momentu w Sentry widac `app:///index.android.bundle` zamiast nazw plikow i numerow linii. Czyli przebudowa APK z tokenem sama w sobie dawala czytelne slady na kilkadziesiat sekund zycia instalacji. Trzy zmiany w `publish_update.py`: * `expo export` z `--source-maps` * `upload_sourcemaps()` woła oficjalny `expo-upload-sourcemaps.js` z @sentry/react-native. Dopasowanie idzie po `debug id` zaszytym w paczce, wiec nazwa release'u ani dist nie sa potrzebne. Brak `SENTRY_AUTH_TOKEN` NIE jest bledem, bo publikacja ma dzialac takze u kogos, kto stawia wlasna instancje; wtedy tylko o tym mowimy. Nieudana wysylka rowniez nie blokuje publikacji: gorsza jest apka bez poprawki niz nieczytelny slad. * `strip_sourcemaps()` kasuje mapy z `dist` PRZED scp. Mapa wazy 11 MB przy 3 MB paczki i zawiera caly kod zrodlowy, a `scp -r .` wyslalby ja razem z reszta, wiec lezalaby publicznie obok paczki. `metadata.json` jej nie wymienia, wiec manifest sie nie psuje (sprawdzone). Pulapka skryptu Sentry: siega po konfiguracje Expo, gdy brakuje KTOREGOKOLWIEK z SENTRY_ORG, SENTRY_PROJECT i SENTRY_URL, i wtedy konczy bledem. SENTRY_URL nie jest sekretem, wiec podstawiamy sentry.io. Musi tez dzialac z katalogu `mobile`, bo zaklada, ze stoi w korzeniu projektu. Publikuj przez `iclaude goon -- ...`, wtedy token wjezdza ze srodowiska. Zweryfikowane end-to-end na katalogu testowym na VPS, bez ruszania zywych kanalow: mapy poszly do Sentry (debug id e2feb975), zostaly usuniete z dist (11,1 MB), manifest zbudowany z 23 assetami, a na serwerze wyladowalo 0 plikow .map. Przy okazji ustalone: paczki juz stojace na wszystkich trzech kanalach maja identyczna tresc z ta, ktorej mapy wyslalem, wiec slady dla obecnych uzytkownikow odczytaja sie bez zmuszania kogokolwiek do ponownego pobrania 3 MB. --- scripts/publish_update.py | 72 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 4 deletions(-) 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():