feat(ota): wysylaj source mapy paczek OTA do Sentry

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.
This commit is contained in:
goon-foss 2026-08-26 10:23:09 +02:00
parent 031c309091
commit 2c92fab2a9

View file

@ -1,7 +1,9 @@
"""Publish a new Expo Updates JS bundle do VPS-a (silent OTA update). """Publish a new Expo Updates JS bundle do VPS-a (silent OTA update).
Workflow: 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) 2. Czyta `dist/metadata.json` (file->hash mapping wygenerowany przez Expo)
3. Buduje manifest JSON wg Expo Updates v1 protocol 3. Buduje manifest JSON wg Expo Updates v1 protocol
4. scp dist/ + manifest.json -> `/root/goon/app/static/expo-updates/<runtime>/<update_id>/` 4. scp dist/ + manifest.json -> `/root/goon/app/static/expo-updates/<runtime>/<update_id>/`
@ -63,7 +65,7 @@ def sha256_b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode() 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)}") print(f"$ {' '.join(cmd)}")
# Windows: `npx` to `.cmd` więc subprocess wymaga `shell=True` (CreateProcess # Windows: `npx` to `.cmd` więc subprocess wymaga `shell=True` (CreateProcess
# nie wie jak rozwiązać .cmd extension bez shella). Linux/Mac: shell=False # 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. # więc i tak nie ma shell injection risk.
shell = sys.platform == "win32" and cmd[0] in ("npx", "npm", "scp", "ssh") 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. # 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: if shell:
subprocess.run(" ".join(cmd), cwd=cwd, check=True, shell=True, env=env) subprocess.run(" ".join(cmd), cwd=cwd, check=True, shell=True, env=env)
else: else:
subprocess.run(cmd, cwd=cwd, check=True, env=env) 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: def main() -> int:
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--runtime", default=RUNTIME_DEFAULT) ap.add_argument("--runtime", default=RUNTIME_DEFAULT)
@ -88,7 +147,12 @@ def main() -> int:
if DIST.exists(): if DIST.exists():
import shutil import shutil
shutil.rmtree(DIST) 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" metadata_path = DIST / "metadata.json"
if not metadata_path.exists(): if not metadata_path.exists():