Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 28 additions & 12 deletions .github/actions/find-reusable-build/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ inputs:
until a run-specific status match is found.
required: false
default: '30'
main-branch-only:
description: >-
When true, only search the base branch (typically `main`) for reusable
builds. Used for PRs with test-only changes that should not compile fresh
native artifacts.
required: false
default: 'false'

outputs:
found:
Expand Down Expand Up @@ -86,6 +93,7 @@ runs:
STATUS_CONTEXT: ${{ inputs.status-context }}
MAX_CANDIDATES: ${{ inputs.max-candidates-per-branch }}
MAX_CANDIDATES_CROSS_PR: ${{ inputs.max-candidates-cross-pr }}
MAIN_BRANCH_ONLY: ${{ inputs.main-branch-only }}
HEAD_BRANCH: ${{ github.head_ref || github.ref_name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
CURRENT_RUN_ID: ${{ github.run_id }}
Expand All @@ -101,6 +109,7 @@ runs:
STATUS_CONTEXT,
MAX_CANDIDATES,
MAX_CANDIDATES_CROSS_PR,
MAIN_BRANCH_ONLY,
HEAD_BRANCH,
HEAD_SHA,
CURRENT_RUN_ID,
Expand Down Expand Up @@ -155,25 +164,32 @@ runs:
// lookups can never discover another PR's run
// (GitHub filters `branch` against head_branch,
// which is the PR source branch).
const tiers = [
{
const tiers = [];
const mainBranchOnly = MAIN_BRANCH_ONLY === 'true';

if (!mainBranchOnly) {
tiers.push({
label: `same-branch (branch=${HEAD_BRANCH})`,
params: { branch: HEAD_BRANCH, per_page: maxCandidates },
},
];
if (BASE_BRANCH && BASE_BRANCH !== HEAD_BRANCH) {
});
}

if (BASE_BRANCH && (mainBranchOnly || BASE_BRANCH !== HEAD_BRANCH)) {
tiers.push({
label: `base-branch (branch=${BASE_BRANCH})`,
params: { branch: BASE_BRANCH, per_page: maxCandidates },
});
}
tiers.push({
label: `cross-pr (event=pull_request, any branch, last ${maxCandidatesCrossPr} runs)`,
params: { event: 'pull_request', per_page: maxCandidatesCrossPr },
// Skip runs already visited by the same-branch tier to avoid
// wasting API calls on duplicates.
skipHeadBranch: HEAD_BRANCH,
});

if (!mainBranchOnly) {
tiers.push({
label: `cross-pr (event=pull_request, any branch, last ${maxCandidatesCrossPr} runs)`,
params: { event: 'pull_request', per_page: maxCandidatesCrossPr },
// Skip runs already visited by the same-branch tier to avoid
// wasting API calls on duplicates.
skipHeadBranch: HEAD_BRANCH,
});
}

async function hasRunFingerprintStatus(sha, runId) {
try {
Expand Down
13 changes: 13 additions & 0 deletions .github/guidelines/E2E_DECISION_TREE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ flowchart TD
L2 -->|ignorable-only changes| NoBlock[No merge block]
L2 -->|non-ignorable changes| Skip2[⛔️ Merge blocked]
GR -->|PR ignorable-only changes| Ignorable[No E2E]
GR -->|PR test-only changes| TestOnly[E2E + Smart selection, reuse main builds]
GR -->|PR has Android-only changes| Android[Android Build + Tests needed]
GR -->|PR has iOS-only changes| iOS[iOS Build + Test needed]
GR -->|PR other files changed| Both[Both Build + Tests needed]
Expand All @@ -25,6 +26,18 @@ flowchart TD
CONF -->|no| AllTagsFallback[Run all E2E needed]
```

## Test-only PR changes

When a PR only changes E2E/performance test files (and other ignorable files), CI still runs Smart E2E Selection and the selected E2E/performance suites, but **does not compile fresh iOS/Android native builds**. Instead, it reuses the latest matching artifacts from `main`.

The native build fingerprint for test-only PRs is computed from **`main` HEAD** (not the PR merge tree) so the lookup key matches completed `ci.yml` runs on `main`. Reuse tries GitHub Actions artifacts first, then the Cirrus `main` APK cache on Android.

If `main` has new native-changing commits but its CI build has not finished yet, reuse lookup may miss — CI logs a warning and **falls back to a fresh native build** instead of failing the workflow. Performance E2E on test-only PRs resolves BrowserStack apps by `custom_id` prefix `MetaMask-Android-*-main-*` so only uploads from `main` are reused (not other branches or PRs).

This applies when all changed files match `e2e_test_files` or `e2e_ignorable` filters in `.github/rules/filter-rules.yml`, with at least one E2E test file changed, and no E2E-relevant workflow files were modified.

Use the `force-builds` label or `[force-builds]` commit tag to override reuse and compile fresh builds — including on test-only PRs that would otherwise require main-branch artifacts.

## E2E tests skipped by default on new PRs

To save infra resources while waiting for static analysis findings and potential fixes/iterations:
Expand Down
38 changes: 38 additions & 0 deletions .github/rules/filter-rules.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,34 @@ config_files: &config_files
ci_files: &ci_files
- '.github/**'

# E2E / PERFORMANCE TEST FILES — do not require a fresh native build, but still
# run E2E + Smart E2E selection when these are the only non-ignorable changes.
e2e_test_files: &e2e_test_files
- 'tests/smoke/**'
- 'tests/regression/**'
- 'tests/performance/**'
- 'tests/smoke-appium/**'
- 'tests/flows/**'
- 'tests/page-objects/**'
- 'tests/selectors/**'
- 'tests/locators/**'
- 'tests/framework/**'
- 'tests/api-mocking/**'
- 'tests/docs/**'
- 'tests/scripts/**'
- 'tests/reporters/**'
- 'tests/tools/e2e-ai-analyzer/**'
- 'tests/playwright*.config.ts'
- 'tests/playwright.*.config.ts'
- 'tests/init.detox.js'
- 'tests/environment.detox.js'
- 'tests/jest.e2e.detox.config.js'
- 'tests/helpers.js'
- 'tests/tags.js'
- 'tests/tags.performance.js'
- 'tests/teams-config.js'
- 'wdio/**'

# ALL IGNORABLE FILES - safe to skip E2E
e2e_ignorable:
- *documentation_files
Expand All @@ -54,6 +82,16 @@ e2e_ignorable:
- *config_files
- *ci_files

# Union of ignorable + E2E test files for test-only change detection.
e2e_test_or_ignorable:
- *documentation_files
- *asset_files
- *low_level_test_files
- *locale_translation_files
- *config_files
- *ci_files
- *e2e_test_files

e2e_relevant_workflows:
- '.github/workflows/ci.yml'
- '.github/workflows/get-requirements.yml'
Expand Down
78 changes: 78 additions & 0 deletions .github/scripts/browserstack-app-validation.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Validates BrowserStack API fields before they are written to GitHub Actions
* outputs or other local files.
*/

/** BrowserStack app URLs use the bs:// scheme with an opaque app hash. */
const BROWSERSTACK_APP_URL_PATTERN = /^bs:\/\/[A-Za-z0-9]+$/;

const WITH_SRP_CUSTOM_ID_PATTERN =
/^MetaMask-Android-With-SRP-[A-Za-z0-9._-]+-\d+$/;
const WITHOUT_SRP_CUSTOM_ID_PATTERN =
/^MetaMask-Android-Without-SRP-[A-Za-z0-9._-]+-\d+$/;

const MAIN_BRANCH_BROWSERSTACK_SLUG = 'main';
const MAIN_WITH_SRP_CUSTOM_ID_PREFIX = `MetaMask-Android-With-SRP-${MAIN_BRANCH_BROWSERSTACK_SLUG}-`;
const MAIN_WITHOUT_SRP_CUSTOM_ID_PREFIX = `MetaMask-Android-Without-SRP-${MAIN_BRANCH_BROWSERSTACK_SLUG}-`;

/**
* @param {unknown} value
* @param {RegExp} pattern
* @param {string} label
* @returns {string}
*/
function assertMatchesPattern(value, pattern, label) {
if (typeof value !== 'string' || !pattern.test(value)) {
throw new Error(`Invalid ${label}`);
}
return value;
}

/**
* @param {unknown} value
* @param {string} label
* @returns {string}
*/
function assertBrowserStackAppUrl(value, label) {
return assertMatchesPattern(value, BROWSERSTACK_APP_URL_PATTERN, label);
}

/**
* @param {unknown} value
* @param {'with-srp' | 'without-srp'} kind
* @returns {string}
*/
function assertBrowserStackCustomId(value, kind) {
const pattern =
kind === 'with-srp'
? WITH_SRP_CUSTOM_ID_PATTERN
: WITHOUT_SRP_CUSTOM_ID_PATTERN;
return assertMatchesPattern(value, pattern, `${kind} custom_id`);
}

/**
* @param {string} path
* @param {Record<string, string>} outputs
*/
function writeGithubOutputs(path, outputs) {
const lines = [];
for (const [key, value] of Object.entries(outputs)) {
if (value.includes('\n') || value.includes('\r')) {
throw new Error(`Refusing to write multiline GitHub output for ${key}`);
}
lines.push(`${key}=${value}`);
}
require('node:fs').appendFileSync(path, `${lines.join('\n')}\n`);
}

module.exports = {
BROWSERSTACK_APP_URL_PATTERN,
WITH_SRP_CUSTOM_ID_PATTERN,
WITHOUT_SRP_CUSTOM_ID_PATTERN,
MAIN_BRANCH_BROWSERSTACK_SLUG,
MAIN_WITH_SRP_CUSTOM_ID_PREFIX,
MAIN_WITHOUT_SRP_CUSTOM_ID_PREFIX,
assertBrowserStackAppUrl,
assertBrowserStackCustomId,
writeGithubOutputs,
};
66 changes: 66 additions & 0 deletions .github/scripts/browserstack-app-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

const {
assertBrowserStackAppUrl,
assertBrowserStackCustomId,
writeGithubOutputs,
} = require('./browserstack-app-validation.cjs');

describe('browserstack-app-validation', () => {
it('accepts valid BrowserStack app URLs', () => {
expect(assertBrowserStackAppUrl('bs://abc123DEF', 'test')).toBe(
'bs://abc123DEF',
);
});

it('rejects malformed BrowserStack app URLs', () => {
expect(() =>
assertBrowserStackAppUrl('https://evil.example/app', 'test'),
).toThrow('Invalid test');
expect(() =>
assertBrowserStackAppUrl('bs://bad chars', 'test'),
).toThrow('Invalid test');
});

it('accepts expected custom_id formats with branch slug', () => {
expect(
assertBrowserStackCustomId(
'MetaMask-Android-With-SRP-main-123',
'with-srp',
),
).toBe('MetaMask-Android-With-SRP-main-123');
expect(
assertBrowserStackCustomId(
'MetaMask-Android-Without-SRP-main-456',
'without-srp',
),
).toBe('MetaMask-Android-Without-SRP-main-456');
expect(
assertBrowserStackCustomId(
'MetaMask-Android-With-SRP-feature_x-789',
'with-srp',
),
).toBe('MetaMask-Android-With-SRP-feature_x-789');
});

it('rejects legacy custom_id formats without branch slug', () => {
expect(() =>
assertBrowserStackCustomId('MetaMask-Android-With-SRP-123', 'with-srp'),
).toThrow('Invalid with-srp custom_id');
});

it('writes only validated single-line GitHub outputs', () => {
const outputPath = path.join(os.tmpdir(), `gh-output-${Date.now()}.txt`);
writeGithubOutputs(outputPath, {
found: 'true',
'with-srp-browserstack-url': 'bs://abc123',
});

expect(fs.readFileSync(outputPath, 'utf8')).toBe(
'found=true\nwith-srp-browserstack-url=bs://abc123\n',
);
fs.unlinkSync(outputPath);
});
});
Loading
Loading