Skip to content

Commit 4fda5a6

Browse files
committed
mobile: auto-rebuild the Dev Tool native client on fingerprint change
Add a fingerprint-gated CI job (build:devtool-native) that rebuilds the Dev Tool dev client only when its Expo fingerprint changes — the ~5% case OTA can't serve. iOS auto-submits to TestFlight; Android builds a sideloadable APK (new devtool-apk profile) that CI self-hosts on the dev-assets bucket, since Google Play has no TestFlight-equivalent for a dev-client APK.
1 parent 81c7c7d commit 4fda5a6

6 files changed

Lines changed: 213 additions & 13 deletions

File tree

app/.gitlab-ci.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ variables:
1414
BUILD_WEB: "true"
1515
BUILD_MOBILE: "true"
1616
PREVIEW_DOMAIN: preview.couchershq.org
17+
# Branch on which a fingerprint change triggers an automatic Dev Tool native
18+
# rebuild (build:devtool-native). Set to mobile/v1.1.20 while validating the
19+
# machinery; switch to develop (i.e. $RELEASE_BRANCH) once it's trusted.
20+
DEVTOOL_BUILD_BRANCH: mobile/v1.1.20
1721
GIT_DEPTH: 1 # Speed up the checkout.
1822
DOCKER_HOST: tcp://docker:2376
1923
DOCKER_TLS_CERTDIR: "/certs"
@@ -939,6 +943,44 @@ preview:pr-comment:
939943
- app/mobile/**/*
940944
- app/scripts/**/*
941945

946+
# Rebuild the Dev Tool *native* client when its fingerprint changes — a native
947+
# dep / app.config.js / Expo SDK change, the ~5% case OTA can't serve (a
948+
# fingerprint mismatch makes the dev client silently ignore branch bundles; see
949+
# docs/mobile-dev-tool-ota.md). scripts/devtool-build.sh builds on EAS:
950+
# iOS -> TestFlight (auto-submit); Android -> a sideloadable APK published to
951+
# s3://$AWS_PREVIEW_BUCKET/devtool-builds/android/ (Google Play has no
952+
# TestFlight-style channel for a dev-client APK). It no-ops when the freshly
953+
# computed fingerprint matches the last-built client, recorded per platform under
954+
# s3://$AWS_PREVIEW_BUCKET/devtool-builds/. Requires the EXPO_TOKEN CI variable.
955+
# Runs only on $DEVTOOL_BUILD_BRANCH while validating; point that at develop once
956+
# trusted.
957+
build:devtool-native:
958+
needs: ["protos"]
959+
stage: build
960+
image: node:22
961+
inherit:
962+
default: false
963+
variables:
964+
# Compute the fingerprint as the devtool variant so it matches the EAS build
965+
# (app.config.js branches on APP_VARIANT for bundle id / scheme / manifest).
966+
APP_VARIANT: devtool
967+
before_script:
968+
- apt-get update -qq && apt-get install -y --no-install-recommends awscli curl
969+
script:
970+
- cd app/mobile
971+
- npm ci
972+
- npm run build:protos
973+
- npm install -g eas-cli
974+
# Separate lines (not a loop) so an iOS failure fails the job instead of being
975+
# masked by a later Android success.
976+
- bash scripts/devtool-build.sh ios
977+
- bash scripts/devtool-build.sh android
978+
rules:
979+
- if: ($BUILD_MOBILE == "true") && ($CI_COMMIT_BRANCH == $DEVTOOL_BUILD_BRANCH)
980+
changes:
981+
- app/proto/**/*
982+
- app/mobile/**/*
983+
942984
preview:protos:
943985
needs: ["protos"]
944986
stage: preview

app/mobile/README.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -207,22 +207,33 @@ Open the **Couchers Dev Tool** app and connect to the Metro server (same network
207207

208208
### Releasing a new Dev Tool build
209209

210+
**Automatic (CI).** Every push to the configured build branch (`DEVTOOL_BUILD_BRANCH` in `app/.gitlab-ci.yml``mobile/v1.1.20` while validating, then `develop`) runs `build:devtool-native`, which recomputes the Expo fingerprint and, **only if it changed since the last-built client**, builds a fresh client on EAS. JS/TS-only changes don't change the fingerprint, so they're skipped — those load over the air (see [`docs/mobile-dev-tool-ota.md`](../../docs/mobile-dev-tool-ota.md)). The last-built fingerprint is recorded per platform under `s3://<dev-assets>/devtool-builds/` and only updated after a successful build, so a failed build is retried next pipeline.
211+
212+
- **iOS** → EAS build + auto-submit to **TestFlight**; invited devs update from the TestFlight app.
213+
- **Android** → EAS builds a sideloadable **APK** (the `devtool-apk` profile), which CI downloads and publishes to the dev-assets bucket at a stable URL: **`https://android--devtool-builds.preview.couchershq.org/`**. Devs bookmark that page and re-download to update. Google Play has no TestFlight-style channel for a dev-client APK (Play distributes AABs through release tracks, not installers), so we host it ourselves.
214+
215+
**Manual** (same EAS builds, run locally):
216+
210217
```bash
211-
npm run release:ios:devtool # iOS → TestFlight
212-
npm run release:android:devtool # Android → Play internal testing
218+
npm run release:ios:devtool # iOS → TestFlight (auto-submit)
219+
npm run release:android:devtool # Android → APK (EAS gives a download link/QR)
213220
```
214221

215-
Once submitted, the build appears in TestFlight after Apple's automated processing (no full App Review for internal testers). Invited devs update from the TestFlight app.
222+
Once submitted, the iOS build appears in TestFlight after Apple's automated processing (no full App Review for internal testers).
216223

217224
### One-time setup (maintainers)
218225

219-
Before the first release, the **Couchers Dev Tool** app records must exist:
226+
Before the first release:
220227

221-
1. Create the app in [App Store Connect](https://appstoreconnect.apple.com) with bundle ID `org.couchers.devtool.ios`, and create the matching app in [Google Play Console](https://play.google.com/console) with package `org.couchers.devtool.android`.
222-
2. Replace `REPLACE_WITH_DEVTOOL_ASC_APP_ID` in `eas.json` (`submit.devtool.ios.ascAppId`) with the new App Store Connect app ID.
228+
1. Create the app in [App Store Connect](https://appstoreconnect.apple.com) with bundle ID `org.couchers.devtool.ios`. (No Google Play Console app is needed — the Android Dev Tool is a self-hosted APK, not a Play release.)
229+
2. Set `submit.devtool.ios.ascAppId` in `eas.json` to the App Store Connect app ID.
223230
3. Invite developers as internal TestFlight testers — no per-device UDID registration is required (unlike EAS internal/ad-hoc distribution).
224231

225-
> **Coming next:** with the Dev Tool installed, we can wire up per-PR JavaScript previews — a CI job publishes each PR's bundle via `eas update` and posts a QR code on the PR. Scanning it in the Dev Tool loads that branch's changes, the mobile analog of our Vercel web previews. (JS-only; the `runtimeVersion: fingerprint` policy ensures a PR that changes native code won't load a mismatched bundle.)
232+
For the **automatic CI rebuild** (`build:devtool-native`), additionally:
233+
234+
4. Add an Expo robot token (build + submit scope) as the masked, protected GitLab CI/CD variable **`EXPO_TOKEN`**. The Android APK is signed with the keystore EAS already holds for the `devtool` builds, so no extra Android credentials are required.
235+
236+
Per-PR JavaScript previews (the OTA QR posted on PRs) are already wired up; see [`docs/mobile-dev-tool-ota.md`](../../docs/mobile-dev-tool-ota.md).
226237

227238
## Updating Dependencies
228239

app/mobile/eas.json

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@
4444
"EXPO_PUBLIC_WEB_BASE_URL": "https://next.couchershq.org",
4545
"EXPO_PUBLIC_COUCHERS_ENV": "preview"
4646
}
47+
},
48+
"devtool-apk": {
49+
"extends": "devtool",
50+
"distribution": "internal",
51+
"android": {
52+
"buildType": "apk"
53+
}
4754
}
4855
},
4956
"submit": {
@@ -69,11 +76,6 @@
6976
"devtool": {
7077
"ios": {
7178
"ascAppId": "6772371797"
72-
},
73-
"android": {
74-
"track": "internal",
75-
"releaseStatus": "completed",
76-
"changesNotSentForReview": true
7779
}
7880
}
7981
}

app/mobile/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"release:ios:devtool": "eas build --platform ios --profile devtool --auto-submit",
1818
"release:ios:staging": "eas build --platform ios --profile staging --auto-submit",
1919
"release:ios:production": "eas build --platform ios --profile production --auto-submit",
20-
"release:android:devtool": "eas build --platform android --profile devtool --auto-submit",
20+
"release:android:devtool": "eas build --platform android --profile devtool-apk",
2121
"release:android:staging": "eas build --platform android --profile staging --auto-submit",
2222
"release:android:production": "eas build --platform android --profile production --auto-submit"
2323
},
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#!/usr/bin/env bash
2+
# Rebuild the Couchers Dev Tool native client on EAS when its fingerprint changes.
3+
#
4+
# The Dev Tool dev client only needs a fresh native build when the Expo
5+
# fingerprint (runtimeVersion) changes — a new native module, an app.config.js
6+
# change, or an Expo SDK bump. Pure JS/TS changes load over the air (see
7+
# docs/mobile-dev-tool-ota.md), so this no-ops on them. We record the last-built
8+
# fingerprint per platform in S3 and compare; on a change we build a fresh client
9+
# and update the marker only once that succeeds, so a failed build is retried.
10+
#
11+
# iOS: eas build --auto-submit -> TestFlight (Apple's tester channel).
12+
# Android: eas build -> a sideloadable APK we host ourselves. Google Play has no
13+
# TestFlight-equivalent for a dev-client APK (Play distributes AABs
14+
# through release tracks, not downloadable installers), so we publish
15+
# the APK to the dev-assets bucket at a stable URL devs bookmark.
16+
#
17+
# Usage: devtool-build.sh <ios|android>
18+
# Env: EXPO_TOKEN EAS auth (build + submit scope)
19+
# AWS_PREVIEW_BUCKET the couchers-dev-assets bucket
20+
# PREVIEW_DOMAIN the dev-assets CDN domain (preview.couchershq.org)
21+
# CI_COMMIT_SHORT_SHA tags the immutable APK filename
22+
# plus the AWS creds the aws CLI reads from the environment
23+
# Run from app/mobile (eas.json, the project, and node_modules must be present).
24+
set -euo pipefail
25+
26+
PLATFORM="${1:?usage: devtool-build.sh <ios|android>}"
27+
case "$PLATFORM" in
28+
ios | android) ;;
29+
*)
30+
echo "platform must be ios or android (got $PLATFORM)" >&2
31+
exit 1
32+
;;
33+
esac
34+
35+
MARKER="s3://${AWS_PREVIEW_BUCKET}/devtool-builds/${PLATFORM}.fingerprint"
36+
37+
# Read a single JSON field from stdin with node (no jq in the node:22 image).
38+
json_field() { node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const j=JSON.parse(s);process.stdout.write(String(eval("j"+process.argv[1])??""))})' "$1"; }
39+
40+
# Fingerprint of the working tree — the same value EAS stamps as runtimeVersion.
41+
# .fingerprintignore keeps this reproducible (google-services.json is an EAS-only
42+
# secret), so the CI-computed hash matches what the EAS build will compute.
43+
CURRENT="$(npx expo-updates fingerprint:generate --platform "$PLATFORM" 2>/dev/null | json_field '.hash')"
44+
echo "current $PLATFORM fingerprint: $CURRENT"
45+
46+
PREVIOUS="$(aws s3 cp "$MARKER" - 2>/dev/null || true)"
47+
echo "last-built $PLATFORM fingerprint: ${PREVIOUS:-<none>}"
48+
49+
if [ -n "$PREVIOUS" ] && [ "$PREVIOUS" = "$CURRENT" ]; then
50+
echo "Fingerprint unchanged for $PLATFORM — installed Dev Tool client still current, skipping build."
51+
exit 0
52+
fi
53+
54+
echo "Fingerprint changed for $PLATFORM — building a new Dev Tool client."
55+
56+
if [ "$PLATFORM" = "ios" ]; then
57+
# Store distribution + auto-submit lands the build in TestFlight; devs update there.
58+
eas build --platform ios --profile devtool --auto-submit --non-interactive
59+
else
60+
# devtool-apk: internal-distribution APK dev client (extends devtool). Not
61+
# submitted anywhere — we download the artifact and host it ourselves.
62+
BUILD_JSON="$(eas build --platform android --profile devtool-apk --non-interactive --json)"
63+
APK_URL="$(printf '%s' "$BUILD_JSON" | json_field '[0].artifacts.applicationArchiveUrl')"
64+
[ -n "$APK_URL" ] || {
65+
echo "could not find the APK artifact URL in the eas build output" >&2
66+
exit 1
67+
}
68+
echo "APK artifact: $APK_URL"
69+
curl -fSL "$APK_URL" -o couchers-devtool.apk
70+
71+
# Immutable per-commit APK (cache forever, no CDN invalidation — matching the OTA
72+
# infra), plus a stable landing page devs bookmark that links to the current one.
73+
PREFIX="devtool-builds/android"
74+
APK_NAME="couchers-devtool-${CI_COMMIT_SHORT_SHA}.apk"
75+
HOST="android--devtool-builds.${PREVIEW_DOMAIN}"
76+
aws s3 cp couchers-devtool.apk "s3://${AWS_PREVIEW_BUCKET}/${PREFIX}/${APK_NAME}" \
77+
--content-type application/vnd.android.package-archive
78+
79+
cat > index.html <<HTML
80+
<!doctype html>
81+
<html lang="en">
82+
<head>
83+
<meta charset="utf-8">
84+
<meta name="viewport" content="width=device-width, initial-scale=1">
85+
<title>Couchers Dev Tool — Android</title>
86+
</head>
87+
<body style="font-family: sans-serif; max-width: 32rem; margin: 3rem auto; padding: 0 1rem">
88+
<h1>Couchers Dev Tool (Android)</h1>
89+
<p><a href="./${APK_NAME}" style="font-size: 1.25rem">Download the latest APK</a></p>
90+
<p>Built from commit <code>${CI_COMMIT_SHORT_SHA}</code>. Enable "install from unknown
91+
sources" to sideload. Re-open this page to grab newer builds.</p>
92+
<p>iOS testers: the Dev Tool ships through TestFlight, not here.</p>
93+
</body>
94+
</html>
95+
HTML
96+
# Stable pointer — overwritten each build, so revalidate rather than cache.
97+
aws s3 cp index.html "s3://${AWS_PREVIEW_BUCKET}/${PREFIX}/index.html" \
98+
--content-type "text/html; charset=utf-8" --cache-control "no-cache"
99+
echo "APK published: https://${HOST}/${APK_NAME}"
100+
echo "Stable page: https://${HOST}/index.html"
101+
fi
102+
103+
# Record the new fingerprint only after a successful build (and publish), so a
104+
# failed build is retried on the next pipeline instead of being marked as done.
105+
printf '%s' "$CURRENT" | aws s3 cp - "$MARKER"
106+
echo "Recorded $PLATFORM fingerprint $CURRENT — Dev Tool client is up to date."

docs/mobile-dev-tool-ota.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,45 @@ RN-shell bundle, and the WebView inside uses whatever web/API override is persis
392392

393393
---
394394

395+
## 11. Native rebuilds — the 5% fingerprint-change case
396+
397+
OTA covers everything that *doesn't* change the fingerprint. The complement —
398+
native deps, `app.config.js`, an Expo SDK bump — changes the fingerprint, and the
399+
dev client then **silently ignores** every branch bundle (§4 safety net). Those
400+
need a fresh native Dev Tool client. That rebuild is automated, gated on the
401+
fingerprint actually changing so it doesn't fire on JS-only commits:
402+
403+
- **`build:devtool-native`** (`app/.gitlab-ci.yml`, `node:22`) runs on
404+
`$DEVTOOL_BUILD_BRANCH` (`mobile/v1.1.20` while validating; point at `develop`
405+
once trusted). For each platform it calls **`scripts/devtool-build.sh`**, which:
406+
1. computes the fingerprint with `npx expo-updates fingerprint:generate`
407+
(`APP_VARIANT=devtool`, so it matches the EAS build — same `.fingerprintignore`
408+
as §8);
409+
2. compares it to the last-built fingerprint stored at
410+
`s3://$AWS_PREVIEW_BUCKET/devtool-builds/<platform>.fingerprint`;
411+
3. on a change, builds on EAS (GitLab only triggers it; no macOS/Android runners):
412+
- **iOS**`eas build --profile devtool --auto-submit`**TestFlight**.
413+
- **Android**`eas build --profile devtool-apk` → a sideloadable **APK**,
414+
which CI downloads and publishes to `s3://$AWS_PREVIEW_BUCKET/devtool-builds/android/`
415+
(an immutable `couchers-devtool-<sha>.apk` plus a stable `index.html` devs
416+
bookmark at `https://android--devtool-builds.$PREVIEW_DOMAIN/`). Google Play
417+
has **no TestFlight-equivalent** for a dev-client APK — Play distributes AABs
418+
through release tracks, not installers — so we host the APK ourselves.
419+
4. writes the new fingerprint to the marker **only after** the build (and, for
420+
Android, the publish) succeeds, so a failed build retries on the next pipeline
421+
rather than being marked done.
422+
- The build waits to completion (these are rare) so the marker is only advanced on
423+
success. Per-platform markers because iOS and Android fingerprints differ.
424+
- **Prerequisites:** the `EXPO_TOKEN` CI variable (build + submit scope). No Play
425+
credentials are needed — Android is self-hosted. See `app/mobile/README.md`
426+
*Releasing a new Dev Tool build*.
427+
428+
This is the missing half of the loop: OTA serves JS branches against an installed
429+
client; this keeps that installed client's native layer current so OTA stays
430+
loadable.
431+
432+
---
433+
395434
## References
396435

397436
- Expo Updates protocol spec: https://docs.expo.dev/technical-specs/expo-updates-1/

0 commit comments

Comments
 (0)