Skip to content

Commit 139d562

Browse files
aapelivclaude
andcommitted
docs: bring Dev Tool OTA doc up to date with the implemented system
Reflects the implemented GitLab CI jobs, the deployed origin-response header injection, the QR + open.html PR comment, Android support, and the google-services.json fingerprint exclusion. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4591ff5 commit 139d562

1 file changed

Lines changed: 99 additions & 56 deletions

File tree

docs/mobile-dev-tool-ota.md

Lines changed: 99 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ How we let anyone open a specific mobile branch on a phone by scanning a QR in
44
the PR, **without** an App Store / TestFlight build per branch, and without
55
paying 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

1012
This supersedes the earlier exploration in `tools/lambdas/expo-ota.md` (which
1113
weighed 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

180182
A 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

Comments
 (0)