From b9476009066d8e2ba8604555adb9471d45057124 Mon Sep 17 00:00:00 2001 From: tommasini Date: Tue, 21 Apr 2026 21:46:31 +0100 Subject: [PATCH 01/35] reusable build e2e and force-builds label to force if it is failing --- .github/actions/check-force-builds/action.yml | 72 ++++++ .../actions/find-reusable-build/action.yml | 206 ++++++++++++++++++ .../actions/post-build-source-hash/action.yml | 58 +++++ .github/workflows/build-android-e2e.yml | 68 +++++- .github/workflows/build-ios-e2e.yml | 51 ++++- .github/workflows/ci.yml | 30 +++ 6 files changed, 476 insertions(+), 9 deletions(-) create mode 100644 .github/actions/check-force-builds/action.yml create mode 100644 .github/actions/find-reusable-build/action.yml create mode 100644 .github/actions/post-build-source-hash/action.yml diff --git a/.github/actions/check-force-builds/action.yml b/.github/actions/check-force-builds/action.yml new file mode 100644 index 00000000000..085db2eb4a9 --- /dev/null +++ b/.github/actions/check-force-builds/action.yml @@ -0,0 +1,72 @@ +name: 'Check force-builds override' +description: >- + Detects whether the current workflow run should bypass native build reuse + (both the GHA cache and the cross-run artifact lookup) and always compile + fresh. The override is honored on `pull_request` events via a `force-builds` + label OR a `[force-builds]` token in the head commit message. It is + intentionally ignored on `merge_group` and `push` events so the merge queue + always uses hash-verified reuse. + +inputs: + github-token: + description: 'GitHub token with `pull-requests: read` access (for label lookup)' + required: true + label-name: + description: 'PR label that, when present, forces fresh builds' + required: false + default: 'force-builds' + commit-tag: + description: 'Case-sensitive substring in the head commit message that forces fresh builds' + required: false + default: '[force-builds]' + +outputs: + force: + description: "'true' when fresh builds should be forced, otherwise 'false'" + value: ${{ steps.compute.outputs.force }} + +runs: + using: 'composite' + steps: + - name: Compute force-builds flag + id: compute + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + LABEL_NAME: ${{ inputs.label-name }} + COMMIT_TAG: ${{ inputs.commit-tag }} + EVENT_NAME: ${{ github.event_name }} + HEAD_COMMIT_HASH: ${{ github.event.pull_request.head.sha || github.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPOSITORY: ${{ github.repository }} + run: | + FORCE="false" + + if [[ "$EVENT_NAME" != "pull_request" ]]; then + echo "Event is $EVENT_NAME; force-builds override is ignored outside pull_request events." + echo "force=$FORCE" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Commit-message tag + if git show --format='%B' --no-patch "$HEAD_COMMIT_HASH" \ + | grep --fixed-strings --quiet "$COMMIT_TAG"; then + echo "-> force=true because '$COMMIT_TAG' was found in commit message of $HEAD_COMMIT_HASH" + FORCE="true" + fi + + # PR label + if [[ -n "$PR_NUMBER" ]]; then + if gh pr view "$PR_NUMBER" --repo "$REPOSITORY" \ + --json labels --jq '.labels[].name' \ + | grep -qx "$LABEL_NAME"; then + echo "-> force=true because '$LABEL_NAME' label is applied to PR #$PR_NUMBER" + FORCE="true" + fi + fi + + if [[ "$FORCE" == "false" ]]; then + echo "No force-builds override active." + fi + + echo "force=$FORCE" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/find-reusable-build/action.yml b/.github/actions/find-reusable-build/action.yml new file mode 100644 index 00000000000..2d97db896ec --- /dev/null +++ b/.github/actions/find-reusable-build/action.yml @@ -0,0 +1,206 @@ +name: 'Find reusable build from prior run' +description: >- + Searches recent workflow runs (same branch first, then base branch) for a run + whose `build-source-hash` commit status matches the current fingerprint AND + whose required build artifacts are still available. If a match is found, + outputs the run id so a subsequent `actions/download-artifact` step can pull + the artifacts directly instead of triggering a fresh native build. + +inputs: + fingerprint: + description: 'The @expo/fingerprint hash the candidate must match' + required: true + artifact-names: + description: 'JSON array of artifact names that must all be present on the candidate run' + required: true + github-token: + description: 'GitHub token with `actions: read` and `statuses: read` permissions' + required: true + workflow-file: + description: 'Workflow filename whose runs will be searched' + required: false + default: 'ci.yml' + base-branch: + description: 'Fallback branch when no same-branch match is found' + required: false + default: 'main' + status-context: + description: 'Commit status context that carries the fingerprint' + required: false + default: 'build-source-hash' + max-candidates-per-branch: + description: 'How many recent runs to inspect per branch' + required: false + default: '10' + +outputs: + found: + description: "'true' when a reusable run was found" + value: ${{ steps.lookup.outputs.found }} + run-id: + description: 'Workflow run id that produced the reusable artifacts' + value: ${{ steps.lookup.outputs.run-id }} + source-sha: + description: 'Commit SHA of the reusable run' + value: ${{ steps.lookup.outputs.source-sha }} + source-branch: + description: 'Branch of the reusable run (same-branch or base-branch)' + value: ${{ steps.lookup.outputs.source-branch }} + +runs: + using: 'composite' + steps: + - name: Search prior runs for matching fingerprint + id: lookup + uses: actions/github-script@v7 + continue-on-error: true + env: + TARGET_FINGERPRINT: ${{ inputs.fingerprint }} + ARTIFACT_NAMES_JSON: ${{ inputs.artifact-names }} + WORKFLOW_FILE: ${{ inputs.workflow-file }} + BASE_BRANCH: ${{ inputs.base-branch }} + STATUS_CONTEXT: ${{ inputs.status-context }} + MAX_CANDIDATES: ${{ inputs.max-candidates-per-branch }} + HEAD_BRANCH: ${{ github.head_ref || github.ref_name }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + CURRENT_RUN_ID: ${{ github.run_id }} + with: + github-token: ${{ inputs.github-token }} + script: | + const { + TARGET_FINGERPRINT, + ARTIFACT_NAMES_JSON, + WORKFLOW_FILE, + BASE_BRANCH, + STATUS_CONTEXT, + MAX_CANDIDATES, + HEAD_BRANCH, + HEAD_SHA, + CURRENT_RUN_ID, + } = process.env; + + const setNotFound = () => { + core.setOutput('found', 'false'); + core.setOutput('run-id', ''); + core.setOutput('source-sha', ''); + core.setOutput('source-branch', ''); + }; + + if (!TARGET_FINGERPRINT) { + core.warning('No fingerprint provided; skipping lookup'); + setNotFound(); + return; + } + + let requiredArtifacts; + try { + requiredArtifacts = JSON.parse(ARTIFACT_NAMES_JSON); + } catch (err) { + core.warning(`Could not parse artifact-names input: ${err.message}`); + setNotFound(); + return; + } + if (!Array.isArray(requiredArtifacts) || requiredArtifacts.length === 0) { + core.warning('artifact-names must be a non-empty JSON array'); + setNotFound(); + return; + } + + const maxCandidates = Number(MAX_CANDIDATES) || 10; + const currentRunId = String(CURRENT_RUN_ID); + + const branchesToSearch = [HEAD_BRANCH]; + if (BASE_BRANCH && BASE_BRANCH !== HEAD_BRANCH) { + branchesToSearch.push(BASE_BRANCH); + } + + async function getFingerprintForSha(sha) { + try { + const { data } = await github.rest.repos.getCombinedStatusForRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: sha, + per_page: 100, + }); + const status = data.statuses.find((s) => s.context === STATUS_CONTEXT); + return status ? status.description : null; + } catch (err) { + core.info(`getCombinedStatusForRef failed for ${sha}: ${err.message}`); + return null; + } + } + + async function hasAllArtifacts(runId) { + try { + const artifacts = await github.paginate( + github.rest.actions.listWorkflowRunArtifacts, + { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: runId, + per_page: 100, + }, + ); + const available = new Set( + artifacts + .filter((a) => !a.expired) + .map((a) => a.name), + ); + const missing = requiredArtifacts.filter((n) => !available.has(n)); + if (missing.length > 0) { + core.info(`Run ${runId} missing artifacts: ${missing.join(', ')}`); + return false; + } + return true; + } catch (err) { + core.info(`listWorkflowRunArtifacts failed for ${runId}: ${err.message}`); + return false; + } + } + + for (const branch of branchesToSearch) { + core.info(`Searching recent ${WORKFLOW_FILE} runs on branch=${branch}`); + let runs; + try { + const { data } = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: WORKFLOW_FILE, + branch, + per_page: maxCandidates, + }); + runs = data.workflow_runs || []; + } catch (err) { + core.warning(`listWorkflowRuns failed for ${branch}: ${err.message}`); + continue; + } + + for (const run of runs) { + if (String(run.id) === currentRunId) continue; + if (run.status !== 'completed' && run.status !== 'in_progress') continue; + if (run.status === 'completed' && run.conclusion && run.conclusion !== 'success' && run.conclusion !== 'neutral') { + continue; + } + if (run.head_sha === HEAD_SHA && String(run.id) !== currentRunId) { + // allow same-SHA reuse from other runs (e.g. earlier attempts) + } + + const fingerprint = await getFingerprintForSha(run.head_sha); + if (!fingerprint) continue; + if (fingerprint !== TARGET_FINGERPRINT) continue; + + if (!(await hasAllArtifacts(run.id))) continue; + + core.info( + `Match: run=${run.id} sha=${run.head_sha} branch=${branch} url=${run.html_url}`, + ); + core.setOutput('found', 'true'); + core.setOutput('run-id', String(run.id)); + core.setOutput('source-sha', run.head_sha); + core.setOutput('source-branch', branch); + return; + } + } + + core.info('No reusable build found'); + setNotFound(); diff --git a/.github/actions/post-build-source-hash/action.yml b/.github/actions/post-build-source-hash/action.yml new file mode 100644 index 00000000000..fa9c77b7e93 --- /dev/null +++ b/.github/actions/post-build-source-hash/action.yml @@ -0,0 +1,58 @@ +name: 'Post build-source-hash commit status' +description: >- + Computes the @expo/fingerprint hash via `yarn fingerprint:generate` and posts + it as a `build-source-hash` GitHub commit status on the current SHA. This + makes the fingerprint queryable by future workflow runs so they can locate a + prior run whose uploaded native build artifacts match the current source. + +inputs: + github-token: + description: 'GitHub token with `statuses: write` permission' + required: true + status-context: + description: 'GitHub commit status context name' + required: false + default: 'build-source-hash' + +outputs: + fingerprint: + description: 'The @expo/fingerprint hash that was posted' + value: ${{ steps.generate-fingerprint.outputs.fingerprint }} + +runs: + using: 'composite' + steps: + - name: Generate fingerprint + id: generate-fingerprint + shell: bash + run: | + FINGERPRINT=$(yarn fingerprint:generate) + echo "fingerprint=$FINGERPRINT" >> "$GITHUB_OUTPUT" + echo "Current fingerprint: ${FINGERPRINT}" + + - name: Post commit status + uses: actions/github-script@v7 + continue-on-error: true + env: + FINGERPRINT: ${{ steps.generate-fingerprint.outputs.fingerprint }} + STATUS_CONTEXT: ${{ inputs.status-context }} + TARGET_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + with: + github-token: ${{ inputs.github-token }} + script: | + const { FINGERPRINT, STATUS_CONTEXT, TARGET_SHA, RUN_URL } = process.env; + if (!FINGERPRINT) { + core.setFailed('Fingerprint is empty; refusing to post status'); + return; + } + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: TARGET_SHA, + state: 'success', + context: STATUS_CONTEXT, + description: FINGERPRINT.slice(0, 140), + target_url: RUN_URL, + }); + core.info(`Posted ${STATUS_CONTEXT}=${FINGERPRINT} on ${TARGET_SHA}`); diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 7bf9240834a..88ae5d6592e 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -26,6 +26,12 @@ on: default: 'qa' type: string +permissions: + contents: read + id-token: write + actions: read + pull-requests: read + jobs: build-android-apks: name: Build Android E2E APKs @@ -45,6 +51,16 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 + # Escape hatch: a `force-builds` label on the PR or a `[force-builds]` + # token in the head commit message forces a fresh native build and + # bypasses both the GHA cache restores and the cross-run artifact + # lookup. Honored only on `pull_request` events. + - name: Check force-builds override + id: force-builds + uses: ./.github/actions/check-force-builds + with: + github-token: ${{ github.token }} + - name: Setup Android Build Environment timeout-minutes: 15 uses: ./.github/actions/setup-e2e-env @@ -105,6 +121,7 @@ jobs: - name: Restore APKs matching fingerprint from branch cache id: apk-cache-restore + if: ${{ steps.force-builds.outputs.force != 'true' }} # This action automatically updates the cache at the end of the workflow uses: cirruslabs/cache@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 with: @@ -120,7 +137,7 @@ jobs: key: android-apk-${{ github.ref_name }}-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ steps.generate-fingerprint.outputs.fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - name: Restore APKs matching fingerprint from main cache - if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} + if: ${{ steps.force-builds.outputs.force != 'true' && steps.apk-cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} id: apk-cache-restore-main # This will only restore the cache, not update it uses: cirruslabs/cache/restore@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 @@ -136,11 +153,52 @@ jobs: # - "Restore Gradle dependencies from main cache" key: android-apk-main-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ steps.generate-fingerprint.outputs.fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + # Cross-PR reuse: before building, try to find a prior workflow run whose + # build-source-hash commit status matches our fingerprint AND whose + # APK + androidTest APK artifacts are still available. Falls back to a + # fresh build when no match is found. + - name: Find reusable build from prior run + id: find-reusable-build + if: ${{ steps.force-builds.outputs.force != 'true' && steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true' }} + uses: ./.github/actions/find-reusable-build + with: + fingerprint: ${{ steps.generate-fingerprint.outputs.fingerprint }} + artifact-names: '["${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.apk","${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release-androidTest.apk"]' + github-token: ${{ github.token }} + + - name: Download reusable APK from prior run + if: ${{ steps.find-reusable-build.outputs.found == 'true' }} + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.apk + path: ${{ steps.determine-target-paths.outputs.apk-target-path }} + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ steps.find-reusable-build.outputs.run-id }} + + - name: Download reusable androidTest APK from prior run + if: ${{ steps.find-reusable-build.outputs.found == 'true' }} + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release-androidTest.apk + path: ${{ steps.determine-target-paths.outputs.test-apk-target-path }} + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ steps.find-reusable-build.outputs.run-id }} + + - name: Log reused Android build source + if: ${{ steps.find-reusable-build.outputs.found == 'true' }} + run: | + echo "Reusing Android build from run ${{ steps.find-reusable-build.outputs.run-id }}" + echo "Source SHA: ${{ steps.find-reusable-build.outputs.source-sha }}" + echo "Source branch: ${{ steps.find-reusable-build.outputs.source-branch }}" + shell: bash + - name: Restore Gradle dependencies from branch cache id: gradle-cache-restore # This action automatically updates the cache at the end of the workflow uses: cirruslabs/cache@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 - if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true' }} + if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true' && steps.find-reusable-build.outputs.found != 'true' }} env: GRADLE_CACHE_VERSION: 1 with: @@ -158,7 +216,7 @@ jobs: - name: Restore Gradle dependencies from main cache # This will only restore the cache, not update it uses: cirruslabs/cache/restore@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 - if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true' && steps.gradle-cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} + if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true' && steps.find-reusable-build.outputs.found != 'true' && steps.gradle-cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} env: GRADLE_CACHE_VERSION: 1 with: @@ -174,7 +232,7 @@ jobs: key: gradle-main-${{ env.GRADLE_CACHE_VERSION }}-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - name: Build Android E2E APKs - if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true' }} + if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true' && steps.find-reusable-build.outputs.found != 'true' }} run: | echo "🏗 Building Android E2E APKs..." export NODE_OPTIONS="--max-old-space-size=4096" @@ -227,7 +285,7 @@ jobs: MM_PREDICT_GTM_MODAL_ENABLED: 'false' - name: Repack APK with JS updates using @expo/repack-app - if: ${{ steps.apk-cache-restore.outputs.cache-hit == 'true' || steps.apk-cache-restore-main.outputs.cache-hit == 'true' }} + if: ${{ steps.apk-cache-restore.outputs.cache-hit == 'true' || steps.apk-cache-restore-main.outputs.cache-hit == 'true' || steps.find-reusable-build.outputs.found == 'true' }} run: | echo "📦 Repacking APK with updated JavaScript bundle using @expo/repack-app..." # Use the optimized repack script which uses @expo/repack-app diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index 73749347a4e..99fb7b85c11 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -21,6 +21,8 @@ on: permissions: contents: read id-token: write + actions: read + pull-requests: read jobs: build-ios-apps: @@ -75,6 +77,16 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 + # Escape hatch: a `force-builds` label on the PR or a `[force-builds]` + # token in the head commit message forces a fresh native build and + # bypasses both the GHA cache restores and the cross-run artifact + # lookup. Honored only on `pull_request` events. + - name: Check force-builds override + id: force-builds + uses: ./.github/actions/check-force-builds + with: + github-token: ${{ github.token }} + - name: Restore Xcode derived data from branch cache id: xcode-restore-cache # This action automatically updates the cache at the end of the workflow @@ -153,6 +165,7 @@ jobs: - name: Restore iOS app matching fingerprint from branch cache id: cache-restore + if: ${{ steps.force-builds.outputs.force != 'true' }} # This action automatically updates the cache at the end of the workflow uses: cirruslabs/cache@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 with: @@ -161,7 +174,7 @@ jobs: key: ios-app-${{ github.ref_name }}-v${{ env.IOS_APP_CACHE_VERSION }}-${{ steps.generate-fingerprint.outputs.fingerprint }} - name: Restore iOS app matching fingerprint from main cache - if: ${{ steps.cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} + if: ${{ steps.force-builds.outputs.force != 'true' && steps.cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} id: cache-restore-main # This will only restore the cache, not update it uses: cirruslabs/cache/restore@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 @@ -170,9 +183,39 @@ jobs: ios/build/Build/Products/Release-iphonesimulator/MetaMask.app key: ios-app-main-v${{ env.IOS_APP_CACHE_VERSION }}-${{ steps.generate-fingerprint.outputs.fingerprint }} + # Cross-PR reuse: before building, try to find a prior workflow run whose + # build-source-hash commit status matches our fingerprint AND whose + # MetaMask.app artifact is still available. Falls back to a fresh build. + - name: Find reusable build from prior run + id: find-reusable-build + if: ${{ steps.force-builds.outputs.force != 'true' && steps.cache-restore.outputs.cache-hit != 'true' && steps.cache-restore-main.outputs.cache-hit != 'true' }} + uses: ./.github/actions/find-reusable-build + with: + fingerprint: ${{ steps.generate-fingerprint.outputs.fingerprint }} + artifact-names: '["${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app"]' + github-token: ${{ github.token }} + + - name: Download reusable iOS build from prior run + if: ${{ steps.find-reusable-build.outputs.found == 'true' }} + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app + path: ios/build/Build/Products/Release-iphonesimulator/MetaMask.app + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ steps.find-reusable-build.outputs.run-id }} + + - name: Log reused iOS build source + if: ${{ steps.find-reusable-build.outputs.found == 'true' }} + run: | + echo "Reusing iOS build from run ${{ steps.find-reusable-build.outputs.run-id }}" + echo "Source SHA: ${{ steps.find-reusable-build.outputs.source-sha }}" + echo "Source branch: ${{ steps.find-reusable-build.outputs.source-branch }}" + shell: bash + # Build the iOS E2E app for simulator - name: Build iOS E2E App - if: ${{ steps.cache-restore.outputs.cache-hit != 'true' && steps.cache-restore-main.outputs.cache-hit != 'true' }} + if: ${{ steps.cache-restore.outputs.cache-hit != 'true' && steps.cache-restore-main.outputs.cache-hit != 'true' && steps.find-reusable-build.outputs.found != 'true' }} run: | echo "🏗 Building iOS E2E App..." export NODE_OPTIONS="--max-old-space-size=8192" @@ -207,7 +250,7 @@ jobs: GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} - name: Repack iOS app with JS updates using @expo/repack-app - if: ${{ steps.cache-restore.outputs.cache-hit == 'true' || steps.cache-restore-main.outputs.cache-hit == 'true' }} + if: ${{ steps.cache-restore.outputs.cache-hit == 'true' || steps.cache-restore-main.outputs.cache-hit == 'true' || steps.find-reusable-build.outputs.found == 'true' }} run: | echo "📦 Repacking iOS app with updated JavaScript bundle using @expo/repack-app..." # Use the optimized repack script which uses @expo/repack-app @@ -287,7 +330,7 @@ jobs: # Only runs when repack step runs (cache hit), as that's when sourcemap is generated - name: Upload iOS Source Map id: upload-sourcemap - if: ${{ steps.cache-restore.outputs.cache-hit == 'true' || steps.cache-restore-main.outputs.cache-hit == 'true' }} + if: ${{ steps.cache-restore.outputs.cache-hit == 'true' || steps.cache-restore-main.outputs.cache-hit == 'true' || steps.find-reusable-build.outputs.found == 'true' }} uses: actions/upload-artifact@v4 with: name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-index.js.map diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9aafb3ca68d..829ab5ba49b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,36 @@ jobs: echo "No changes detected" fi + post-build-source-hash: + name: Post build-source-hash commit status + runs-on: ubuntu-latest + # Always publish the commit status (even for merge_group or when builds are + # skipped) so future runs can still find this commit when searching for + # reusable native builds. + if: ${{ !github.event.pull_request.head.repo.fork }} + permissions: + contents: read + statuses: write + needs: + - check-skip-merge-queue + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: yarn + - name: Install Yarn dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn install --immutable + - name: Post build-source-hash commit status + uses: ./.github/actions/post-build-source-hash + with: + github-token: ${{ github.token }} + dedupe: name: Dedupe runs-on: ubuntu-latest From 7276cfa8df6957a855fbcb9f20071dee3159cf22 Mon Sep 17 00:00:00 2001 From: tommasini Date: Wed, 22 Apr 2026 11:33:32 +0100 Subject: [PATCH 02/35] fix permissions --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbd7925f5ed..1219e7d9ab0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -576,6 +576,8 @@ jobs: permissions: contents: read id-token: write + actions: read + pull-requests: read needs: [needs_e2e_build, smart-e2e-selection] uses: ./.github/workflows/build-android-e2e.yml with: @@ -613,6 +615,8 @@ jobs: permissions: contents: read id-token: write + actions: read + pull-requests: read needs: [needs_e2e_build, smart-e2e-selection] uses: ./.github/workflows/build-ios-e2e.yml secrets: inherit From c4e511a618ddd2fa24ae5266d592ee8305a15d9c Mon Sep 17 00:00:00 2001 From: tommasini Date: Wed, 22 Apr 2026 12:05:35 +0100 Subject: [PATCH 03/35] empty commit to test reusable e2e builds From 21c39e4e7c8b4552e33f7a5fc3ddd5a1c96c8f80 Mon Sep 17 00:00:00 2001 From: tommasini Date: Wed, 22 Apr 2026 14:27:44 +0100 Subject: [PATCH 04/35] minimize fingerprint changes detection; fix fingerprint missmatch for prs and cross prs --- .github/workflows/build-android-e2e.yml | 41 +++++++++++++++++-------- .github/workflows/build-ios-e2e.yml | 41 +++++++++++++++++-------- .github/workflows/ci.yml | 12 ++++++-- fingerprint.config.js | 28 +++++++++++++---- 4 files changed, 90 insertions(+), 32 deletions(-) diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 88ae5d6592e..352310f798c 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -25,6 +25,15 @@ on: required: false default: 'qa' type: string + source-fingerprint: + description: >- + Canonical @expo/fingerprint hash for this commit, computed once in + the `post-build-source-hash` job. Used for cache keys and cross-run + artifact lookups. When empty (e.g. forked PR where the hash job is + skipped), the build compiles fresh with no cache reuse. + required: false + default: '' + type: string permissions: contents: read @@ -91,13 +100,21 @@ jobs: echo "🚀 Setting up project..." yarn setup:github-ci --no-build-ios - # Generate fingerprint AFTER setup but BEFORE any build modifications (the fingerprint now is fake we do not want the cached apk) - - name: Generate current fingerprint - id: generate-fingerprint + # Use the canonical fingerprint computed upstream by `post-build-source-hash`. + # Do NOT recompute it here — Cirrus ubuntu `node_modules/` differs from + # ubuntu-latest `node_modules/` (different Node/yarn versions, prebuilt + # binaries), so recomputing per-runner would give a different value than + # the one posted as the `build-source-hash` commit status and break + # cross-run artifact reuse. + - name: Report source fingerprint run: | - FINGERPRINT=$(yarn fingerprint:generate) - echo "fingerprint=$FINGERPRINT" >> "$GITHUB_OUTPUT" - echo "Current fingerprint: ${FINGERPRINT}" + if [[ -z "$SOURCE_FINGERPRINT" ]]; then + echo "::warning::No source-fingerprint provided (likely a forked PR); caches and reuse disabled." + else + echo "Source fingerprint: $SOURCE_FINGERPRINT" + fi + env: + SOURCE_FINGERPRINT: ${{ inputs.source-fingerprint }} - name: Determine target paths and Artifact Names id: determine-target-paths @@ -121,7 +138,7 @@ jobs: - name: Restore APKs matching fingerprint from branch cache id: apk-cache-restore - if: ${{ steps.force-builds.outputs.force != 'true' }} + if: ${{ steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' }} # This action automatically updates the cache at the end of the workflow uses: cirruslabs/cache@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 with: @@ -134,10 +151,10 @@ jobs: # - "Restore APKs matching fingerprint from main cache" # - "Restore Gradle dependencies from branch cache" # - "Restore Gradle dependencies from main cache" - key: android-apk-${{ github.ref_name }}-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ steps.generate-fingerprint.outputs.fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + key: android-apk-${{ github.ref_name }}-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ inputs.source-fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - name: Restore APKs matching fingerprint from main cache - if: ${{ steps.force-builds.outputs.force != 'true' && steps.apk-cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} + if: ${{ steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' && steps.apk-cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} id: apk-cache-restore-main # This will only restore the cache, not update it uses: cirruslabs/cache/restore@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 @@ -151,7 +168,7 @@ jobs: # - "Restore APKs matching fingerprint from main cache" # - "Restore Gradle dependencies from branch cache" # - "Restore Gradle dependencies from main cache" - key: android-apk-main-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ steps.generate-fingerprint.outputs.fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + key: android-apk-main-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ inputs.source-fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} # Cross-PR reuse: before building, try to find a prior workflow run whose # build-source-hash commit status matches our fingerprint AND whose @@ -159,10 +176,10 @@ jobs: # fresh build when no match is found. - name: Find reusable build from prior run id: find-reusable-build - if: ${{ steps.force-builds.outputs.force != 'true' && steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true' }} + if: ${{ steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' && steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true' }} uses: ./.github/actions/find-reusable-build with: - fingerprint: ${{ steps.generate-fingerprint.outputs.fingerprint }} + fingerprint: ${{ inputs.source-fingerprint }} artifact-names: '["${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.apk","${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release-androidTest.apk"]' github-token: ${{ github.token }} diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index 99fb7b85c11..c4af581a9cf 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -17,6 +17,15 @@ on: required: false default: 'qa' type: string + source-fingerprint: + description: >- + Canonical @expo/fingerprint hash for this commit, computed once in + the `post-build-source-hash` job. Used for cache keys and cross-run + artifact lookups. When empty (e.g. forked PR where the hash job is + skipped), the build compiles fresh with no cache reuse. + required: false + default: '' + type: string permissions: contents: read @@ -155,43 +164,51 @@ jobs: echo "🚀 Setting up project..." yarn setup:github-ci --build-ios --no-build-android - # Generate fingerprint AFTER setup but BEFORE any build modifications - - name: Generate current fingerprint - id: generate-fingerprint + # Use the canonical fingerprint computed upstream by `post-build-source-hash`. + # Do NOT recompute it here — macOS `node_modules/` differs from Linux + # `node_modules/` in ways @expo/fingerprint hashes (prebuilt binaries, + # platform-conditional deps), so recomputing per-runner would give a + # different value than the one posted as the `build-source-hash` commit + # status and break cross-run artifact reuse. + - name: Report source fingerprint run: | - FINGERPRINT=$(yarn fingerprint:generate) - echo "fingerprint=$FINGERPRINT" >> "$GITHUB_OUTPUT" - echo "Current fingerprint: ${FINGERPRINT}" + if [[ -z "$SOURCE_FINGERPRINT" ]]; then + echo "::warning::No source-fingerprint provided (likely a forked PR); caches and reuse disabled." + else + echo "Source fingerprint: $SOURCE_FINGERPRINT" + fi + env: + SOURCE_FINGERPRINT: ${{ inputs.source-fingerprint }} - name: Restore iOS app matching fingerprint from branch cache id: cache-restore - if: ${{ steps.force-builds.outputs.force != 'true' }} + if: ${{ steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' }} # This action automatically updates the cache at the end of the workflow uses: cirruslabs/cache@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 with: path: | ios/build/Build/Products/Release-iphonesimulator/MetaMask.app - key: ios-app-${{ github.ref_name }}-v${{ env.IOS_APP_CACHE_VERSION }}-${{ steps.generate-fingerprint.outputs.fingerprint }} + key: ios-app-${{ github.ref_name }}-v${{ env.IOS_APP_CACHE_VERSION }}-${{ inputs.source-fingerprint }} - name: Restore iOS app matching fingerprint from main cache - if: ${{ steps.force-builds.outputs.force != 'true' && steps.cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} + if: ${{ steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' && steps.cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} id: cache-restore-main # This will only restore the cache, not update it uses: cirruslabs/cache/restore@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 with: path: | ios/build/Build/Products/Release-iphonesimulator/MetaMask.app - key: ios-app-main-v${{ env.IOS_APP_CACHE_VERSION }}-${{ steps.generate-fingerprint.outputs.fingerprint }} + key: ios-app-main-v${{ env.IOS_APP_CACHE_VERSION }}-${{ inputs.source-fingerprint }} # Cross-PR reuse: before building, try to find a prior workflow run whose # build-source-hash commit status matches our fingerprint AND whose # MetaMask.app artifact is still available. Falls back to a fresh build. - name: Find reusable build from prior run id: find-reusable-build - if: ${{ steps.force-builds.outputs.force != 'true' && steps.cache-restore.outputs.cache-hit != 'true' && steps.cache-restore-main.outputs.cache-hit != 'true' }} + if: ${{ steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' && steps.cache-restore.outputs.cache-hit != 'true' && steps.cache-restore-main.outputs.cache-hit != 'true' }} uses: ./.github/actions/find-reusable-build with: - fingerprint: ${{ steps.generate-fingerprint.outputs.fingerprint }} + fingerprint: ${{ inputs.source-fingerprint }} artifact-names: '["${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app"]' github-token: ${{ github.token }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1219e7d9ab0..dbbfb221d0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,8 +90,12 @@ jobs: statuses: write needs: - check-skip-merge-queue + outputs: + fingerprint: ${{ steps.publish.outputs.fingerprint }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -104,6 +108,7 @@ jobs: retry_wait_seconds: 30 command: yarn install --immutable - name: Post build-source-hash commit status + id: publish uses: ./.github/actions/post-build-source-hash with: github-token: ${{ github.token }} @@ -578,12 +583,13 @@ jobs: id-token: write actions: read pull-requests: read - needs: [needs_e2e_build, smart-e2e-selection] + needs: [needs_e2e_build, smart-e2e-selection, post-build-source-hash] uses: ./.github/workflows/build-android-e2e.yml with: build_type: 'main' metamask_environment: 'e2e' keystore_target: 'qa' + source-fingerprint: ${{ needs.post-build-source-hash.outputs.fingerprint }} secrets: inherit e2e-smoke-tests-android: @@ -617,8 +623,10 @@ jobs: id-token: write actions: read pull-requests: read - needs: [needs_e2e_build, smart-e2e-selection] + needs: [needs_e2e_build, smart-e2e-selection, post-build-source-hash] uses: ./.github/workflows/build-ios-e2e.yml + with: + source-fingerprint: ${{ needs.post-build-source-hash.outputs.fingerprint }} secrets: inherit ios-tests-ready: diff --git a/fingerprint.config.js b/fingerprint.config.js index 2b50b90fa94..b4c3da2aadc 100644 --- a/fingerprint.config.js +++ b/fingerprint.config.js @@ -8,6 +8,17 @@ const config = { /** * Track files and directories under `extraSources` if they affect native code changes. + * + * Intentionally NOT tracked: + * - `.github/workflows/**` and `.github/scripts/**` — these orchestrate CI + * but do not change what `scripts/build.sh` / native toolchains compile + * into the artifacts. Including them would invalidate the fingerprint on + * every unrelated CI workflow edit on `main`, which currently breaks + * build caching and cross-PR artifact reuse (every open PR's merge ref + * picks up those edits). The files that actually drive the native build + * (`scripts/build.sh`, `scripts/setup.mjs`, `react-native.config.js`, + * native dirs, `package.json`/`yarn.lock`, and `.yarn/patches`) are + * tracked explicitly below or by `@expo/fingerprint` defaults. */ extraSources: [ { @@ -16,14 +27,19 @@ const config = { reasons: ['Detect yarn patch changes.'], }, { - type: 'dir', - filePath: '.github/workflows', - reasons: ['Detect Github workflow changes.'], + type: 'file', + filePath: '.github/workflows/push-eas-update.yml', + reasons: ['Detect OTA update workflow changes.'], }, { - type: 'dir', - filePath: '.github/scripts', - reasons: ['Detect Github workflow script changes.'], + type: 'file', + filePath: '.github/workflows/runway-ota-build-core.yml', + reasons: ['Detect Runway OTA build workflow changes.'], + }, + { + type: 'file', + filePath: '.github/workflows/build.yml', + reasons: ['Detect build workflow changes.'], }, { type: 'file', From 1dc79bb6c9a62ee5b836088ab01bfe521f6afd51 Mon Sep 17 00:00:00 2001 From: tommasini Date: Wed, 22 Apr 2026 14:56:27 +0100 Subject: [PATCH 05/35] address cursor bug bots --- .github/actions/check-force-builds/action.yml | 21 +++++++++++++------ .github/workflows/ci.yml | 10 +++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/.github/actions/check-force-builds/action.yml b/.github/actions/check-force-builds/action.yml index 085db2eb4a9..68102dfc281 100644 --- a/.github/actions/check-force-builds/action.yml +++ b/.github/actions/check-force-builds/action.yml @@ -9,7 +9,9 @@ description: >- inputs: github-token: - description: 'GitHub token with `pull-requests: read` access (for label lookup)' + description: >- + GitHub token with `pull-requests: read` (for label lookup) and + `contents: read` (to fetch the head commit message via the REST API). required: true label-name: description: 'PR label that, when present, forces fresh builds' @@ -48,11 +50,18 @@ runs: exit 0 fi - # Commit-message tag - if git show --format='%B' --no-patch "$HEAD_COMMIT_HASH" \ - | grep --fixed-strings --quiet "$COMMIT_TAG"; then - echo "-> force=true because '$COMMIT_TAG' was found in commit message of $HEAD_COMMIT_HASH" - FORCE="true" + # Commit-message tag. + COMMIT_MESSAGE="" + if COMMIT_MESSAGE=$(gh api \ + "repos/$REPOSITORY/commits/$HEAD_COMMIT_HASH" \ + --jq '.commit.message' 2>/dev/null); then + if printf '%s' "$COMMIT_MESSAGE" \ + | grep --fixed-strings --quiet "$COMMIT_TAG"; then + echo "-> force=true because '$COMMIT_TAG' was found in commit message of $HEAD_COMMIT_HASH" + FORCE="true" + fi + else + echo "::warning::Failed to fetch commit message for $HEAD_COMMIT_HASH via GitHub API; commit-tag force-builds check skipped for this run (the '$LABEL_NAME' label path still works)." fi # PR label diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbbfb221d0d..c311c5c2288 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -573,6 +573,9 @@ jobs: name: 'Build Android APKs' if: >- ${{ + !cancelled() && + needs.needs_e2e_build.result == 'success' && + needs.smart-e2e-selection.result == 'success' && github.event_name != 'merge_group' && !github.event.pull_request.head.repo.fork && (needs.needs_e2e_build.outputs.android_changed == 'true' || needs.smart-e2e-selection.outputs.force_run == 'true') && @@ -611,8 +614,15 @@ jobs: build-ios-apps: name: 'Build iOS Apps' + # See `build-android-apks` above for why `!cancelled()` + explicit + # `result == 'success'` checks are used here: a failure/skip of + # `post-build-source-hash` must NOT block the E2E build — it only + # disables cross-run cache reuse. if: >- ${{ + !cancelled() && + needs.needs_e2e_build.result == 'success' && + needs.smart-e2e-selection.result == 'success' && github.event_name != 'merge_group' && !github.event.pull_request.head.repo.fork && (needs.needs_e2e_build.outputs.ios_changed == 'true' || needs.smart-e2e-selection.outputs.force_run == 'true') && From d669669fc40a3682b9a85127ebb6b02ccc5d38e3 Mon Sep 17 00:00:00 2001 From: tommasini Date: Wed, 22 Apr 2026 15:33:10 +0100 Subject: [PATCH 06/35] fix cursor bug bot --- .../actions/find-reusable-build/action.yml | 100 ++++++++++++++---- .github/workflows/build-android-e2e.yml | 5 + .github/workflows/build-ios-e2e.yml | 1 + .github/workflows/ci.yml | 2 + 4 files changed, 86 insertions(+), 22 deletions(-) diff --git a/.github/actions/find-reusable-build/action.yml b/.github/actions/find-reusable-build/action.yml index 2d97db896ec..65766ac7374 100644 --- a/.github/actions/find-reusable-build/action.yml +++ b/.github/actions/find-reusable-build/action.yml @@ -1,10 +1,18 @@ name: 'Find reusable build from prior run' description: >- - Searches recent workflow runs (same branch first, then base branch) for a run - whose `build-source-hash` commit status matches the current fingerprint AND - whose required build artifacts are still available. If a match is found, - outputs the run id so a subsequent `actions/download-artifact` step can pull - the artifacts directly instead of triggering a fresh native build. + Searches recent workflow runs across three tiers (same branch, base branch, + then any open PR branch) for a run whose `build-source-hash` commit status + matches the current fingerprint AND whose required build artifacts are still + available. If a match is found, outputs the run id so a subsequent + `actions/download-artifact` step can pull the artifacts directly instead of + triggering a fresh native build. + + The third (cross-PR) tier is required because GitHub's `listWorkflowRuns` + `branch` parameter filters against `head_branch` — the PR source branch for + `pull_request` events — so branch-scoped lookups can never discover other + PRs' runs. The cross-PR tier drops the branch filter and instead uses + `event: pull_request` to let the fingerprint itself act as the cross-PR + deduplication key. inputs: fingerprint: @@ -29,9 +37,17 @@ inputs: required: false default: 'build-source-hash' max-candidates-per-branch: - description: 'How many recent runs to inspect per branch' + description: 'How many recent runs to inspect per branch-scoped tier (same-branch, base-branch)' required: false default: '10' + max-candidates-cross-pr: + description: >- + How many recent `pull_request`-event runs (across all branches) to inspect + in the cross-PR tier. The fingerprint filter is highly discriminating, so + the practical cost is one `getCombinedStatusForRef` call per candidate + until a match is found. + required: false + default: '30' outputs: found: @@ -61,6 +77,7 @@ runs: BASE_BRANCH: ${{ inputs.base-branch }} STATUS_CONTEXT: ${{ inputs.status-context }} MAX_CANDIDATES: ${{ inputs.max-candidates-per-branch }} + MAX_CANDIDATES_CROSS_PR: ${{ inputs.max-candidates-cross-pr }} HEAD_BRANCH: ${{ github.head_ref || github.ref_name }} HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} CURRENT_RUN_ID: ${{ github.run_id }} @@ -74,6 +91,7 @@ runs: BASE_BRANCH, STATUS_CONTEXT, MAX_CANDIDATES, + MAX_CANDIDATES_CROSS_PR, HEAD_BRANCH, HEAD_SHA, CURRENT_RUN_ID, @@ -107,12 +125,42 @@ runs: } const maxCandidates = Number(MAX_CANDIDATES) || 10; + const maxCandidatesCrossPr = Number(MAX_CANDIDATES_CROSS_PR) || 30; const currentRunId = String(CURRENT_RUN_ID); - const branchesToSearch = [HEAD_BRANCH]; + // Three-tier discovery: + // 1. same-branch — fastest path, catches retries and new commits + // on the current PR. + // 2. base-branch — catches post-merge CI runs on `main`. Only + // matches `push`-event runs (pull_request runs + // have head_branch=, not main). + // 3. cross-pr — searches recent `pull_request` runs across + // ALL source branches so two unrelated PRs with + // the same fingerprint can reuse each other's + // artifacts. This tier deliberately drops the + // `branch` filter; without it, branch-scoped + // lookups can never discover another PR's run + // (GitHub filters `branch` against head_branch, + // which is the PR source branch). + const tiers = [ + { + label: `same-branch (branch=${HEAD_BRANCH})`, + params: { branch: HEAD_BRANCH, per_page: maxCandidates }, + }, + ]; if (BASE_BRANCH && BASE_BRANCH !== HEAD_BRANCH) { - branchesToSearch.push(BASE_BRANCH); + tiers.push({ + label: `base-branch (branch=${BASE_BRANCH})`, + params: { branch: BASE_BRANCH, per_page: maxCandidates }, + }); } + tiers.push({ + label: `cross-pr (event=pull_request, any branch, last ${maxCandidatesCrossPr} runs)`, + params: { event: 'pull_request', per_page: maxCandidatesCrossPr }, + // Skip runs already visited by the same-branch tier to avoid + // wasting API calls on duplicates. + skipHeadBranch: HEAD_BRANCH, + }); async function getFingerprintForSha(sha) { try { @@ -158,32 +206,40 @@ runs: } } - for (const branch of branchesToSearch) { - core.info(`Searching recent ${WORKFLOW_FILE} runs on branch=${branch}`); + const seenRunIds = new Set(); + seenRunIds.add(currentRunId); + + for (const tier of tiers) { + core.info(`Searching tier: ${tier.label}`); let runs; try { const { data } = await github.rest.actions.listWorkflowRuns({ owner: context.repo.owner, repo: context.repo.repo, workflow_id: WORKFLOW_FILE, - branch, - per_page: maxCandidates, + ...tier.params, }); runs = data.workflow_runs || []; } catch (err) { - core.warning(`listWorkflowRuns failed for ${branch}: ${err.message}`); + core.warning(`listWorkflowRuns failed for tier "${tier.label}": ${err.message}`); continue; } for (const run of runs) { - if (String(run.id) === currentRunId) continue; + const runIdStr = String(run.id); + if (seenRunIds.has(runIdStr)) continue; + seenRunIds.add(runIdStr); + + if (tier.skipHeadBranch && run.head_branch === tier.skipHeadBranch) continue; if (run.status !== 'completed' && run.status !== 'in_progress') continue; - if (run.status === 'completed' && run.conclusion && run.conclusion !== 'success' && run.conclusion !== 'neutral') { + if ( + run.status === 'completed' && + run.conclusion && + run.conclusion !== 'success' && + run.conclusion !== 'neutral' + ) { continue; } - if (run.head_sha === HEAD_SHA && String(run.id) !== currentRunId) { - // allow same-SHA reuse from other runs (e.g. earlier attempts) - } const fingerprint = await getFingerprintForSha(run.head_sha); if (!fingerprint) continue; @@ -192,15 +248,15 @@ runs: if (!(await hasAllArtifacts(run.id))) continue; core.info( - `Match: run=${run.id} sha=${run.head_sha} branch=${branch} url=${run.html_url}`, + `Match: tier="${tier.label}" run=${run.id} sha=${run.head_sha} branch=${run.head_branch} url=${run.html_url}`, ); core.setOutput('found', 'true'); - core.setOutput('run-id', String(run.id)); + core.setOutput('run-id', runIdStr); core.setOutput('source-sha', run.head_sha); - core.setOutput('source-branch', branch); + core.setOutput('source-branch', run.head_branch || ''); return; } } - core.info('No reusable build found'); + core.info('No reusable build found across any tier'); setNotFound(); diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 352310f798c..dd26cb6a134 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -40,6 +40,11 @@ permissions: id-token: write actions: read pull-requests: read + # Required by `find-reusable-build` so `getCombinedStatusForRef` can read + # the `build-source-hash` commit status on candidate runs. On a public + # repo unauthenticated reads already work, but declaring this explicitly + # future-proofs us against visibility/token-scope changes. + statuses: read jobs: build-android-apks: diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index c4af581a9cf..cc9457c086b 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -32,6 +32,7 @@ permissions: id-token: write actions: read pull-requests: read + statuses: read jobs: build-ios-apps: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c311c5c2288..f760533770a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -586,6 +586,7 @@ jobs: id-token: write actions: read pull-requests: read + statuses: read needs: [needs_e2e_build, smart-e2e-selection, post-build-source-hash] uses: ./.github/workflows/build-android-e2e.yml with: @@ -633,6 +634,7 @@ jobs: id-token: write actions: read pull-requests: read + statuses: read needs: [needs_e2e_build, smart-e2e-selection, post-build-source-hash] uses: ./.github/workflows/build-ios-e2e.yml with: From 6dc1273ad1856d9af67db8ad2bac6082bea626a4 Mon Sep 17 00:00:00 2001 From: tommasini Date: Wed, 22 Apr 2026 21:51:43 +0100 Subject: [PATCH 07/35] test cache From a0b8627b344d5da229592efb93620a95874c8787 Mon Sep 17 00:00:00 2001 From: tommasini Date: Wed, 22 Apr 2026 23:28:58 +0100 Subject: [PATCH 08/35] v2 --- .github/workflows/build-android-e2e.yml | 197 +++++++++++++++++---- .github/workflows/build-ios-e2e.yml | 220 +++++++++++++++++------- 2 files changed, 318 insertions(+), 99 deletions(-) diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index dd26cb6a134..3ab19cfba15 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -40,10 +40,6 @@ permissions: id-token: write actions: read pull-requests: read - # Required by `find-reusable-build` so `getCombinedStatusForRef` can read - # the `build-source-hash` commit status on candidate runs. On a public - # repo unauthenticated reads already work, but declaring this explicitly - # future-proofs us against visibility/token-scope changes. statuses: read jobs: @@ -65,6 +61,14 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 + # ------------------------------------------------------------------------- + # Fast-path: probe branch cache, main cache, and cross-run artifacts BEFORE + # any heavy native setup. These steps only need `inputs.source-fingerprint` + # and `github.token`, so we can short-circuit setup-e2e-env (Android SDK + + # AVD + Java + emulator deps) and `yarn setup:github-ci --no-build-ios` + # when a compatible APK + androidTest APK already exist. + # ------------------------------------------------------------------------- + # Escape hatch: a `force-builds` label on the PR or a `[force-builds]` # token in the head commit message forces a fresh native build and # bypasses both the GHA cache restores and the cross-run artifact @@ -75,36 +79,6 @@ jobs: with: github-token: ${{ github.token }} - - name: Setup Android Build Environment - timeout-minutes: 15 - uses: ./.github/actions/setup-e2e-env - with: - platform: android - setup-simulator: false - configure-keystores: true - android-api-level: 36 - target: ${{ inputs.keystore_target }} # qa for taget=main and flask for target=flask - - - name: Restore .metamask folder - id: restore-metamask - uses: actions/cache@v4 - with: - path: .metamask - key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} - - - name: Install Foundry if cache missed - if: steps.restore-metamask.outputs.cache-hit != 'true' - run: yarn install:foundryup - - name: Setup project dependencies with retry - uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 - with: - timeout_minutes: 10 - max_attempts: 3 - retry_wait_seconds: 30 - command: | - echo "🚀 Setting up project..." - yarn setup:github-ci --no-build-ios - # Use the canonical fingerprint computed upstream by `post-build-source-hash`. # Do NOT recompute it here — Cirrus ubuntu `node_modules/` differs from # ubuntu-latest `node_modules/` (different Node/yarn versions, prebuilt @@ -216,11 +190,158 @@ jobs: echo "Source branch: ${{ steps.find-reusable-build.outputs.source-branch }}" shell: bash + # When any of the three reuse paths hit (branch cache, main cache, + # cross-run artifact), gate = false and we skip the full setup-e2e-env + # + Gradle setup and jump straight to a lean setup + repack. + - name: Compute native-build gate + id: gate + run: | + if [[ "${{ steps.apk-cache-restore.outputs.cache-hit }}" == "true" \ + || "${{ steps.apk-cache-restore-main.outputs.cache-hit }}" == "true" \ + || "${{ steps.find-reusable-build.outputs.found }}" == "true" ]]; then + echo "needs-native-build=false" >> "$GITHUB_OUTPUT" + echo "Reuse path active; heavy Android setup + Gradle restore will be skipped." + else + echo "needs-native-build=true" >> "$GITHUB_OUTPUT" + echo "No reuse path; full native build + setup will run." + fi + shell: bash + + # ------------------------------------------------------------------------- + # Heavy native setup — only runs on a full native-build path. + # setup-e2e-env handles: keystore, Java, Android SDK + AVD, yarn install, + # Foundry. Pulling the AVD + emulator deps alone is ~1m that repack + # doesn't need. + # ------------------------------------------------------------------------- + + - name: Setup Android Build Environment + if: ${{ steps.gate.outputs.needs-native-build == 'true' }} + timeout-minutes: 15 + uses: ./.github/actions/setup-e2e-env + with: + platform: android + setup-simulator: false + configure-keystores: true + android-api-level: 36 + target: ${{ inputs.keystore_target }} # qa for taget=main and flask for target=flask + + - name: Restore .metamask folder + if: ${{ steps.gate.outputs.needs-native-build == 'true' }} + id: restore-metamask + uses: actions/cache@v4 + with: + path: .metamask + key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} + + - name: Install Foundry if cache missed + if: ${{ steps.gate.outputs.needs-native-build == 'true' && steps.restore-metamask.outputs.cache-hit != 'true' }} + run: yarn install:foundryup + - name: Setup project dependencies with retry + if: ${{ steps.gate.outputs.needs-native-build == 'true' }} + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: | + echo "🚀 Setting up project..." + yarn setup:github-ci --no-build-ios + + # ------------------------------------------------------------------------- + # Lean setup — only runs on the reuse-hit path. Replicates just the bits + # that `yarn build:repack:android` needs: + # - Java + ANDROID_HOME (apksigner is invoked by @expo/repack-app) + # - keystore (apksigner signs the repacked APK) + # - Node + yarn install (Metro bundler + @expo/repack-app) + # - lightweight setup (build-inpage-bridge, patch-package, etc.) + # Skips AVD creation, emulator deps, jetify, and Gradle — none are + # needed for repack, saving ~2-3m. + # ------------------------------------------------------------------------- + + - name: Set Android environment variables (reuse-hit path) + if: ${{ steps.gate.outputs.needs-native-build != 'true' }} + run: | + echo "ANDROID_HOME=/opt/android-sdk" >> "$GITHUB_ENV" + echo "ANDROID_SDK_ROOT=/opt/android-sdk" >> "$GITHUB_ENV" + shell: bash + + - name: Configure Android Signing Certificates (reuse-hit path) + if: ${{ steps.gate.outputs.needs-native-build != 'true' }} + uses: MetaMask/github-tools/.github/actions/configure-keystore@0259e8a920318b02a8860e178d79796eaa08de02 + with: + aws-role-to-assume: 'arn:aws:iam::363762752069:role/metamask-mobile-build-signer-qa' + aws-region: 'us-east-2' + platform: 'android' + target: ${{ inputs.keystore_target }} + + - name: Setup Java (reuse-hit path) + if: ${{ steps.gate.outputs.needs-native-build != 'true' }} + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Node.js (reuse-hit path) + if: ${{ steps.gate.outputs.needs-native-build != 'true' }} + uses: actions/setup-node@v6 + with: + node-version: '20.18.0' + + - name: Enable corepack (reuse-hit path) + if: ${{ steps.gate.outputs.needs-native-build != 'true' }} + run: | + corepack enable + corepack prepare yarn@3.8.7 --activate + shell: bash + + # Matches the cache key used by `setup-e2e-env` so both paths share the + # same node_modules cache. + - name: Restore yarn cache (reuse-hit path) + if: ${{ steps.gate.outputs.needs-native-build != 'true' }} + uses: actions/cache@v4 + with: + path: | + node_modules + key: e2e-yarn-android-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + + - name: Install JS dependencies (reuse-hit path) + if: ${{ steps.gate.outputs.needs-native-build != 'true' }} + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn install --immutable + env: + NODE_OPTIONS: --max-old-space-size=4096 + YARN_ENABLE_GLOBAL_CACHE: 'true' + + - name: Restore .metamask folder (reuse-hit path) + if: ${{ steps.gate.outputs.needs-native-build != 'true' }} + id: restore-metamask-lean + uses: actions/cache@v4 + with: + path: .metamask + key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} + + # `--no-build-ios --no-build-android` skips the native-only setup tasks + # (bundler, CocoaPods, jetify) but still runs the JS-side tasks that + # `yarn build:repack:android` depends on: build-inpage-bridge, + # patch-package, allow-scripts, install-foundry, terms-of-use. + - name: Run lightweight project setup (reuse-hit path) + if: ${{ steps.gate.outputs.needs-native-build != 'true' }} + run: yarn setup:github-ci --no-build-ios --no-build-android + shell: bash + + # ------------------------------------------------------------------------- + # Gradle caches + native build — only on the full native-build path. + # ------------------------------------------------------------------------- + - name: Restore Gradle dependencies from branch cache id: gradle-cache-restore # This action automatically updates the cache at the end of the workflow uses: cirruslabs/cache@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 - if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true' && steps.find-reusable-build.outputs.found != 'true' }} + if: ${{ steps.gate.outputs.needs-native-build == 'true' }} env: GRADLE_CACHE_VERSION: 1 with: @@ -238,7 +359,7 @@ jobs: - name: Restore Gradle dependencies from main cache # This will only restore the cache, not update it uses: cirruslabs/cache/restore@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 - if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true' && steps.find-reusable-build.outputs.found != 'true' && steps.gradle-cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} + if: ${{ steps.gate.outputs.needs-native-build == 'true' && steps.gradle-cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} env: GRADLE_CACHE_VERSION: 1 with: @@ -254,7 +375,7 @@ jobs: key: gradle-main-${{ env.GRADLE_CACHE_VERSION }}-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - name: Build Android E2E APKs - if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true' && steps.find-reusable-build.outputs.found != 'true' }} + if: ${{ steps.gate.outputs.needs-native-build == 'true' }} run: | echo "🏗 Building Android E2E APKs..." export NODE_OPTIONS="--max-old-space-size=4096" @@ -307,7 +428,7 @@ jobs: MM_PREDICT_GTM_MODAL_ENABLED: 'false' - name: Repack APK with JS updates using @expo/repack-app - if: ${{ steps.apk-cache-restore.outputs.cache-hit == 'true' || steps.apk-cache-restore-main.outputs.cache-hit == 'true' || steps.find-reusable-build.outputs.found == 'true' }} + if: ${{ steps.gate.outputs.needs-native-build != 'true' }} run: | echo "📦 Repacking APK with updated JavaScript bundle using @expo/repack-app..." # Use the optimized repack script which uses @expo/repack-app diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index cc9457c086b..ed862354074 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -87,6 +87,13 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 + # ------------------------------------------------------------------------- + # Fast-path: probe branch cache, main cache, and cross-run artifacts BEFORE + # any heavy native setup. These steps only need `inputs.source-fingerprint` + # and `github.token`, so we can short-circuit Ruby/Bundler/CocoaPods/Xcode + # (~6m) when a compatible .app already exists. + # ------------------------------------------------------------------------- + # Escape hatch: a `force-builds` label on the PR or a `[force-builds]` # token in the head commit message forces a fresh native build and # bypasses both the GHA cache restores and the cross-run artifact @@ -97,8 +104,96 @@ jobs: with: github-token: ${{ github.token }} + # Use the canonical fingerprint computed upstream by `post-build-source-hash`. + # Do NOT recompute it here — macOS `node_modules/` differs from Linux + # `node_modules/` in ways @expo/fingerprint hashes (prebuilt binaries, + # platform-conditional deps), so recomputing per-runner would give a + # different value than the one posted as the `build-source-hash` commit + # status and break cross-run artifact reuse. + - name: Report source fingerprint + run: | + if [[ -z "$SOURCE_FINGERPRINT" ]]; then + echo "::warning::No source-fingerprint provided (likely a forked PR); caches and reuse disabled." + else + echo "Source fingerprint: $SOURCE_FINGERPRINT" + fi + env: + SOURCE_FINGERPRINT: ${{ inputs.source-fingerprint }} + + - name: Restore iOS app matching fingerprint from branch cache + id: cache-restore + if: ${{ steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' }} + # This action automatically updates the cache at the end of the workflow + uses: cirruslabs/cache@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 + with: + path: | + ios/build/Build/Products/Release-iphonesimulator/MetaMask.app + key: ios-app-${{ github.ref_name }}-v${{ env.IOS_APP_CACHE_VERSION }}-${{ inputs.source-fingerprint }} + + - name: Restore iOS app matching fingerprint from main cache + if: ${{ steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' && steps.cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} + id: cache-restore-main + # This will only restore the cache, not update it + uses: cirruslabs/cache/restore@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 + with: + path: | + ios/build/Build/Products/Release-iphonesimulator/MetaMask.app + key: ios-app-main-v${{ env.IOS_APP_CACHE_VERSION }}-${{ inputs.source-fingerprint }} + + # Cross-PR reuse: before building, try to find a prior workflow run whose + # build-source-hash commit status matches our fingerprint AND whose + # MetaMask.app artifact is still available. Falls back to a fresh build. + - name: Find reusable build from prior run + id: find-reusable-build + if: ${{ steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' && steps.cache-restore.outputs.cache-hit != 'true' && steps.cache-restore-main.outputs.cache-hit != 'true' }} + uses: ./.github/actions/find-reusable-build + with: + fingerprint: ${{ inputs.source-fingerprint }} + artifact-names: '["${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app"]' + github-token: ${{ github.token }} + + - name: Download reusable iOS build from prior run + if: ${{ steps.find-reusable-build.outputs.found == 'true' }} + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app + path: ios/build/Build/Products/Release-iphonesimulator/MetaMask.app + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ steps.find-reusable-build.outputs.run-id }} + + - name: Log reused iOS build source + if: ${{ steps.find-reusable-build.outputs.found == 'true' }} + run: | + echo "Reusing iOS build from run ${{ steps.find-reusable-build.outputs.run-id }}" + echo "Source SHA: ${{ steps.find-reusable-build.outputs.source-sha }}" + echo "Source branch: ${{ steps.find-reusable-build.outputs.source-branch }}" + shell: bash + + # When any of the three reuse paths hit (branch cache, main cache, + # cross-run artifact), gate = false and we skip the heavy Xcode + CocoaPods + # setup and jump straight to a lean JS-only setup + repack. + - name: Compute native-build gate + id: gate + run: | + if [[ "${{ steps.cache-restore.outputs.cache-hit }}" == "true" \ + || "${{ steps.cache-restore-main.outputs.cache-hit }}" == "true" \ + || "${{ steps.find-reusable-build.outputs.found }}" == "true" ]]; then + echo "needs-native-build=false" >> "$GITHUB_OUTPUT" + echo "Reuse path active; heavy native setup will be skipped." + else + echo "needs-native-build=true" >> "$GITHUB_OUTPUT" + echo "No reuse path; full native build + setup will run." + fi + shell: bash + + # ------------------------------------------------------------------------- + # Heavy native setup — only runs on a full native-build path. + # ------------------------------------------------------------------------- + - name: Restore Xcode derived data from branch cache id: xcode-restore-cache + if: ${{ steps.gate.outputs.needs-native-build == 'true' }} # This action automatically updates the cache at the end of the workflow uses: cirruslabs/cache@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 with: @@ -108,7 +203,7 @@ jobs: key: ${{ runner.os }}-xcode-${{ github.ref_name }}-${{ env.XCODE_CACHE_VERSION }}-${{ hashFiles('ios/**/*.{h,m,mm,swift}', 'ios/**/Podfile.lock', 'yarn.lock') }} - name: Restore Xcode derived data from main cache - if: ${{ steps.xcode-restore-cache.outputs.cache-hit != 'true' && github.ref_name != 'main' }} + if: ${{ steps.gate.outputs.needs-native-build == 'true' && steps.xcode-restore-cache.outputs.cache-hit != 'true' && github.ref_name != 'main' }} id: xcode-restore-cache-main # This will only restore the cache, not update it uses: cirruslabs/cache/restore@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 @@ -120,6 +215,7 @@ jobs: # Install Node.js, Xcode tools, and other iOS development dependencies - name: Installing iOS Environment Setup + if: ${{ steps.gate.outputs.needs-native-build == 'true' }} timeout-minutes: 15 uses: ./.github/actions/setup-e2e-env with: @@ -127,6 +223,7 @@ jobs: setup-simulator: false - name: Print iOS tool versions + if: ${{ steps.gate.outputs.needs-native-build == 'true' }} run: | echo "🔧 Node.js Version:" node -v || echo "Node not found" @@ -142,9 +239,11 @@ jobs: # Clean iOS plist files to prevent extended attribute issues - name: Clean iOS plist files + if: ${{ steps.gate.outputs.needs-native-build == 'true' }} run: find ios -name "*.plist" -exec xattr -c {} \; - name: Restore .metamask folder + if: ${{ steps.gate.outputs.needs-native-build == 'true' }} id: restore-metamask uses: actions/cache@v4 with: @@ -152,10 +251,11 @@ jobs: key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} - name: Install Foundry if cache missed - if: steps.restore-metamask.outputs.cache-hit != 'true' + if: ${{ steps.gate.outputs.needs-native-build == 'true' && steps.restore-metamask.outputs.cache-hit != 'true' }} run: yarn install:foundryup # Run project setup with retry for better resilience - name: Setup project dependencies with retry + if: ${{ steps.gate.outputs.needs-native-build == 'true' }} uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: timeout_minutes: 10 @@ -165,75 +265,72 @@ jobs: echo "🚀 Setting up project..." yarn setup:github-ci --build-ios --no-build-android - # Use the canonical fingerprint computed upstream by `post-build-source-hash`. - # Do NOT recompute it here — macOS `node_modules/` differs from Linux - # `node_modules/` in ways @expo/fingerprint hashes (prebuilt binaries, - # platform-conditional deps), so recomputing per-runner would give a - # different value than the one posted as the `build-source-hash` commit - # status and break cross-run artifact reuse. - - name: Report source fingerprint - run: | - if [[ -z "$SOURCE_FINGERPRINT" ]]; then - echo "::warning::No source-fingerprint provided (likely a forked PR); caches and reuse disabled." - else - echo "Source fingerprint: $SOURCE_FINGERPRINT" - fi - env: - SOURCE_FINGERPRINT: ${{ inputs.source-fingerprint }} + # ------------------------------------------------------------------------- + # Lean JS-only setup — only runs on the reuse-hit path so that + # `yarn build:repack:ios` has Node, @expo/repack-app, and the inpage + # bridge + terms-of-use assets. Skips Ruby/Bundler/CocoaPods/Xcode entirely + # (not needed for repack — saves ~6m wall time on cache-hit builds). + # ------------------------------------------------------------------------- - - name: Restore iOS app matching fingerprint from branch cache - id: cache-restore - if: ${{ steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' }} - # This action automatically updates the cache at the end of the workflow - uses: cirruslabs/cache@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 + - name: Setup Node.js (reuse-hit path) + if: ${{ steps.gate.outputs.needs-native-build != 'true' }} + uses: actions/setup-node@v6 with: - path: | - ios/build/Build/Products/Release-iphonesimulator/MetaMask.app - key: ios-app-${{ github.ref_name }}-v${{ env.IOS_APP_CACHE_VERSION }}-${{ inputs.source-fingerprint }} + node-version: '20.18.0' - - name: Restore iOS app matching fingerprint from main cache - if: ${{ steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' && steps.cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} - id: cache-restore-main - # This will only restore the cache, not update it - uses: cirruslabs/cache/restore@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 + - name: Enable corepack (reuse-hit path) + if: ${{ steps.gate.outputs.needs-native-build != 'true' }} + run: | + corepack enable + corepack prepare yarn@3.8.7 --activate + shell: bash + + # Matches the cache key used by `setup-e2e-env` so both paths share the + # same node_modules cache. + - name: Restore yarn cache (reuse-hit path) + if: ${{ steps.gate.outputs.needs-native-build != 'true' }} + uses: actions/cache@v4 with: path: | - ios/build/Build/Products/Release-iphonesimulator/MetaMask.app - key: ios-app-main-v${{ env.IOS_APP_CACHE_VERSION }}-${{ inputs.source-fingerprint }} + node_modules + key: e2e-yarn-ios-${{ runner.os }}-${{ hashFiles('yarn.lock') }} - # Cross-PR reuse: before building, try to find a prior workflow run whose - # build-source-hash commit status matches our fingerprint AND whose - # MetaMask.app artifact is still available. Falls back to a fresh build. - - name: Find reusable build from prior run - id: find-reusable-build - if: ${{ steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' && steps.cache-restore.outputs.cache-hit != 'true' && steps.cache-restore-main.outputs.cache-hit != 'true' }} - uses: ./.github/actions/find-reusable-build + - name: Install JS dependencies (reuse-hit path) + if: ${{ steps.gate.outputs.needs-native-build != 'true' }} + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: - fingerprint: ${{ inputs.source-fingerprint }} - artifact-names: '["${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app"]' - github-token: ${{ github.token }} + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn install --immutable + env: + NODE_OPTIONS: --max-old-space-size=4096 + YARN_ENABLE_GLOBAL_CACHE: 'true' - - name: Download reusable iOS build from prior run - if: ${{ steps.find-reusable-build.outputs.found == 'true' }} - uses: actions/download-artifact@v4 + - name: Restore .metamask folder (reuse-hit path) + if: ${{ steps.gate.outputs.needs-native-build != 'true' }} + id: restore-metamask-lean + uses: actions/cache@v4 with: - name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app - path: ios/build/Build/Products/Release-iphonesimulator/MetaMask.app - github-token: ${{ github.token }} - repository: ${{ github.repository }} - run-id: ${{ steps.find-reusable-build.outputs.run-id }} + path: .metamask + key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} - - name: Log reused iOS build source - if: ${{ steps.find-reusable-build.outputs.found == 'true' }} - run: | - echo "Reusing iOS build from run ${{ steps.find-reusable-build.outputs.run-id }}" - echo "Source SHA: ${{ steps.find-reusable-build.outputs.source-sha }}" - echo "Source branch: ${{ steps.find-reusable-build.outputs.source-branch }}" + # `--no-build-ios --no-build-android` skips the native setup tasks + # (bundler, CocoaPods, jetify) but still runs the JS-side tasks that + # `yarn build:repack:ios` depends on: build-inpage-bridge, + # patch-package, allow-scripts, install-foundry, terms-of-use. + - name: Run lightweight project setup (reuse-hit path) + if: ${{ steps.gate.outputs.needs-native-build != 'true' }} + run: yarn setup:github-ci --no-build-ios --no-build-android shell: bash + # ------------------------------------------------------------------------- + # Build (fresh) or Repack (reuse) — exactly one of these runs per job. + # ------------------------------------------------------------------------- + # Build the iOS E2E app for simulator - name: Build iOS E2E App - if: ${{ steps.cache-restore.outputs.cache-hit != 'true' && steps.cache-restore-main.outputs.cache-hit != 'true' && steps.find-reusable-build.outputs.found != 'true' }} + if: ${{ steps.gate.outputs.needs-native-build == 'true' }} run: | echo "🏗 Building iOS E2E App..." export NODE_OPTIONS="--max-old-space-size=8192" @@ -268,7 +365,7 @@ jobs: GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} - name: Repack iOS app with JS updates using @expo/repack-app - if: ${{ steps.cache-restore.outputs.cache-hit == 'true' || steps.cache-restore-main.outputs.cache-hit == 'true' || steps.find-reusable-build.outputs.found == 'true' }} + if: ${{ steps.gate.outputs.needs-native-build != 'true' }} run: | echo "📦 Repacking iOS app with updated JavaScript bundle using @expo/repack-app..." # Use the optimized repack script which uses @expo/repack-app @@ -344,11 +441,12 @@ jobs: if-no-files-found: error continue-on-error: true - # Upload source map file for crash debugging and error tracking if exists - # Only runs when repack step runs (cache hit), as that's when sourcemap is generated + # Upload source map file for crash debugging and error tracking. + # Both paths produce it: `yarn build:ios:main:e2e` via + # `scripts/ios/bundle-js-and-sentry-upload.sh` and `yarn build:repack:ios` + # via `scripts/repack.js` both write to `sourcemaps/ios/index.js.map`. - name: Upload iOS Source Map id: upload-sourcemap - if: ${{ steps.cache-restore.outputs.cache-hit == 'true' || steps.cache-restore-main.outputs.cache-hit == 'true' || steps.find-reusable-build.outputs.found == 'true' }} uses: actions/upload-artifact@v4 with: name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-index.js.map From caf870a714efa2c7e9e102e3148e50aaeb1c57e9 Mon Sep 17 00:00:00 2001 From: tommasini Date: Thu, 23 Apr 2026 09:40:47 +0100 Subject: [PATCH 09/35] test cache v2 From 6d4fefea12f698e4d6f85b03c1fd24d3808dd9f7 Mon Sep 17 00:00:00 2001 From: tommasini Date: Thu, 23 Apr 2026 11:31:34 +0100 Subject: [PATCH 10/35] v3 --- .github/workflows/build-android-e2e.yml | 20 +++++++++++++++++++- .github/workflows/build-ios-e2e.yml | 18 ++++++++++++++++++ app.config.js | 10 ++++++++++ metro.config.js | 15 +++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 3ab19cfba15..6c4de4fc913 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -333,6 +333,18 @@ jobs: run: yarn setup:github-ci --no-build-ios --no-build-android shell: bash + - name: Restore Metro transform cache (reuse-hit path) + if: ${{ steps.gate.outputs.needs-native-build != 'true' }} + id: metro-cache-restore + uses: actions/cache@v4 + with: + path: .metro-cache + key: metro-android-${{ github.ref_name }}-${{ hashFiles('yarn.lock', 'babel.config.js', 'metro.config.js', 'metro.transform.js', 'app.config.js') }} + restore-keys: | + metro-android-${{ github.ref_name }}- + metro-android-main- + metro-android- + # ------------------------------------------------------------------------- # Gradle caches + native build — only on the full native-build path. # ------------------------------------------------------------------------- @@ -444,7 +456,13 @@ jobs: GITHUB_CI: 'true' CI: 'true' NODE_OPTIONS: '--max-old-space-size=8192' - METRO_MAX_WORKERS: '2' + METRO_MAX_WORKERS: '6' + METRO_CACHE_DIR: ${{ github.workspace }}/.metro-cache + # `@expo/repack-app` hard-codes `--reset-cache` on its `expo + # export:embed` invocation; `scripts/repack.js` strips that flag at + # spawn time, but we also disable reset in metro.config.js as a + # belt-and-suspenders so the persisted cache is actually used. + METRO_RESET_CACHE: 'false' BRIDGE_USE_DEV_APIS: 'true' RAMP_INTERNAL_BUILD: 'true' SEEDLESS_ONBOARDING_ENABLED: 'true' diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index ed862354074..eba1d0ec4c8 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -324,6 +324,18 @@ jobs: run: yarn setup:github-ci --no-build-ios --no-build-android shell: bash + - name: Restore Metro transform cache (reuse-hit path) + if: ${{ steps.gate.outputs.needs-native-build != 'true' }} + id: metro-cache-restore + uses: actions/cache@v4 + with: + path: .metro-cache + key: metro-ios-${{ github.ref_name }}-${{ hashFiles('yarn.lock', 'babel.config.js', 'metro.config.js', 'metro.transform.js', 'app.config.js') }} + restore-keys: | + metro-ios-${{ github.ref_name }}- + metro-ios-main- + metro-ios- + # ------------------------------------------------------------------------- # Build (fresh) or Repack (reuse) — exactly one of these runs per job. # ------------------------------------------------------------------------- @@ -381,6 +393,12 @@ jobs: GITHUB_CI: 'true' CI: 'true' NODE_OPTIONS: '--max-old-space-size=8192' + METRO_MAX_WORKERS: '6' + METRO_CACHE_DIR: ${{ github.workspace }}/.metro-cache + # See Android comment: `@expo/repack-app` hard-codes `--reset-cache`; + # we strip it in `scripts/repack.js` and defensively disable reset + # here so the persisted cache is actually honored. + METRO_RESET_CACHE: 'false' BRIDGE_USE_DEV_APIS: 'true' RAMP_INTERNAL_BUILD: 'true' SEEDLESS_ONBOARDING_ENABLED: 'true' diff --git a/app.config.js b/app.config.js index 58d67b16c5d..30dd125569e 100644 --- a/app.config.js +++ b/app.config.js @@ -10,6 +10,15 @@ const OTA_ENV_MAP = { const OTA_ENV = OTA_ENV_MAP[process.env.METAMASK_ENVIRONMENT] ?? 'exp'; +// Disable OTA updates for E2E builds. We only ship OTA updates to real users +// on production/RC builds; flipping this off for E2E lets the native runtime +// skip reading `app.manifest` entirely (see expo-updates' +// `EmbeddedManifestUtils`), which in turn lets `scripts/repack.js` replace +// the redundant Metro-backed manifest generation with a cheap stub and cut +// ~2m off every repack. +const IS_E2E_BUILD = + process.env.IS_TEST === 'true' || process.env.METAMASK_ENVIRONMENT === 'e2e'; + const CODE_SIGNING_CERTS = { production: './certs/production.certificate.pem', exp: './certs/exp.certificate.pem', @@ -100,6 +109,7 @@ module.exports = { owner: 'metamask', runtimeVersion: RUNTIME_VERSION, updates: { + ...(IS_E2E_BUILD ? { enabled: false } : {}), codeSigningCertificate: CODE_SIGNING_CERTS[OTA_ENV], codeSigningMetadata: { keyid: CODE_SIGNING_KEYIDS[OTA_ENV], diff --git a/metro.config.js b/metro.config.js index 2cdcde3aa83..d23d80d89a0 100644 --- a/metro.config.js +++ b/metro.config.js @@ -9,6 +9,8 @@ const { getDefaultConfig } = require('expo/metro-config'); const { mergeConfig } = require('@react-native/metro-config'); const { lockdownSerializer } = require('@lavamoat/react-native-lockdown'); +// eslint-disable-next-line import-x/no-extraneous-dependencies +const FileStore = require('metro-cache').FileStore; // eslint-disable-next-line import-x/no-nodejs-modules const { parseArgs } = require('node:util'); @@ -73,6 +75,18 @@ module.exports = function (baseConfig) { ), ); + // Allow CI (and power users) to point Metro's transform cache at a stable, + // cacheable path via `METRO_CACHE_DIR`. When unset we fall back to Metro's + // default, which is `/metro-cache` — ephemeral on Cirrus + // runners and therefore never reused between jobs. + // + // The actions/cache step in `.github/workflows/build-{android,ios}-e2e.yml` + // saves and restores this directory keyed on yarn.lock + babel/metro/expo + // config, so E2E repacks start with a warm transform cache. + const metroCacheStores = process.env.METRO_CACHE_DIR + ? [new FileStore({ root: process.env.METRO_CACHE_DIR })] + : undefined; + return wrapWithReanimatedMetroConfig( mergeConfig(defaultConfig, { resolver: { @@ -235,6 +249,7 @@ module.exports = function (baseConfig) { getPolyfills, }, ), + ...(metroCacheStores ? { cacheStores: metroCacheStores } : {}), resetCache: process.env.METRO_RESET_CACHE !== 'false', maxWorkers, }), From d9c1383d65e4ccf04dec1678ff0adff2a46c0215 Mon Sep 17 00:00:00 2001 From: tommasini Date: Thu, 23 Apr 2026 11:37:04 +0100 Subject: [PATCH 11/35] v3.1 --- scripts/repack.js | 156 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/scripts/repack.js b/scripts/repack.js index 43f139e4a95..be4e24045fe 100644 --- a/scripts/repack.js +++ b/scripts/repack.js @@ -5,6 +5,7 @@ const fs = require('fs'); const path = require('path'); +const { spawn } = require('child_process'); /** * Logger utility @@ -16,6 +17,159 @@ const logger = { warn: (msg) => console.warn(`⚠️ ${msg}`), }; +// Repack only runs for E2E-style builds; we use this to safely stub +// `expo-updates` manifest generation (see `makeCustomSpawnAsync`). +const IS_E2E_REPACK = + process.env.IS_TEST === 'true' || + process.env.E2E === 'true' || + process.env.METAMASK_ENVIRONMENT === 'e2e'; + +/** + * Implements the `SpawnProcessAsync` contract expected by `@expo/repack-app` + * using Node's `child_process.spawn`. Resolves with `{ pid, output, stdout, + * stderr, status, signal }` and attaches `.child` to the returned promise. + * + * Kept dependency-free so repack keeps working even if `@expo/spawn-async` + * is pruned. + */ +function runSpawnAsync(command, args, options = {}) { + let child; + const promise = new Promise((resolve, reject) => { + child = spawn(command, args, { stdio: 'pipe', ...options }); + let stdout = ''; + let stderr = ''; + if (child.stdout) { + child.stdout.on('data', (data) => { + const chunk = data.toString(); + stdout += chunk; + if (options.stdio === 'inherit') process.stdout.write(chunk); + }); + } + if (child.stderr) { + child.stderr.on('data', (data) => { + const chunk = data.toString(); + stderr += chunk; + if (options.stdio === 'inherit') process.stderr.write(chunk); + }); + } + child.once('error', (err) => { + Object.assign(err, { + pid: child.pid, + output: [stdout, stderr], + stdout, + stderr, + status: null, + signal: null, + }); + reject(err); + }); + child.once('close', (code, signal) => { + const result = { + pid: child.pid, + output: [stdout, stderr], + stdout, + stderr, + status: code, + signal, + }; + if (code !== 0) { + const err = new Error( + `${command} ${args.join(' ')} exited with code ${code}${signal ? ` (signal ${signal})` : ''}` + ); + Object.assign(err, result); + reject(err); + return; + } + resolve(result); + }); + }); + promise.child = child; + return promise; +} + +/** + * Resolves a fake `SpawnProcessResult` synchronously, keeping the same shape + * `@expo/repack-app` expects. + */ +function fakeSpawnResult() { + const promise = Promise.resolve({ + pid: 0, + output: ['', ''], + stdout: '', + stderr: '', + status: 0, + signal: null, + }); + promise.child = {}; // Duck-typed; @expo/repack-app only awaits the result. + return promise; +} + +/** + * Creates a `spawnAsync` function tailored to the current repack context. + * + * Two optimizations relative to `@expo/repack-app`'s default spawn: + * + * 1. Strip `--reset-cache` from `expo export:embed` so Metro's transform + * cache persists across invocations (shared with CI `actions/cache`). + * Without this, every repack pays a ~2-3m cold-cache tax. + * + * 2. Stub `expo-updates/utils/build/createUpdatesResources.js` for E2E + * builds. `@expo/repack-app` always generates `app.manifest` when + * `expo-updates` is a dependency — even though E2E builds set + * `EXUpdatesEnabled=false` at runtime, so the manifest is never + * consulted. Skipping this second Metro bundle saves ~2m per repack. + * The stub writes an empty JSON manifest so the downstream `copyFile` + * call still succeeds. + * + * @param {string} workingDirectory - Passed through so the stub can write + * `app.manifest` at the expected path. + */ +function makeCustomSpawnAsync(workingDirectory) { + return function customSpawnAsync(command, args, options = {}) { + // 1) Drop --reset-cache from `npx expo export:embed` invocations so + // Metro reuses a persisted transform cache. + if ( + command === 'npx' && + Array.isArray(args) && + args[0] === 'expo' && + args[1] === 'export:embed' && + args.includes('--reset-cache') + ) { + args = args.filter((a) => a !== '--reset-cache'); + } + + // 2) Skip `createUpdatesResources.js` for E2E repacks — `expo-updates` + // is disabled at runtime (see `app.config.js`), so the manifest is + // unused. We still write a stub so the library's `copyFile(manifest)` + // step below doesn't ENOENT. + if ( + IS_E2E_REPACK && + typeof command === 'string' && + command.endsWith('createUpdatesResources.js') + ) { + const manifestPath = path.join(workingDirectory, 'app.manifest'); + try { + fs.mkdirSync(workingDirectory, { recursive: true }); + fs.writeFileSync( + manifestPath, + JSON.stringify({ id: 'e2e-stub', assets: [], launchAsset: {} }) + ); + logger.info( + 'Skipped expo-updates manifest generation (E2E build; updates disabled at runtime)' + ); + } catch (err) { + logger.warn( + `Could not write stub app.manifest at ${manifestPath}: ${err.message}. Falling back to real generation.` + ); + return runSpawnAsync(command, args, options); + } + return fakeSpawnResult(); + } + + return runSpawnAsync(command, args, options); + }; +} + function getKeystoreConfig() { const isCI = !!process.env.CI; const keystorePath = process.env.ANDROID_KEYSTORE_PATH; @@ -87,6 +241,7 @@ async function repackAndroid() { sourcemapOutput: sourcemapPath, }, env: process.env, + spawnAsync: makeCustomSpawnAsync(workingDir), }); // Copy to final location @@ -203,6 +358,7 @@ async function repackIos() { sourcemapOutput: sourcemapPath, }, env: process.env, + spawnAsync: makeCustomSpawnAsync(workingDir), }); // Verify repacked app exists and contains a bundle executable From 5e125bf6e7191f2da86d9a5f749cad149e10ea13 Mon Sep 17 00:00:00 2001 From: tommasini Date: Thu, 23 Apr 2026 12:26:42 +0100 Subject: [PATCH 12/35] metro cache dep --- package.json | 1 + yarn.lock | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/package.json b/package.json index 481fc889f49..923e220a38e 100644 --- a/package.json +++ b/package.json @@ -656,6 +656,7 @@ "lint-staged": "10.5.4", "listr2": "^8.0.2", "liveline": "0.0.7", + "metro-cache": "^0.81.1", "metro-react-native-babel-preset": "~0.76.9", "metro-react-native-babel-transformer": "~0.76.9", "nock": "^14.0.11", diff --git a/yarn.lock b/yarn.lock index 644aca84b8f..371825a078e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -36144,6 +36144,7 @@ __metadata: lodash: "npm:4.18.1" lottie-react-native: "npm:6.7.2" luxon: "npm:^3.5.0" + metro-cache: "npm:^0.81.1" metro-react-native-babel-preset: "npm:~0.76.9" metro-react-native-babel-transformer: "npm:~0.76.9" mockttp: "npm:^3.15.2" @@ -36343,6 +36344,17 @@ __metadata: languageName: node linkType: hard +"metro-cache@npm:^0.81.1": + version: 0.81.5 + resolution: "metro-cache@npm:0.81.5" + dependencies: + exponential-backoff: "npm:^3.1.1" + flow-enums-runtime: "npm:^0.0.6" + metro-core: "npm:0.81.5" + checksum: 10/6ffc8283ca9002c2a99a9e787e59c764399218459f9db352b9cb7543bf0f38de973130dfc9587997b6fd206c0b87b7c33def754814505c282286f12938c606d0 + languageName: node + linkType: hard + "metro-config@npm:0.81.1, metro-config@npm:^0.81.0": version: 0.81.1 resolution: "metro-config@npm:0.81.1" @@ -36370,6 +36382,17 @@ __metadata: languageName: node linkType: hard +"metro-core@npm:0.81.5": + version: 0.81.5 + resolution: "metro-core@npm:0.81.5" + dependencies: + flow-enums-runtime: "npm:^0.0.6" + lodash.throttle: "npm:^4.1.1" + metro-resolver: "npm:0.81.5" + checksum: 10/9ecf5b646ec7cc3d5de7d2ebd21e37713d7b86b68a6e94ec911b2c73a20d7abd972406e2ffa2084f2d156ed5f767fe5658c5c2cc3343f3ed10fc276fe385aa84 + languageName: node + linkType: hard + "metro-file-map@npm:0.81.1": version: 0.81.1 resolution: "metro-file-map@npm:0.81.1" @@ -36470,6 +36493,15 @@ __metadata: languageName: node linkType: hard +"metro-resolver@npm:0.81.5": + version: 0.81.5 + resolution: "metro-resolver@npm:0.81.5" + dependencies: + flow-enums-runtime: "npm:^0.0.6" + checksum: 10/3f20592755ac52db973a8c111adddad7430322b0b27c5d3d2cf2e2ff73e0693922f98b32a9a46941abc97b604cfb116b0e42c64f005e5c002460fe141a4e5847 + languageName: node + linkType: hard + "metro-runtime@npm:0.81.1, metro-runtime@npm:^0.81.0": version: 0.81.1 resolution: "metro-runtime@npm:0.81.1" From fe592815c1d7bf2a0ca768273bcc777eef26ffd6 Mon Sep 17 00:00:00 2001 From: tommasini Date: Thu, 23 Apr 2026 12:45:24 +0100 Subject: [PATCH 13/35] deduplicate --- yarn.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index 371825a078e..47128656863 100644 --- a/yarn.lock +++ b/yarn.lock @@ -36371,7 +36371,7 @@ __metadata: languageName: node linkType: hard -"metro-core@npm:0.81.1, metro-core@npm:^0.81.0": +"metro-core@npm:0.81.1": version: 0.81.1 resolution: "metro-core@npm:0.81.1" dependencies: @@ -36382,7 +36382,7 @@ __metadata: languageName: node linkType: hard -"metro-core@npm:0.81.5": +"metro-core@npm:0.81.5, metro-core@npm:^0.81.0": version: 0.81.5 resolution: "metro-core@npm:0.81.5" dependencies: From b753174db01ba009901f2607ca9e7a7e7cc5397f Mon Sep 17 00:00:00 2001 From: tommasini Date: Thu, 23 Apr 2026 13:16:36 +0100 Subject: [PATCH 14/35] test cache From a050212110e9c060ad7a31b2b2075510beba5359 Mon Sep 17 00:00:00 2001 From: tommasini Date: Fri, 24 Apr 2026 12:05:00 +0100 Subject: [PATCH 15/35] revert fingerprint changes and keep ota fingerprint config intact --- .github/workflows/push-eas-update.yml | 4 +-- fingerprint.config.js | 15 ----------- package.json | 1 + scripts/generate-fingerprint-eas.js | 38 +++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 17 deletions(-) create mode 100644 scripts/generate-fingerprint-eas.js diff --git a/.github/workflows/push-eas-update.yml b/.github/workflows/push-eas-update.yml index 7c6608ae8b8..06e314ef8db 100644 --- a/.github/workflows/push-eas-update.yml +++ b/.github/workflows/push-eas-update.yml @@ -247,7 +247,7 @@ jobs: id: branch_fingerprint run: | echo "🧬 Generating fingerprint for current branch..." - FINGERPRINT=$(yarn fingerprint:generate) + FINGERPRINT=$(yarn fingerprint:generate:eas) echo "fingerprint=$FINGERPRINT" >> "$GITHUB_OUTPUT" echo "Target PR fingerprint: $FINGERPRINT" @@ -274,7 +274,7 @@ jobs: working-directory: main run: | echo "🧬 Generating fingerprint for base branch (${BASE_BRANCH_REF})..." - FINGERPRINT=$(yarn fingerprint:generate) + FINGERPRINT=$(yarn fingerprint:generate:eas) echo "fingerprint=$FINGERPRINT" >> "$GITHUB_OUTPUT" echo "Base branch fingerprint: $FINGERPRINT" diff --git a/fingerprint.config.js b/fingerprint.config.js index b4c3da2aadc..6b43e0b5412 100644 --- a/fingerprint.config.js +++ b/fingerprint.config.js @@ -26,21 +26,6 @@ const config = { filePath: '.yarn/patches', reasons: ['Detect yarn patch changes.'], }, - { - type: 'file', - filePath: '.github/workflows/push-eas-update.yml', - reasons: ['Detect OTA update workflow changes.'], - }, - { - type: 'file', - filePath: '.github/workflows/runway-ota-build-core.yml', - reasons: ['Detect Runway OTA build workflow changes.'], - }, - { - type: 'file', - filePath: '.github/workflows/build.yml', - reasons: ['Detect build workflow changes.'], - }, { type: 'file', filePath: 'react-native.config.js', diff --git a/package.json b/package.json index 923e220a38e..d4c7fb2271c 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "build:android:checksum:flask": "export METAMASK_BUILD_TYPE='flask' && ./scripts/checksum.sh flask", "build:android:checksum:verify": "shasum -a 512 -c sha512sums.txt", "fingerprint:generate": "node scripts/generate-fingerprint.js", + "fingerprint:generate:eas": "node scripts/generate-fingerprint-eas.js", "build:repack:android": "PLATFORM=android node scripts/repack.js", "build:repack:ios": "PLATFORM=ios node scripts/repack.js", "build:liveline-webview": "./scripts/build-liveline-webview.sh", diff --git a/scripts/generate-fingerprint-eas.js b/scripts/generate-fingerprint-eas.js new file mode 100644 index 00000000000..886f292e4f6 --- /dev/null +++ b/scripts/generate-fingerprint-eas.js @@ -0,0 +1,38 @@ +const { createFingerprintAsync } = require('@expo/fingerprint'); + +/** + * EAS-specific fingerprint generator. + * + * Extends the base fingerprint.config.js with `.github/workflows` and + * `.github/scripts` so that CI workflow edits are considered native-affecting + * when comparing fingerprints in the push-eas-update workflow. + * + * These sources are intentionally excluded from the global fingerprint.config.js + * to avoid invalidating build caches on every unrelated CI edit to `main`. + */ +async function generateFingerprint() { + try { + const { hash } = await createFingerprintAsync(process.cwd(), { + extraSources: [ + { + type: 'dir', + filePath: '.github/workflows', + reasons: ['Detect Github workflow changes.'], + }, + { + type: 'dir', + filePath: '.github/scripts', + reasons: ['Detect Github workflow script changes.'], + }, + ], + }); + // Only output the hash to stdout, with no extra output, to ensure that scripts or tools consuming this output receive only the hash value and are not affected by additional text. + process.stdout.write(hash); + } catch (error) { + // Write error to stderr instead of stdout to avoid corrupting the hash output + process.stderr.write(`Error generating fingerprint: ${error.message}\n`); + process.exit(1); + } +} + +generateFingerprint(); From f628dbc7bf15b0bed9ad66decd212638ef5b393a Mon Sep 17 00:00:00 2001 From: tommasini Date: Fri, 24 Apr 2026 15:16:56 +0100 Subject: [PATCH 16/35] remove repack optimizations --- .github/workflows/build-android-e2e.yml | 18 --- .github/workflows/build-ios-e2e.yml | 17 --- app.config.js | 10 -- metro.config.js | 15 --- package.json | 1 - scripts/repack.js | 156 ------------------------ yarn.lock | 14 +-- 7 files changed, 1 insertion(+), 230 deletions(-) diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 6c4de4fc913..3f942fd2cb6 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -333,18 +333,6 @@ jobs: run: yarn setup:github-ci --no-build-ios --no-build-android shell: bash - - name: Restore Metro transform cache (reuse-hit path) - if: ${{ steps.gate.outputs.needs-native-build != 'true' }} - id: metro-cache-restore - uses: actions/cache@v4 - with: - path: .metro-cache - key: metro-android-${{ github.ref_name }}-${{ hashFiles('yarn.lock', 'babel.config.js', 'metro.config.js', 'metro.transform.js', 'app.config.js') }} - restore-keys: | - metro-android-${{ github.ref_name }}- - metro-android-main- - metro-android- - # ------------------------------------------------------------------------- # Gradle caches + native build — only on the full native-build path. # ------------------------------------------------------------------------- @@ -457,12 +445,6 @@ jobs: CI: 'true' NODE_OPTIONS: '--max-old-space-size=8192' METRO_MAX_WORKERS: '6' - METRO_CACHE_DIR: ${{ github.workspace }}/.metro-cache - # `@expo/repack-app` hard-codes `--reset-cache` on its `expo - # export:embed` invocation; `scripts/repack.js` strips that flag at - # spawn time, but we also disable reset in metro.config.js as a - # belt-and-suspenders so the persisted cache is actually used. - METRO_RESET_CACHE: 'false' BRIDGE_USE_DEV_APIS: 'true' RAMP_INTERNAL_BUILD: 'true' SEEDLESS_ONBOARDING_ENABLED: 'true' diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index eba1d0ec4c8..cc2132dda1c 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -324,18 +324,6 @@ jobs: run: yarn setup:github-ci --no-build-ios --no-build-android shell: bash - - name: Restore Metro transform cache (reuse-hit path) - if: ${{ steps.gate.outputs.needs-native-build != 'true' }} - id: metro-cache-restore - uses: actions/cache@v4 - with: - path: .metro-cache - key: metro-ios-${{ github.ref_name }}-${{ hashFiles('yarn.lock', 'babel.config.js', 'metro.config.js', 'metro.transform.js', 'app.config.js') }} - restore-keys: | - metro-ios-${{ github.ref_name }}- - metro-ios-main- - metro-ios- - # ------------------------------------------------------------------------- # Build (fresh) or Repack (reuse) — exactly one of these runs per job. # ------------------------------------------------------------------------- @@ -394,11 +382,6 @@ jobs: CI: 'true' NODE_OPTIONS: '--max-old-space-size=8192' METRO_MAX_WORKERS: '6' - METRO_CACHE_DIR: ${{ github.workspace }}/.metro-cache - # See Android comment: `@expo/repack-app` hard-codes `--reset-cache`; - # we strip it in `scripts/repack.js` and defensively disable reset - # here so the persisted cache is actually honored. - METRO_RESET_CACHE: 'false' BRIDGE_USE_DEV_APIS: 'true' RAMP_INTERNAL_BUILD: 'true' SEEDLESS_ONBOARDING_ENABLED: 'true' diff --git a/app.config.js b/app.config.js index 30dd125569e..58d67b16c5d 100644 --- a/app.config.js +++ b/app.config.js @@ -10,15 +10,6 @@ const OTA_ENV_MAP = { const OTA_ENV = OTA_ENV_MAP[process.env.METAMASK_ENVIRONMENT] ?? 'exp'; -// Disable OTA updates for E2E builds. We only ship OTA updates to real users -// on production/RC builds; flipping this off for E2E lets the native runtime -// skip reading `app.manifest` entirely (see expo-updates' -// `EmbeddedManifestUtils`), which in turn lets `scripts/repack.js` replace -// the redundant Metro-backed manifest generation with a cheap stub and cut -// ~2m off every repack. -const IS_E2E_BUILD = - process.env.IS_TEST === 'true' || process.env.METAMASK_ENVIRONMENT === 'e2e'; - const CODE_SIGNING_CERTS = { production: './certs/production.certificate.pem', exp: './certs/exp.certificate.pem', @@ -109,7 +100,6 @@ module.exports = { owner: 'metamask', runtimeVersion: RUNTIME_VERSION, updates: { - ...(IS_E2E_BUILD ? { enabled: false } : {}), codeSigningCertificate: CODE_SIGNING_CERTS[OTA_ENV], codeSigningMetadata: { keyid: CODE_SIGNING_KEYIDS[OTA_ENV], diff --git a/metro.config.js b/metro.config.js index d23d80d89a0..2cdcde3aa83 100644 --- a/metro.config.js +++ b/metro.config.js @@ -9,8 +9,6 @@ const { getDefaultConfig } = require('expo/metro-config'); const { mergeConfig } = require('@react-native/metro-config'); const { lockdownSerializer } = require('@lavamoat/react-native-lockdown'); -// eslint-disable-next-line import-x/no-extraneous-dependencies -const FileStore = require('metro-cache').FileStore; // eslint-disable-next-line import-x/no-nodejs-modules const { parseArgs } = require('node:util'); @@ -75,18 +73,6 @@ module.exports = function (baseConfig) { ), ); - // Allow CI (and power users) to point Metro's transform cache at a stable, - // cacheable path via `METRO_CACHE_DIR`. When unset we fall back to Metro's - // default, which is `/metro-cache` — ephemeral on Cirrus - // runners and therefore never reused between jobs. - // - // The actions/cache step in `.github/workflows/build-{android,ios}-e2e.yml` - // saves and restores this directory keyed on yarn.lock + babel/metro/expo - // config, so E2E repacks start with a warm transform cache. - const metroCacheStores = process.env.METRO_CACHE_DIR - ? [new FileStore({ root: process.env.METRO_CACHE_DIR })] - : undefined; - return wrapWithReanimatedMetroConfig( mergeConfig(defaultConfig, { resolver: { @@ -249,7 +235,6 @@ module.exports = function (baseConfig) { getPolyfills, }, ), - ...(metroCacheStores ? { cacheStores: metroCacheStores } : {}), resetCache: process.env.METRO_RESET_CACHE !== 'false', maxWorkers, }), diff --git a/package.json b/package.json index d4c7fb2271c..81435179c26 100644 --- a/package.json +++ b/package.json @@ -657,7 +657,6 @@ "lint-staged": "10.5.4", "listr2": "^8.0.2", "liveline": "0.0.7", - "metro-cache": "^0.81.1", "metro-react-native-babel-preset": "~0.76.9", "metro-react-native-babel-transformer": "~0.76.9", "nock": "^14.0.11", diff --git a/scripts/repack.js b/scripts/repack.js index be4e24045fe..43f139e4a95 100644 --- a/scripts/repack.js +++ b/scripts/repack.js @@ -5,7 +5,6 @@ const fs = require('fs'); const path = require('path'); -const { spawn } = require('child_process'); /** * Logger utility @@ -17,159 +16,6 @@ const logger = { warn: (msg) => console.warn(`⚠️ ${msg}`), }; -// Repack only runs for E2E-style builds; we use this to safely stub -// `expo-updates` manifest generation (see `makeCustomSpawnAsync`). -const IS_E2E_REPACK = - process.env.IS_TEST === 'true' || - process.env.E2E === 'true' || - process.env.METAMASK_ENVIRONMENT === 'e2e'; - -/** - * Implements the `SpawnProcessAsync` contract expected by `@expo/repack-app` - * using Node's `child_process.spawn`. Resolves with `{ pid, output, stdout, - * stderr, status, signal }` and attaches `.child` to the returned promise. - * - * Kept dependency-free so repack keeps working even if `@expo/spawn-async` - * is pruned. - */ -function runSpawnAsync(command, args, options = {}) { - let child; - const promise = new Promise((resolve, reject) => { - child = spawn(command, args, { stdio: 'pipe', ...options }); - let stdout = ''; - let stderr = ''; - if (child.stdout) { - child.stdout.on('data', (data) => { - const chunk = data.toString(); - stdout += chunk; - if (options.stdio === 'inherit') process.stdout.write(chunk); - }); - } - if (child.stderr) { - child.stderr.on('data', (data) => { - const chunk = data.toString(); - stderr += chunk; - if (options.stdio === 'inherit') process.stderr.write(chunk); - }); - } - child.once('error', (err) => { - Object.assign(err, { - pid: child.pid, - output: [stdout, stderr], - stdout, - stderr, - status: null, - signal: null, - }); - reject(err); - }); - child.once('close', (code, signal) => { - const result = { - pid: child.pid, - output: [stdout, stderr], - stdout, - stderr, - status: code, - signal, - }; - if (code !== 0) { - const err = new Error( - `${command} ${args.join(' ')} exited with code ${code}${signal ? ` (signal ${signal})` : ''}` - ); - Object.assign(err, result); - reject(err); - return; - } - resolve(result); - }); - }); - promise.child = child; - return promise; -} - -/** - * Resolves a fake `SpawnProcessResult` synchronously, keeping the same shape - * `@expo/repack-app` expects. - */ -function fakeSpawnResult() { - const promise = Promise.resolve({ - pid: 0, - output: ['', ''], - stdout: '', - stderr: '', - status: 0, - signal: null, - }); - promise.child = {}; // Duck-typed; @expo/repack-app only awaits the result. - return promise; -} - -/** - * Creates a `spawnAsync` function tailored to the current repack context. - * - * Two optimizations relative to `@expo/repack-app`'s default spawn: - * - * 1. Strip `--reset-cache` from `expo export:embed` so Metro's transform - * cache persists across invocations (shared with CI `actions/cache`). - * Without this, every repack pays a ~2-3m cold-cache tax. - * - * 2. Stub `expo-updates/utils/build/createUpdatesResources.js` for E2E - * builds. `@expo/repack-app` always generates `app.manifest` when - * `expo-updates` is a dependency — even though E2E builds set - * `EXUpdatesEnabled=false` at runtime, so the manifest is never - * consulted. Skipping this second Metro bundle saves ~2m per repack. - * The stub writes an empty JSON manifest so the downstream `copyFile` - * call still succeeds. - * - * @param {string} workingDirectory - Passed through so the stub can write - * `app.manifest` at the expected path. - */ -function makeCustomSpawnAsync(workingDirectory) { - return function customSpawnAsync(command, args, options = {}) { - // 1) Drop --reset-cache from `npx expo export:embed` invocations so - // Metro reuses a persisted transform cache. - if ( - command === 'npx' && - Array.isArray(args) && - args[0] === 'expo' && - args[1] === 'export:embed' && - args.includes('--reset-cache') - ) { - args = args.filter((a) => a !== '--reset-cache'); - } - - // 2) Skip `createUpdatesResources.js` for E2E repacks — `expo-updates` - // is disabled at runtime (see `app.config.js`), so the manifest is - // unused. We still write a stub so the library's `copyFile(manifest)` - // step below doesn't ENOENT. - if ( - IS_E2E_REPACK && - typeof command === 'string' && - command.endsWith('createUpdatesResources.js') - ) { - const manifestPath = path.join(workingDirectory, 'app.manifest'); - try { - fs.mkdirSync(workingDirectory, { recursive: true }); - fs.writeFileSync( - manifestPath, - JSON.stringify({ id: 'e2e-stub', assets: [], launchAsset: {} }) - ); - logger.info( - 'Skipped expo-updates manifest generation (E2E build; updates disabled at runtime)' - ); - } catch (err) { - logger.warn( - `Could not write stub app.manifest at ${manifestPath}: ${err.message}. Falling back to real generation.` - ); - return runSpawnAsync(command, args, options); - } - return fakeSpawnResult(); - } - - return runSpawnAsync(command, args, options); - }; -} - function getKeystoreConfig() { const isCI = !!process.env.CI; const keystorePath = process.env.ANDROID_KEYSTORE_PATH; @@ -241,7 +87,6 @@ async function repackAndroid() { sourcemapOutput: sourcemapPath, }, env: process.env, - spawnAsync: makeCustomSpawnAsync(workingDir), }); // Copy to final location @@ -358,7 +203,6 @@ async function repackIos() { sourcemapOutput: sourcemapPath, }, env: process.env, - spawnAsync: makeCustomSpawnAsync(workingDir), }); // Verify repacked app exists and contains a bundle executable diff --git a/yarn.lock b/yarn.lock index 47128656863..573dfbd8621 100644 --- a/yarn.lock +++ b/yarn.lock @@ -36144,7 +36144,6 @@ __metadata: lodash: "npm:4.18.1" lottie-react-native: "npm:6.7.2" luxon: "npm:^3.5.0" - metro-cache: "npm:^0.81.1" metro-react-native-babel-preset: "npm:~0.76.9" metro-react-native-babel-transformer: "npm:~0.76.9" mockttp: "npm:^3.15.2" @@ -36344,17 +36343,6 @@ __metadata: languageName: node linkType: hard -"metro-cache@npm:^0.81.1": - version: 0.81.5 - resolution: "metro-cache@npm:0.81.5" - dependencies: - exponential-backoff: "npm:^3.1.1" - flow-enums-runtime: "npm:^0.0.6" - metro-core: "npm:0.81.5" - checksum: 10/6ffc8283ca9002c2a99a9e787e59c764399218459f9db352b9cb7543bf0f38de973130dfc9587997b6fd206c0b87b7c33def754814505c282286f12938c606d0 - languageName: node - linkType: hard - "metro-config@npm:0.81.1, metro-config@npm:^0.81.0": version: 0.81.1 resolution: "metro-config@npm:0.81.1" @@ -36382,7 +36370,7 @@ __metadata: languageName: node linkType: hard -"metro-core@npm:0.81.5, metro-core@npm:^0.81.0": +"metro-core@npm:^0.81.0": version: 0.81.5 resolution: "metro-core@npm:0.81.5" dependencies: From 22e1eb0e2a6e108329aba3b26ed7dad9c80a6719 Mon Sep 17 00:00:00 2001 From: tommasini Date: Fri, 24 Apr 2026 15:28:21 +0100 Subject: [PATCH 17/35] revert fingerprint config changes --- .github/workflows/push-eas-update.yml | 4 +-- fingerprint.config.js | 24 +++++++---------- package.json | 1 - scripts/generate-fingerprint-eas.js | 38 --------------------------- 4 files changed, 12 insertions(+), 55 deletions(-) delete mode 100644 scripts/generate-fingerprint-eas.js diff --git a/.github/workflows/push-eas-update.yml b/.github/workflows/push-eas-update.yml index 06e314ef8db..7c6608ae8b8 100644 --- a/.github/workflows/push-eas-update.yml +++ b/.github/workflows/push-eas-update.yml @@ -247,7 +247,7 @@ jobs: id: branch_fingerprint run: | echo "🧬 Generating fingerprint for current branch..." - FINGERPRINT=$(yarn fingerprint:generate:eas) + FINGERPRINT=$(yarn fingerprint:generate) echo "fingerprint=$FINGERPRINT" >> "$GITHUB_OUTPUT" echo "Target PR fingerprint: $FINGERPRINT" @@ -274,7 +274,7 @@ jobs: working-directory: main run: | echo "🧬 Generating fingerprint for base branch (${BASE_BRANCH_REF})..." - FINGERPRINT=$(yarn fingerprint:generate:eas) + FINGERPRINT=$(yarn fingerprint:generate) echo "fingerprint=$FINGERPRINT" >> "$GITHUB_OUTPUT" echo "Base branch fingerprint: $FINGERPRINT" diff --git a/fingerprint.config.js b/fingerprint.config.js index 6b43e0b5412..d5dbf6def12 100644 --- a/fingerprint.config.js +++ b/fingerprint.config.js @@ -6,26 +6,22 @@ * Docs - https://docs.expo.dev/versions/latest/sdk/fingerprint/#fingerprintconfigjs */ const config = { - /** - * Track files and directories under `extraSources` if they affect native code changes. - * - * Intentionally NOT tracked: - * - `.github/workflows/**` and `.github/scripts/**` — these orchestrate CI - * but do not change what `scripts/build.sh` / native toolchains compile - * into the artifacts. Including them would invalidate the fingerprint on - * every unrelated CI workflow edit on `main`, which currently breaks - * build caching and cross-PR artifact reuse (every open PR's merge ref - * picks up those edits). The files that actually drive the native build - * (`scripts/build.sh`, `scripts/setup.mjs`, `react-native.config.js`, - * native dirs, `package.json`/`yarn.lock`, and `.yarn/patches`) are - * tracked explicitly below or by `@expo/fingerprint` defaults. - */ extraSources: [ { type: 'dir', filePath: '.yarn/patches', reasons: ['Detect yarn patch changes.'], }, + { + type: 'dir', + filePath: '.github/workflows', + reasons: ['Detect Github workflow changes.'], + }, + { + type: 'dir', + filePath: '.github/scripts', + reasons: ['Detect Github workflow script changes.'], + }, { type: 'file', filePath: 'react-native.config.js', diff --git a/package.json b/package.json index 81435179c26..481fc889f49 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,6 @@ "build:android:checksum:flask": "export METAMASK_BUILD_TYPE='flask' && ./scripts/checksum.sh flask", "build:android:checksum:verify": "shasum -a 512 -c sha512sums.txt", "fingerprint:generate": "node scripts/generate-fingerprint.js", - "fingerprint:generate:eas": "node scripts/generate-fingerprint-eas.js", "build:repack:android": "PLATFORM=android node scripts/repack.js", "build:repack:ios": "PLATFORM=ios node scripts/repack.js", "build:liveline-webview": "./scripts/build-liveline-webview.sh", diff --git a/scripts/generate-fingerprint-eas.js b/scripts/generate-fingerprint-eas.js deleted file mode 100644 index 886f292e4f6..00000000000 --- a/scripts/generate-fingerprint-eas.js +++ /dev/null @@ -1,38 +0,0 @@ -const { createFingerprintAsync } = require('@expo/fingerprint'); - -/** - * EAS-specific fingerprint generator. - * - * Extends the base fingerprint.config.js with `.github/workflows` and - * `.github/scripts` so that CI workflow edits are considered native-affecting - * when comparing fingerprints in the push-eas-update workflow. - * - * These sources are intentionally excluded from the global fingerprint.config.js - * to avoid invalidating build caches on every unrelated CI edit to `main`. - */ -async function generateFingerprint() { - try { - const { hash } = await createFingerprintAsync(process.cwd(), { - extraSources: [ - { - type: 'dir', - filePath: '.github/workflows', - reasons: ['Detect Github workflow changes.'], - }, - { - type: 'dir', - filePath: '.github/scripts', - reasons: ['Detect Github workflow script changes.'], - }, - ], - }); - // Only output the hash to stdout, with no extra output, to ensure that scripts or tools consuming this output receive only the hash value and are not affected by additional text. - process.stdout.write(hash); - } catch (error) { - // Write error to stderr instead of stdout to avoid corrupting the hash output - process.stderr.write(`Error generating fingerprint: ${error.message}\n`); - process.exit(1); - } -} - -generateFingerprint(); From 6cd7c442789571b8e890a5a67f4351e02aa166a7 Mon Sep 17 00:00:00 2001 From: tommasini Date: Fri, 24 Apr 2026 19:48:50 +0100 Subject: [PATCH 18/35] ci: add native build artifact reuse across PRs - Compute a deterministic fingerprint of native build sources and post it as a build-source-hash commit status on every run - find-reusable-build searches same branch, base branch, and cross-PR runs (no branch filter) to reuse artifacts with a matching fingerprint - Lean path: download prior artifact and repack JS bundle (~5 min) - Heavy path: full native build when no reuse candidate is found - Add force-builds label / [force-builds] commit token to bypass reuse - Use GitHub Artifacts exclusively for build storage (no cirruslabs cache) --- .github/actions/check-force-builds/action.yml | 2 +- .../actions/post-build-source-hash/action.yml | 23 +++- .github/workflows/build-android-e2e.yml | 101 ++++-------------- .github/workflows/build-ios-e2e.yml | 64 ++--------- .github/workflows/ci.yml | 13 +-- scripts/repack.js | 5 +- 6 files changed, 56 insertions(+), 152 deletions(-) diff --git a/.github/actions/check-force-builds/action.yml b/.github/actions/check-force-builds/action.yml index 68102dfc281..dcd5c2f93ad 100644 --- a/.github/actions/check-force-builds/action.yml +++ b/.github/actions/check-force-builds/action.yml @@ -38,7 +38,7 @@ runs: LABEL_NAME: ${{ inputs.label-name }} COMMIT_TAG: ${{ inputs.commit-tag }} EVENT_NAME: ${{ github.event_name }} - HEAD_COMMIT_HASH: ${{ github.event.pull_request.head.sha || github.sha }} + HEAD_COMMIT_HASH: ${{ github.event.pull_request.head.sha }} PR_NUMBER: ${{ github.event.pull_request.number }} REPOSITORY: ${{ github.repository }} run: | diff --git a/.github/actions/post-build-source-hash/action.yml b/.github/actions/post-build-source-hash/action.yml index fa9c77b7e93..9d766767c5e 100644 --- a/.github/actions/post-build-source-hash/action.yml +++ b/.github/actions/post-build-source-hash/action.yml @@ -1,14 +1,20 @@ name: 'Post build-source-hash commit status' description: >- - Computes the @expo/fingerprint hash via `yarn fingerprint:generate` and posts - it as a `build-source-hash` GitHub commit status on the current SHA. This - makes the fingerprint queryable by future workflow runs so they can locate a - prior run whose uploaded native build artifacts match the current source. + Computes the @expo/fingerprint hash via `yarn fingerprint:generate` against + the CURRENTLY CHECKED-OUT workspace, then posts it as a `build-source-hash` + GitHub commit status on `target-sha`. This makes the fingerprint queryable + by future workflow runs so they can locate a prior run whose uploaded + native build artifacts match the current source. inputs: github-token: description: 'GitHub token with `statuses: write` permission' required: true + target-sha: + description: >- + Commit SHA to post the `build-source-hash` status on. MUST match the + ref that was checked out before calling this action. + required: true status-context: description: 'GitHub commit status context name' required: false @@ -36,7 +42,7 @@ runs: env: FINGERPRINT: ${{ steps.generate-fingerprint.outputs.fingerprint }} STATUS_CONTEXT: ${{ inputs.status-context }} - TARGET_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + TARGET_SHA: ${{ inputs.target-sha }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} with: github-token: ${{ inputs.github-token }} @@ -46,6 +52,13 @@ runs: core.setFailed('Fingerprint is empty; refusing to post status'); return; } + if (!TARGET_SHA) { + core.setFailed( + 'target-sha input is empty; refusing to post status ' + + '(would silently post on the wrong commit).' + ); + return; + } await github.rest.repos.createCommitStatus({ owner: context.repo.owner, repo: context.repo.repo, diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 3f942fd2cb6..4de4a826259 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -35,13 +35,6 @@ on: default: '' type: string -permissions: - contents: read - id-token: write - actions: read - pull-requests: read - statuses: read - jobs: build-android-apks: name: Build Android E2E APKs @@ -49,7 +42,6 @@ jobs: timeout-minutes: 40 env: GRADLE_USER_HOME: /home/admin/_work/.gradle - CACHE_GENERATION: v1 # Increment this to bust the cache (v1, v2, v3, etc.) YARN_ENABLE_GLOBAL_CACHE: 'true' # Enable Yarn global cache for faster installs outputs: apk-uploaded: ${{ steps.upload-apk.outcome == 'success' }} @@ -61,18 +53,6 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 - # ------------------------------------------------------------------------- - # Fast-path: probe branch cache, main cache, and cross-run artifacts BEFORE - # any heavy native setup. These steps only need `inputs.source-fingerprint` - # and `github.token`, so we can short-circuit setup-e2e-env (Android SDK + - # AVD + Java + emulator deps) and `yarn setup:github-ci --no-build-ios` - # when a compatible APK + androidTest APK already exist. - # ------------------------------------------------------------------------- - - # Escape hatch: a `force-builds` label on the PR or a `[force-builds]` - # token in the head commit message forces a fresh native build and - # bypasses both the GHA cache restores and the cross-run artifact - # lookup. Honored only on `pull_request` events. - name: Check force-builds override id: force-builds uses: ./.github/actions/check-force-builds @@ -88,7 +68,7 @@ jobs: - name: Report source fingerprint run: | if [[ -z "$SOURCE_FINGERPRINT" ]]; then - echo "::warning::No source-fingerprint provided (likely a forked PR); caches and reuse disabled." + echo "::warning::No source-fingerprint provided (likely a forked PR); artifact reuse disabled." else echo "Source fingerprint: $SOURCE_FINGERPRINT" fi @@ -115,55 +95,18 @@ jobs: exit 1 fi - - name: Restore APKs matching fingerprint from branch cache - id: apk-cache-restore - if: ${{ steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' }} - # This action automatically updates the cache at the end of the workflow - uses: cirruslabs/cache@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 - with: - path: | - ${{ steps.determine-target-paths.outputs.apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.apk - ${{ steps.determine-target-paths.outputs.test-apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}-androidTest.apk - # Include Gradle properties in key to force rebuild when properties change - # Keep the `hashFiles` call for Gradle config in-sync with these steps: - # - "Restore APKs matching fingerprint from branch cache" - # - "Restore APKs matching fingerprint from main cache" - # - "Restore Gradle dependencies from branch cache" - # - "Restore Gradle dependencies from main cache" - key: android-apk-${{ github.ref_name }}-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ inputs.source-fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - - - name: Restore APKs matching fingerprint from main cache - if: ${{ steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' && steps.apk-cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} - id: apk-cache-restore-main - # This will only restore the cache, not update it - uses: cirruslabs/cache/restore@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 - with: - path: | - ${{ steps.determine-target-paths.outputs.apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.apk - ${{ steps.determine-target-paths.outputs.test-apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}-androidTest.apk - # Include Gradle properties in key to force rebuild when properties change - # Keep the `hashFiles` call for Gradle config in-sync with these steps: - # - "Restore APKs matching fingerprint from branch cache" - # - "Restore APKs matching fingerprint from main cache" - # - "Restore Gradle dependencies from branch cache" - # - "Restore Gradle dependencies from main cache" - key: android-apk-main-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ inputs.source-fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - - # Cross-PR reuse: before building, try to find a prior workflow run whose - # build-source-hash commit status matches our fingerprint AND whose - # APK + androidTest APK artifacts are still available. Falls back to a - # fresh build when no match is found. - name: Find reusable build from prior run id: find-reusable-build - if: ${{ steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' && steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true' }} + if: ${{ steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' }} uses: ./.github/actions/find-reusable-build with: fingerprint: ${{ inputs.source-fingerprint }} artifact-names: '["${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.apk","${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release-androidTest.apk"]' github-token: ${{ github.token }} - - name: Download reusable APK from prior run + id: download-reusable-apk if: ${{ steps.find-reusable-build.outputs.found == 'true' }} + continue-on-error: true uses: actions/download-artifact@v4 with: name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.apk @@ -173,7 +116,9 @@ jobs: run-id: ${{ steps.find-reusable-build.outputs.run-id }} - name: Download reusable androidTest APK from prior run + id: download-reusable-test-apk if: ${{ steps.find-reusable-build.outputs.found == 'true' }} + continue-on-error: true uses: actions/download-artifact@v4 with: name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release-androidTest.apk @@ -183,25 +128,25 @@ jobs: run-id: ${{ steps.find-reusable-build.outputs.run-id }} - name: Log reused Android build source - if: ${{ steps.find-reusable-build.outputs.found == 'true' }} + if: ${{ steps.download-reusable-apk.outcome == 'success' && steps.download-reusable-test-apk.outcome == 'success' }} run: | echo "Reusing Android build from run ${{ steps.find-reusable-build.outputs.run-id }}" echo "Source SHA: ${{ steps.find-reusable-build.outputs.source-sha }}" echo "Source branch: ${{ steps.find-reusable-build.outputs.source-branch }}" shell: bash - # When any of the three reuse paths hit (branch cache, main cache, - # cross-run artifact), gate = false and we skip the full setup-e2e-env - # + Gradle setup and jump straight to a lean setup + repack. - name: Compute native-build gate id: gate run: | - if [[ "${{ steps.apk-cache-restore.outputs.cache-hit }}" == "true" \ - || "${{ steps.apk-cache-restore-main.outputs.cache-hit }}" == "true" \ - || "${{ steps.find-reusable-build.outputs.found }}" == "true" ]]; then + if [[ "${{ steps.find-reusable-build.outputs.found }}" == "true" \ + && "${{ steps.download-reusable-apk.outcome }}" == "success" \ + && "${{ steps.download-reusable-test-apk.outcome }}" == "success" ]]; then echo "needs-native-build=false" >> "$GITHUB_OUTPUT" - echo "Reuse path active; heavy Android setup + Gradle restore will be skipped." + echo "Reuse path active (cross-run artifact download succeeded); heavy Android setup + Gradle restore will be skipped." else + if [[ "${{ steps.find-reusable-build.outputs.found }}" == "true" ]]; then + echo "::warning::Reusable run was found but artifact download failed (apk=${{ steps.download-reusable-apk.outcome }}, test-apk=${{ steps.download-reusable-test-apk.outcome }}); falling back to fresh native build." + fi echo "needs-native-build=true" >> "$GITHUB_OUTPUT" echo "No reuse path; full native build + setup will run." fi @@ -348,12 +293,9 @@ jobs: path: | ~/_work/.gradle/caches ~/_work/.gradle/wrapper - # Include Gradle properties in key to force rebuild when properties change - # Keep the `hashFiles` call for Gradle config in-sync with these steps: - # - "Restore APKs matching fingerprint from branch cache" - # - "Restore APKs matching fingerprint from main cache" - # - "Restore Gradle dependencies from branch cache" - # - "Restore Gradle dependencies from main cache" + # Include Gradle properties in key to force rebuild when properties change. + # Keep the `hashFiles` call for Gradle config in-sync with the + # sibling "Restore Gradle dependencies from main cache" step below. key: gradle-${{ github.ref_name }}-${{ env.GRADLE_CACHE_VERSION }}-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - name: Restore Gradle dependencies from main cache @@ -366,12 +308,9 @@ jobs: path: | ~/_work/.gradle/caches ~/_work/.gradle/wrapper - # Include Gradle properties in key to force rebuild when properties change - # Keep the `hashFiles` call for Gradle config in-sync with these steps: - # - "Restore APKs matching fingerprint from branch cache" - # - "Restore APKs matching fingerprint from main cache" - # - "Restore Gradle dependencies from branch cache" - # - "Restore Gradle dependencies from main cache" + # Include Gradle properties in key to force rebuild when properties change. + # Keep the `hashFiles` call for Gradle config in-sync with the + # sibling "Restore Gradle dependencies from branch cache" step above. key: gradle-main-${{ env.GRADLE_CACHE_VERSION }}-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - name: Build Android E2E APKs diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index cc2132dda1c..cf893e1d69a 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -27,13 +27,6 @@ on: default: '' type: string -permissions: - contents: read - id-token: write - actions: read - pull-requests: read - statuses: read - jobs: build-ios-apps: name: Build iOS E2E Apps @@ -43,9 +36,7 @@ jobs: app-uploaded: ${{ steps.upload-app.outcome == 'success' }} sourcemap-uploaded: ${{ steps.upload-sourcemap.outcome == 'success' }} env: - # Bump these to bust the respective caches and force a full rebuild XCODE_CACHE_VERSION: 1 - IOS_APP_CACHE_VERSION: 2 RCT_NO_LAUNCH_PACKAGER: 1 XCODE_BUILD_SETTINGS: 'COMPILER_INDEX_STORE_ENABLE=NO' GITHUB_CI: 'true' # This ensures it's available during pod install @@ -87,17 +78,6 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 - # ------------------------------------------------------------------------- - # Fast-path: probe branch cache, main cache, and cross-run artifacts BEFORE - # any heavy native setup. These steps only need `inputs.source-fingerprint` - # and `github.token`, so we can short-circuit Ruby/Bundler/CocoaPods/Xcode - # (~6m) when a compatible .app already exists. - # ------------------------------------------------------------------------- - - # Escape hatch: a `force-builds` label on the PR or a `[force-builds]` - # token in the head commit message forces a fresh native build and - # bypasses both the GHA cache restores and the cross-run artifact - # lookup. Honored only on `pull_request` events. - name: Check force-builds override id: force-builds uses: ./.github/actions/check-force-builds @@ -113,39 +93,16 @@ jobs: - name: Report source fingerprint run: | if [[ -z "$SOURCE_FINGERPRINT" ]]; then - echo "::warning::No source-fingerprint provided (likely a forked PR); caches and reuse disabled." + echo "::warning::No source-fingerprint provided (likely a forked PR); artifact reuse disabled." else echo "Source fingerprint: $SOURCE_FINGERPRINT" fi env: SOURCE_FINGERPRINT: ${{ inputs.source-fingerprint }} - - name: Restore iOS app matching fingerprint from branch cache - id: cache-restore - if: ${{ steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' }} - # This action automatically updates the cache at the end of the workflow - uses: cirruslabs/cache@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 - with: - path: | - ios/build/Build/Products/Release-iphonesimulator/MetaMask.app - key: ios-app-${{ github.ref_name }}-v${{ env.IOS_APP_CACHE_VERSION }}-${{ inputs.source-fingerprint }} - - - name: Restore iOS app matching fingerprint from main cache - if: ${{ steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' && steps.cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} - id: cache-restore-main - # This will only restore the cache, not update it - uses: cirruslabs/cache/restore@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 - with: - path: | - ios/build/Build/Products/Release-iphonesimulator/MetaMask.app - key: ios-app-main-v${{ env.IOS_APP_CACHE_VERSION }}-${{ inputs.source-fingerprint }} - - # Cross-PR reuse: before building, try to find a prior workflow run whose - # build-source-hash commit status matches our fingerprint AND whose - # MetaMask.app artifact is still available. Falls back to a fresh build. - name: Find reusable build from prior run id: find-reusable-build - if: ${{ steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' && steps.cache-restore.outputs.cache-hit != 'true' && steps.cache-restore-main.outputs.cache-hit != 'true' }} + if: ${{ steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' }} uses: ./.github/actions/find-reusable-build with: fingerprint: ${{ inputs.source-fingerprint }} @@ -153,7 +110,9 @@ jobs: github-token: ${{ github.token }} - name: Download reusable iOS build from prior run + id: download-reusable-app if: ${{ steps.find-reusable-build.outputs.found == 'true' }} + continue-on-error: true uses: actions/download-artifact@v4 with: name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app @@ -163,25 +122,24 @@ jobs: run-id: ${{ steps.find-reusable-build.outputs.run-id }} - name: Log reused iOS build source - if: ${{ steps.find-reusable-build.outputs.found == 'true' }} + if: ${{ steps.download-reusable-app.outcome == 'success' }} run: | echo "Reusing iOS build from run ${{ steps.find-reusable-build.outputs.run-id }}" echo "Source SHA: ${{ steps.find-reusable-build.outputs.source-sha }}" echo "Source branch: ${{ steps.find-reusable-build.outputs.source-branch }}" shell: bash - # When any of the three reuse paths hit (branch cache, main cache, - # cross-run artifact), gate = false and we skip the heavy Xcode + CocoaPods - # setup and jump straight to a lean JS-only setup + repack. - name: Compute native-build gate id: gate run: | - if [[ "${{ steps.cache-restore.outputs.cache-hit }}" == "true" \ - || "${{ steps.cache-restore-main.outputs.cache-hit }}" == "true" \ - || "${{ steps.find-reusable-build.outputs.found }}" == "true" ]]; then + if [[ "${{ steps.find-reusable-build.outputs.found }}" == "true" \ + && "${{ steps.download-reusable-app.outcome }}" == "success" ]]; then echo "needs-native-build=false" >> "$GITHUB_OUTPUT" - echo "Reuse path active; heavy native setup will be skipped." + echo "Reuse path active (cross-run artifact download succeeded); heavy native setup will be skipped." else + if [[ "${{ steps.find-reusable-build.outputs.found }}" == "true" ]]; then + echo "::warning::Reusable run was found but artifact download failed (outcome=${{ steps.download-reusable-app.outcome }}); falling back to fresh native build." + fi echo "needs-native-build=true" >> "$GITHUB_OUTPUT" echo "No reuse path; full native build + setup will run." fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f760533770a..e8e63e86622 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,10 +81,6 @@ jobs: post-build-source-hash: name: Post build-source-hash commit status runs-on: ubuntu-latest - # Always publish the commit status (even for merge_group or when builds are - # skipped) so future runs can still find this commit when searching for - # reusable native builds. - if: ${{ !github.event.pull_request.head.repo.fork }} permissions: contents: read statuses: write @@ -112,6 +108,9 @@ jobs: uses: ./.github/actions/post-build-source-hash with: github-token: ${{ github.token }} + # .head.sha = PR head (pull_request events); github.sha fallback = pushed/scheduled commit + # (push to main, merge_group, schedule) where no PR payload exists. + target-sha: ${{ github.event.pull_request.head.sha || github.sha }} dedupe: name: Dedupe @@ -573,7 +572,6 @@ jobs: name: 'Build Android APKs' if: >- ${{ - !cancelled() && needs.needs_e2e_build.result == 'success' && needs.smart-e2e-selection.result == 'success' && github.event_name != 'merge_group' && @@ -615,13 +613,8 @@ jobs: build-ios-apps: name: 'Build iOS Apps' - # See `build-android-apks` above for why `!cancelled()` + explicit - # `result == 'success'` checks are used here: a failure/skip of - # `post-build-source-hash` must NOT block the E2E build — it only - # disables cross-run cache reuse. if: >- ${{ - !cancelled() && needs.needs_e2e_build.result == 'success' && needs.smart-e2e-selection.result == 'success' && github.event_name != 'merge_group' && diff --git a/scripts/repack.js b/scripts/repack.js index 43f139e4a95..49908a6ece3 100644 --- a/scripts/repack.js +++ b/scripts/repack.js @@ -218,8 +218,9 @@ async function repackIos() { throw new Error( `Repacked app is missing its bundle executable at "${executablePath}". ` + `@expo/repack-app may have dropped the binary (possible symlink handling issue). ` + - `Aborting to prevent uploading a broken artifact — bust IOS_APP_CACHE_VERSION ` + - `in build-ios-e2e.yml to force a full rebuild.` + `Aborting to prevent uploading a broken artifact — add the \`force-builds\` ` + + `label (or a \`[force-builds]\` token in the commit message) to the PR to ` + + `bypass cross-run artifact reuse and force a full native rebuild.` ); } logger.success(`Bundle executable verified: ${sourceAppName}`); From 15ea6a08e170429832a14b54b1aa98ead0e48188 Mon Sep 17 00:00:00 2001 From: tommasini Date: Fri, 24 Apr 2026 20:06:17 +0100 Subject: [PATCH 19/35] remove unnecessary foundry installation if cache is missed, since it was installed anyway running yarn setup:github:ci --- .github/workflows/build-android-e2e.yml | 3 --- .github/workflows/build-ios-e2e.yml | 4 ---- 2 files changed, 7 deletions(-) diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 4de4a826259..76533058a53 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -178,9 +178,6 @@ jobs: path: .metamask key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} - - name: Install Foundry if cache missed - if: ${{ steps.gate.outputs.needs-native-build == 'true' && steps.restore-metamask.outputs.cache-hit != 'true' }} - run: yarn install:foundryup - name: Setup project dependencies with retry if: ${{ steps.gate.outputs.needs-native-build == 'true' }} uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index e757ddb412a..976c345820c 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -208,10 +208,6 @@ jobs: path: .metamask key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} - - name: Install Foundry if cache missed - if: ${{ steps.gate.outputs.needs-native-build == 'true' && steps.restore-metamask.outputs.cache-hit != 'true' }} - run: yarn install:foundryup - # Run project setup with retry for better resilience - name: Setup project dependencies with retry if: ${{ steps.gate.outputs.needs-native-build == 'true' }} From 6d14511dbf91f6d0af7c1f9916b25d393a1589a8 Mon Sep 17 00:00:00 2001 From: tommasini Date: Fri, 24 Apr 2026 20:11:11 +0100 Subject: [PATCH 20/35] bring back !isCancelled to build ios and build android --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8e63e86622..e2d3c7de8db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -572,6 +572,7 @@ jobs: name: 'Build Android APKs' if: >- ${{ + !cancelled() && needs.needs_e2e_build.result == 'success' && needs.smart-e2e-selection.result == 'success' && github.event_name != 'merge_group' && @@ -615,6 +616,7 @@ jobs: name: 'Build iOS Apps' if: >- ${{ + !cancelled() && needs.needs_e2e_build.result == 'success' && needs.smart-e2e-selection.result == 'success' && github.event_name != 'merge_group' && From 65f6838d762a461837a5214f20607362371a027a Mon Sep 17 00:00:00 2001 From: tommasini Date: Sat, 25 Apr 2026 00:10:27 +0100 Subject: [PATCH 21/35] test cache From ba160a7d293183b94cf88ff77228a6e0463bc013 Mon Sep 17 00:00:00 2001 From: tommasini Date: Sat, 25 Apr 2026 00:35:02 +0100 Subject: [PATCH 22/35] test cache 2 (last commit was to distant, probably cache was already invalid) From 2d346d9d8c50419466edb4e8bdbb340a26a04c96 Mon Sep 17 00:00:00 2001 From: tommasini Date: Sat, 25 Apr 2026 00:55:19 +0100 Subject: [PATCH 23/35] Drop the conclusion filter. Keep only the status filter (completed/in_progress) and let hasAllArtifacts validate reusability. --- .github/actions/find-reusable-build/action.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/actions/find-reusable-build/action.yml b/.github/actions/find-reusable-build/action.yml index 65766ac7374..fe0b7095deb 100644 --- a/.github/actions/find-reusable-build/action.yml +++ b/.github/actions/find-reusable-build/action.yml @@ -231,15 +231,8 @@ runs: seenRunIds.add(runIdStr); if (tier.skipHeadBranch && run.head_branch === tier.skipHeadBranch) continue; + if (run.status !== 'completed' && run.status !== 'in_progress') continue; - if ( - run.status === 'completed' && - run.conclusion && - run.conclusion !== 'success' && - run.conclusion !== 'neutral' - ) { - continue; - } const fingerprint = await getFingerprintForSha(run.head_sha); if (!fingerprint) continue; From f3197fd409ed8baff6bf3eaa7f0ceba0d7e8a2d0 Mon Sep 17 00:00:00 2001 From: tommasini Date: Sat, 25 Apr 2026 01:11:50 +0100 Subject: [PATCH 24/35] test cache From a542923144ceda3de94a45c7f4fb807388afd823 Mon Sep 17 00:00:00 2001 From: tommasini Date: Tue, 28 Apr 2026 10:59:38 +0100 Subject: [PATCH 25/35] revert unnecessary changes --- fingerprint.config.js | 3 +++ yarn.lock | 22 +--------------------- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/fingerprint.config.js b/fingerprint.config.js index d5dbf6def12..2b50b90fa94 100644 --- a/fingerprint.config.js +++ b/fingerprint.config.js @@ -6,6 +6,9 @@ * Docs - https://docs.expo.dev/versions/latest/sdk/fingerprint/#fingerprintconfigjs */ const config = { + /** + * Track files and directories under `extraSources` if they affect native code changes. + */ extraSources: [ { type: 'dir', diff --git a/yarn.lock b/yarn.lock index 7a511d936a8..0f83d097d04 100644 --- a/yarn.lock +++ b/yarn.lock @@ -36399,7 +36399,7 @@ __metadata: languageName: node linkType: hard -"metro-core@npm:0.81.1": +"metro-core@npm:0.81.1, metro-core@npm:^0.81.0": version: 0.81.1 resolution: "metro-core@npm:0.81.1" dependencies: @@ -36410,17 +36410,6 @@ __metadata: languageName: node linkType: hard -"metro-core@npm:^0.81.0": - version: 0.81.5 - resolution: "metro-core@npm:0.81.5" - dependencies: - flow-enums-runtime: "npm:^0.0.6" - lodash.throttle: "npm:^4.1.1" - metro-resolver: "npm:0.81.5" - checksum: 10/9ecf5b646ec7cc3d5de7d2ebd21e37713d7b86b68a6e94ec911b2c73a20d7abd972406e2ffa2084f2d156ed5f767fe5658c5c2cc3343f3ed10fc276fe385aa84 - languageName: node - linkType: hard - "metro-file-map@npm:0.81.1": version: 0.81.1 resolution: "metro-file-map@npm:0.81.1" @@ -36521,15 +36510,6 @@ __metadata: languageName: node linkType: hard -"metro-resolver@npm:0.81.5": - version: 0.81.5 - resolution: "metro-resolver@npm:0.81.5" - dependencies: - flow-enums-runtime: "npm:^0.0.6" - checksum: 10/3f20592755ac52db973a8c111adddad7430322b0b27c5d3d2cf2e2ff73e0693922f98b32a9a46941abc97b604cfb116b0e42c64f005e5c002460fe141a4e5847 - languageName: node - linkType: hard - "metro-runtime@npm:0.81.1, metro-runtime@npm:^0.81.0": version: 0.81.1 resolution: "metro-runtime@npm:0.81.1" From 9ec0fef603682c4620c8cf8207476a08d2978408 Mon Sep 17 00:00:00 2001 From: tommasini Date: Thu, 7 May 2026 20:23:27 +0100 Subject: [PATCH 26/35] give missing permissions to build ios and build android --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 037020cbba5..a9eacd7aab6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -832,6 +832,9 @@ jobs: permissions: contents: read id-token: write + actions: read + statuses: read + pull-requests: read needs: [get_requirements, smart-e2e-selection, post-build-source-hash] uses: ./.github/workflows/build-android-e2e.yml with: @@ -871,6 +874,9 @@ jobs: permissions: contents: read id-token: write + actions: read + statuses: read + pull-requests: read needs: [get_requirements, smart-e2e-selection, post-build-source-hash] uses: ./.github/workflows/build-ios-e2e.yml with: From 0dde74afc6d42c0afbdd082bfcea015c6c4d22fc Mon Sep 17 00:00:00 2001 From: tommasini Date: Thu, 7 May 2026 20:29:35 +0100 Subject: [PATCH 27/35] address to low sev cursor bug bot --- .github/actions/check-force-builds/action.yml | 2 +- .github/workflows/build-android-e2e.yml | 7 ------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/actions/check-force-builds/action.yml b/.github/actions/check-force-builds/action.yml index dcd5c2f93ad..cead62e05db 100644 --- a/.github/actions/check-force-builds/action.yml +++ b/.github/actions/check-force-builds/action.yml @@ -68,7 +68,7 @@ runs: if [[ -n "$PR_NUMBER" ]]; then if gh pr view "$PR_NUMBER" --repo "$REPOSITORY" \ --json labels --jq '.labels[].name' \ - | grep -qx "$LABEL_NAME"; then + | grep --fixed-strings --line-regexp --quiet "$LABEL_NAME"; then echo "-> force=true because '$LABEL_NAME' label is applied to PR #$PR_NUMBER" FORCE="true" fi diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 353478147a8..0319e7db80a 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -77,13 +77,6 @@ jobs: ${{ env.GRADLE_USER_HOME }}/caches ${{ env.GRADLE_USER_HOME }}/wrapper - - name: Restore .metamask folder (Foundry download cache for install:foundryup) - if: ${{ inputs.runner_provider != 'namespace' }} - uses: actions/cache@v4 - with: - path: .metamask - key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} - # Use the canonical fingerprint computed upstream by `post-build-source-hash`. # Do NOT recompute it here — Cirrus ubuntu `node_modules/` differs from # ubuntu-latest `node_modules/` (different Node/yarn versions, prebuilt From 4b8f7f03cb5ae92d129e16101ca76a4fa22d6f9d Mon Sep 17 00:00:00 2001 From: tommasini Date: Thu, 7 May 2026 20:37:11 +0100 Subject: [PATCH 28/35] reuse setup e2e env to clean android e2e build file and add condition to not install foundry twice --- .github/actions/setup-e2e-env/action.yml | 11 +++ .github/workflows/build-android-e2e.yml | 113 +++++------------------ .github/workflows/build-ios-e2e.yml | 3 +- 3 files changed, 38 insertions(+), 89 deletions(-) diff --git a/.github/actions/setup-e2e-env/action.yml b/.github/actions/setup-e2e-env/action.yml index e84b7cdda72..5594b419c0f 100644 --- a/.github/actions/setup-e2e-env/action.yml +++ b/.github/actions/setup-e2e-env/action.yml @@ -69,6 +69,16 @@ inputs: description: 'Whether to configure keystores for E2E tests' required: false default: 'true' + install-foundry: + description: >- + Whether to install Foundry (anvil/cast/forge) and add `node_modules/.bin` + to `$GITHUB_PATH`. Set to `'false'` when the caller already runs + `yarn setup:github-ci` afterwards (which itself runs `install:foundryup` + via `installFoundryTask`), to avoid the redundant install step. Test + runner workflows that go straight to Detox/seeder without an intervening + `yarn setup:github-ci` MUST keep this enabled (default). + required: false + default: 'true' keystore-role-to-assume: description: 'AWS IAM role to assume for keystore configuration' required: false @@ -302,6 +312,7 @@ runs: YARN_ENABLE_GLOBAL_CACHE: 'true' - name: Install Foundry + if: ${{ inputs.install-foundry == 'true' }} shell: bash run: | echo "Installing Foundry via yarn install:foundryup (matches local dev and tests/seeder)..." diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 0319e7db80a..860f54916d2 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -197,32 +197,44 @@ jobs: shell: bash # ------------------------------------------------------------------------- - # Heavy native setup — only runs on a full native-build path. - # setup-e2e-env handles: keystore, Java, Android SDK + AVD, yarn install, - # Foundry. Pulling the AVD + emulator deps alone is ~1m that repack - # doesn't need. + # Shared environment setup — runs on BOTH the native-build path and the + # reuse-hit path. With `setup-simulator: false` the action only does what + # both paths need: ANDROID_HOME env vars, keystore (apksigner signs the + # APK in either path), Java, Node + yarn install. Foundry is intentionally + # skipped here; the follow-up `yarn setup:github-ci` step below installs + # it via `installFoundryTask` (one install instead of two). # ------------------------------------------------------------------------- - name: Setup Android Build Environment - if: ${{ steps.gate.outputs.needs-native-build == 'true' }} + if: ${{ inputs.runner_provider != 'namespace' }} timeout-minutes: 15 uses: ./.github/actions/setup-e2e-env with: platform: android setup-simulator: false configure-keystores: true - android-api-level: 36 - target: ${{ inputs.keystore_target }} # qa for taget=main and flask for target=flask + install-foundry: false + target: ${{ inputs.keystore_target }} # qa for target=main, flask for target=flask - name: Restore .metamask folder - if: ${{ steps.gate.outputs.needs-native-build == 'true' }} + if: ${{ inputs.runner_provider != 'namespace' }} id: restore-metamask uses: actions/cache@v4 with: path: .metamask key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} - - name: Setup project dependencies with retry + # ------------------------------------------------------------------------- + # Per-path JS setup. Both invocations of `yarn setup:github-ci` install + # Foundry, build the inpage bridge, patch-package, etc. The only + # meaningful difference is `jetifyTask`: + # - native build path uses `--no-build-ios` → BUILD_ANDROID=true → jetify runs + # - reuse-hit path uses `--no-build-android` → BUILD_ANDROID=false → jetify is skipped + # `yarn build:repack:android` doesn't need jetified node_modules, so the + # reuse-hit path skips that work. + # ------------------------------------------------------------------------- + + - name: Setup project dependencies with retry (native-build path) if: ${{ steps.gate.outputs.needs-native-build == 'true' }} uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: @@ -233,91 +245,16 @@ jobs: echo "🚀 Setting up project..." yarn setup:github-ci --no-build-ios - # ------------------------------------------------------------------------- - # Lean setup — only runs on the reuse-hit path. Replicates just the bits - # that `yarn build:repack:android` needs: - # - Java + ANDROID_HOME (apksigner is invoked by @expo/repack-app) - # - keystore (apksigner signs the repacked APK) - # - Node + yarn install (Metro bundler + @expo/repack-app) - # - lightweight setup (build-inpage-bridge, patch-package, etc.) - # Skips AVD creation, emulator deps, jetify, and Gradle — none are - # needed for repack, saving ~2-3m. - # ------------------------------------------------------------------------- - - - name: Set Android environment variables (reuse-hit path) - if: ${{ steps.gate.outputs.needs-native-build != 'true' }} - run: | - echo "ANDROID_HOME=/opt/android-sdk" >> "$GITHUB_ENV" - echo "ANDROID_SDK_ROOT=/opt/android-sdk" >> "$GITHUB_ENV" - shell: bash - - - name: Configure Android Signing Certificates (reuse-hit path) - if: ${{ steps.gate.outputs.needs-native-build != 'true' }} - uses: MetaMask/github-tools/.github/actions/configure-keystore@0259e8a920318b02a8860e178d79796eaa08de02 - with: - aws-role-to-assume: 'arn:aws:iam::363762752069:role/metamask-mobile-build-signer-qa' - aws-region: 'us-east-2' - platform: 'android' - target: ${{ inputs.keystore_target }} - - - name: Setup Java (reuse-hit path) - if: ${{ steps.gate.outputs.needs-native-build != 'true' }} - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 - with: - java-version: '17' - distribution: 'temurin' - - - name: Setup Node.js (reuse-hit path) - if: ${{ steps.gate.outputs.needs-native-build != 'true' }} - uses: actions/setup-node@v6 - with: - node-version: '20.18.0' - - - name: Enable corepack (reuse-hit path) - if: ${{ steps.gate.outputs.needs-native-build != 'true' }} - run: | - corepack enable - corepack prepare yarn@3.8.7 --activate - shell: bash - - # Matches the cache key used by `setup-e2e-env` so both paths share the - # same node_modules cache. - - name: Restore yarn cache (reuse-hit path) - if: ${{ steps.gate.outputs.needs-native-build != 'true' }} - uses: actions/cache@v4 - with: - path: | - node_modules - key: e2e-yarn-android-${{ runner.os }}-${{ hashFiles('yarn.lock') }} - - - name: Install JS dependencies (reuse-hit path) + - name: Setup project dependencies with retry (reuse-hit path) if: ${{ steps.gate.outputs.needs-native-build != 'true' }} uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: timeout_minutes: 10 max_attempts: 3 retry_wait_seconds: 30 - command: yarn install --immutable - env: - NODE_OPTIONS: --max-old-space-size=4096 - YARN_ENABLE_GLOBAL_CACHE: 'true' - - - name: Restore .metamask folder (reuse-hit path) - if: ${{ steps.gate.outputs.needs-native-build != 'true' }} - id: restore-metamask-lean - uses: actions/cache@v4 - with: - path: .metamask - key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} - - # `--no-build-ios --no-build-android` skips the native-only setup tasks - # (bundler, CocoaPods, jetify) but still runs the JS-side tasks that - # `yarn build:repack:android` depends on: build-inpage-bridge, - # patch-package, allow-scripts, install-foundry, terms-of-use. - - name: Run lightweight project setup (reuse-hit path) - if: ${{ steps.gate.outputs.needs-native-build != 'true' }} - run: yarn setup:github-ci --no-build-ios --no-build-android - shell: bash + command: | + echo "📦 Setting up project (lightweight, skips jetify)..." + yarn setup:github-ci --no-build-ios --no-build-android # ------------------------------------------------------------------------- # Gradle caches + native build — only on the full native-build path. diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index db6313f62c4..e7076cba7ce 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -175,7 +175,7 @@ jobs: ios/build key: ${{ runner.os }}-xcode-main-${{ env.XCODE_CACHE_VERSION }}-${{ hashFiles('ios/**/*.{h,m,mm,swift}', 'ios/**/Podfile.lock', 'yarn.lock') }} - # Install Node.js, Xcode tools, and other iOS development dependencies + # Install Node.js, Xcode tools, and other iOS development dependencies. - name: Installing iOS Environment Setup if: ${{ steps.gate.outputs.needs-native-build == 'true' }} timeout-minutes: 15 @@ -183,6 +183,7 @@ jobs: with: platform: ios setup-simulator: false + install-foundry: false - name: Print iOS tool versions if: ${{ steps.gate.outputs.needs-native-build == 'true' }} From a06210c443eac94a619e33c73cca2aa32ea6c986 Mon Sep 17 00:00:00 2001 From: tommasini Date: Thu, 7 May 2026 22:09:07 +0100 Subject: [PATCH 29/35] remove unnecessary permission on build ios e2e --- .github/workflows/build-ios-e2e.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index e7076cba7ce..d4cb84f3402 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -32,10 +32,6 @@ on: type: string default: current -permissions: - contents: read - id-token: write - jobs: build-ios-apps: name: Build iOS E2E Apps From 29a075ed2f6827a57b2e84fdf5dfe45cb6dda65b Mon Sep 17 00:00:00 2001 From: tommasini Date: Thu, 7 May 2026 22:20:42 +0100 Subject: [PATCH 30/35] remove unnecessary permission on build ios e2e --- .github/workflows/build-android-e2e.yml | 25 ------------------------- .github/workflows/build-ios-e2e.yml | 23 ----------------------- 2 files changed, 48 deletions(-) diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 860f54916d2..c526d021b2b 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -77,12 +77,6 @@ jobs: ${{ env.GRADLE_USER_HOME }}/caches ${{ env.GRADLE_USER_HOME }}/wrapper - # Use the canonical fingerprint computed upstream by `post-build-source-hash`. - # Do NOT recompute it here — Cirrus ubuntu `node_modules/` differs from - # ubuntu-latest `node_modules/` (different Node/yarn versions, prebuilt - # binaries), so recomputing per-runner would give a different value than - # the one posted as the `build-source-hash` commit status and break - # cross-run artifact reuse. - name: Report source fingerprint run: | if [[ -z "$SOURCE_FINGERPRINT" ]]; then @@ -196,15 +190,6 @@ jobs: fi shell: bash - # ------------------------------------------------------------------------- - # Shared environment setup — runs on BOTH the native-build path and the - # reuse-hit path. With `setup-simulator: false` the action only does what - # both paths need: ANDROID_HOME env vars, keystore (apksigner signs the - # APK in either path), Java, Node + yarn install. Foundry is intentionally - # skipped here; the follow-up `yarn setup:github-ci` step below installs - # it via `installFoundryTask` (one install instead of two). - # ------------------------------------------------------------------------- - - name: Setup Android Build Environment if: ${{ inputs.runner_provider != 'namespace' }} timeout-minutes: 15 @@ -224,16 +209,6 @@ jobs: path: .metamask key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} - # ------------------------------------------------------------------------- - # Per-path JS setup. Both invocations of `yarn setup:github-ci` install - # Foundry, build the inpage bridge, patch-package, etc. The only - # meaningful difference is `jetifyTask`: - # - native build path uses `--no-build-ios` → BUILD_ANDROID=true → jetify runs - # - reuse-hit path uses `--no-build-android` → BUILD_ANDROID=false → jetify is skipped - # `yarn build:repack:android` doesn't need jetified node_modules, so the - # reuse-hit path skips that work. - # ------------------------------------------------------------------------- - - name: Setup project dependencies with retry (native-build path) if: ${{ steps.gate.outputs.needs-native-build == 'true' }} uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index d4cb84f3402..3df95ea41bd 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -84,12 +84,6 @@ jobs: with: github-token: ${{ github.token }} - # Use the canonical fingerprint computed upstream by `post-build-source-hash`. - # Do NOT recompute it here — macOS `node_modules/` differs from Linux - # `node_modules/` in ways @expo/fingerprint hashes (prebuilt binaries, - # platform-conditional deps), so recomputing per-runner would give a - # different value than the one posted as the `build-source-hash` commit - # status and break cross-run artifact reuse. - name: Report source fingerprint run: | if [[ -z "$SOURCE_FINGERPRINT" ]]; then @@ -221,13 +215,6 @@ jobs: echo "🚀 Setting up project..." yarn setup:github-ci --build-ios --no-build-android - # ------------------------------------------------------------------------- - # Lean JS-only setup — only runs on the reuse-hit path so that - # `yarn build:repack:ios` has Node, @expo/repack-app, and the inpage - # bridge + terms-of-use assets. Skips Ruby/Bundler/CocoaPods/Xcode entirely - # (not needed for repack — saves ~6m wall time on cache-hit builds). - # ------------------------------------------------------------------------- - - name: Setup Node.js (reuse-hit path) if: ${{ steps.gate.outputs.needs-native-build != 'true' }} uses: actions/setup-node@v6 @@ -241,8 +228,6 @@ jobs: corepack prepare yarn@3.8.7 --activate shell: bash - # Matches the cache key used by `setup-e2e-env` so both paths share the - # same node_modules cache. - name: Restore yarn cache (reuse-hit path) if: ${{ steps.gate.outputs.needs-native-build != 'true' }} uses: actions/cache@v4 @@ -271,19 +256,11 @@ jobs: path: .metamask key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} - # `--no-build-ios --no-build-android` skips the native setup tasks - # (bundler, CocoaPods, jetify) but still runs the JS-side tasks that - # `yarn build:repack:ios` depends on: build-inpage-bridge, - # patch-package, allow-scripts, install-foundry, terms-of-use. - name: Run lightweight project setup (reuse-hit path) if: ${{ steps.gate.outputs.needs-native-build != 'true' }} run: yarn setup:github-ci --no-build-ios --no-build-android shell: bash - # ------------------------------------------------------------------------- - # Build (fresh) or Repack (reuse) — exactly one of these runs per job. - # ------------------------------------------------------------------------- - # Build the iOS E2E app for simulator - name: Build iOS E2E App if: ${{ steps.gate.outputs.needs-native-build == 'true' }} From 2dfff5422426d0f5cb92836f81ca75ed7ef2f713 Mon Sep 17 00:00:00 2001 From: tommasini Date: Thu, 7 May 2026 22:27:53 +0100 Subject: [PATCH 31/35] Missing JDK / ANDROID_HOME / keystore on Namespace; Redundant cirruslabs/cache runs on Namespace- it would have caused redundant restore and a redundant cache save at end-of-run on every Namespace build. Fixed by re-adding the gate --- .github/workflows/build-android-e2e.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index c526d021b2b..a991bb3f0ed 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -191,7 +191,6 @@ jobs: shell: bash - name: Setup Android Build Environment - if: ${{ inputs.runner_provider != 'namespace' }} timeout-minutes: 15 uses: ./.github/actions/setup-e2e-env with: @@ -201,6 +200,8 @@ jobs: install-foundry: false target: ${{ inputs.keystore_target }} # qa for target=main, flask for target=flask + # The Namespace cache action above already includes `.metamask`, so the + # dedicated actions/cache restore is only needed on non-Namespace runners. - name: Restore .metamask folder if: ${{ inputs.runner_provider != 'namespace' }} id: restore-metamask @@ -235,11 +236,15 @@ jobs: # Gradle caches + native build — only on the full native-build path. # ------------------------------------------------------------------------- + # Skipped on Namespace runners because the Namespace cache action above + # already handles `${GRADLE_USER_HOME}/caches` and `wrapper` — running + # cirruslabs/cache on top of it would be redundant work and a redundant + # save at end of run. - name: Restore Gradle dependencies from branch cache id: gradle-cache-restore # This action automatically updates the cache at the end of the workflow uses: cirruslabs/cache@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 - if: ${{ steps.gate.outputs.needs-native-build == 'true' }} + if: ${{ inputs.runner_provider != 'namespace' && steps.gate.outputs.needs-native-build == 'true' }} env: GRADLE_CACHE_VERSION: 1 with: @@ -254,7 +259,7 @@ jobs: - name: Restore Gradle dependencies from main cache # This will only restore the cache, not update it uses: cirruslabs/cache/restore@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 - if: ${{ steps.gate.outputs.needs-native-build == 'true' && steps.gradle-cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} + if: ${{ inputs.runner_provider != 'namespace' && steps.gate.outputs.needs-native-build == 'true' && steps.gradle-cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} env: GRADLE_CACHE_VERSION: 1 with: From d7dd2dae2c85194595e2ec807364390eee5c26e4 Mon Sep 17 00:00:00 2001 From: tommasini Date: Thu, 7 May 2026 22:30:54 +0100 Subject: [PATCH 32/35] =?UTF-8?q?Added=20steps.force-builds.outputs.force?= =?UTF-8?q?=20!=3D=20'true'=20to=20the=20if=20of=20both=20APK=20cache=20re?= =?UTF-8?q?store=20steps=20(apk-cache-restore=20and=20apk-cache-restore-ma?= =?UTF-8?q?in).=20With=20the=20step=20skipped,=20cache-hit=20stays=20empty?= =?UTF-8?q?,=20the=20gate's=20elif=20doesn't=20match,=20and=20we=20fall=20?= =?UTF-8?q?through=20to=20needs-native-build=3Dtrue=20=E2=80=94=20the=20ac?= =?UTF-8?q?tual=20fresh=20build.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-android-e2e.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index a991bb3f0ed..44fc8849a9d 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -117,7 +117,7 @@ jobs: github-token: ${{ github.token }} - name: Restore APKs matching fingerprint from branch cache - if: ${{ inputs.runner_provider != 'namespace' && steps.find-reusable-build.outputs.found != 'true' }} + if: ${{ inputs.runner_provider != 'namespace' && steps.force-builds.outputs.force != 'true' && steps.find-reusable-build.outputs.found != 'true' }} id: apk-cache-restore # This action automatically updates the cache at the end of the workflow uses: cirruslabs/cache@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 @@ -128,7 +128,7 @@ jobs: key: android-apk-${{ github.ref_name }}-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ inputs.source-fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - name: Restore APKs matching fingerprint from main cache - if: ${{ inputs.runner_provider != 'namespace' && steps.find-reusable-build.outputs.found != 'true' && steps.apk-cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} + if: ${{ inputs.runner_provider != 'namespace' && steps.force-builds.outputs.force != 'true' && steps.find-reusable-build.outputs.found != 'true' && steps.apk-cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} id: apk-cache-restore-main # This will only restore the cache, not update it uses: cirruslabs/cache/restore@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 From d2959a4111e2771188419f7ab5ee2ea2dc832b0b Mon Sep 17 00:00:00 2001 From: tommasini Date: Thu, 7 May 2026 23:06:57 +0100 Subject: [PATCH 33/35] test cache From b30dc0ea1b685ede22369f14f9b42cb469489fc3 Mon Sep 17 00:00:00 2001 From: tommasini Date: Thu, 7 May 2026 23:31:17 +0100 Subject: [PATCH 34/35] add retry to build ios e2e --- .github/workflows/build-ios-e2e.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index 3df95ea41bd..e5517b5b376 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -258,8 +258,14 @@ jobs: - name: Run lightweight project setup (reuse-hit path) if: ${{ steps.gate.outputs.needs-native-build != 'true' }} - run: yarn setup:github-ci --no-build-ios --no-build-android - shell: bash + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: | + echo "📦 Setting up project (lightweight, skips Xcode build)..." + yarn setup:github-ci --no-build-ios --no-build-android # Build the iOS E2E app for simulator - name: Build iOS E2E App From 362701ca0c37e01eeb4403c5377b12abc429a938 Mon Sep 17 00:00:00 2001 From: tommasini Date: Fri, 8 May 2026 00:41:59 +0100 Subject: [PATCH 35/35] address cursor bug bot --- .github/workflows/build-ios-e2e.yml | 4 ++-- .github/workflows/ci.yml | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index 593f25e010c..e5517b5b376 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -152,7 +152,7 @@ jobs: path: | ~/Library/Developer/Xcode/DerivedData ios/build - key: ${{ runner.os }}-xcode-${{ github.ref_name }}-${{ env.XCODE_CACHE_VERSION }}-${{ hashFiles('ios/**/*.{h,m,mm,swift}', 'ios/**/Podfile.lock', 'yarn.lock') }}-${{ github.run_id }} + key: ${{ runner.os }}-xcode-${{ github.ref_name }}-${{ env.XCODE_CACHE_VERSION }}-${{ hashFiles('ios/**/*.{h,m,mm,swift}', 'ios/**/Podfile.lock', 'yarn.lock') }} - name: Restore Xcode derived data from main cache if: ${{ steps.gate.outputs.needs-native-build == 'true' && steps.xcode-restore-cache.outputs.cache-hit != 'true' && github.ref_name != 'main' }} @@ -163,7 +163,7 @@ jobs: path: | ~/Library/Developer/Xcode/DerivedData ios/build - key: ${{ runner.os }}-xcode-main-${{ env.XCODE_CACHE_VERSION }}-${{ hashFiles('ios/**/*.{h,m,mm,swift}', 'ios/**/Podfile.lock', 'yarn.lock') }}-${{ github.run_id }} + key: ${{ runner.os }}-xcode-main-${{ env.XCODE_CACHE_VERSION }}-${{ hashFiles('ios/**/*.{h,m,mm,swift}', 'ios/**/Podfile.lock', 'yarn.lock') }} # Install Node.js, Xcode tools, and other iOS development dependencies. - name: Installing iOS Environment Setup diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9eacd7aab6..dd294508160 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,7 @@ jobs: post-build-source-hash: name: Post build-source-hash commit status runs-on: ubuntu-latest + if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }} permissions: contents: read statuses: write