@@ -4,8 +4,10 @@ How we let anyone open a specific mobile branch on a phone by scanning a QR in
44the PR, ** without** an App Store / TestFlight build per branch, and without
55paying for EAS Update.
66
7- ** Status:** design decided, implementation in progress. Scope is the ** Dev Tool
8- build only** — never production.
7+ ** Status:** implemented. iOS validated end-to-end on a real device against the
8+ real S3 + CloudFront (2026-05-24); the Android publish pipeline is live, with
9+ on-device Android validation still pending. Scope is the ** Dev Tool build only** —
10+ never production.
911
1012This supersedes the earlier exploration in ` tools/lambdas/expo-ota.md ` (which
1113weighed a hand-rolled edge function vs. the ` expo-open-ota ` server). We evaluated
@@ -179,9 +181,9 @@ invalidation is ever needed**. (This is the big simplification over a mutable
179181
180182A manifest is per-platform (one ` launchAsset ` ). Two options:
181183
182- - ** No Lambda change (start here ):** publish both platforms and use
184+ - ** No Lambda change (implemented ):** publish both platforms and use
183185 platform-specific URLs — ` /ios/manifest ` and ` /android/manifest ` . The PR
184- comment renders an iOS QR and an Android QR.
186+ comment renders an iOS section and an Android section, each with its own QR.
185187- ** One-URL nicety (optional):** extend the viewer-request Lambda so that when
186188 ` db2 === "ota" ` it reads the ` expo-platform ` request header and rewrites to
187189 ` /ota/<sha>/<platform>/manifest ` . Then a single QR works on both. Small, isolated
@@ -220,14 +222,14 @@ multipart body with only the content-type (no `expo-protocol-version` /
220222` expo-sfv-version ` ) makes the client reject the manifest before fetching anything
221223("failed to load app from …"); it does not infer the protocol from the content-type.
222224
223- So the manifest response needs the header injected at the edge. ** Extend the
224- existing ` preview-origin-response.js ` Lambda@Edge** to add ` expo-protocol-version: 1 `
225- (and ` expo-sfv-version: 0 ` ) on responses whose key is the OTA manifest
226- (` /ota/<sha>/<platform>/manifest ` ) — scoped by path, mirroring its current ` /web/ `
227- branch, so web previews are unaffected. This reuses infra we already deploy; no new
228- function. (A CloudFront response-headers policy also works but attaches per cache
229- behavior, so it can't scope by path/host as cleanly given the host→prefix routing
230- lives in the viewer-request Lambda.)
225+ So the manifest response needs the header injected at the edge. The existing
226+ ` tools/lambdas/ preview-origin-response.js` Lambda@Edge now adds
227+ ` expo-protocol-version: 1 ` (and ` expo-sfv-version: 0 ` ) on responses whose key is
228+ the OTA manifest (` /ota/<sha>/<platform>/manifest ` ) — scoped by path, mirroring its
229+ ` /web/ ` branch, so web previews are unaffected. This reuses infra we already
230+ deploy; no new function. (A CloudFront response-headers policy also works but
231+ attaches per cache behavior, so it can't scope by path/host as cleanly given the
232+ host→prefix routing lives in the viewer-request Lambda.)
231233
232234---
233235
@@ -251,31 +253,43 @@ All asset/bundle URLs baked into the manifest point back at
251253
252254---
253255
254- ## 7. CI publish job
255-
256- A job (GitLab CI, gated on ` app/mobile/** ` changes; or a GitHub Action under
257- ` tools/.github/workflows/ ` reusing the existing Lambda-deploy AWS creds pattern)
258- that, per mobile PR:
259-
260- 1 . For each platform: ` APP_VARIANT=devtool npx expo export --platform <p> ` →
261- ` dist/ ` (bundle at ` dist/_expo/static/js/<p>/entry-<hash>.hbc ` , assets at
262- ` dist/assets/<metro-hash> ` , and ` dist/metadata.json ` which lists the bundle +
263- each asset's ` {path, ext} ` ). NOTE: SDK 54's export does ** not** emit a
264- ` dist/expoConfig.json ` ; get the public config from
265- ` APP_VARIANT=devtool npx expo config --type public --json ` for ` extra.expoClient ` .
266- 2 . Compute the fingerprint ` runtimeVersion ` (` npx @expo/fingerprint ` /
267- ` npx expo-updates fingerprint:generate --platform <p> ` — verify the exact
268- invocation for our toolchain). It must equal the installed Dev Tool build's
269- fingerprint.
270- 3 . Generate the manifest from ` metadata.json ` + ` expo config --type public --json ` :
271- mint a UUID ` id ` , set ` runtimeVersion ` , compute each asset's base64url SHA-256
272- (this is its manifest ` key ` ), and rewrite every path to its
273- ` <sha>--ota.preview.couchershq.org ` URL. Wrap as ` multipart/mixed ` (Phase 2
274- confirms whether the dev launcher requires this or accepts plain JSON).
275- 4 . ` aws s3 cp ` the bundle, assets, and manifest under ` ota/<sha>/<platform>/ ` ,
276- with correct ` --content-type ` on each.
277- 5 . No CloudFront invalidation needed (immutable keys).
278- 6 . Post / update the PR comment with the QR(s) — see §9.
256+ ## 7. CI publish jobs
257+
258+ Implemented as three GitLab CI jobs in ` app/.gitlab-ci.yml ` , gated on
259+ ` app/proto/** ` , ` app/mobile/** ` , ` app/scripts/** ` changes (and always on the
260+ ` develop ` release branch). The GitHub Action option was dropped in favour of
261+ keeping everything in the existing GitLab pipeline, so the comment job can ` needs: `
262+ the upload job and only post once the links are live.
263+
264+ ** ` build:mobile-ota ` ** (` node:22 ` ) — for each platform in ` OTA_PLATFORMS `
265+ (` ios android ` ):
266+
267+ 1 . ` APP_VARIANT=devtool npx expo export --platform <p> ` → ` dist/ ` (bundle, assets,
268+ and ` dist/metadata.json ` listing the bundle + each asset's ` {path, ext} ` ). SDK
269+ 54's export does ** not** emit ` dist/expoConfig.json ` , so the public config comes
270+ from ` npx expo config --type public --json ` (` ota-expo-config.json ` ) for
271+ ` extra.expoClient ` .
272+ 2 . ` npx expo-updates fingerprint:generate --platform <p> ` → ` .hash ` for
273+ ` runtimeVersion ` . Must equal the installed Dev Tool build's fingerprint — see §8
274+ on ` .fingerprintignore ` .
275+ 3 . ` node scripts/ota-stage.mjs ` builds the manifest from ` metadata.json ` +
276+ ` ota-expo-config.json ` : content-addressed asset keys (base64url SHA-256),
277+ rewrites every URL to ` <sha>--ota.preview.couchershq.org/<platform>/… ` , and
278+ writes both the ` manifest.json ` object and the protocol-v1 ` multipart/mixed `
279+ ` manifest ` body (+ its ` manifest.content-type ` ). It also writes ` open.html ` , an
280+ https redirect to the dev-launcher deep link (see §9).
281+ 4 . ` npx --yes qrcode ` renders ` qr.png ` encoding the deep link. Using ` npx ` (not a
282+ ` package.json ` dep) keeps ` qrcode ` out of the fingerprint sources.
283+
284+ ** ` preview:mobile-ota ` ** (` aws-base ` ) — ` aws s3 cp ` the ` manifest ` (with its
285+ multipart content-type), ` bundle.hbc ` , ` assets/ ` , ` qr.png ` , and ` open.html ` to
286+ ` s3://couchers-dev-assets/ota/<sha>/<platform>/ ` . No CloudFront invalidation
287+ (immutable keys).
288+
289+ ** ` preview:pr-comment ` ** (` python:3.12-slim ` ) — ` needs: ` the upload job so every
290+ link is already live, then runs ` app/scripts/pr_preview_comment.py ` to post/update
291+ the sticky PR comment (§9). No-ops if ` GITHUB_PREVIEW_TOKEN ` is unset or there's no
292+ open PR, so it never reds the pipeline.
279293
280294---
281295
@@ -285,6 +299,18 @@ In `app/mobile/app.config.js`, **devtool variant only**:
285299
286300- ** Keep** ` runtimeVersion: { policy: "fingerprint" } ` and ` updates.enabled: true `
287301 (the launcher uses the fingerprint to gate updates).
302+ - ** Add ` app/mobile/.fingerprintignore ` ** excluding ` google-services.json ` and
303+ ` **/eas-environment-secrets/** ` . The devtool build receives ` google-services.json `
304+ only as an EAS file env secret (` GOOGLE_SERVICES_JSON ` , preview environment), so
305+ it's present on EAS builds but absent locally and in CI. Expo hashes it into the
306+ fingerprint by ** contents** (` @expo/fingerprint ` tags it
307+ ` expoConfigExternalFile:contentsOnly ` ), which made the runtime version diverge
308+ between the EAS-built client and our local/CI computation — the client would then
309+ ignore every OTA. The ignore drops it everywhere (ignored sources get a null hash
310+ and are skipped entirely), so the fingerprint is reproducible without the secret.
311+ The native build still uses the file normally; a Firebase/FCM config change needs
312+ a fresh native build regardless. The secret is write-only on EAS, so it can't be
313+ pulled to reproduce locally — exclusion is the only no-secret-distribution fix.
288314- ** Remove** ` codeSigningCertificate ` + ` codeSigningMetadata ` (no signing — §3).
289315 Correspondingly revert the ` .gitignore ` exception and the committed
290316 ` certs/certificate.pem ` ; they were for the server approach.
@@ -304,23 +330,31 @@ Production and staging stay on EAS Update (`u.expo.dev`) untouched.
304330
305331## 9. PR comment + QR
306332
307- The phone camera opens a custom-scheme deep link; the existing
308- ` app/mobile/dev-url-qr.html ` already proves this pattern for the web/API axis.
309- Per PR, the comment renders QR(s) by change type:
333+ ` preview:pr-comment ` posts one ** sticky** comment per PR (marked with the HTML
334+ comment ` <!-- couchers-preview-bot --> ` ; ` app/scripts/pr_preview_comment.py `
335+ resolves the PR from the commit SHA via the GitHub API, then upserts the comment).
336+ It's assembled from ** sections** , so more previews (web, coverage, …) can be
337+ appended as the pipeline grows.
310338
311- - ** Web-only PR** → set the WebView target, no OTA:
312- ` couchers-devtool://dev-settings?api=<dev-api>&web=<vercel preview url> `
313- (handled by ` app/mobile/app/dev-settings.tsx ` → ` config/urls.ts ` override,
314- persisted in AsyncStorage).
315- - ** RN-shell PR** → load the branch bundle:
316- ` couchers-devtool://expo-development-client/?url=<encoded manifest url> `
317- (` https://<sha>--ota.preview.couchershq.org/<platform>/manifest ` ), one QR per
318- platform unless the one-URL Lambda nicety (§5) is in place.
339+ ** Implemented today — the RN-shell OTA section** , one block per platform with:
319340
320- The two axes compose: scanning the RN-shell QR loads the branch's native shell;
321- the WebView inside it uses whatever web/API override is persisted (or the
322- devtool's configured ` EXPO_PUBLIC_WEB_BASE_URL ` ). To pin both, set the
323- dev-settings link first, then load the bundle.
341+ - a ** QR** (` qr.png ` ) encoding the dev-launcher deep link
342+ ` couchers-devtool://expo-development-client/?url=<encoded manifest url> `
343+ (` https://<sha>--ota.preview.couchershq.org/<platform>/manifest ` ) — scan with the
344+ phone camera to open the branch directly in the installed Dev Tool;
345+ - a clickable ** Open in Dev Tool** link. GitHub's comment sanitiser strips
346+ custom-scheme (` couchers-devtool:// ` ) hrefs, so the link targets the hosted
347+ ` open.html ` (https), which ` location.replace ` s to the deep link on the device;
348+ - the raw deep link in a ` <details> ` block for copy/paste.
349+
350+ ** Designed, not yet wired in — the web/API axis.** The Dev Tool also supports
351+ repointing its WebView via
352+ ` couchers-devtool://dev-settings?api=<dev-api>&web=<vercel preview url> `
353+ (` app/mobile/app/dev-settings.tsx ` → ` config/urls.ts ` , persisted in AsyncStorage;
354+ the ` app/mobile/dev-url-qr.html ` precedent proves the pattern). It's orthogonal to
355+ the OTA shell and can be added as another section. The two axes compose: load the
356+ RN-shell bundle, and the WebView inside uses whatever web/API override is persisted
357+ (or the devtool's configured ` EXPO_PUBLIC_WEB_BASE_URL ` ).
324358
325359---
326360
@@ -329,7 +363,10 @@ dev-settings link first, then load the bundle.
329363- ** Fingerprint match is the #1 failure cause.** The exported ` runtimeVersion `
330364 must equal the installed Dev Tool build's, or the client silently ignores the
331365 update. JS-only branches built from the same native config match
332- deterministically; native changes don't (by design — the safety net).
366+ deterministically; native changes don't (by design — the safety net). One real
367+ trap hit in practice: config files supplied only as EAS file env secrets (e.g.
368+ ` google-services.json ` ) are hashed into the fingerprint on EAS but absent
369+ locally/in CI — handled by ` .fingerprintignore ` (§8).
333370- ** CloudFront must forward the ` expo-* ` headers** to origin/function as needed,
334371 and not cache the manifest keyed in a way that ignores platform. Per-SHA keys
335372 are immutable, so caching is otherwise free.
@@ -342,9 +379,11 @@ dev-settings link first, then load the bundle.
342379 ` npx expo-updates fingerprint:generate --platform <p> ` → ` .hash ` ; the device's
343380 ` expo-runtime-version ` matched the value computed from the tree (resolved).
344381 The ` expo-protocol-version: 1 ` * response header* is ** required** (§5 — confirmed:
345- without it the client rejects the manifest before fetching). Remaining: the same
346- end-to-end run against the real S3 bucket + CloudFront with the header injected by
347- an extended ` preview-origin-response.js ` .
382+ without it the client rejects the manifest before fetching). The full iOS
383+ end-to-end run against the real S3 bucket + CloudFront, with the header injected
384+ by the deployed ` preview-origin-response.js ` , also succeeded (2026-05-24).
385+ ** Remaining:** the same on-device validation for ** Android** — the publish
386+ pipeline is live, but no Android Dev Tool client has loaded a branch OTA yet.
348387- ** Going to production (out of scope now):** would add code signing (private key
349388 signs manifests, build embeds the public cert), staged rollouts, and instant
350389 rollback (protocol v1 directives). At that point reconsider ` expo-open-ota `
@@ -363,5 +402,9 @@ dev-settings link first, then load the bundle.
363402- App config / channels: ` app/mobile/app.config.js ` , ` app/mobile/eas.json `
364403- In-app web/API override: ` app/mobile/config/urls.ts ` , ` app/mobile/app/dev-settings.tsx `
365404- QR pattern precedent: ` app/mobile/dev-url-qr.html `
405+ - CI publish jobs: ` app/.gitlab-ci.yml ` (` build:mobile-ota ` , ` preview:mobile-ota ` , ` preview:pr-comment ` )
406+ - Manifest/QR/open.html staging: ` app/mobile/scripts/ota-stage.mjs `
407+ - PR comment generator: ` app/scripts/pr_preview_comment.py `
408+ - Fingerprint exclusion: ` app/mobile/.fingerprintignore `
366409- Dev-launcher load path (source): ` app/mobile/node_modules/expo-dev-launcher/ios/EXDevLauncherController.m ` , ` .../EXDevLauncherUpdatesHelper.m ` , ` .../Manifest/EXDevLauncherManifestParser.m ` , ` .../android/.../helpers/DevLauncherUpdatesHelper.kt `
367410- Dev-client API disablement (source): ` app/mobile/node_modules/expo-updates/.../UpdatesDevLauncherController.kt `
0 commit comments