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']);