Mobile: per-PR preview channels with in-app switcher and PR-body QRs
Status summary: done, verified end-to-end (this PR body carries its own live QR section, produced by running the CI script locally against PR #2412). Kills last-write-wins on the shared preview channel: PRs publish to their own channel, the app can switch channels via deep link, and every mobile PR gets tappable QRs in its body.
Today every publisher (merge-to-main CI, PR-branch agents) writes to the one preview channel, so whatever published last is what's on Misha's phone — his #2410 preview got silently replaced by an unrelated main merge within minutes. Decided 2026-08-04: per-PR channels, in-app switcher, QRs on PR bodies.
Design#
previewchannel becomes main-only. PRs publish to a channel named after their branch (sanitized: non[a-z0-9._-]chars →-).- Channel switching uses
Updates.setUpdateRequestHeadersOverride({"expo-channel-name": ...})— the headers-only override, which does not needdisableAntiBrickingMeasures(override keys only have to exist in the embedded headers, and EAS builds embedexpo-channel-name). Persisted natively in UserDefaults across restarts. - Deep link
iterate://preview-channel/<channel>opens a confirm screen (no auto-switch on link open) that applies the override, fetches, reloads. - A managed PR-body section (markdownAnnotator, like loc-report) shows both QRs in
<details>blocks for any PR touchingapps/mobile/**— OTA deep-link QR and full-install QR — with the heuristic picking which is expanded: PR runtime matches the latest finished preview build → OTA open; otherwise install open (OTA still present: Misha may have installed this branch's native build earlier). QR PNGs upload to the existinggh-attach-assetsrelease; images render inline from release URLs, wrapped in<a>so they're tappable when reading the PR on the phone. - Native-change case: find a usable build for the PR's runtime, trigger one (
--no-wait) if none.
Checklist#
-
apps/mobile/src/lib/preview-channel.ts: set/clear channel override + AsyncStorage mirror (expo-updates has no getter) -
apps/mobile/src/app/preview-channel/[channel].tsx: deep-link confirm screen (switch → check → fetch → reload; friendly "nothing published/incompatible" outcome) - Build info screen: show active channel override + "reset to default channel" action
-
write-build-info.mjs: preferGITHUB_HEAD_REF(PR head branch) overGITHUB_REF_NAMEfor the branch stamp -
scripts/ci/publish-mobile-pr-preview.ts: publish PR channel, decide js-only vs native, ensure build exists for native, generate + upload QRs, write PR-body section -
.depot/workflows/mobile-pr-preview.yml: pull_request onapps/mobile/**, per-PR concurrency (cancel-in-progress), contents:write + pull-requests:write - unit tests for the decision + rendering logic (pure functions, DI'd exec — no mocks)
- README blurb in
apps/mobile/README.md
Notes#
- Stacked on
mobile-fingerprint-sourceskips(PR #2411) — without sourceSkips, script-only package.json edits would misclassify PRs as native. - Follow-up (not here): clean up EAS channels/branches when PRs close; an in-app list of open PR channels (needs an authed EAS API proxy).
Implementation log#
- e2e run against PR #2412 caught
--statuscasing: the eas-cli flag wants lowercase (finished) while its JSON output reports uppercase (FINISHED). - The runtime-mismatch path exercised for real: this branch changes the fingerprint (sourceSkips config from #2411), so the run triggered native build 9623bb11 and expanded the install QR — exactly the heuristic working.
- The PR-triggered build shares the post-merge fingerprint, so the merge-to-main workflow will find it and skip its own build.