From d0c85f95b7c6be5ce7f63713e95e6045e9e85949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 22 Oct 2026 10:58:56 +0200 Subject: [PATCH] ci: skip device smoke lanes when a PR changes no lane input Android, Linux, macOS, and web smoke now use the affected-check model the iOS smoke already used. Unit tests own no device lane, so a test-only PR skips every device lane. Pushes to main and uncertain diffs still run all. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/android.yml | 19 +++- .github/workflows/apple.yml | 33 ++++-- .github/workflows/ci.yml | 16 ++- .github/workflows/linux.yml | 28 ++++- scripts/__tests__/apple-ci-impact.test.ts | 59 +++++++++- scripts/__tests__/device-lane-impact.test.ts | 92 ++++++++++++++++ scripts/apple-ci-impact.ts | 108 +++++++------------ scripts/device-lane-impact.ts | 89 +++++++++++++++ vitest.config.ts | 1 + 9 files changed, 357 insertions(+), 88 deletions(-) create mode 100644 scripts/__tests__/device-lane-impact.test.ts create mode 100644 scripts/device-lane-impact.ts diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index d498bc01b5..f96f7b93ab 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -41,13 +41,27 @@ jobs: - name: Setup toolchain uses: ./.github/actions/setup-node-pnpm + - name: Fetch the PR base for Android smoke selection + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: git fetch origin "$BASE_SHA" --depth=1 || echo "Base fetch failed; Android smoke selection will fail open." + + - name: Select Android smoke tests + id: impact + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: node --experimental-strip-types scripts/device-lane-impact.ts replay-android android-helpers + - name: Setup Android replay host + if: steps.impact.outputs.run != 'false' id: android-replay-host uses: ./.github/actions/setup-android-replay-host with: package-helpers: 'true' - name: Restore fixture APK + if: steps.impact.outputs.run != 'false' id: fixture-app uses: ./.github/actions/setup-fixture-app with: @@ -56,12 +70,15 @@ jobs: wait-for-artifact-seconds: '1800' - name: Report fixture cache source + if: steps.impact.outputs.run != 'false' run: echo "Android fixture APK source=${{ steps.fixture-app.outputs.source }}" - name: Run Android emulator catalog coverage contract + if: steps.impact.outputs.run != 'false' run: node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/smoke-android-emulator-coverage.test.ts - name: Run Android smoke checks + if: steps.impact.outputs.run != 'false' uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2.35.0 with: api-level: 36 @@ -79,7 +96,7 @@ jobs: node --experimental-strip-types src/bin.ts test test/integration/replays/android/emulator/01-settings.ad --retries 2 --report-junit test/artifacts/replays-android-smoke.junit.xml - name: Upload Android artifacts - if: always() + if: always() && steps.impact.outputs.run != 'false' uses: ./.github/actions/upload-agent-device-artifacts with: artifact-name: android-artifacts diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml index f56bb9dfbf..26b4f6021f 100644 --- a/.github/workflows/apple.yml +++ b/.github/workflows/apple.yml @@ -56,15 +56,20 @@ jobs: shell: bash run: | git fetch origin "$BASE_SHA" --depth=1 || echo "Base fetch failed; Apple selection will fail open." + - name: Select iOS smoke tests + id: ios-impact + shell: bash + run: node --experimental-strip-types scripts/apple-ci-impact.ts smoke + - name: Select macOS smoke tests + id: macos-impact + shell: bash + run: node --experimental-strip-types scripts/apple-ci-impact.ts macos - name: Build the shared CLI id: build + if: steps.ios-impact.outputs.run != 'false' || steps.macos-impact.outputs.run != 'false' uses: ./.github/actions/run-gate with: gate: build - - name: Select iOS smoke tests - id: ios-impact - shell: bash - run: node --experimental-strip-types scripts/apple-ci-impact.ts smoke # The macOS clean-install proof may raise permission UI. Run the iOS focus proof first. - name: iOS smoke tests id: ios @@ -101,7 +106,7 @@ jobs: RESULT_BUNDLE_PATH: ${{ github.workspace }}/.tmp/xctest-host/RunnerTests.xcresult RESULT_SUMMARY_PATH: ${{ github.workspace }}/.tmp/xctest-host/test-results-summary.json XCTEST_LANE: host - if: ${{ !cancelled() && steps.build.outcome == 'success' && steps.ios-cleanup.outcome == 'success' }} + if: ${{ !cancelled() && steps.build.outcome == 'success' && steps.ios-cleanup.outcome == 'success' && steps.macos-impact.outputs.run != 'false' }} - name: Diagnose the Apple host after a phase failure if: ${{ always() && (steps.ios.outcome == 'failure' || steps.macos.outcome == 'failure' || steps.ios-cleanup.outcome == 'failure') }} continue-on-error: true @@ -119,11 +124,12 @@ jobs: shell: bash run: | pnpm clean:daemon - - name: Require both Apple phases to succeed + - name: Require the selected Apple phases to succeed if: always() env: IOS_SELECTED: ${{ steps.ios-impact.outputs.run }} IOS_OUTCOME: ${{ steps.ios.outcome }} + MACOS_SELECTED: ${{ steps.macos-impact.outputs.run }} MACOS_OUTCOME: ${{ steps.macos.outcome }} IOS_CLEANUP_OUTCOME: ${{ steps.ios-cleanup.outcome }} MACOS_CLEANUP_OUTCOME: ${{ steps.macos-cleanup.outcome }} @@ -136,9 +142,18 @@ jobs: else test "$IOS_OUTCOME" = success fi - test "$MACOS_OUTCOME" = success - test "$IOS_CLEANUP_OUTCOME" = success - test "$MACOS_CLEANUP_OUTCOME" = success + if [ "$MACOS_SELECTED" = false ]; then + test "$MACOS_OUTCOME" = skipped + else + test "$MACOS_OUTCOME" = success + fi + if [ "$IOS_SELECTED" = false ] && [ "$MACOS_SELECTED" = false ]; then + test "$IOS_CLEANUP_OUTCOME" = skipped + test "$MACOS_CLEANUP_OUTCOME" = skipped + else + test "$IOS_CLEANUP_OUTCOME" = success + test "$MACOS_CLEANUP_OUTCOME" = success + fi - name: Upload Apple artifacts if: always() uses: ./.github/actions/upload-agent-device-artifacts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47345f5942..53bdd1ae10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -395,21 +395,35 @@ jobs: uses: ./.github/actions/run-gate with: { gate: fixture-cache } + - name: Fetch the PR base for web smoke selection + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: git fetch origin "$BASE_SHA" --depth=1 || echo "Base fetch failed; web smoke selection will fail open." + + - name: Select web smoke tests + id: web-impact + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: node --experimental-strip-types scripts/device-lane-impact.ts web-smoke + # Shares this job's ubuntu toolchain. AGENT_DEVICE_WEB_E2E is step-scoped # so it cannot leak into the node/provider suites above. - name: Run live web smoke + if: steps.web-impact.outputs.run != 'false' env: AGENT_DEVICE_WEB_E2E: '1' run: pnpm clean:daemon - name: Execute live web smoke + if: steps.web-impact.outputs.run != 'false' env: AGENT_DEVICE_WEB_E2E: '1' uses: ./.github/actions/run-gate with: { gate: web-smoke } - name: Upload web smoke artifacts - if: always() + if: always() && steps.web-impact.outputs.run != 'false' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: web-smoke-artifacts diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 5b320dccbf..aa807b9ee3 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -46,6 +46,21 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup toolchain + uses: ./.github/actions/setup-node-pnpm + + - name: Fetch the PR base for Linux smoke selection + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: git fetch origin "$BASE_SHA" --depth=1 || echo "Base fetch failed; Linux smoke selection will fail open." + + - name: Select Linux smoke tests + id: impact + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: node --experimental-strip-types scripts/device-lane-impact.ts replay-linux linux-command-evidence + # Unbounded, this step could not fail — it could only stall. On 2026-08-19 a slow mirror # held apt past the job's 30-minute budget on four main-branch runs and several unrelated # PRs, which reported as a red check on branches that never ran a line of project code. @@ -54,6 +69,7 @@ jobs: # that drips bytes slowly enough to defeat the socket timeout. A healthy install takes # about a minute, so six is generous. - name: Install Linux desktop dependencies + if: steps.impact.outputs.run != 'false' timeout-minutes: 6 run: | set -euo pipefail @@ -77,10 +93,8 @@ jobs: gnome-calculator \ wmctrl - - name: Setup toolchain - uses: ./.github/actions/setup-node-pnpm - - name: Start Xvfb and D-Bus + if: steps.impact.outputs.run != 'false' run: | # Start virtual framebuffer (1280x1024, 24-bit color) Xvfb :99 -screen 0 1280x1024x24 & @@ -93,6 +107,7 @@ jobs: echo "DBUS_SESSION_BUS_PID=$DBUS_SESSION_BUS_PID" >> "$GITHUB_ENV" - name: Start AT-SPI2 registry + if: steps.impact.outputs.run != 'false' run: | # The registry must start AFTER DBUS_SESSION_BUS_ADDRESS is available # (it was written to GITHUB_ENV in the previous step). @@ -112,6 +127,7 @@ jobs: fi - name: Verify environment + if: steps.impact.outputs.run != 'false' run: | echo "=== Display ===" xdotool getdisplaygeometry @@ -125,9 +141,11 @@ jobs: xdotool version - name: Run Linux replay smoke test + if: steps.impact.outputs.run != 'false' run: pnpm clean:daemon - name: Execute Linux replay smoke test + if: steps.impact.outputs.run != 'false' uses: ./.github/actions/run-gate with: gate: replay-linux @@ -138,15 +156,17 @@ jobs: test/artifacts/replays-linux.junit.xml - name: Reset daemon before Linux command evidence + if: steps.impact.outputs.run != 'false' run: pnpm clean:daemon - name: Execute Linux command evidence + if: steps.impact.outputs.run != 'false' uses: ./.github/actions/run-gate with: gate: linux-command-evidence - name: Upload Linux artifacts - if: always() + if: always() && steps.impact.outputs.run != 'false' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: linux-artifacts diff --git a/scripts/__tests__/apple-ci-impact.test.ts b/scripts/__tests__/apple-ci-impact.test.ts index ea1d8eca06..e4b0413b81 100644 --- a/scripts/__tests__/apple-ci-impact.test.ts +++ b/scripts/__tests__/apple-ci-impact.test.ts @@ -5,7 +5,12 @@ import path from 'node:path'; import { expect, test } from 'vitest'; import { parse } from 'yaml'; import { mkdtempForTestSync } from '@agent-device/test-kit'; -import { selectAppleBridgeProof, selectIosSmoke, selectIosXctests } from '../apple-ci-impact.ts'; +import { + selectAppleBridgeProof, + selectIosSmoke, + selectIosXctests, + selectMacosSmoke, +} from '../apple-ci-impact.ts'; import { selectChecks } from '../check-affected/model.ts'; const repoRoot = path.resolve(import.meta.dirname, '../..'); @@ -226,12 +231,32 @@ test.each([ ['macOS silently skipped', { MACOS_OUTCOME: 'skipped' }, 1], ['iOS cleanup fails', { IOS_CLEANUP_OUTCOME: 'failure' }, 1], ['macOS cleanup fails', { MACOS_CLEANUP_OUTCOME: 'failure' }, 1], + ['macOS not selected', { MACOS_SELECTED: 'false', MACOS_OUTCOME: 'skipped' }, 0], + ['unselected macOS unexpectedly ran', { MACOS_SELECTED: 'false' }, 1], + [ + 'no phase selected', + { + IOS_SELECTED: 'false', + IOS_OUTCOME: 'skipped', + MACOS_SELECTED: 'false', + MACOS_OUTCOME: 'skipped', + IOS_CLEANUP_OUTCOME: 'skipped', + MACOS_CLEANUP_OUTCOME: 'skipped', + }, + 0, + ], + [ + 'cleanup skipped while a phase ran', + { IOS_SELECTED: 'false', IOS_OUTCOME: 'skipped', MACOS_CLEANUP_OUTCOME: 'skipped' }, + 1, + ], ] as const)('the final Apple status guard preserves failure: %s', (_label, overrides, expected) => { const guard = appleJob().steps.find((step) => step.env?.IOS_OUTCOME)!; expect(guard.if).toBe('always()'); expect(guard.env).toEqual({ IOS_SELECTED: '${{ steps.ios-impact.outputs.run }}', IOS_OUTCOME: '${{ steps.ios.outcome }}', + MACOS_SELECTED: '${{ steps.macos-impact.outputs.run }}', MACOS_OUTCOME: '${{ steps.macos.outcome }}', IOS_CLEANUP_OUTCOME: '${{ steps.ios-cleanup.outcome }}', MACOS_CLEANUP_OUTCOME: '${{ steps.macos-cleanup.outcome }}', @@ -241,6 +266,7 @@ test.each([ ...process.env, IOS_SELECTED: 'true', IOS_OUTCOME: 'success', + MACOS_SELECTED: 'true', MACOS_OUTCOME: 'success', IOS_CLEANUP_OUTCOME: 'success', MACOS_CLEANUP_OUTCOME: 'success', @@ -280,6 +306,35 @@ test('iOS smoke uses affected-check ownership and fails open on uncertain diffs' expect(selectIosSmoke('push', ['src/foo.test.ts']).run).toBe(true); }); +test('macOS smoke uses affected-check ownership and keeps the bridge proof inputs', () => { + const steps = appleJob().steps; + const select = steps.find((step) => step.id === 'macos-impact')!; + const build = steps.find((step) => step.id === 'build')!; + const macos = steps.find((step) => step.id === 'macos')!; + expect(select.run).toContain('scripts/apple-ci-impact.ts macos'); + expect(steps.indexOf(select)).toBeLessThan(steps.indexOf(build)); + expect(build.if).toContain("steps.macos-impact.outputs.run != 'false'"); + expect(macos.if).toContain("steps.macos-impact.outputs.run != 'false'"); + for (const file of [ + 'src/foo.test.ts', + 'packages/daemon/src/sessions/__tests__/session.test.ts', + 'packages/platform-android/src/index.ts', + 'test/integration/replays/ios/basic.ad', + ]) { + expect(selectMacosSmoke('pull_request', [file]).run, file).toBe(false); + } + for (const file of [ + 'packages/platform-apple/src/foldable/fold-helper-cache.test.ts', + 'packages/platform-apple/src/index.ts', + 'src/index.ts', + '.github/actions/smoke-macos/action.yml', + ]) { + expect(selectMacosSmoke('pull_request', [file]).run, file).toBe(true); + } + expect(selectMacosSmoke('pull_request', null).run).toBe(true); + expect(selectMacosSmoke('push', ['src/foo.test.ts']).run).toBe(true); +}); + function cacheInputs(action: string, stepId = 'source-hash'): string[] { const doc = parse(action) as { runs?: { steps?: Array<{ id?: string; run?: string }> } }; const hashStep = doc.runs?.steps?.find((step) => step.id === stepId)?.run ?? ''; @@ -542,7 +597,7 @@ test('a shallow PR merge still yields a known change set for all Apple selectors ); expect(select('xctest')).toContain('skip;'); expect(select('bridge')).toContain('skip;'); - expect(select('smoke')).toContain('run; iOS device-lane input changed: src/feature.ts'); + expect(select('smoke')).toContain('run; replay-ios input changed: src/feature.ts'); } finally { fs.rmSync(root, { recursive: true, force: true }); } diff --git a/scripts/__tests__/device-lane-impact.test.ts b/scripts/__tests__/device-lane-impact.test.ts new file mode 100644 index 0000000000..a8fd631c96 --- /dev/null +++ b/scripts/__tests__/device-lane-impact.test.ts @@ -0,0 +1,92 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { parse } from 'yaml'; +import type { CheckId } from '../check-affected/model.ts'; +import { selectDeviceLane } from '../device-lane-impact.ts'; + +const repoRoot = path.resolve(import.meta.dirname, '../..'); + +type Step = { id?: string; name?: string; if?: string; run?: string }; + +function jobSteps(workflow: string, job: string): Step[] { + const doc = parse( + fs.readFileSync(path.join(repoRoot, '.github/workflows', workflow), 'utf8'), + ) as { + jobs: Record; + }; + return doc.jobs[job]!.steps; +} + +const LANES = { + android: ['replay-android', 'android-helpers'], + linux: ['replay-linux', 'linux-command-evidence'], + web: ['web-smoke'], +} as const satisfies Record; + +function select(target: keyof typeof LANES, changedPaths: string[] | null) { + return selectDeviceLane('pull_request', changedPaths, LANES[target]).run; +} + +test('a unit-test-only PR skips every device lane', () => { + const unitTests = [ + 'src/foo.test.ts', + 'packages/daemon/src/sessions/__tests__/session.test.ts', + 'packages/platform-android/src/snapshot.test.ts', + 'packages/platform-linux/src/__tests__/atspi.test.ts', + ]; + for (const target of ['android', 'linux', 'web'] as const) { + expect(select(target, unitTests), target).toBe(false); + } +}); + +test.each([ + ['android', 'packages/platform-android/src/index.ts', true], + ['android', 'test/integration/smoke-android-emulator.test.ts', true], + ['android', 'examples/test-app/src/App.tsx', true], + ['android', 'packages/platform-linux/src/index.ts', false], + ['linux', 'packages/platform-linux/src/index.ts', true], + ['linux', 'linux/atspi-dump.py', true], + ['linux', 'packages/platform-android/src/index.ts', false], + ['web', 'packages/platform-web/src/index.ts', true], + ['web', 'packages/platform-android/src/index.ts', false], +] as const)('%s smoke selection for %s is %s', (target, file, run) => { + expect(select(target, [file])).toBe(run); +}); + +test('shared runtime, uncertain, and push changes run every device lane', () => { + for (const target of ['android', 'linux', 'web'] as const) { + expect(select(target, ['src/index.ts']), target).toBe(true); + expect(select(target, ['src/foo.test.ts', 'package.json']), target).toBe(true); + expect(select(target, null), target).toBe(true); + expect(select(target, []), target).toBe(true); + expect(selectDeviceLane('push', ['src/foo.test.ts'], LANES[target]).run, target).toBe(true); + } +}); + +const WEB_SMOKE_STEPS = [ + 'Run live web smoke', + 'Execute live web smoke', + 'Upload web smoke artifacts', +]; + +test.each([ + ['android.yml', 'smoke-android', 'impact', 'android', null], + ['linux.yml', 'smoke-linux', 'impact', 'linux', null], + ['ci.yml', 'integration', 'web-impact', 'web', WEB_SMOKE_STEPS], +] as const)('%s gates its device steps on the %s selection', (workflow, job, id, target, names) => { + const steps = jobSteps(workflow, job); + const selection = steps.findIndex((step) => step.id === id); + expect(steps[selection]?.run).toContain( + `scripts/device-lane-impact.ts ${LANES[target].join(' ')}`, + ); + // Null means every step after the selection belongs to the lane. + const gated = names + ? names.map((name) => steps.find((step) => step.name === name)) + : steps.slice(selection + 1); + expect(gated.length).toBeGreaterThan(0); + for (const step of gated) { + expect(steps.indexOf(step!), step?.name).toBeGreaterThan(selection); + expect(step?.if, step?.name).toContain(`steps.${id}.outputs.run != 'false'`); + } +}); diff --git a/scripts/apple-ci-impact.ts b/scripts/apple-ci-impact.ts index 3afb2c8515..dbd6196624 100644 --- a/scripts/apple-ci-impact.ts +++ b/scripts/apple-ci-impact.ts @@ -1,58 +1,40 @@ -import fs from 'node:fs'; -import { spawnSync } from 'node:child_process'; import { pathToFileURL } from 'node:url'; -import { selectChecks } from './check-affected/model.ts'; - -type Selection = { run: boolean; reason: string }; +import { + checkOwnership, + pullRequestChangedPaths, + reportSelection, + selectDeviceLane, + type Selection, +} from './device-lane-impact.ts'; export function selectIosSmoke( eventName: string, changedPaths: readonly string[] | null, ): Selection { - if (eventName !== 'pull_request') return { run: true, reason: 'main runs exercise iOS smoke' }; - const checked = checkOwnership(changedPaths); - if (checked.early) return checked.early; - const input = checked.plan.reasons.find( - (entry) => entry.check === 'swift-runner-ios' || entry.check === 'replay-ios', - ); - return input - ? { run: true, reason: `iOS device-lane input changed: ${input.path}` } - : { run: false, reason: 'the affected-check model selected no iOS device lane' }; + return selectDeviceLane(eventName, changedPaths, ['swift-runner-ios', 'replay-ios']); } -function checkOwnership( +// The macOS phase also hosts the darwin-only bridge proof, so its inputs keep the phase. +export function selectMacosSmoke( + eventName: string, changedPaths: readonly string[] | null, -): { early: Selection; plan?: never } | { early?: never; plan: ReturnType } { - if (changedPaths === null) { - return { early: { run: true, reason: 'the PR change set could not be established' } }; - } - if (changedPaths.length === 0) { - return { early: { run: true, reason: 'the PR diff is empty' } }; - } - const plan = selectChecks({ changedFiles: changedPaths }); - if (plan.failOpen) { - return { - early: { - run: true, - reason: `affected-check ownership is uncertain: ${plan.failOpenReasons[0]?.path}`, - }, - }; - } - return { plan }; +): Selection { + const lanes = selectDeviceLane(eventName, changedPaths, [ + 'macos-coverage', + 'packaged-runner-swift', + 'ios-snapshot-differential', + 'swift-runner-macos', + 'macos-helper', + 'replay-macos', + ]); + return lanes.run ? lanes : selectAppleBridgeProof(changedPaths); } export function selectIosXctests( eventName: string, changedPaths: readonly string[] | null, ): Selection { - if (eventName !== 'pull_request') - return { run: true, reason: 'main and manual runs exercise XCTest' }; - const checked = checkOwnership(changedPaths); - if (checked.early) return checked.early; - const input = checked.plan.reasons.find((entry) => entry.check === 'swift-runner-ios'); - return input - ? { run: true, reason: `XCTest input changed: ${input.path}` } - : { run: false, reason: 'the affected-check model selected no iOS runner build' }; + return selectDeviceLane(eventName, changedPaths, ['swift-runner-ios']); } export function selectAppleBridgeProof(changedPaths: readonly string[] | null): Selection { @@ -66,41 +48,25 @@ export function selectAppleBridgeProof(changedPaths: readonly string[] | null): : { run: false, reason: 'the PR changed no Apple implementation or gate tooling' }; } -function changedPathsFromGit(baseSha: string): string[] | null { - if (!/^[a-f0-9]{40}$/.test(baseSha)) return null; - const result = spawnSync('git', ['diff', '--name-only', '-z', baseSha, 'HEAD'], { - encoding: 'utf8', - }); - if (result.error || result.status !== 0 || result.stdout === null) return null; - return result.stdout.split('\0').filter(Boolean); -} +const TARGETS = { + smoke: { label: 'iOS smoke', select: selectIosSmoke }, + macos: { label: 'macOS smoke', select: selectMacosSmoke }, + xctest: { label: 'iOS XCTest', select: selectIosXctests }, + bridge: { + label: 'Apple bridge proof', + select: (_eventName: string, changedPaths: readonly string[] | null) => + selectAppleBridgeProof(changedPaths), + }, +} as const; if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { - const target = process.argv[2]; - if (target !== 'xctest' && target !== 'bridge' && target !== 'smoke') { - process.stderr.write('Expected xctest, bridge, or smoke target\n'); + const target = process.argv[2] ?? ''; + if (!Object.hasOwn(TARGETS, target)) { + process.stderr.write(`Expected one of: ${Object.keys(TARGETS).join(', ')}\n`); process.exitCode = 2; } else { + const { label, select } = TARGETS[target as keyof typeof TARGETS]; const eventName = process.env.GITHUB_EVENT_NAME ?? ''; - const changedPaths = - eventName === 'pull_request' ? changedPathsFromGit(process.env.BASE_SHA ?? '') : []; - const selection = - target === 'smoke' - ? selectIosSmoke(eventName, changedPaths) - : target === 'xctest' - ? selectIosXctests(eventName, changedPaths) - : selectAppleBridgeProof(changedPaths); - const label = - target === 'smoke' ? 'iOS smoke' : target === 'xctest' ? 'iOS XCTest' : 'Apple bridge proof'; - process.stdout.write(`${label}: ${selection.run ? 'run' : 'skip'}; ${selection.reason}\n`); - if (process.env.GITHUB_OUTPUT) { - fs.appendFileSync(process.env.GITHUB_OUTPUT, `run=${selection.run}\n`); - } - if (process.env.GITHUB_STEP_SUMMARY) { - fs.appendFileSync( - process.env.GITHUB_STEP_SUMMARY, - `### ${label} selection\n\n${selection.run ? 'Run' : 'Skip'}: ${selection.reason}.\n`, - ); - } + reportSelection(label, select(eventName, pullRequestChangedPaths(eventName))); } } diff --git a/scripts/device-lane-impact.ts b/scripts/device-lane-impact.ts new file mode 100644 index 0000000000..8fa04bc660 --- /dev/null +++ b/scripts/device-lane-impact.ts @@ -0,0 +1,89 @@ +import fs from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { ALL_CHECKS, selectChecks, type CheckId } from './check-affected/model.ts'; + +export type Selection = { run: boolean; reason: string }; + +type Plan = ReturnType; + +export function checkOwnership( + changedPaths: readonly string[] | null, +): { early: Selection; plan?: never } | { early?: never; plan: Plan } { + if (changedPaths === null) { + return { early: { run: true, reason: 'the PR change set could not be established' } }; + } + if (changedPaths.length === 0) { + return { early: { run: true, reason: 'the PR diff is empty' } }; + } + const plan = selectChecks({ changedFiles: changedPaths }); + if (plan.failOpen) { + return { + early: { + run: true, + reason: `affected-check ownership is uncertain: ${plan.failOpenReasons[0]?.path}`, + }, + }; + } + return { plan }; +} + +// A PR runs a device lane only when the affected-check model selects one of its checks. +// Unit tests own no device lane, so a test-only PR skips every lane. Pushes run every lane. +export function selectDeviceLane( + eventName: string, + changedPaths: readonly string[] | null, + checks: readonly CheckId[], +): Selection { + if (eventName !== 'pull_request') return { run: true, reason: 'main runs exercise every lane' }; + const checked = checkOwnership(changedPaths); + if (checked.early) return checked.early; + const input = checked.plan.reasons.find((entry) => checks.includes(entry.check)); + return input + ? { run: true, reason: `${input.check} input changed: ${input.path}` } + : { run: false, reason: `the affected-check model selected none of ${checks.join(', ')}` }; +} + +function changedPathsFromGit(baseSha: string): string[] | null { + if (!/^[a-f0-9]{40}$/.test(baseSha)) return null; + const result = spawnSync('git', ['diff', '--name-only', '-z', baseSha, 'HEAD'], { + encoding: 'utf8', + }); + if (result.error || result.status !== 0 || result.stdout === null) return null; + return result.stdout.split('\0').filter(Boolean); +} + +export function pullRequestChangedPaths(eventName: string): string[] | null { + return eventName === 'pull_request' ? changedPathsFromGit(process.env.BASE_SHA ?? '') : []; +} + +export function reportSelection(label: string, selection: Selection): void { + process.stdout.write(`${label}: ${selection.run ? 'run' : 'skip'}; ${selection.reason}\n`); + if (process.env.GITHUB_OUTPUT) { + fs.appendFileSync(process.env.GITHUB_OUTPUT, `run=${selection.run}\n`); + } + if (process.env.GITHUB_STEP_SUMMARY) { + fs.appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + `### ${label} selection\n\n${selection.run ? 'Run' : 'Skip'}: ${selection.reason}.\n`, + ); + } +} + +function isCheckId(value: string): value is CheckId { + return (ALL_CHECKS as readonly string[]).includes(value); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + const checks = process.argv.slice(2); + if (checks.length === 0 || !checks.every(isCheckId)) { + process.stderr.write(`Expected affected-check ids, got: ${checks.join(' ') || 'nothing'}\n`); + process.exitCode = 2; + } else { + const eventName = process.env.GITHUB_EVENT_NAME ?? ''; + reportSelection( + 'Device lane', + selectDeviceLane(eventName, pullRequestChangedPaths(eventName), checks), + ); + } +} diff --git a/vitest.config.ts b/vitest.config.ts index b5a54e80f7..d34b319001 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -159,6 +159,7 @@ export default defineConfig({ 'scripts/__tests__/xctest-declarations.test.ts', 'scripts/__tests__/xctest-selection.test.ts', 'scripts/__tests__/apple-ci-impact.test.ts', + 'scripts/__tests__/device-lane-impact.test.ts', // The nightly XCTest lane's reporter/liveness check, which otherwise only ever // executes on a macOS runner at 04:30. 'scripts/__tests__/xctest-run-summary.test.ts',