Skip to content

Commit 6cefa63

Browse files
committed
ci(test-app): key the fixture build cache on the Expo fingerprint
The fixture cache key was a hand-maintained `hashFiles` list, and it already had a hole: `accessory-setup.config.json` was absent from it, but app.config.js reads that file into `NSAccessorySetupBluetoothServices`. Editing the service UUID changed the Info.plist without changing the key, so the cache would serve an app built from the old value. The Expo fingerprint enumerates the native inputs instead of us restating them, and covers that file as a config-plugin source. @expo/fingerprint ships a CLI that prints it; passing no --platform is deliberate, since its default is what @expo/cli hashes for its own build cache, so the key agrees with what `expo run:*` looks up. The fingerprint hashes the native build and nothing else — it exists to decide native/JS compatibility, so the bundle this Release build embeds is outside it. Rather than bolt a second hash onto the key to cover the JS, the build is now repacked: @expo/repack-app rebuilds the bundle from current source and swaps it into the cached app. Keying on the fingerprint alone is then exactly right, and a JS-only change costs seconds instead of another ~22 minute build. Measured on a real Release bundle: 13.5s, Hermes bytecode preserved, native binary byte -identical. --js-bundle-only is precisely the gap the fingerprint leaves; every other thing repack would refresh is hashed into the key already, so a hit proves it unchanged. Gitignoring /ios and /android is what makes this work across machines, and it is all that is needed: @expo/fingerprint resolves the project workflow by asking the VCS whether the platform markers are ignored, and appends `ios/**/*` and `android/**/*` itself once it concludes CNG. The prebuild output exists on developer machines but never in a fresh CI checkout, so without that the fingerprint was machine-specific — ab99f403 locally against 14d08bcd in CI — and no cache could ever have been shared between the two. On top of that, serve developers the dev-client build from Actions artifacts keyed on the same fingerprint, so a checkout with no native changes installs CI's binary instead of building. iOS builds on a macOS runner, Android on a Linux one. Android has no build-only mode — the CLI resolves a device before gradle because a debug build is narrowed to that device's ABI — so the emulator is there to satisfy it, and --all-arch opts out of the narrowing so the artifact is not pinned to the emulator's x86_64. Every AVD on the maintainer's Apple Silicon machine reports arm64-v8a only, so a single-ABI artifact could not serve both it and CI. Only CI publishes: GitHub has no API for creating an artifact outside a workflow run, and the fingerprint does not capture the toolchain, so one known toolchain is the only safe producer. The provider refuses release builds outright rather than hand back a stale bundle, and a missing token or a failed lookup only ever costs a local build. The provider must set a User-Agent explicitly. @expo/cli replaces global fetch with fetch-nodeshim, which sends none, and GitHub rejects those with a 403 — which the error handling would otherwise have swallowed into "build locally" forever. Finally, `expo run:ios --output` replaces the DerivedData scan in the build step. `find ... | head -1` picked an arbitrary bundle when more than one matched; this machine has two Release-iphonesimulator AgentDeviceTester.app builds, so that was reachable rather than theoretical.
1 parent 3fed8ac commit 6cefa63

8 files changed

Lines changed: 447 additions & 135 deletions

File tree

.github/actions/setup-fixture-app/action.yml

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,19 @@ description: "Build (or restore from cache) the examples/test-app fixture app an
33

44
# Any job that needs a controlled app to drive can use this instead of an Apple
55
# system app. Building it costs ~22 minutes, so the built .app is cached and the
6-
# build only runs when its sources or the toolchain actually change.
6+
# build only runs when the native inputs or the toolchain actually change.
77
#
88
# The cache is shared: GitHub caches are per-repository and readable across
99
# workflows, and a run can restore caches from its own branch or the default
1010
# branch. So once a run on main populates it, every workflow gets the hit. The
1111
# key is computed here from a fixed input list, so all callers agree on it —
1212
# do not fold caller-specific inputs into it, or the cache stops being shared.
1313
#
14+
# Keyed on the Expo fingerprint, which hashes the native build and nothing else.
15+
# A hit therefore guarantees only that the native side is current, so the JS the
16+
# Release build embedded may be stale — @expo/repack-app swaps in a bundle built
17+
# from the current source, which takes seconds instead of another full build.
18+
#
1419
# Relevant to #320 (move replay coverage off system apps onto a stable fixture).
1520

1621
inputs:
@@ -48,37 +53,47 @@ runs:
4853
XCODE_KEY="$(xcodebuild -version | tr '\n' ' ' | sed -E 's/[[:space:]]+/ /g; s/[[:space:]]$//' | tr ' ' '-' | tr -cd '[:alnum:]._-')"
4954
echo "key=$XCODE_KEY" >> "$GITHUB_OUTPUT"
5055
56+
# Installed even on a hit, because the fingerprint is derived from the
57+
# dependency graph and cannot be computed without it.
58+
- name: Install test app dependencies
59+
shell: bash
60+
run: pnpm test-app:install
61+
62+
- name: Resolve fingerprint cache key
63+
id: fingerprint
64+
shell: bash
65+
run: |
66+
set -euo pipefail
67+
# No --platform: its default (android + ios) is what @expo/cli hashes for
68+
# its own build cache, so this key agrees with what `expo run:*` looks up.
69+
HASH="$(pnpm --dir examples/test-app exec fingerprint fingerprint:generate | jq -r .hash)"
70+
echo "key=$HASH" >> "$GITHUB_OUTPUT"
71+
echo "fingerprint: $HASH"
72+
5173
- name: Cache fixture app build
5274
id: cache
5375
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.2.3
5476
with:
5577
path: ${{ github.workspace }}/.tmp/fixture-app
56-
# Everything that can change the binary: app sources, native config,
57-
# dependency graph, this action's build logic, and the toolchain.
58-
# Deliberately caller-independent so the cache is shared across workflows.
59-
key: fixture-app-ios-${{ inputs.runtime-version }}-${{ steps.xcode.outputs.key }}-${{ hashFiles('examples/test-app/src/**', 'examples/test-app/app/**', 'examples/test-app/modules/**', 'examples/test-app/app.config.js', 'examples/test-app/package.json', 'examples/test-app/pnpm-lock.yaml', 'examples/test-app/pnpm-workspace.yaml', '.github/actions/setup-fixture-app/action.yml') }}
78+
# The fingerprint, the toolchain it is built with, and this action's own
79+
# build logic. Deliberately caller-independent so the cache is shared
80+
# across workflows.
81+
key: fixture-app-ios-${{ inputs.runtime-version }}-${{ steps.xcode.outputs.key }}-${{ steps.fingerprint.outputs.key }}-${{ hashFiles('.github/actions/setup-fixture-app/action.yml') }}
6082

6183
- name: Build fixture app
6284
if: steps.cache.outputs.cache-hit != 'true'
6385
shell: bash
6486
run: |
6587
set -euo pipefail
6688
# Release embeds the JS bundle, so no Metro server is needed in CI.
67-
pnpm test-app:install
89+
# --output writes the bundle to the deterministic path the cache keeps,
90+
# instead of leaving it under a per-project DerivedData hash. The CLI
91+
# copies it before installing, so the simulator still gets this binary.
6892
pnpm --dir examples/test-app exec expo run:ios \
6993
--configuration Release \
7094
--device "${{ inputs.device-name }}" \
71-
--no-bundler
72-
# Stash the bundle at a stable path: DerivedData paths are hashed per
73-
# project, so the cache needs somewhere deterministic to keep it.
74-
APP_PATH="$(find "$HOME/Library/Developer/Xcode/DerivedData" -type d -name '*.app' -path '*Release-iphonesimulator*' | head -1)"
75-
if [ -z "$APP_PATH" ]; then
76-
echo "::error::Built the fixture app but could not locate its .app bundle to cache."
77-
exit 1
78-
fi
79-
mkdir -p "${{ github.workspace }}/.tmp/fixture-app"
80-
rm -rf "${{ github.workspace }}/.tmp/fixture-app/"*.app
81-
cp -R "$APP_PATH" "${{ github.workspace }}/.tmp/fixture-app/"
95+
--no-bundler \
96+
--output "${{ github.workspace }}/.tmp/fixture-app"
8297
8398
- name: Locate fixture app
8499
id: locate
@@ -87,7 +102,7 @@ runs:
87102
set -euo pipefail
88103
APP="$(find "${{ github.workspace }}/.tmp/fixture-app" -maxdepth 1 -type d -name '*.app' | head -1)"
89104
if [ -z "$APP" ]; then
90-
echo "::error::No fixture app bundle at .tmp/fixture-app (restored an empty cache?)."
105+
echo "::error::No fixture app bundle at .tmp/fixture-app (empty cache, or the build emitted nothing)."
91106
exit 1
92107
fi
93108
# Read the id from the bundle itself rather than duplicating it here, so
@@ -97,6 +112,30 @@ runs:
97112
echo "app-id=$APP_ID" >> "$GITHUB_OUTPUT"
98113
echo "fixture app: $(basename "$APP") ($APP_ID)"
99114
115+
# A hit means the native build is current but the bundle it embedded is from
116+
# whenever that build ran. --js-bundle-only is exactly the gap the
117+
# fingerprint leaves: everything else it would refresh is already hashed into
118+
# the key, so a hit proves it unchanged.
119+
- name: Repack the cached app with the current JS
120+
if: steps.cache.outputs.cache-hit == 'true'
121+
shell: bash
122+
run: |
123+
set -euo pipefail
124+
APP="${{ steps.locate.outputs.app-path }}"
125+
OUT="${{ github.workspace }}/.tmp/fixture-app-repacked/$(basename "$APP")"
126+
rm -rf "$(dirname "$OUT")"
127+
mkdir -p "$(dirname "$OUT")"
128+
pnpm --dir examples/test-app exec repack-app \
129+
--platform ios \
130+
--source-app "$APP" \
131+
--output "$OUT" \
132+
--js-bundle-only
133+
# Swap it into the path the cache restored, so the install step and the
134+
# cached bundle stay one and the same. Safe to mutate: actions/cache does
135+
# not write back on a hit.
136+
rm -rf "$APP"
137+
mv "$OUT" "$APP"
138+
100139
- name: Install fixture app
101140
if: inputs.install == 'true'
102141
shell: bash
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
name: Test App Build Cache
2+
3+
# Keeps a dev-client build of examples/test-app published as a workflow artifact
4+
# per platform, so `pnpm test-app:ios` and `pnpm test-app:android` install CI's
5+
# binary instead of running a native build. Only CI can publish: GitHub has no
6+
# API for creating an artifact outside a workflow run, so this is the sole
7+
# producer and every developer machine is a reader (see
8+
# examples/test-app/build-cache-provider.js).
9+
#
10+
# The artifact is keyed by the Expo fingerprint, which covers native inputs only.
11+
# Runs on every push to main, but builds only when that fingerprint has no
12+
# artifact yet -- on a hit the provider short-circuits the build and this costs a
13+
# couple of minutes. Artifacts expire after 90 days; the next run rebuilds.
14+
15+
on:
16+
push:
17+
branches:
18+
- main
19+
workflow_dispatch:
20+
21+
permissions:
22+
contents: read
23+
# The provider lists and downloads artifacts through the REST API.
24+
actions: read
25+
26+
concurrency:
27+
# One producer at a time, so the same fingerprint is never built twice over.
28+
# Runs queue instead of cancelling: a long build would otherwise be killed by
29+
# the next merge and the cache might never be populated at all. Queued runs are
30+
# cheap — they find the artifact published and skip the build.
31+
group: ci-${{ github.workflow }}
32+
cancel-in-progress: false
33+
34+
jobs:
35+
dev-client:
36+
name: ${{ matrix.name }}
37+
runs-on: ${{ matrix.runs-on }}
38+
timeout-minutes: 60
39+
strategy:
40+
# The platforms are independent caches; one failing must not withhold the
41+
# other's artifact.
42+
fail-fast: false
43+
matrix:
44+
include:
45+
- name: iOS dev client
46+
platform: ios
47+
runs-on: macos-26
48+
- name: Android dev client
49+
platform: android
50+
runs-on: ubuntu-latest
51+
steps:
52+
- name: Checkout
53+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
54+
55+
- name: Setup toolchain
56+
uses: ./.github/actions/setup-node-pnpm
57+
58+
- name: Install test app dependencies
59+
run: pnpm test-app:install
60+
61+
# `--device generic` builds for the simulator but skips install and launch,
62+
# so no simulator has to be booted. The provider resolves the cache first:
63+
# when the fingerprint already has an artifact it downloads it and the
64+
# build is skipped, which also proves the cached artifact is still usable.
65+
- name: Build the iOS dev client if this fingerprint is not cached yet
66+
if: matrix.platform == 'ios'
67+
env:
68+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
69+
run: |
70+
pnpm --dir examples/test-app exec expo run:ios --device generic --no-bundler
71+
72+
- name: Setup Android host
73+
if: matrix.platform == 'android'
74+
uses: ./.github/actions/setup-android-replay-host
75+
76+
- name: Install Android SDK packages
77+
if: matrix.platform == 'android'
78+
run: |
79+
set -euo pipefail
80+
SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}"
81+
SDKMANAGER="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager"
82+
if [ ! -x "$SDKMANAGER" ]; then
83+
SDKMANAGER="$SDK_ROOT/cmdline-tools/bin/sdkmanager"
84+
fi
85+
yes | "$SDKMANAGER" --licenses >/dev/null
86+
"$SDKMANAGER" "platforms;android-36" "build-tools;36.0.0"
87+
88+
# Android has no build-only mode: the CLI resolves a device before gradle,
89+
# because a debug build is narrowed to that device's ABI. --all-arch opts
90+
# out of that narrowing, which is what makes the artifact usable on any
91+
# developer's device rather than only on this emulator's x86_64. The
92+
# emulator is here to satisfy the CLI, and installs the result as a bonus
93+
# check that the published APK is loadable.
94+
- name: Build the Android dev client if this fingerprint is not cached yet
95+
if: matrix.platform == 'android'
96+
uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2.35.0
97+
env:
98+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
99+
with:
100+
api-level: 36
101+
arch: x86_64
102+
profile: pixel_7
103+
target: google_apis_playstore
104+
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -no-metrics
105+
script: |
106+
set -e
107+
pnpm --dir examples/test-app exec expo run:android --no-bundler --all-arch
108+
109+
# The provider writes this only when it actually built something, so its
110+
# presence is what decides whether there is anything new to publish. The
111+
# name comes from the provider so the artifact can only be published under
112+
# the name the provider looks up.
113+
- name: Stage the build for upload
114+
id: stage
115+
run: |
116+
set -euo pipefail
117+
INTENT=examples/test-app/.expo/remote-build-cache-upload.json
118+
if [ ! -f "$INTENT" ]; then
119+
echo "This fingerprint is already cached; nothing to publish."
120+
exit 0
121+
fi
122+
NAME="$(node -p "JSON.parse(require('fs').readFileSync('$INTENT', 'utf8')).name")"
123+
BUILD_PATH="$(node -p "JSON.parse(require('fs').readFileSync('$INTENT', 'utf8')).buildPath")"
124+
# A .app is a directory and an .apk is a file, so only test existence.
125+
if [ ! -e "$BUILD_PATH" ]; then
126+
echo "::error::Provider reported a build at $BUILD_PATH but there is nothing there."
127+
exit 1
128+
fi
129+
# Tar the binary: artifact zips drop the executable bit, which would
130+
# leave an installed .app unable to launch.
131+
mkdir -p .tmp/test-app-build-cache
132+
tar -czf .tmp/test-app-build-cache/app.tar.gz \
133+
-C "$(dirname "$BUILD_PATH")" "$(basename "$BUILD_PATH")"
134+
echo "name=$NAME" >> "$GITHUB_OUTPUT"
135+
echo "publishing $NAME ($(du -h .tmp/test-app-build-cache/app.tar.gz | cut -f1))"
136+
137+
- name: Publish to the build cache
138+
if: steps.stage.outputs.name != ''
139+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
140+
with:
141+
name: ${{ steps.stage.outputs.name }}
142+
path: .tmp/test-app-build-cache/app.tar.gz
143+
if-no-files-found: error
144+
compression-level: 0 # already gzipped

examples/test-app/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,7 @@
11
.expo/
22
node_modules/
3+
4+
# Generated by `expo prebuild`. Ignoring them is also what makes
5+
# @expo/fingerprint treat this project as CNG and skip hashing them.
6+
/ios
7+
/android

examples/test-app/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,31 @@ The app declares `@expo/dom-webview` directly to keep Expo's development runtime
5252
on the SDK 56 native module; Android verification failed when the dev client
5353
resolved an older transitive copy.
5454

55+
### Build cache
56+
57+
`pnpm test-app:ios` and `pnpm test-app:android` install the build CI already made
58+
instead of running a full native build, unless you changed something native. It
59+
is keyed by the [Expo fingerprint](https://docs.expo.dev/versions/latest/sdk/fingerprint/),
60+
which covers native inputs only: app config, dependencies, autolinking, and the
61+
local modules' native code. Editing screens never needs a rebuild, because Metro
62+
serves JavaScript at runtime.
63+
64+
That fingerprint is why `/ios` and `/android` are gitignored: ignoring the
65+
prebuild output is what makes @expo/fingerprint treat this app as CNG and skip
66+
hashing it. Un-ignore them and the fingerprint starts describing your machine
67+
rather than the project, and nothing will ever hit the cache.
68+
69+
Reading the cache needs a GitHub token. `gh auth login` provides one; `GITHUB_TOKEN`
70+
or `GH_TOKEN` is used first if set. Without a token, `expo run:*` says so and
71+
builds locally as before — the cache only ever saves time, it is never required.
72+
73+
Only CI writes to it, in `.github/workflows/test-app-build-cache.yml`: GitHub has
74+
no API for creating a workflow artifact outside a workflow run. iOS is built on a
75+
macOS runner and Android on a Linux one, so a native change costs one build per
76+
platform on `main` and every developer downloads the result. Dev-client builds
77+
for simulators and emulators only; a release build embeds its own JS bundle,
78+
which the fingerprint does not describe, so the cache declines to serve one.
79+
5580
### iOS simulator
5681

5782
From the repo root, install dependencies and run the development build on the

examples/test-app/app.config.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
const accessorySetupConfig = require('./accessory-setup.config.json');
22

3-
const buildRunCacheDir =
4-
process.env.AGENT_DEVICE_EXPO_BUILD_CACHE_DIR?.trim() || './.expo/build-run-cache';
5-
63
const accessoryInfoPlist = {
74
NSAccessorySetupBluetoothServices: [accessorySetupConfig.serviceUuid],
85
NSAccessorySetupKitSupports: ['Bluetooth'],
@@ -17,9 +14,10 @@ module.exports = {
1714
orientation: 'default',
1815
userInterfaceStyle: 'automatic',
1916
buildCacheProvider: {
20-
plugin: 'expo-build-disk-cache',
17+
plugin: './build-cache-provider.js',
2118
options: {
22-
cacheDir: buildRunCacheDir,
19+
owner: 'callstack',
20+
repo: 'agent-device',
2321
},
2422
},
2523
plugins: ['expo-router'],

0 commit comments

Comments
 (0)