ci: reuse native e2e builds across PRs via build-source-hash + artifact lookup #29148
ci: reuse native e2e builds across PRs via build-source-hash + artifact lookup #29148tommasini wants to merge 11 commits into
build-source-hash + artifact lookup #29148Conversation
|
CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #29148 +/- ##
==========================================
+ Coverage 75.40% 82.23% +6.83%
==========================================
Files 5096 5107 +11
Lines 134310 134963 +653
Branches 30148 30355 +207
==========================================
+ Hits 101275 110986 +9711
+ Misses 25916 16409 -9507
- Partials 7119 7568 +449 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
… prs and cross prs
build-source-hash + artifact lookupbuild-source-hash + artifact lookup cp-7.74.0
build-source-hash + artifact lookup cp-7.74.0build-source-hash + artifact lookup
AI PR Analysis🚫 Merge safe: false | 🟠 Risk: high
AI analysis did not complete. Manual review recommended. |
🔍 Smart E2E Test Selection
click to see 🤖 AI reasoning detailsE2E Test Selection:
Why run any tests? The build pipeline changes affect how E2E build artifacts (APKs, iOS .app) are produced and potentially reused from prior runs. Running a representative set of E2E tests validates that:
Why not run all tests? No app code changed, so there's no risk of regressions in specific feature areas. The risk is purely in the build pipeline producing valid artifacts. Tag selection rationale: Selected Performance Test Selection: |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 6dc1273. Configure here.
| ${{ | ||
| !cancelled() && | ||
| needs.needs_e2e_build.result == 'success' && | ||
| needs.smart-e2e-selection.result == 'success' && |
There was a problem hiding this comment.
Missing post-build-source-hash result check allows silent failures
Medium Severity
The build-android-apks and build-ios-apps jobs use !cancelled() plus explicit result == 'success' checks for needs_e2e_build and smart-e2e-selection, but the comment on build-ios-apps says "a failure/skip of post-build-source-hash must NOT block the E2E build — it only disables cross-run cache reuse." In practice the source-fingerprint output silently becomes empty on post-build-source-hash failure, which disables all fingerprint-based caching (both GHA cache tiers and cross-run lookup), not just cross-run reuse. Every build on every PR degrades to a fresh ~20 min compile if the post-build-source-hash job breaks — with no visible error on the build jobs themselves to alert anyone that caching is fully disabled.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 6dc1273. Configure here.
|
|
✅ E2E Fixture Validation — Schema is up to date |





PR Title
ci: reuse native e2e builds across PRs via
build-source-hash+ artifact lookupDescription
Why. Native E2E builds are ~20 min per platform. Today, the
cirruslabs/cachelayer keyed by@expo/fingerprintreuses the native shell within a single branch and via amainfallback, and the existing@expo/repack-appstep swaps in fresh JS on a cache hit. GitHub Actions caches are branch-scoped, though, so two unrelated PRs with byte-identical native inputs still each pay the ~20 min cost — the only cross-PR path is themainfallback, which requiresmainto have happened to build the matching fingerprint.What. This PR adds a cross-PR reuse tier on top of the existing pipeline, modeled on metamask-extension#41435 and adapted to mobile's
@expo/fingerprint+@expo/repack-apparchitecture.post-build-source-hashjob inci.ymlrunsyarn fingerprint:generateand posts the result as abuild-source-hashGitHub commit status on every event (includingmerge_groupand fork PRs where the E2E builds themselves may be skipped). This guarantees the fingerprint is queryable for every commit.find-reusable-buildsearches the 10 most recentci.ymlruns on the head branch, then onmain, for a run whosebuild-source-hashstatus matches the current fingerprint AND whose required native artifacts are still available (verified vialistWorkflowRunArtifacts). Same-SHA rejection, non-success runs are filtered out, and every API call is wrapped incontinue-on-error— any lookup failure falls through to a fresh build.build-ios-e2e.ymlandbuild-android-e2e.ymlcall the lookup after the existing GHA cache restores miss. On a match,actions/download-artifact@v4pulls theMetaMask.appor bothrelease.apk+release-androidTest.apkdirectly from the source run using itsrun-id. The repack + source-map upload steps'if:conditions were extended to include the reuse path.androidTestAPK too. If either is missing or expired, the candidate is rejected and we keep searching.Escape hatch. A per-PR override to force a fresh native build for safety when reuse is suspected wrong:
force-buildslabel to the PR, or[force-builds]in the head commit message.A new composite action
check-force-buildsdetects either, and gates the branch-scoped GHA cache restore, themain-scoped GHA cache restore, and thefind-reusable-buildstep with its output. The build/repack steps' existing conditions naturally resolve to "build fresh, skip repack" because all three reuse outputs are empty. The override is honored only onpull_requestevents — inmerge_groupandpushthe merge queue always uses hash-verified reuse or fresh builds.Documentation.
CI Native Build Reuse (Cross-PR)
This document describes how MetaMask Mobile reuses iOS
.appand Android.apkE2E build artifacts across pull requests when the native inputs haven't
changed, so unrelated PRs with identical native state skip the ~20 min native
compile entirely.
The mechanism sits on top of the existing
@expo/fingerprint+@expo/repack-apppipeline and the
cirruslabs/cacheGHA-cache layer. It is modeled aftermetamask-extension#41435
but adapted to mobile's native-shell + repackable-JS architecture.
Problem statement
Native builds on CI are expensive (~20 min per platform). Before this change,
mobile already cached the built native shell via
cirruslabs/cache, keyed bythe
@expo/fingerprinthash. That cache has two scopes:mainfallback (read only)GitHub Actions caches are branch-scoped: PR B cannot read PR A's cache
entry, even if they produce an identical native shell. The only cross-PR path
is the
mainfallback, which relies onmainhaving happened to buildsomething with the matching fingerprint.
Result: two unrelated PRs that only change tests/docs rebuild the native
shell twice, even though the shells are byte-identical.
High-level flow
flowchart TD start[Build job starts] fp[Compute fingerprint] ghaBranch{GHA cache hit on branch?} ghaMain{GHA cache hit on main?} lookup[find-reusable-build: scan recent ci.yml runs] match{Matching build-source-hash AND artifact available?} download[actions/download-artifact with run-id] build[Fresh native build] repack[yarn build:repack - swap in fresh JS] upload[Upload artifact + save GHA cache] endNode[Done] start --> fp --> ghaBranch ghaBranch -- hit --> repack ghaBranch -- miss --> ghaMain ghaMain -- hit --> repack ghaMain -- miss --> lookup --> match match -- yes --> download --> repack match -- no --> build --> upload --> endNode repack --> uploadThe three tiers in order of preference:
lineage).
main-scoped GHA cache (cross-PR, but only ifmainhas built amatching fingerprint recently).
works across unrelated PRs).
Tiers 1 and 2 existed before. Tier 3 is the new one.
Components
scripts/generate-fingerprint.jsThin wrapper around
@expo/fingerprint'screateFingerprintAsync, exposed asyarn fingerprint:generate. Produces one hash covering native folders, Expoconfig, patches, and dependency manifests. This is the content-addressable
identity of the native shell.
.github/actions/post-build-source-hash/action.ymlComposite action that:
yarn fingerprint:generate.build-source-hashand description set to the hash value.The post is wrapped in
continue-on-error: true— a failure to post neverblocks the workflow.
.github/actions/find-reusable-build/action.ymlComposite action driven by
actions/github-script. Given a fingerprint and alist of required artifact names, it:
ci.ymlon the head branch.getCombinedStatusForRefon its head SHAand looks for a
build-source-hashstatus whose description matches.listWorkflowRunArtifactsand verifies thatevery required artifact name is present and not expired.
mainby default).found,run-id,source-sha,source-branch.Everything is wrapped in
continue-on-error: trueand returnsfound=falseon any API error, so a lookup failure falls through to a fresh build.
.github/workflows/ci.ymlNew job
post-build-source-hashthat runs on every event (includingmerge_groupand fork PRs where builds are skipped) withpermissions: statuses: write. It guarantees the hash is published for everycommit, so future runs can always look back and find it.
.github/workflows/build-ios-e2e.ymlandbuild-android-e2e.ymlAfter the existing GHA cache restore steps and only if both missed, the
workflows invoke
find-reusable-buildand, on a match, callactions/download-artifact@v4withrun-id+github-tokento pull thenative shell from the source run directly into the expected build-output
path.
The fresh-build step is gated on
find-reusable-build.outputs.found != 'true'. The repack, source-map upload,and downstream upload steps include the reuse branch in their
if:conditions.
Both workflows also request
actions: readpermission, required forlistWorkflowRunsand cross-run artifact download.Artifact naming contract
The lookup must match exactly what the upload step produces. Current contract:
${build_type}-${metamask_environment}-MetaMask.app${build_type}-${metamask_environment}-release.apkAND${build_type}-${metamask_environment}-release-androidTest.apk(both must be present)Android requires both artifacts because E2E uses the test APK too. If one is
missing or expired, the candidate run is rejected and we keep searching.
Runtime decision table
JS bundle is always rebuilt. Only the native shell is reused.
Cross-PR behavior, explained
first build of the session. Pre-existing behavior, unchanged.
main: ifmainhas built the matching fingerprintrecently, tier 2 hits. Pre-existing behavior, unchanged.
artifacts, and posts
build-source-hash. PR B'sfind-reusable-builddiscovers PR A's run via the status + artifact check on the
mainfallbackscan, and downloads the artifact. Tier 3 — new.
The base-branch scan uses the workflow-file's default branch (
main). PRsagainst other release branches can still benefit because we scan the head
branch first.
Storage implications
cirruslabs/cache@...(auto-savevariant) writes one branch-scoped entry per successful run, same as before.
A cache write from a reuse-path run is byte-identical to a cache write from
a fresh-build run.
remains 7 days.
which substitutes for a ~20 min native compile.
max-candidates-per-branch(10) × 2 branches perbuild job. Negligible against the 5000 req/hr token budget.
Failure modes and fail-safes
yarn fingerprint:generatefailspost-build-source-hashjob fails (not fatal to builds)continue-on-error: trueswallows; no future lookup will matchlistWorkflowRuns/ status API returns an errorfind-reusable-buildoutputsfound=false, fresh build runshasAllArtifactsreturns false, candidate rejectedThere is no path in which reuse produces a stale build. The fingerprint covers
native inputs, so a hash match guarantees the native shell is a correct
function of the current source. JS is rebuilt unconditionally during repack.
Force a fresh build
Three mechanisms exist, in increasing blast radius:
1.
force-buildslabel or[force-builds]commit tag (per-PR, recommended)Use this when you suspect a reuse-correctness issue on a specific PR — for
example, one of the known gaps below is triggered, or you simply want to
verify a fresh build matches.
force-buildslabel to the PR, or[force-builds]in the head commit message (anywhere, casesensitive).
The .github/actions/check-force-builds/action.yml
composite action runs early in both build workflows and, when either
condition is met, skips:
main-scoped GHA cache restore, andfind-reusable-buildcross-run lookup.The
Build iOS E2E App/Build Android E2E APKsstep then runs as ifnothing was ever cached, producing a truly fresh native binary. Repack does
not run in this path because there is no pre-built shell to swap JS into.
Scope notes:
pull_requestevents. Inmerge_groupandpusheventsthe override is intentionally ignored so the merge queue always uses
hash-verified reuse (or fresh builds when no match exists).
reuse artifacts uploaded by this run. If you also want to prevent future
reuse of the output, use mechanism 3 below.
2.
[skip-e2e]commit tag orskip-e2elabelSkips E2E builds (and the downstream tests) entirely. See
.github/workflows/needs-e2e-build.yml.
Not a "force-build" per se — use this when you want to bypass E2E altogether.
3. Bump the cache-version env var (repo-wide, permanent)
Increment
IOS_APP_CACHE_VERSIONin.github/workflows/build-ios-e2e.yml
or
CACHE_GENERATIONin.github/workflows/build-android-e2e.yml.
This busts the GHA cache for every branch and forces a fresh compile
everywhere. Reuse lookups will still find prior artifacts against the old
fingerprint — if you want to bust those too, either retire the associated
commit statuses or wait up to 7 days for artifact expiry.
force-buildslabel / tag[skip-e2e]/skip-e2elabel*_CACHE_VERSIONenvObservability
Each reuse hit logs:
find-reusable-buildlogs every candidate it inspects and the reason itrejected non-matches (wrong hash, missing artifacts, wrong status). This
shows up in the Actions log for the build job.
The
build-source-hashcommit status itself is visible in the PR checkspanel, showing the hash value as its description.
Known fingerprint gaps
The fingerprint (via
@expo/fingerprint+ this repo'sfingerprint.config.js)covers the standard native inputs: contents of
ios/andandroid/, Expo andReact Native autolinking output,
app.json/app.config.*,patches/and.yarn/patches,react-native's ownpackage.json, and rootpackage.jsonscripts. For the vast majority of PRs this is sufficient — a native-affecting
dependency or patch change produces a different fingerprint, which invalidates
reuse; a pure-JS dependency change keeps the fingerprint stable and the JS
bundle is rebuilt unconditionally during repack.
Residual scenarios where reuse could theoretically produce a different binary
than a fresh build, and the fingerprint wouldn't notice:
GOOGLE_SERVICES_B64_*, API keys injected at Xcode/Gradle time). Thesearen't part of the fingerprint inputs. In practice they're managed per
environment and are stable for a given
${build_type}-${metamask_environment}artifact name, which is part of the cache key — but an in-place value change
(e.g., rotating a key) without a corresponding code change would be invisible
to the fingerprint.
CocoaPods version, Android SDK/NDK versions. Cirrus runners are pinned, but
image updates are out-of-band of the fingerprint.
node_modulesnatively without a corresponding change in
patches/,ios/, orandroid/. Rare, but possible.If you hit any of these or suspect one, use the
force-buildsescape hatch(above) to verify. If it becomes recurring, the fix is to extend
fingerprint.config.jsextraSourcesto include the relevant input.Non-goals (deferred)
[skip-builds]escape hatch for non-E2E builds.[reused from <sha>].this mechanism does not apply. A parallel path (path-filter skip, or a
dedicated
unit-tests-source-hashwith Jest-result artifacts) would beneeded there.
Validation
tests/**. Thesecond PR's build job should log
Reusing build from run <id>and skipthe native compile step.
ios/Podfileor
android/app/build.gradle. The fingerprint changes, no candidatematches, fresh build runs.
run. Subsequent lookups reject that candidate and either find another or
fall through to a fresh build.
Files
Repo-admin follow-up (one-time). The
force-buildslabel needs to exist in the repo before PRs can apply it; the commit-tag path works without setup:gh label create force-builds \ --description "Bypass native build reuse and compile fresh" \ --color d93f0bScope. E2E iOS + Android only. Non-goals left for later:
[skip-builds]broader escape hatch,[reused from <sha>]PR-comment annotation, RC / TestFlight / BrowserStack reuse, and unit-test reuse (different mechanism).Changelog
CHANGELOG entry: null
Related issues
Fixes: No issue — CI infrastructure enhancement (cross-PR native build reuse + force-builds escape hatch).
Manual testing steps
Screenshots/Recordings
Before
N/A — CI-only change.
After
N/A — CI-only change. Observability via job logs (
Reusing ... from run <id>,Posted build-source-hash=<hash>,force=true because ...).Pre-merge author checklist
Performance checks (if applicable)
trace()for usage andaddTokenfor an exampleFor performance guidelines and tooling, see the Performance Guide.
Pre-merge reviewer checklist
Note
Medium Risk
Changes CI build/reuse logic and required permissions for iOS/Android E2E artifact generation; misconfiguration could cause unexpected cache misses, wrong reuse, or flaky E2E runs (though it falls back to fresh builds on errors).
Overview
Enables cross-PR reuse of native iOS/Android E2E build outputs by publishing a canonical
@expo/fingerprintas abuild-source-hashcommit status and using it to locate/download matching artifacts from priorci.ymlruns when branch/main caches miss.Adds composite actions:
post-build-source-hash(compute fingerprint + post commit status),find-reusable-build(search recent runs across same branch, base branch, then allpull_requestruns; verify fingerprint status + required artifacts), andcheck-force-builds(bypass all reuse when PR hasforce-buildslabel or[force-builds]in head commit message).Updates
build-ios-e2e.yml/build-android-e2e.ymlto take asource-fingerprintinput (no per-runner recompute), gate cache restores/lookup on it, download reusable artifacts on a match, and expand repack/sourcemap conditions; updatesci.ymlto run the new status-publishing job and plumb its output/permissions into E2E build jobs. Also narrowsfingerprint.config.jsto stop hashing all.githubworkflows/scripts (to avoid invalidating reuse) while still tracking a few specific workflow files.Reviewed by Cursor Bugbot for commit 6dc1273. Bugbot is set up for automated code reviews on this repo. Configure here.