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>
183 lines
7.3 KiB
Python
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())
|