Skip to content

Commit 4fde051

Browse files
ci(MMQA-1667): skip native builds when PR only changes E2E/performance tests (#33180)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## **Description** PRs that only touch E2E or performance test files currently trigger full iOS and Android native builds even though the app binary does not change. This adds **test-only change detection** so CI still runs Smart E2E Selection (including performance tag selection), but **reuses the latest matching builds from `main`** instead of compiling fresh native artifacts. an example where the PR only touches test files and does not create builds for e2e, but reuse ones from main can be seen in #33188 ### What changed - **`.github/rules/filter-rules.yml`**: new `e2e_test_files` and `e2e_test_or_ignorable` filters for smoke/regression/performance test paths and shared E2E infra. - **`.github/workflows/get-requirements.yml`**: new `native_build_needed` output; test-only PRs set `native_build_needed=false` while keeping E2E + Smart selection enabled. - **`.github/actions/find-reusable-build`**: `main-branch-only` mode to search only the base branch when reusing builds. - **`.github/workflows/build-android-e2e.yml`** / **`build-ios-e2e.yml`**: `reuse-main-builds-only` input skips repack and fails fast if no main artifact is found. - **`.github/workflows/ci.yml`**: wires `native_build_needed` into E2E and performance build jobs. - **`.github/workflows/run-performance-e2e.yml`**: skips fresh BrowserStack Android builds on test-only PRs and resolves the latest uploaded apps via BrowserStack API. - **`.github/guidelines/E2E_DECISION_TREE.md`**: documents the new test-only path. `force-builds` label / `[force-builds]` commit tag still forces fresh native builds. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: MMQA-1667 ## **Manual testing steps** ```gherkin Feature: test-only PR CI build reuse Scenario: PR with only E2E test file changes Given a PR that only modifies files under tests/smoke/, tests/regression/, or tests/performance/ When CI runs on the PR Then Smart E2E Selection still runs And build-android-apks / build-ios-apps reuse main-branch artifacts (no native compile, no JS repack) And selected E2E / performance suites still execute Scenario: PR with app code changes Given a PR that modifies app/ or native project files When CI runs on the PR Then native iOS/Android builds run as before ``` N/A for on-device manual QA — CI workflow change only. Validated locally with `compute-e2e-platform-flags` unit tests and the `run-compute-e2e-platform-flags` GitHub Actions entrypoint. ## **Screenshots/Recordings** N/A — CI infrastructure change with no UI impact. ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - [ ] I've tested with a power user scenario - [ ] I've instrumented key operations with Sentry traces for production performance metrics ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-3598b715-73d3-4e36-9d45-e8c014354b2e"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-3598b715-73d3-4e36-9d45-e8c014354b2e"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: javiergarciavera <javiergarciavera@users.noreply.github.com>
1 parent ac5b30b commit 4fde051

15 files changed

Lines changed: 812 additions & 163 deletions

.github/actions/find-reusable-build/action.yml

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ inputs:
5555
until a run-specific status match is found.
5656
required: false
5757
default: '30'
58+
main-branch-only:
59+
description: >-
60+
When true, only search the base branch (typically `main`) for reusable
61+
builds. Used for PRs with test-only changes that should not compile fresh
62+
native artifacts.
63+
required: false
64+
default: 'false'
5865

5966
outputs:
6067
found:
@@ -86,6 +93,7 @@ runs:
8693
STATUS_CONTEXT: ${{ inputs.status-context }}
8794
MAX_CANDIDATES: ${{ inputs.max-candidates-per-branch }}
8895
MAX_CANDIDATES_CROSS_PR: ${{ inputs.max-candidates-cross-pr }}
96+
MAIN_BRANCH_ONLY: ${{ inputs.main-branch-only }}
8997
HEAD_BRANCH: ${{ github.head_ref || github.ref_name }}
9098
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
9199
CURRENT_RUN_ID: ${{ github.run_id }}
@@ -101,6 +109,7 @@ runs:
101109
STATUS_CONTEXT,
102110
MAX_CANDIDATES,
103111
MAX_CANDIDATES_CROSS_PR,
112+
MAIN_BRANCH_ONLY,
104113
HEAD_BRANCH,
105114
HEAD_SHA,
106115
CURRENT_RUN_ID,
@@ -155,25 +164,32 @@ runs:
155164
// lookups can never discover another PR's run
156165
// (GitHub filters `branch` against head_branch,
157166
// which is the PR source branch).
158-
const tiers = [
159-
{
167+
const tiers = [];
168+
const mainBranchOnly = MAIN_BRANCH_ONLY === 'true';
169+
170+
if (!mainBranchOnly) {
171+
tiers.push({
160172
label: `same-branch (branch=${HEAD_BRANCH})`,
161173
params: { branch: HEAD_BRANCH, per_page: maxCandidates },
162-
},
163-
];
164-
if (BASE_BRANCH && BASE_BRANCH !== HEAD_BRANCH) {
174+
});
175+
}
176+
177+
if (BASE_BRANCH && (mainBranchOnly || BASE_BRANCH !== HEAD_BRANCH)) {
165178
tiers.push({
166179
label: `base-branch (branch=${BASE_BRANCH})`,
167180
params: { branch: BASE_BRANCH, per_page: maxCandidates },
168181
});
169182
}
170-
tiers.push({
171-
label: `cross-pr (event=pull_request, any branch, last ${maxCandidatesCrossPr} runs)`,
172-
params: { event: 'pull_request', per_page: maxCandidatesCrossPr },
173-
// Skip runs already visited by the same-branch tier to avoid
174-
// wasting API calls on duplicates.
175-
skipHeadBranch: HEAD_BRANCH,
176-
});
183+
184+
if (!mainBranchOnly) {
185+
tiers.push({
186+
label: `cross-pr (event=pull_request, any branch, last ${maxCandidatesCrossPr} runs)`,
187+
params: { event: 'pull_request', per_page: maxCandidatesCrossPr },
188+
// Skip runs already visited by the same-branch tier to avoid
189+
// wasting API calls on duplicates.
190+
skipHeadBranch: HEAD_BRANCH,
191+
});
192+
}
177193
178194
async function hasRunFingerprintStatus(sha, runId) {
179195
try {

.github/guidelines/E2E_DECISION_TREE.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ flowchart TD
1212
L2 -->|ignorable-only changes| NoBlock[No merge block]
1313
L2 -->|non-ignorable changes| Skip2[⛔️ Merge blocked]
1414
GR -->|PR ignorable-only changes| Ignorable[No E2E]
15+
GR -->|PR test-only changes| TestOnly[E2E + Smart selection, reuse main builds]
1516
GR -->|PR has Android-only changes| Android[Android Build + Tests needed]
1617
GR -->|PR has iOS-only changes| iOS[iOS Build + Test needed]
1718
GR -->|PR other files changed| Both[Both Build + Tests needed]
@@ -25,6 +26,18 @@ flowchart TD
2526
CONF -->|no| AllTagsFallback[Run all E2E needed]
2627
```
2728

29+
## Test-only PR changes
30+
31+
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`.
32+
33+
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.
34+
35+
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).
36+
37+
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.
38+
39+
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.
40+
2841
## E2E tests skipped by default on new PRs
2942

3043
To save infra resources while waiting for static analysis findings and potential fixes/iterations:

.github/rules/filter-rules.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,34 @@ config_files: &config_files
4545
ci_files: &ci_files
4646
- '.github/**'
4747

48+
# E2E / PERFORMANCE TEST FILES — do not require a fresh native build, but still
49+
# run E2E + Smart E2E selection when these are the only non-ignorable changes.
50+
e2e_test_files: &e2e_test_files
51+
- 'tests/smoke/**'
52+
- 'tests/regression/**'
53+
- 'tests/performance/**'
54+
- 'tests/smoke-appium/**'
55+
- 'tests/flows/**'
56+
- 'tests/page-objects/**'
57+
- 'tests/selectors/**'
58+
- 'tests/locators/**'
59+
- 'tests/framework/**'
60+
- 'tests/api-mocking/**'
61+
- 'tests/docs/**'
62+
- 'tests/scripts/**'
63+
- 'tests/reporters/**'
64+
- 'tests/tools/e2e-ai-analyzer/**'
65+
- 'tests/playwright*.config.ts'
66+
- 'tests/playwright.*.config.ts'
67+
- 'tests/init.detox.js'
68+
- 'tests/environment.detox.js'
69+
- 'tests/jest.e2e.detox.config.js'
70+
- 'tests/helpers.js'
71+
- 'tests/tags.js'
72+
- 'tests/tags.performance.js'
73+
- 'tests/teams-config.js'
74+
- 'wdio/**'
75+
4876
# ALL IGNORABLE FILES - safe to skip E2E
4977
e2e_ignorable:
5078
- *documentation_files
@@ -54,6 +82,16 @@ e2e_ignorable:
5482
- *config_files
5583
- *ci_files
5684

85+
# Union of ignorable + E2E test files for test-only change detection.
86+
e2e_test_or_ignorable:
87+
- *documentation_files
88+
- *asset_files
89+
- *low_level_test_files
90+
- *locale_translation_files
91+
- *config_files
92+
- *ci_files
93+
- *e2e_test_files
94+
5795
e2e_relevant_workflows:
5896
- '.github/workflows/ci.yml'
5997
- '.github/workflows/get-requirements.yml'
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Validates BrowserStack API fields before they are written to GitHub Actions
3+
* outputs or other local files.
4+
*/
5+
6+
/** BrowserStack app URLs use the bs:// scheme with an opaque app hash. */
7+
const BROWSERSTACK_APP_URL_PATTERN = /^bs:\/\/[A-Za-z0-9]+$/;
8+
9+
const WITH_SRP_CUSTOM_ID_PATTERN =
10+
/^MetaMask-Android-With-SRP-[A-Za-z0-9._-]+-\d+$/;
11+
const WITHOUT_SRP_CUSTOM_ID_PATTERN =
12+
/^MetaMask-Android-Without-SRP-[A-Za-z0-9._-]+-\d+$/;
13+
14+
const MAIN_BRANCH_BROWSERSTACK_SLUG = 'main';
15+
const MAIN_WITH_SRP_CUSTOM_ID_PREFIX = `MetaMask-Android-With-SRP-${MAIN_BRANCH_BROWSERSTACK_SLUG}-`;
16+
const MAIN_WITHOUT_SRP_CUSTOM_ID_PREFIX = `MetaMask-Android-Without-SRP-${MAIN_BRANCH_BROWSERSTACK_SLUG}-`;
17+
18+
/**
19+
* @param {unknown} value
20+
* @param {RegExp} pattern
21+
* @param {string} label
22+
* @returns {string}
23+
*/
24+
function assertMatchesPattern(value, pattern, label) {
25+
if (typeof value !== 'string' || !pattern.test(value)) {
26+
throw new Error(`Invalid ${label}`);
27+
}
28+
return value;
29+
}
30+
31+
/**
32+
* @param {unknown} value
33+
* @param {string} label
34+
* @returns {string}
35+
*/
36+
function assertBrowserStackAppUrl(value, label) {
37+
return assertMatchesPattern(value, BROWSERSTACK_APP_URL_PATTERN, label);
38+
}
39+
40+
/**
41+
* @param {unknown} value
42+
* @param {'with-srp' | 'without-srp'} kind
43+
* @returns {string}
44+
*/
45+
function assertBrowserStackCustomId(value, kind) {
46+
const pattern =
47+
kind === 'with-srp'
48+
? WITH_SRP_CUSTOM_ID_PATTERN
49+
: WITHOUT_SRP_CUSTOM_ID_PATTERN;
50+
return assertMatchesPattern(value, pattern, `${kind} custom_id`);
51+
}
52+
53+
/**
54+
* @param {string} path
55+
* @param {Record<string, string>} outputs
56+
*/
57+
function writeGithubOutputs(path, outputs) {
58+
const lines = [];
59+
for (const [key, value] of Object.entries(outputs)) {
60+
if (value.includes('\n') || value.includes('\r')) {
61+
throw new Error(`Refusing to write multiline GitHub output for ${key}`);
62+
}
63+
lines.push(`${key}=${value}`);
64+
}
65+
require('node:fs').appendFileSync(path, `${lines.join('\n')}\n`);
66+
}
67+
68+
module.exports = {
69+
BROWSERSTACK_APP_URL_PATTERN,
70+
WITH_SRP_CUSTOM_ID_PATTERN,
71+
WITHOUT_SRP_CUSTOM_ID_PATTERN,
72+
MAIN_BRANCH_BROWSERSTACK_SLUG,
73+
MAIN_WITH_SRP_CUSTOM_ID_PREFIX,
74+
MAIN_WITHOUT_SRP_CUSTOM_ID_PREFIX,
75+
assertBrowserStackAppUrl,
76+
assertBrowserStackCustomId,
77+
writeGithubOutputs,
78+
};
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import fs from 'node:fs';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
5+
const {
6+
assertBrowserStackAppUrl,
7+
assertBrowserStackCustomId,
8+
writeGithubOutputs,
9+
} = require('./browserstack-app-validation.cjs');
10+
11+
describe('browserstack-app-validation', () => {
12+
it('accepts valid BrowserStack app URLs', () => {
13+
expect(assertBrowserStackAppUrl('bs://abc123DEF', 'test')).toBe(
14+
'bs://abc123DEF',
15+
);
16+
});
17+
18+
it('rejects malformed BrowserStack app URLs', () => {
19+
expect(() =>
20+
assertBrowserStackAppUrl('https://evil.example/app', 'test'),
21+
).toThrow('Invalid test');
22+
expect(() =>
23+
assertBrowserStackAppUrl('bs://bad chars', 'test'),
24+
).toThrow('Invalid test');
25+
});
26+
27+
it('accepts expected custom_id formats with branch slug', () => {
28+
expect(
29+
assertBrowserStackCustomId(
30+
'MetaMask-Android-With-SRP-main-123',
31+
'with-srp',
32+
),
33+
).toBe('MetaMask-Android-With-SRP-main-123');
34+
expect(
35+
assertBrowserStackCustomId(
36+
'MetaMask-Android-Without-SRP-main-456',
37+
'without-srp',
38+
),
39+
).toBe('MetaMask-Android-Without-SRP-main-456');
40+
expect(
41+
assertBrowserStackCustomId(
42+
'MetaMask-Android-With-SRP-feature_x-789',
43+
'with-srp',
44+
),
45+
).toBe('MetaMask-Android-With-SRP-feature_x-789');
46+
});
47+
48+
it('rejects legacy custom_id formats without branch slug', () => {
49+
expect(() =>
50+
assertBrowserStackCustomId('MetaMask-Android-With-SRP-123', 'with-srp'),
51+
).toThrow('Invalid with-srp custom_id');
52+
});
53+
54+
it('writes only validated single-line GitHub outputs', () => {
55+
const outputPath = path.join(os.tmpdir(), `gh-output-${Date.now()}.txt`);
56+
writeGithubOutputs(outputPath, {
57+
found: 'true',
58+
'with-srp-browserstack-url': 'bs://abc123',
59+
});
60+
61+
expect(fs.readFileSync(outputPath, 'utf8')).toBe(
62+
'found=true\nwith-srp-browserstack-url=bs://abc123\n',
63+
);
64+
fs.unlinkSync(outputPath);
65+
});
66+
});

0 commit comments

Comments
 (0)