diff --git a/.github/actions/check-force-builds/action.yml b/.github/actions/check-force-builds/action.yml new file mode 100644 index 00000000000..cead62e05db --- /dev/null +++ b/.github/actions/check-force-builds/action.yml @@ -0,0 +1,81 @@ +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` (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' + 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 }} + 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. + 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 + if [[ -n "$PR_NUMBER" ]]; then + if gh pr view "$PR_NUMBER" --repo "$REPOSITORY" \ + --json labels --jq '.labels[].name' \ + | 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 + 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..fe0b7095deb --- /dev/null +++ b/.github/actions/find-reusable-build/action.yml @@ -0,0 +1,255 @@ +name: 'Find reusable build from prior run' +description: >- + 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: + 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-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: + 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 }} + 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 }} + with: + github-token: ${{ inputs.github-token }} + script: | + const { + TARGET_FINGERPRINT, + ARTIFACT_NAMES_JSON, + WORKFLOW_FILE, + BASE_BRANCH, + STATUS_CONTEXT, + MAX_CANDIDATES, + MAX_CANDIDATES_CROSS_PR, + 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 maxCandidatesCrossPr = Number(MAX_CANDIDATES_CROSS_PR) || 30; + const currentRunId = String(CURRENT_RUN_ID); + + // 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) { + 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 { + 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; + } + } + + 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, + ...tier.params, + }); + runs = data.workflow_runs || []; + } catch (err) { + core.warning(`listWorkflowRuns failed for tier "${tier.label}": ${err.message}`); + continue; + } + + for (const run of runs) { + 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; + + const fingerprint = await getFingerprintForSha(run.head_sha); + if (!fingerprint) continue; + if (fingerprint !== TARGET_FINGERPRINT) continue; + + if (!(await hasAllArtifacts(run.id))) continue; + + core.info( + `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', runIdStr); + core.setOutput('source-sha', run.head_sha); + core.setOutput('source-branch', run.head_branch || ''); + return; + } + } + + core.info('No reusable build found across any tier'); + 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..9d766767c5e --- /dev/null +++ b/.github/actions/post-build-source-hash/action.yml @@ -0,0 +1,71 @@ +name: 'Post build-source-hash commit status' +description: >- + 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 + 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: ${{ inputs.target-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; + } + 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, + 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/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 f812b2317fd..2afc01d7fd5 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 runner_provider: description: Runner provider forwarded from the caller required: false @@ -50,6 +59,12 @@ jobs: - name: Checkout repo uses: actions/checkout@v6 + - name: Check force-builds override + id: force-builds + uses: ./.github/actions/check-force-builds + with: + github-token: ${{ github.token }} + - name: Configure Namespace cache if: ${{ inputs.runner_provider == 'namespace' }} uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1 @@ -62,40 +77,15 @@ 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') }} - - - 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: 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 - - # 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 + - 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); artifact 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 @@ -117,15 +107,17 @@ jobs: exit 1 fi - # TEMPORARY: `${{ github.run_id }}` makes every key unique per workflow - # run so we always get a fresh build during the RN 0.81 upgrade — the - # `yarn fingerprint:generate` heuristic doesn't track every native input - # being changed (yarn patches, MainApplication, Podfile shims, etc.) so - # the branch cache can serve a stale .apk and only the JS gets repacked. - # Remove the trailing `-${{ github.run_id }}` from each `key:` below - # once the upgrade is settled and fingerprint covers the touched paths. + - name: Find reusable build from prior run + id: find-reusable-build + if: ${{ inputs.runner_provider != 'namespace' && 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: Restore APKs matching fingerprint from branch cache - if: ${{ inputs.runner_provider != 'namespace' }} + 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 @@ -133,16 +125,10 @@ jobs: 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 }}-${{ steps.generate-fingerprint.outputs.fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}-${{ github.run_id }} + 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.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 @@ -150,53 +136,143 @@ jobs: 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 }}-${{ steps.generate-fingerprint.outputs.fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}-${{ github.run_id }} + key: android-apk-main-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ inputs.source-fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + - 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 + 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 + 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 + 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.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 + + - name: Compute native-build gate + id: gate + run: | + 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 (cross-run artifact download succeeded); heavy Android setup + Gradle restore will be skipped." + elif [[ "${{ steps.apk-cache-restore.outputs.cache-hit }}" == "true" \ + || "${{ steps.apk-cache-restore-main.outputs.cache-hit }}" == "true" ]]; then + echo "needs-native-build=false" >> "$GITHUB_OUTPUT" + echo "APK cache hit; 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 + shell: bash + + - name: Setup Android Build Environment + timeout-minutes: 15 + uses: ./.github/actions/setup-e2e-env + with: + platform: android + setup-simulator: false + configure-keystores: true + 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 + uses: actions/cache@v4 + with: + path: .metamask + key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} + + - 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: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: | + echo "🚀 Setting up project..." + yarn setup:github-ci --no-build-ios + + - 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: | + 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. + # ------------------------------------------------------------------------- + # 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: ${{ inputs.runner_provider != 'namespace' && steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true' }} + if: ${{ inputs.runner_provider != 'namespace' && steps.gate.outputs.needs-native-build == 'true' }} env: GRADLE_CACHE_VERSION: 1 with: 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" - key: gradle-${{ github.ref_name }}-${{ env.GRADLE_CACHE_VERSION }}-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}-${{ github.run_id }} + # 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 # This will only restore the cache, not update it uses: cirruslabs/cache/restore@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 - if: ${{ inputs.runner_provider != 'namespace' && 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: ${{ 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: 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" - key: gradle-main-${{ env.GRADLE_CACHE_VERSION }}-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}-${{ github.run_id }} + # 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 - if: ${{ inputs.runner_provider == 'namespace' || (steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true') }} + if: ${{ steps.gate.outputs.needs-native-build == 'true' }} run: | echo "🏗 Building Android E2E APKs..." export NODE_OPTIONS="--max-old-space-size=4096" @@ -245,7 +321,7 @@ jobs: MM_PREDICT_GTM_MODAL_ENABLED: 'false' - name: Repack APK with JS updates using @expo/repack-app - if: ${{ inputs.runner_provider != 'namespace' && (steps.apk-cache-restore.outputs.cache-hit == 'true' || steps.apk-cache-restore-main.outputs.cache-hit == '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 @@ -261,7 +337,7 @@ jobs: GITHUB_CI: 'true' CI: 'true' NODE_OPTIONS: '--max-old-space-size=8192' - METRO_MAX_WORKERS: '2' + METRO_MAX_WORKERS: '6' 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 2095d077355..6be48da940d 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -17,16 +17,21 @@ 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 runner_provider: description: Runner provider forwarded from the caller required: false type: string default: current -permissions: - contents: read - id-token: write - jobs: build-ios-apps: name: Build iOS E2E Apps @@ -36,9 +41,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: 4 - IOS_APP_CACHE_VERSION: 5 + XCODE_CACHE_VERSION: 1 RCT_NO_LAUNCH_PACKAGER: 1 XCODE_BUILD_SETTINGS: 'COMPILER_INDEX_STORE_ENABLE=NO' GITHUB_CI: 'true' # This ensures it's available during pod install @@ -75,25 +78,84 @@ jobs: - name: Checkout repo uses: actions/checkout@v6 - # TEMPORARY: `${{ github.run_id }}` makes every key unique per workflow - # run so we always get a fresh build during the RN 0.81 upgrade — the - # `yarn fingerprint:generate` heuristic doesn't track every native input - # being changed (yarn patches, AppDelegate, Podfile shims, etc.) so the - # branch cache can serve a stale .app and only the JS gets repacked. - # Remove the trailing `-${{ github.run_id }}` from each `key:` below - # once the upgrade is settled and fingerprint covers the touched paths. + - name: Check force-builds override + id: force-builds + uses: ./.github/actions/check-force-builds + with: + github-token: ${{ github.token }} + + - name: Report source fingerprint + run: | + if [[ -z "$SOURCE_FINGERPRINT" ]]; then + 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: Find reusable build from prior run + id: find-reusable-build + 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 }}-MetaMask.app"]' + 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 + 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.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 + + - name: Compute native-build gate + id: gate + run: | + 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 (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 + 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: 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.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 @@ -101,23 +163,20 @@ 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') }} - - name: Restore .metamask folder (Foundry download cache for install:foundryup) - uses: actions/cache@v4 - with: - path: .metamask - key: .metamask-${{ hashFiles('package.json', '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 uses: ./.github/actions/setup-e2e-env with: platform: ios setup-simulator: false + install-foundry: 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" @@ -133,10 +192,20 @@ 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: + path: .metamask + key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} + # 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 @@ -146,36 +215,61 @@ 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 + - 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: | - FINGERPRINT=$(yarn fingerprint:generate) - echo "fingerprint=$FINGERPRINT" >> "$GITHUB_OUTPUT" - echo "Current fingerprint: ${FINGERPRINT}" + corepack enable + corepack prepare yarn@3.8.7 --activate + shell: bash - - name: Restore iOS app matching fingerprint from branch cache - id: cache-restore - # This action automatically updates the cache at the end of the workflow - uses: cirruslabs/cache@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 + - 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-${{ github.ref_name }}-v${{ env.IOS_APP_CACHE_VERSION }}-${{ steps.generate-fingerprint.outputs.fingerprint }}-${{ github.run_id }} + node_modules + key: e2e-yarn-ios-${{ runner.os }}-${{ hashFiles('yarn.lock') }} - - name: Restore iOS app matching fingerprint from main cache - if: ${{ 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: Install JS dependencies (reuse-hit path) + if: ${{ steps.gate.outputs.needs-native-build != 'true' }} + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 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 }}-${{ github.run_id }} + 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') }} + + - name: Run lightweight project setup (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: | + 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 - if: ${{ steps.cache-restore.outputs.cache-hit != 'true' && steps.cache-restore-main.outputs.cache-hit != 'true' }} + if: ${{ steps.gate.outputs.needs-native-build == 'true' }} run: | echo "🏗 Building iOS E2E App..." export NODE_OPTIONS="--max-old-space-size=8192" @@ -205,7 +299,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.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 @@ -221,6 +315,7 @@ jobs: GITHUB_CI: 'true' CI: 'true' NODE_OPTIONS: '--max-old-space-size=8192' + METRO_MAX_WORKERS: '6' BRIDGE_USE_DEV_APIS: 'true' RAMP_INTERNAL_BUILD: 'true' SEEDLESS_ONBOARDING_ENABLED: 'true' @@ -276,11 +371,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' }} 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 bcdf6e82a0a..704d767485c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,41 @@ jobs: echo "No changes detected" fi + 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 + needs: + - get_requirements + 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' + 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 + id: publish + 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 runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} @@ -798,12 +833,16 @@ jobs: permissions: contents: read id-token: write - needs: [get_requirements, smart-e2e-selection] + 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: build_type: 'main' metamask_environment: 'e2e' keystore_target: 'qa' + source-fingerprint: ${{ needs.post-build-source-hash.outputs.fingerprint }} runner_provider: ${{ inputs.runner_provider }} secrets: inherit @@ -836,9 +875,13 @@ jobs: permissions: contents: read id-token: write - needs: [get_requirements, smart-e2e-selection] + 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: + source-fingerprint: ${{ needs.post-build-source-hash.outputs.fingerprint }} runner_provider: ${{ inputs.runner_provider }} secrets: inherit 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}`);