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
5 changes: 5 additions & 0 deletions .github/workflows/agent-guidance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
189 changes: 85 additions & 104 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 +
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions .github/workflows/pr-cancel-on-close.yml
Original file line number Diff line number Diff line change
@@ -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\")
Comment thread
thymikee marked this conversation as resolved.
| select(any(.pull_requests[]; .number == $PR_NUMBER))
| select(.id != $RUN_ID)
| select(.path != \".github/workflows/pr-preview-cleanup.yml\")
Comment thread
thymikee marked this conversation as resolved.
| .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
23 changes: 23 additions & 0 deletions scripts/gate/workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions scripts/gate/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type RawStep = {
uses?: string;
run?: string;
with?: Record<string, unknown>;
parallel?: RawStep[];
};

type Job = { name?: string; steps?: RawStep[]; uses?: string; with?: Record<string, unknown> };
Expand Down Expand Up @@ -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[];
Expand All @@ -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: [] };
}

Expand Down
Loading