Skip to content

ci: reuse native E2E builds across commits and PRs #135213

ci: reuse native E2E builds across commits and PRs

ci: reuse native E2E builds across commits and PRs #135213

Workflow file for this run

name: ci
on:
push:
branches: [main]
pull_request:
types:
- opened
- reopened
- synchronize
branches-ignore:
- stable
merge_group:
schedule:
# Run the full suite "overnight," once every hour from 2:00am UTC until 6:00am UTC.
# This helps to identy the flaky and failed tests on main branch
- cron: '0 2-6 * * *'
workflow_dispatch:
inputs:
runner_provider:
description: Runner provider for this manual trial run
required: true
type: choice
options:
- current
- namespace
default: current
concurrency:
group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }}
cancel-in-progress: ${{ !(contains(github.ref, 'refs/heads/main') || contains(github.ref, 'refs/heads/stable')) }}
jobs:
get_requirements:
name: Get workflow and job requirements
uses: ./.github/workflows/get-requirements.yml
check-diff:
name: Check diff
runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ios-build' || 'macos-latest' }}
if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }}
needs:
- get_requirements
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: yarn
- uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb #v1
with:
ruby-version: '3.2.9'
env:
BUNDLE_GEMFILE: ios/Gemfile
- 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: 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: Clean state and following up dependencies installation with retry
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2
with:
timeout_minutes: 20
max_attempts: 3
retry_wait_seconds: 30
command: yarn setup:github-ci
- name: Require clean working directory
shell: bash
run: |
if ! git diff --exit-code; then
echo "Working tree dirty at end of job"
exit 1
else
echo "No changes detected"
fi
post-build-source-hash:
name: Post build-source-hash commit status
runs-on: ubuntu-latest
if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }}
permissions:
contents: read
statuses: write
needs:
- get_requirements
outputs:
fingerprint: ${{ steps.publish.outputs.fingerprint }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: yarn
- name: Install Yarn dependencies with retry
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: yarn install --immutable
- name: Post build-source-hash commit status
id: publish
uses: ./.github/actions/post-build-source-hash
with:
github-token: ${{ github.token }}
# .head.sha = PR head (pull_request events); github.sha fallback = pushed/scheduled commit
# (push to main, merge_group, schedule) where no PR payload exists.
target-sha: ${{ github.event.pull_request.head.sha || github.sha }}
dedupe:
name: Dedupe
runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }}
needs:
- get_requirements
steps:
- uses: actions/checkout@v6
- name: Configure Namespace cache
if: ${{ inputs.runner_provider == 'namespace' }}
uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1
with:
path: |
~/.cache/yarn
.metamask
node_modules
.yarn/cache
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: ${{ inputs.runner_provider != 'namespace' && '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: Clean state and following up dependencies installation
run: yarn setup:github-ci --node
- name: Deduplicate dependencies with retry
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: yarn deduplicate
- name: Print error if duplicates found
shell: bash
run: |
if ! git diff --exit-code; then
echo "Duplicate dependencies detected; run 'yarn deduplicate' to remove them"
exit 1
fi
git-safe-dependencies:
name: Run `@lavamoat/git-safe-dependencies`
runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }}
needs:
- get_requirements
steps:
- uses: actions/checkout@v6
- name: Configure Namespace cache
if: ${{ inputs.runner_provider == 'namespace' }}
uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1
with:
path: |
~/.cache/yarn
.metamask
node_modules
.yarn/cache
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: ${{ inputs.runner_provider != 'namespace' && '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: Clean state and following up dependencies installation
run: yarn setup:github-ci --node
- name: Run @lavamoat/git-safe-dependencies with retry
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: yarn git-safe-dependencies
scripts:
name: Run `${{ matrix.scripts }}`
runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }}
needs:
- get_requirements
strategy:
matrix:
scripts:
- lint
- lint:tsc
- format:check
- audit:ci
- test:depcheck
- test:tgz-check
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 2
- name: Configure Namespace cache
if: ${{ inputs.runner_provider == 'namespace' }}
uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1
with:
path: |
~/.cache/yarn
.metamask
node_modules
.yarn/cache
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: ${{ inputs.runner_provider != 'namespace' && '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: Clean state and following up dependencies installation
run: yarn setup:github-ci --node
- run: yarn ${{ matrix['scripts'] }}
- name: Require clean working directory
shell: bash
run: |
if ! git diff --exit-code; then
echo "Working tree dirty at end of job"
exit 1
else
echo "No changes detected"
fi
js-bundle-size-check:
name: JS bundle size check
runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }}
needs:
- get_requirements
permissions:
contents: read
statuses: write
steps:
- uses: actions/checkout@v6
- name: Configure Namespace cache
if: ${{ inputs.runner_provider == 'namespace' }}
uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1
with:
path: |
~/.cache/yarn
.metamask
node_modules
.yarn/cache
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: ${{ inputs.runner_provider != 'namespace' && '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: Clean state and following up dependencies installation
run: yarn setup:github-ci --no-build-android
- name: Generate iOS bundle
run: yarn gen-bundle:ios
env:
NODE_OPTIONS: --max_old_space_size=12288
- name: Check bundle size
if: github.event_name == 'pull_request'
env:
GITHUB_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
# Baseline is the latest successful ci/ios-js-bundle-size status on
# github.event.pull_request.base.sha (GitHub's PR base commit for this run), not git merge-base(head, base).
append_job_summary() {
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
cat >> "$GITHUB_STEP_SUMMARY"
fi
}
FILE_PATH='ios/main.jsbundle'
CURRENT_MB="$(stat -c %s "$FILE_PATH" | awk '{print $1/1024/1024}')"
echo "Current iOS JS bundle: ${CURRENT_MB} MB"
BASELINE_MB=""
BASE_SHA='${{ github.event.pull_request.base.sha }}'
REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}"
if [[ -n "$BASE_SHA" ]]; then
echo "Resolving baseline bundle size from ci/ios-js-bundle-size status on base commit ${BASE_SHA}"
if ! STATUSES_JSON="$(
curl -fsS \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/commits/${BASE_SHA}/status"
)"; then
echo "::warning::Failed to fetch commit statuses for base ${BASE_SHA}; skipping iOS JS bundle delta check."
append_job_summary <<EOF
### iOS JS bundle size (PR)
**Result:** skipped — could not load [combined status](${REPO_URL}/commit/${BASE_SHA}) for PR base \`${BASE_SHA}\`.
| | MiB |
|---|--:|
| Current bundle | ${CURRENT_MB} |
Baseline would be read from the latest successful \`ci/ios-js-bundle-size\` status description on that base commit (MiB from awk, then an optional trailing semicolon, matching the main-branch post step).
EOF
exit 0
fi
DESC="$(
echo "$STATUSES_JSON" | jq -r '
(.statuses // [])
| map(select(.context == "ci/ios-js-bundle-size" and .state == "success"))
| sort_by(.updated_at // .created_at)
| if length > 0 then .[-1].description else "" end
'
)"
# Status description is "${FILE_SIZE_MB};" from the Post iOS JS bundle size step (awk MiB + ';').
# Regex in a variable: ';' in an unquoted [[ =~ ... ]] pattern is parsed as a command separator (shellcheck SC1072).
_bundle_desc_re='^[[:space:]]*([0-9]+(\.[0-9]*)?)[[:space:]]*;?[[:space:]]*$'
if [[ "$DESC" =~ $_bundle_desc_re ]]; then
BASELINE_MB="${BASH_REMATCH[1]}"
echo "Baseline from base ref status: ${BASELINE_MB} MB"
else
echo "::warning::No successful ci/ios-js-bundle-size status (or unrecognized description) on base ${BASE_SHA}; skipping iOS JS bundle delta check."
append_job_summary <<EOF
### iOS JS bundle size (PR)
**Result:** skipped — no parseable \`ci/ios-js-bundle-size\` success status on PR base [\`${BASE_SHA}\`](${REPO_URL}/commit/${BASE_SHA}).
| | MiB |
|---|--:|
| Current bundle | ${CURRENT_MB} |
| Raw description | \`${DESC:-<empty>}\` |
EOF
exit 0
fi
else
echo "::warning::Pull request base SHA is empty; skipping iOS JS bundle delta check."
append_job_summary <<EOF
### iOS JS bundle size (PR)
**Result:** skipped — \`github.event.pull_request.base.sha\` was empty.
| | MiB |
|---|--:|
| Current bundle | ${CURRENT_MB} |
EOF
exit 0
fi
if [[ -z "${BASELINE_MB:-}" ]]; then
echo "::warning::Baseline is undefined; skipping iOS JS bundle delta check."
append_job_summary <<EOF
### iOS JS bundle size (PR)
**Result:** skipped — baseline MiB was empty after parsing.
| | MiB |
|---|--:|
| Current bundle | ${CURRENT_MB} |
EOF
exit 0
fi
DELTA_MB="$(echo "${CURRENT_MB} - ${BASELINE_MB}" | bc -l)"
echo "Delta (current - baseline): ${DELTA_MB} MB"
echo "::notice::iOS JS bundle delta vs baseline: ${DELTA_MB} MB (current ${CURRENT_MB} MB, baseline ${BASELINE_MB} MB)"
if (( $(echo "${DELTA_MB} > 1" | bc -l) )); then
echo "::error::JS bundle exceeds baseline by more than 1 MiB (delta ${DELTA_MB} MiB; current ${CURRENT_MB} MiB, baseline ${BASELINE_MB} MiB)."
append_job_summary <<EOF
### iOS JS bundle size (PR)
**Result:** failed — delta is more than 1 MiB above the status baseline on PR base [\`${BASE_SHA}\`](${REPO_URL}/commit/${BASE_SHA}).
| | MiB |
|---|--:|
| PR base commit | — |
| Baseline (from status) | ${BASELINE_MB} |
| Current bundle | ${CURRENT_MB} |
| Delta (current - baseline) | ${DELTA_MB} |
EOF
exit 1
fi
echo "JS bundle size check passed (delta is one or less)."
append_job_summary <<EOF
### iOS JS bundle size (PR)
**Result:** passed
| | MiB |
|---|--:|
| PR base commit | [\`${BASE_SHA}\`](${REPO_URL}/commit/${BASE_SHA}) |
| Baseline (from \`ci/ios-js-bundle-size\` status) | ${BASELINE_MB} |
| Current bundle | ${CURRENT_MB} |
| Delta (current - baseline) | ${DELTA_MB} |
Fail threshold: more than 1 MiB above baseline. Baseline is **not** from \`git merge-base\`; it is the value posted to the PR base commit by CI.
EOF
- name: Post iOS JS bundle size to commit status
if: ${{ github.ref == 'refs/heads/main' }}
env:
GITHUB_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
FILE_PATH='ios/main.jsbundle'
# Match scripts/js-bundle-stats.sh: GNU stat byte size, MiB via awk
FILE_SIZE_MB="$(stat -c %s "$FILE_PATH" | awk '{print $1/1024/1024}')"
echo "File size of $FILE_PATH: $FILE_SIZE_MB MB"
COMMIT_SHA='${{ github.event.pull_request.head.sha || github.sha }}'
TARGET_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
BODY="$(jq -n \
--arg state success \
--arg context 'ci/ios-js-bundle-size' \
--arg desc "${FILE_SIZE_MB};" \
--arg target_url "$TARGET_URL" \
'{
state: $state,
context: $context,
description: ($desc | if length > 140 then .[0:140] else . end),
target_url: $target_url
}')"
curl -fsS -X POST \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/statuses/${COMMIT_SHA}" \
-d "$BODY"
- name: Upload iOS bundle
uses: actions/upload-artifact@v4
with:
name: ios-bundle
path: ios/main.jsbundle
retention-days: 7
ship-js-bundle-size-check:
name: Ship JS bundle size check
runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
needs: [js-bundle-size-check]
if: ${{ github.ref == 'refs/heads/main' }}
steps:
- uses: actions/checkout@v6
- name: Download iOS bundle
uses: actions/download-artifact@v4
with:
name: ios-bundle
path: ios
- name: Verify iOS bundle artifact layout
shell: bash
run: |
set -euo pipefail
ls -la ios/
if [[ ! -f ios/main.jsbundle ]]; then
echo "::error::Expected ios/main.jsbundle to be a regular file (artifact layout may be wrong)."
exit 1
fi
- name: Report iOS JS bundle size and commit (main)
run: |
SHORT_SHA="$(git rev-parse --short HEAD)"
FILE_PATH='ios/main.jsbundle'
MB="$(stat -c %s "$FILE_PATH" | awk '{print $1/1024/1024}')"
echo "commit_short=$SHORT_SHA"
echo "::notice::iOS JS bundle (main): ${MB} MiB @ ${SHORT_SHA}"
- name: Push bundle size to mobile_bundlesize_stats repo
run: ./scripts/push-bundle-size.sh
env:
GITHUB_ACTOR: metamaskbot
GITHUB_TOKEN: ${{ secrets.MOBILE_BUNDLESIZE_TOKEN }}
check-workflows:
name: Check workflows
runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }}
needs:
- get_requirements
steps:
- uses: actions/checkout@v6
- name: Download actionlint
id: download-actionlint
run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/62dc61a45fc95efe8c800af7a557ab0b9165d63b/scripts/download-actionlint.bash) 1.7.1
shell: bash
- name: Check workflow files
run: ${{ steps.download-actionlint.outputs.executable }} -color -config-file .github/actionlint.yaml
shell: bash
unit-tests:
name: Unit tests (${{ matrix.shard }})
runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }}
needs:
- get_requirements
strategy:
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
steps:
- uses: actions/checkout@v6
- name: Configure Namespace cache
if: ${{ inputs.runner_provider == 'namespace' }}
uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1
with:
path: |
~/.cache/yarn
.metamask
node_modules
.yarn/cache
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: ${{ inputs.runner_provider != 'namespace' && '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: Clean state and following up dependencies installation
run: yarn setup:github-ci --node
- name: Prepare results directory
run: mkdir -p tests/results
# The "10" in this command is the total number of shards. It must be kept
# in sync with the length of matrix.shard
- run: yarn test:unit --shard=${{ matrix.shard }}/10 --forceExit --silent --coverageReporters=json --json --outputFile=tests/results/unit-test-results-${{ matrix.shard }}.json
env:
NODE_OPTIONS: ${{ inputs.runner_provider == 'namespace' && '--max_old_space_size=12288' || '--max_old_space_size=20480' }}
- name: Rename coverage report and extract test count for this shard
shell: bash
run: |
mv ./tests/coverage/coverage-final.json ./tests/coverage/coverage-unit-${{ matrix.shard }}.json
cp tests/results/unit-test-results-${{ matrix.shard }}.json ./tests/coverage/jest-results.json
count=$(jq '(.numPassedTests // 0) + (.numFailedTests // 0)' tests/results/unit-test-results-${{ matrix.shard }}.json)
echo "{\"count\": $count}" > ./tests/coverage/count.json
- uses: actions/upload-artifact@v4
with:
name: coverage-unit-${{ matrix.shard }}
path: ./tests/coverage/
if-no-files-found: error
retention-days: 7
- name: Require clean working directory
shell: bash
run: |
if ! git diff --exit-code; then
echo "Working tree dirty at end of job"
exit 1
else
echo "No changes detected"
fi
# We need to merge both unit and component view tests into a single coverage report so the PR coverage
# threshold calculation is accurate.
merge-unit-and-component-view-tests:
runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
needs: [unit-tests, component-view-tests]
if: ${{ !cancelled() && github.event_name != 'merge_group' }}
steps:
- uses: actions/checkout@v6
- name: Configure Namespace cache
if: ${{ inputs.runner_provider == 'namespace' }}
uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1
with:
path: |
~/.cache/yarn
.metamask
node_modules
.yarn/cache
- name: Restore node_modules cache
if: ${{ inputs.runner_provider != 'namespace' }}
id: cache-node-modules
uses: actions/cache@v4
with:
path: |
node_modules
.yarn/install-state.gz
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }}
- name: Install Yarn dependencies with retry
if: ${{ inputs.runner_provider == 'namespace' || steps.cache-node-modules.outputs.cache-hit != 'true' }}
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: yarn install --immutable
- name: Clean state and following up dependencies installation
run: yarn setup:github-ci --node
- uses: actions/download-artifact@v4
with:
pattern: coverage-*
path: tests/coverage/
- name: Aggregate test counts and gather coverage reports
shell: bash
run: |
unit_total=0
for file in ./tests/coverage/coverage-unit-*/count.json; do
[ -f "$file" ] || continue
count=$(jq '.count // 0' "$file")
unit_total=$((unit_total + count))
done
echo "{\"unit_test_number\": $unit_total}" > unit-test-stats.json
echo "Unit test count: $unit_total"
cv_total=0
for file in ./tests/coverage/coverage-cv-*/count.json; do
[ -f "$file" ] || continue
count=$(jq '.count // 0' "$file")
cv_total=$((cv_total + count))
done
echo "{\"component_view_test_number\": $cv_total}" > cv-test-stats.json
echo "CV test count: $cv_total"
mkdir -p tests/coverage-cv-merged
for file in ./tests/coverage/coverage-cv-*/coverage-cv-*.json; do
[ -f "$file" ] && cp "$file" ./tests/coverage-cv-merged/
done
mkdir -p tests/coverage-unit-merged
for file in ./tests/coverage/coverage-unit-*/coverage-unit-*.json; do
[ -f "$file" ] && cp "$file" ./tests/coverage-unit-merged/
done
find ./tests/coverage/coverage-* -name 'coverage-*.json' -exec mv {} ./tests/coverage/ \;
- run: yarn test:merge-coverage
- run: yarn test:validate-coverage
- uses: actions/upload-artifact@v4
with:
name: lcov.info
path: ./tests/merged-coverage/lcov.info
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@v4
with:
name: cv-test-stats
path: ./cv-test-stats.json
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@v4
with:
name: unit-test-stats
path: ./unit-test-stats.json
if-no-files-found: error
retention-days: 7
- name: Generate CV test coverage report
run: yarn nyc report --temp-dir ./tests/coverage-cv-merged --report-dir ./tests/coverage-cv-lcov --reporter html --reporter json-summary
- uses: actions/upload-artifact@v4
with:
name: cv-test-coverage-html
path: ./tests/coverage-cv-lcov/
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@v4
with:
name: cv-test-coverage-summary
path: ./tests/coverage-cv-lcov/coverage-summary.json
if-no-files-found: error
retention-days: 7
- name: Generate unit test coverage summary
run: yarn nyc report --temp-dir ./tests/coverage-unit-merged --report-dir ./tests/coverage-unit-lcov --reporter json-summary
- uses: actions/upload-artifact@v4
with:
name: unit-test-coverage-summary
path: ./tests/coverage-unit-lcov/coverage-summary.json
if-no-files-found: error
retention-days: 7
- name: Require clean working directory
shell: bash
run: |
if ! git diff --exit-code; then
echo "Working tree dirty at end of job"
exit 1
else
echo "No changes detected"
fi
component-view-tests:
name: Component view tests
runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }}
needs:
- get_requirements
strategy:
matrix:
shard: [1, 2]
steps:
- uses: actions/checkout@v6
- name: Configure Namespace cache
if: ${{ inputs.runner_provider == 'namespace' }}
uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1
with:
path: |
~/.cache/yarn
.metamask
node_modules
.yarn/cache
- name: Restore node_modules cache
if: ${{ inputs.runner_provider != 'namespace' }}
id: cache-node-modules
uses: actions/cache@v4
with:
path: |
node_modules
.yarn/install-state.gz
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }}
- name: Install Yarn dependencies with retry
if: ${{ inputs.runner_provider == 'namespace' || steps.cache-node-modules.outputs.cache-hit != 'true' }}
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: yarn install --immutable
- name: Clean state and following up dependencies installation
run: yarn setup:github-ci --node
- name: Prepare results directory
run: mkdir -p tests/results
- run: |
yarn test:view:ci \
--shard=${{ matrix.shard }}/2 \
--json \
--outputFile=tests/results/cv-test-results-${{ matrix.shard }}.json
env:
NODE_OPTIONS: ${{ inputs.runner_provider == 'namespace' && '--max-old-space-size=12288' || '--max-old-space-size=20480' }}
- name: Rename coverage report and extract test count for this shard
shell: bash
run: |
mv ./tests/coverage/coverage-final.json ./tests/coverage/coverage-cv-${{ matrix.shard }}.json
cp tests/results/cv-test-results-${{ matrix.shard }}.json ./tests/coverage/jest-results.json
count=$(jq '(.numPassedTests // 0) + (.numFailedTests // 0)' tests/results/cv-test-results-${{ matrix.shard }}.json)
echo "{\"count\": $count}" > ./tests/coverage/count.json
- uses: actions/upload-artifact@v4
with:
name: coverage-cv-${{ matrix.shard }}
path: ./tests/coverage/
if-no-files-found: error
retention-days: 7
smart-e2e-selection:
name: 'Smart E2E Selection'
runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ needs.get_requirements.outputs.run_smart_e2e_selection == 'true' }}
needs:
- get_requirements
continue-on-error: true
permissions:
contents: read
issues: write
pull-requests: write
outputs:
ai_e2e_test_tags: ${{ steps.e2e-selection.outputs.ai_e2e_test_tags }}
ai_confidence: ${{ steps.e2e-selection.outputs.ai_confidence }}
steps:
- name: Checkout for action definition
uses: actions/checkout@v6
with:
sparse-checkout: |
.github/actions/smart-e2e-selection
sparse-checkout-cone-mode: false
fetch-depth: 1
- name: Run Smart E2E Selection
id: e2e-selection
uses: ./.github/actions/smart-e2e-selection
with:
claude-api-key: ${{ secrets.E2E_CLAUDE_API_KEY }}
openai-api-key: ${{ secrets.E2E_OPENAI_API_KEY }}
google-api-key: ${{ secrets.E2E_GEMINI_API_KEY }}
github-token: ${{ github.token }}
pr-number: ${{ github.event.pull_request.number }}
repository: ${{ github.repository }}
post-comment: 'true'
base-ref: ${{ github.event.pull_request.base.ref }}
build-android-apks:
name: 'Build Android APKs'
if: >-
${{
!cancelled() &&
needs.get_requirements.outputs.android_e2e_needed == 'true' &&
!(fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags == '[]')
}}
permissions:
contents: read
id-token: write
actions: read
statuses: read
pull-requests: read
needs: [get_requirements, smart-e2e-selection, post-build-source-hash]
uses: ./.github/workflows/build-android-e2e.yml
with:
build_type: 'main'
metamask_environment: 'e2e'
keystore_target: 'qa'
source-fingerprint: ${{ needs.post-build-source-hash.outputs.fingerprint }}
runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
e2e-smoke-tests-android:
name: 'Android E2E Smoke Tests'
if: ${{ !cancelled() && needs.build-android-apks.result == 'success' }}
permissions:
contents: read
id-token: write
needs: [get_requirements, build-android-apks, smart-e2e-selection]
uses: ./.github/workflows/run-e2e-smoke-tests-android.yml
with:
changed_files: ${{ needs.get_requirements.outputs.changed_files }}
selected_tags: >-
${{
(fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) ||
'["ALL"]'
}}
runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
build-ios-apps:
name: 'Build iOS Apps'
if: >-
${{
!cancelled() &&
needs.get_requirements.outputs.ios_e2e_needed == 'true' &&
!(fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags == '[]')
}}
permissions:
contents: read
id-token: write
actions: read
statuses: read
pull-requests: read
needs: [get_requirements, smart-e2e-selection, post-build-source-hash]
uses: ./.github/workflows/build-ios-e2e.yml
with:
source-fingerprint: ${{ needs.post-build-source-hash.outputs.fingerprint }}
runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
ios-tests-ready:
name: 'iOS Tests Ready'
runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ !cancelled() && needs.build-ios-apps.result == 'success' }}
needs: [build-ios-apps]
steps:
- 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' }}
permissions:
contents: read
id-token: write
needs: [get_requirements, ios-tests-ready, smart-e2e-selection]
uses: ./.github/workflows/run-e2e-smoke-tests-ios.yml
with:
changed_files: ${{ needs.get_requirements.outputs.changed_files }}
selected_tags: >-
${{
(fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) ||
'["ALL"]'
}}
runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
# Fixture validation — ensures committed E2E fixtures match the live app state schema
validate-e2e-fixtures:
name: 'Validate E2E Fixtures'
if: ${{ needs.ios-tests-ready.result == 'success' && github.event_name == 'pull_request' }}
permissions:
contents: read
id-token: write
needs: [get_requirements, ios-tests-ready, smart-e2e-selection]
uses: ./.github/workflows/run-e2e-workflow.yml
with:
test-suite-name: validate-e2e-fixtures
platform: ios
test_suite_tag: 'FixtureValidation'
split_number: 1
total_splits: 1
build_type: 'main'
metamask_environment: 'qa'
runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
report-fixture-validation:
name: 'Report Fixture Validation'
runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ !cancelled() && needs.validate-e2e-fixtures.result != 'skipped' }}
needs: [validate-e2e-fixtures]
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v6
- name: Download fixture validation results
continue-on-error: true
uses: actions/download-artifact@v4
with:
name: test-e2e-main-validate-e2e-fixtures-junit-results
path: fixture-results/
- name: Report results
env:
RESULTS_PATH: fixture-results
VALIDATION_RESULT: ${{ needs.validate-e2e-fixtures.result }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: node .github/scripts/e2e-report-fixture-validation.mjs
sonar-cloud:
name: SonarCloud analysis
runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
needs: merge-unit-and-component-view-tests
if: ${{ !cancelled() && github.event_name != 'merge_group' && !github.event.pull_request.head.repo.fork }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # SonarCloud needs a full checkout to perform necessary analysis
- name: Configure Namespace cache
if: ${{ inputs.runner_provider == 'namespace' }}
uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1
with:
path: |
~/.cache/yarn
.metamask
node_modules
.yarn/cache
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- uses: actions/download-artifact@v4
with:
name: lcov.info
path: coverage/
- name: Upload coverage reports to Codecov
if: ${{ always() }}
continue-on-error: true
uses: codecov/codecov-action@d9f34f8cd5cb3b3eb79b3e4b5dae3a16df499a70
- name: SonarCloud Scan
if: ${{ env.HAVE_SONAR_TOKEN == 'true' }}
continue-on-error: true
# This is SonarSource/sonarqube-scan-action@v7.0.0
uses: SonarSource/sonarqube-scan-action@a31c9398be7ace6bbfaf30c0bd5d415f843d45e9
env:
HAVE_SONAR_TOKEN: ${{ secrets.SONAR_TOKEN != '' }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
- name: Require clean working directory
shell: bash
run: |
if ! git diff --exit-code; then
echo "Working tree dirty at end of job"
exit 1
else
echo "No changes detected"
fi
# Revert git update-index --no-assume-unchanged for each entry
echo "Reverting assume unchanged for the following paths:"
for path in "${EXCLUDES[@]}"; do
echo "$path"
git update-index --no-assume-unchanged "$path"
done
sonar-cloud-quality-gate-status:
name: SonarCloud quality gate status
runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
needs: sonar-cloud
if: ${{ !cancelled() && github.event_name != 'merge_group' && !github.event.pull_request.head.repo.fork }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: SonarCloud Quality Gate Status
id: sonar-status
env:
REPO: ${{ github.repository }}
ISSUE_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Skip step if event is not a PR
if [[ "${{ github.event_name }}" != "pull_request" ]]; then
echo "This job only runs for pull requests."
exit 0
fi
# Bypass step if skip-sonar-cloud label is found
LABEL=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/repos/$REPO/issues/$ISSUE_NUMBER/labels" | \
jq -r '.[] | select(.name=="skip-sonar-cloud") | .name')
if [[ "$LABEL" == "skip-sonar-cloud" ]]; then
echo "skip-sonar-cloud label found. Skipping SonarCloud Quality Gate check."
else
sleep 30
PROJECT_KEY="metamask-mobile"
PR_NUMBER="${{ github.event.pull_request.number }}"
SONAR_TOKEN="${{ secrets.SONAR_TOKEN }}"
if [ -z "$PR_NUMBER" ]; then
echo "No pull request number found. Failing the check."
exit 1
fi
RESPONSE=$(curl -s -u "$SONAR_TOKEN:" \
"https://sonarcloud.io/api/qualitygates/project_status?projectKey=$PROJECT_KEY&pullRequest=$PR_NUMBER")
echo "SonarCloud API Response: $RESPONSE"
STATUS=$(echo "$RESPONSE" | jq -r '.projectStatus.status')
if [[ "$STATUS" == "ERROR" ]]; then
echo "Quality Gate failed."
exit 1
elif [[ "$STATUS" == "OK" ]]; then
echo "Quality Gate passed."
else
echo "Could not determine Quality Gate status."
exit 1
fi
fi
check-all-jobs-pass:
name: Check all jobs pass
# Run the aggregate gate even when optional dependencies are skipped.
# The composite action decides which skipped jobs are acceptable.
if: ${{ always() && !cancelled() }}
runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
needs:
- get_requirements
- check-diff
- dedupe
- scripts
- unit-tests
- component-view-tests
- check-workflows
- js-bundle-size-check
- sonar-cloud-quality-gate-status
- build-android-apks
- build-ios-apps
- e2e-smoke-tests-android
- e2e-smoke-tests-ios
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 1
sparse-checkout: |
.github/actions/ci-status-gate
- name: Evaluate CI status
uses: ./.github/actions/ci-status-gate
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)$'
event-name: ${{ github.event_name }}
is-fork: ${{ github.event.pull_request.head.repo.fork == true }}
log-merge-group-failure:
name: Log merge group failure
runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
# Only run this job if the merge group event fails, skip on forks
if: ${{ github.event_name == 'merge_group' && failure() }}
needs:
- check-all-jobs-pass
steps:
- name: Log merge group failure to Google Sheets
uses: MetaMask/github-tools/.github/actions/log-merge-group-failure@v1
with:
google-application-credentials: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}
google-service-account: ${{ secrets.GOOGLE_SERVICE_ACCOUNT }}
spreadsheet-id: ${{ secrets.GOOGLE_MERGE_QUEUE_SPREADSHEET_ID }}
sheet-name: ${{ secrets.GOOGLE_MERGE_QUEUE_SHEET_NAME }}