Skip to content

ci(test-app): key the fixture build cache on the Expo fingerprint#1321

Open
thymikee wants to merge 1 commit into
mainfrom
claude/expo-fingerprints-remote-cache-ee0d20
Open

ci(test-app): key the fixture build cache on the Expo fingerprint#1321
thymikee wants to merge 1 commit into
mainfrom
claude/expo-fingerprints-remote-cache-ee0d20

Conversation

@thymikee

Copy link
Copy Markdown
Member

Replaces the fixture app's hand-maintained cache key with the Expo fingerprint, and serves developers CI's dev-client build so a checkout with no native changes skips xcodebuild.

Why

The old key was a 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. The fingerprint enumerates the native inputs rather than us restating them, and covers that file as a config-plugin source.

Two things worth reviewing carefully

The fingerprint was machine-specific, so no cache could ever have been shared. getBareIosSourcesAsync hashes ios/ whenever the directory exists, with no gitignore check. This app is CNG, so laptops have prebuild output and CI does not:

local (ios/ present):  ab99f403ce15545ed5796f7a44a88ebb1f27f0a4
CI   (no ios/):        14d08bcd075dc083f48d8430490c1632de7a8848

Fixed with .fingerprintignore, not .gitignore — the sourcer ignores the latter. It works because Hash.js skips sources whose hash is null, so an ignored dir contributes exactly what an absent one does. Verified: the hash is now identical with and without fabricated prebuild output.

The fingerprint cannot key the CI build alone. It omits JS by design; that build compiles Release with the bundle embedded. So the key keeps a hashFiles half for the app's own JS. That split is a real boundary, not an oversight — a stale bundle would let scenarios fail for the wrong reason, or pass while proving nothing. For the same reason the provider refuses Release outright.

Design

Builds live in Actions artifacts, not Releases, so the public Releases page stays clean and entries expire at 90 days. Only CI publishes — GitHub has no API for creating an artifact outside a workflow run (the same reason Rock's provider throws on upload), and the fingerprint does not capture the toolchain, so one known Xcode is the only safe producer. No new dependencies, no PAT: GITHUB_TOKEN in CI, gh auth token locally. A missing token or failed lookup only ever costs a local build.

Every published GitHub-backed Expo provider (@eggl-js/expo-github-cache, @huextrat/..., eas-git-cache, Expo's own example) stores builds in Releases, which is why none were reused.

The provider must set a User-Agent explicitly: @expo/cli replaces global fetch with fetch-nodeshim, which sends none, and GitHub 403s those. The error handling would otherwise have swallowed that into "build locally" forever.

Also swaps the DerivedData scan for expo run:ios --output; find ... | head -1 picked an arbitrary bundle when more than one matched.

Verified

  • Provider accepted by @expo/cli's own plugin validation; Release returns null; dev-client reaches GitHub.
  • Full download → unzip → untar → .app with the exec bit intact (the artifact holds a tarball because artifact zips drop permissions, which would leave the app unable to launch).
  • Warm cache serves from disk with no network, so the previous expo-build-disk-cache benefit is retained.
  • CLI and scripts/test-app-fingerprint.mjs compute the same hash, so the key spaces agree.
  • oxlint passes; both YAML files parse.

Not yet verified

The 22-minute build itself. setup-fixture-app is exercised only by the nightly conformance-differential, so no PR check touches these changes — I'm dispatching that workflow against this branch.

test-app-build-cache cannot be dispatched until this lands, since workflow_dispatch only works for workflows already on the default branch. Its first run on main is the real test.

Note

One-time cache bust expected: .gitignore and app.config.js both feed the fingerprint.

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.8 MB 1.8 MB 0 B
JS gzip 566.7 kB 566.7 kB 0 B
npm tarball 681.6 kB 681.6 kB 0 B
npm unpacked 2.4 MB 2.4 MB 0 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 25.6 ms 26.3 ms +0.7 ms
CLI --help 52.7 ms 54.2 ms +1.5 ms

Top changed chunks: no changes in the largest emitted chunks.

@thymikee
thymikee force-pushed the claude/expo-fingerprints-remote-cache-ee0d20 branch from 266cee0 to c3f2e7f Compare July 17, 2026 10:31
@thymikee
thymikee force-pushed the claude/expo-fingerprints-remote-cache-ee0d20 branch 3 times, most recently from dbbb1ee to 6cefa63 Compare July 17, 2026 11:25
@thymikee

Copy link
Copy Markdown
Member Author

One blocker remains on head dbbb1ee:

The remote artifact identity omits the producer toolchain and simulator architecture. artifactName() is only fingerprint.<hash>.dev-client.<platform>, while the Expo native fingerprint deliberately does not describe Xcode/SDK or the host/target simulator architecture. The producer uses the mutable macos-26 image, and Debug simulator output may be active-architecture-specific. That lets an artifact survive an Xcode image change or be downloaded by an incompatible Intel/Apple-Silicon consumer until its 90-day expiry. Include a pinned/recorded toolchain identity and target architecture in the artifact key/name, or prove and verify a universal simulator binary; then exercise an actual build → upload → download → install/launch hit on every supported simulator architecture.

The ordinary PR CI is green, but it does not execute the new default-branch-only producer workflow; the conformance differential is still running, so the claimed build-cache path has no live evidence yet.

@thymikee
thymikee force-pushed the claude/expo-fingerprints-remote-cache-ee0d20 branch from 6cefa63 to a529f4c Compare July 17, 2026 11:38
@thymikee

Copy link
Copy Markdown
Member Author

Both points were right. Fixed on a529f4c4.

Artifact identity — confirmed, and worse than "may be"

Debug simulator output is active-architecture-only. Every .app in this machine's DerivedData:

Debug   simulator .app →  arm64            (active-arch-only)
Release simulator .app →  x86_64 arm64     (universal)

The dev-client artifact is Debug, so CI's arm64 slice genuinely cannot run on an Intel Mac. You were also right that I already knew this and was inconsistent: setup-fixture-app's own cache key includes an Xcode key, and I omitted it from the remote artifact.

artifactName() now carries both:

fingerprint.<hash>.dev-client.ios.arm64.Xcode-26.2-Build-version-17C52
fingerprint.<hash>.dev-client.android

Both sides compute it, so a mismatched consumer asks for a name that does not exist and builds locally — a mutable macos-26 bumping Xcode just changes the name rather than silently serving a stale-SDK binary. That also removes the need to test "every supported simulator architecture": the only supported one is whatever CI produces, and everything else is a miss by construction rather than an incompatible download.

Android takes neither dimension deliberately: --all-arch carries every ABI and adb installs without a local toolchain. Verified the failure path too — no xcodebuild means warn, return null, no network call, and a refusal to publish untagged rather than a fallback.

Live evidence

Correct, and workflow_dispatch cannot reach a workflow that is not yet on the default branch, so the producer could not run pre-merge at all. It now also runs on PRs that touch it — this PR included, currently in run 29577522527.

Both producers now build against a booted simulator/emulator instead of --device generic, so a run installs and launches what it publishes: a miss proves the binary is loadable before it reaches anyone, and a hit proves the downloaded artifact still is — the build → upload → download → install/launch round trip you asked for.

Only branches in this repo may publish. A fork's pull_request run has a read-only token but can still upload artifacts, and the provider serves by name — the identity says what a binary claims to be, not who built it. Forks still build; they just cannot publish.

The conformance differential has since finished: 29573744244 completed success, including Run differential and the assertion that the installed app is the one the scenarios target. That covers the fixture path (--output, the fingerprint key) but not the artifact path, which is what the producer run above is for.

Still unproven until those land: the Android emulator/gradle job has never executed, and repack only fires on a cache hit, so it needs a second run after this one populates.

@thymikee
thymikee force-pushed the claude/expo-fingerprints-remote-cache-ee0d20 branch 3 times, most recently from fc0138c to aa6d29f Compare July 17, 2026 12:07
@thymikee

Copy link
Copy Markdown
Member Author

Live evidence now exists, and it partially reverses the identity fix — with measurements rather than reasoning.

The producer ran, published, and I round-tripped the artifact by hand

The workflow now also runs on PRs (workflow_dispatch cannot reach a workflow that is not yet on the default branch). Its first iOS job published:

fingerprint.d3898e36….dev-client.ios.arm64.Xcode-26.5-Build-version-17F42   22.3 MB

I downloaded that artifact, unpacked it, and installed it on a throwaway simulator on my machine:

slices:            arm64                  ← Debug is active-arch-only: you were right
built against SDK: iphonesimulator26.5
MinimumOSVersion:  16.4
install: OK
launch:  com.callstack.agentdevicelab: 78776
running: UIKitApplication:com.callstack.agentdevicelab

That is build → upload → download → install → launch, executed end to end.

Architecture: kept — confirmed real

Debug really is active-architecture-only. CI's .app carries an arm64 slice and nothing else (Release, by contrast, is x86_64 arm64). An Intel Mac genuinely cannot load it, so the architecture stays in the identity and a mismatched consumer misses and builds locally.

Xcode: dropped — pinning it guaranteed a permanent miss

CI is on Xcode 26.5; this machine is on 26.2. With the Xcode build in the name, the two never agree, so the cache would have hit zero times — the fix would have silently killed the feature it protected, and looked like it was working.

And the pin was not buying correctness. The launch above is CI's 26.5/SDK-26.5 binary running on a 26.2 runtime. What actually gates loading is MinimumOSVersion: 16.4, which comes from the app config and is therefore already hashed into the fingerprint — a consumer whose runtime is too old cannot load their own local build either, so there is no regression to protect against.

On the mutable macos-26 image: an artifact surviving an Xcode bump is fine precisely because the deployment target, not the compiler, decides loadability. The fingerprint still changes whenever anything that shapes the binary's inputs changes.

Dropping it also removed the xcodebuild shell-out, the output parsing, and the async/try-catch scaffolding it needed — artifactName() is a pure function again.

Also addressed

--all-arch is the one flag here that expo run:android --help does not list. Since losing it is silent — the build would narrow to CI's x86_64 and every arm64 machine would get an APK adb refuses — the stage step now asserts the APK contains lib/arm64-v8a/ before publishing. Verified against a good/bad APK pair. expo-build-properties' documented buildArchs cannot replace the flag: -PreactNativeArchitectures is passed on the command line and beats gradle.properties unless --all-arch has already zeroed it.

Fork PRs cannot publish (read-only token still permits artifact upload, and the identity says what a binary claims to be, not who built it). Both conformance runs are green: 29573744244 and 29576571810.

Still outstanding: the Android job has never gotten past its SDK step (my set -euo pipefail turned yes's SIGPIPE into a failure; fixed), and repack only fires on a cache hit, so it needs the next run.

@thymikee

Copy link
Copy Markdown
Member Author

Re-review of head aa6d29f is code-clean. The architecture-aware identity is sound: iOS separates arm64/x86_64 consumers, Android’s all-arch artifact is ABI-checked, and the documented Xcode 26.5→26.2 launch evidence supports omitting Xcode from the key while deployment target remains fingerprinted. Merge readiness still needs the outstanding workflow evidence: a successful Android build/install/ABI-check/upload, then a subsequent clean run on the final identity proving cache download/install/launch. The ordinary PR checks are green, but those producer/cache-hit paths are not yet fully demonstrated on this head.

@thymikee

Copy link
Copy Markdown
Member Author

Update: the previously outstanding Android producer run 29578779022 has now completed successfully, including build, staging/ABI validation, and upload. The remaining readiness evidence is the final-head run 29579144409 (currently in progress) and then a subsequent clean run proving the final artifact identity’s cache download/install/launch path.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Jul 17, 2026
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.

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

The fingerprint describes the project, not the machine that compiled it, so it
cannot decide on its own whether a published binary is loadable somewhere else.
Debug simulator builds are active-architecture-only — every .app in this repo's
DerivedData carries an arm64 slice alone, against x86_64+arm64 for Release — so
CI's dev-client output cannot run on an Intel Mac, and `macos-26` is a mutable
image whose Xcode moves underneath it. The artifact identity therefore carries
the architecture and the Xcode build alongside the fingerprint, which turns both
into a miss and a local build rather than an unusable download. An .apk needs
neither: --all-arch carries every ABI, and adb installs it without a local
toolchain. 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. 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.

Both producers now build against a booted simulator or emulator rather than
skipping install, so a run installs and launches what it publishes: on a miss
that proves the binary is loadable before it reaches anyone, and on a hit it
proves the downloaded artifact still is. The workflow also runs on PRs that
touch it, because workflow_dispatch only reaches workflows already on the
default branch and it could otherwise not be exercised until after it merged.
Only branches in this repo may publish: a fork's run can still upload artifacts,
and the identity says what a binary claims to be, not who built it.

Only CI publishes, because GitHub has no API for creating an artifact outside a
workflow run. The provider refuses release builds outright rather than hand back
a stale bundle; a missing token, an unknown toolchain, 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.
@thymikee
thymikee force-pushed the claude/expo-fingerprints-remote-cache-ee0d20 branch from aa6d29f to c2713c8 Compare July 17, 2026 12:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant