Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
30 changes: 26 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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<<PUSH_DIFF_EOF"
echo "$files"
echo "PUSH_DIFF_EOF"
} >> "$GITHUB_OUTPUT"

# No install: the planner is dependency-free TypeScript that node strips.
- uses: actions/setup-node@v6
with:
Expand All @@ -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"

Expand Down
19 changes: 14 additions & 5 deletions scripts/ci/lanes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
54 changes: 54 additions & 0 deletions test/ci/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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']);
Expand Down
Loading