ci(test-app): key the fixture build cache on the Expo fingerprint#1321
ci(test-app): key the fixture build cache on the Expo fingerprint#1321thymikee wants to merge 1 commit into
Conversation
Size Report
Startup median (7 runs, lower is better):
Top changed chunks: no changes in the largest emitted chunks. |
266cee0 to
c3f2e7f
Compare
dbbb1ee to
6cefa63
Compare
|
One blocker remains on head The remote artifact identity omits the producer toolchain and 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. |
6cefa63 to
a529f4c
Compare
|
Both points were right. Fixed on Artifact identity — confirmed, and worse than "may be"
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:
Both sides compute it, so a mismatched consumer asks for a name that does not exist and builds locally — a mutable Android takes neither dimension deliberately: Live evidenceCorrect, and Both producers now build against a booted simulator/emulator instead of Only branches in this repo may publish. A fork's The conformance differential has since finished: 29573744244 completed success, including 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. |
fc0138c to
aa6d29f
Compare
|
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 handThe 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: I downloaded that artifact, unpacked it, and installed it on a throwaway simulator on my machine: That is build → upload → download → install → launch, executed end to end. Architecture: kept — confirmed real
Xcode: dropped — pinning it guaranteed a permanent missCI 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 On the mutable Dropping it also removed the Also addressed
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 |
|
Re-review of head |
|
Update: the previously outstanding Android producer run |
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.
aa6d29f to
c2713c8
Compare
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
hashFileslist, and it already had a hole:accessory-setup.config.jsonwas absent from it, butapp.config.jsreads that file intoNSAccessorySetupBluetoothServices. 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.
getBareIosSourcesAsynchashesios/whenever the directory exists, with no gitignore check. This app is CNG, so laptops have prebuild output and CI does not:Fixed with
.fingerprintignore, not.gitignore— the sourcer ignores the latter. It works becauseHash.jsskips sources whose hash isnull, 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
hashFileshalf 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_TOKENin CI,gh auth tokenlocally. 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/clireplaces globalfetchwithfetch-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 -1picked an arbitrary bundle when more than one matched.Verified
@expo/cli's own plugin validation; Release returns null; dev-client reaches GitHub..appwith the exec bit intact (the artifact holds a tarball because artifact zips drop permissions, which would leave the app unable to launch).expo-build-disk-cachebenefit is retained.scripts/test-app-fingerprint.mjscompute the same hash, so the key spaces agree.oxlintpasses; both YAML files parse.Not yet verified
The 22-minute build itself.
setup-fixture-appis exercised only by the nightlyconformance-differential, so no PR check touches these changes — I'm dispatching that workflow against this branch.test-app-build-cachecannot be dispatched until this lands, sinceworkflow_dispatchonly works for workflows already on the default branch. Its first run onmainis the real test.Note
One-time cache bust expected:
.gitignoreandapp.config.jsboth feed the fingerprint.