diff --git a/.github/workflows/agent-guidance.yml b/.github/workflows/agent-guidance.yml index 7a6053a975..5a5b056331 100644 --- a/.github/workflows/agent-guidance.yml +++ b/.github/workflows/agent-guidance.yml @@ -29,6 +29,11 @@ on: permissions: contents: read +concurrency: + group: >- + ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: true + jobs: contract: name: Ownership and context budgets diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d5073aa57..4d35afaa3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,13 +30,16 @@ concurrency: cancel-in-progress: true jobs: + # Lint, typecheck, and package verification share one checkout and install. The lint gates + # and typecheck are independent reads of the tree, so they run as one parallel group. + # # The Swift trailing-comma assertion is text-only and runs before the toolchain setup, so a # grep failure does not wait on an install. The DI-seams check below needs a real TypeScript # runtime (#1976 / PR #2006), so it runs after Setup toolchain instead. - lint: - name: Lint & Format + lint-typecheck-package: + name: Lint, Typecheck & Package runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -69,51 +72,97 @@ jobs: - name: Setup toolchain uses: ./.github/actions/setup-node-pnpm - # Same false-green shape as the Swift check above (#1976). An earlier revision of this - # gate (PR #2006, first review pass) fixed the exit-code handling but kept the ban/allow - # decision as a regex that exempted matches by the *spelling* of the typeof target - # (`typeof fetch` always passed, SCREAMING_SNAKE_CASE targets always passed) — a name-based - # semantic allowlist that would silently pass a new, genuinely test-only `typeof fetch` seam - # anywhere in the tree while banning an equally legitimate seam under any other name. - # scripts/di-seams instead checks each match against an explicit, typed, per-site allowlist - # (scripts/di-seams/approved.ts) keyed by (file, field, typeof-target): a triple is exempt - # only because it was individually reviewed and named, never because of how it is spelled. - # The gate fails just as hard on a stale approval (one whose triple no longer matches - # anything) as on an unapproved seam, so the allowlist can't drift out of sync with the code - # it describes. See scripts/di-seams/model.ts and its tests. - - name: Fail if test-only DI seams reappear in production code - uses: ./.github/actions/run-gate - with: { gate: di-seams } + - parallel: + # Same false-green shape as the Swift check above (#1976). An earlier revision of this + # gate (PR #2006, first review pass) fixed the exit-code handling but kept the ban/allow + # decision as a regex that exempted matches by the *spelling* of the typeof target + # (`typeof fetch` always passed, SCREAMING_SNAKE_CASE targets always passed) — a name-based + # semantic allowlist that would silently pass a new, genuinely test-only `typeof fetch` seam + # anywhere in the tree while banning an equally legitimate seam under any other name. + # scripts/di-seams instead checks each match against an explicit, typed, per-site allowlist + # (scripts/di-seams/approved.ts) keyed by (file, field, typeof-target): a triple is exempt + # only because it was individually reviewed and named, never because of how it is spelled. + # The gate fails just as hard on a stale approval (one whose triple no longer matches + # anything) as on an unapproved seam, so the allowlist can't drift out of sync with the code + # it describes. See scripts/di-seams/model.ts and its tests. + - name: Fail if test-only DI seams reappear in production code + uses: ./.github/actions/run-gate + with: { gate: di-seams } + + - name: Run oxlint + uses: ./.github/actions/run-gate + with: { gate: lint } + + - name: Check formatting + uses: ./.github/actions/run-gate + with: { gate: format } + + - name: Run typecheck + uses: ./.github/actions/run-gate + with: { gate: typecheck } - - name: Run oxlint + - name: Build CLI uses: ./.github/actions/run-gate - with: { gate: lint } + with: { gate: build } - - name: Check formatting + - name: Verify emitted chunk ownership uses: ./.github/actions/run-gate - with: { gate: format } + with: { gate: bundle-owner-files } + + # The build runs on the default toolchain Node and the package is verified on the minimum + # supported Node, so this job covers what a user on `engines.node` floor actually installs. + - name: Setup Node.js 22.12 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: '22.12' - # Structural guards share one checkout and install. Every gate stays an - # independently named step with its own failure message; the gate manifest - # derives lane ownership from these declarations, so merging jobs changes - # nothing it asserts. + # Packs, lints the tarball with publint/attw, installs it outside the workspace, and imports + # every published entry point before running the CLI. See scripts/check-package.ts. + # + # Runs the script directly rather than through `pnpm check:package`: the repo's pinned pnpm + # requires Node >= 22.13 and refuses to start on the 22.12 floor this job exists to cover. The + # gate itself only needs `node` and `npm`, so it is the package.json script minus the launcher. + - name: Verify the published package on Node.js 22.12 + run: node --experimental-strip-types scripts/check-package.ts + + # Structural guards and the history-backed compatibility gates share one deep checkout and + # install. Every gate stays an independently named step with its own failure message; the gate + # manifest derives lane ownership from these declarations, so merging jobs changes nothing it + # asserts. repo-guards: - name: Repo Guards + name: Repo Guards & Compatibility runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 steps: # The layering ratchets (R6/R9/R10) measure the merge-base with origin/main, which a - # shallow checkout cannot reach. + # shallow checkout cannot reach. Fallow, replay-compat, and daemon-wire-compat need full + # history or tags. - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + fetch-tags: true # The layering gate parses production sources with `oxc-parser`, so # dependencies are required; keep install-deps enabled. - name: Setup toolchain uses: ./.github/actions/setup-node-pnpm + # The fallow audit runs in the background beside the guards below. A cold fallow run costs + # several minutes on a hosted runner: since fallow 3.20 every import re-walks the root + # tsconfig's project references. + - name: Run Fallow audit + id: fallow-audit + background: true + env: + FALLOW_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + uses: ./.github/actions/run-gate + with: + gate: fallow + args: | + --base + ${{ env.FALLOW_BASE }} + - name: Check import-direction DAG # Structured import-direction lint over the resolved graph. See # scripts/layering/check.ts and CONTEXT.md (Architecture: folder DAG + @@ -186,43 +235,6 @@ jobs: uses: ./.github/actions/run-gate with: { gate: mcp-metadata } - # Slowest guard (~3 min), so it runs last and structural failures surface - # before it. Runs on plain Node via the shared install above. - - name: Check numeric ranges - uses: ./.github/actions/run-gate - with: { gate: freerange } - - # History-backed compatibility gates share one deep checkout: fallow, - # replay-compat, and daemon-wire-compat all need either full history or tags, - # which no shallow unit lane can read. - compat-provenance: - name: Compatibility & Provenance - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Setup toolchain - uses: ./.github/actions/setup-node-pnpm - - - name: Run Fallow audit - env: - FALLOW_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} - uses: ./.github/actions/run-gate - with: - gate: fallow - args: | - --base - ${{ env.FALLOW_BASE }} - - - name: Check for production-unused exports - uses: ./.github/actions/run-gate - with: { gate: production-exports } - # The frozen replay-compat corpus (#1417) claims each entry was published by # a released tag. Only a full-history checkout can re-derive that claim. - name: Verify corpus entries against their released blobs @@ -235,52 +247,21 @@ jobs: # The daemon RPC wire ledger (#1432) is compared against the ledger as it # stood at the last RELEASED tag, which only a tagged checkout can read. - # The shallow Repo Guards lane holds the ledger to its source; this step - # holds it to the last release. - name: Compare the daemon RPC wire surface against the last released tag uses: ./.github/actions/run-gate with: { gate: daemon-wire-compat } - # Typecheck and package verification share one checkout and install; both - # need the default toolchain only, and the package step re-pins Node itself. - typecheck-package: - name: Typecheck & Package - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Setup toolchain - uses: ./.github/actions/setup-node-pnpm - - - name: Run typecheck + - name: Check numeric ranges uses: ./.github/actions/run-gate - with: { gate: typecheck } + with: { gate: freerange } - - name: Build CLI - uses: ./.github/actions/run-gate - with: { gate: build } + # Shares fallow's `.fallow` cache directory with the audit, so it starts once the audit ends. + - name: Wait for the Fallow audit + wait: fallow-audit - - name: Verify emitted chunk ownership + - name: Check for production-unused exports uses: ./.github/actions/run-gate - with: { gate: bundle-owner-files } - - # The build runs on the default toolchain Node and the package is verified on the minimum - # supported Node, so this job covers what a user on `engines.node` floor actually installs. - - name: Setup Node.js 22.12 - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 - with: - node-version: '22.12' - - # Packs, lints the tarball with publint/attw, installs it outside the workspace, and imports - # every published entry point before running the CLI. See scripts/check-package.ts. - # - # Runs the script directly rather than through `pnpm check:package`: the repo's pinned pnpm - # requires Node >= 22.13 and refuses to start on the 22.12 floor this job exists to cover. The - # gate itself only needs `node` and `npm`, so it is the package.json script minus the launcher. - - name: Verify the published package on Node.js 22.12 - run: node --experimental-strip-types scripts/check-package.ts + with: { gate: production-exports } # Runs the full unit + provider-integration suites under coverage with # thresholds, so a separate unit-tests job would rerun the same tests. diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f8e7882058..9050cbe633 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -42,6 +42,11 @@ on: permissions: contents: read +concurrency: + group: >- + ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: true + jobs: check: name: Prose, internal links, and heading anchors diff --git a/.github/workflows/pr-cancel-on-close.yml b/.github/workflows/pr-cancel-on-close.yml new file mode 100644 index 0000000000..63a527457e --- /dev/null +++ b/.github/workflows/pr-cancel-on-close.yml @@ -0,0 +1,44 @@ +name: Cancel runs of closed PRs + +# A merged or closed PR gates nothing, so its queued and running runs only hold runners. +on: + pull_request: + types: + - closed + +permissions: + actions: write + +jobs: + cancel: + # A fork PR's token is read-only, so it cannot cancel runs. + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # pr-preview-cleanup.yml runs on the same close event and must finish. + - name: Cancel the PR's unfinished runs + env: + GH_TOKEN: ${{ github.token }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + RUN_ID: ${{ github.run_id }} + run: | + ids=$(gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/runs" \ + -f branch="$HEAD_REF" -f event=pull_request -f per_page=100 \ + --jq ".workflow_runs[] + | select(.status != \"completed\") + | select(.head_repository.full_name == \"$GITHUB_REPOSITORY\") + | select(any(.pull_requests[]; .number == $PR_NUMBER)) + | select(.id != $RUN_ID) + | select(.path != \".github/workflows/pr-preview-cleanup.yml\") + | .id") + refused=() + for id in $ids; do + gh run cancel "$id" --repo "$GITHUB_REPOSITORY" || refused+=("$id") + done + # A run that finished after the listing refuses cancellation; any other refusal is an error. + for id in "${refused[@]}"; do + status=$(gh run view "$id" --repo "$GITHUB_REPOSITORY" --json status --jq .status) + [ "$status" = completed ] || { echo "::error::Could not cancel run $id ($status)"; exit 1; } + done diff --git a/scripts/gate/workflows.test.ts b/scripts/gate/workflows.test.ts index 21c43e8919..06a6b5b13d 100644 --- a/scripts/gate/workflows.test.ts +++ b/scripts/gate/workflows.test.ts @@ -221,6 +221,29 @@ jobs: assert.ok(caller?.uses.includes('.github/workflows/called.yml')); }); +test('gates inside a parallel step group belong to the lane, alongside sequential ones', () => { + const lanes = planted({ + '.github/workflows/planted.yml': `name: Planted +on: + pull_request: +jobs: + planted: + steps: + - parallel: + - uses: ./.github/actions/run-gate + with: + gate: layering + - uses: ./.github/actions/run-gate + with: + gate: lint + - uses: ./.github/actions/run-gate + with: + gate: format +`, + }); + assert.deepEqual(lanes[0]?.gates, ['layering', 'lint', 'format']); +}); + test('a remote or nested reusable workflow fails closed', () => { const lanes = planted({ '.github/workflows/planted.yml': `name: Planted diff --git a/scripts/gate/workflows.ts b/scripts/gate/workflows.ts index 1672a85430..b711641ca4 100644 --- a/scripts/gate/workflows.ts +++ b/scripts/gate/workflows.ts @@ -36,6 +36,7 @@ type RawStep = { uses?: string; run?: string; with?: Record; + parallel?: RawStep[]; }; type Job = { name?: string; steps?: RawStep[]; uses?: string; with?: Record }; @@ -179,6 +180,11 @@ function readLocalWorkflowJobs(uses: string, root: string): Job[] | null { return jobs.length > 0 ? jobs : null; } +/** A job's steps with each `parallel:` group opened into the steps it runs. */ +function runnableSteps(steps: readonly RawStep[]): RawStep[] { + return steps.flatMap((step) => (step.parallel ? runnableSteps(step.parallel) : [step])); +} + /** What a job runs, with the caller's `with` inputs that the steps' `${{ inputs.* }}` resolve to. */ type JobSteps = { steps: RawStep[]; @@ -192,13 +198,15 @@ type JobSteps = { * missing, jobless, or nested reusable workflow fails closed as unsupported. */ function jobSteps(job: Job, root: string): JobSteps { - if (!job.uses) return { steps: job.steps ?? [], inputs: {}, files: [], unsupported: [] }; + if (!job.uses) { + return { steps: runnableSteps(job.steps ?? []), inputs: {}, files: [], unsupported: [] }; + } const called = readLocalWorkflowJobs(job.uses, root); if (!called || called.some((calledJob) => calledJob.uses)) { const unsupported = [`\`uses: ${job.uses}\` (reusable workflow)`]; return { steps: [], inputs: {}, files: [], unsupported }; } - const steps = called.flatMap((calledJob) => calledJob.steps ?? []); + const steps = called.flatMap((calledJob) => runnableSteps(calledJob.steps ?? [])); return { steps, inputs: job.with ?? {}, files: [job.uses.slice(2)], unsupported: [] }; }