From d907cde477158be96235b2c7acbeff9bd7c4d3d0 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 4 Sep 2026 23:33:25 +0200 Subject: [PATCH] ci: narrow branch pushes to main/next by their changed files, fail closed planCi now treats a branch push the same way it treats a pull request: it narrows to the areas selectAreas derives from the event's changed files, provided that list resolved to at least one file. An empty list - an unresolved before..after diff (new branch, force-push with no shared history) or a diff that genuinely touched nothing - keeps the existing full-validation fallback, so the push stays fail closed rather than skipping lanes it cannot account for. ci.yml gains a push counterpart to the pull request's changed-file step: it resolves before via a shallow fetch and diffs it against the pushed commit, leaving the file list empty (and the plan job falling back to full validation) whenever that fetch or diff fails. Tag pushes and workflow_dispatch are untouched - they never reach the selective branch. Claude-Session: https://claude.ai/code/session_01NSrXpH1WyqP7udpWWhHz1i --- .github/workflows/ci.yml | 30 +++++++++++++++++++--- scripts/ci/lanes.ts | 19 ++++++++++---- test/ci/plan.test.ts | 54 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0e38510a..538c2b7a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,11 @@ name: CI # └─► site ─► smoke # all ─► verdict (the required check) # -# A pull request runs the lanes its changed files require; a push, a tag or a -# dispatch runs every lane. Coverage is collected on pushes to the long-lived -# branches only, so a pull request's unit lane runs uninstrumented. +# A pull request runs the lanes its changed files require; a branch push to +# main/next narrows the same way from its before..after diff. An unresolved +# push diff, a tag or a manual dispatch runs every lane. Coverage is +# collected on pushes to the long-lived branches only, so a pull request's +# unit lane runs uninstrumented. on: push: @@ -64,6 +66,26 @@ jobs: all: - '**' + # `before` is unresolvable for a new branch or a force-push with no + # shared history, and the fetch below can fail for the same reason - + # both leave `files` empty, which `planCi` treats as fail-closed (full + # validation), not as "nothing changed". + - if: github.event_name == 'push' + id: push-diff + run: | + before="${{ github.event.before }}" + files="" + if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then + if git fetch --no-tags --depth=1 origin "$before" 2>/dev/null; then + files="$(git diff --name-only "$before" "${{ github.sha }}" 2>/dev/null || true)" + fi + fi + { + echo "files<> "$GITHUB_OUTPUT" + # No install: the planner is dependency-free TypeScript that node strips. - uses: actions/setup-node@v6 with: @@ -72,7 +94,7 @@ jobs: - id: plan env: EVENT_NAME: ${{ github.event_name }} - CHANGED_FILES: ${{ steps.changed.outputs.all_files }} + CHANGED_FILES: ${{ github.event_name == 'pull_request' && steps.changed.outputs.all_files || steps.push-diff.outputs.files }} REF_NAME: ${{ github.ref_name }} run: node scripts/ci/lanes.ts >> "$GITHUB_OUTPUT" diff --git a/scripts/ci/lanes.ts b/scripts/ci/lanes.ts index f1bd847c2..b3808ab68 100644 --- a/scripts/ci/lanes.ts +++ b/scripts/ci/lanes.ts @@ -199,7 +199,13 @@ export interface CiPlan { export interface PlanInput { eventName: string; - /** Files a pull request changed; ignored on every other event. */ + /** + * Files the triggering event changed: a pull request's full diff, or a + * branch push's `before..after` range. Empty means the diff is unknown (a + * new branch, a force-push with no shared history) or genuinely touched + * nothing; either way `planCi` falls back to full validation for a push. + * Ignored for every other event, which always validates everything. + */ changedFiles: readonly string[]; /** Branch the event ran on; coverage is collected on the long-lived ones. */ refName: string; @@ -235,13 +241,16 @@ export const selectLanes = (effective: EffectiveLanes, isPullRequest: boolean): LANES.filter(lane => lane.when === 'always' || effective[lane.when]).filter(lane => !lane.pullRequestOnly || isPullRequest); /** - * Everything `ci.yml` needs to know, from the event alone. A push, a tag or a - * dispatch validates every area; only a pull request narrows to what it - * changed. + * Everything `ci.yml` needs to know, from the event alone. A pull request + * always narrows to what it changed; a branch push to `main`/`next` narrows + * the same way whenever its diff resolved to at least one file. Anything + * else - an unresolved push diff, `merge_group`, a tag/release or a manual + * dispatch - validates every area, fail closed. */ export const planCi = ({ eventName, changedFiles, refName }: PlanInput): CiPlan => { const isPullRequest = eventName === 'pull_request'; - const areas = isPullRequest ? selectAreas(changedFiles) : ALL_AREAS; + const isSelectivePush = eventName === 'push' && changedFiles.length > 0; + const areas = isPullRequest || isSelectivePush ? selectAreas(changedFiles) : ALL_AREAS; const effective = effectiveLanes(areas); const coverage = eventName === 'push' && COVERAGE_BRANCHES.has(refName); const lanes = selectLanes(effective, isPullRequest); diff --git a/test/ci/plan.test.ts b/test/ci/plan.test.ts index 0c82fb317..42a61bf90 100644 --- a/test/ci/plan.test.ts +++ b/test/ci/plan.test.ts @@ -15,6 +15,7 @@ const repoRoot = resolve(import.meta.dirname!, '../..'); const workflow = readFileSync(resolve(repoRoot, '.github/workflows/ci.yml'), 'utf8'); const pullRequest = (changedFiles: string[]): CiPlan => planCi({ eventName: 'pull_request', changedFiles, refName: 'feature' }); +const push = (changedFiles: string[], refName = 'next'): CiPlan => planCi({ eventName: 'push', changedFiles, refName }); const ids = (entries: Array<{ id: string }>): string[] => entries.map(entry => entry.id); describe('lane table', () => { @@ -57,6 +58,59 @@ describe('plan for a push to a long-lived branch', () => { }); }); +describe('plan for a push whose before..after diff did not resolve', () => { + // The workflow leaves `changedFiles` empty both when `before` is unknown (a + // new branch, a force-push with no shared history) and when the diff ran but + // legitimately touched nothing; `planCi` cannot tell those apart and must not + // skip validation for either, on any branch. + it('validates every area, regardless of branch', () => { + const plan = planCi({ eventName: 'push', changedFiles: [], refName: 'feature-x' }); + expect(Object.values(plan.areas).every(Boolean)).toBe(true); + expect(ids(plan.test)).toEqual(['unit', 'webgl', 'webgpu', 'firefox', 'audio', 'tilemap', 'bench']); + }); +}); + +describe('plan for a push with a resolved diff', () => { + it('narrows to the engine lanes for an engine change, same as a pull request', () => { + const plan = push(['src/rendering/webgl2/backend.ts']); + expect(ids(plan.test)).toEqual(['unit', 'webgl', 'webgpu', 'firefox', 'bench']); + expect(ids(plan.verify)).toEqual(['package']); + expect(plan).toMatchObject({ build: true, site: true, smoke: true, smokeSample: false }); + }); + + it('builds the site without the engine lanes for a site-only change', () => { + const plan = push(['site/src/pages/index.astro']); + expect(plan.test).toEqual([]); + expect(plan).toMatchObject({ build: true, site: true, smoke: true }); + }); + + it('runs the engine lanes without the site build for an engine-test-only change', () => { + const plan = push(['test/rendering/webgl2/backend.test.ts']); + expect(ids(plan.test)).toContain('unit'); + expect(plan).toMatchObject({ site: false, smoke: false }); + }); + + it('runs only the gates for a docs-only change', () => { + const plan = push(['README.md']); + expect(ids(plan.gates)).toEqual(['typecheck', 'lint', 'sync']); + expect(plan.test).toEqual([]); + expect(plan.verify).toEqual([]); + expect(plan).toMatchObject({ build: false, site: false, smoke: false }); + }); + + it('never enables the pull-request-only release lane', () => { + expect(ids(push(['scripts/release/prepare.ts']).verify)).toEqual(['package']); + }); +}); + +describe('plan for events that always validate everything', () => { + it('ignores a changed-file list on a manual dispatch', () => { + const plan = planCi({ eventName: 'workflow_dispatch', changedFiles: ['README.md'], refName: 'next' }); + expect(plan.areas).toEqual(planCi({ eventName: 'workflow_dispatch', changedFiles: [], refName: 'next' }).areas); + expect(ids(plan.test)).toEqual(['unit', 'webgl', 'webgpu', 'firefox', 'audio', 'tilemap', 'bench']); + }); +}); + describe('plan for a pull request', () => { it('runs everything for an engine change, uninstrumented, with a sampled smoke', () => { const plan = pullRequest(['src/rendering/webgl2/backend.ts']);