Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 9 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,14 @@ 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`.

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
70 changes: 70 additions & 0 deletions .github/scripts/compute-e2e-platform-flags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { computeE2EPlatformFlags } from './compute-e2e-platform-flags';

describe('computeE2EPlatformFlags', () => {
const baseInput = {
githubEventName: 'pull_request',
isFork: false,
shouldSkipE2E: false,
allChangesCount: 1,
ignorableCount: 0,
e2eTestFilesCount: 1,
e2eTestOrIgnorableCount: 1,
e2eWorkflowsCount: 0,
androidCount: 0,
iosCount: 0,
androidOrIgnorableCount: 0,
iosOrIgnorableCount: 0,
allChangesFiles: 'tests/smoke/wallet/foo.spec.ts',
};

it('skips native builds for test-only PR changes', () => {
const result = computeE2EPlatformFlags(baseInput);

expect(result).toMatchObject({
android: true,
ios: true,
e2eNeeded: true,
nativeBuildNeeded: false,
runSmartE2ESelection: true,
message: expect.stringContaining('test-only'),
});
});

it('keeps native builds when app code changes', () => {
const result = computeE2EPlatformFlags({
...baseInput,
allChangesCount: 2,
e2eTestOrIgnorableCount: 1,
androidCount: 1,
androidOrIgnorableCount: 1,
allChangesFiles: 'app/components/Foo.tsx tests/smoke/wallet/foo.spec.ts',
});

expect(result.nativeBuildNeeded).toBe(true);
expect(result.android).toBe(true);
expect(result.ios).toBe(true);
});

it('skips E2E for ignorable-only changes', () => {
const result = computeE2EPlatformFlags({
...baseInput,
e2eTestFilesCount: 0,
ignorableCount: 1,
e2eTestOrIgnorableCount: 1,
allChangesFiles: 'README.md',
});

expect(result.e2eNeeded).toBe(false);
expect(result.nativeBuildNeeded).toBe(false);
expect(result.runSmartE2ESelection).toBe(false);
});

it('requires native builds when E2E workflow files change', () => {
const result = computeE2EPlatformFlags({
...baseInput,
e2eWorkflowsCount: 1,
});

expect(result.nativeBuildNeeded).toBe(true);
});
});
128 changes: 128 additions & 0 deletions .github/scripts/compute-e2e-platform-flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Pure decision logic for CI E2E platform and native-build requirements.
*/

export interface E2EPlatformFlagsInput {
githubEventName: string;
isFork: boolean;
shouldSkipE2E: boolean;
allChangesCount: number;
ignorableCount: number;
e2eTestFilesCount: number;
e2eTestOrIgnorableCount: number;
e2eWorkflowsCount: number;
androidCount: number;
iosCount: number;
androidOrIgnorableCount: number;
iosOrIgnorableCount: number;
allChangesFiles?: string;
}

export interface E2EPlatformFlagsResult {
android: boolean;
ios: boolean;
e2eNeeded: boolean;
nativeBuildNeeded: boolean;
runSmartE2ESelection: boolean;
message: string;
changedFiles: string;
}

export function computeE2EPlatformFlags(
input: E2EPlatformFlagsInput,
): E2EPlatformFlagsResult {
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,
};
}
Loading
Loading