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
79 changes: 71 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ jobs:
# A pull request needs the whole range for `git diff base...head`; a
# push or schedule classifies every flag as true without reading git.
fetch-depth: ${{ (github.event_name == 'pull_request' || github.event_name == 'merge_group') && 0 || 1 }}
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
# #640: the gate is policy, so its code comes from the base branch and
# never from the pull request it judges. `path: base` keeps it beside
# the checkout that supplies the data under test.
ref: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.repository.default_branch }}
path: base
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
Expand All @@ -78,11 +85,23 @@ jobs:
GROUP_BASE_REF: ${{ github.event.merge_group.base_ref }}
run: |
set -euo pipefail
# #640: the pull request that introduces `scripts/gate-integrity.mjs`
# runs this job with a base branch that predates the file. The test is
# against the base tree, so a diff cannot decide whether the summary
# runs; without the file there is no summary to write, and the merge
# still needs its T3 PASS.
gate_integrity() {
if [ -f base/scripts/gate-integrity.mjs ]; then
node base/scripts/gate-integrity.mjs "$@"
else
echo "::notice::gate-integrity is not on the base branch yet; this change introduces it"
fi
}
ref=""
case "$EVENT" in
pull_request) base="$PR_BASE"; head="$PR_HEAD"; ref="$PR_BASE_REF" ;;
merge_group) base="$GROUP_BASE"; head="$GROUP_HEAD"; ref="$GROUP_BASE_REF" ;;
*) node scripts/ci-changes.mjs --all; exit 0 ;;
*) node base/scripts/ci-changes.mjs --all; gate_integrity --all; exit 0 ;;
esac
# #752: a branch cut before the latest `main` names a base commit that
# this checkout does not carry, so the two-commit diff could not be
Expand All @@ -91,7 +110,10 @@ jobs:
# CodeQL never starts for an ignored path. The script fetches the base
# and retries; the conservative every-gate answer is still its last
# resort, and it lives there where the suite can drive it.
node scripts/ci-changes.mjs --git-base "$base" --git-head "$head" --base-ref "$ref"
node base/scripts/ci-changes.mjs --repo . --git-base "$base" --git-head "$head" --base-ref "$ref"
# #640: the reviewer must see that the gate is inside the diff this gate
# judges; an unreadable diff fails closed instead of reporting a clean one.
gate_integrity --repo . --git-base "$base" --git-head "$head" --base-ref "$ref"

lint:
name: lint
Expand Down Expand Up @@ -435,6 +457,13 @@ jobs:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
# #640: the gate is policy, so its code comes from the base branch and
# never from the pull request it judges. `path: base` keeps it beside
# the checkout that supplies the data under test.
ref: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.repository.default_branch }}
path: base
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
Expand All @@ -443,7 +472,7 @@ jobs:
# The choice lives in scripts/lib/risk.mjs (chooseBase) so that
# merge_group measures against its base sha instead of HEAD~1, and so
# test/riskBase.test.ts covers it. The step fetches what it needs.
run: echo "ref=$(node scripts/risk-gate.mjs --print-base)" >> "$GITHUB_OUTPUT"
run: echo "ref=$(node base/scripts/risk-gate.mjs --print-base)" >> "$GITHUB_OUTPUT"
# The branch and the title are the mechanical-release key (#80): only a PR
# whose head is release/vX.Y.Z and whose title is exactly "release: vX.Y.Z"
# is classified as the mechanical class. A PR that merely touches the same
Expand All @@ -453,7 +482,7 @@ jobs:
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_REF: ${{ steps.base.outputs.ref }}
run: node scripts/risk-gate.mjs --base "$BASE_REF" --branch "$PR_BRANCH" --title "$PR_TITLE"
run: node base/scripts/risk-gate.mjs --repo . --base "$BASE_REF" --branch "$PR_BRANCH" --title "$PR_TITLE"

# The drill imports `dist/db.js` and the checked-out `scripts/` only: no file
# under `node_modules` is loaded at runtime, so a second `npm ci` and
Expand Down Expand Up @@ -495,12 +524,19 @@ jobs:
contents: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
# #640: the gate is policy, so its code comes from the base branch and
# never from the pull request it judges. `path: base` keeps it beside
# the checkout that supplies the data under test.
ref: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.repository.default_branch }}
path: base
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
# No `npm ci`: the checker is plain node and reads only the workflow files,
# so a floating tag fails before any dependency install can run.
- run: node scripts/action-pins.mjs
- run: node base/scripts/action-pins.mjs --workflows .github/workflows

# actionlint reads the YAML, the expressions and the shell syntax. It has no
# taint analysis, so `${{ github.event.pull_request.title }}` inside `run:` is
Expand All @@ -516,13 +552,20 @@ jobs:
contents: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
# #640: the gate is policy, so its code comes from the base branch and
# never from the pull request it judges. `path: base` keeps it beside
# the checkout that supplies the data under test.
ref: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.repository.default_branch }}
path: base
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
# Plain node over the workflow files, so it runs in seconds and before any
# dependency install could be the thing that fails.
- name: Reject a workflow expression inside a shell script
run: node scripts/workflow-injection.mjs
run: node base/scripts/workflow-injection.mjs --workflows .github/workflows --allowlist base/.github/agent/workflow-injection-allowlist.json
- name: Lint the workflows
uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0
with:
Expand Down Expand Up @@ -622,6 +665,12 @@ jobs:
actions: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
# #640: the gate is policy, so its code comes from the base branch and
# never from the pull request it judges. `path: base` keeps it beside
# the checkout that supplies the data under test.
ref: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.repository.default_branch }}
path: base
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
Expand All @@ -636,7 +685,7 @@ jobs:
GITHUB_REPOSITORY: ${{ github.repository }}
# No `npm ci`: the script is plain node and reads only the API and the
# event payload, so a dependency install cannot be the thing that fails.
run: node scripts/codeql-required.mjs
run: node base/scripts/codeql-required.mjs

# #644: a deploy-only diff is not code, so the app gates skip it, but `deploy/`
# ships executable and syntax-sensitive files. `changes` sets `deploy` when any
Expand Down Expand Up @@ -721,17 +770,31 @@ jobs:
contents: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
# #640: the gate is policy, so its code comes from the base branch and
# never from the pull request it judges. `path: base` keeps it beside
# the checkout that supplies the data under test.
ref: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.repository.default_branch }}
path: base
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
# #640: a reviewer has to be able to see in the log which copy of the gate
# judged the pull request. This names the base tree's blob for the
# evaluator the next step runs, so a tampered copy on the branch cannot be
# mistaken for it — the sha printed here is the one the verdict came from.
- name: Name the evaluator that comes from the base tree
run: |
set -euo pipefail
echo "scripts/ci-ok.mjs from the base tree: blob $(git -C base rev-parse HEAD:scripts/ci-ok.mjs)"
# `skipped` is a failure here unless the job is named in SKIPPABLE_ON for
# this event, and a required job missing from `needs` fails too.
- name: Every required job passed
env:
NEEDS: ${{ toJSON(needs) }}
CHANGES: ${{ toJSON(needs.changes.outputs) }}
GITHUB_EVENT_NAME: ${{ github.event_name }}
run: node scripts/ci-ok.mjs
run: node base/scripts/ci-ok.mjs

# #305: a red `main` used to be invisible. `ci-ok` computed the commit's
# health and stopped, no job held a write scope on issues, and the branch was
Expand Down
9 changes: 8 additions & 1 deletion .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,17 @@ jobs:
# analyze exits 0 whether it found a sink or nothing at all, so the
# verdict is this step: fail on a high/critical result the recorded
# baseline does not already hold. It runs on a push to main too.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
# #640: the gate is policy, so its code comes from the base branch and
# never from the pull request it judges. `path: base` keeps it beside
# the checkout that supplies the data under test.
ref: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.repository.default_branch }}
path: base
- name: Fail on a new high or critical CodeQL result
env:
# #307: the advisory reads this run's review threads, and a step inherits
# no token — `pull-requests: read` grants the permission, never the
# credential. Scoped to this step, like the other credentials here.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/codeql-gate.mjs --sarif codeql-results --baseline .github/agent/codeql-baseline.json
run: node base/scripts/codeql-gate.mjs --sarif codeql-results --baseline base/.github/agent/codeql-baseline.json
45 changes: 45 additions & 0 deletions docs/autonomous-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,51 @@ CodeQL never starts for an ignored path. The short circuit for a diff that
touches only paths `codeql.yml` ignores is separate and is described under
[Supply-chain gates](#supply-chain-gates).

### Gate integrity: the evaluator runs from the base branch

A pull request decides nothing about itself. Every evaluator is checked out from
the base branch into `base/` and executed from there:

| Job | Runs |
|---|---|
| `changes` | `node base/scripts/ci-changes.mjs --repo . --git-base <base> --git-head <head>` |
| `risk` | `node base/scripts/risk-gate.mjs --repo . --base <ref>` |
| `action-pins` | `node base/scripts/action-pins.mjs --workflows .github/workflows` |
| `workflow-lint` | `node base/scripts/workflow-injection.mjs --workflows .github/workflows --allowlist base/.github/agent/workflow-injection-allowlist.json` |
| `codeql-required` | `node base/scripts/codeql-required.mjs` |
| `ci-ok` | `node base/scripts/ci-ok.mjs` |
| CodeQL gate | `node base/scripts/codeql-gate.mjs --sarif codeql-results --baseline base/.github/agent/codeql-baseline.json` |

Only the data under test comes from the head: the diff (`--repo .` names the
checkout that carries the pull request's commits), the workflow files the two
supply-chain checks read, and the SARIF the analysis produced. The CodeQL
baseline and the workflow-injection allowlist are read from `base/`, because a
pull request that could widen either would grade its own work.

`ci-ok` also prints the blob the base tree holds for the evaluator it is about to
run: `scripts/ci-ok.mjs from the base tree: blob <sha>`. That line is how a
reviewer tells the two copies apart in a run log — the sha named there is the
file the next step executes — so a branch that rewrites `scripts/ci-ok.mjs` is
visible as a mismatch instead of an invisible substitution. It is evidence, not
a gate: the verdict still comes from the base file.

The `changes` job also runs `scripts/gate-integrity.mjs`, which writes every
gate file in the diff into the job summary. It fails closed when the diff cannot
be read: a summary that says "no gate file" over an unreadable diff would be a
blind gate, not a clean one. The step runs it only when the base tree has the
file (`[ -f base/scripts/gate-integrity.mjs ]`), because the pull request that
introduces the summary runs against a base branch that predates it; the test is
on the base path, so a diff cannot decide whether the summary runs. Nothing
about the merge requirement changes — a diff that edits the gate is still T3 and
still needs a PASS at the tip.

What this does not move: for a `pull_request` event GitHub fetches the workflow
YAML itself from the pull request, so a job's own `run:` lines are, by
construction, the head's text. The protection for that shape is the required
check plus the T3 PASS on a diff the `gate-integrity` summary names, not this
checkout split. Moving the aggregate onto a `workflow_run` workflow, which always
executes the default branch's copy, is a separate slice.

### A red `main` reports itself

`ci-ok` still only computes the commit's health; the job that says so out loud is
Expand Down
11 changes: 10 additions & 1 deletion scripts/action-pins.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@
// job on any action reference that is not a 40-hex pin with its tag comment.
import { scanActorGates, scanWorkflows } from './lib/action-pins.mjs'

const directory = '.github/workflows'
// #640: the checker runs from the base branch, but the workflows it reads are
// the pull request's own — they are the data under test, so the directory is an
// argument.
function argument(name, fallback) {
const index = process.argv.indexOf(name)
if (index === -1) return fallback
return process.argv[index + 1] ?? fallback
}

const directory = argument('--workflows', '.github/workflows')
const report = scanWorkflows(directory)

for (const finding of report.findings) {
Expand Down
6 changes: 5 additions & 1 deletion scripts/ci-changes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,12 @@ function fail(message) {
// once by the base branch ref before that conservative answer is allowed — with
// its history, because a depth-limited fetch places the commit but grafts it as
// a shallow root, so the three-dot diff fails again, now with `no merge base`.
// #640: the classifier runs from the base branch tree, so the checkout that
// holds the pull request's commits is named instead of assumed to be the cwd.
const repo = argument('--repo')

function git(args) {
const result = spawnSync('git', args, { encoding: 'utf8' })
const result = spawnSync('git', repo === '' ? args : ['-C', repo, ...args], { encoding: 'utf8' })
return { ok: result.status === 0, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }
}

Expand Down
100 changes: 100 additions & 0 deletions scripts/gate-integrity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env node
// #640: name every gate file a pull request changes, in the job summary.
//
// The evaluators (`ci-ok`, `changes`, `risk`, `action-pins`, `workflow-lint`,
// `codeql-required`, `codeql-gate`) run from the base branch, so the pull
// request cannot edit the code that judges it. That is why this summary exists:
// the reviewer must still see that the gate itself is in the diff.
//
// node scripts/gate-integrity.mjs --repo . --git-base <sha> --git-head <sha>
// node scripts/gate-integrity.mjs --all (push, schedule, manual run)
//
// An unreadable diff fails closed: a summary that says "no gate file" while the
// diff could not be read would be a blind gate, not a clean one.

import { appendFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'

import { changedPathsForPullRequest } from './lib/ciChanges.mjs'
import { classifyGateFiles, gateSummary } from './lib/gate-integrity.mjs'

function argument(name, fallback = '') {
const index = process.argv.indexOf(name)
if (index === -1) return fallback
return process.argv[index + 1] ?? fallback
}

const repo = argument('--repo', '.')

function git(args) {
const result = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8' })
return { ok: result.status === 0, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }
}

function unshallow() {
if (git(['rev-parse', '--is-shallow-repository']).stdout.trim() !== 'true') return false
return git(['fetch', '--no-tags', '--unshallow', 'origin']).ok
}

function pathsFor(base, head, baseRef) {
return changedPathsForPullRequest({
base,
head,
baseRef,
git: {
diff: (from, to) => {
const result = git(['diff', '--name-only', '--no-renames', `${from}...${to}`])
if (!result.ok) return null
return result.stdout
.split('\n')
.map((line) => line.trim())
.filter((line) => line !== '')
},
hasCommit: (sha) => git(['cat-file', '-e', `${sha}^{commit}`]).ok,
fetchSha: (sha) => {
if (!git(['fetch', '--no-tags', 'origin', sha]).ok) return false
return git(['cat-file', '-e', `${sha}^{commit}`]).ok
},
deepen: unshallow,
fetchRef: (ref) => {
if (!git(['fetch', '--no-tags', 'origin', ref]).ok) return false
unshallow()
return true
},
},
})
}

function writeSummary(markdown) {
const output = process.env.GITHUB_STEP_SUMMARY ?? ''
if (output !== '') appendFileSync(output, markdown)
process.stdout.write(markdown)
}

if (process.argv.includes('--all')) {
writeSummary(gateSummary([], { changed: 0 }))
console.log('gate-integrity: ok — every path is in scope on this event')
process.exit(0)
}

const base = argument('--git-base')
const head = argument('--git-head')
const baseRef = argument('--base-ref')
if (base === '' || head === '') {
console.error('gate-integrity: usage: --git-base <sha> --git-head <sha> [--base-ref <ref>] | --all')
process.exit(2)
}

const paths = pathsFor(base, head, baseRef)
if (paths === null) {
console.error(`gate-integrity: cannot read the diff ${base}...${head}; failing closed`)
process.exit(2)
}

const files = classifyGateFiles(paths)
writeSummary(gateSummary(files, { changed: paths.length, base }))
console.log(
files.length === 0
? `gate-integrity: ok — no gate file in ${paths.length} changed path(s)`
: `gate-integrity: ok — ${files.length} gate file(s) in ${paths.length} changed path(s): ${files.join(', ')}`,
)
Loading
Loading