diff --git a/.depcheckrc.yml b/.depcheckrc.yml index 2e417fb658c5..7526d977f405 100644 --- a/.depcheckrc.yml +++ b/.depcheckrc.yml @@ -38,6 +38,8 @@ ignores: # ESBuild is used for AI E2E script compilation - 'esbuild' - 'esbuild-register' + # Used via `node -r @babel/register` in package.json scripts (not an import) + - '@babel/register' # tsx runs all scripts/tooling/*.ts files directly (CLI wrapper, MCP server, yarn pre/post hooks, report CLI) - 'tsx' # agent-device is used as a CLI (`yarn agent-device`), not imported @@ -152,6 +154,9 @@ ignores: # Used in Yarn plugin for preview builds - '@yarnpkg/core' + # Consumed by the Yarn plugin and agent hooks, outside of app source + - '@metamask/tooling-insight' + # Babel plugins referenced in babel.config.js by their short name (without # the `babel-plugin-` prefix). Babel resolves them correctly at build time # but depcheck doesn't recognize the indirect reference. See babel.config.js diff --git a/.detoxrc.js b/.detoxrc.js index 813928e2a520..2a53a826cd82 100644 --- a/.detoxrc.js +++ b/.detoxrc.js @@ -36,15 +36,6 @@ module.exports = { retries: process.env.CI ? 1 : 0, }, configurations: { - 'ios.sim.apiSpecs': { - device: 'ios.simulator', - app: process.env.CI ? `ios.${process.env.METAMASK_BUILD_TYPE}.release` : 'ios.debug', - testRunner: { - args: { - "$0": "node tests/smoke/api-specs/run-api-spec-tests.js", - }, - }, - }, 'android.emu.main': { device: 'android.emulator', app: 'android.debug', diff --git a/.e2e.env.example b/.e2e.env.example index 16ffed395632..0c18132bd6d0 100644 --- a/.e2e.env.example +++ b/.e2e.env.example @@ -28,6 +28,11 @@ export PREBUILT_ANDROID_TEST_APK_PATH='build/MetaMask-Test.apk' # export SKIP_APP_REINSTALL=true export SKIP_APP_REINSTALL=false +# Playwright Appium: reuse one WebDriver session per worker (emulator/simulator). +# Default is on when unset. Set false to force per-test deleteSession (rollback). +# BrowserStack ignores this and never reuses sessions. +# export APPIUM_SESSION_REUSE=false + export TEST_SRP_1= export TEST_SRP_2= export TEST_SRP_3= diff --git a/.eslintrc.js b/.eslintrc.js index cb1157a08dee..5a43d2823f0f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -84,8 +84,6 @@ const utilNumberImportBurndownFiles = [ 'app/components/UI/Stake/components/StakingBalance/StakingBanners/ClaimBanner/ClaimBanner.tsx', 'app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.test.tsx', 'app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.tsx', - 'app/components/UI/Stake/components/StakingConfirmation/YouReceiveCard/YouReceiveCard.test.tsx', - 'app/components/UI/Stake/components/StakingConfirmation/YouReceiveCard/YouReceiveCard.tsx', 'app/components/UI/Stake/hooks/useBalance.ts', 'app/components/UI/UrlAutocomplete/Result.tsx', 'app/components/Views/GasEducationCarousel/index.js', @@ -98,9 +96,7 @@ const utilNumberImportBurndownFiles = [ 'app/components/Views/confirmations/components/info/typed-sign-v3v4/simulation/components/native-value-display/native-value-display.tsx', 'app/components/Views/confirmations/components/info/typed-sign-v3v4/simulation/components/value-display/value-display.tsx', 'app/components/Views/confirmations/components/transactions/custom-amount/custom-amount.tsx', - 'app/components/Views/confirmations/context/send-context/utils.ts', 'app/components/Views/confirmations/external/staking/hooks/useStakingDetails.ts', - 'app/components/Views/confirmations/hooks/earn/useCustomAmount.tsx', 'app/components/Views/confirmations/hooks/gas/useCancelSpeedupGas/useCancelSpeedupGas.ts', 'app/components/Views/confirmations/hooks/send/useBalance.ts', 'app/components/Views/confirmations/hooks/send/useCurrencyConversions.ts', @@ -263,6 +259,55 @@ module.exports = { ], }, }, + // MMQA-2174 / MMQA-2173: re-apply after *.{ts,tsx} override which replaces no-restricted-syntax + { + files: ['tests/page-objects/**/*.{js,ts}', 'tests/flows/**/*.{js,ts}'], + excludedFiles: [ + 'tests/page-objects/**/*.test.ts', + 'tests/page-objects/**/*.test.js', + 'tests/flows/**/*.test.ts', + 'tests/flows/**/*.test.js', + ], + rules: { + // UnifiedGestures Identifier stays out of this error list so legacy PO + // usages remain warn-only via no-restricted-imports (MMQA-2174). + 'no-restricted-syntax': [ + 'error', + { + selector: 'WithStatement', + message: 'With statements are not allowed', + }, + { + selector: 'SequenceExpression', + message: 'Sequence expressions are not allowed', + }, + ], + }, + }, + { + files: ['tests/smoke-appium/**/*.{js,ts}', 'tests/smoke/**/*.{js,ts}'], + excludedFiles: [ + 'tests/smoke-appium/**/*.test.ts', + 'tests/smoke/**/*.test.ts', + ], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: 'WithStatement', + message: 'With statements are not allowed', + }, + { + selector: 'SequenceExpression', + message: 'Sequence expressions are not allowed', + }, + { + selector: "Identifier[name='UnifiedGestures']", + message: 'Use Gestures instead of UnifiedGestures.', + }, + ], + }, + }, { files: ['*.js', '*.jsx'], parser: '@babel/eslint-parser', diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 53b75e010e85..42604e4592ca 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -50,7 +50,6 @@ scripts/build.sh @MetaMask/mobile-pla fingerprint.config.js @MetaMask/mobile-platform builds.yml @MetaMask/mobile-platform .github/workflows/generate-build-version.yml @MetaMask/mobile-platform -.github/workflows/commit-build-version.yml @MetaMask/mobile-platform .github/workflows/push-eas-update.yml @MetaMask/mobile-admins .github/workflows/build-and-upload-to-testflight.yml @MetaMask/mobile-admins .github/workflows/upload-to-testflight.yml @MetaMask/mobile-admins @@ -373,9 +372,7 @@ tests/websocket/ @MetaMask/qa .github/workflows/run-appium-smoke-tests-ios.yml @MetaMask/qa .github/workflows/run-e2e-workflow.yml @MetaMask/qa .github/workflows/run-e2e-api-specs.yml @MetaMask/qa -.github/workflows/run-e2e-smoke-tests-android.yml @MetaMask/qa .github/workflows/run-e2e-smoke-tests-android-flask.yml @MetaMask/qa -.github/workflows/run-e2e-smoke-tests-ios.yml @MetaMask/qa .github/workflows/run-e2e-smoke-tests-ios-flask.yml @MetaMask/qa .github/workflows/rerun-ci-on-skipped-e2e-labels.yml @MetaMask/qa .github/workflows/update-e2e-fixtures.yml @MetaMask/qa @@ -383,8 +380,7 @@ tests/websocket/ @MetaMask/qa .github/workflows/flaky-test-report.yml @MetaMask/qa .github/workflows/performance-test-runner.yml @MetaMask/qa .github/workflows/run-performance-e2e.yml @MetaMask/qa -.github/workflows/run-performance-e2e-experimental.yml @MetaMask/qa -.github/workflows/run-performance-e2e-release.yml @MetaMask/qa +.github/workflows/run-performance-e2e-manual.yml @MetaMask/qa .github/workflows/run-system-tests.yml @MetaMask/qa .github/scripts/e2e-*.mjs @MetaMask/qa .github/scripts/collect-qa-stats.mjs @MetaMask/qa diff --git a/.github/actions/find-reusable-build/action.yml b/.github/actions/find-reusable-build/action.yml index 73bd8691b3e7..44321c93fbc7 100644 --- a/.github/actions/find-reusable-build/action.yml +++ b/.github/actions/find-reusable-build/action.yml @@ -14,6 +14,12 @@ description: >- `event: pull_request` to let the fingerprint itself act as the cross-PR deduplication key. + Each tier is swept once per run status (`success`, `in_progress`, `failure`, + then `completed`) so that one crowded page of recent runs cannot consume the + candidate window. A donor only needs to have uploaded its artifacts, not to + have finished: the native build publishes the app roughly 25-35 minutes before + the run completes, since E2E tests keep the run alive well past that point. + inputs: fingerprint: description: 'The @expo/fingerprint hash the candidate must match' @@ -44,17 +50,23 @@ inputs: 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)' + description: >- + How many recent runs to inspect per branch-scoped tier (same-branch, + base-branch), per conclusion sweep. Because sweeps filter server-side on + run conclusion, this budget is spent on terminal runs rather than on + in-flight ones — which matters most for the base-branch tier, where `main` + CI runs longer than the merge cadence, so a large share of the newest runs + are always still building. 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 `listCommitStatusesForRef` call per candidate - until a run-specific status match is found. + in the cross-PR tier, per conclusion sweep. The fingerprint filter is highly + discriminating, so the practical cost is one `listCommitStatusesForRef` call + per candidate until a run-specific status match is found. required: false - default: '30' + default: '50' main-branch-only: description: >- When true, only search the base branch (typically `main`) for reusable @@ -147,7 +159,7 @@ runs: : requiredArtifacts; const maxCandidates = Number(MAX_CANDIDATES) || 10; - const maxCandidatesCrossPr = Number(MAX_CANDIDATES_CROSS_PR) || 30; + const maxCandidatesCrossPr = Number(MAX_CANDIDATES_CROSS_PR) || 50; const currentRunId = String(CURRENT_RUN_ID); // Three-tier discovery: @@ -248,22 +260,84 @@ runs: const seenRunIds = new Set(); seenRunIds.add(currentRunId); - for (const tier of tiers) { - core.info(`Searching tier: ${tier.label}`); - let runs; + // Rate-limit accounting. The `/rate_limit` endpoint is itself free, so + // reading it before and after the search costs nothing and tells us how + // much of the hourly core budget one lookup actually consumes. + async function readRateLimit() { + try { + const { data } = await github.rest.rateLimit.get(); + return data.resources.core; + } catch (err) { + core.info(`rateLimit.get failed: ${err.message}`); + return null; + } + } + + const rateLimitBefore = await readRateLimit(); + + async function logRateLimit() { + const after = await readRateLimit(); + if (!after) return; + const used = rateLimitBefore ? rateLimitBefore.remaining - after.remaining : null; + const resetAt = new Date(after.reset * 1000).toISOString(); + core.info( + `Rate limit (core): ${after.remaining}/${after.limit} remaining, ` + + `${used === null ? 'unknown' : used} request(s) used by this lookup, resets at ${resetAt}`, + ); + if (after.remaining < after.limit * 0.1) { + core.warning( + `GitHub API core rate limit is low: ${after.remaining}/${after.limit} remaining until ${resetAt}`, + ); + } + } + + // Each tier is swept once per run status below, applied server-side via + // the `status` filter so that a single crowded page of recent runs can't + // consume a tier's candidate window. On a busy repo the newest runs are + // dominated by in-flight runs and by runs that concurrency cancelled + // mid-build; sweeping per status keeps each category's budget separate. + // + // Order matters, and it is deliberately not "terminal states first": + // + // success - a fully green run is the most conservative donor. + // in_progress - a run only needs to have *uploaded* its artifacts to be + // a valid donor; it does not need to have finished. The + // native build completes roughly 25-35 minutes before the + // run itself does, because E2E tests keep the run alive + // long after the `.app` is published. Skipping these runs + // discarded a complete, verified build for that entire + // window - measured at 8 of 18 cold-fingerprint rebuilds, + // one of them with a donor that had been ready 32 minutes. + // `hasAllArtifacts` below is what makes this safe: a run + // mid-upload simply fails that check and is skipped. + // failure - a run can fail on a job unrelated to the native build + // while still holding complete artifacts. + // completed - last resort; picks up cancelled, timed-out and neutral + // runs, preserving reach. + // + // `queued` is intentionally not swept: such runs cannot have artifacts yet, + // so they would only cost API calls. + const statusSweeps = ['success', 'in_progress', 'failure', 'completed']; + + async function listCandidates(tier, status) { try { const { data } = await github.rest.actions.listWorkflowRuns({ owner: context.repo.owner, repo: context.repo.repo, workflow_id: WORKFLOW_FILE, + status, ...tier.params, }); - runs = data.workflow_runs || []; + return data.workflow_runs || []; } catch (err) { - core.warning(`listWorkflowRuns failed for tier "${tier.label}": ${err.message}`); - continue; + core.warning( + `listWorkflowRuns failed for tier "${tier.label}" (status=${status}): ${err.message}`, + ); + return []; } + } + async function findMatch(tier, runs) { for (const run of runs) { const runIdStr = String(run.id); if (seenRunIds.has(runIdStr)) continue; @@ -271,8 +345,9 @@ runs: if (tier.skipHeadBranch && run.head_branch === tier.skipHeadBranch) continue; - if (run.status !== 'completed') continue; - + // Deliberately no `run.status === 'completed'` gate: artifact + // availability, not run completion, is what makes a donor usable, and + // `hasAllArtifacts` verifies that below. if (!(await hasRunFingerprintStatus(run.head_sha, run.id))) { core.info(`Run ${run.id} has no run-specific ${STATUS_CONTEXT} status matching fingerprint ${TARGET_FINGERPRINT}`); continue; @@ -280,16 +355,33 @@ runs: if (!(await hasAllArtifacts(run.id))) continue; + return run; + } + return null; + } + + for (const tier of tiers) { + core.info(`Searching tier: ${tier.label}`); + + for (const status of statusSweeps) { + const runs = await listCandidates(tier, status); + core.info(` sweep status=${status}: ${runs.length} run(s) returned`); + + const match = await findMatch(tier, runs); + if (!match) continue; + core.info( - `Match: tier="${tier.label}" run=${run.id} sha=${run.head_sha} branch=${run.head_branch} url=${run.html_url}`, + `Match: tier="${tier.label}" status=${status} run=${match.id} sha=${match.head_sha} branch=${match.head_branch} url=${match.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 || ''); + core.setOutput('run-id', String(match.id)); + core.setOutput('source-sha', match.head_sha); + core.setOutput('source-branch', match.head_branch || ''); + await logRateLimit(); return; } } core.info('No reusable build found across any tier'); + await logRateLimit(); setNotFound(); diff --git a/.github/actions/setup-e2e-env/action.yml b/.github/actions/setup-e2e-env/action.yml index d45840252717..d6b62cf38485 100644 --- a/.github/actions/setup-e2e-env/action.yml +++ b/.github/actions/setup-e2e-env/action.yml @@ -325,12 +325,22 @@ runs: if: ${{ inputs.runner_provider != 'namespace' && inputs.runner_provider != 'bitrise' }} uses: actions/cache@v4 with: + # NOTE: actions/cache archives directories recursively and does NOT + # honour `!` exclusion patterns for directory contents (resolvePaths + # runs with implicitDescendants: false). Gradle/CMake intermediates + # that Android native builds write inside node_modules (~4-5 GB + # uncompressed) are instead deleted by the "Clean native build + # intermediates from node_modules" step in build-android-e2e.yml + # before the post-job save runs. path: | node_modules .yarn/install-state.gz - key: ${{ inputs.cache-prefix }}-yarn-v2-${{ inputs.platform }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + # v3: bump to drop existing entries polluted with those build + # intermediates — a cache hit is never re-saved, so without the bump + # the fat archives would keep being served forever. + key: ${{ inputs.cache-prefix }}-yarn-v3-${{ inputs.platform }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} restore-keys: | - ${{ inputs.cache-prefix }}-yarn-v2-${{ inputs.platform }}-${{ runner.os }}- + ${{ inputs.cache-prefix }}-yarn-v3-${{ inputs.platform }}-${{ runner.os }}- continue-on-error: true - name: Install JavaScript dependencies with retry diff --git a/.github/actions/smart-e2e-selection/action.yml b/.github/actions/smart-e2e-selection/action.yml index 26281b8b2660..fc721eacf691 100644 --- a/.github/actions/smart-e2e-selection/action.yml +++ b/.github/actions/smart-e2e-selection/action.yml @@ -155,8 +155,8 @@ runs: echo "SKIPPED=true" >> "$GITHUB_OUTPUT" echo "SKIP_REASON=PR targets a release or stable branch (release/* or stable)" >> "$GITHUB_OUTPUT" echo "ai_confidence=100" >> "$GITHUB_OUTPUT" - # Release/stable target → the scheduled performance workflow covers these branches. - # No need to also trigger the PR performance job. + # Release/stable target → performance runs are label-only. Return [] + # so AI output cannot automatically trigger the PR performance job. echo "ai_performance_test_tags=[]" >> "$GITHUB_OUTPUT" else echo "✅ Running AI analysis for PR #$PR_NUMBER" diff --git a/.github/guidelines/E2E_DECISION_TREE.md b/.github/guidelines/E2E_DECISION_TREE.md index c938e6fd1c97..c113d08b02e2 100644 --- a/.github/guidelines/E2E_DECISION_TREE.md +++ b/.github/guidelines/E2E_DECISION_TREE.md @@ -61,7 +61,7 @@ Runs only when all of the following are true: ## (Exceptional) force Appium iOS smoke tests on PRs -Appium iOS smoke tests are skipped on PRs by default (they still run on every `main` push/schedule). To also run them on a PR, add the `run-appium-ios-tests` label. Smart E2E Selection still controls which suites run. CI re-runs automatically when the label is added or removed. +Appium iOS smoke tests are skipped on PRs by default (they still run on every `main` push/schedule). They also run automatically on a PR when shared smoke infra or Appium specs change (`tests/page-objects/**`, `tests/selectors/**`, `tests/locators/**`, `tests/framework/**`, `tests/smoke-appium/**`). To force them on any other PR, add the `run-appium-ios-tests` label. Smart E2E Selection still controls which suites run. CI re-runs automatically when the label is added or removed. ## E2E flakiness detection in PRs diff --git a/.github/guidelines/LABELING_GUIDELINES.md b/.github/guidelines/LABELING_GUIDELINES.md index ba9b080480aa..697de723ea3e 100644 --- a/.github/guidelines/LABELING_GUIDELINES.md +++ b/.github/guidelines/LABELING_GUIDELINES.md @@ -45,7 +45,7 @@ Using any of these labels should be exceptional in case of CI friction and urgen ### Force Appium iOS Smoke Tests -- **run-appium-ios-tests**: Also runs Appium iOS smoke tests on a PR (normally they run only on `main` push/schedule). Uses the same Smart E2E Selection tags as Detox/Appium Android — it does not bypass path filters, build gates, or AI tag selection. Remove `pr-not-ready-for-e2e` when the PR is ready for E2E validation. Adding or removing this label re-triggers CI. Not honored on fork PRs. +- **run-appium-ios-tests**: Also runs Appium iOS smoke tests on a PR (normally they run on `main` push/schedule, or automatically when `tests/smoke-appium/**` / shared smoke infra paths change). Uses the same Smart E2E Selection tags as Detox/Appium Android — it does not bypass path filters, build gates, or AI tag selection. Remove `pr-not-ready-for-e2e` when the PR is ready for E2E validation. Adding or removing this label re-triggers CI. Not honored on fork PRs. ### Block merge if any is present diff --git a/.github/rules/filter-rules.yml b/.github/rules/filter-rules.yml index 231d7a263e85..1f103954f1a5 100644 --- a/.github/rules/filter-rules.yml +++ b/.github/rules/filter-rules.yml @@ -129,12 +129,14 @@ ios_or_ignorable: - *config_files - *ci_files -# Shared smoke test infrastructure whose changes can break Appium iOS smoke tests. +# Shared smoke / Appium paths whose changes should also run Appium iOS on PRs +# (Appium Android already runs whenever Android E2E builds succeed). e2e_smoke_infra: - 'tests/page-objects/**' - 'tests/selectors/**' - 'tests/locators/**' - 'tests/framework/**' + - 'tests/smoke-appium/**' all_changes: - '**' diff --git a/.github/scripts/add-release-label-to-pr-and-linked-issues.ts b/.github/scripts/add-release-label-to-pr-and-linked-issues.ts index 45fb21cc32c9..5d61de55a2fb 100644 --- a/.github/scripts/add-release-label-to-pr-and-linked-issues.ts +++ b/.github/scripts/add-release-label-to-pr-and-linked-issues.ts @@ -8,6 +8,13 @@ import { Labelable, addLabelToLabelable } from './shared/labelable'; import { retrievePullRequest } from './shared/pull-request'; import { isValidVersionFormat } from './shared/utils'; +// Extracts version from cherry-pick PR titles (e.g., "cp-8.3.0" -> "8.3.0") +function extractCherryPickVersion(title: string): string | null { + const cherryPickPattern = /cp-(\d+\.\d+\.\d+)/i; + const match = title.match(cherryPickPattern); + return match ? match[1] : null; +} + main().catch((error: Error): void => { console.error(error); process.exit(1); @@ -42,12 +49,31 @@ async function main(): Promise { process.exit(1); } - // Release label indicates the next release version number + // Use cherry-pick version from PR title if present, otherwise use next release version + // CodeQL: user-controlled input is intentional; worst case is mislabeling, caught during release validation + const pullRequestTitle = context.payload.pull_request?.title || ''; + const cherryPickVersion = extractCherryPickVersion(pullRequestTitle); + + let releaseVersionNumber: string; + if (cherryPickVersion) { + if (!isValidVersionFormat(cherryPickVersion)) { + core.setFailed( + `Cherry-pick version (${cherryPickVersion}) extracted from PR title is not a valid version format. The expected format is "x.y.z", where "x", "y" and "z" are numbers.`, + ); + process.exit(1); + } + core.info(`Cherry-pick detected, using release version ${cherryPickVersion}`); + releaseVersionNumber = cherryPickVersion; + } else { + releaseVersionNumber = nextReleaseVersionNumber; + } + + // Release label indicates the release version number // Example release label: "release-6.5.0" const releaseLabel: Label = { - name: `release-${nextReleaseVersionNumber}`, + name: `release-${releaseVersionNumber}`, color: 'EDEDED', - description: `Issue or pull request that will be included in release ${nextReleaseVersionNumber}`, + description: `Issue or pull request that will be included in release ${releaseVersionNumber}`, }; // Initialise octokit, required to call Github GraphQL API diff --git a/.github/scripts/aggregate-appium-timings.ts b/.github/scripts/aggregate-appium-timings.ts new file mode 100644 index 000000000000..c27205dd9569 --- /dev/null +++ b/.github/scripts/aggregate-appium-timings.ts @@ -0,0 +1,50 @@ +#!/usr/bin/env node +/* eslint-disable import-x/no-nodejs-modules */ +/** + * CLI entry for aggregating Appium phase-timing JSON artifacts. + * + * Usage: + * yarn appium-smoke:aggregate-timings + * yarn tsx .github/scripts/aggregate-appium-timings.ts [--input ] [--markdown ] + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + aggregateTimingSuites, + collectTimingFiles, + DEFAULT_INPUT_DIR, + formatTrendMarkdown, + loadTimingFile, + parseArgs, + type TimingSuiteFile, +} from '../../tests/framework/telemetry/aggregatePhaseTimings.ts'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../..'); + +function main(argv: string[] = process.argv.slice(2)): void { + const opts = parseArgs(argv, resolve(repoRoot, DEFAULT_INPUT_DIR)); + const files = collectTimingFiles(opts.input); + const suites = files + .map((f) => loadTimingFile(f)) + .filter((s): s is TimingSuiteFile => s !== null); + + if (suites.length === 0) { + console.error( + `No Appium timing JSON found under ${opts.input}. Run smoke tests first or pass --input.`, + ); + process.exitCode = 1; + return; + } + + const report = aggregateTimingSuites(suites); + const markdown = formatTrendMarkdown(report); + process.stdout.write(markdown); + + if (opts.markdown) { + mkdirSync(dirname(opts.markdown), { recursive: true }); + writeFileSync(opts.markdown, markdown, 'utf8'); + } +} + +main(); diff --git a/.github/scripts/run-compute-e2e-platform-flags.cjs b/.github/scripts/run-compute-e2e-platform-flags.cjs index 1e7fc6fab6fc..abcab735d7da 100644 --- a/.github/scripts/run-compute-e2e-platform-flags.cjs +++ b/.github/scripts/run-compute-e2e-platform-flags.cjs @@ -61,7 +61,7 @@ if ( } else if (readInt(process.env.E2E_SMOKE_INFRA_COUNT) > 0) { runAppiumIos = true; console.log( - '-> RUN_APPIUM_IOS=true due to e2e smoke infra changes (page-objects/selectors/locators/framework)', + '-> RUN_APPIUM_IOS=true due to e2e smoke infra changes (page-objects/selectors/locators/framework/smoke-appium)', ); } } diff --git a/.github/workflows/auto-rc-ota-build-core.yml b/.github/workflows/auto-rc-ota-build-core.yml index 5abd58ba7649..10ee31998d78 100644 --- a/.github/workflows/auto-rc-ota-build-core.yml +++ b/.github/workflows/auto-rc-ota-build-core.yml @@ -43,6 +43,11 @@ on: required: false type: boolean default: true + build_number: + description: 'Build number from generate-build-version.yml to apply locally at build time' + required: false + type: string + default: '' outputs: semantic_version: description: 'package.json version at the built commit (empty when OTA bump skips the build)' @@ -76,6 +81,7 @@ jobs: build_name: ${{ inputs.build_name }} platform: ${{ inputs.platform }} source_branch: ${{ inputs.source_branch || github.ref_name }} + build_number: ${{ inputs.build_number }} upload_to_sentry: true secrets: inherit diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 237f6b4282f2..f815a082df35 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -138,6 +138,14 @@ jobs: echo "❌ Error: build_type ${{ inputs.build_type }} is not valid" exit 1 fi + # Captured here, pre-setup, so the reuse summary can print the same APK + # cache keys the restore steps below actually used. `hashFiles` is + # evaluated wherever it is written, and the summary now runs after the + # build — by then jetify has rewritten `*.gradle` files under + # `node_modules/*/android/`, so an inline `hashFiles` there would render + # keys that were never used, in exactly the row someone is reading to + # debug a cache miss. + echo "gradle-files-hash=${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}" >> "$GITHUB_OUTPUT" - name: Check Namespace E2E APK cache id: namespace-apk-cache @@ -349,79 +357,6 @@ jobs: fi shell: bash - - name: Write Android E2E reuse summary - if: ${{ always() }} - shell: bash - env: - SOURCE_FINGERPRINT: ${{ inputs.source-fingerprint }} - BUILD_TYPE: ${{ inputs.build_type }} - METAMASK_ENVIRONMENT: ${{ inputs.metamask_environment }} - REUSABLE_FOUND: ${{ steps.find-reusable-build.outputs.found }} - REUSABLE_RUN_ID: ${{ steps.find-reusable-build.outputs.run-id }} - METADATA_VALID: ${{ steps.validate-reusable-metadata.outputs.valid }} - APK_DOWNLOAD_OUTCOME: ${{ steps.download-reusable-apk.outcome }} - TEST_APK_DOWNLOAD_OUTCOME: ${{ steps.download-reusable-test-apk.outcome }} - NAMESPACE_CACHE_HIT: ${{ steps.namespace-apk-cache.outputs.cache-hit }} - BRANCH_CACHE_HIT: ${{ steps.apk-cache-restore.outputs.cache-hit }} - MAIN_CACHE_HIT: ${{ steps.apk-cache-restore-main.outputs.cache-hit }} - BRANCH_CACHE_KEY: android-apk-${{ github.ref_name }}-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ inputs.source-fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - MAIN_CACHE_KEY: android-apk-main-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ inputs.source-fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - NEEDS_NATIVE_BUILD: ${{ steps.gate.outputs.needs-native-build }} - REPOSITORY: ${{ github.repository }} - run: | - value() { - if [[ -n "${1:-}" ]]; then - printf '%s' "$1" - else - printf 'n/a' - fi - } - - yes_no() { - case "${1:-}" in - true) printf 'yes' ;; - *) printf 'no' ;; - esac - } - - github_candidate="$(yes_no "$REUSABLE_FOUND")" - if [[ -n "${REUSABLE_RUN_ID:-}" ]]; then - github_candidate="yes ([https://github.com/$REPOSITORY/actions/runs/$REUSABLE_RUN_ID](https://github.com/$REPOSITORY/actions/runs/$REUSABLE_RUN_ID))" - fi - - decision="Fresh native build" - if [[ "${NAMESPACE_CACHE_HIT:-}" == "true" ]]; then - decision="Namespace APK cache" - elif [[ "${REUSABLE_FOUND:-}" == "true" && "${METADATA_VALID:-}" == "true" && "${APK_DOWNLOAD_OUTCOME:-}" == "success" && "${TEST_APK_DOWNLOAD_OUTCOME:-}" == "success" ]]; then - decision="GitHub artifact reuse" - elif [[ "${BRANCH_CACHE_HIT:-}" == "true" || "${MAIN_CACHE_HIT:-}" == "true" ]]; then - decision="Cirrus APK cache" - elif [[ "${NEEDS_NATIVE_BUILD:-}" == "false" ]]; then - decision="reuse/cache hit" - fi - - branch_cache="$(yes_no "$BRANCH_CACHE_HIT")" - main_cache="$(yes_no "$MAIN_CACHE_HIT")" - if [[ "${BRANCH_CACHE_HIT:-}" == "true" ]]; then - branch_cache="yes (\`$BRANCH_CACHE_KEY\`)" - elif [[ "${MAIN_CACHE_HIT:-}" == "true" ]]; then - main_cache="yes (\`$MAIN_CACHE_KEY\`)" - fi - - { - echo "### Android E2E build reuse" - echo "" - echo "| Field | Value |" - echo "| --- | --- |" - echo "| Decision | $decision |" - echo "| Native build fingerprint | \`$(value "$SOURCE_FINGERPRINT")\` |" - echo "| Build type | \`$(value "$BUILD_TYPE")\` |" - echo "| MetaMask environment | \`$(value "$METAMASK_ENVIRONMENT")\` |" - echo "| Branch APK cache hit | $branch_cache |" - echo "| Main APK cache hit | $main_cache |" - echo "| GitHub artifact candidate | $github_candidate |" - } >> "$GITHUB_STEP_SUMMARY" - - name: Setup Android Build Environment timeout-minutes: 15 uses: ./.github/actions/setup-e2e-env @@ -464,6 +399,150 @@ jobs: echo "📦 Setting up project (lightweight, skips jetify)..." yarn setup:github-ci --no-build-ios --no-build-android + # Roughly four and a half minutes elapse between the lookup near the top of + # this job and the build step below, spent on the Android toolchain and + # dependency install. A donor can finish uploading inside that window: the + # native build publishes its APKs well before its run ends, so a run that had + # nothing to offer at lookup time may have complete artifacts by now. Re-check + # before committing to a ~18 minute compile. This costs one lookup on the path + # that was about to be the expensive one, and adds no waiting — it only + # harvests a donor that appeared while we were busy anyway. + # + # Deliberately placed *before* the Gradle cache restores rather than directly + # before the build, which is where the iOS equivalent sits. "Restore Gradle + # dependencies from branch cache" below is `cirruslabs/cache` (not `/restore`), + # so it saves at post-job whenever its key missed. Today a miss is always + # followed by a compile, so what it saves is populated; if a late reuse could + # skip the compile after that step had run, the post-job save would write a + # near-empty tree under `gradle-${{ github.ref_name }}-…` — a key that on + # `main` is identical to the shared `gradle-main-…` key every other branch + # falls back to. Running the re-check first, and gating both restores on it, + # removes that possibility and skips the cache download too. The cost is ~27 + # seconds of harvest window out of ~4.5 minutes. + - name: Re-check for a reusable build published since the first lookup + id: find-reusable-build-late + if: ${{ inputs.runner_provider != 'namespace' && steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' && inputs.include_arm64 == false && steps.gate.outputs.needs-native-build == 'true' }} + 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"]' + metadata-artifact-name: ${{ inputs.reuse-main-builds-only != true && env.E2E_BUILD_METADATA_ARTIFACT || '' }} + github-token: ${{ github.token }} + main-branch-only: ${{ inputs.reuse-main-builds-only }} + max-candidates-per-branch: ${{ inputs.reuse-main-builds-only && '30' || '10' }} + + - name: Download late reusable Android build metadata + id: download-late-metadata + if: ${{ steps.find-reusable-build-late.outputs.found == 'true' }} + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: ${{ env.E2E_BUILD_METADATA_ARTIFACT }} + path: .e2e-reusable-metadata/android-late + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ steps.find-reusable-build-late.outputs.run-id }} + + # Same acceptance criteria as the first lookup's validation. A late donor is + # held to an identical standard; the only difference is when it was found. + # Failures here are logged plainly rather than via `::warning::` — not finding + # a late donor is the ordinary outcome and should not annotate the run. + - name: Validate late reusable Android build metadata + id: validate-late-metadata + if: ${{ steps.find-reusable-build-late.outputs.found == 'true' }} + shell: bash + env: + EXPECTED_FINGERPRINT: ${{ inputs.source-fingerprint }} + EXPECTED_BUILD_TYPE: ${{ inputs.build_type }} + EXPECTED_METAMASK_ENVIRONMENT: ${{ inputs.metamask_environment }} + EXPECTED_RUN_ID: ${{ steps.find-reusable-build-late.outputs.run-id }} + EXPECTED_HEAD_SHA: ${{ steps.find-reusable-build-late.outputs.source-sha }} + EXPECTED_ARTIFACTS: '["${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.apk","${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release-androidTest.apk"]' + METADATA_FILE: .e2e-reusable-metadata/android-late/android.json + run: | + set -euo pipefail + echo "valid=false" >> "$GITHUB_OUTPUT" + if [[ "${{ steps.download-late-metadata.outcome }}" != "success" ]]; then + echo "Late reusable Android metadata download failed; continuing with the fresh native build." + exit 0 + fi + if [[ ! -f "$METADATA_FILE" ]]; then + echo "Late reusable Android metadata file missing at $METADATA_FILE; continuing with the fresh native build." + exit 0 + fi + if jq -e \ + --arg fingerprint "$EXPECTED_FINGERPRINT" \ + --arg buildType "$EXPECTED_BUILD_TYPE" \ + --arg metamaskEnvironment "$EXPECTED_METAMASK_ENVIRONMENT" \ + --arg workflowRunId "$EXPECTED_RUN_ID" \ + --arg headSha "$EXPECTED_HEAD_SHA" \ + --argjson artifactNames "$EXPECTED_ARTIFACTS" \ + '.schemaVersion == 1 + and .fingerprint == $fingerprint + and .platform == "android" + and .buildType == $buildType + and .metamaskEnvironment == $metamaskEnvironment + and .workflowRunId == $workflowRunId + and .headSha == $headSha + and .artifactNames == $artifactNames' "$METADATA_FILE" >/dev/null; then + echo "valid=true" >> "$GITHUB_OUTPUT" + echo "Late reusable Android metadata is valid." + else + echo "Late reusable Android metadata did not match current build request; continuing with the fresh native build." + cat "$METADATA_FILE" + fi + + # Defensive, not load-bearing. The iOS equivalent of this step is required + # there because `actions/download-artifact` merges into its destination and the + # iOS payload is a `.app` *directory* bundle, so a stale one would blend with + # the donor's. An APK is a single file that the extraction overwrites whole. + # Nothing on this path should have written these files either: the Cirrus APK + # caches above only restore when the first lookup found nothing, and a hit + # there forces `needs-native-build=false`, which this step cannot be reached + # under. Kept as cheap insurance against a partial restore — if the download + # then fails, the compile still runs and regenerates both files. + - name: Clear any stale Android APKs before the late download + if: ${{ steps.validate-late-metadata.outputs.valid == 'true' }} + shell: bash + env: + APK_TARGET: ${{ steps.determine-target-paths.outputs.apk-target-path }} + TEST_APK_TARGET: ${{ steps.determine-target-paths.outputs.test-apk-target-path }} + ARTIFACT_NAME: ${{ steps.determine-target-paths.outputs.artifact_name }} + run: rm -f "${APK_TARGET}/${ARTIFACT_NAME}.apk" "${TEST_APK_TARGET}/${ARTIFACT_NAME}-androidTest.apk" + + - name: Download late reusable APK + id: download-late-apk + if: ${{ steps.validate-late-metadata.outputs.valid == 'true' && inputs.include_arm64 == false }} + 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-late.outputs.run-id }} + + - name: Download late reusable androidTest APK + id: download-late-test-apk + if: ${{ steps.validate-late-metadata.outputs.valid == 'true' && inputs.include_arm64 == false }} + 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-late.outputs.run-id }} + + - name: Log late-reused Android build source + if: ${{ steps.download-late-apk.outcome == 'success' && steps.download-late-test-apk.outcome == 'success' }} + shell: bash + run: | + echo "A reusable Android build appeared while this job was setting up; skipping the native compile." + echo "Reusing Android build from run ${{ steps.find-reusable-build-late.outputs.run-id }}" + echo "Source SHA: ${{ steps.find-reusable-build-late.outputs.source-sha }}" + echo "Source branch: ${{ steps.find-reusable-build-late.outputs.source-branch }}" + # ------------------------------------------------------------------------- # Gradle caches + native build — only on the full native-build path. # ------------------------------------------------------------------------- @@ -476,7 +555,7 @@ jobs: 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.gate.outputs.needs-native-build == 'true' }} + if: ${{ inputs.runner_provider != 'namespace' && steps.gate.outputs.needs-native-build == 'true' && !(steps.download-late-apk.outcome == 'success' && steps.download-late-test-apk.outcome == 'success') }} env: GRADLE_CACHE_VERSION: 1 with: @@ -491,7 +570,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: ${{ inputs.runner_provider != 'namespace' && 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' && !(steps.download-late-apk.outcome == 'success' && steps.download-late-test-apk.outcome == 'success') }} env: GRADLE_CACHE_VERSION: 1 with: @@ -511,8 +590,10 @@ jobs: echo "init-script=$RUNNER_TEMP/namespace-gradle-init.gradle" >> "$GITHUB_OUTPUT" shell: bash + # Both downloads must have succeeded to skip the compile — a half-downloaded + # donor leaves the job without a test APK, so it falls through to a full build. - name: Build Android E2E APKs - if: ${{ steps.gate.outputs.needs-native-build == 'true' }} + if: ${{ steps.gate.outputs.needs-native-build == 'true' && !(steps.download-late-apk.outcome == 'success' && steps.download-late-test-apk.outcome == 'success') }} run: | echo "🏗 Building Android E2E APKs..." export NODE_OPTIONS="--max-old-space-size=4096" @@ -582,8 +663,10 @@ jobs: printf '%s\n' "${EXPECTED}" > "${CACHE_DIR}/.e2e-apk-cache-marker" echo "Namespace APK cache marker + APKs written to ${CACHE_DIR}." + # A late-reused APK carries the donor's JS bundle, not this branch's, so a late + # reuse must repack exactly like an early one. - name: Repack APK with JS updates using @expo/repack-app - if: ${{ steps.gate.outputs.needs-native-build != 'true' && inputs.reuse-main-builds-only != true }} + if: ${{ inputs.reuse-main-builds-only != true && (steps.gate.outputs.needs-native-build != 'true' || (steps.download-late-apk.outcome == 'success' && steps.download-late-test-apk.outcome == 'success')) }} run: | echo "📦 Repacking APK with updated JavaScript bundle using @expo/repack-app..." # Use the optimized repack script which uses @expo/repack-app @@ -622,6 +705,118 @@ jobs: GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} + # Deliberately placed after the build and repack rather than next to the gate, + # where it used to sit. The gate is computed before the late re-check exists, + # so from up there a late reuse still reported "Fresh native build" — the row + # someone reads first when a run looks wrong. Here every input to the decision + # is final. `always()` runs on failure and on cancellation, so a failed or + # cancelled compile still gets its summary. + - name: Write Android E2E reuse summary + if: ${{ always() }} + shell: bash + env: + SOURCE_FINGERPRINT: ${{ inputs.source-fingerprint }} + BUILD_TYPE: ${{ inputs.build_type }} + METAMASK_ENVIRONMENT: ${{ inputs.metamask_environment }} + REUSABLE_FOUND: ${{ steps.find-reusable-build.outputs.found }} + REUSABLE_RUN_ID: ${{ steps.find-reusable-build.outputs.run-id }} + METADATA_VALID: ${{ steps.validate-reusable-metadata.outputs.valid }} + APK_DOWNLOAD_OUTCOME: ${{ steps.download-reusable-apk.outcome }} + TEST_APK_DOWNLOAD_OUTCOME: ${{ steps.download-reusable-test-apk.outcome }} + LATE_LOOKUP_OUTCOME: ${{ steps.find-reusable-build-late.outcome }} + LATE_REUSABLE_FOUND: ${{ steps.find-reusable-build-late.outputs.found }} + LATE_REUSABLE_RUN_ID: ${{ steps.find-reusable-build-late.outputs.run-id }} + LATE_METADATA_VALID: ${{ steps.validate-late-metadata.outputs.valid }} + LATE_APK_DOWNLOAD_OUTCOME: ${{ steps.download-late-apk.outcome }} + LATE_TEST_APK_DOWNLOAD_OUTCOME: ${{ steps.download-late-test-apk.outcome }} + NAMESPACE_CACHE_HIT: ${{ steps.namespace-apk-cache.outputs.cache-hit }} + BRANCH_CACHE_HIT: ${{ steps.apk-cache-restore.outputs.cache-hit }} + MAIN_CACHE_HIT: ${{ steps.apk-cache-restore-main.outputs.cache-hit }} + # Uses the hash captured pre-setup by `determine-target-paths`, not an + # inline `hashFiles`: this step now runs after jetify, which rewrites + # `*.gradle` files under `node_modules/*/android/`, so an inline call here + # would render keys the restore steps never used. + BRANCH_CACHE_KEY: android-apk-${{ github.ref_name }}-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ inputs.source-fingerprint }}-${{ steps.determine-target-paths.outputs.gradle-files-hash }} + MAIN_CACHE_KEY: android-apk-main-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ inputs.source-fingerprint }}-${{ steps.determine-target-paths.outputs.gradle-files-hash }} + NEEDS_NATIVE_BUILD: ${{ steps.gate.outputs.needs-native-build }} + REPOSITORY: ${{ github.repository }} + run: | + value() { + if [[ -n "${1:-}" ]]; then + printf '%s' "$1" + else + printf 'n/a' + fi + } + + yes_no() { + case "${1:-}" in + true) printf 'yes' ;; + *) printf 'no' ;; + esac + } + + run_link() { + printf '[https://github.com/%s/actions/runs/%s](https://github.com/%s/actions/runs/%s)' \ + "$REPOSITORY" "$1" "$REPOSITORY" "$1" + } + + github_candidate="$(yes_no "$REUSABLE_FOUND")" + if [[ -n "${REUSABLE_RUN_ID:-}" ]]; then + github_candidate="yes ($(run_link "$REUSABLE_RUN_ID"))" + fi + + # Three states, so "the re-check never ran" reads differently from "it ran + # and found nothing" — the two have very different causes. + late_candidate="n/a (not reached)" + if [[ -n "${LATE_LOOKUP_OUTCOME:-}" && "${LATE_LOOKUP_OUTCOME}" != "skipped" ]]; then + late_candidate="$(yes_no "$LATE_REUSABLE_FOUND")" + if [[ -n "${LATE_REUSABLE_RUN_ID:-}" ]]; then + late_candidate="yes ($(run_link "$LATE_REUSABLE_RUN_ID"))" + fi + fi + + # The late branch is checked first: on a late reuse the gate still says + # `needs-native-build=true`, because it was computed before the re-check. + # A late reuse is mutually exclusive with every other branch below — it + # requires a non-Namespace runner and a `true` gate, so no Namespace hit, + # no early GitHub reuse and no Cirrus hit can coexist with it. + decision="Fresh native build" + if [[ "${LATE_APK_DOWNLOAD_OUTCOME:-}" == "success" && "${LATE_TEST_APK_DOWNLOAD_OUTCOME:-}" == "success" ]]; then + decision="GitHub artifact reuse (late — donor appeared during setup)" + elif [[ "${NAMESPACE_CACHE_HIT:-}" == "true" ]]; then + decision="Namespace APK cache" + elif [[ "${REUSABLE_FOUND:-}" == "true" && "${METADATA_VALID:-}" == "true" && "${APK_DOWNLOAD_OUTCOME:-}" == "success" && "${TEST_APK_DOWNLOAD_OUTCOME:-}" == "success" ]]; then + decision="GitHub artifact reuse" + elif [[ "${BRANCH_CACHE_HIT:-}" == "true" || "${MAIN_CACHE_HIT:-}" == "true" ]]; then + decision="Cirrus APK cache" + elif [[ "${NEEDS_NATIVE_BUILD:-}" == "false" ]]; then + decision="reuse/cache hit" + fi + + branch_cache="$(yes_no "$BRANCH_CACHE_HIT")" + main_cache="$(yes_no "$MAIN_CACHE_HIT")" + if [[ "${BRANCH_CACHE_HIT:-}" == "true" ]]; then + branch_cache="yes (\`$BRANCH_CACHE_KEY\`)" + elif [[ "${MAIN_CACHE_HIT:-}" == "true" ]]; then + main_cache="yes (\`$MAIN_CACHE_KEY\`)" + fi + + { + echo "### Android E2E build reuse" + echo "" + echo "| Field | Value |" + echo "| --- | --- |" + echo "| Decision | $decision |" + echo "| Native build fingerprint | \`$(value "$SOURCE_FINGERPRINT")\` |" + echo "| Build type | \`$(value "$BUILD_TYPE")\` |" + echo "| MetaMask environment | \`$(value "$METAMASK_ENVIRONMENT")\` |" + echo "| Branch APK cache hit | $branch_cache |" + echo "| Main APK cache hit | $main_cache |" + echo "| GitHub artifact candidate | $github_candidate |" + echo "| Late re-check candidate | $late_candidate |" + } >> "$GITHUB_STEP_SUMMARY" + - name: Write Android E2E build metadata shell: bash env: @@ -729,3 +924,23 @@ jobs: path: ${{ steps.determine-target-paths.outputs.test-apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}-androidTest.apk retention-days: 7 if-no-files-found: error + + # Gradle builds every RN library module inside node_modules + # (node_modules//android/build) and CMake writes to + # node_modules//android/.cxx — ~4-5 GB of intermediates that used + # to balloon the post-job yarn cache save (setup-e2e-env) from ~600 MB + # to ~1.5 GB, adding ~1 min of cache download to every Android E2E job. + # Main steps all run before post-job steps, so deleting the + # intermediates here (after the APKs have been uploaded — they live in + # android/app/build, not node_modules) means the post-job save archives + # a clean tree. Gradle build reuse is handled by the dedicated Gradle + # caches, which are correctly keyed on Gradle config files. + - name: Clean native build intermediates from node_modules + if: ${{ steps.gate.outputs.needs-native-build == 'true' }} + continue-on-error: true + shell: bash + run: | + before=$(du -sm node_modules | cut -f1) + find node_modules -type d \( -name build -o -name .cxx \) -path '*/android/*' -prune -exec rm -rf {} + 2>/dev/null || true + after=$(du -sm node_modules | cut -f1) + echo "node_modules: ${before} MB -> ${after} MB (removed $((before - after)) MB of Gradle/CMake intermediates)" diff --git a/.github/workflows/build-android-upload-to-browserstack.yml b/.github/workflows/build-android-upload-to-browserstack.yml index f01ac660c35b..bc0d53de2b78 100644 --- a/.github/workflows/build-android-upload-to-browserstack.yml +++ b/.github/workflows/build-android-upload-to-browserstack.yml @@ -29,7 +29,11 @@ on: branch_name: required: false type: string - description: 'Branch name to use for builds (defaults to github.ref_name)' + description: 'Branch label used for BrowserStack build names' + source_ref: + required: false + type: string + description: 'Exact branch, tag, or commit to use for native app builds' build_variant: required: false type: string @@ -170,27 +174,15 @@ jobs: main-branch-only: ${{ inputs.main_branch_only }} max-candidates-per-branch: ${{ inputs.main_branch_only && '30' || '10' }} - - name: Find reusable performance APKs on run-performance-e2e-release.yml - id: find-release + - name: Find reusable performance APKs on run-performance-e2e-manual.yml + id: find-manual if: ${{ steps.find-ci.outputs.found != 'true' && steps.find-perf.outputs.found != 'true' }} uses: ./.github/actions/find-reusable-build with: fingerprint: ${{ inputs.source_fingerprint }} artifact-names: ${{ format('["android-apk-{0}","android-apk-{1}"]', needs.check-builds-needed.outputs.with-srp-build-name, needs.check-builds-needed.outputs.without-srp-build-name) }} github-token: ${{ github.token }} - workflow-file: run-performance-e2e-release.yml - main-branch-only: ${{ inputs.main_branch_only }} - max-candidates-per-branch: ${{ inputs.main_branch_only && '30' || '10' }} - - - name: Find reusable performance APKs on run-performance-e2e-experimental.yml - id: find-exp - if: ${{ steps.find-ci.outputs.found != 'true' && steps.find-perf.outputs.found != 'true' && steps.find-release.outputs.found != 'true' }} - uses: ./.github/actions/find-reusable-build - with: - fingerprint: ${{ inputs.source_fingerprint }} - artifact-names: ${{ format('["android-apk-{0}","android-apk-{1}"]', needs.check-builds-needed.outputs.with-srp-build-name, needs.check-builds-needed.outputs.without-srp-build-name) }} - github-token: ${{ github.token }} - workflow-file: run-performance-e2e-experimental.yml + workflow-file: run-performance-e2e-manual.yml main-branch-only: ${{ inputs.main_branch_only }} max-candidates-per-branch: ${{ inputs.main_branch_only && '30' || '10' }} @@ -217,24 +209,15 @@ jobs: echo "source-workflow=run-performance-e2e.yml" } >> "$GITHUB_OUTPUT" echo "Candidate: run-performance-e2e.yml run ${{ steps.find-perf.outputs.run-id }}" - elif [ "${{ steps.find-release.outputs.found }}" = "true" ]; then + elif [ "${{ steps.find-manual.outputs.found }}" = "true" ]; then { echo "found=true" - echo "run-id=${{ steps.find-release.outputs.run-id }}" - echo "source-sha=${{ steps.find-release.outputs.source-sha }}" - echo "source-branch=${{ steps.find-release.outputs.source-branch }}" - echo "source-workflow=run-performance-e2e-release.yml" + echo "run-id=${{ steps.find-manual.outputs.run-id }}" + echo "source-sha=${{ steps.find-manual.outputs.source-sha }}" + echo "source-branch=${{ steps.find-manual.outputs.source-branch }}" + echo "source-workflow=run-performance-e2e-manual.yml" } >> "$GITHUB_OUTPUT" - echo "Candidate: run-performance-e2e-release.yml run ${{ steps.find-release.outputs.run-id }}" - elif [ "${{ steps.find-exp.outputs.found }}" = "true" ]; then - { - echo "found=true" - echo "run-id=${{ steps.find-exp.outputs.run-id }}" - echo "source-sha=${{ steps.find-exp.outputs.source-sha }}" - echo "source-branch=${{ steps.find-exp.outputs.source-branch }}" - echo "source-workflow=run-performance-e2e-experimental.yml" - } >> "$GITHUB_OUTPUT" - echo "Candidate: run-performance-e2e-experimental.yml run ${{ steps.find-exp.outputs.run-id }}" + echo "Candidate: run-performance-e2e-manual.yml run ${{ steps.find-manual.outputs.run-id }}" else { echo "found=false" @@ -427,7 +410,7 @@ jobs: with: build_name: ${{ needs.check-builds-needed.outputs.with-srp-build-name }} platform: android - source_branch: ${{ inputs.branch_name || github.ref_name }} + source_branch: ${{ inputs.source_ref || inputs.branch_name || github.ref_name }} secrets: inherit build-without-srp: @@ -445,7 +428,7 @@ jobs: with: build_name: ${{ needs.check-builds-needed.outputs.without-srp-build-name }} platform: android - source_branch: ${{ inputs.branch_name || github.ref_name }} + source_branch: ${{ inputs.source_ref || inputs.branch_name || github.ref_name }} secrets: inherit upload-to-browserstack: @@ -605,6 +588,24 @@ jobs: eval "$(node /tmp/export-profile-secrets.js "$build_name")" } + # Expo export:embed forces resetCache=false when CI=true (see + # @expo/cli exportEmbedAsync). Dual with/without-SRP packs therefore + # share /tmp/metro-cache and the second pack can reuse babel-inlined + # PREDEFINED_PASSWORD / ADDITIONAL_SRP_* from the first. Wipe explicitly. + clear_metro_transform_cache() { + local label="$1" + rm -rf /tmp/metro-cache + echo "Cleared /tmp/metro-cache before $label repack (CI disables Metro resetCache)" + } + + unset_srp_bake_ins() { + unset PREDEFINED_PASSWORD || true + local i + for i in $(seq 1 20); do + unset "ADDITIONAL_SRP_$i" || true + done + } + repack_profile() { local build_name="$1" local src_dir="$2" @@ -623,12 +624,23 @@ jobs: export_build_env "$build_name" + # Isolate Metro transform cache keys between with-SRP and without-SRP. + export METRO_TRANSFORM_PROFILE="$label" + + # Boolean-only diagnostics — never print secret values. + if [ -n "${PREDEFINED_PASSWORD:-}" ] || [ -n "${ADDITIONAL_SRP_1:-}" ]; then + echo "Transform bake-ins present=yes (profile=$label)" + else + echo "Transform bake-ins present=no (profile=$label)" + fi + export REPACK_SOURCE_APK="$src_apk" export REPACK_OUTPUT_APK="android/app/build/outputs/apk/prod/release/app-prod-release-repack-$label.apk" export REPACK_FINAL_APK="$out_dir/app-prod-release.apk" export REPACK_WORKING_DIR="android/app/build/repack-working-$label" export REPACK_SOURCEMAP_PATH="sourcemaps/android/index.android.bundle.$label.map" + clear_metro_transform_cache "$label" yarn build:repack:android echo "✅ Repacked $label → $REPACK_FINAL_APK ($(du -h "$REPACK_FINAL_APK" | cut -f1))" } @@ -636,8 +648,9 @@ jobs: # with-SRP first (ADDITIONAL_SRP_1 + PREDEFINED_PASSWORD inlined into JS via metro) repack_profile "$WITH_SRP_BUILD_NAME" artifacts/with-srp artifacts/with-srp-repacked with-srp - # without-SRP: drop with-SRP bake-ins so they are not left in process.env - unset ADDITIONAL_SRP_1 PREDEFINED_PASSWORD || true + # without-SRP: drop with-SRP bake-ins so they are not left in process.env, + # then wipe Metro cache so CI cannot reuse the with-SRP transforms. + unset_srp_bake_ins repack_profile "$WITHOUT_SRP_BUILD_NAME" artifacts/without-srp artifacts/without-srp-repacked without-srp # Replace staged dirs so the upload step always reads artifacts/{with,without}-srp diff --git a/.github/workflows/build-ios-upload-to-browserstack.yml b/.github/workflows/build-ios-upload-to-browserstack.yml index 432e4f3da95f..3fe61f2373ae 100644 --- a/.github/workflows/build-ios-upload-to-browserstack.yml +++ b/.github/workflows/build-ios-upload-to-browserstack.yml @@ -18,7 +18,11 @@ on: branch_name: required: false type: string - description: 'Branch name to use for builds (defaults to github.ref_name)' + description: 'Branch label used for BrowserStack build names' + source_ref: + required: false + type: string + description: 'Exact branch, tag, or commit to use for native app builds' build_variant: required: false type: string @@ -100,7 +104,7 @@ jobs: with: build_name: ${{ inputs.build_variant == 'exp' && 'main-exp-with-srp' || inputs.build_variant == 'rc' && 'main-rc-with-srp' || 'main-e2e-bs-with-srp' }} platform: ios - source_branch: ${{ inputs.branch_name || github.ref_name }} + source_branch: ${{ inputs.source_ref || inputs.branch_name || github.ref_name }} secrets: inherit trigger-ios-without-srp-build: @@ -111,7 +115,7 @@ jobs: with: build_name: ${{ inputs.build_variant == 'exp' && 'main-exp-without-srp' || inputs.build_variant == 'rc' && 'main-rc-without-srp' || 'main-e2e-bs-without-srp' }} platform: ios - source_branch: ${{ inputs.branch_name || github.ref_name }} + source_branch: ${{ inputs.source_ref || inputs.branch_name || github.ref_name }} secrets: inherit download-and-upload-to-browserstack: diff --git a/.github/workflows/build-rc-auto.yml b/.github/workflows/build-rc-auto.yml index 2c9a86d4ff05..1c425fb95464 100644 --- a/.github/workflows/build-rc-auto.yml +++ b/.github/workflows/build-rc-auto.yml @@ -7,8 +7,9 @@ # in-progress run of this workflow for that branch is cancelled (one branch + one # workflow at a time). # -# Version bump: generate-build-version → commit-build-version, then iOS and Android -# builds are triggered in parallel via auto-rc-ota-build-core.yml (bump commit passed as source_branch). +# Version bump: generate-build-version, then iOS and Android builds are triggered in parallel +# via auto-rc-ota-build-core.yml with the generated number applied locally at build time. +# Both platforms pin source_branch to github.sha so they build the same commit. # # The RC build comment includes an AI-generated test plan (inline with collapsible sections). # @@ -31,8 +32,8 @@ concurrency: cancel-in-progress: true permissions: - contents: write pull-requests: write + contents: read actions: write id-token: write @@ -116,28 +117,19 @@ jobs: needs: validate-and-find-pr if: needs.validate-and-find-pr.outputs.has-pr == 'true' && needs.validate-and-find-pr.outputs.rc-frozen != 'true' - update_rc_build_version: - name: Commit RC build version - uses: ./.github/workflows/commit-build-version.yml - needs: [validate-and-find-pr, generate_rc_build_version] - if: needs.validate-and-find-pr.outputs.has-pr == 'true' && needs.validate-and-find-pr.outputs.rc-frozen != 'true' - permissions: - contents: write - id-token: write - with: - base-branch: ${{ needs.validate-and-find-pr.outputs.branch-name }} - build_number: ${{ needs.generate_rc_build_version.outputs.build-version }} - trigger-ios-rc-build: name: Trigger iOS RC Build uses: ./.github/workflows/auto-rc-ota-build-core.yml needs: - validate-and-find-pr - - update_rc_build_version + - generate_rc_build_version if: needs.validate-and-find-pr.outputs.has-pr == 'true' && needs.validate-and-find-pr.outputs.rc-frozen != 'true' with: platform: ios - source_branch: ${{ needs.update_rc_build_version.outputs.commit-hash }} + # Pin to the triggering commit so iOS and Android build the same SHA even if + # cherry-picks land on the release branch while the two jobs are in flight. + source_branch: ${{ github.sha }} + build_number: ${{ needs.generate_rc_build_version.outputs.build-version }} distribute_external: true secrets: inherit @@ -146,11 +138,12 @@ jobs: uses: ./.github/workflows/auto-rc-ota-build-core.yml needs: - validate-and-find-pr - - update_rc_build_version + - generate_rc_build_version if: needs.validate-and-find-pr.outputs.has-pr == 'true' && needs.validate-and-find-pr.outputs.rc-frozen != 'true' with: platform: android - source_branch: ${{ needs.update_rc_build_version.outputs.commit-hash }} + source_branch: ${{ github.sha }} + build_number: ${{ needs.generate_rc_build_version.outputs.build-version }} secrets: inherit post-rc-build-comment: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6b1b13ac8cf0..96ee77b43c74 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,9 +24,7 @@ on: build_number: description: >- Optional (workflow_call only). From generate-build-version.yml. When non-empty, each matrix - build runner applies scripts/set-build-version.sh locally before building. Omit when - source_branch already contains the desired build number (e.g. commit-hash from - commit-build-version.yml). + build runner applies scripts/set-build-version.sh locally before building. required: false type: string default: '' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13336c7ad36a..f8ad314f37e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1032,7 +1032,7 @@ jobs: fi component-view-tests: - name: Component view tests + name: Component view tests (${{ matrix.shard }}) runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} # BITRISE/NAMESPACE-SHADOW: skipped on Bitrise shadow (iOS-only, INFRA-3679); Namespace shadow runs this Linux job only when NAMESPACE_SHADOW_LINUX_AUTO_DISPATCH=true if: ${{ !cancelled() && needs.get_requirements.result == 'success' && needs.get_requirements.outputs.skip_everything != 'true' && inputs.runner_provider != 'bitrise' && (inputs.runner_provider != 'namespace' || vars.NAMESPACE_SHADOW_LINUX_AUTO_DISPATCH == 'true') }} @@ -1041,7 +1041,7 @@ jobs: - prepare-ci-js-deps strategy: matrix: - shard: [1, 2] + shard: [1, 2, 3] steps: - uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 if: ${{ inputs.runner_provider == 'namespace' }} @@ -1067,7 +1067,7 @@ jobs: run: mkdir -p tests/results - run: | yarn test:view:ci \ - --shard=${{ matrix.shard }}/2 \ + --shard=${{ matrix.shard }}/3 \ --json \ --outputFile=tests/results/cv-test-results-${{ matrix.shard }}.json env: @@ -1214,7 +1214,8 @@ jobs: name: 'Run Performance Tests (PR)' # Run when: # - run-performance-tests label is on the PR (forced run, all tests), OR - # - smart-e2e-selection ran and AI selected performance tags. + # - the PR targets main and Smart E2E Selection chose performance tags. + # PRs targeting release/* or stable only run performance tests via the label. # ai_performance_test_tags == '[]' → AI ran and found no perf-relevant changes: skip. # ai_performance_test_tags == '' → conservative fallback (AI failed) or run-performance-tests label: run all tests. # ai_performance_test_tags == '[…]' → specific tags selected: run those tests. @@ -1228,6 +1229,7 @@ jobs: ( needs.get_requirements.outputs.run_performance == 'true' || ( + github.event.pull_request.base.ref == 'main' && needs.smart-e2e-selection.result == 'success' && needs.smart-e2e-selection.outputs.ai_performance_test_tags != '[]' ) @@ -1240,6 +1242,7 @@ jobs: performance_tags_reasoning: ${{ needs.get_requirements.outputs.run_performance == 'true' && 'Forced run via run-performance-tests label on PR' || needs.smart-e2e-selection.outputs.ai_performance_test_reasoning }} build_variant: 'e2e' branch_name: ${{ github.head_ref }} + source_ref: ${{ github.sha }} pr_number: ${{ github.event.pull_request.number }} reuse_main_builds: ${{ needs.get_requirements.outputs.native_build_needed == 'false' }} source_fingerprint: ${{ needs.native-build-fingerprint.outputs.fingerprint }} @@ -1329,31 +1332,8 @@ jobs: # See: https://github.com/MetaMask/metamask-mobile/pull/31217 retention-days: 30 - e2e-smoke-tests-android: - name: 'Android E2E Smoke Tests' - # BITRISE/NAMESPACE-SHADOW: skipped on Bitrise shadow (iOS-only, INFRA-3679); Namespace shadow runs this Linux-runner job only when NAMESPACE_SHADOW_LINUX_AUTO_DISPATCH=true - if: >- - ${{ - inputs.runner_provider != 'bitrise' && - (inputs.runner_provider != 'namespace' || vars.NAMESPACE_SHADOW_LINUX_AUTO_DISPATCH == 'true') && - !cancelled() && - needs.build-android-apks.result == 'success' && - (needs.prepare-e2e-timings.result == 'success' || needs.prepare-e2e-timings.result == 'failure' || needs.prepare-e2e-timings.result == 'skipped') - }} - permissions: - contents: read - id-token: write - needs: [get_requirements, build-android-apks, smart-e2e-selection, prepare-e2e-timings] - uses: ./.github/workflows/run-e2e-smoke-tests-android.yml - with: - changed_spec_files: ${{ needs.get_requirements.outputs.changed_spec_files }} - selected_tags: >- - ${{ - (fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 85 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) || - '["ALL"]' - }} - runner_provider: ${{ inputs.runner_provider }} - secrets: inherit + # Detox Android E2E smoke workflow removed — tags fully migrated to Appium + # (run-appium-smoke-tests-android.yml). build-ios-apps: name: 'Build iOS Apps' @@ -1389,28 +1369,8 @@ jobs: - name: iOS build complete run: echo "Dummy step to better visualize the Android and iOS E2E tests in Github workflow graph" - e2e-smoke-tests-ios: - name: 'iOS E2E Smoke Tests' - if: >- - ${{ - !cancelled() && - needs.ios-tests-ready.result == 'success' && - (needs.prepare-e2e-timings.result == 'success' || needs.prepare-e2e-timings.result == 'failure' || needs.prepare-e2e-timings.result == 'skipped') - }} - permissions: - contents: read - id-token: write - needs: [get_requirements, ios-tests-ready, smart-e2e-selection, prepare-e2e-timings] - uses: ./.github/workflows/run-e2e-smoke-tests-ios.yml - with: - changed_spec_files: ${{ needs.get_requirements.outputs.changed_spec_files }} - selected_tags: >- - ${{ - (fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 85 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) || - '["ALL"]' - }} - runner_provider: ${{ inputs.runner_provider }} - secrets: inherit + # Detox iOS E2E smoke workflow removed — tags fully migrated to Appium + # (run-appium-smoke-tests-ios.yml). appium-smoke-tests-android: name: 'Appium Smoke Tests (Android)' @@ -1446,7 +1406,8 @@ jobs: appium-smoke-tests-ios: name: 'Appium Smoke Tests (iOS)' # Main push/schedule: always run when the iOS build succeeded. - # PRs: skipped by default; add run-appium-ios-tests, or change page-objects/selectors/locators/framework + # PRs: skipped by default; add run-appium-ios-tests, or change + # page-objects/selectors/locators/framework/smoke-appium # (same smart-selected tags as other E2E jobs). if: >- ${{ @@ -1745,8 +1706,6 @@ jobs: - sonar-cloud-quality-gate-status - build-android-apks - build-ios-apps - - e2e-smoke-tests-android - - e2e-smoke-tests-ios - appium-smoke-tests-android - appium-smoke-tests-ios steps: @@ -1769,7 +1728,7 @@ jobs: with: needs-json: ${{ toJSON(needs) }} requirement-context-json: ${{ toJSON(needs.get_requirements.outputs) }} - e2e-job-regex: '^(build-android-apks|build-ios-apps|e2e-smoke-tests-android|e2e-smoke-tests-ios|appium-smoke-tests-android|appium-smoke-tests-ios)$' + e2e-job-regex: '^(build-android-apks|build-ios-apps|appium-smoke-tests-android|appium-smoke-tests-ios)$' event-name: ${{ github.event_name }} is-fork: ${{ github.event.pull_request.head.repo.fork == true }} diff --git a/.github/workflows/commit-build-version.yml b/.github/workflows/commit-build-version.yml deleted file mode 100644 index 1621d5752749..000000000000 --- a/.github/workflows/commit-build-version.yml +++ /dev/null @@ -1,64 +0,0 @@ -############################################################################################## -# -# Commit Build Version (reusable) -# -# Applies a caller-supplied build number to version files, commits, and pushes to base-branch. -# Does not generate the number — use generate-build-version.yml first. -# -############################################################################################## -name: Commit Build Version - -on: - workflow_call: - inputs: - base-branch: - description: 'Branch, tag, or SHA to checkout and push the version bump to' - required: true - type: string - build_number: - description: 'Build number from generate-build-version.yml outputs.build-version' - required: true - type: string - outputs: - commit-hash: - description: 'Commit SHA with build version bumped' - value: ${{ jobs.commit.outputs.commit-hash }} - -permissions: - id-token: write - -jobs: - commit: - runs-on: ubuntu-latest - outputs: - commit-hash: ${{ steps.bump-build-version.outputs.commit-hash }} - steps: - - name: Get token - id: get-token - uses: MetaMask/github-tools/.github/actions/get-token@v1 - with: - token-exchange-url: ${{ vars.TOKEN_EXCHANGE_URL }} - permissions: | - contents: write - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ inputs.base-branch }} - token: ${{ steps.get-token.outputs.token }} - - name: Bump build version - id: bump-build-version - shell: bash - env: - BUILD_NUMBER: ${{ inputs.build_number }} - HEAD_REF: ${{ inputs.base-branch }} - run: | - ./scripts/set-build-version.sh "$BUILD_NUMBER" - git diff - git config user.name metamaskbot - git config user.email metamaskbot@users.noreply.github.com - git add package.json - git add ios/MetaMask.xcodeproj/project.pbxproj - git add android/app/build.gradle - git commit -m "[skip ci] Bump version number to ${BUILD_NUMBER}" - git push origin HEAD:"$HEAD_REF" --force-with-lease - echo "commit-hash=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml index 4b83b603551d..97091d2049d1 100644 --- a/.github/workflows/create-release-pr.yml +++ b/.github/workflows/create-release-pr.yml @@ -93,15 +93,9 @@ jobs: run: | .github/scripts/resolve-previous-ref.sh - generate-build-version: - needs: resolve-bases - if: needs.resolve-bases.outputs.is_ota != 'true' - uses: ./.github/workflows/generate-build-version.yml - create-release-pr: - needs: [resolve-bases, resolve-previous-ref, generate-build-version] - # When generate-build-version is skipped (OTA), this job must still run; see https://docs.github.com/en/actions/using-jobs/using-jobs-in-a-workflow#handling-skipped-dependent-jobs - if: always() && needs.resolve-bases.result == 'success' && needs.resolve-previous-ref.result == 'success' && (needs.generate-build-version.result == 'success' || needs.generate-build-version.result == 'skipped') + needs: [resolve-bases, resolve-previous-ref] + if: needs.resolve-bases.result == 'success' && needs.resolve-previous-ref.result == 'success' name: Create Release Pull Request using Github Tools runs-on: ubuntu-latest steps: @@ -123,6 +117,26 @@ jobs: members: read workflows: write + # Read current build number from version files for github-tools (non-OTA only). + # Kept in this job to avoid a separate runner just for the metadata read. + - name: Checkout repository + if: needs.resolve-bases.outputs.is_ota != 'true' + uses: actions/checkout@v4 + with: + ref: ${{ needs.resolve-bases.outputs.checkout_base }} + fetch-depth: 1 + + - name: Setup Node.js + if: needs.resolve-bases.outputs.is_ota != 'true' + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + - name: Read build number from version files + if: needs.resolve-bases.outputs.is_ota != 'true' + id: meta + run: ./scripts/get-build-metadata.sh --ci + - name: Create Release PR (semver / native version bump) if: needs.resolve-bases.outputs.is_ota != 'true' uses: MetaMask/github-tools/.github/actions/create-release-pr@v1.16.0 @@ -132,7 +146,10 @@ jobs: release-pr-base-branch: ${{ needs.resolve-bases.outputs.release_base }} semver-version: ${{ inputs.semver-version }} previous-version-ref: ${{ needs.resolve-previous-ref.outputs.previous_ref }} - mobile-build-version: ${{ needs.generate-build-version.outputs.build-version }} + # github-tools requires this input for mobile, but only bumps semver on the release branch. + # Build numbers are applied locally at build time (see build.yml build_number input). + # ios_version_code is the shared mobile build number from get-build-metadata.sh. + mobile-build-version: ${{ steps.meta.outputs.ios_version_code }} github-token: ${{ steps.get-token.outputs.token }} google-application-creds-base64: ${{ github.event_name == 'workflow_dispatch' && secrets.GCP_RLS_SHEET_ACCOUNT_BASE64 || secrets.google-application-creds-base64 }} diff --git a/.github/workflows/expo-dev-build.yml b/.github/workflows/expo-dev-build.yml index 437febcf6f00..f913d2d8d11e 100644 --- a/.github/workflows/expo-dev-build.yml +++ b/.github/workflows/expo-dev-build.yml @@ -2,8 +2,15 @@ # # Expo Dev Build. # -# Triggered on every push to main. Builds the main-dev-expo configuration (Debug) -# for both iOS and Android using the reusable build.yml workflow. +# Triggered on every push to main, but only actually builds when the @expo/fingerprint +# hash (plus a hash of builds.yml, which fingerprint doesn't track) differs from the last +# successful build — i.e. only when native code changed. A weekly cron (and the +# `force_build` manual dispatch input) forces a fresh build regardless, so the GitHub +# Actions artifacts consumed by `yarn install:ios:dev` / `yarn install:android:dev` never +# go stale for longer than a week even if native code hasn't changed. +# +# Builds the main-dev-expo configuration (Debug) for both iOS and Android using the +# reusable build.yml workflow. # # Produces simulator .app + device IPA (iOS) and APK + test APK (Android). # No version bump or TestFlight upload. @@ -20,6 +27,10 @@ on: push: branches: - main + schedule: + # Weekly safety-net build (Monday 06:00 UTC) so GitHub Actions artifacts don't expire + # and go stale during long stretches with no native-affecting changes on main. + - cron: '0 6 * * 1' workflow_dispatch: inputs: runner_provider: @@ -30,14 +41,199 @@ on: - current - namespace default: current + force_build: + description: Force a fresh build even if the fingerprint is unchanged + required: false + type: boolean + default: false permissions: contents: write id-token: write + actions: read + +concurrency: + # Non-cancelling: a burst of merges to main serializes instead of racing, so a queued + # run can find the marker artifact uploaded by the run ahead of it and skip its build. + group: expo-dev-build-${{ github.ref }} + cancel-in-progress: false jobs: + resolve-dev-build: + name: Resolve dev build gate + runs-on: ubuntu-latest + timeout-minutes: 30 + outputs: + needs-build: ${{ steps.gate.outputs.needs-build }} + gate-key: ${{ steps.gate-key.outputs.gate-key }} + reusable-run-id: ${{ steps.gate.outputs.reusable-run-id }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + 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: Compute gate key + id: gate-key + run: | + FINGERPRINT="$(yarn fingerprint:generate)" + if [[ -z "$FINGERPRINT" ]]; then + echo "::error::yarn fingerprint:generate produced an empty hash" + exit 1 + fi + # builds.yml isn't tracked by @expo/fingerprint, but the main-dev-expo entry + # (signing, CONFIGURATION, IS_DEVICE_BUILD, code_fencing, ...) affects the + # binary, so fold a short hash of it into the gate key. + BUILDS_HASH="$(sha256sum builds.yml | cut -c1-8)" + GATE_KEY="${FINGERPRINT}-${BUILDS_HASH}" + echo "gate-key=${GATE_KEY}" >> "$GITHUB_OUTPUT" + echo "Fingerprint: ${FINGERPRINT}" + echo "builds.yml hash: ${BUILDS_HASH}" + echo "Gate key: ${GATE_KEY}" + + - name: Look up reusable dev build + id: lookup + if: ${{ github.event_name != 'schedule' && inputs.force_build != true }} + uses: actions/github-script@v7 + env: + GATE_KEY: ${{ steps.gate-key.outputs.gate-key }} + # Scoped to the current branch, not hardcoded to main: `yarn install:*:dev` + # resolves artifacts per branch, so a marker from another branch must not + # authorize skipping here — a manual dispatch on a feature branch would + # otherwise reuse main's marker and leave that branch with no artifacts. + GATE_BRANCH: ${{ github.ref_name }} + with: + script: | + const { GATE_KEY, GATE_BRANCH } = process.env; + const markerName = `expo-dev-build-key-${GATE_KEY}`; + const requiredArtifacts = [ + 'ios-app-main-dev-expo', + 'ios-ipa-main-dev-expo', + 'android-apk-main-dev-expo', + ]; + + const setNotFound = () => { + core.setOutput('found', 'false'); + core.setOutput('run-id', ''); + }; + + let markers; + try { + markers = await github.paginate(github.rest.actions.listArtifactsForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + name: markerName, + per_page: 100, + }); + } catch (err) { + core.warning(`Failed to list marker artifacts: ${err.message}`); + setNotFound(); + return; + } + + const candidates = markers + .filter((a) => !a.expired && a.workflow_run && a.workflow_run.head_branch === GATE_BRANCH) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + + if (candidates.length === 0) { + core.info(`No live marker artifact named "${markerName}" found on ${GATE_BRANCH}.`); + setNotFound(); + return; + } + + const runId = candidates[0].workflow_run.id; + + let runArtifacts; + try { + runArtifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: runId, + per_page: 100, + }); + } catch (err) { + core.warning(`listWorkflowRunArtifacts failed for run ${runId}: ${err.message}`); + setNotFound(); + return; + } + + const available = new Set(runArtifacts.filter((a) => !a.expired).map((a) => a.name)); + const missing = requiredArtifacts.filter((n) => !available.has(n)); + if (missing.length > 0) { + core.info(`Run ${runId} is missing artifacts: ${missing.join(', ')}`); + setNotFound(); + return; + } + + core.info(`Reusable dev build found: run ${runId} (gate key ${GATE_KEY})`); + core.setOutput('found', 'true'); + core.setOutput('run-id', String(runId)); + + - name: Compute build gate + id: gate + env: + FORCE_BUILD: ${{ github.event_name == 'schedule' || inputs.force_build == true }} + LOOKUP_FOUND: ${{ steps.lookup.outputs.found }} + LOOKUP_RUN_ID: ${{ steps.lookup.outputs.run-id }} + run: | + if [[ "$FORCE_BUILD" == "true" ]]; then + echo "needs-build=true" >> "$GITHUB_OUTPUT" + echo "reusable-run-id=" >> "$GITHUB_OUTPUT" + echo "Forcing fresh build (schedule or force_build input)." + elif [[ "$LOOKUP_FOUND" == "true" ]]; then + echo "needs-build=false" >> "$GITHUB_OUTPUT" + echo "reusable-run-id=${LOOKUP_RUN_ID}" >> "$GITHUB_OUTPUT" + echo "Reusing dev build from run ${LOOKUP_RUN_ID}; fingerprint unchanged." + else + echo "needs-build=true" >> "$GITHUB_OUTPUT" + echo "reusable-run-id=" >> "$GITHUB_OUTPUT" + echo "No reusable dev build found; compiling fresh." + fi + + - name: Write dev build gate summary + if: ${{ always() }} + env: + GATE_KEY: ${{ steps.gate-key.outputs.gate-key }} + NEEDS_BUILD: ${{ steps.gate.outputs.needs-build }} + REUSABLE_RUN_ID: ${{ steps.gate.outputs.reusable-run-id }} + REPOSITORY: ${{ github.repository }} + run: | + # NEEDS_BUILD is empty when an earlier step failed (this step runs on always()), + # which must not be reported as a reuse decision. + case "$NEEDS_BUILD" in + true) decision="Fresh build" ;; + false) decision="Reused (fingerprint unchanged)" ;; + *) decision="Undetermined (gate did not complete)" ;; + esac + reused_run="n/a" + if [[ -n "${REUSABLE_RUN_ID:-}" ]]; then + reused_run="[https://github.com/${REPOSITORY}/actions/runs/${REUSABLE_RUN_ID}](https://github.com/${REPOSITORY}/actions/runs/${REUSABLE_RUN_ID})" + fi + { + echo "### Expo Dev Build gate" + echo "" + echo "| Field | Value |" + echo "| --- | --- |" + echo "| Decision | ${decision} |" + echo "| Gate key | \`${GATE_KEY}\` |" + echo "| Reused run | ${reused_run} |" + } >> "$GITHUB_STEP_SUMMARY" + build-dev: name: Expo dev build (main-dev-expo) + needs: [resolve-dev-build] + if: ${{ needs.resolve-dev-build.outputs.needs-build == 'true' }} uses: ./.github/workflows/build.yml with: build_name: main-dev-expo @@ -45,3 +241,55 @@ jobs: source_branch: ${{ github.ref_name }} runner_provider: ${{ inputs.runner_provider }} secrets: inherit + + record-dev-build: + name: Record dev build marker + needs: [resolve-dev-build, build-dev] + # Inherits workflow-level permissions (contents: write, actions: read) - no + # job-level override, since actions/upload-artifact's exact permission + # requirements aren't documented and the workflow-level set is a safe superset. + if: ${{ !cancelled() && needs.build-dev.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Verify app artifacts and write marker + id: verify + uses: actions/github-script@v7 + with: + script: | + const requiredArtifacts = [ + 'ios-app-main-dev-expo', + 'ios-ipa-main-dev-expo', + 'android-apk-main-dev-expo', + ]; + + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.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.setFailed(`Refusing to write marker: missing artifacts ${missing.join(', ')}`); + return; + } + core.info('All required app artifacts present; marker will be uploaded.'); + + - name: Write marker file + run: | + mkdir -p .expo-dev-build-marker + { + echo "gate_key=${{ needs.resolve-dev-build.outputs.gate-key }}" + echo "run_id=${{ github.run_id }}" + echo "head_sha=${{ github.sha }}" + } > .expo-dev-build-marker/marker.txt + + - name: Upload marker artifact + uses: actions/upload-artifact@v4 + with: + name: expo-dev-build-key-${{ needs.resolve-dev-build.outputs.gate-key }} + path: .expo-dev-build-marker/marker.txt + if-no-files-found: error diff --git a/.github/workflows/flaky-test-report.yml b/.github/workflows/flaky-test-report.yml index 54ee9e23ab27..c06ba16addd4 100644 --- a/.github/workflows/flaky-test-report.yml +++ b/.github/workflows/flaky-test-report.yml @@ -48,7 +48,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Generate Playwright test health report - uses: MetaMask/github-tools/.github/actions/playwright-test-health-report@4a2ddd75e9a8182261f0f07fbfc332cc856c89a0 + uses: MetaMask/github-tools/.github/actions/playwright-test-health-report@a4a179a9ad2d02f6acb3c781e1b22a38bbc8dbca with: repository: ${{ github.event.repository.name }} workflow-ids: ci.yml @@ -57,3 +57,4 @@ jobs: lookback-days: ${{ github.event_name == 'workflow_dispatch' && inputs.lookback_days || '1' }} top-n: '10' report-title: Playwright Test Health Report (Mobile Appium) + test-source-prefix: tests/smoke-appium diff --git a/.github/workflows/get-requirements.yml b/.github/workflows/get-requirements.yml index a1e716391a59..cf91d932f5f8 100644 --- a/.github/workflows/get-requirements.yml +++ b/.github/workflows/get-requirements.yml @@ -30,7 +30,7 @@ on: description: 'Whether performance E2E tests should be forced to run via the run-performance-tests PR label' value: ${{ jobs.detect-changes.outputs.run_performance }} run_appium_ios: - description: 'Whether Appium iOS smoke tests should also run on a PR via the run-appium-ios-tests label or e2e smoke infra file changes' + description: 'Whether Appium iOS smoke tests should also run on a PR via the run-appium-ios-tests label or e2e smoke infra / smoke-appium file changes' value: ${{ jobs.detect-changes.outputs.run_appium_ios }} native_build_needed: description: 'Whether fresh iOS/Android native E2E builds are required. False when only E2E/performance test files changed — CI reuses main branch builds instead.' diff --git a/.github/workflows/performance-test-runner.yml b/.github/workflows/performance-test-runner.yml index c5f5de5df1fa..b46eef0c6f4c 100644 --- a/.github/workflows/performance-test-runner.yml +++ b/.github/workflows/performance-test-runner.yml @@ -26,7 +26,7 @@ on: branch_name: required: true type: string - description: 'Branch name' + description: 'Branch name used for performance observability' browserstack_build_name: required: true type: string @@ -40,7 +40,7 @@ on: required: false type: string default: e2e - description: 'Deprecated for env; kept for workflow_call compatibility. Use feature_flags_environment for client-config API.' + description: 'App build variant used for performance observability' feature_flags_environment: required: false type: string @@ -77,6 +77,7 @@ jobs: run-tests: name: Run ${{ inputs.build_type }} Tests on ${{ inputs.platform }} - ${{ matrix.device.name }} runs-on: ubuntu-latest + timeout-minutes: 20 strategy: fail-fast: false matrix: @@ -86,12 +87,10 @@ jobs: uses: actions/checkout@v4 - name: Restore node_modules cache - id: cache uses: actions/cache@v4 with: path: | node_modules - .yarn/cache .yarn/install-state.gz key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} restore-keys: | @@ -111,18 +110,9 @@ jobs: retry_wait_seconds: 30 command: yarn --immutable ## This installs dependencies and creates the node_modules state file - - 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 - run: yarn setup:github-ci + # BrowserStack performance projects do not use the local Anvil fixture. + run: yarn setup:github-ci --node working-directory: '.' - name: BrowserStack Env Setup @@ -144,6 +134,8 @@ jobs: SELECTED_SENTRY_DSN="" SENTRY_ENVIRONMENT="github-actions-performance-e2e" FEATURE_FLAGS_ENV="${{ inputs.feature_flags_environment }}" + CI_BUILD_VARIANT="${{ inputs.build_variant }}" + BRANCH_NAME="${{ inputs.branch_name }}" BROWSERSTACK_GEO_LOCATION="${BROWSERSTACK_GEO_LOCATION:-SE}" if [[ "$FEATURE_FLAGS_ENV" != "rc" && "$FEATURE_FLAGS_ENV" != "exp" && "$FEATURE_FLAGS_ENV" != "test" && "$FEATURE_FLAGS_ENV" != "dev" && "$FEATURE_FLAGS_ENV" != "prod" ]]; then @@ -177,6 +169,24 @@ jobs: ;; esac + # Track-only RC observability: distinct Sentry environment + release + # version tag. RELEASE_VERSION is derived from release/* branches for + # filtering; the mm-mobile-rc-* Sentry release bucket is only used when + # CI_BUILD_VARIANT is actually rc (avoids polluting RC releases when a + # non-RC perf run happens on a release branch). + RELEASE_VERSION="" + if [[ "$BRANCH_NAME" =~ ^release/(.+)$ ]]; then + RELEASE_VERSION="${BASH_REMATCH[1]}" + fi + if [[ "$CI_BUILD_VARIANT" == "rc" ]]; then + SENTRY_ENVIRONMENT="${SENTRY_ENVIRONMENT}-rc" + fi + + SENTRY_RELEASE="mm-mobile-perf-${CI_BUILD_VARIANT}-${{ github.sha }}" + if [[ "$CI_BUILD_VARIANT" == "rc" && -n "$RELEASE_VERSION" ]]; then + SENTRY_RELEASE="mm-mobile-rc-${RELEASE_VERSION}" + fi + { echo "BROWSERSTACK_DEVICE=${{ matrix.device.name }}" echo "BROWSERSTACK_OS_VERSION=${{ matrix.device.os_version }}" @@ -195,8 +205,11 @@ jobs: echo "E2E_PASSWORD=${{ secrets.E2E_PASSWORD }}" echo "E2E_PERFORMANCE_SENTRY_DSN=$SELECTED_SENTRY_DSN" echo "E2E_PERFORMANCE_SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT" - echo "E2E_PERFORMANCE_SENTRY_RELEASE=${{ github.sha }}" + echo "E2E_PERFORMANCE_SENTRY_RELEASE=$SENTRY_RELEASE" echo "E2E_PERFORMANCE_BUILD_VARIANT=$FEATURE_FLAGS_ENV" + echo "E2E_PERFORMANCE_CI_BUILD_VARIANT=$CI_BUILD_VARIANT" + echo "E2E_PERFORMANCE_RELEASE_VERSION=$RELEASE_VERSION" + echo "E2E_PERFORMANCE_GITHUB_REF_NAME=$BRANCH_NAME" echo "DISABLE_VIDEO_DOWNLOAD=true" } >> "$GITHUB_ENV" diff --git a/.github/workflows/publish-slack-release-testing-status.yml b/.github/workflows/publish-slack-release-testing-status.yml deleted file mode 100644 index 40204e436c5e..000000000000 --- a/.github/workflows/publish-slack-release-testing-status.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Trigger Publish Slack Release Testing Status - -on: - schedule: - - cron: '0 15 * * 1-5' #M-F at 15:00 UTC - workflow_dispatch: # Allows manual triggering - -jobs: - call-publish-slack-release-testing-status: - name: Publish Slack release testing status - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - steps: - - name: Get token - id: get-token - uses: MetaMask/github-tools/.github/actions/get-token@v1 - with: - token-exchange-url: ${{ vars.TOKEN_EXCHANGE_URL }} - permissions: | - contents: read - pull_requests: read - - - name: Publish Slack release testing status - uses: MetaMask/github-tools/.github/actions/publish-slack-release-testing-status@v1 - with: - platform: 'mobile' - test-only: 'true' - google-document-id: '1tsoodlAlyvEUpkkcNcbZ4PM9HuC9cEM80RZeoVv5OCQ' - slack-api-key: ${{ secrets.SLACKBOT_RLS_TOKEN }} - github-token: ${{ steps.get-token.outputs.token }} - google-application-creds-base64: ${{ secrets.GCP_RLS_SHEET_ACCOUNT_BASE64 }} diff --git a/.github/workflows/run-appium-e2e-workflow.yml b/.github/workflows/run-appium-e2e-workflow.yml index 2ae87d9e446c..50ed84e12c6c 100644 --- a/.github/workflows/run-appium-e2e-workflow.yml +++ b/.github/workflows/run-appium-e2e-workflow.yml @@ -381,6 +381,23 @@ jobs: if-no-files-found: ignore retention-days: 7 + - name: Upload Appium phase timings (Namespace) + if: ${{ always() && inputs.runner_provider == 'namespace' }} + uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3 + with: + name: appium-timings-${{ inputs.test-suite-name }} + path: tests/test-reports/appium-timings/ + if-no-files-found: ignore + retention-days: 7 + - name: Upload Appium phase timings (current) + if: ${{ always() && inputs.runner_provider != 'namespace' }} + uses: actions/upload-artifact@v4 + with: + name: appium-timings-${{ inputs.test-suite-name }} + path: tests/test-reports/appium-timings/ + if-no-files-found: ignore + retention-days: 7 + - name: Upload failure screen recordings (Namespace) if: ${{ always() && (steps.run-tests-android.outcome == 'failure' || steps.run-tests-ios.outcome == 'failure') && inputs.runner_provider == 'namespace' }} uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3 diff --git a/.github/workflows/run-appium-smoke-tests-android.yml b/.github/workflows/run-appium-smoke-tests-android.yml index 90dd6399a686..ee58393cc82e 100644 --- a/.github/workflows/run-appium-smoke-tests-android.yml +++ b/.github/workflows/run-appium-smoke-tests-android.yml @@ -65,7 +65,7 @@ jobs: }} strategy: matrix: - split: [1, 2, 3, 4] + split: [1, 2] fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: @@ -73,7 +73,8 @@ jobs: platform: android test_suite_tag: SmokeAccounts split_number: ${{ matrix.split }} - total_splits: 4 + total_splits: 2 + test-timeout-minutes: 25 build_type: ${{ inputs.build_type }} metamask_environment: ${{ inputs.metamask_environment }} runner_provider: ${{ inputs.runner_provider }} @@ -92,11 +93,12 @@ jobs: fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: - test-suite-name: appium-perps-android-smoke + test-suite-name: appium-perps-android-smoke-${{ matrix.split }} platform: android test_suite_tag: SmokePerps split_number: ${{ matrix.split }} total_splits: 2 + test-timeout-minutes: 25 build_type: ${{ inputs.build_type }} metamask_environment: ${{ inputs.metamask_environment }} runner_provider: ${{ inputs.runner_provider }} @@ -111,7 +113,7 @@ jobs: }} strategy: matrix: - split: [1, 2, 3] + split: [1, 2] fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: @@ -119,7 +121,7 @@ jobs: platform: android test_suite_tag: SmokePredictions split_number: ${{ matrix.split }} - total_splits: 3 + total_splits: 2 test-timeout-minutes: 25 build_type: ${{ inputs.build_type }} metamask_environment: ${{ inputs.metamask_environment }} @@ -135,7 +137,7 @@ jobs: }} strategy: matrix: - split: [1, 2, 3, 4] + split: [1, 2] fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: @@ -143,14 +145,13 @@ jobs: platform: android test_suite_tag: SmokeSeedlessOnboarding split_number: ${{ matrix.split }} - total_splits: 4 + total_splits: 2 test-timeout-minutes: 25 build_type: ${{ inputs.build_type }} metamask_environment: ${{ inputs.metamask_environment }} runner_provider: ${{ inputs.runner_provider }} secrets: inherit - appium-snaps-android-smoke: if: >- ${{ @@ -160,7 +161,7 @@ jobs: }} strategy: matrix: - split: [1, 2, 3, 4, 5, 6, 7, 8] + split: [1, 2, 3, 4, 5, 6] fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: @@ -168,10 +169,9 @@ jobs: platform: android test_suite_tag: SmokeSnaps split_number: ${{ matrix.split }} - total_splits: 8 - # Temporary: full login+install per test makes Android snaps shards slow. - # Drop back toward 25 once Appium has reloadReactNative (no full login each test). - test-timeout-minutes: 40 + total_splits: 6 + # Session reuse + soft reload: denser shards; drop toward 25 after one green main. + test-timeout-minutes: 30 build_type: ${{ inputs.build_type }} metamask_environment: ${{ inputs.metamask_environment }} runner_provider: ${{ inputs.runner_provider }} @@ -186,15 +186,15 @@ jobs: }} strategy: matrix: - split: [1] + split: [1, 2] fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: - test-suite-name: appium-network-abstractions-android-smoke + test-suite-name: appium-network-abstractions-android-smoke-${{ matrix.split }} platform: android test_suite_tag: SmokeNetworkAbstractions split_number: ${{ matrix.split }} - total_splits: 1 + total_splits: 2 build_type: ${{ inputs.build_type }} metamask_environment: ${{ inputs.metamask_environment }} runner_provider: ${{ inputs.runner_provider }} @@ -209,7 +209,7 @@ jobs: }} strategy: matrix: - split: [1, 2, 3, 4] + split: [1, 2] fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: @@ -217,7 +217,8 @@ jobs: platform: android test_suite_tag: SmokeWalletPlatform split_number: ${{ matrix.split }} - total_splits: 4 + total_splits: 2 + test-timeout-minutes: 25 build_type: ${{ inputs.build_type }} metamask_environment: ${{ inputs.metamask_environment }} runner_provider: ${{ inputs.runner_provider }} @@ -232,7 +233,7 @@ jobs: }} strategy: matrix: - split: [1, 2] + split: [1] fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: @@ -240,8 +241,8 @@ jobs: platform: android test_suite_tag: SmokeSwap split_number: ${{ matrix.split }} - total_splits: 2 - test-timeout-minutes: 25 + total_splits: 1 + test-timeout-minutes: 30 build_type: ${{ inputs.build_type }} metamask_environment: ${{ inputs.metamask_environment }} runner_provider: ${{ inputs.runner_provider }} @@ -256,7 +257,7 @@ jobs: }} strategy: matrix: - split: [1, 2] + split: [1] fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: @@ -264,19 +265,44 @@ jobs: platform: android test_suite_tag: SmokeStake split_number: ${{ matrix.split }} - total_splits: 2 + total_splits: 1 test-timeout-minutes: 25 build_type: ${{ inputs.build_type }} metamask_environment: ${{ inputs.metamask_environment }} runner_provider: ${{ inputs.runner_provider }} secrets: inherit - appium-mmconnect-android-smoke: + # appium-mmconnect-android-smoke: + # if: >- + # ${{ + # !cancelled() && + # (contains(fromJson(inputs.selected_tags), 'ALL') || + # contains(fromJson(inputs.selected_tags), 'SmokeMMConnect')) + # }} + # strategy: + # matrix: + # split: [1] + # fail-fast: false + # uses: ./.github/workflows/run-appium-e2e-workflow.yml + # with: + # test-suite-name: appium-mmconnect-android-smoke-${{ matrix.split }} + # platform: android + # test_suite_tag: SmokeMMConnect + # split_number: ${{ matrix.split }} + # total_splits: 1 + # test-timeout-minutes: 35 + # android-tag: google_apis + # build_type: ${{ inputs.build_type }} + # metamask_environment: ${{ inputs.metamask_environment }} + # runner_provider: ${{ inputs.runner_provider }} + # secrets: inherit + + appium-money-android-smoke: if: >- ${{ !cancelled() && (contains(fromJson(inputs.selected_tags), 'ALL') || - contains(fromJson(inputs.selected_tags), 'SmokeMMConnect')) + contains(fromJson(inputs.selected_tags), 'SmokeMoney')) }} strategy: matrix: @@ -284,24 +310,23 @@ jobs: fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: - test-suite-name: appium-mmconnect-android-smoke-${{ matrix.split }} + test-suite-name: appium-money-android-smoke-${{ matrix.split }} platform: android - test_suite_tag: SmokeMMConnect + test_suite_tag: SmokeMoney split_number: ${{ matrix.split }} total_splits: 1 - test-timeout-minutes: 35 - android-tag: google_apis + test-timeout-minutes: 25 build_type: ${{ inputs.build_type }} metamask_environment: ${{ inputs.metamask_environment }} runner_provider: ${{ inputs.runner_provider }} secrets: inherit - appium-money-android-smoke: + appium-browser-android-smoke: if: >- ${{ !cancelled() && (contains(fromJson(inputs.selected_tags), 'ALL') || - contains(fromJson(inputs.selected_tags), 'SmokeMoney')) + contains(fromJson(inputs.selected_tags), 'SmokeBrowser')) }} strategy: matrix: @@ -309,9 +334,9 @@ jobs: fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: - test-suite-name: appium-money-android-smoke-${{ matrix.split }} + test-suite-name: appium-browser-android-smoke-${{ matrix.split }} platform: android - test_suite_tag: SmokeMoney + test_suite_tag: SmokeBrowser split_number: ${{ matrix.split }} total_splits: 1 test-timeout-minutes: 25 @@ -320,49 +345,73 @@ jobs: runner_provider: ${{ inputs.runner_provider }} secrets: inherit - appium-browser-android-smoke: + appium-confirmations-android-smoke: if: >- ${{ !cancelled() && (contains(fromJson(inputs.selected_tags), 'ALL') || - contains(fromJson(inputs.selected_tags), 'SmokeBrowser')) + contains(fromJson(inputs.selected_tags), 'SmokeConfirmations')) }} strategy: matrix: - split: [1] + split: [1, 2] fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: - test-suite-name: appium-browser-android-smoke-${{ matrix.split }} + test-suite-name: appium-confirmations-android-smoke-${{ matrix.split }} platform: android - test_suite_tag: SmokeBrowser + test_suite_tag: SmokeConfirmations split_number: ${{ matrix.split }} - total_splits: 1 + total_splits: 2 + test-timeout-minutes: 35 + build_type: ${{ inputs.build_type }} + metamask_environment: ${{ inputs.metamask_environment }} + runner_provider: ${{ inputs.runner_provider }} + secrets: inherit + + appium-multichain-api-android-smoke: + if: >- + ${{ + !cancelled() && + (contains(fromJson(inputs.selected_tags), 'ALL') || + contains(fromJson(inputs.selected_tags), 'SmokeMultiChainAPI')) + }} + strategy: + matrix: + split: [1, 2] + fail-fast: false + uses: ./.github/workflows/run-appium-e2e-workflow.yml + with: + test-suite-name: appium-multichain-api-android-smoke-${{ matrix.split }} + platform: android + test_suite_tag: SmokeMultiChainAPI + split_number: ${{ matrix.split }} + total_splits: 2 test-timeout-minutes: 25 build_type: ${{ inputs.build_type }} metamask_environment: ${{ inputs.metamask_environment }} runner_provider: ${{ inputs.runner_provider }} secrets: inherit - appium-confirmations-android-smoke: + appium-network-expansion-android-smoke: if: >- ${{ !cancelled() && (contains(fromJson(inputs.selected_tags), 'ALL') || - contains(fromJson(inputs.selected_tags), 'SmokeConfirmations')) + contains(fromJson(inputs.selected_tags), 'SmokeNetworkExpansion')) }} strategy: matrix: - split: [1] + split: [1, 2] fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: - test-suite-name: appium-confirmations-android-smoke-${{ matrix.split }} + test-suite-name: appium-network-expansion-android-smoke-${{ matrix.split }} platform: android - test_suite_tag: SmokeConfirmations + test_suite_tag: SmokeNetworkExpansion split_number: ${{ matrix.split }} - total_splits: 1 - test-timeout-minutes: 60 + total_splits: 2 + test-timeout-minutes: 25 build_type: ${{ inputs.build_type }} metamask_environment: ${{ inputs.metamask_environment }} runner_provider: ${{ inputs.runner_provider }} diff --git a/.github/workflows/run-appium-smoke-tests-ios.yml b/.github/workflows/run-appium-smoke-tests-ios.yml index 15ddcf0d672d..d55162d3519c 100644 --- a/.github/workflows/run-appium-smoke-tests-ios.yml +++ b/.github/workflows/run-appium-smoke-tests-ios.yml @@ -44,7 +44,7 @@ jobs: }} strategy: matrix: - split: [1, 2, 3, 4] + split: [1, 2] fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: @@ -52,7 +52,7 @@ jobs: platform: ios test_suite_tag: SmokeAccounts split_number: ${{ matrix.split }} - total_splits: 4 + total_splits: 2 test-timeout-minutes: 25 build_type: ${{ inputs.build_type || 'main' }} metamask_environment: ${{ inputs.metamask_environment || 'e2e' }} @@ -72,11 +72,12 @@ jobs: fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: - test-suite-name: appium-perps-ios-smoke + test-suite-name: appium-perps-ios-smoke-${{ matrix.split }} platform: ios test_suite_tag: SmokePerps split_number: ${{ matrix.split }} total_splits: 2 + test-timeout-minutes: 25 build_type: ${{ inputs.build_type || 'main' }} metamask_environment: ${{ inputs.metamask_environment || 'e2e' }} runner_provider: ${{ inputs.runner_provider || 'current' }} @@ -91,7 +92,7 @@ jobs: }} strategy: matrix: - split: [1, 2, 3] + split: [1, 2] fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: @@ -99,7 +100,7 @@ jobs: platform: ios test_suite_tag: SmokePredictions split_number: ${{ matrix.split }} - total_splits: 3 + total_splits: 2 test-timeout-minutes: 25 build_type: ${{ inputs.build_type || 'main' }} metamask_environment: ${{ inputs.metamask_environment || 'e2e' }} @@ -115,7 +116,7 @@ jobs: }} strategy: matrix: - split: [1, 2, 3, 4] + split: [1, 2] fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: @@ -123,7 +124,7 @@ jobs: platform: ios test_suite_tag: SmokeSeedlessOnboarding split_number: ${{ matrix.split }} - total_splits: 4 + total_splits: 2 test-timeout-minutes: 25 build_type: ${{ inputs.build_type || 'main' }} metamask_environment: ${{ inputs.metamask_environment || 'e2e' }} @@ -140,7 +141,7 @@ jobs: }} strategy: matrix: - split: [1, 2, 3, 4, 5, 6, 7, 8] + split: [1, 2, 3, 4, 5, 6] fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: @@ -148,8 +149,9 @@ jobs: platform: ios test_suite_tag: SmokeSnaps split_number: ${{ matrix.split }} - total_splits: 8 - test-timeout-minutes: 25 + total_splits: 6 + # Session reuse + denser shards; headroom vs former 25m single-shard load. + test-timeout-minutes: 30 build_type: ${{ inputs.build_type || 'main' }} metamask_environment: ${{ inputs.metamask_environment || 'e2e' }} runner_provider: ${{ inputs.runner_provider || 'current' }} @@ -164,15 +166,15 @@ jobs: }} strategy: matrix: - split: [1] + split: [1, 2] fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: - test-suite-name: appium-network-abstractions-ios-smoke + test-suite-name: appium-network-abstractions-ios-smoke-${{ matrix.split }} platform: ios test_suite_tag: SmokeNetworkAbstractions split_number: ${{ matrix.split }} - total_splits: 1 + total_splits: 2 build_type: ${{ inputs.build_type || 'main' }} metamask_environment: ${{ inputs.metamask_environment || 'e2e' }} runner_provider: ${{ inputs.runner_provider || 'current' }} @@ -187,7 +189,7 @@ jobs: }} strategy: matrix: - split: [1, 2, 3, 4] + split: [1, 2] fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: @@ -195,7 +197,8 @@ jobs: platform: ios test_suite_tag: SmokeWalletPlatform split_number: ${{ matrix.split }} - total_splits: 4 + total_splits: 2 + test-timeout-minutes: 25 build_type: ${{ inputs.build_type || 'main' }} metamask_environment: ${{ inputs.metamask_environment || 'e2e' }} runner_provider: ${{ inputs.runner_provider || 'current' }} @@ -234,7 +237,7 @@ jobs: }} strategy: matrix: - split: [1, 2] + split: [1] fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: @@ -242,7 +245,7 @@ jobs: platform: ios test_suite_tag: SmokeStake split_number: ${{ matrix.split }} - total_splits: 2 + total_splits: 1 test-timeout-minutes: 25 build_type: ${{ inputs.build_type || 'main' }} metamask_environment: ${{ inputs.metamask_environment || 'e2e' }} @@ -306,7 +309,7 @@ jobs: }} strategy: matrix: - split: [1] + split: [1, 2] fail-fast: false uses: ./.github/workflows/run-appium-e2e-workflow.yml with: @@ -314,8 +317,56 @@ jobs: platform: ios test_suite_tag: SmokeConfirmations split_number: ${{ matrix.split }} - total_splits: 1 - test-timeout-minutes: 60 + total_splits: 2 + test-timeout-minutes: 35 + build_type: ${{ inputs.build_type || 'main' }} + metamask_environment: ${{ inputs.metamask_environment || 'e2e' }} + runner_provider: ${{ inputs.runner_provider || 'current' }} + secrets: inherit + + appium-multichain-api-ios-smoke: + if: >- + ${{ + !cancelled() && + (contains(fromJson(inputs.selected_tags || '["ALL"]'), 'ALL') || + contains(fromJson(inputs.selected_tags || '["ALL"]'), 'SmokeMultiChainAPI')) + }} + strategy: + matrix: + split: [1, 2] + fail-fast: false + uses: ./.github/workflows/run-appium-e2e-workflow.yml + with: + test-suite-name: appium-multichain-api-ios-smoke-${{ matrix.split }} + platform: ios + test_suite_tag: SmokeMultiChainAPI + split_number: ${{ matrix.split }} + total_splits: 2 + test-timeout-minutes: 25 + build_type: ${{ inputs.build_type || 'main' }} + metamask_environment: ${{ inputs.metamask_environment || 'e2e' }} + runner_provider: ${{ inputs.runner_provider || 'current' }} + secrets: inherit + + appium-network-expansion-ios-smoke: + if: >- + ${{ + !cancelled() && + (contains(fromJson(inputs.selected_tags || '["ALL"]'), 'ALL') || + contains(fromJson(inputs.selected_tags || '["ALL"]'), 'SmokeNetworkExpansion')) + }} + strategy: + matrix: + split: [1, 2] + fail-fast: false + uses: ./.github/workflows/run-appium-e2e-workflow.yml + with: + test-suite-name: appium-network-expansion-ios-smoke-${{ matrix.split }} + platform: ios + test_suite_tag: SmokeNetworkExpansion + split_number: ${{ matrix.split }} + total_splits: 2 + test-timeout-minutes: 25 build_type: ${{ inputs.build_type || 'main' }} metamask_environment: ${{ inputs.metamask_environment || 'e2e' }} runner_provider: ${{ inputs.runner_provider || 'current' }} diff --git a/.github/workflows/run-e2e-api-specs.yml b/.github/workflows/run-e2e-api-specs.yml index 82eb15fb9296..5e652eb247b1 100644 --- a/.github/workflows/run-e2e-api-specs.yml +++ b/.github/workflows/run-e2e-api-specs.yml @@ -1,5 +1,4 @@ -# This workflow runs API specs tests on iOS simulator. -# API specs are backend API integration tests that use iOS simulator but don't require mobile app UI. +# OpenRPC API specs on iOS Appium (dedicated workflow — not Appium smoke tags). name: API Specs E2E Tests @@ -11,6 +10,11 @@ on: required: false type: string default: current + ios_build_run_id: + description: Optional GitHub Actions run id that has main-e2e-MetaMask.app + required: false + type: string + default: '' workflow_dispatch: inputs: runner_provider: @@ -21,6 +25,11 @@ on: - current - namespace default: current + ios_build_run_id: + description: Optional GitHub Actions run id that has main-e2e-MetaMask.app (else latest successful build on main) + required: false + type: string + default: '' pull_request: types: [opened, synchronize] @@ -28,11 +37,19 @@ jobs: api-specs-ios: name: 'api-specs-ios' if: false - runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ios-build' || 'macos-latest-xlarge' }} continue-on-error: true + permissions: + actions: read + contents: read + runs-on: >- + ${{ + (inputs.runner_provider || 'current') == 'namespace' && + 'namespace-profile-metamask-ios-e2e' || + fromJSON('["ghcr.io/cirruslabs/macos-runner:tahoe"]') + }} env: - METAMASK_ENVIRONMENT: 'dev' + METAMASK_ENVIRONMENT: 'e2e' METAMASK_BUILD_TYPE: 'main' MM_TEST_WALLET_SRP: ${{ secrets.MM_TEST_WALLET_SRP }} SEGMENT_WRITE_KEY_QA: ${{ secrets.SEGMENT_WRITE_KEY_QA }} @@ -41,145 +58,283 @@ jobs: SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SOLANA_E2E_TEST_SRP: ${{ secrets.MM_SOLANA_E2E_TEST_SRP }} + IOS_APP_ARTIFACT_PATH: artifacts/main-e2e-MetaMask.app steps: + - name: Checkout (Namespace) + if: ${{ (inputs.runner_provider || 'current') == 'namespace' }} + uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 + - name: Checkout + if: ${{ (inputs.runner_provider || 'current') != 'namespace' }} uses: actions/checkout@v4 with: ref: ${{ github.head_ref || github.ref }} clean: true - - name: Setup Node.js - uses: actions/setup-node@v4 + - name: Setup E2E environment (iOS) + uses: ./.github/actions/setup-e2e-env with: - node-version: '24.16.0' - cache: 'yarn' - - - name: Enable Corepack and setup Yarn - run: | - corepack enable - corepack prepare yarn@4.14.1 --activate - - - name: Install JavaScript dependencies with retry - uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 - env: - NODE_OPTIONS: --max-old-space-size=4096 + platform: ios + setup-simulator: 'false' + configure-keystores: 'false' + install-foundry: 'true' + skip-pod-install: 'true' + install-applesimutils: 'false' + runner_provider: ${{ inputs.runner_provider || 'current' }} + + - name: Cache ffmpeg (Homebrew) + uses: actions/cache@v4 with: - timeout_minutes: 10 - max_attempts: 3 - retry_wait_seconds: 30 - command: yarn install --immutable - - - name: Verify Detox CLI - run: yarn detox --version - - - name: Setup Xcode - run: sudo xcode-select -s /Applications/Xcode_26.3.app + path: ~/.cache/mms-ffmpeg + key: brew-ffmpeg-${{ runner.os }}-v1 + restore-keys: | + brew-ffmpeg-${{ runner.os }}- - - name: Install applesimutils for iOS simulator control - run: | - echo "Installing applesimutils for Detox iOS simulator control..." - brew tap wix/brew && brew install applesimutils - echo "✅ applesimutils installed" + - name: Install ffmpeg for XCUITest screen recording + run: bash scripts/e2e/ensure-ffmpeg-ci.sh + shell: bash - - name: Build Detox framework cache for API specs + - name: Download main-e2e iOS app (self-contained) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ARTIFACT_NAME: main-e2e-MetaMask.app + APP_PATH: ${{ env.IOS_APP_ARTIFACT_PATH }} + INPUT_RUN_ID: ${{ inputs.ios_build_run_id || '' }} + HEAD_REF: ${{ github.head_ref }} + REF_NAME: ${{ github.ref_name }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + REPOSITORY: ${{ github.repository }} run: | - echo "🔧 Building Detox framework cache specifically for API specs..." - - # Clean any existing cache first - yarn detox clean-framework-cache - - # Build fresh framework cache - yarn detox build-framework-cache - - # Verify the framework was built successfully - if [[ -d ~/Library/Detox/ios/framework ]]; then - echo "✅ Detox framework cache built successfully" - find ~/Library/Detox/ios/framework/ -maxdepth 1 -exec ls -la {} \; - else - echo "❌ Failed to build Detox framework cache" + set -euo pipefail + + mkdir -p "$(dirname "$APP_PATH")" + rm -rf "$APP_PATH" + + resolve_run_id() { + if [ -n "${INPUT_RUN_ID}" ]; then + echo "${INPUT_RUN_ID}" + return + fi + + # Prefer a successful build on the PR head branch when present, else main. + BRANCH="${HEAD_REF:-${REF_NAME}}" + if [ -z "${BRANCH}" ] || [ "${BRANCH}" = "${DEFAULT_BRANCH}" ]; then + BRANCH="main" + fi + + echo "Resolving latest successful build workflow run on branch '${BRANCH}'..." >&2 + RUN_ID="$( + gh run list \ + --repo "${REPOSITORY}" \ + --workflow build.yml \ + --branch "${BRANCH}" \ + --status success \ + --limit 20 \ + --json databaseId \ + --jq '.[].databaseId' \ + | while read -r id; do + if gh api \ + "repos/${REPOSITORY}/actions/runs/${id}/artifacts" \ + --jq ".artifacts[] | select(.name == \"${ARTIFACT_NAME}\") | .name" \ + | grep -qx "${ARTIFACT_NAME}"; then + echo "${id}" + break + fi + done + )" + + if [ -z "${RUN_ID}" ] && [ "${BRANCH}" != "main" ]; then + echo "No ${ARTIFACT_NAME} on '${BRANCH}'; falling back to main..." >&2 + RUN_ID="$( + gh run list \ + --repo "${REPOSITORY}" \ + --workflow build.yml \ + --branch main \ + --status success \ + --limit 20 \ + --json databaseId \ + --jq '.[].databaseId' \ + | while read -r id; do + if gh api \ + "repos/${REPOSITORY}/actions/runs/${id}/artifacts" \ + --jq ".artifacts[] | select(.name == \"${ARTIFACT_NAME}\") | .name" \ + | grep -qx "${ARTIFACT_NAME}"; then + echo "${id}" + break + fi + done + )" + fi + + if [ -z "${RUN_ID}" ]; then + echo "::error title=main-e2e iOS artifact not found::Could not find a successful build.yml run with artifact ${ARTIFACT_NAME}." + exit 1 + fi + + echo "${RUN_ID}" + } + + RUN_ID="$(resolve_run_id)" + echo "📥 Downloading ${ARTIFACT_NAME} from run ${RUN_ID}..." + echo "Run URL: https://github.com/${REPOSITORY}/actions/runs/${RUN_ID}" + + if ! gh run download "${RUN_ID}" \ + --repo "${REPOSITORY}" \ + -n "${ARTIFACT_NAME}" \ + -D "${APP_PATH}"; then + echo "::error title=iOS artifact download failed::Failed to download ${ARTIFACT_NAME} from run ${RUN_ID}." exit 1 fi - - name: Setup pre-built QA app for API specs - run: | - echo "🚀 Setting up pre-built QA app for API specs..." - - # Base URL for artifacts - base_url="https://github.com/MetaMask/tmp-bitrise-migration-artifacts/releases/download/test5" - - # Create required directory - mkdir -p ios/build/Build/Products/Release-iphonesimulator/ - - echo "📥 Downloading iOS QA app..." - - # Download Release-iphonesimulator.zip - if curl -L --fail -o /tmp/Release-iphonesimulator.zip "${base_url}/Release-iphonesimulator.zip"; then - echo "✅ Downloaded Release-iphonesimulator.zip" - echo "📦 Extracting iOS QA app..." + if [ ! -f "${APP_PATH}/Info.plist" ]; then + # gh sometimes extracts as nested MetaMask.app under the destination + NESTED="$(find "${APP_PATH}" -maxdepth 2 -type d -name 'MetaMask.app' | head -1 || true)" + if [ -n "${NESTED}" ] && [ -f "${NESTED}/Info.plist" ]; then + echo "Normalizing nested bundle ${NESTED} -> ${APP_PATH}" + TMP_DIR="$(mktemp -d)" + mv "${NESTED}" "${TMP_DIR}/MetaMask.app" + rm -rf "${APP_PATH}" + mv "${TMP_DIR}/MetaMask.app" "${APP_PATH}" + rmdir "${TMP_DIR}" 2>/dev/null || true + fi + fi - # Extract preserving directory structure - cd ios/build/Build/Products/Release-iphonesimulator/ - unzip -o /tmp/Release-iphonesimulator.zip "MetaMask-QA.app/*" - cd - > /dev/null + if [ ! -f "${APP_PATH}/Info.plist" ]; then + echo "::error title=Invalid iOS app bundle::Info.plist missing under ${APP_PATH}" + find "${APP_PATH}" -maxdepth 2 2>/dev/null || true + exit 1 + fi - # Set proper permissions for the app bundle - chmod -R 755 "ios/build/Build/Products/Release-iphonesimulator/MetaMask-QA.app/" 2>/dev/null || true + echo "✅ main-e2e MetaMask.app ready at ${APP_PATH}" + shell: bash - else - echo "❌ Failed to download iOS QA app artifacts" + - name: Restore iOS bundle executable permissions + env: + APP_PATH: ${{ env.IOS_APP_ARTIFACT_PATH }} + run: | + BUNDLE_EXEC=$(/usr/libexec/PlistBuddy -c "Print CFBundleExecutable" "$APP_PATH/Info.plist" 2>/dev/null) + if [ -z "$BUNDLE_EXEC" ]; then + echo "Could not read CFBundleExecutable from Info.plist" exit 1 fi - # Verify setup - if [[ -f "ios/build/Build/Products/Release-iphonesimulator/MetaMask-QA.app/Info.plist" ]]; then - echo "✅ iOS QA app ready for API specs" - echo "📋 App bundle contents:" - find "ios/build/Build/Products/Release-iphonesimulator/MetaMask-QA.app/" -maxdepth 1 -exec ls -la {} \; | head -10 - else - echo "❌ iOS QA app setup failed - Info.plist not found" - echo "📋 Directory contents:" - find "ios/build/Build/Products/Release-iphonesimulator/" -maxdepth 1 -exec ls -la {} \; 2>/dev/null || true + ACTUAL_PATH=$(find "$APP_PATH" -maxdepth 1 -iname "$BUNDLE_EXEC" -type f | head -1) + if [ -z "$ACTUAL_PATH" ]; then + echo "Bundle executable not found: $BUNDLE_EXEC" exit 1 fi - - name: Clean environment before API specs - run: | - echo "🧹 Cleaning environment before API specs tests..." + if [ "$(basename "$ACTUAL_PATH")" != "$BUNDLE_EXEC" ]; then + mv "$ACTUAL_PATH" "$APP_PATH/${BUNDLE_EXEC}_fix" + mv "$APP_PATH/${BUNDLE_EXEC}_fix" "$APP_PATH/$BUNDLE_EXEC" + fi - # Clean up any lock files - find . -name "*.lock" -type f -delete 2>/dev/null || true + chmod +x "$APP_PATH/$BUNDLE_EXEC" - # Reset Detox lock file - yarn detox reset-lock-file + if [ -d "$APP_PATH/Frameworks" ]; then + find "$APP_PATH/Frameworks" -type d -name "*.framework" | while IFS= read -r fw; do + binary="$fw/$(basename "$fw" .framework)" + if [ -f "$binary" ]; then + chmod +x "$binary" + fi + done + find "$APP_PATH/Frameworks" -type f -name "*.dylib" -exec chmod +x {} \; + fi + echo "Restored execute permissions on main binary and all framework binaries" + shell: bash - # Clean any hanging processes - pkill -f "Metro\|node\|npm" 2>/dev/null || true + - name: Resolve XCUITest driver version for WDA cache key + id: xcuitest-version + run: echo "version=$(node scripts/e2e/resolve-xcuitest-driver-version.mjs)" >> "$GITHUB_OUTPUT" - echo "✅ Environment cleaned and ready for API specs" + - name: Get Xcode version for WDA cache key + id: xcode-version + run: echo "version=$(xcodebuild -version 2>/dev/null | head -1 | tr ' ' '-')" >> "$GITHUB_OUTPUT" - - name: Run API Specs tests + - name: Restore WDA DerivedData cache + uses: actions/cache@v4 + with: + path: ~/appium-wda + key: wda-derived-data-xcuitest-${{ steps.xcuitest-version.outputs.version }}-${{ steps.xcode-version.outputs.version }}-${{ runner.os }} + restore-keys: | + wda-derived-data-xcuitest-${{ steps.xcuitest-version.outputs.version }}-${{ steps.xcode-version.outputs.version }}- + + - name: Check if WDA is prebuilt + id: wda-prebuilt run: | - echo "🚀 Running API specs tests..." - echo "Using Detox configuration: ios.sim.apiSpecs" + WDA_APP=$(find ~/appium-wda/Build/Products -name 'WebDriverAgentRunner-Runner.app' -type d 2>/dev/null | head -1 || true) + XCTESTRUN=$(find ~/appium-wda/Build/Products -name '*.xctestrun' 2>/dev/null | head -1 || true) + if [ -n "$WDA_APP" ] && [ -n "$XCTESTRUN" ]; then + echo "ready=true" >> "$GITHUB_OUTPUT" + echo "WDA prebuilt artifacts found" + else + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "WDA cache miss — prepare-ios-appium-runner will prebuild after sim boot" + fi + shell: bash - # Run API specs with retries - yarn test:api-specs --retries 1 + - name: Prepare iOS Appium runner + id: prepare-ios-appium + timeout-minutes: 30 + run: node scripts/e2e/prepare-ios-appium-runner.mjs + env: + IOS_SIMULATOR_NAME: iPhone 16 Pro + IOS_APP_PATH: ${{ env.IOS_APP_ARTIFACT_PATH }} + IOS_BUNDLE_ID: io.metamask.MetaMask + SKIP_WDA_PREBUILD: ${{ steps.wda-prebuilt.outputs.ready == 'true' && 'true' || 'false' }} + IOS_SIMULATOR_POST_BOOT_SETTLE_MS: '30000' + IOS_APP_WARM_LAUNCH_SETTLE_MS: '15000' + + - name: Verify Appium runner connectivity (iOS) + run: yarn tsx scripts/e2e/verify-appium-runner-connectivity.mjs ios + env: + IOS_SIMULATOR_UDID: ${{ steps.prepare-ios-appium.outputs.ios-simulator-udid }} - echo "✅ API specs tests completed" + - name: Run API Specs (iOS Appium) + timeout-minutes: 60 + run: yarn test:api-specs + env: + APPIUM_SMOKE_SUITE_NAME: api-specs-ios + APPIUM_SMOKE_JOB_TITLE: API Specs (ios Appium) + APPIUM_SMOKE_ARTIFACT_NAME: api-specs-report + PLAYWRIGHT_JSON_OUTPUT_FILE: tests/test-reports/playwright-json/playwright-report.json + SKIP_APPIUM_STOP: 'true' + IOS_APP_PATH: ${{ env.IOS_APP_ARTIFACT_PATH }} + IOS_SIMULATOR_NAME: iPhone 16 Pro + IOS_SIMULATOR_UDID: ${{ steps.prepare-ios-appium.outputs.ios-simulator-udid }} + IOS_WDA_PREINSTALLED: ${{ steps.prepare-ios-appium.outputs.ios-wda-preinstalled }} + IOS_WDA_BUNDLE_ID: ${{ steps.prepare-ios-appium.outputs.ios-wda-bundle-id }} + SKIP_DEVICE_BOOT: 'true' + SKIP_APP_REINSTALL: 'true' + USE_PREBUILT_WDA: 'true' + IOS_SIMULATOR_POST_BOOT_SETTLE_MS: '30000' + IOS_PRE_LAUNCH_SETTLE_MS: '1500' + MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} + + - name: Upload Playwright HTML report (Namespace) + if: ${{ always() && (inputs.runner_provider || 'current') == 'namespace' }} + uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3 + with: + name: api-specs-report + path: tests/test-reports/appium-smoke-report/api-specs-ios/ + if-no-files-found: ignore + retention-days: 7 - - name: Upload API specs test results - if: always() + - name: Upload Playwright HTML report (current) + if: ${{ always() && (inputs.runner_provider || 'current') != 'namespace' }} uses: actions/upload-artifact@v4 with: - name: api-specs-test-results - path: tests/reports/ + name: api-specs-report + path: tests/test-reports/appium-smoke-report/api-specs-ios/ + if-no-files-found: ignore retention-days: 7 - - name: Upload API specs screenshots - if: failure() || cancelled() + - name: Upload Playwright JSON report + if: always() uses: actions/upload-artifact@v4 with: - name: api-specs-screenshots - path: tests/artifacts/ + name: api-specs-playwright-json + path: tests/test-reports/playwright-json/ + if-no-files-found: ignore retention-days: 7 diff --git a/.github/workflows/run-e2e-smoke-tests-android.yml b/.github/workflows/run-e2e-smoke-tests-android.yml deleted file mode 100644 index 77b6bb922ae6..000000000000 --- a/.github/workflows/run-e2e-smoke-tests-android.yml +++ /dev/null @@ -1,254 +0,0 @@ -name: Android E2E Smoke Tests - -on: - workflow_call: - inputs: - selected_tags: - description: 'JSON array of selected tags from Smart E2E selection (e.g., ["SmokeAccounts", "SmokeSwap"])' - required: false - type: string - default: '["ALL"]' - changed_spec_files: - description: 'Space-separated list of changed E2E spec file paths, used to run modified specs twice for flakiness detection' - required: false - type: string - default: '' - runner_provider: - description: Runner provider forwarded from the caller - required: false - type: string - default: current - -permissions: - contents: read - id-token: write - -jobs: - perps-android-smoke: - if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokePerps') - strategy: - matrix: - split: [1] - fail-fast: false - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: perps-android-smoke-${{ matrix.split }} - platform: android - test_suite_tag: 'SmokePerps' - split_number: ${{ matrix.split }} - total_splits: 1 - changed_spec_files: ${{ inputs.changed_spec_files }} - runner_provider: ${{ inputs.runner_provider }} - secrets: inherit - - wallet-platform-android-smoke: - if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokeWalletPlatform') - strategy: - matrix: - split: [1, 2] - fail-fast: false - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: wallet-platform-android-smoke-${{ matrix.split }} - platform: android - test_suite_tag: 'SmokeWalletPlatform' - split_number: ${{ matrix.split }} - total_splits: 2 - changed_spec_files: ${{ inputs.changed_spec_files }} - runner_provider: ${{ inputs.runner_provider }} - secrets: inherit - - network-abstraction-android-smoke: - if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokeNetworkAbstractions') - strategy: - matrix: - split: [1, 2] - fail-fast: false - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: network-abstraction-android-smoke-${{ matrix.split }} - platform: android - test_suite_tag: 'SmokeNetworkAbstractions' - split_number: ${{ matrix.split }} - total_splits: 2 - changed_spec_files: ${{ inputs.changed_spec_files }} - runner_provider: ${{ inputs.runner_provider }} - secrets: inherit - - network-expansion-android-smoke: - if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokeNetworkExpansion') - strategy: - matrix: - split: [1, 2] - fail-fast: false - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: network-expansion-android-smoke-${{ matrix.split }} - platform: android - test_suite_tag: 'SmokeNetworkExpansion' - split_number: ${{ matrix.split }} - total_splits: 2 - changed_spec_files: ${{ inputs.changed_spec_files }} - runner_provider: ${{ inputs.runner_provider }} - secrets: inherit - - confirmations-android-smoke: - if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokeConfirmations') - strategy: - matrix: - split: [1, 2, 3, 4] - fail-fast: false - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: confirmations-android-smoke-${{ matrix.split }} - platform: android - test_suite_tag: 'SmokeConfirmations' - split_number: ${{ matrix.split }} - total_splits: 4 - changed_spec_files: ${{ inputs.changed_spec_files }} - runner_provider: ${{ inputs.runner_provider }} - secrets: inherit - - multichain-api-android-smoke: - if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokeMultiChainAPI') - strategy: - matrix: - split: [1] - fail-fast: false - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: multichain-api-android-smoke-${{ matrix.split }} - platform: android - test_suite_tag: 'SmokeMultiChainAPI' - split_number: ${{ matrix.split }} - total_splits: 1 - changed_spec_files: ${{ inputs.changed_spec_files }} - runner_provider: ${{ inputs.runner_provider }} - secrets: inherit - - snaps-android-smoke: - if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokeSnaps') - strategy: - matrix: - split: [1, 2, 3, 4] - fail-fast: false - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: snaps-android-smoke-${{ matrix.split }} - platform: android - test_suite_tag: 'SmokeSnaps' - split_number: ${{ matrix.split }} - total_splits: 4 - changed_spec_files: ${{ inputs.changed_spec_files }} - runner_provider: ${{ inputs.runner_provider }} - secrets: inherit - - report-android-smoke-tests: - name: Report Android Smoke Tests - runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} - # Appium migration: skip rollup when every Detox shard is skipped (e.g. AI selects - # an Appium-only tag like SmokeAccounts / SmokeSwap / SmokeStake / SmokeBrowser). Non-empty tags still trigger this workflow. - if: >- - ${{ - !cancelled() && - ( - contains(toJSON(needs), '"result":"success"') || - contains(toJSON(needs), '"result":"failure"') - ) - }} - needs: - - perps-android-smoke - - wallet-platform-android-smoke - - network-abstraction-android-smoke - - network-expansion-android-smoke - - confirmations-android-smoke - - multichain-api-android-smoke - - snaps-android-smoke - steps: - - name: Checkout (Namespace) - uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 - if: ${{ inputs.runner_provider == 'namespace' }} - - - name: Checkout - uses: actions/checkout@v6 - if: ${{ inputs.runner_provider != 'namespace' }} - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - - - name: Download shards test artifacts (XMLs + Screenshots) (Namespace) - if: ${{ inputs.runner_provider == 'namespace' }} - uses: namespace-actions/download-artifact@7cbad919e4b0e09f17e9d6311a444ff002992b5b # v2.0.1 - continue-on-error: true - with: - path: all-test-artifacts/ - pattern: 'test-e2e-main-*-android-*' - - name: Download shards test artifacts (XMLs + Screenshots) (current) - if: ${{ inputs.runner_provider != 'namespace' }} - uses: actions/download-artifact@v4 - continue-on-error: true - with: - path: all-test-artifacts/ - pattern: 'test-e2e-main-*-android-*' - - - name: Post Test Report - uses: dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 - with: - name: 'Android E2E Smoke Test Results' - path: 'all-test-artifacts/**/junit.xml' - reporter: 'jest-junit' - fail-on-error: false - list-suites: 'failed' - list-tests: 'failed' - - - name: Upload all test artifacts (XMLs + Screenshots) (Namespace) - if: ${{ inputs.runner_provider == 'namespace' }} - uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3 - with: - name: e2e-smoke-android-all-test-artifacts - path: all-test-artifacts/ - retention-days: 7 - - name: Upload all test artifacts (XMLs + Screenshots) (current) - if: ${{ inputs.runner_provider != 'namespace' }} - uses: actions/upload-artifact@v4 - with: - name: e2e-smoke-android-all-test-artifacts - path: all-test-artifacts/ - retention-days: 7 - - - name: Install CI script dependencies - continue-on-error: true - uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 - with: - timeout_minutes: 10 - max_attempts: 3 - retry_wait_seconds: 30 - command: cd .github/scripts && yarn --immutable - - - name: Create mobile JSON test report - id: create-json-report - continue-on-error: true - run: | - TEST_RESULTS_PATH=all-test-artifacts \ - TEST_RUNS_PATH=test/test-results/test-runs-android.json \ - RUN_ID=${{ github.run_id }} \ - PR_NUMBER=${{ github.event.pull_request.number || '0' }} \ - GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} \ - node .github/scripts/e2e-create-json-test-report.mjs - - - name: Upload JSON test report (Namespace) - if: ${{ steps.create-json-report.outcome == 'success' && inputs.runner_provider == 'namespace' }} - uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3 - with: - name: test-e2e-android-json-report - path: test/test-results/test-runs-android.json - retention-days: 7 - - name: Upload JSON test report (current) - if: ${{ steps.create-json-report.outcome == 'success' && inputs.runner_provider != 'namespace' }} - uses: actions/upload-artifact@v4 - with: - name: test-e2e-android-json-report - path: test/test-results/test-runs-android.json - retention-days: 7 diff --git a/.github/workflows/run-e2e-smoke-tests-ios.yml b/.github/workflows/run-e2e-smoke-tests-ios.yml deleted file mode 100644 index 835c98529279..000000000000 --- a/.github/workflows/run-e2e-smoke-tests-ios.yml +++ /dev/null @@ -1,262 +0,0 @@ -name: iOS E2E Smoke Tests - -on: - workflow_call: - inputs: - selected_tags: - description: 'JSON array of selected tags from Smart E2E selection (e.g., ["SmokeAccounts", "SmokeSwap"])' - required: false - type: string - default: '["ALL"]' - changed_spec_files: - description: 'Space-separated list of changed E2E spec file paths, used to run modified specs twice for flakiness detection' - required: false - type: string - default: '' - runner_provider: - description: Runner provider forwarded from the caller - required: false - type: string - default: current - -permissions: - contents: read - id-token: write - -jobs: - confirmations-ios-smoke: - if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokeConfirmations') - strategy: - matrix: - split: [1, 2, 3, 4] - fail-fast: false - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: confirmations-ios-smoke-${{ matrix.split }} - platform: ios - test_suite_tag: 'SmokeConfirmations' - split_number: ${{ matrix.split }} - total_splits: 4 - changed_spec_files: ${{ inputs.changed_spec_files }} - build_type: 'main' - runner_provider: ${{ inputs.runner_provider }} - secrets: inherit - - perps-ios-smoke: - if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokePerps') - strategy: - matrix: - split: [1] - fail-fast: false - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: perps-ios-smoke-${{ matrix.split }} - platform: ios - test_suite_tag: 'SmokePerps' - split_number: ${{ matrix.split }} - total_splits: 1 - changed_spec_files: ${{ inputs.changed_spec_files }} - build_type: 'main' - runner_provider: ${{ inputs.runner_provider }} - secrets: inherit - - wallet-platform-ios-smoke: - if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokeWalletPlatform') - strategy: - matrix: - split: [1, 2] - fail-fast: false - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: wallet-platform-ios-smoke-${{ matrix.split }} - platform: ios - test_suite_tag: 'SmokeWalletPlatform' - split_number: ${{ matrix.split }} - total_splits: 2 - changed_spec_files: ${{ inputs.changed_spec_files }} - build_type: 'main' - runner_provider: ${{ inputs.runner_provider }} - secrets: inherit - - network-abstraction-ios-smoke: - if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokeNetworkAbstractions') - strategy: - matrix: - split: [1, 2] - fail-fast: false - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: network-abstraction-ios-smoke-${{ matrix.split }} - platform: ios - test_suite_tag: 'SmokeNetworkAbstractions' - split_number: ${{ matrix.split }} - total_splits: 2 - changed_spec_files: ${{ inputs.changed_spec_files }} - build_type: 'main' - runner_provider: ${{ inputs.runner_provider }} - secrets: inherit - - network-expansion-ios-smoke: - if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokeNetworkExpansion') - strategy: - matrix: - split: [1, 2] - fail-fast: false - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: network-expansion-ios-smoke-${{ matrix.split }} - platform: ios - test_suite_tag: 'SmokeNetworkExpansion' - split_number: ${{ matrix.split }} - total_splits: 2 - changed_spec_files: ${{ inputs.changed_spec_files }} - build_type: 'main' - runner_provider: ${{ inputs.runner_provider }} - secrets: inherit - - multichain-api-ios-smoke: - if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokeMultiChainAPI') - strategy: - matrix: - split: [1] - fail-fast: false - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: multichain-api-ios-smoke-${{ matrix.split }} - platform: ios - test_suite_tag: 'SmokeMultiChainAPI' - split_number: ${{ matrix.split }} - total_splits: 1 - changed_spec_files: ${{ inputs.changed_spec_files }} - build_type: 'main' - runner_provider: ${{ inputs.runner_provider }} - secrets: inherit - - snaps-ios-smoke: - if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokeSnaps') - strategy: - matrix: - split: [1, 2, 3, 4] - fail-fast: false - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: snaps-ios-smoke-${{ matrix.split }} - platform: ios - test_suite_tag: 'SmokeSnaps' - split_number: ${{ matrix.split }} - total_splits: 4 - changed_spec_files: ${{ inputs.changed_spec_files }} - build_type: 'main' - runner_provider: ${{ inputs.runner_provider }} - bitrise_machine_type: ${{ inputs.runner_provider == 'bitrise' && matrix.split == 1 && vars.BITRISE_E2E_HEAVY_MACHINE_TYPE || '' }} - secrets: inherit - - report-ios-smoke-tests: - name: Report iOS Smoke Tests - runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} - # Appium migration: skip rollup when every Detox shard is skipped (e.g. AI selects - # an Appium-only tag like SmokeAccounts / SmokeSwap / SmokeStake / SmokeBrowser). Non-empty tags still trigger this workflow. - if: >- - ${{ - !cancelled() && - ( - contains(toJSON(needs), '"result":"success"') || - contains(toJSON(needs), '"result":"failure"') - ) - }} - needs: - - confirmations-ios-smoke - - perps-ios-smoke - - wallet-platform-ios-smoke - - network-abstraction-ios-smoke - - network-expansion-ios-smoke - - multichain-api-ios-smoke - - snaps-ios-smoke - steps: - - name: Checkout (Namespace) - uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 - if: ${{ inputs.runner_provider == 'namespace' }} - - - name: Checkout - uses: actions/checkout@v6 - if: ${{ inputs.runner_provider != 'namespace' }} - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - - - name: Download shards test artifacts (XMLs + Screenshots) (Namespace) - if: ${{ inputs.runner_provider == 'namespace' }} - uses: namespace-actions/download-artifact@7cbad919e4b0e09f17e9d6311a444ff002992b5b # v2.0.1 - continue-on-error: true - with: - path: all-test-artifacts/ - pattern: 'test-e2e-main-*-ios-*' - - name: Download shards test artifacts (XMLs + Screenshots) (current) - if: ${{ inputs.runner_provider != 'namespace' }} - uses: actions/download-artifact@v4 - continue-on-error: true - with: - path: all-test-artifacts/ - pattern: 'test-e2e-main-*-ios-*' - - - name: Post Test Report - uses: dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 - with: - name: 'iOS E2E Smoke Test Results' - path: 'all-test-artifacts/**/junit.xml' - reporter: 'jest-junit' - fail-on-error: false - list-suites: 'failed' - list-tests: 'failed' - - - name: Upload all test artifacts (XMLs + Screenshots) (Namespace) - if: ${{ inputs.runner_provider == 'namespace' }} - uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3 - with: - name: e2e-smoke-ios-all-test-artifacts - path: all-test-artifacts/ - retention-days: 7 - - name: Upload all test artifacts (XMLs + Screenshots) (current) - if: ${{ inputs.runner_provider != 'namespace' }} - uses: actions/upload-artifact@v4 - with: - name: e2e-smoke-ios-all-test-artifacts - path: all-test-artifacts/ - retention-days: 7 - - - name: Install CI script dependencies - continue-on-error: true - uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 - with: - timeout_minutes: 10 - max_attempts: 3 - retry_wait_seconds: 30 - command: cd .github/scripts && yarn --immutable - - - name: Create mobile JSON test report - id: create-json-report - continue-on-error: true - run: | - TEST_RESULTS_PATH=all-test-artifacts \ - TEST_RUNS_PATH=test/test-results/test-runs-ios.json \ - RUN_ID=${{ github.run_id }} \ - PR_NUMBER=${{ github.event.pull_request.number || '0' }} \ - GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} \ - node .github/scripts/e2e-create-json-test-report.mjs - - - name: Upload JSON test report (Namespace) - if: ${{ steps.create-json-report.outcome == 'success' && inputs.runner_provider == 'namespace' }} - uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3 - with: - name: test-e2e-ios-json-report - path: test/test-results/test-runs-ios.json - retention-days: 7 - - name: Upload JSON test report (current) - if: ${{ steps.create-json-report.outcome == 'success' && inputs.runner_provider != 'namespace' }} - uses: actions/upload-artifact@v4 - with: - name: test-e2e-ios-json-report - path: test/test-results/test-runs-ios.json - retention-days: 7 diff --git a/.github/workflows/run-performance-e2e-experimental.yml b/.github/workflows/run-performance-e2e-experimental.yml deleted file mode 100644 index f2d9d942b353..000000000000 --- a/.github/workflows/run-performance-e2e-experimental.yml +++ /dev/null @@ -1,70 +0,0 @@ -# Same as run-performance-e2e-release but for experimental builds (main branch, build-exp). -# Triggers experimental Android/iOS builds and runs performance E2E. -name: Performance E2E Tests for Experimental Builds -on: - schedule: - - cron: '0 */3 * * 1-6' # Every 3 hours, Mon–Fri (aligned with main performance workflow) - workflow_dispatch: - inputs: - sentry_target: - description: 'Sentry target for performance events (test or real)' - required: false - type: choice - options: - - test - - real - default: test - push: - branches: - - main - -# Must be at least as permissive as the called reusable workflow (run-performance-e2e.yml), -# which transitively requires contents: write via build-{android,ios}-upload-to-browserstack.yml. -permissions: - contents: write - id-token: write - actions: write - pull-requests: write - statuses: write - -concurrency: - group: performance-e2e-experimental-${{ github.ref }}-${{ github.event_name }} - cancel-in-progress: false - -jobs: - check-experimental-trigger: - name: Check if Experimental Performance Tests Should Run - runs-on: ubuntu-latest - outputs: - should-run: ${{ steps.check.outputs.should-run }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Check experimental trigger conditions - id: check - run: | - # Always run for manual dispatch or push to main - if [[ "${{ github.event_name }}" == "workflow_dispatch" || ("${{ github.event_name }}" == "push" && "${{ github.ref_name }}" == "main") ]]; then - echo "should-run=true" >> "$GITHUB_OUTPUT" - echo "Experimental performance tests triggered by ${{ github.event_name }}" - # For scheduled runs, always run (periodic performance check on experimental builds) - elif [[ "${{ github.event_name }}" == "schedule" ]]; then - echo "should-run=true" >> "$GITHUB_OUTPUT" - echo "Scheduled run: experimental performance E2E" - else - echo "should-run=false" >> "$GITHUB_OUTPUT" - fi - - run-performance-e2e-experimental: - name: Run Performance E2E Tests for Experimental Builds - uses: ./.github/workflows/run-performance-e2e.yml - needs: [check-experimental-trigger] - if: needs.check-experimental-trigger.outputs.should-run == 'true' - with: - branch_name: main - build_variant: exp - sentry_target: ${{ inputs.sentry_target || 'test' }} - secrets: inherit diff --git a/.github/workflows/run-performance-e2e-manual.yml b/.github/workflows/run-performance-e2e-manual.yml new file mode 100644 index 000000000000..1df9864561e1 --- /dev/null +++ b/.github/workflows/run-performance-e2e-manual.yml @@ -0,0 +1,100 @@ +name: Run Performance E2E Tests Manually + +# Scheduled runs use the e2e build from main: +# - 00:00, 06:00, 12:00, 18:00 UTC +on: + schedule: + - cron: '0 */6 * * 1-6' + workflow_dispatch: + inputs: + build_variant: + description: 'BrowserStack build profile' + required: false + type: choice + options: + - e2e + - exp + - rc + default: e2e + sentry_target: + description: 'Sentry target for performance events (test or real)' + required: false + type: choice + options: + - test + - real + default: test + browserstack_app_url_android_onboarding: + description: 'BrowserStack Android Onboarding App URL (bs://...)' + required: false + type: string + browserstack_app_url_ios_onboarding: + description: 'BrowserStack iOS Onboarding App URL (bs://...)' + required: false + type: string + browserstack_app_url_android_imported_wallet: + description: 'BrowserStack Android Imported Wallet App URL (bs://...)' + required: false + type: string + browserstack_app_url_ios_imported_wallet: + description: 'BrowserStack iOS Imported Wallet App URL (bs://...)' + required: false + type: string + performance_tags: + description: 'JSON array of performance tags. Blank runs all; [] skips all.' + required: false + type: string + default: '' + reuse_main_builds: + description: 'For e2e only, reuse the latest matching Android apps from main CI' + required: false + type: boolean + default: false + source_fingerprint: + description: 'Optional @expo/fingerprint hash for Android app reuse' + required: false + type: string + default: '' + +permissions: + contents: write + id-token: write + actions: write + pull-requests: write + statuses: write + +concurrency: + # Manual runs are isolated by ref and variant so they do not block the + # scheduled cadence. + group: performance-e2e-trigger-${{ github.event_name == 'schedule' && github.event.schedule || github.ref }}-${{ github.event_name == 'schedule' && 'scheduled' || inputs.build_variant }} + cancel-in-progress: false + +jobs: + scheduled-e2e: + name: Scheduled Performance E2E (e2e) + if: github.event_name == 'schedule' && github.event.schedule == '0 */6 * * 1-6' + uses: ./.github/workflows/run-performance-e2e.yml + with: + branch_name: main + source_ref: main + build_variant: e2e + sentry_target: test + secrets: inherit + + manual: + name: Manual Performance E2E (${{ inputs.build_variant }}) + if: github.event_name == 'workflow_dispatch' + uses: ./.github/workflows/run-performance-e2e.yml + with: + branch_name: ${{ github.ref_name }} + source_ref: ${{ github.ref_name }} + build_variant: ${{ inputs.build_variant }} + sentry_target: ${{ inputs.sentry_target }} + browserstack_app_url_android_onboarding: ${{ inputs.browserstack_app_url_android_onboarding }} + browserstack_app_url_ios_onboarding: ${{ inputs.browserstack_app_url_ios_onboarding }} + browserstack_app_url_android_imported_wallet: ${{ inputs.browserstack_app_url_android_imported_wallet }} + browserstack_app_url_ios_imported_wallet: ${{ inputs.browserstack_app_url_ios_imported_wallet }} + performance_tags: ${{ inputs.performance_tags }} + reuse_main_builds: ${{ inputs.build_variant == 'e2e' && inputs.reuse_main_builds }} + source_fingerprint: ${{ inputs.source_fingerprint }} + secrets: inherit diff --git a/.github/workflows/run-performance-e2e-release.yml b/.github/workflows/run-performance-e2e-release.yml deleted file mode 100644 index 386601079fcc..000000000000 --- a/.github/workflows/run-performance-e2e-release.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: Performance E2E Tests for Release Builds -on: - schedule: - - cron: '*/30 * * * *' # Every 30 minutes to check for metamaskbot commits - workflow_dispatch: - inputs: - sentry_target: - description: 'Sentry target for performance events (test or real)' - required: false - type: choice - options: - - test - - real - default: test - push: - branches: - - 'release/*' - -# Must be at least as permissive as the called reusable workflow (run-performance-e2e.yml), -# which transitively requires contents: write via build-{android,ios}-upload-to-browserstack.yml. -permissions: - contents: write - id-token: write - actions: write - pull-requests: write - statuses: write - -jobs: - check-release-trigger: - name: Check if Release Performance Tests Should Run - runs-on: ubuntu-latest - outputs: - should-run: ${{ steps.check.outputs.should-run }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Check release trigger conditions - id: check - run: | - # Always run for manual dispatch or release branch pushes - if [[ "${{ github.event_name }}" == "workflow_dispatch" || ("${{ github.event_name }}" == "push" && "${{ github.ref_name }}" =~ ^release/) ]]; then - echo "should-run=true" >> "$GITHUB_OUTPUT" - echo "Performance tests triggered by ${{ github.event_name }}" - # For scheduled runs, check for recent metamaskbot version bumps - elif [[ "${{ github.event_name }}" == "schedule" ]]; then - git fetch --all - - # Get the most recent metamaskbot version bump commit - RECENT_VERSION_BUMP=$(git log --oneline --grep="Bump version number" --author="metamaskbot" --since="2 hours ago" --all | head -1) - - if [[ -n "$RECENT_VERSION_BUMP" ]]; then - # Extract the commit hash - COMMIT_HASH=$(echo "$RECENT_VERSION_BUMP" | cut -d' ' -f1) - - # Check if the commit message contains "Bump version number" (ignore [skip ci]) - COMMIT_MESSAGE=$(git log -1 --format="%s" "$COMMIT_HASH") - if [[ "$COMMIT_MESSAGE" =~ "Bump version number" ]]; then - # Check if we've already processed this commit by looking for a workflow run with this commit - # We'll use a simple approach: check if the commit is from the last 30 minutes - COMMIT_TIME=$(git log -1 --format="%ct" "$COMMIT_HASH") - CURRENT_TIME=$(date +%s) - TIME_DIFF=$((CURRENT_TIME - COMMIT_TIME)) - - # Only run if the commit is from the last 30 minutes (to avoid re-running the same commit) - if [[ $TIME_DIFF -lt 1800 ]]; then - echo "should-run=true" >> "$GITHUB_OUTPUT" - echo "Recent metamaskbot version bump found (within last 30 min): $RECENT_VERSION_BUMP" - echo "Commit message: $COMMIT_MESSAGE" - else - echo "should-run=false" >> "$GITHUB_OUTPUT" - echo "Metamaskbot version bump found but older than 30 minutes: $RECENT_VERSION_BUMP" - fi - else - echo "should-run=false" >> "$GITHUB_OUTPUT" - echo "Metamaskbot commit found but not a version bump: $RECENT_VERSION_BUMP" - echo "Commit message: $COMMIT_MESSAGE" - fi - else - echo "should-run=false" >> "$GITHUB_OUTPUT" - echo "No recent metamaskbot version bumps found" - fi - else - echo "should-run=false" >> "$GITHUB_OUTPUT" - fi - - run-performance-e2e-release: - name: Run Performance E2E Tests for Release Builds - uses: ./.github/workflows/run-performance-e2e.yml - needs: [check-release-trigger] - if: needs.check-release-trigger.outputs.should-run == 'true' - with: - sentry_target: ${{ inputs.sentry_target || 'test' }} - build_variant: 'rc' - secrets: inherit diff --git a/.github/workflows/run-performance-e2e.yml b/.github/workflows/run-performance-e2e.yml index ed66e5179200..1e24ba99e4d6 100644 --- a/.github/workflows/run-performance-e2e.yml +++ b/.github/workflows/run-performance-e2e.yml @@ -1,72 +1,8 @@ -name: Build Apps and Run Performance E2E Tests -# This workflow runs performance E2E tests every 3 hours during weekdays (Monday-Friday) -# Schedule: Every 3 hours at the top of the hour (00:00, 03:00, 06:00, 09:00, 12:00, 15:00, 18:00, 21:00) -# Days: Monday (1) through Friday (6) -on: - schedule: - - cron: '0 */3 * * 1-6' - push: - branches: - - main - workflow_dispatch: - inputs: - description: - description: 'Optional description for this test run' - required: false - type: string - sentry_target: - description: 'Sentry target for performance events (test or real)' - required: false - type: choice - options: - - test - - real - default: test +name: Run Performance E2E Tests - browserstack_app_url_android_onboarding: - description: 'BrowserStack Android Onboarding App URL (bs://...)' - required: false - type: string - browserstack_app_url_ios_onboarding: - description: 'BrowserStack iOS Onboarding App URL (bs://...)' - required: false - type: string - browserstack_app_url_android_imported_wallet: - description: 'BrowserStack Android Imported Wallet App URL (bs://...)' - required: false - type: string - browserstack_app_url_ios_imported_wallet: - description: 'BrowserStack iOS Imported Wallet App URL (bs://...)' - required: false - type: string - performance_tags: - description: 'JSON array of performance tags (e.g. ["@PerformanceLogin","@PerformanceSwaps"]). Omit/blank = run all. "[]" = skip all (PR with no relevant changes).' - required: false - type: string - default: '' - reuse_main_builds: - description: >- - When true, skip fresh Android BrowserStack builds and reuse the latest - uploaded apps from main-branch CI instead (same behavior as CI test-only PRs). - Falls back to a fresh build if no reusable main apps are found. Ignored when - manual browserstack_app_url_* inputs are provided. - required: false - type: boolean - default: false - source_fingerprint: - description: >- - Optional @expo/fingerprint hash. When omitted, the workflow computes and - posts build-source-hash so Android BrowserStack dual builds can be reused - across runs with matching native sources. - required: false - type: string - default: '' +on: workflow_call: inputs: - description: - description: 'Optional description for this test run' - required: false - type: string sentry_target: description: 'Sentry target for performance events (test or real)' required: false @@ -90,11 +26,15 @@ on: required: false type: string branch_name: - description: 'Branch name to use for build names (defaults to auto-detection)' - required: false + description: 'Branch name used for build names and reports' + required: true + type: string + source_ref: + description: 'Exact branch, tag, or commit to use for native app builds' + required: true type: string build_variant: - description: 'BrowserStack build profile (e2e = build-e2e, works on feature branches; rc = build-rc, release branches only; exp = experimental)' + description: 'BrowserStack build profile (e2e = standard E2E, exp = experimental, rc = release candidate)' required: false type: string default: 'e2e' @@ -115,8 +55,8 @@ on: default: '' reuse_main_builds: description: >- - When true, skip fresh Android BrowserStack builds and reuse the latest - uploaded apps from main-branch CI instead. + For e2e builds only, skip fresh Android BrowserStack builds and reuse + the latest uploaded apps from main-branch CI instead. required: false type: boolean default: false @@ -137,8 +77,8 @@ permissions: statuses: write concurrency: - # Include build_variant so e2e, experimental (exp), and release/direct (rc) don't share the same - # queue — avoids "Build Apps" (direct/release) and e2e/experimental runs canceling each other. + # Include build_variant so scheduled/PR e2e runs and manual exp/rc runs don't + # share the same queue — avoids unrelated variants canceling each other's pending run. # Note: GitHub allows at most 1 running + 1 pending per group; a 3rd run cancels the pending one. group: performance-e2e-${{ github.ref }}-${{ github.event_name }}-${{ inputs.build_variant || 'e2e' }} # Do not cancel in-progress runs; pending runs can still be replaced by a newer trigger. @@ -156,73 +96,129 @@ env: DISABLE_VIDEO_DOWNLOAD: true jobs: - determine-branch-name: - name: Determine Branch Name + prepare: + name: Prepare Performance E2E runs-on: ubuntu-latest outputs: - branch_name: ${{ steps.get-branch.outputs.branch_name }} + fingerprint: ${{ steps.resolve-fingerprint.outputs.fingerprint }} + android_matrix: ${{ steps.read-matrix.outputs.android_matrix }} + ios_matrix: ${{ steps.read-matrix.outputs.ios_matrix }} + android_build_name: ${{ steps.set-build-names.outputs.android_build_name }} + ios_build_name: ${{ steps.set-build-names.outputs.ios_build_name }} + run_tests: ${{ steps.select-tests.outputs.run_tests }} + grep_pattern: ${{ steps.select-tests.outputs.grep_pattern }} steps: - - name: Get correct branch name - id: get-branch + - name: Checkout + uses: actions/checkout@v6 + + - name: Map performance tags to grep pattern + id: select-tests env: - INPUT_BRANCH: ${{ inputs.branch_name }} - HEAD_REF: ${{ github.head_ref }} - REF_NAME: ${{ github.ref_name }} - EVENT_NAME: ${{ github.event_name }} + PERF_TAGS: ${{ inputs.performance_tags }} + PERF_REASONING: ${{ inputs.performance_tags_reasoning }} run: | - # Determine the correct branch name for all trigger scenarios: - # - workflow_call (from ci.yml): uses INPUT_BRANCH (github.head_ref for PRs, github.ref_name for push) - # - pull_request: uses HEAD_REF (source branch, e.g., "feature-branch") - # - schedule: uses REF_NAME (typically "main") - # - workflow_dispatch: uses INPUT_BRANCH if provided, otherwise REF_NAME - # Priority: explicit input > PR head_ref > ref_name - if [ -n "$INPUT_BRANCH" ]; then - BRANCH_NAME="$INPUT_BRANCH" - echo "Using explicit input: $BRANCH_NAME" - elif [ "$EVENT_NAME" = "pull_request" ] && [ -n "$HEAD_REF" ]; then - BRANCH_NAME="$HEAD_REF" - echo "Using PR source branch: $BRANCH_NAME" + TAGS="$PERF_TAGS" + REASONING="$PERF_REASONING" + + # No input (blank) → schedule/dispatch or label override: run all tests. + if [[ -z "$TAGS" ]]; then + echo "run_tests=true" >> "$GITHUB_OUTPUT" + echo "grep_pattern=" >> "$GITHUB_OUTPUT" + echo "▶️ Running all performance tests (no tag filter)" + exit 0 + fi + + # '[]' → no performance-relevant changes: skip tests and native builds. + if [[ "$TAGS" == "[]" ]]; then + echo "run_tests=false" >> "$GITHUB_OUTPUT" + echo "grep_pattern=" >> "$GITHUB_OUTPUT" + echo "⏭️ Skipping performance tests — no performance-relevant changes detected" + if [[ -n "$REASONING" ]]; then + echo " Reasoning: $REASONING" + fi + exit 0 + fi + + GREP=$(echo "$TAGS" | jq -r 'join("|")') + echo "run_tests=true" >> "$GITHUB_OUTPUT" + echo "grep_pattern=$GREP" >> "$GITHUB_OUTPUT" + echo "⚡ Tags: $TAGS" + echo " Grep pattern: $GREP" + if [[ -n "$REASONING" ]]; then + echo " Reasoning: $REASONING" + fi + + - name: Read device matrix + id: read-matrix + env: + BRANCH_NAME: ${{ inputs.branch_name }} + PR_NUMBER: ${{ inputs.pr_number }} + run: | + FILE="tests/performance/device-matrix.json" + + # PRs use Android low-profile devices only. Scheduled/manual runs use + # the full matrix. iOS runs require manually supplied BrowserStack URLs. + if [ -n "$PR_NUMBER" ]; then + ANDROID_MATRIX=$(jq '[.android_devices[] | select(.category == "low")]' "$FILE") + IOS_MATRIX='[]' + echo "PR mode: Android low category devices only (PR: $PR_NUMBER, branch: $BRANCH_NAME)" else - BRANCH_NAME="$REF_NAME" - echo "Using ref_name: $BRANCH_NAME" + ANDROID_MATRIX=$(jq ".android_devices" "$FILE") + IOS_MATRIX=$(jq ".ios_devices" "$FILE") + echo "Full mode: all devices (branch: $BRANCH_NAME)" fi - echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT" - echo "Branch: $BRANCH_NAME" + { + echo "android_matrix<> "$GITHUB_OUTPUT" + + echo "Selected: $(echo "$ANDROID_MATRIX" | jq length) Android, $(echo "$IOS_MATRIX" | jq length) iOS" - # Same contract as ci.yml native-build-fingerprint → build-android-e2e: - # fingerprint must resolve for this job to succeed. Empty fingerprint disables - # reuse inside build-android-upload-to-browserstack (resolve job is skipped) but - # the dual Gradle path still runs. Download-miss fallback lives in that workflow, - # matching build-android-e2e.yml's continue-on-error + native-build gate. - ensure-fingerprint: - name: Ensure native build fingerprint - runs-on: ubuntu-latest - outputs: - fingerprint: ${{ steps.resolve.outputs.fingerprint }} - steps: + - name: Set BrowserStack build names + id: set-build-names + env: + BRANCH_NAME: ${{ inputs.branch_name }} + run: | + echo "android_build_name=Android-Performance-$BRANCH_NAME-Branch" >> "$GITHUB_OUTPUT" + echo "ios_build_name=iOS-Performance-$BRANCH_NAME-Branch" >> "$GITHUB_OUTPUT" + + # A fingerprint is only needed when tests will run and Android apps were + # not supplied. PR CI normally provides it; schedule/manual runs compute it. - name: Use caller-provided fingerprint id: provided - if: ${{ inputs.source_fingerprint }} + if: >- + steps.select-tests.outputs.run_tests == 'true' && + !inputs.browserstack_app_url_android_onboarding && + !inputs.browserstack_app_url_android_imported_wallet && + inputs.source_fingerprint env: SOURCE_FINGERPRINT: ${{ inputs.source_fingerprint }} run: | echo "fingerprint=$SOURCE_FINGERPRINT" >> "$GITHUB_OUTPUT" echo "Using caller-provided fingerprint: $SOURCE_FINGERPRINT" - - name: Checkout - if: ${{ !inputs.source_fingerprint }} - uses: actions/checkout@v6 - - name: Set up Node.js - if: ${{ !inputs.source_fingerprint }} + if: >- + steps.select-tests.outputs.run_tests == 'true' && + !inputs.browserstack_app_url_android_onboarding && + !inputs.browserstack_app_url_android_imported_wallet && + !inputs.source_fingerprint uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: yarn - name: Install Yarn dependencies with retry - if: ${{ !inputs.source_fingerprint }} + if: >- + steps.select-tests.outputs.run_tests == 'true' && + !inputs.browserstack_app_url_android_onboarding && + !inputs.browserstack_app_url_android_imported_wallet && + !inputs.source_fingerprint uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: timeout_minutes: 10 @@ -232,7 +228,11 @@ jobs: - name: Compute and post build-source-hash id: computed - if: ${{ !inputs.source_fingerprint }} + if: >- + steps.select-tests.outputs.run_tests == 'true' && + !inputs.browserstack_app_url_android_onboarding && + !inputs.browserstack_app_url_android_imported_wallet && + !inputs.source_fingerprint uses: ./.github/actions/post-build-source-hash with: github-token: ${{ github.token }} @@ -240,7 +240,11 @@ jobs: target-sha: ${{ github.sha }} - name: Resolve fingerprint output - id: resolve + id: resolve-fingerprint + if: >- + steps.select-tests.outputs.run_tests == 'true' && + !inputs.browserstack_app_url_android_onboarding && + !inputs.browserstack_app_url_android_imported_wallet env: PROVIDED: ${{ steps.provided.outputs.fingerprint }} COMPUTED: ${{ steps.computed.outputs.fingerprint }} @@ -255,125 +259,14 @@ jobs: fi echo "Resolved fingerprint: $(grep fingerprint= "$GITHUB_OUTPUT" | tail -1)" - read-device-matrix: - name: Read Device Matrix - runs-on: ubuntu-latest - needs: [determine-branch-name] - outputs: - android_matrix: ${{ steps.read-matrix.outputs.android_matrix }} - ios_matrix: ${{ steps.read-matrix.outputs.ios_matrix }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Select Devices - id: read-matrix - env: - BRANCH_NAME: ${{ needs.determine-branch-name.outputs.branch_name }} - PR_NUMBER: ${{ inputs.pr_number }} - run: | - FILE="tests/performance/device-matrix.json" - - # PRs: run the AI-selected performance tests on Android low profile only. - # Pushes keep the previous low-device behavior to control cost and flakes. - # Schedule and workflow_dispatch use the full device matrix. - if [ -n "$PR_NUMBER" ]; then - ANDROID_MATRIX=$(jq '[.android_devices[] | select(.category == "low")]' "$FILE") - IOS_MATRIX='[]' - echo "PR mode: Android low category devices only (PR: $PR_NUMBER, branch: $BRANCH_NAME)" - elif [ "${{ github.event_name }}" = "push" ]; then - ANDROID_MATRIX=$(jq '[.android_devices[] | select(.category == "low")]' "$FILE") - IOS_MATRIX=$(jq '[.ios_devices[] | select(.category == "low")]' "$FILE") - echo "Push mode: low category devices only (event: ${{ github.event_name }}, branch: $BRANCH_NAME)" - else - ANDROID_MATRIX=$(jq ".android_devices" "$FILE") - IOS_MATRIX=$(jq ".ios_devices" "$FILE") - echo "Full mode: All devices (event: ${{ github.event_name }}, branch: $BRANCH_NAME)" - fi - - { - echo "android_matrix<> "$GITHUB_OUTPUT" - - echo "Selected: $(echo "$ANDROID_MATRIX" | jq length) Android, $(echo "$IOS_MATRIX" | jq length) iOS" - - set-build-names: - name: Set Unified BrowserStack Build Names - runs-on: ubuntu-latest - needs: [determine-branch-name] - outputs: - android_build_name: ${{ steps.set-builds.outputs.android_build_name }} - ios_build_name: ${{ steps.set-builds.outputs.ios_build_name }} - steps: - - name: Set unified build names - id: set-builds - run: | - BRANCH_NAME="${{ needs.determine-branch-name.outputs.branch_name }}" - echo "android_build_name=Android-Performance-$BRANCH_NAME-Branch" >> "$GITHUB_OUTPUT" - echo "ios_build_name=iOS-Performance-$BRANCH_NAME-Branch" >> "$GITHUB_OUTPUT" - echo "Set unified build names:" - echo " Android: Android-Performance-$BRANCH_NAME-Branch" - echo " iOS: iOS-Performance-$BRANCH_NAME-Branch" - - compute-test-selection: - name: Compute Performance Test Selection - runs-on: ubuntu-latest - outputs: - run_tests: ${{ steps.map.outputs.run_tests }} - grep_pattern: ${{ steps.map.outputs.grep_pattern }} - steps: - - name: Map performance tags to grep pattern - id: map - env: - PERF_REASONING: ${{ inputs.performance_tags_reasoning }} - run: | - TAGS='${{ inputs.performance_tags }}' - REASONING="$PERF_REASONING" - - # No input (blank) → schedule/dispatch: run all tests with no grep filter - if [[ -z "$TAGS" ]]; then - echo "run_tests=true" >> "$GITHUB_OUTPUT" - echo "grep_pattern=" >> "$GITHUB_OUTPUT" - echo "▶️ Running all performance tests (schedule/dispatch mode — no tag filter)" - exit 0 - fi - - # '[]' → PR with no performance-relevant changes: skip all tests - if [[ "$TAGS" == "[]" ]]; then - echo "run_tests=false" >> "$GITHUB_OUTPUT" - echo "grep_pattern=" >> "$GITHUB_OUTPUT" - echo "⏭️ Skipping performance tests — no performance-relevant changes detected in this PR" - if [[ -n "$REASONING" ]]; then - echo " Reasoning: $REASONING" - fi - exit 0 - fi - - # Build OR-pattern for playwright --grep (e.g. "@PerformanceSwaps|@PerformanceLogin") - # Playwright matches against the full test name (describe + test title), so each tag - # selects only the individual tests that use it in their describe block. - GREP=$(echo "$TAGS" | jq -r 'join("|")') - - echo "run_tests=true" >> "$GITHUB_OUTPUT" - echo "grep_pattern=$GREP" >> "$GITHUB_OUTPUT" - - echo "⚡ Performance test selection:" - echo " Tags: $TAGS" - echo " Grep pattern: $GREP" - if [[ -n "$REASONING" ]]; then - echo " Reasoning: $REASONING" - fi - resolve-main-browserstack-urls: name: Resolve main-branch BrowserStack Android apps runs-on: ubuntu-latest + needs: [prepare] if: >- + needs.prepare.outputs.run_tests == 'true' && inputs.reuse_main_builds && + inputs.build_variant == 'e2e' && !inputs.browserstack_app_url_android_onboarding && !inputs.browserstack_app_url_android_imported_wallet outputs: @@ -401,57 +294,45 @@ jobs: trigger-android-dual-versions: name: Trigger Android Dual Versions and Extract BrowserStack URLs uses: ./.github/workflows/build-android-upload-to-browserstack.yml - needs: [determine-branch-name, resolve-main-browserstack-urls, ensure-fingerprint] + needs: [prepare, resolve-main-browserstack-urls] # Build/upload when not reusing main, or when main reuse lookup found nothing. # Fingerprint job must succeed (same as ci.yml native-build-fingerprint → E2E). # Download-miss → fresh dual Gradle fallback is inside build-android-upload-to-browserstack. if: >- always() && !cancelled() && + needs.prepare.result == 'success' && + needs.prepare.outputs.run_tests == 'true' && (needs.resolve-main-browserstack-urls.result == 'skipped' || needs.resolve-main-browserstack-urls.result == 'success') && - needs.ensure-fingerprint.result == 'success' && (!inputs.browserstack_app_url_android_onboarding && !inputs.browserstack_app_url_android_imported_wallet) && ( !inputs.reuse_main_builds || + inputs.build_variant != 'e2e' || needs.resolve-main-browserstack-urls.outputs.found != 'true' ) with: - branch_name: ${{ needs.determine-branch-name.outputs.branch_name }} - build_variant: ${{ inputs.build_variant || 'e2e' }} - source_fingerprint: ${{ needs.ensure-fingerprint.outputs.fingerprint }} - main_branch_only: ${{ inputs.reuse_main_builds }} - secrets: inherit - - trigger-ios-dual-versions: - name: Trigger iOS Dual Versions and Extract BrowserStack URLs - uses: ./.github/workflows/build-ios-upload-to-browserstack.yml - needs: [determine-branch-name] - if: false # temporarily disabled — iOS tests not yet active - # if: (!inputs.browserstack_app_url_ios_onboarding && !inputs.browserstack_app_url_ios_imported_wallet) - with: - branch_name: ${{ needs.determine-branch-name.outputs.branch_name }} + branch_name: ${{ inputs.branch_name }} + source_ref: ${{ inputs.source_ref }} build_variant: ${{ inputs.build_variant || 'e2e' }} + source_fingerprint: ${{ needs.prepare.outputs.fingerprint }} + # Coerce to a real boolean. On schedule/push, workflow_dispatch inputs are + # unset; passing a blank value into this boolean workflow_call input makes + # the reusable Android build job fail to start. + main_branch_only: ${{ inputs.reuse_main_builds == true && inputs.build_variant == 'e2e' }} secrets: inherit # ============================================================================= - # PHASE 1: ONBOARDING TESTS (Run First) + # ONBOARDING TESTS # ============================================================================= run-android-onboarding-tests: name: Performance Test Android (Onboarding) uses: ./.github/workflows/performance-test-runner.yml needs: - [ - read-device-matrix, - trigger-android-dual-versions, - resolve-main-browserstack-urls, - set-build-names, - determine-branch-name, - compute-test-selection, - ] + [prepare, trigger-android-dual-versions, resolve-main-browserstack-urls] if: >- always() && !failure() && !cancelled() && - needs.compute-test-selection.outputs.run_tests == 'true' && + needs.prepare.outputs.run_tests == 'true' && ( needs.trigger-android-dual-versions.result == 'skipped' || needs.trigger-android-dual-versions.result == 'success' @@ -470,70 +351,47 @@ jobs: build_type: onboarding sentry_target: ${{ inputs.sentry_target || 'test' }} build_variant: ${{ inputs.build_variant || 'e2e' }} - device_matrix: ${{ needs.read-device-matrix.outputs.android_matrix }} - browserstack_app_url: ${{ needs.resolve-main-browserstack-urls.outputs.without-srp-browserstack-url || needs.trigger-android-dual-versions.outputs.without-srp-browserstack-url || inputs.browserstack_app_url_android_onboarding }} - app_version: ${{ needs.resolve-main-browserstack-urls.outputs.without-srp-version || needs.trigger-android-dual-versions.outputs.without-srp-version || 'Manual-Input' }} - branch_name: ${{ needs.determine-branch-name.outputs.branch_name }} - browserstack_build_name: ${{ needs.set-build-names.outputs.android_build_name }} - grep_tags: ${{ needs.compute-test-selection.outputs.grep_pattern }} + branch_name: ${{ inputs.branch_name }} + device_matrix: ${{ needs.prepare.outputs.android_matrix }} + # Prefer an explicitly supplied or freshly uploaded app. Reused main + # URLs are only a fallback so onboarding cannot receive a stale variant. + browserstack_app_url: ${{ inputs.browserstack_app_url_android_onboarding || needs.trigger-android-dual-versions.outputs.without-srp-browserstack-url || needs.resolve-main-browserstack-urls.outputs.without-srp-browserstack-url }} + app_version: ${{ needs.trigger-android-dual-versions.outputs.without-srp-version || needs.resolve-main-browserstack-urls.outputs.without-srp-version || 'Manual-Input' }} + browserstack_build_name: ${{ needs.prepare.outputs.android_build_name }} + grep_tags: ${{ needs.prepare.outputs.grep_pattern }} secrets: inherit run-ios-onboarding-tests: name: Performance Test iOS (Onboarding) uses: ./.github/workflows/performance-test-runner.yml needs: - [ - read-device-matrix, - trigger-ios-dual-versions, - set-build-names, - determine-branch-name, - compute-test-selection, - ] - if: always() && !failure() && !cancelled() && needs.compute-test-selection.outputs.run_tests == 'true' && (needs.trigger-ios-dual-versions.result == 'skipped' || needs.trigger-ios-dual-versions.result == 'success') && (inputs.browserstack_app_url_ios_onboarding != '' || needs.trigger-ios-dual-versions.outputs.without-srp-browserstack-url != '') + - prepare + if: always() && !failure() && !cancelled() && needs.prepare.outputs.run_tests == 'true' && inputs.browserstack_app_url_ios_onboarding != '' with: platform: ios build_type: onboarding sentry_target: ${{ inputs.sentry_target || 'test' }} build_variant: ${{ inputs.build_variant || 'e2e' }} - device_matrix: ${{ needs.read-device-matrix.outputs.ios_matrix }} - browserstack_app_url: ${{ needs.trigger-ios-dual-versions.outputs.without-srp-browserstack-url || inputs.browserstack_app_url_ios_onboarding }} - app_version: ${{ needs.trigger-ios-dual-versions.outputs.without-srp-version || 'Manual-Input' }} - branch_name: ${{ needs.determine-branch-name.outputs.branch_name }} - browserstack_build_name: ${{ needs.set-build-names.outputs.ios_build_name }} - grep_tags: ${{ needs.compute-test-selection.outputs.grep_pattern }} + branch_name: ${{ inputs.branch_name }} + device_matrix: ${{ needs.prepare.outputs.ios_matrix }} + browserstack_app_url: ${{ inputs.browserstack_app_url_ios_onboarding }} + app_version: Manual-Input + browserstack_build_name: ${{ needs.prepare.outputs.ios_build_name }} + grep_tags: ${{ needs.prepare.outputs.grep_pattern }} secrets: inherit # ============================================================================= - # PHASE 2: IMPORTED WALLET TESTS (Run After Onboarding Complete) + # IMPORTED WALLET TESTS (Run in Parallel with Onboarding) # ============================================================================= - wait-for-onboarding-completion: - name: Wait for Onboarding Completion - runs-on: ubuntu-latest - needs: [run-android-onboarding-tests, run-ios-onboarding-tests] - if: always() - steps: - - name: Wait for onboarding tests to complete - run: | - echo "Onboarding tests have completed (success or failure)" - echo "Proceeding with imported wallet tests..." - run-android-imported-wallet-tests: name: Performance Test Android (Imported Wallet) uses: ./.github/workflows/performance-test-runner.yml needs: - [ - read-device-matrix, - trigger-android-dual-versions, - resolve-main-browserstack-urls, - wait-for-onboarding-completion, - set-build-names, - determine-branch-name, - compute-test-selection, - ] + [prepare, trigger-android-dual-versions, resolve-main-browserstack-urls] if: >- always() && !cancelled() && - needs.compute-test-selection.outputs.run_tests == 'true' && + needs.prepare.outputs.run_tests == 'true' && ( needs.trigger-android-dual-versions.result == 'skipped' || needs.trigger-android-dual-versions.result == 'success' @@ -552,38 +410,31 @@ jobs: build_type: imported-wallet sentry_target: ${{ inputs.sentry_target || 'test' }} build_variant: ${{ inputs.build_variant || 'e2e' }} - device_matrix: ${{ needs.read-device-matrix.outputs.android_matrix }} - browserstack_app_url: ${{ needs.resolve-main-browserstack-urls.outputs.with-srp-browserstack-url || needs.trigger-android-dual-versions.outputs.with-srp-browserstack-url || inputs.browserstack_app_url_android_imported_wallet }} - app_version: ${{ needs.resolve-main-browserstack-urls.outputs.with-srp-version || needs.trigger-android-dual-versions.outputs.with-srp-version || 'Manual-Input' }} - branch_name: ${{ needs.determine-branch-name.outputs.branch_name }} - browserstack_build_name: ${{ needs.set-build-names.outputs.android_build_name }} - grep_tags: ${{ needs.compute-test-selection.outputs.grep_pattern }} + branch_name: ${{ inputs.branch_name }} + device_matrix: ${{ needs.prepare.outputs.android_matrix }} + browserstack_app_url: ${{ inputs.browserstack_app_url_android_imported_wallet || needs.trigger-android-dual-versions.outputs.with-srp-browserstack-url || needs.resolve-main-browserstack-urls.outputs.with-srp-browserstack-url }} + app_version: ${{ needs.trigger-android-dual-versions.outputs.with-srp-version || needs.resolve-main-browserstack-urls.outputs.with-srp-version || 'Manual-Input' }} + browserstack_build_name: ${{ needs.prepare.outputs.android_build_name }} + grep_tags: ${{ needs.prepare.outputs.grep_pattern }} secrets: inherit run-ios-imported-wallet-tests: name: Performance Test iOS (Imported Wallet) uses: ./.github/workflows/performance-test-runner.yml needs: - [ - read-device-matrix, - trigger-ios-dual-versions, - wait-for-onboarding-completion, - set-build-names, - determine-branch-name, - compute-test-selection, - ] - if: always() && !cancelled() && needs.compute-test-selection.outputs.run_tests == 'true' && (needs.trigger-ios-dual-versions.result == 'skipped' || needs.trigger-ios-dual-versions.result == 'success') && (inputs.browserstack_app_url_ios_imported_wallet != '' || needs.trigger-ios-dual-versions.outputs.with-srp-browserstack-url != '') + - prepare + if: always() && !cancelled() && needs.prepare.outputs.run_tests == 'true' && inputs.browserstack_app_url_ios_imported_wallet != '' with: platform: ios build_type: imported-wallet sentry_target: ${{ inputs.sentry_target || 'test' }} build_variant: ${{ inputs.build_variant || 'e2e' }} - device_matrix: ${{ needs.read-device-matrix.outputs.ios_matrix }} - browserstack_app_url: ${{ needs.trigger-ios-dual-versions.outputs.with-srp-browserstack-url || inputs.browserstack_app_url_ios_imported_wallet }} - app_version: ${{ needs.trigger-ios-dual-versions.outputs.with-srp-version || 'Manual-Input' }} - branch_name: ${{ needs.determine-branch-name.outputs.branch_name }} - browserstack_build_name: ${{ needs.set-build-names.outputs.ios_build_name }} - grep_tags: ${{ needs.compute-test-selection.outputs.grep_pattern }} + branch_name: ${{ inputs.branch_name }} + device_matrix: ${{ needs.prepare.outputs.ios_matrix }} + browserstack_app_url: ${{ inputs.browserstack_app_url_ios_imported_wallet }} + app_version: Manual-Input + browserstack_build_name: ${{ needs.prepare.outputs.ios_build_name }} + grep_tags: ${{ needs.prepare.outputs.grep_pattern }} secrets: inherit aggregate-results: @@ -591,14 +442,13 @@ jobs: runs-on: ubuntu-latest needs: [ + prepare, run-android-imported-wallet-tests, run-android-onboarding-tests, run-ios-imported-wallet-tests, run-ios-onboarding-tests, - wait-for-onboarding-completion, - determine-branch-name, ] - if: always() + if: always() && needs.prepare.outputs.run_tests == 'true' steps: - name: Checkout code uses: actions/checkout@v4 @@ -612,7 +462,7 @@ jobs: - name: Run aggregation script env: - BRANCH_NAME: ${{ needs.determine-branch-name.outputs.branch_name }} + BRANCH_NAME: ${{ inputs.branch_name }} BUILD_VARIANT: ${{ inputs.build_variant || 'e2e' }} run: | echo "Processing all test results..." @@ -632,9 +482,9 @@ jobs: post-pr-comment: name: Post Performance Results to PR runs-on: ubuntu-latest - needs: [aggregate-results, determine-branch-name] + needs: [aggregate-results] # Only runs when triggered from a PR via workflow_call with a pr_number - if: always() && !cancelled() && inputs.pr_number != '' + if: always() && !cancelled() && needs.aggregate-results.result != 'skipped' && inputs.pr_number != '' steps: - name: Checkout code uses: actions/checkout@v4 @@ -694,9 +544,8 @@ jobs: run-ios-imported-wallet-tests, run-ios-onboarding-tests, aggregate-results, - determine-branch-name, ] - if: always() + if: always() && needs.aggregate-results.result != 'skipped' steps: - name: Checkout code uses: actions/checkout@v4 @@ -711,7 +560,7 @@ jobs: id: summary env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BRANCH_NAME: ${{ needs.determine-branch-name.outputs.branch_name }} + BRANCH_NAME: ${{ inputs.branch_name }} run: | { echo "summary<` may die from SIGINT (130) instead — so all of them +// must be treated as "interrupted", not just 129. +const INTERRUPTED_EXIT_CODES = new Set([ + 129, // SIGHUP + 130, // SIGINT + 131, // SIGQUIT + 137, // SIGKILL + 143, // SIGTERM +]); + +// Set on process.env as soon as this process starts so any nested +// `yarn