Skip to content

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

Merged
thymikee merged 1 commit into
mainfrom
claude/expo-fingerprints-remote-cache-ee0d20
Jul 18, 2026
Merged

ci(test-app): key the fixture build cache on the Expo fingerprint#1321
thymikee merged 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 -8.2 kB
JS gzip 569.7 kB 566.7 kB -3.0 kB
npm tarball 684.3 kB 681.6 kB -2.7 kB
npm unpacked 2.4 MB 2.4 MB -8.2 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 19.9 ms 17.5 ms -2.5 ms
CLI --help 41.7 ms 36.4 ms -5.3 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/internal/daemon.js -61.8 kB -16.3 kB
dist/src/record-trace-recording.js +55.6 kB +15.2 kB
dist/src/runner-client.js +46.3 kB +13.5 kB
dist/src/cli.js -3.3 kB -977 B
dist/src/snapshot-diagnostics.js -210 B -80 B

@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
@thymikee
thymikee force-pushed the claude/expo-fingerprints-remote-cache-ee0d20 branch from aa6d29f to c2713c8 Compare July 17, 2026 12:35
@thymikee

Copy link
Copy Markdown
Member Author

Re-review of rebased head c2713c8 remains code-clean. The only patch delta from the previously reviewed head is the correct anchored .gitignore conflict resolution. Final-head run 29580800315 is green and proves cache miss → build → ABI validation where applicable → install/open → upload for both iOS and Android on fingerprint 66ef9e…. One evidence gap remains before merge: both jobs logged No cached test app build, so this head has not yet proved download/repack/install/open from the artifacts it published. Please rerun the same final head and show Downloading the test app build plus successful install/open for both platforms. ready-for-human remains appropriate because code review is clean; this is the remaining merge-readiness evidence.

@thymikee
thymikee force-pushed the claude/expo-fingerprints-remote-cache-ee0d20 branch 2 times, most recently from 55cd6c2 to be3d552 Compare July 17, 2026 17:08
@thymikee thymikee removed the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Jul 17, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Re-review of rewritten head be3d552: the Release-artifact direction is materially cleaner and all 25 checks are green, but one blocker remains.

Artifact lookup failures bypass the promised inline-build fallback. In .github/actions/setup-fixture-app/action.yml, the fetch step runs under set -euo pipefail, and ART_ID="$(gh api ...)" is executed before the guarded download block. Any GitHub API/auth/transient lookup failure exits the composite immediately, so SOURCE=build is never reached. That turns an artifact-service outage into a conformance failure even though this action explicitly promises that cache failures only cost an inline build. Make the lookup non-fatal (leave ART_ID empty and warn), and add a regression/invocation proof for that path.

After the fix, merge readiness also needs a current-head consumer run showing artifact download → untar → JS repack → install → normal scenario drive (source=artifact). The successful final-head producer jobs build/upload, but the conformance dispatches cited in the thread predate this rewrite and do not prove the new consumer path.

…rtifacts in CI

Splits the test app's build caching by context instead of running one remote
cache for both.

Locally, `expo run:*` caches the native build on disk via the
expo-build-disk-cache provider, keyed by the Expo fingerprint. A second run with
no native change reuses the first build; a screen edit never rebuilds, because
Metro serves JS. This is the original ask — "next time we don't build unless
native changes" — and needs no token, no network, and no custom provider.

In CI, test-app-build-cache.yml builds a Release binary per platform when the
fingerprint has no artifact yet, and publishes it as a GitHub Actions artifact
named `fingerprint.<hash>.<platform>`. Release, not dev-client, so the JS bundle
is embedded and a consuming job needs no Metro. setup-fixture-app installs it by
downloading the artifact and refreshing the JS with @expo/repack-app, so keying
on the native-only fingerprint stays correct — a JS-only change reuses the same
native binary in seconds. It falls back to an inline build when no artifact
exists yet, so a caller is never left without an app.

Release removes the sharp edges the dev-client cache needed. Its simulator .app
is universal (x86_64+arm64) rather than the active-arch-only slice a debug build
emits, so no architecture tag. It links against the SDK but loading is gated by
the deployment target, which the fingerprint already covers, so no toolchain
tag. And the CLI only narrows *debug* builds to the device ABI, so a Release APK
spans every ABI without the undocumented --all-arch flag. The artifact name
collapses to fingerprint plus platform.

This deletes build-cache-provider.js entirely — with it goes the custom Expo
provider that had to reach GitHub from inside @expo/cli, and every workaround
that forced: the fetch-nodeshim User-Agent shim, the arch/Xcode identity, the
upload-intent handoff. CI now talks to the artifacts API with plain `gh api`
outside the patched fetch, and locally the disk cache never hits the network.

The fingerprint comes from @expo/fingerprint's own `fingerprint:generate` (no
--platform, matching what @expo/cli hashes). Gitignoring /ios and /android is
what makes it machine-independent: the library asks the VCS whether the platform
markers are ignored and, concluding CNG, skips hashing them — so a developer's
prebuild output and a fresh CI checkout agree.

conformance-differential consumes setup-fixture-app, so it gains
`permissions: actions: read` for the artifact lookup.

The artifact lookup is non-fatal: a query outage leaves the id empty and
falls through to an inline build like a miss does, rather than exiting the
composite under set -e and turning a cache blip into a caller failure.
test/scripts/setup-fixture-app-fallback-smoke.sh drives that step's real shell
against a failing gh and asserts source=build; ci.yml runs it.
@thymikee
thymikee force-pushed the claude/expo-fingerprints-remote-cache-ee0d20 branch from be3d552 to 1bd7566 Compare July 18, 2026 05:47
@thymikee

Copy link
Copy Markdown
Member Author

Both fixed on 1bd7566a.

Non-fatal lookup

You were exactly right — the download block was guarded but the ART_ID="$(gh api …)" lookup ran bare under set -e, so a query outage exited the composite before SOURCE=build. Now guarded:

if ! ART_ID="$(gh api … --jq '' 2>/dev/null)"; then
  echo "::warning::Could not query the build cache for $NAME; building inline."
  ART_ID=""
fi

An empty id then takes the same path as "no artifact found."

Regression proof

test/scripts/setup-fixture-app-fallback-smoke.sh (run by ci.yml's Integration job) extracts the real fetch step out of action.yml — not a copy, so it can't drift — substitutes the ${{ }} expressions, and runs it against a gh stub that fails every call. It asserts the step reaches source=build and exits 0.

I verified it isn't a tautology: with the guard temporarily removed, the same test fails (exit 1 at the bare assignment). One caveat worth flagging from building it — my first harness wrapped the script in if OUT="$(fn)", which silently disables set -e inside the function and made both old and new "pass"; the committed test runs the step as a top-level bash script.sh in a separate process, where set -e is live.

Consumer run

Right that the earlier dispatches predate this. Dispatched on this exact head: 29632885345. The fingerprint.282ca2a5….ios artifact exists and this head computes the same fingerprint, so it should take source=artifact → download → untar → repack → install → drive scenarios. I'll report the result here when it lands.

@thymikee

Copy link
Copy Markdown
Member Author

Consumer run landed green on head 1bd7566a29632885345, completed success. It took the artifact path, not the fallback:

05:55:17  restored fingerprint.282ca2a5….ios from the build cache
          pnpm … exec repack-app --platform ios --source-app … --js-bundle-only
          fixture app: AgentDeviceTester.app (com.callstack.agentdevicelab, source=artifact)
          expected=com.callstack.agentdevicelab actual=com.callstack.agentdevicelab (source=artifact)
Run differential -> success

So the full chain is exercised: download → untar → JS repack → install → app-id verify → scenario drive, all source=artifact. Setup fixture app ran in 292s (vs ~22 min for an inline build), which also rules out a silent fallback.

Both blockers resolved. Ready for re-review.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Jul 18, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Re-review of head 1bd7566a is clean. The guarded gh api assignment now degrades lookup/auth/transient failures to the existing inline build without masking fingerprint generation or later build failures. The new smoke test extracts and executes the real composite step, and is revert-sensitive: restoring the bare assignment exits under set -euo pipefail, so the test loses source=build and fails.

All 25 PR checks are green. Current-head consumer run 29632885345 also closes the evidence gap: it restored the fingerprinted artifact, repacked current JS, installed and verified com.callstack.agentdevicelab, then completed the differential with the known tracked divergence only. Code review and practical evidence are ready for human merge review.

@thymikee
thymikee merged commit ef118b9 into main Jul 18, 2026
26 checks passed
@thymikee
thymikee deleted the claude/expo-fingerprints-remote-cache-ee0d20 branch July 18, 2026 07:36
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-18 07:36 UTC

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