Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
11 changes: 11 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,16 @@ 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.

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.

## 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
69 changes: 69 additions & 0 deletions .github/scripts/browserstack-app-validation.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* 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-\d+$/;
const WITHOUT_SRP_CUSTOM_ID_PATTERN = /^MetaMask-Android-Without-SRP-\d+$/;

/**
* @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,
assertBrowserStackAppUrl,
assertBrowserStackCustomId,
writeGithubOutputs,
};
50 changes: 50 additions & 0 deletions .github/scripts/browserstack-app-validation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
const {
assertBrowserStackAppUrl,
assertBrowserStackCustomId,
writeGithubOutputs,
} = require('./browserstack-app-validation.cjs');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');

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', () => {
expect(assertBrowserStackCustomId('MetaMask-Android-With-SRP-123', 'with-srp')).toBe(
'MetaMask-Android-With-SRP-123',
);
expect(
assertBrowserStackCustomId(
'MetaMask-Android-Without-SRP-456',
'without-srp',
),
).toBe('MetaMask-Android-Without-SRP-456');
});

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);
});
});
108 changes: 108 additions & 0 deletions .github/scripts/compute-e2e-platform-flags.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Pure decision logic for CI E2E platform and native-build requirements.
*/

/**
* @param {object} input
* @returns {object}
*/
function computeE2EPlatformFlags(input) {
const {
githubEventName,
isFork,
shouldSkipE2E,
allChangesCount,
ignorableCount,
e2eTestFilesCount,
e2eTestOrIgnorableCount,
e2eWorkflowsCount,
androidCount,
iosCount,
androidOrIgnorableCount,
iosOrIgnorableCount,
allChangesFiles = '',
} = input;

let android = false;
let ios = false;
let changed = '';
let message = '';
let nativeBuildNeeded = true;

const ignorableOnly =
allChangesCount > 0 &&
ignorableCount === allChangesCount &&
e2eWorkflowsCount === 0;

const testOnlyChanges =
allChangesCount > 0 &&
e2eTestOrIgnorableCount >= allChangesCount &&
e2eTestFilesCount > 0 &&
e2eWorkflowsCount === 0;

if (githubEventName === 'schedule' || githubEventName === 'push') {
message = 'E2E for both platforms (scheduled or push to main)';
android = true;
ios = true;
} else if (githubEventName === 'merge_group') {
message = 'Skipping E2E (merge queue)';
} else if (isFork) {
message = 'Skipping E2E (fork PR)';
} else if (shouldSkipE2E) {
message = 'Skipping E2E (skip signal)';
} else if (ignorableOnly) {
message = 'Skipping E2E (ignorable-only changes)';
} else if (testOnlyChanges) {
message =
'E2E for both platforms (test-only changes — reuse main native builds)';
android = true;
ios = true;
nativeBuildNeeded = false;
changed = allChangesFiles;
} else if (
androidCount > 0 &&
iosCount === 0 &&
e2eWorkflowsCount === 0 &&
androidOrIgnorableCount >= allChangesCount
) {
message = 'E2E Android only';
android = true;
changed = allChangesFiles;
} else if (
iosCount > 0 &&
androidCount === 0 &&
e2eWorkflowsCount === 0 &&
iosOrIgnorableCount >= allChangesCount
) {
message = 'E2E iOS only';
ios = true;
changed = allChangesFiles;
} else {
message = 'E2E for both platforms';
android = true;
ios = true;
changed = allChangesFiles;
}

const e2eNeeded = android || ios;

const runSmartE2ESelection =
githubEventName === 'pull_request' &&
e2eNeeded &&
!isFork &&
!shouldSkipE2E;

return {
android,
ios,
e2eNeeded,
nativeBuildNeeded: e2eNeeded ? nativeBuildNeeded : false,
runSmartE2ESelection,
message,
changedFiles: changed,
};
}

module.exports = {
computeE2EPlatformFlags,
};
Loading
Loading