goon/scripts/publish_update.py
jtrzupek 5896b58688 fix(ota): make publish_update.py work one-shot on Windows git-bash
Publishing the OTA from Windows git-bash failed at the scp step (2026-06-02):
- git-bash (MSYS) rewrote the /root/... env path to 'C:/Program Files/Git/root/...'
  before Python saw it → upload targeted a bogus remote dir.
- scp local source 'C:\...\dist' is parsed as host 'C' (drive letter = host).

Fixes: default runtime 1.0→1.1 (active channel, app.json runtimeVersion=1.1); scp
source passed as '.' with cwd=DIST (no drive letter); MSYS_NO_PATHCONV=1 in subprocess
env; defensive un-mangle of a git-bash-converted VPS_BASE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:56:34 +02:00

183 lines
7.3 KiB
Python

"""Publish a new Expo Updates JS bundle do VPS-a (silent OTA update).
Workflow:
1. `npx expo export --platform android` -> produkuje `mobile/dist/`
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
from datetime import UTC, datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MOBILE = ROOT / "mobile"
DIST = MOBILE / "dist"
RUNTIME_DEFAULT = "1.1" # == EXPO_RUNTIME_VERSION w APK (bump 1.0→1.1 2026-06; aktywny kanał + app.json runtimeVersion=1.1).
# 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) -> 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"}
if shell:
subprocess.run(" ".join(cmd), cwd=cwd, check=True, shell=True, env=env)
else:
subprocess.run(cmd, cwd=cwd, check=True, env=env)
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)
run(["npx", "expo", "export", "--platform", "android"], cwd=MOBILE)
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())