From 29cb64e6e2fa337998933e5d438ec27e118cf3ee Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 31 Oct 2026 12:55:32 -0700 Subject: [PATCH 1/5] ci: an agent lane's push runs the fast checks; the heavy suites run when ready and before merge (AST-030 FR20, DEC-14) --- .github/scripts/ci-docsite-browser.test.mjs | 7 +- .github/scripts/ci-heavy-deferral.test.mjs | 322 ++++++++++++++++++ .../scripts/ci-interaction-webkit.test.mjs | 2 +- .github/scripts/ci-interaction.test.mjs | 2 +- .github/scripts/ci-pr-gate.mjs | 40 ++- .github/scripts/ci-test-routing.test.mjs | 2 +- .github/scripts/ci-tooling-routing.test.mjs | 7 +- .github/scripts/spec-owner-workflow.test.mjs | 2 +- .github/workflows/ci.yml | 110 +++++- .github/workflows/pr-comment.yml | 16 + CONTRIBUTING.md | 5 + docs/specs/AST-030/spec.md | 32 ++ 12 files changed, 521 insertions(+), 26 deletions(-) create mode 100644 .github/scripts/ci-heavy-deferral.test.mjs diff --git a/.github/scripts/ci-docsite-browser.test.mjs b/.github/scripts/ci-docsite-browser.test.mjs index dd8e0c6915a45..a117b6e2eee8c 100644 --- a/.github/scripts/ci-docsite-browser.test.mjs +++ b/.github/scripts/ci-docsite-browser.test.mjs @@ -29,20 +29,21 @@ describe('docsite-test', () => { it('is one job that builds the docsite once and runs both suites on it', () => { expect(jobs['docsite-data']).toBeUndefined(); expect(jobs['docsite-browser']).toBeUndefined(); - expect(owner.needs).toBeUndefined(); + // It needs only the heavy-suite deferral (spec:AST-030 FR20). + expect(owner.needs).toEqual(['heavy-ci']); expect(owner['continue-on-error']).toBeUndefined(); // A required context keeps only the draft skip in its condition; an // outside contributor's run stops at the first step with the reason. expect(owner.if).toBe( gated( - "${{ github.event_name != 'workflow_dispatch' && !startsWith(github.base_ref, 'release/v') }}", + "${{ !cancelled() && github.event_name != 'workflow_dispatch' && !startsWith(github.base_ref, 'release/v') }}", PR_REPORT_GATE, ), ); expect(owner.steps[0].name).toBe( 'Require a branch in this repository or the ci:run label', ); - expect(owner.steps.findIndex(s => s.id === 'scope')).toBe(2); + expect(owner.steps.findIndex(s => s.id === 'scope')).toBe(3); const builds = owner.steps.filter(s => /pnpm -F @astryxdesign\/docsite build/.test(s.run ?? ''), diff --git a/.github/scripts/ci-heavy-deferral.test.mjs b/.github/scripts/ci-heavy-deferral.test.mjs new file mode 100644 index 0000000000000..4de19fc0d4e20 --- /dev/null +++ b/.github/scripts/ci-heavy-deferral.test.mjs @@ -0,0 +1,322 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file An agent lane's push defers the heavy suites (spec:AST-030 FR20, + * DEC-14). + * + * @input ci.yml, lint.yml and pr-comment.yml, evaluated as Actions would for + * each kind of push + * @output proofs that a deferred push runs only the fast checks, that every + * required context it reaches fails with the reason instead of passing as + * skipped, and that every other push, the ready and reopen events and any + * later attempt run everything + * @position Node contract for the `heavy-ci` decision and the jobs it gates + */ + +import {spawnSync} from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import {describe, expect, it} from 'vitest'; +import yaml from 'yaml'; + +import { + HEAVY_CANDIDATE, + HEAVY_DEFERRED, + HEAVY_DEFERRED_MESSAGE, + HEAVY_RUNS, + ungated, +} from './ci-pr-gate.mjs'; + +const read = name => + yaml.parse( + fs.readFileSync( + path.resolve(import.meta.dirname, '../workflows', name), + 'utf8', + ), + ); +const ci = read('ci.yml'); +const lint = read('lint.yml'); +const prComment = read('pr-comment.yml'); + +// The jobs that skip on a deferred push. +const HEAVY = [ + 'test-ui', + 'test-node', + 'native-swift-runner', + 'run-labels', + 'size-base', + 'test-build', + 'build-storybook', + 'pr-interaction', + 'pr-interaction-webkit', +]; +// The required contexts ci.yml reports (spec:AST-030 FR7). +const REQUIRED = ['test', 'build', 'docsite-test']; + +function pushEvent({ + action = 'synchronize', + attempt = '1', + login = 'cixzhang', + base = 'main', + head = 'fix/thing', +} = {}) { + return { + event_name: 'pull_request', + repository: 'meta/astryx', + run_attempt: attempt, + base_ref: base, + head_ref: head, + event: { + action, + pull_request: { + draft: false, + user: {login}, + head: {repo: {full_name: 'meta/astryx'}}, + labels: [], + }, + }, + }; +} + +/** + * Evaluates a job or step condition as Actions would. `needs` maps a job id + * to `{result, outputs}`; a condition with no status-check function also + * needs every needed job to have succeeded. + */ +function evaluate(condition, {github, needs = {}, jobNeeds = []}) { + const expression = String(condition) + .replace(/^\$\{\{\s*|\s*\}\}$/g, '') + .replace(/\.labels\.\*\.name/g, '.labels.map(l => l.name)') + .replace(/needs\.([a-z][a-z0-9-]*)/g, 'needs["$1"]'); + const hasStatus = /\b(always|cancelled|failure|success)\(\)/.test(expression); + const needed = [jobNeeds].flat().map(id => needs[id] ?? {result: 'success'}); + const succeeded = needed.every(need => need.result === 'success'); + const all = new Proxy(needs, { + get: (target, key) => target[key] ?? {result: 'success', outputs: {}}, + }); + return Boolean( + Function( + 'github', + 'needs', + 'inputs', + 'always', + 'cancelled', + 'success', + 'failure', + 'startsWith', + 'contains', + `return ${hasStatus ? '' : 'success() && '}(${expression});`, + )( + github, + all, + {}, + () => true, + () => false, + () => succeeded, + () => !succeeded, + (value, prefix) => String(value ?? '').startsWith(prefix), + (list, item) => list.includes(item), + ), + ); +} + +/** The `heavy-ci` result and output a push leaves, by its own condition. */ +function heavyCi(github, {specOnly = false} = {}) { + if (!evaluate(ci.jobs['heavy-ci'].if, {github})) + return {result: 'skipped', outputs: {}}; + return { + result: 'success', + outputs: {deferred: specOnly ? 'false' : 'true'}, + }; +} + +/** Whether a job starts, with every need but `heavy-ci` as given. */ +function starts(id, github, {specOnly, others = 'success'} = {}) { + const job = ci.jobs[id]; + const needs = Object.fromEntries( + [job.needs ?? []] + .flat() + .map(need => [ + need, + need === 'heavy-ci' + ? heavyCi(github, {specOnly}) + : {result: others, outputs: {}}, + ]), + ); + return evaluate(job.if, {github, needs, jobNeeds: job.needs ?? []}); +} + +const deferralStep = id => + ci.jobs[id].steps.find(step => step.name === 'Heavy CI deferred'); + +describe('which pushes defer the heavy suites (spec:AST-030 FR20)', () => { + it('is a push to a ready agent-lane pull request into main, in its first attempt', () => { + expect(ci.jobs['heavy-ci'].if).toContain(HEAVY_CANDIDATE); + expect(evaluate(HEAVY_CANDIDATE, {github: pushEvent()})).toBe(true); + }); + + it.each([ + ['marked ready', {action: 'ready_for_review'}], + ['opened', {action: 'opened'}], + ['reopened', {action: 'reopened'}], + ['re-run (the pre-merge run)', {attempt: '2'}], + ['from another author', {login: 'someone'}], + ['a release merge-back', {head: 'chore/merge-v1.2.3-to-main'}], + ])('runs everything when the pull request is %s', (_, event) => { + const github = pushEvent(event); + expect(evaluate(HEAVY_CANDIDATE, {github})).toBe(false); + expect(heavyCi(github).result).toBe('skipped'); + for (const id of HEAVY) expect(starts(id, github), id).toBe(true); + for (const id of REQUIRED) { + expect(starts(id, github), id).toBe(true); + expect( + evaluate(deferralStep(id).if, { + github, + needs: {'heavy-ci': heavyCi(github)}, + }), + id, + ).toBe(false); + } + }); + + it('never defers a release-branch pull request', () => { + const github = pushEvent({base: 'release/v1.2.3'}); + expect(heavyCi(github).result).toBe('skipped'); + }); + + it('runs everything for a spec-only change', () => { + const github = pushEvent(); + for (const id of HEAVY) + expect(starts(id, github, {specOnly: true}), id).toBe(true); + }); +}); + +describe('a deferred push', () => { + const github = pushEvent(); + + it('skips every heavy job', () => { + for (const id of HEAVY) { + expect(starts(id, github), id).toBe(false); + // The deferral sits right after the gate, and ungated() sees past it. + expect(String(ci.jobs[id].if), id).toContain(`&& (${HEAVY_RUNS} && (`); + expect(ungated(ci.jobs[id].if), id).not.toContain('heavy-ci'); + } + }); + + it('still runs the fast checks: typecheck and lint', () => { + expect(starts('typecheck', github)).toBe(true); + expect(JSON.stringify(ci.jobs.typecheck)).not.toContain('heavy-ci'); + expect(JSON.stringify(lint)).not.toContain('heavy-ci'); + }); + + it('fails every required context with the reason, never passing as skipped', () => { + for (const id of REQUIRED) { + // The job starts even though the jobs it joins skipped... + expect(starts(id, github, {others: 'skipped'}), id).toBe(true); + // ...and its deferral step, which exits 1, runs first after the fork + // gate, so the context fails before any join could pass. + const steps = ci.jobs[id].steps; + const step = deferralStep(id); + expect(steps.indexOf(step), id).toBe(1); + expect(step.if).toBe(HEAVY_DEFERRED); + expect( + evaluate(step.if, {github, needs: {'heavy-ci': heavyCi(github)}}), + id, + ).toBe(true); + expect(step.run, id).toContain(`::error::${HEAVY_DEFERRED_MESSAGE}`); + expect(step.run, id).toMatch(/\bexit 1\s*$/); + } + }); + + it('decides with one cheap job that reads the trusted scope', () => { + const job = ci.jobs['heavy-ci']; + expect(job['runs-on']).toBe('ubuntu-slim'); + expect(job.outputs.deferred).toBe('${{ steps.decide.outputs.deferred }}'); + expect( + job.steps.some(step => step.uses?.endsWith('/actions/test-scope')), + ).toBe(true); + for (const id of [...HEAVY, ...REQUIRED]) + expect([ci.jobs[id].needs].flat(), id).toContain('heavy-ci'); + }); + + it.each([ + ['true', 'false', ''], + ['false', 'true', HEAVY_DEFERRED_MESSAGE], + ['', 'true', HEAVY_DEFERRED_MESSAGE], + ])( + 'defers unless the change is spec-only (spec_only=%s → deferred=%s)', + (specOnly, deferred, summary) => { + const step = ci.jobs['heavy-ci'].steps.find(item => item.id === 'decide'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'heavy-ci-')); + try { + const output = path.join(dir, 'output'); + const stepSummary = path.join(dir, 'summary'); + const result = spawnSync( + 'bash', + ['-e', '-c', step.run.replaceAll(/\$\{\{[^}]*\}\}/g, 'abc')], + { + cwd: dir, + encoding: 'utf8', + env: { + ...process.env, + SPEC_ONLY: specOnly, + GITHUB_OUTPUT: output, + GITHUB_STEP_SUMMARY: stepSummary, + GITHUB_RUN_ID: '7', + GITHUB_RUN_ATTEMPT: '1', + }, + }, + ); + expect(result.status, result.stderr).toBe(0); + expect(fs.readFileSync(output, 'utf8')).toBe(`deferred=${deferred}\n`); + expect( + fs.existsSync(stepSummary) + ? fs.readFileSync(stepSummary, 'utf8') + : '', + ).toBe(summary ? `${summary}.\n` : ''); + expect(fs.existsSync(path.join(dir, 'heavy-ci-deferred.json'))).toBe( + deferred === 'true', + ); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }, + ); +}); + +describe('the PR report on a deferred push', () => { + const comment = prComment.jobs.comment; + + it("reads the run attempt's deferral, only for a failed run", () => { + const download = comment.steps.find( + step => step.id === 'deferral-download', + ); + expect(download.uses).toBe('actions/download-artifact@v8'); + expect(download.with.name).toBe( + 'heavy-ci-deferred-${{ steps.identity.outputs.run_attempt }}', + ); + expect(download.with['run-id']).toBe( + '${{ steps.identity.outputs.run_id }}', + ); + expect(download.if).toContain( + "steps.identity.outputs.source_conclusion == 'failure'", + ); + expect(download['continue-on-error']).toBe(true); + const upload = ci.jobs['heavy-ci'].steps.find(step => + step.uses?.startsWith('actions/upload-artifact'), + ); + expect(upload.with.name).toBe( + 'heavy-ci-deferred-${{ github.run_attempt }}', + ); + }); + + it('says the heavy CI was deferred', () => { + const publish = comment.steps.find(step => step.id === 'publish'); + expect(publish.with.script).toContain(HEAVY_DEFERRED_MESSAGE); + expect(publish.with.script).toContain( + 'heavy-ci-deferred/heavy-ci-deferred.json', + ); + }); +}); diff --git a/.github/scripts/ci-interaction-webkit.test.mjs b/.github/scripts/ci-interaction-webkit.test.mjs index 20982174e60cd..18d0753951e5f 100644 --- a/.github/scripts/ci-interaction-webkit.test.mjs +++ b/.github/scripts/ci-interaction-webkit.test.mjs @@ -58,7 +58,7 @@ describe('WebKit interaction proof routing', () => { expect(ungated(webkit.if)).toBe( "github.event_name != 'workflow_dispatch' && always() && !cancelled()", ); - expect(webkit.needs).toEqual(['build-storybook']); + expect(webkit.needs).toEqual(['heavy-ci', 'build-storybook']); }); it('stays on a standard runner within the 15-minute budget', () => { diff --git a/.github/scripts/ci-interaction.test.mjs b/.github/scripts/ci-interaction.test.mjs index cd075ab85928e..96b50234102aa 100644 --- a/.github/scripts/ci-interaction.test.mjs +++ b/.github/scripts/ci-interaction.test.mjs @@ -49,7 +49,7 @@ describe('interaction proof routing', () => { expect(ungated(proofs.if)).toBe( "github.event_name != 'workflow_dispatch' && always() && !cancelled()", ); - expect(proofs.needs).toEqual(['build-storybook']); + expect(proofs.needs).toEqual(['heavy-ci', 'build-storybook']); }); it('decides its own scope first, and gates every later step on it', () => { diff --git a/.github/scripts/ci-pr-gate.mjs b/.github/scripts/ci-pr-gate.mjs index 3b8901f842cc7..324d9733df636 100644 --- a/.github/scripts/ci-pr-gate.mjs +++ b/.github/scripts/ci-pr-gate.mjs @@ -33,13 +33,45 @@ export function gated(condition, gate = PR_CI_GATE) { return condition.startsWith('${{') ? `\${{ ${wrapped} }}` : wrapped; } -/** The job's own condition, without the gate in front. */ +/** + * The job's own condition, without the gate in front, nor the heavy-suite + * deferral a heavy job adds after it (spec:AST-030 FR20). + */ export function ungated(condition) { - const expression = condition.replace(/^\$\{\{\s*|\s*\}\}$/g, ''); + let expression = condition.replace(/^\$\{\{\s*|\s*\}\}$/g, ''); for (const gate of [PR_CI_GATE, PR_REPORT_GATE]) { const prefix = `${gate} && (`; - if (expression.startsWith(prefix)) - return expression.slice(prefix.length, -1); + if (expression.startsWith(prefix)) { + expression = expression.slice(prefix.length, -1); + break; + } } + if (expression.startsWith(`${HEAVY_RUNS} && (`)) + expression = expression.slice(HEAVY_RUNS.length + 5, -1); return expression; } + +/** + * A push that defers the heavy suites (spec:AST-030 FR20, DEC-14): a push to a + * ready pull request from the shared agent login, in its first attempt, into + * `main`. A release merge-back is never deferred. The `heavy-ci` job runs only + * then, and defers unless the change is spec-only. + */ +export const HEAVY_CANDIDATE = + "(github.event_name == 'pull_request' && github.event.action == 'synchronize' && " + + "github.run_attempt == '1' && github.event.pull_request.user.login == 'cixzhang' && " + + "github.base_ref == 'main' && !startsWith(github.head_ref, 'chore/merge-v'))"; + +/** `heavy-ci` decided to defer this push's heavy suites. */ +export const HEAVY_DEFERRED = "needs.heavy-ci.outputs.deferred == 'true'"; + +/** + * A heavy job's condition after the gate. `heavy-ci` skips for every push it + * does not decide, so the job runs then too (`!cancelled()`, not success()). + */ +export const HEAVY_RUNS = + "!cancelled() && needs.heavy-ci.outputs.deferred != 'true'"; + +/** What a deferred required check and the PR report say. */ +export const HEAVY_DEFERRED_MESSAGE = + 'Heavy CI deferred: runs when marked ready and before merge'; diff --git a/.github/scripts/ci-test-routing.test.mjs b/.github/scripts/ci-test-routing.test.mjs index a8cacdfe436f1..d2b446ca34c24 100644 --- a/.github/scripts/ci-test-routing.test.mjs +++ b/.github/scripts/ci-test-routing.test.mjs @@ -775,7 +775,7 @@ describe('ci.yml interaction proofs', () => { it('runs the interaction config in Chromium against the exact PR head', () => { // One fixed runner: out of scope, every step after the scope step skips. expect(KNOWN_RUNNERS.has(job['runs-on']), job['runs-on']).toBe(true); - expect(job.needs).toEqual(['build-storybook']); + expect(job.needs).toEqual(['heavy-ci', 'build-storybook']); const checkout = job.steps.find( candidate => candidate.uses?.startsWith('actions/checkout@') && diff --git a/.github/scripts/ci-tooling-routing.test.mjs b/.github/scripts/ci-tooling-routing.test.mjs index 9267e4f894c29..f60ea0ac2465d 100644 --- a/.github/scripts/ci-tooling-routing.test.mjs +++ b/.github/scripts/ci-tooling-routing.test.mjs @@ -191,6 +191,7 @@ describe('Node-tooling CI routing', () => { it('preserves the historical joins and fails them on owned-lane failure', () => { expect(ci.jobs.test.needs).toEqual([ + 'heavy-ci', 'release-bump', 'test-ui', 'test-node', @@ -199,7 +200,11 @@ describe('Node-tooling CI routing', () => { 'pr-interaction', 'pr-interaction-webkit', ]); - expect(ci.jobs.build.needs).toEqual(['build-storybook', 'typecheck']); + expect(ci.jobs.build.needs).toEqual([ + 'heavy-ci', + 'build-storybook', + 'typecheck', + ]); const testJoin = step(ci.jobs.test, 'Require expected test-owner outcomes'); expect(testJoin.run).toBe('node .github/scripts/ci-test-join.mjs'); expect(ci.jobs.test.permissions).toEqual({ diff --git a/.github/scripts/spec-owner-workflow.test.mjs b/.github/scripts/spec-owner-workflow.test.mjs index 9fab31bd646e7..7fcb41fc10117 100644 --- a/.github/scripts/spec-owner-workflow.test.mjs +++ b/.github/scripts/spec-owner-workflow.test.mjs @@ -222,7 +222,7 @@ describe('spec-only workflow contract', () => { it('fails closed when file APIs are truncated or scope classification fails', () => { const ci = read('.github/workflows/ci.yml'); expect(ci).toContain( - `if: ${gated("${{ github.event_name != 'workflow_dispatch' && !startsWith(github.base_ref, 'release/v') }}", PR_REPORT_GATE)}`, + `if: ${gated("${{ !cancelled() && github.event_name != 'workflow_dispatch' && !startsWith(github.base_ref, 'release/v') }}", PR_REPORT_GATE)}`, ); const action = read('.github/actions/test-scope/action.yml'); expect(action).toContain( diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f5db03721062..97ee8d65c6f75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -358,8 +358,61 @@ jobs: # code and theme, nothing sent to a document the code navigated to), and # only the production build has the opaque-origin sandbox at all. The # spec-only lane validates spec records here, where it always has. + # Whether this push defers the heavy suites (spec:AST-030 FR20, DEC-14): a + # push to a ready agent-lane pull request (the shared agent login), in its + # first attempt, runs only the fast checks (lint, typecheck). The heavy jobs + # skip, and the required checks fail with the reason until the pull request + # is marked ready or reopened, or its run is re-run in full before merge + # (any later attempt runs everything). A spec-only change is never deferred. + # Every other push skips this job, and nothing waits for it. + heavy-ci: + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && ((github.event_name == 'pull_request' && github.event.action == 'synchronize' && github.run_attempt == '1' && github.event.pull_request.user.login == 'cixzhang' && github.base_ref == 'main' && !startsWith(github.head_ref, 'chore/merge-v'))) }} + runs-on: ubuntu-slim + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + deferred: ${{ steps.decide.outputs.deferred }} + steps: + - name: Check out the scope action + uses: actions/checkout@v7 + with: + sparse-checkout: .github/actions/test-scope + path: .test-scope + persist-credentials: false + + - name: Decide this push's scope + id: scope + uses: ./.test-scope/.github/actions/test-scope + + - name: Defer the heavy suites unless the change is spec-only + id: decide + env: + SPEC_ONLY: ${{ steps.scope.outputs.spec_only }} + run: | + if [ "$SPEC_ONLY" = true ]; then + echo 'deferred=false' >> "$GITHUB_OUTPUT" + exit 0 + fi + echo 'deferred=true' >> "$GITHUB_OUTPUT" + echo "::notice title=Heavy CI deferred::Heavy CI deferred: runs when marked ready and before merge. Mark the pull request ready, reopen it, or re-run all jobs of this run to run everything." + echo "Heavy CI deferred: runs when marked ready and before merge." >> "$GITHUB_STEP_SUMMARY" + printf '{"headSha":"%s","runId":"%s","runAttempt":"%s"}\n' \ + "${{ github.event.pull_request.head.sha }}" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" > heavy-ci-deferred.json + + # The PR report says why the heavy checks did not run. + - name: Upload the deferral for the PR report + if: steps.decide.outputs.deferred == 'true' + uses: actions/upload-artifact@v7 + with: + name: heavy-ci-deferred-${{ github.run_attempt }} + path: heavy-ci-deferred.json + retention-days: 7 + docsite-test: - if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name != 'workflow_dispatch' && !startsWith(github.base_ref, 'release/v')) }} + needs: [heavy-ci] + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (!cancelled() && github.event_name != 'workflow_dispatch' && !startsWith(github.base_ref, 'release/v')) }} # The Next.js production build uses every core it gets. runs-on: ubuntu-latest timeout-minutes: 15 @@ -372,6 +425,13 @@ jobs: run: | echo "::error::Waiting for a maintainer's ci:run label: an outside contributor's pull request runs CI once a maintainer adds it (spec:AST-030 FR17)." exit 1 + # An agent lane's push defers the heavy suites (spec:AST-030 FR20): the + # required check fails with the reason, never passes as skipped. + - name: Heavy CI deferred + if: needs.heavy-ci.outputs.deferred == 'true' + run: | + echo "::error::Heavy CI deferred: runs when marked ready and before merge (spec:AST-030 FR20)." + exit 1 # This job decides its own scope (no separate scope job): the step # reads the PR's file list and the base's trusted classifier, and # everything is in scope when it cannot decide. @@ -493,7 +553,8 @@ jobs: # build-storybook + typecheck below. test-ui: name: test-ui (${{ matrix.shard }}/${{ strategy.job-total }}) - if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (github.event_name != 'workflow_dispatch') }} + needs: [heavy-ci] + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && needs.heavy-ci.outputs.deferred != 'true' && (github.event_name != 'workflow_dispatch')) }} # One runner ran the whole UI project in ~16 minutes and set the PR's wall # time. Vitest's --shard gives each leg an equal share of the files; the # two slowest files (~3.5 minutes each) bound a leg, so more legs stop @@ -607,7 +668,8 @@ jobs: test-node: name: test-node (${{ matrix.shard }}/${{ strategy.job-total }}) - if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (github.event_name != 'workflow_dispatch') }} + needs: [heavy-ci] + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && needs.heavy-ci.outputs.deferred != 'true' && (github.event_name != 'workflow_dispatch')) }} # One runner ran the Node project in ~12 minutes. Vitest's --shard splits # the files across the legs; `test` joins them. strategy: @@ -700,7 +762,8 @@ jobs: # test-native-swift runs on macOS only then. It reads the same scope step as # every test job; anything undecidable picks macOS. native-swift-runner: - if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (github.event_name != 'workflow_dispatch') }} + needs: [heavy-ci] + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && needs.heavy-ci.outputs.deferred != 'true' && (github.event_name != 'workflow_dispatch')) }} runs-on: ubuntu-slim timeout-minutes: 5 permissions: @@ -818,6 +881,7 @@ jobs: test: needs: [ + heavy-ci, release-bump, test-ui, test-node, @@ -837,6 +901,13 @@ jobs: run: | echo "::error::Waiting for a maintainer's ci:run label: an outside contributor's pull request runs CI once a maintainer adds it (spec:AST-030 FR17)." exit 1 + # An agent lane's push defers the heavy suites (spec:AST-030 FR20): the + # required check fails with the reason, never passes as skipped. + - name: Heavy CI deferred + if: needs.heavy-ci.outputs.deferred == 'true' + run: | + echo "::error::Heavy CI deferred: runs when marked ready and before merge (spec:AST-030 FR20)." + exit 1 # This job decides its own scope (no separate scope job): the step # reads the PR's file list and the base's trusted classifier, and # everything is in scope when it cannot decide. @@ -885,7 +956,8 @@ jobs: # own terms. The decision script comes from the merge commit's trusted # sparse checkout, and a lookup that fails denies the suite. run-labels: - if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (github.event_name != 'workflow_dispatch' || inputs.operation == 'release-check') }} + needs: [heavy-ci] + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && needs.heavy-ci.outputs.deferred != 'true' && (github.event_name != 'workflow_dispatch' || inputs.operation == 'release-check')) }} runs-on: ubuntu-slim timeout-minutes: 5 permissions: @@ -918,7 +990,8 @@ jobs: await decideRunLabels({github, context, core}); build-storybook: - if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && ((github.event_name != 'workflow_dispatch' || inputs.operation == 'release-check') && always() && !cancelled()) }} + needs: [heavy-ci] + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && needs.heavy-ci.outputs.deferred != 'true' && ((github.event_name != 'workflow_dispatch' || inputs.operation == 'release-check') && always() && !cancelled())) }} # Every browser lane waits for this job, and the package builds and the # Storybook build run side by side (build-packages-and-storybook.mjs). runs-on: ${{ (github.event_name == 'workflow_dispatch' && inputs.operation == 'release-check' && github.triggering_actor != 'github-actions[bot]') && '4-core-ubuntu' || 'ubuntu-latest' }} @@ -1173,7 +1246,8 @@ jobs: # inside it, so no browser lane waits for the second build; the head sizes # still come from build-storybook's analysis. size-base: - if: (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (github.event_name == 'pull_request') + needs: [heavy-ci] + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && needs.heavy-ci.outputs.deferred != 'true' && (github.event_name == 'pull_request')) }} runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -1373,8 +1447,8 @@ jobs: # gate passes. A skipped build-storybook job (a draft) skips this gate too; # a fork pull request without `ci:run` still reports here. build: - needs: [build-storybook, typecheck] - if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name != 'workflow_dispatch' && !cancelled() && (needs.build-storybook.result != 'skipped' || github.event_name == 'pull_request' && !(github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run')))) }} + needs: [heavy-ci, build-storybook, typecheck] + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name != 'workflow_dispatch' && !cancelled() && (needs.build-storybook.result != 'skipped' || needs.heavy-ci.outputs.deferred == 'true' || github.event_name == 'pull_request' && !(github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run')))) }} runs-on: ubuntu-slim permissions: {} steps: @@ -1383,6 +1457,13 @@ jobs: run: | echo "::error::Waiting for a maintainer's ci:run label: an outside contributor's pull request runs CI once a maintainer adds it (spec:AST-030 FR17)." exit 1 + # An agent lane's push defers the heavy suites (spec:AST-030 FR20): the + # required check fails with the reason, never passes as skipped. + - name: Heavy CI deferred + if: needs.heavy-ci.outputs.deferred == 'true' + run: | + echo "::error::Heavy CI deferred: runs when marked ready and before merge (spec:AST-030 FR20)." + exit 1 - name: Assert parallel builds succeeded run: | echo "build-storybook: ${{ needs.build-storybook.result }}" @@ -1863,8 +1944,8 @@ jobs: delete-merged: true pr-interaction: - needs: [build-storybook] - if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (github.event_name != 'workflow_dispatch' && always() && !cancelled()) }} + needs: [heavy-ci, build-storybook] + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && needs.heavy-ci.outputs.deferred != 'true' && (github.event_name != 'workflow_dispatch' && always() && !cancelled())) }} # Frame evidence measures timing: four cores, one Playwright worker. runs-on: ubuntu-latest timeout-minutes: 15 @@ -1981,8 +2062,8 @@ jobs: # required context. Specs that need Chromium-only evidence skip themselves # in WebKit. pr-interaction-webkit: - needs: [build-storybook] - if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (github.event_name != 'workflow_dispatch' && always() && !cancelled()) }} + needs: [heavy-ci, build-storybook] + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && needs.heavy-ci.outputs.deferred != 'true' && (github.event_name != 'workflow_dispatch' && always() && !cancelled())) }} # Frame evidence measures timing: four cores, one Playwright worker. runs-on: ubuntu-latest timeout-minutes: 15 @@ -2098,7 +2179,8 @@ jobs: # package ownership explicit; the historical `test` join above projects its # result. Until runtime:build has a specialized route, broad CI dispatches it. test-build: - if: (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (github.event_name != 'workflow_dispatch') + needs: [heavy-ci] + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && needs.heavy-ci.outputs.deferred != 'true' && (github.event_name != 'workflow_dispatch')) }} runs-on: ubuntu-24.04-arm timeout-minutes: 15 permissions: diff --git a/.github/workflows/pr-comment.yml b/.github/workflows/pr-comment.yml index 75c491e4ece3a..3da241b0a89ce 100644 --- a/.github/workflows/pr-comment.yml +++ b/.github/workflows/pr-comment.yml @@ -322,6 +322,19 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} continue-on-error: true + # A failed run may be a deferred agent-lane push (spec:AST-030 FR20), + # whose report says so instead of an unavailable analysis. + - name: Download the heavy-CI deferral from the CI run + id: deferral-download + if: steps.identity.outputs.valid == 'true' && steps.identity.outputs.source_conclusion == 'failure' + uses: actions/download-artifact@v8 + with: + name: heavy-ci-deferred-${{ steps.identity.outputs.run_attempt }} + path: heavy-ci-deferred + run-id: ${{ steps.identity.outputs.run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true + - name: Cross-check artifact identity id: cross-check continue-on-error: true @@ -405,6 +418,9 @@ jobs: sourceRunAttempt: Number(process.env.RUN_ATTEMPT), sourceConclusion: process.env.SOURCE_CONCLUSION, }, + fallbackMessage: require('node:fs').existsSync('heavy-ci-deferred/heavy-ci-deferred.json') + ? 'Heavy CI deferred: runs when marked ready and before merge. Lint and typecheck ran on this push.' + : undefined, }); # The CI-completion lane runs on workflow_run, so GitHub attaches its jobs to diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 05c65e6fdb36e..d24d2d63239b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -940,6 +940,11 @@ Storybook story for anything you added or altered. request past either limit shows a failing check that says why, and runs in full at its pre-merge re-run. Engineering owners listed in `.github/ENGOWNERS` are not limited. +- **Pull requests from the shared agent login run the heavy suites when ready + and before merge.** A push to a ready agent pull request runs lint and + typecheck; `test`, `build` and `docsite-test` fail with "Heavy CI deferred" + until the pull request is marked ready or reopened, or its latest run is + re-run with **Re-run all jobs**, which runs everything at that head. ## Code Style diff --git a/docs/specs/AST-030/spec.md b/docs/specs/AST-030/spec.md index 9fdc9bf939497..8fc5eb5c968fa 100644 --- a/docs/specs/AST-030/spec.md +++ b/docs/specs/AST-030/spec.md @@ -318,6 +318,18 @@ they are separately named. required check would count as passing. Events other than a pull request (a push to `main`, a merge-group entry, a dispatch) are unaffected, and a fork's code never runs with the repository's secrets. +- **FR20 — An agent lane's push runs the fast checks; the heavy suites run when + it is ready and before merge.** A push to a ready pull request authored by the + shared agent login, into `main`, in its run's first attempt, runs lint and + typecheck only, unless its trusted classification is spec-only. Its heavy jobs + skip, and the required contexts `test`, `build` and `docsite-test` (FR7) + report failure with "Heavy CI deferred: runs when marked ready and before + merge", never success, so nothing merges on that run; the PR report says the + same. Marking the pull request ready, opening or reopening it, and any later + attempt of its run (the pre-merge re-run of all jobs) run every suite at that + head. A release merge-back, a release-branch pull request, every other + author, the review and spec gates (FR15) and the heavy-suite caps (FR16) are + unaffected (DEC-14). ### Platform support @@ -369,6 +381,7 @@ read change (FR14). | FR15 | workflow contract and reconciler tests | ENGOWNER spec PR; owner command; other comment; fork review; non-spec PR | `review-required` succeeds while spec approval is missing, another workflow publishes a review context, a fork review starts the spec checks, or a non-command comment starts a runner | | FR16 | `heavy-caps-action.test.mjs` cap cases and heavy-job contract | sixteenth-oldest PR; 101st heavy run of the day, beside cancelled, light and other-branch runs; agent lane and its five-PR and forty-run backstops; an engineering owner, including the shared login past those backstops, a commented-out or trailing-comment handle and an unreadable roster; bot; the deciding run; re-run; failed lookup | a heavy suite that runs ahead of the author's fifteen oldest PRs, or past the daily cap, a capped job that passes, an engineering owner or a re-run that is held, or a roster read from the pull request's head fails | | FR17 | `ci-pr-gate.test.mjs` over every `pull_request` workflow and the shared conditions | branch here, fork with and without `ci:run`, another label, draft with the label, ready, push, merge group, dispatch | a job that can start on a pull request without the condition, a label trigger, a required job that skips for a pull request that may not run CI, or a draft or unlabelled fork that runs fails | +| FR20 | `ci-heavy-deferral.test.mjs` over ci.yml, lint.yml and pr-comment.yml | agent push; marked ready; opened; reopened; re-run; another author; release branch; merge-back; spec-only change | a heavy job runs on a deferred push, a required context passes or skips on one, a ready, reopened or re-run pull request defers, a spec-only change defers, or the report hides the deferral | ## Decision log @@ -539,6 +552,25 @@ it, so the merge rules change only when an administrator removes it. Rejected: keeping a separate spec-owner workflow, which split one review state across two workflows and two visible gates. + +### DEC-14 — Agent lanes run the heavy suites when ready and before merge + +**Reference:** `spec:AST-030/DEC-14` +**Decider:** `cixzhang` + +Most pull-request runs come from the shared agent login, and an agent pushes +many times while it works; each push ran every heavy suite on a head that was +replaced minutes later. The heavy suites now run at the two heads that decide +a merge: the one that becomes ready and the one re-run before merge. Between +them lint and typecheck give fast feedback. A deferred required context fails +with its reason rather than passing as skipped, because a skipped required +check counts as passing (FR17's rule), so a deferred head cannot merge. + +Rejected: leaving the required contexts pending (a commit status beside the +job's check run of the same name is ambiguous to branch protection); deferring +spec-only changes (their heavy jobs already do nothing, and every push would +then need a re-run to merge). + ## Open questions None. From df685d00894237087e09aa50a84c68338154c7f3 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 31 Oct 2026 12:57:14 -0700 Subject: [PATCH 2/5] spec(AST-030): format FR20 --- docs/specs/AST-030/spec.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/specs/AST-030/spec.md b/docs/specs/AST-030/spec.md index 8fc5eb5c968fa..756f81e081250 100644 --- a/docs/specs/AST-030/spec.md +++ b/docs/specs/AST-030/spec.md @@ -381,7 +381,7 @@ read change (FR14). | FR15 | workflow contract and reconciler tests | ENGOWNER spec PR; owner command; other comment; fork review; non-spec PR | `review-required` succeeds while spec approval is missing, another workflow publishes a review context, a fork review starts the spec checks, or a non-command comment starts a runner | | FR16 | `heavy-caps-action.test.mjs` cap cases and heavy-job contract | sixteenth-oldest PR; 101st heavy run of the day, beside cancelled, light and other-branch runs; agent lane and its five-PR and forty-run backstops; an engineering owner, including the shared login past those backstops, a commented-out or trailing-comment handle and an unreadable roster; bot; the deciding run; re-run; failed lookup | a heavy suite that runs ahead of the author's fifteen oldest PRs, or past the daily cap, a capped job that passes, an engineering owner or a re-run that is held, or a roster read from the pull request's head fails | | FR17 | `ci-pr-gate.test.mjs` over every `pull_request` workflow and the shared conditions | branch here, fork with and without `ci:run`, another label, draft with the label, ready, push, merge group, dispatch | a job that can start on a pull request without the condition, a label trigger, a required job that skips for a pull request that may not run CI, or a draft or unlabelled fork that runs fails | -| FR20 | `ci-heavy-deferral.test.mjs` over ci.yml, lint.yml and pr-comment.yml | agent push; marked ready; opened; reopened; re-run; another author; release branch; merge-back; spec-only change | a heavy job runs on a deferred push, a required context passes or skips on one, a ready, reopened or re-run pull request defers, a spec-only change defers, or the report hides the deferral | +| FR20 | `ci-heavy-deferral.test.mjs` over ci.yml, lint.yml and pr-comment.yml | agent push; marked ready; opened; reopened; re-run; another author; release branch; merge-back; spec-only change | a heavy job runs on a deferred push, a required context passes or skips on one, a ready, reopened or re-run pull request defers, a spec-only change defers, or the report hides the deferral | ## Decision log @@ -552,7 +552,6 @@ it, so the merge rules change only when an administrator removes it. Rejected: keeping a separate spec-owner workflow, which split one review state across two workflows and two visible gates. - ### DEC-14 — Agent lanes run the heavy suites when ready and before merge **Reference:** `spec:AST-030/DEC-14` From b28ef00686eda460444afc7123eecfb6eb0acdd1 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 31 Oct 2026 14:14:00 -0700 Subject: [PATCH 3/5] ci: defer test-native-swift too; one shared agent-login list; scope the FR20 wording to ci.yml --- .github/actions/heavy-caps/action.yml | 1 + .github/scripts/ci-heavy-deferral.test.mjs | 20 ++++++++++++++++++++ .github/scripts/ci-native-swift.test.mjs | 2 +- .github/scripts/ci-pr-gate.mjs | 9 ++++++++- .github/workflows/ci.yml | 6 +++--- .github/workflows/pr-comment.yml | 2 +- CONTRIBUTING.md | 5 +++-- docs/specs/AST-030/spec.md | 7 ++++--- 8 files changed, 41 insertions(+), 11 deletions(-) diff --git a/.github/actions/heavy-caps/action.yml b/.github/actions/heavy-caps/action.yml index 6a789ab7043d2..58d3e0b8f9388 100644 --- a/.github/actions/heavy-caps/action.yml +++ b/.github/actions/heavy-caps/action.yml @@ -32,6 +32,7 @@ runs: if (login.endsWith('[bot]')) return; // Agent lanes share one login: each branch prefix counts as an author. + // The same list as SHARED_AGENT_LOGINS (.github/scripts/ci-pr-gate.mjs). const shared = ['cixzhang'].includes(login); const lane = ref => (shared ? ref.split('/')[0] : ''); const mine = ref => lane(ref) === lane(pr.head.ref); diff --git a/.github/scripts/ci-heavy-deferral.test.mjs b/.github/scripts/ci-heavy-deferral.test.mjs index 4de19fc0d4e20..55f7d29611a30 100644 --- a/.github/scripts/ci-heavy-deferral.test.mjs +++ b/.github/scripts/ci-heavy-deferral.test.mjs @@ -26,6 +26,7 @@ import { HEAVY_DEFERRED, HEAVY_DEFERRED_MESSAGE, HEAVY_RUNS, + SHARED_AGENT_LOGINS, ungated, } from './ci-pr-gate.mjs'; @@ -45,6 +46,7 @@ const HEAVY = [ 'test-ui', 'test-node', 'native-swift-runner', + 'test-native-swift', 'run-labels', 'size-base', 'test-build', @@ -107,6 +109,7 @@ function evaluate(condition, {github, needs = {}, jobNeeds = []}) { 'failure', 'startsWith', 'contains', + 'fromJSON', `return ${hasStatus ? '' : 'success() && '}(${expression});`, )( github, @@ -118,6 +121,7 @@ function evaluate(condition, {github, needs = {}, jobNeeds = []}) { () => !succeeded, (value, prefix) => String(value ?? '').startsWith(prefix), (list, item) => list.includes(item), + JSON.parse, ), ); } @@ -186,6 +190,22 @@ describe('which pushes defer the heavy suites (spec:AST-030 FR20)', () => { expect(heavyCi(github).result).toBe('skipped'); }); + it('names the shared agent logins in one list, the heavy-suite caps included', () => { + expect(HEAVY_CANDIDATE).toContain( + `fromJSON('${JSON.stringify(SHARED_AGENT_LOGINS)}')`, + ); + const caps = fs.readFileSync( + path.resolve(import.meta.dirname, '../actions/heavy-caps/action.yml'), + 'utf8', + ); + const listed = /const shared = (\[[^\]]*\])\.includes\(login\)/.exec( + caps, + )[1]; + expect(JSON.parse(listed.replaceAll("'", '"'))).toEqual( + SHARED_AGENT_LOGINS, + ); + }); + it('runs everything for a spec-only change', () => { const github = pushEvent(); for (const id of HEAVY) diff --git a/.github/scripts/ci-native-swift.test.mjs b/.github/scripts/ci-native-swift.test.mjs index 944ff89dba58b..5fc048646567c 100644 --- a/.github/scripts/ci-native-swift.test.mjs +++ b/.github/scripts/ci-native-swift.test.mjs @@ -59,7 +59,7 @@ describe('native Swift routing', () => { expect(runner.steps.find(s => s.id === 'scope').uses).toBe( './.test-scope/.github/actions/test-scope', ); - expect(swift.needs).toEqual(['native-swift-runner']); + expect(swift.needs).toEqual(['heavy-ci', 'native-swift-runner']); expect(swift['runs-on']).toBe( `\${{ ${applies} && 'macos-15' || 'ubuntu-slim' }}`, ); diff --git a/.github/scripts/ci-pr-gate.mjs b/.github/scripts/ci-pr-gate.mjs index 324d9733df636..e3391d4287bbe 100644 --- a/.github/scripts/ci-pr-gate.mjs +++ b/.github/scripts/ci-pr-gate.mjs @@ -57,9 +57,16 @@ export function ungated(condition) { * `main`. A release merge-back is never deferred. The `heavy-ci` job runs only * then, and defers unless the change is spec-only. */ +/** + * The shared agent logins. The heavy-suite caps + * (.github/actions/heavy-caps) list the same logins, and a test holds the two + * lists equal. + */ +export const SHARED_AGENT_LOGINS = ['cixzhang']; + export const HEAVY_CANDIDATE = "(github.event_name == 'pull_request' && github.event.action == 'synchronize' && " + - "github.run_attempt == '1' && github.event.pull_request.user.login == 'cixzhang' && " + + `github.run_attempt == '1' && contains(fromJSON('${JSON.stringify(SHARED_AGENT_LOGINS)}'), github.event.pull_request.user.login) && ` + "github.base_ref == 'main' && !startsWith(github.head_ref, 'chore/merge-v'))"; /** `heavy-ci` decided to defer this push's heavy suites. */ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97ee8d65c6f75..f5c6a584bbe57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -366,7 +366,7 @@ jobs: # (any later attempt runs everything). A spec-only change is never deferred. # Every other push skips this job, and nothing waits for it. heavy-ci: - if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && ((github.event_name == 'pull_request' && github.event.action == 'synchronize' && github.run_attempt == '1' && github.event.pull_request.user.login == 'cixzhang' && github.base_ref == 'main' && !startsWith(github.head_ref, 'chore/merge-v'))) }} + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && ((github.event_name == 'pull_request' && github.event.action == 'synchronize' && github.run_attempt == '1' && contains(fromJSON('["cixzhang"]'), github.event.pull_request.user.login) && github.base_ref == 'main' && !startsWith(github.head_ref, 'chore/merge-v'))) }} runs-on: ubuntu-slim timeout-minutes: 5 permissions: @@ -786,8 +786,8 @@ jobs: paths: '^packages/native/.*(\.swift|/Package\.swift)$|^\.github/scripts/native-swift\.sh$|^\.github/actions/test-scope/' test-native-swift: - needs: [native-swift-runner] - if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (github.event_name != 'workflow_dispatch' && always() && !cancelled()) }} + needs: [heavy-ci, native-swift-runner] + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && needs.heavy-ci.outputs.deferred != 'true' && (github.event_name != 'workflow_dispatch' && always() && !cancelled())) }} runs-on: ${{ needs.native-swift-runner.outputs.applies == 'true' && 'macos-15' || 'ubuntu-slim' }} timeout-minutes: 30 permissions: diff --git a/.github/workflows/pr-comment.yml b/.github/workflows/pr-comment.yml index 3da241b0a89ce..3d5dc85ee2d05 100644 --- a/.github/workflows/pr-comment.yml +++ b/.github/workflows/pr-comment.yml @@ -419,7 +419,7 @@ jobs: sourceConclusion: process.env.SOURCE_CONCLUSION, }, fallbackMessage: require('node:fs').existsSync('heavy-ci-deferred/heavy-ci-deferred.json') - ? 'Heavy CI deferred: runs when marked ready and before merge. Lint and typecheck ran on this push.' + ? 'Heavy CI deferred: runs when marked ready and before merge. Lint, typecheck and any path-filtered checks ran on this push.' : undefined, }); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d24d2d63239b0..19324a87d8dca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -941,8 +941,9 @@ Storybook story for anything you added or altered. full at its pre-merge re-run. Engineering owners listed in `.github/ENGOWNERS` are not limited. - **Pull requests from the shared agent login run the heavy suites when ready - and before merge.** A push to a ready agent pull request runs lint and - typecheck; `test`, `build` and `docsite-test` fail with "Heavy CI deferred" + and before merge.** A push to a ready agent pull request runs lint and, in + the CI workflow, only typecheck (path-filtered workflows run as usual); + `test`, `build` and `docsite-test` fail with "Heavy CI deferred" until the pull request is marked ready or reopened, or its latest run is re-run with **Re-run all jobs**, which runs everything at that head. diff --git a/docs/specs/AST-030/spec.md b/docs/specs/AST-030/spec.md index 756f81e081250..ff091ddbc6999 100644 --- a/docs/specs/AST-030/spec.md +++ b/docs/specs/AST-030/spec.md @@ -320,9 +320,10 @@ they are separately named. code never runs with the repository's secrets. - **FR20 — An agent lane's push runs the fast checks; the heavy suites run when it is ready and before merge.** A push to a ready pull request authored by the - shared agent login, into `main`, in its run's first attempt, runs lint and - typecheck only, unless its trusted classification is spec-only. Its heavy jobs - skip, and the required contexts `test`, `build` and `docsite-test` (FR7) + shared agent login, into `main`, in its run's first attempt, runs only + typecheck in the CI workflow (lint keeps its own workflow), unless its trusted + classification is spec-only; the path-filtered workflows (FR14) are + unaffected. Its heavy jobs skip, and the required contexts `test`, `build` and `docsite-test` (FR7) report failure with "Heavy CI deferred: runs when marked ready and before merge", never success, so nothing merges on that run; the PR report says the same. Marking the pull request ready, opening or reopening it, and any later From e054d223812b7aa5f3f48d158517f5b3881ba2dc Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 1 Nov 2026 14:11:31 -0800 Subject: [PATCH 4/5] spec(AST-030): FR20's Verification row names the skipped-decision and failed-jobs re-run cases --- docs/specs/AST-030/spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/AST-030/spec.md b/docs/specs/AST-030/spec.md index 627ff2ce18cc5..c47399ac7e0a4 100644 --- a/docs/specs/AST-030/spec.md +++ b/docs/specs/AST-030/spec.md @@ -532,7 +532,7 @@ The nightly perf lane is the one scheduled measurement outside them (FR18). | FR17 | `ci-pr-gate.test.mjs` over every `pull_request` workflow and the shared conditions | branch here, fork with and without `ci:run`, another label, draft with the label, ready, push, merge group, dispatch | a job that can start on a pull request without the condition, a label trigger, a required job that skips for a pull request that may not run CI, or a draft or unlabelled fork that runs fails | | FR18 | `perf-nightly-workflow.test.mjs`, `perf-trend.test.mjs` and `run-labels.test.mjs` | nightly and dispatch; labelled, unlabelled, draft and unvouched-fork pull requests; write, read and no access; newest label event; failed lookup; 10% boundary; metric floors; seven-day window edges; fewer than three runs; a metric over once, twice in a row, or a different metric before; recorded and pull request runs | a run on an unlabelled pull request, a label honoured from read access, a regression called at exactly 10%, under its floor or on one run alone, a run outside the window in the baseline, a pull request run that records, or a second regression issue fails | | FR19 | `ci-impact.test.mjs`, `pr-preview.test.mjs` and the maintenance template | CI change and non-CI change; standard, non-standard and expression runners; added, removed and changed jobs; trigger change | a CI change reports no impact, a move onto a non-standard runner or a pool or time-to-green rise past its threshold goes unflagged, or the section names a price | -| FR20 | `ci-heavy-deferral.test.mjs` over ci.yml, lint.yml and pr-comment.yml | agent push; marked ready; opened; reopened; re-run; another author; release branch; merge-back; spec-only change | a heavy job runs on a deferred push, a required context passes or skips on one, a ready, reopened or re-run pull request defers, a spec-only change defers, or the report hides the deferral | +| FR20 | `ci-heavy-deferral.test.mjs` over ci.yml, lint.yml and pr-comment.yml | agent push; marked ready; opened; reopened; re-run; another author; release branch; merge-back; spec-only change; a release-check and an opt-in label run, where the decision skips; a failed-jobs re-run of a deferred push | a heavy job runs on a deferred push, a required context passes or skips on one, a ready, reopened or re-run pull request defers, a spec-only change defers, the report hides the deferral, or a job below the decision skips because it skipped | | FR21 | `ci-heavy-lanes.test.mjs` dispatch cases, `main-unit-run.test.mjs` over deploy.yml, `release-check-receipt.test.mjs` | push run; dispatched unit run; tip with the scheduler's or a person's release-check, or a person's maintenance capture; unit run cancelled, skipped or timed out; newer recheck or person's run of the tip; a person's validation of an older commit; cut commit with and without a passing unit run | a push runs the unit suites, a push cancels a unit run, a release-check comes without a unit run of its commit, an incomplete run goes unreported or waits a window, or a cut publishes without a passing unit run | | FR22 | `main-validation-workflow.test.mjs` over the Main workflow and its commit resolver; `vitest-shard-sequencer.test.mjs` over the recorded Node times | six UI and six Node legs; dispatch without a commit, with a commit in main's history, with a commit outside it and with a malformed one; a re-check from the workflow token and from a person, and one that names `sha`; a `sha` before the size records, and one of only spaces; four and six Node legs over the recorded suite, a file with no recorded time, a project other than `node` | a hold, a leg past the fifteen-minute budget, a commit outside main's history that validates, a person's dispatch or a person's `sha` run that cancels or replaces a validation of main, a `sha` lane that checks out another commit, or a `sha` run named for main's tip, or two of the slowest Node files in one leg, a file run by no leg or by two, or a split that depends on collection order fails | | FR23 | `release-check-shape.test.mjs`, `a11y-contract-shard.test.mjs` and the release-check workflow contract | release-check and pull-request legs; a contract file longer than a leg's share, one it cannot name exactly, and a serial one; the recorded suite; a re-check from an admin, a maintainer and a writer, from another branch, with a short SHA and with an older commit; failed-only beside a passed, a running and a missing run | a narrower release-check, a held or over-budget leg, a test selected by no leg or by two, a re-check that a writer can start, that waits for main's running check, or that re-runs jobs of another commit fails | From a325536451be58a0c224ad247fecaee0a427fea2 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 1 Nov 2026 14:32:07 -0800 Subject: [PATCH 5/5] ci: heavy jobs that need admission check it, so CI-full still stops them (AST-030 FR16) --- .github/scripts/ci-heavy-deferral.test.mjs | 25 +++++++++++++++++++--- .github/scripts/ci-pr-gate.mjs | 10 +++++++++ .github/workflows/ci.yml | 10 ++++----- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/.github/scripts/ci-heavy-deferral.test.mjs b/.github/scripts/ci-heavy-deferral.test.mjs index 43591c5e8e6b4..b26f3125db102 100644 --- a/.github/scripts/ci-heavy-deferral.test.mjs +++ b/.github/scripts/ci-heavy-deferral.test.mjs @@ -341,7 +341,7 @@ describe("heavy-ci's skip on every other run", () => { }); /** Every job's start as Actions decides it, ancestors included. */ - function plan(github, inputs = {}) { + function plan(github, inputs = {}, {full = false} = {}) { const results = {}; const order = []; const visit = id => { @@ -393,12 +393,17 @@ describe("heavy-ci's skip on every other run", () => { ), ); results[id] = { - result: runs ? 'success' : 'skipped', + // When CI is full, admission fails at its first step. + result: !runs + ? 'skipped' + : id === 'admission' && full + ? 'failure' + : 'success', outputs: id === 'run-labels' ? {a11y: 'true', rtl: 'true', visual: 'true'} : id === 'room' - ? {full: 'false'} + ? {full: String(full)} : {}, }; } @@ -424,6 +429,20 @@ describe("heavy-ci's skip on every other run", () => { expect(results[id].result, id).toBe('success'); }); + it('starts no heavy job when CI is full (spec:AST-030 FR16)', () => { + // A fork's run with ci:run cannot cancel itself, so only the jobs' own + // conditions stop it. + const github = pushEvent({login: 'someone'}); + github.event.pull_request.head.repo.full_name = 'someone/astryx'; + github.event.pull_request.labels = [{name: 'ci:run'}]; + const results = plan(github, {}, {full: true}); + expect(results.admission.result).toBe('failure'); + expect(results['heavy-ci'].result).toBe('skipped'); + // test-native-swift always runs to report its runner, as on main. + for (const id of HEAVY.filter(item => item !== 'test-native-swift')) + expect(results[id].result, id).toBe('skipped'); + }); + it('runs the suites an opt-in label asks for on a pull request', () => { const github = pushEvent({login: 'someone', action: 'labeled'}); github.event.pull_request.labels = [ diff --git a/.github/scripts/ci-pr-gate.mjs b/.github/scripts/ci-pr-gate.mjs index e3391d4287bbe..14725f9ba0bec 100644 --- a/.github/scripts/ci-pr-gate.mjs +++ b/.github/scripts/ci-pr-gate.mjs @@ -48,6 +48,8 @@ export function ungated(condition) { } if (expression.startsWith(`${HEAVY_RUNS} && (`)) expression = expression.slice(HEAVY_RUNS.length + 5, -1); + if (expression.startsWith(`${HEAVY_ADMITTED} && (`)) + expression = expression.slice(HEAVY_ADMITTED.length + 5, -1); return expression; } @@ -79,6 +81,14 @@ export const HEAVY_DEFERRED = "needs.heavy-ci.outputs.deferred == 'true'"; export const HEAVY_RUNS = "!cancelled() && needs.heavy-ci.outputs.deferred != 'true'"; +/** + * `!cancelled()` above also drops Actions' implicit success check, so a heavy + * job that needs admission and had no status check of its own checks + * admission itself: when CI is full admission fails, and the job must stop + * (spec:AST-030 FR16). + */ +export const HEAVY_ADMITTED = "needs.admission.result == 'success'"; + /** What a deferred required check and the PR report say. */ export const HEAVY_DEFERRED_MESSAGE = 'Heavy CI deferred: runs when marked ready and before merge'; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bc59fa7c76c6..b89a74e261fd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -686,7 +686,7 @@ jobs: test-ui: name: test-ui (${{ matrix.shard }}/${{ strategy.job-total }}) needs: [admission, heavy-ci] - if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && needs.heavy-ci.outputs.deferred != 'true' && (github.event_name != 'workflow_dispatch')) }} + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && needs.heavy-ci.outputs.deferred != 'true' && (needs.admission.result == 'success' && (github.event_name != 'workflow_dispatch'))) }} # One runner ran the whole UI project in ~16 minutes and set the PR's wall # time. Vitest's --shard gives each leg an equal share of the files; the # two slowest files (~3.5 minutes each) bound a leg, so more legs stop @@ -801,7 +801,7 @@ jobs: test-node: name: test-node (${{ matrix.shard }}/${{ strategy.job-total }}) needs: [admission, heavy-ci] - if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && needs.heavy-ci.outputs.deferred != 'true' && (github.event_name != 'workflow_dispatch')) }} + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && needs.heavy-ci.outputs.deferred != 'true' && (needs.admission.result == 'success' && (github.event_name != 'workflow_dispatch'))) }} # One runner ran the Node project in ~12 minutes. Vitest's --shard splits # the files across the legs; `test` joins them. strategy: @@ -895,7 +895,7 @@ jobs: # every test job; anything undecidable picks macOS. native-swift-runner: needs: [admission, heavy-ci] - if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && needs.heavy-ci.outputs.deferred != 'true' && (github.event_name != 'workflow_dispatch')) }} + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && needs.heavy-ci.outputs.deferred != 'true' && (needs.admission.result == 'success' && (github.event_name != 'workflow_dispatch'))) }} runs-on: ubuntu-slim timeout-minutes: 5 permissions: @@ -1114,7 +1114,7 @@ jobs: # sparse checkout, and a lookup that fails denies the suite. run-labels: needs: [admission, heavy-ci] - if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && needs.heavy-ci.outputs.deferred != 'true' && (github.event_name != 'workflow_dispatch' || inputs.operation == 'release-check')) }} + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && needs.heavy-ci.outputs.deferred != 'true' && (needs.admission.result == 'success' && (github.event_name != 'workflow_dispatch' || inputs.operation == 'release-check'))) }} runs-on: ubuntu-slim timeout-minutes: 5 permissions: @@ -2399,7 +2399,7 @@ jobs: # result. Until runtime:build has a specialized route, broad CI dispatches it. test-build: needs: [admission, heavy-ci] - if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && needs.heavy-ci.outputs.deferred != 'true' && (github.event_name != 'workflow_dispatch')) }} + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && needs.heavy-ci.outputs.deferred != 'true' && (needs.admission.result == 'success' && (github.event_name != 'workflow_dispatch'))) }} runs-on: ubuntu-24.04-arm timeout-minutes: 15 permissions: