Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions app/.gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ variables:
BUILD_WEB: "true"
BUILD_MOBILE: "true"
PREVIEW_DOMAIN: preview.couchershq.org
# Branch on which a fingerprint change triggers an automatic Dev Tool native
# rebuild (build:devtool-native). Set to mobile/v1.1.20 while validating the
# machinery; switch to develop (i.e. $RELEASE_BRANCH) once it's trusted.
DEVTOOL_BUILD_BRANCH: mobile/v1.1.20
GIT_DEPTH: 1 # Speed up the checkout.
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_CERTDIR: "/certs"
Expand Down Expand Up @@ -939,6 +943,44 @@ preview:pr-comment:
- app/mobile/**/*
- app/scripts/**/*

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

preview:protos:
needs: ["protos"]
stage: preview
Expand Down
25 changes: 18 additions & 7 deletions app/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,22 +207,33 @@ Open the **Couchers Dev Tool** app and connect to the Metro server (same network

### Releasing a new Dev Tool build

**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.

- **iOS** → EAS build + auto-submit to **TestFlight**; invited devs update from the TestFlight app.
- **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.

**Manual** (same EAS builds, run locally):

```bash
npm run release:ios:devtool # iOS → TestFlight
npm run release:android:devtool # Android → Play internal testing
npm run release:ios:devtool # iOS → TestFlight (auto-submit)
npm run release:android:devtool # Android → APK (EAS gives a download link/QR)
```

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.
Once submitted, the iOS build appears in TestFlight after Apple's automated processing (no full App Review for internal testers).

### One-time setup (maintainers)

Before the first release, the **Couchers Dev Tool** app records must exist:
Before the first release:

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`.
2. Replace `REPLACE_WITH_DEVTOOL_ASC_APP_ID` in `eas.json` (`submit.devtool.ios.ascAppId`) with the new App Store Connect app ID.
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.)
2. Set `submit.devtool.ios.ascAppId` in `eas.json` to the App Store Connect app ID.
3. Invite developers as internal TestFlight testers — no per-device UDID registration is required (unlike EAS internal/ad-hoc distribution).

> **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.)
For the **automatic CI rebuild** (`build:devtool-native`), additionally:

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.

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).

## Updating Dependencies

Expand Down
12 changes: 7 additions & 5 deletions app/mobile/eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@
"EXPO_PUBLIC_WEB_BASE_URL": "https://next.couchershq.org",
"EXPO_PUBLIC_COUCHERS_ENV": "preview"
}
},
"devtool-apk": {
"extends": "devtool",
"distribution": "internal",
"android": {
"buildType": "apk"
}
}
},
"submit": {
Expand All @@ -69,11 +76,6 @@
"devtool": {
"ios": {
"ascAppId": "6772371797"
},
"android": {
"track": "internal",
"releaseStatus": "completed",
"changesNotSentForReview": true
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion app/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"release:ios:devtool": "eas build --platform ios --profile devtool --auto-submit",
"release:ios:staging": "eas build --platform ios --profile staging --auto-submit",
"release:ios:production": "eas build --platform ios --profile production --auto-submit",
"release:android:devtool": "eas build --platform android --profile devtool --auto-submit",
"release:android:devtool": "eas build --platform android --profile devtool-apk",
"release:android:staging": "eas build --platform android --profile staging --auto-submit",
"release:android:production": "eas build --platform android --profile production --auto-submit"
},
Expand Down
106 changes: 106 additions & 0 deletions app/mobile/scripts/devtool-build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Rebuild the Couchers Dev Tool native client on EAS when its fingerprint changes.
#
# The Dev Tool dev client only needs a fresh native build when the Expo
# fingerprint (runtimeVersion) changes — a new native module, an app.config.js
# change, or an Expo SDK bump. Pure JS/TS changes load over the air (see
# docs/mobile-dev-tool-ota.md), so this no-ops on them. We record the last-built
# fingerprint per platform in S3 and compare; on a change we build a fresh client
# and update the marker only once that succeeds, so a failed build is retried.
#
# iOS: eas build --auto-submit -> TestFlight (Apple's tester channel).
# Android: eas build -> a sideloadable APK we host ourselves. Google Play has no
# TestFlight-equivalent for a dev-client APK (Play distributes AABs
# through release tracks, not downloadable installers), so we publish
# the APK to the dev-assets bucket at a stable URL devs bookmark.
#
# Usage: devtool-build.sh <ios|android>
# Env: EXPO_TOKEN EAS auth (build + submit scope)
# AWS_PREVIEW_BUCKET the couchers-dev-assets bucket
# PREVIEW_DOMAIN the dev-assets CDN domain (preview.couchershq.org)
# CI_COMMIT_SHORT_SHA tags the immutable APK filename
# plus the AWS creds the aws CLI reads from the environment
# Run from app/mobile (eas.json, the project, and node_modules must be present).
set -euo pipefail

PLATFORM="${1:?usage: devtool-build.sh <ios|android>}"
case "$PLATFORM" in
ios | android) ;;
*)
echo "platform must be ios or android (got $PLATFORM)" >&2
exit 1
;;
esac

MARKER="s3://${AWS_PREVIEW_BUCKET}/devtool-builds/${PLATFORM}.fingerprint"

# Read a single JSON field from stdin with node (no jq in the node:22 image).
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"; }

# Fingerprint of the working tree — the same value EAS stamps as runtimeVersion.
# .fingerprintignore keeps this reproducible (google-services.json is an EAS-only
# secret), so the CI-computed hash matches what the EAS build will compute.
CURRENT="$(npx expo-updates fingerprint:generate --platform "$PLATFORM" 2>/dev/null | json_field '.hash')"
echo "current $PLATFORM fingerprint: $CURRENT"

PREVIOUS="$(aws s3 cp "$MARKER" - 2>/dev/null || true)"
echo "last-built $PLATFORM fingerprint: ${PREVIOUS:-<none>}"

if [ -n "$PREVIOUS" ] && [ "$PREVIOUS" = "$CURRENT" ]; then
echo "Fingerprint unchanged for $PLATFORM — installed Dev Tool client still current, skipping build."
exit 0
fi

echo "Fingerprint changed for $PLATFORM — building a new Dev Tool client."

if [ "$PLATFORM" = "ios" ]; then
# Store distribution + auto-submit lands the build in TestFlight; devs update there.
eas build --platform ios --profile devtool --auto-submit --non-interactive
else
# devtool-apk: internal-distribution APK dev client (extends devtool). Not
# submitted anywhere — we download the artifact and host it ourselves.
BUILD_JSON="$(eas build --platform android --profile devtool-apk --non-interactive --json)"
APK_URL="$(printf '%s' "$BUILD_JSON" | json_field '[0].artifacts.applicationArchiveUrl')"
[ -n "$APK_URL" ] || {
echo "could not find the APK artifact URL in the eas build output" >&2
exit 1
}
echo "APK artifact: $APK_URL"
curl -fSL "$APK_URL" -o couchers-devtool.apk

# Immutable per-commit APK (cache forever, no CDN invalidation — matching the OTA
# infra), plus a stable landing page devs bookmark that links to the current one.
PREFIX="devtool-builds/android"
APK_NAME="couchers-devtool-${CI_COMMIT_SHORT_SHA}.apk"
HOST="android--devtool-builds.${PREVIEW_DOMAIN}"
aws s3 cp couchers-devtool.apk "s3://${AWS_PREVIEW_BUCKET}/${PREFIX}/${APK_NAME}" \
--content-type application/vnd.android.package-archive

cat > index.html <<HTML
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Couchers Dev Tool — Android</title>
</head>
<body style="font-family: sans-serif; max-width: 32rem; margin: 3rem auto; padding: 0 1rem">
<h1>Couchers Dev Tool (Android)</h1>
<p><a href="./${APK_NAME}" style="font-size: 1.25rem">Download the latest APK</a></p>
<p>Built from commit <code>${CI_COMMIT_SHORT_SHA}</code>. Enable "install from unknown
sources" to sideload. Re-open this page to grab newer builds.</p>
<p>iOS testers: the Dev Tool ships through TestFlight, not here.</p>
</body>
</html>
HTML
# Stable pointer — overwritten each build, so revalidate rather than cache.
aws s3 cp index.html "s3://${AWS_PREVIEW_BUCKET}/${PREFIX}/index.html" \
--content-type "text/html; charset=utf-8" --cache-control "no-cache"
echo "APK published: https://${HOST}/${APK_NAME}"
echo "Stable page: https://${HOST}/index.html"
fi

# Record the new fingerprint only after a successful build (and publish), so a
# failed build is retried on the next pipeline instead of being marked as done.
printf '%s' "$CURRENT" | aws s3 cp - "$MARKER"
echo "Recorded $PLATFORM fingerprint $CURRENT — Dev Tool client is up to date."
39 changes: 39 additions & 0 deletions docs/mobile-dev-tool-ota.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,45 @@ RN-shell bundle, and the WebView inside uses whatever web/API override is persis

---

## 11. Native rebuilds — the 5% fingerprint-change case

OTA covers everything that *doesn't* change the fingerprint. The complement —
native deps, `app.config.js`, an Expo SDK bump — changes the fingerprint, and the
dev client then **silently ignores** every branch bundle (§4 safety net). Those
need a fresh native Dev Tool client. That rebuild is automated, gated on the
fingerprint actually changing so it doesn't fire on JS-only commits:

- **`build:devtool-native`** (`app/.gitlab-ci.yml`, `node:22`) runs on
`$DEVTOOL_BUILD_BRANCH` (`mobile/v1.1.20` while validating; point at `develop`
once trusted). For each platform it calls **`scripts/devtool-build.sh`**, which:
1. computes the fingerprint with `npx expo-updates fingerprint:generate`
(`APP_VARIANT=devtool`, so it matches the EAS build — same `.fingerprintignore`
as §8);
2. compares it to the last-built fingerprint stored at
`s3://$AWS_PREVIEW_BUCKET/devtool-builds/<platform>.fingerprint`;
3. on a change, builds on EAS (GitLab only triggers it; no macOS/Android runners):
- **iOS** → `eas build --profile devtool --auto-submit` → **TestFlight**.
- **Android** → `eas build --profile devtool-apk` → a sideloadable **APK**,
which CI downloads and publishes to `s3://$AWS_PREVIEW_BUCKET/devtool-builds/android/`
(an immutable `couchers-devtool-<sha>.apk` plus a stable `index.html` devs
bookmark at `https://android--devtool-builds.$PREVIEW_DOMAIN/`). Google Play
has **no TestFlight-equivalent** for a dev-client APK — Play distributes AABs
through release tracks, not installers — so we host the APK ourselves.
4. writes the new fingerprint to the marker **only after** the build (and, for
Android, the publish) succeeds, so a failed build retries on the next pipeline
rather than being marked done.
- The build waits to completion (these are rare) so the marker is only advanced on
success. Per-platform markers because iOS and Android fingerprints differ.
- **Prerequisites:** the `EXPO_TOKEN` CI variable (build + submit scope). No Play
credentials are needed — Android is self-hosted. See `app/mobile/README.md` →
*Releasing a new Dev Tool build*.

This is the missing half of the loop: OTA serves JS branches against an installed
client; this keeps that installed client's native layer current so OTA stays
loadable.

---

## References

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