Skip to content

feat(ci): execute the gates that certify a pull request from the default branch - #179

Merged
mbeacom merged 10 commits into
mainfrom
mbeacom-ci-gates-repository-trust
Aug 27, 2026
Merged

feat(ci): execute the gates that certify a pull request from the default branch#179
mbeacom merged 10 commits into
mainfrom
mbeacom-ci-gates-repository-trust

Conversation

@mbeacom

@mbeacom mbeacom commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Closes #137.

What was wrong

Every CI gate here runs from the pull request's own checkout, so a pull request can neuter the check that is supposed to certify it and still produce a green required status. Measured, not assumed: #98's clean-clone-builds executed three steps named (network denied) that exist only on that branch, while main's ci.yml contained none.

Reading a script from a trusted base revision does not fix it — the step invoking it is equally under the pull request's control.

The option #137 didn't have

The issue judged required review the honest first move and dismissed "workflows outside the PR's control" as organization-only. That was right about organization required workflows and wrong about the capability.

pull_request_target runs outside the pull request's control on any repository, personal or not, and since 2025-12-08 GitHub takes the workflow file, every referenced action, and the actions/checkout commit for that event from the repository's default branch — not from the PR, and not even from its base branch.

What this ships

.github/workflows/trusted-gates.yml (new), on pull_request_target:

  • trusted-dco — the authoritative sign-off gate. Runs main's scripts/check-dco.ts from main's workflow. The PR's commits are fetched as git objects and read with git log; they are never checked out and never executed. ci.yml's dco job stays as a faster advisory report that can only fail open, and now says so.
  • gate-integrity — closes the other half. A required check is matched by name, so a PR that cannot edit the trusted job could still shadow it; every route to that runs through .github/workflows/. Blocks changes under .github/workflows/, .github/actions/, scripts/, packages/ci/, or CODEOWNERS unless a maintainer applies gate-change-acknowledged — which requires triage or write access, so an external contributor cannot self-authorize.

Safety rules for the privileged trigger, all verifiable in the diff: no PR code is checked out, installed, built or executed; no bun install (both checks import Node builtins only); read-only permissions; persist-credentials: false so the fetch is anonymous; untrusted values reach steps via env:, never ${{ }} inside a run: body; commit subjects are echoed inside ::stop-commands:: so a message reading ::error:: cannot forge annotations in a privileged run.

Required review is declined, with the reason recorded

The issue's preferred option is not available to this repository as a real control. GitHub does not let an author approve their own pull request, so with a sole maintainer who is also the sole code owner, required_approving_review_count >= 1 — or require_code_owner_review, which implies it — deadlocks every self-authored change. The escape is the admin bypass ruleset 19149458 already grants always, which turns the rule into a bypass performed on every merge. For an external contributor it adds nothing, because they cannot merge anyway.

A rule that is either a deadlock or a routine bypass is exactly what ADR-0016 warns about. ADR-0035 records the finding instead. The CODEOWNERS half is adopted anyway, as explicitness — with both caveats stated in the file so the lines aren't mistaken for a gate.

Evidence (ADR-0016)

gate-integrity's pass condition is an absence, which is where blindness hides. Observed failing on this change's own real changed-path list, not a fixture, through the CLI the workflow invokes — then passing once the label is applied, with the change still named rather than swallowed. All three fail-quiet guards observed firing:

Input Result
empty list listed no changed files, which cannot happen → exit 1
2 read, --expected-files 9 reports 9 changed file(s) but 2 were read → exit 1
{"message":"Not Found"} expected a JSON array, got object → exit 1

One defect was found this way rather than by review: formatBlock listed offending paths without the reason each was protected. The code was changed, not the test. The suite also fails if a surface is added to GATE_SURFACES without a case observing it block.

Also validated: actionlint clean on every workflow, adr lint 35 records / 0 errors, bun test 2681 pass / 0 fail, typecheck and lint clean, and the ::stop-commands:: trap verified to preserve a failing exit status while still restoring.

Not yet observed, stated plainly: the deployed workflow has never run and cannot before merge — pull_request_target takes the workflow from the default branch, so trusted-gates.yml is inert until it lands. ADR-0035 action item 3 stays open until both jobs are seen red then green on a real PR. That is why this PR is not claiming rung-2 evidence.

Independent security review, and what it changed

The implementation was reviewed against a fork-author-with-no-write-access threat model before this was proposed. Three findings, two of them bypasses. They're named in ADR-0035 rather than quietly fixed, because a record claiming a boundary is more trustworthy when it says where the boundary leaked during construction.

  1. A stale acknowledgment authorized later pushes (the serious one). The label was read from the event payload and never bound to a commit, so: open a small plausible scripts/ change → get it acknowledged → push a workflow edit. The synchronize run saw the same label and reported success over gate paths nobody looked at. Nothing else invalidated it — the main ruleset has no pull_request rule, so there isn't even stale-review dismissal to inherit.
    Closed by a dismiss-stale-acknowledgment job that removes the label on every push, and by reading labels from the API rather than the pre-dismissal payload. The timestamp alternative was rejected: commit dates are author-controlled, so GIT_COMMITTER_DATE would make a stale ack look fresh. Dismissal depends on no attacker-controlled value.
  2. .github/CODEOWNERS was unprotected while the root file was — and GitHub resolves .github/ first, so adding one supersedes the protected file without touching it. All three locations are covered now, asserted against a stated list rather than against the surface list (which can't see a surface that was never added — a small instance of ADR-0016's own subject).
  3. Attacker-chosen paths could forge workflow commands. Git permits a newline in a filename and the runner trims leading whitespace before testing for the :: prefix, so indentation was not protection. ::stop-commands:: now wraps both steps and printed paths escape every control and format character. JSON.stringify was insufficient — it leaves U+200B as invisible as it found it. The test caught that.

I also found one bypass myself before the review, by reading what the files endpoint actually returns: a rename reports only the new path, so moving trusted-gates.yml out of .github/workflows/ would have passed clean and deleted the gate on merge. A deletion was never affected, which is what made it easy to miss. Both ends of a rename are read now.

And one fail-open in the dismissal step: piping gh into grep -q lets a match close the pipe, gh dies of SIGPIPE, and under pipefail the pipeline reports failure — reading as "the label is gone" exactly when it is present.

The review also confirmed with evidence the properties this rests on: no PR code is checked out, installed, built or executed; no ${{ }} in any run: body; the fetch is genuinely anonymous under persist-credentials: false; and the runner masks the ::stop-commands:: token before echoing it, so it can't be learned and replayed.

Repository settings

Active now (applied and verified by re-reading the API):

  • sha_pinning_required falsetrue. Every action here was already SHA-pinned, so no workflow changed; it removes the ability to introduce a mutable tag later — including inside an acknowledged change.
  • Label gate-change-acknowledged created.

Must wait until merge — the exact verified commands are in docs/repository-trust-operations.md:

  • Adding trusted-dco and gate-integrity as required contexts on ruleset 19149458. This cannot be done before merge: the jobs don't exist until this lands, and a required context that never reports would block every PR including this one. The payload was constructed against the live ruleset, validated, and deliberately not sent.

Left as a maintainer decision: tightening fork-PR workflow approval from first_time_contributors to all_external_contributors. Real friction against a real hardening; the command is in the operations doc rather than applied silently.

What this does not close

Whoever can merge can change a gate and acknowledge the change. Merge access is the boundary it always was, and nothing here is tamper-proof. What is closed is narrower, and is what #137 was actually about: the check certifying a pull request is no longer authored by that pull request.


ADR-0035 is proposed and agent-drafted — it binds nothing until ratified.

…ult branch

Every CI gate here runs from the pull request's own checkout, so a pull
request can neuter the check that certifies it and still produce a green
required status. Measured on #98, whose clean-clone-builds executed three
"(network denied)" steps that exist only on that branch while main's ci.yml
contained none. Reading a script from origin/main does not fix it: the step
invoking it is equally under the pull request's control.

Move the gates that matter onto pull_request_target, which GitHub executes
from the repository's default branch — workflow file, referenced actions and
the actions/checkout commit alike. trusted-dco becomes the authoritative
sign-off gate, reading the pull request's commits as fetched git objects that
are never checked out or executed; ci.yml's dco job stays as a faster advisory
report that can only fail open. gate-integrity closes the other half: a
required check is matched by name, so a pull request that cannot edit the
trusted job could still shadow it, and every route to that runs through
.github/workflows/.

Both jobs import Node builtins only and run with no bun install, declare
read-only permissions, keep the token out of git via persist-credentials:
false, and pass untrusted values through env rather than ${{ }} inside run
bodies. Commit subjects are echoed inside ::stop-commands:: so a message
reading ::error:: cannot forge annotations in a privileged run.

check-gate-integrity's pass condition is an absence, so it refuses to pass
over an empty changed-file list, a truncated one, or an unreadable payload —
each observed firing, along with the guard itself blocking on this change's
real changed paths and passing once the label is applied (ADR-0016). The test
suite fails if a surface is added to GATE_SURFACES without a case observing it
block.

Required review is declined rather than shipped: GitHub does not let an author
approve their own pull request, so with a sole maintainer it deadlocks or is
waived by the admin bypass that is already always. ADR-0035 records that
reasoning. Repository-wide SHA pinning for actions is now required, and
docs/repository-trust-operations.md separates the controls that are active
from the ruleset change that cannot be applied until this lands.

None of this is tamper-proof, and nothing here says it is. Whoever can merge
can change a gate and acknowledge it. What is closed is narrower: the check
certifying a pull request is no longer authored by that pull request.

Closes #137

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Mark Beacom <m@beacom.dev>
Copilot AI balanced review requested due to automatic review settings August 26, 2026 21:48
@mbeacom mbeacom added gate-change-acknowledged A maintainer has seen and accepted this PR's change to the CI gate surface (ADR-0035) github_actions Pull requests that update GitHub Actions code labels Aug 26, 2026
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown

Decisions governing this change

  • 0001 — Record architecture decisions as versioned markdown in git
    • via path: docs/adr/**
  • 0007 — Isolate integrations as optional adapters and build only against public surfaces
    • via path: .github/workflows/**
  • 0010 — Use Bun as the package manager and test runner while publishing Node-targeted artifacts
    • via path: .github/workflows/**
    • via path: package.json
  • 0017 — Keep dependency audit scope explicit and release-scoped
    • via path: .github/workflows/ci.yml
  • 0025 — Ship badges as recipes over existing output, not a new CLI surface
    • via path: .github/workflows/ci.yml
    • via path: package.json
  • 0030 — Keep extension surfaces that carry a dependency tree outside this repository
    • via path: package.json
  • 0032 — Publish one lockstep OCI image after the coordinated release succeeds
    • via path: .github/workflows/ci.yml
  • 0035 — Execute the gates that certify a pull request from the default branch
    • via path: .github/workflows/**
    • via path: CODEOWNERS
    • via path: scripts/**

…pass

Found by reading what GitHub's files endpoint returns rather than assuming it.
A rename reports `filename` as the new path only, with the old one in
`previous_filename`. So a pull request that moved
.github/workflows/trusted-gates.yml to .github/wf/trusted-gates.yml presented
the guard with a path matching nothing, passed clean, and would have deleted
the trusted gate on merge.

A deletion was never affected — `filename` is the deleted path — which is
exactly what made the gap easy to miss.

Both ends of a rename are now read, which can only ever add a path. The entry
count and the path count are kept apart: --expected-files is compared against
the entry count, because a rename contributes two paths and comparing the
wrong one would fail every renaming pull request as "truncated", which is the
merge-stopping direction.

Observed blocking end-to-end through the CLI on a rename payload, with the
permanent negative cases kept alongside a case asserting the new path alone
would have evaded the matcher, so the test documents what it defends.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Mark Beacom <m@beacom.dev>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Protected-path renames and stale acknowledgment labels can bypass the new gate, while privileged logs remain injectable.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds default-branch-controlled CI gates to prevent pull requests from modifying their own certification logic.

Changes:

  • Adds trusted DCO and gate-integrity workflows.
  • Introduces gate-surface validation with tests.
  • Documents governance decisions and repository operations.
File summaries
File Description
.github/workflows/trusted-gates.yml Defines trusted PR gates.
.github/workflows/ci.yml Marks the existing DCO job advisory.
scripts/check-gate-integrity.ts Implements gate-path validation.
scripts/check-gate-integrity.test.ts Tests gate classification and failures.
scripts/check-dco.ts Documents trusted versus advisory execution.
package.json Adds the gate-integrity command.
CODEOWNERS Explicitly assigns gate surfaces.
docs/adr/0035-*.md Records the proposed trust architecture.
docs/repository-trust-operations.md Documents rollout and verification.
CHANGELOG.md Records the new controls.
Review details
  • Files reviewed: 10/10 changed files
  • Comments generated: 4
  • Review effort level: Balanced

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/check-gate-integrity.ts Outdated
function main(argv: readonly string[]): void {
const options = parseArgs(argv);

const paths = pluck(flattenPages(readJson(options.files)), 'filename');
Comment thread .github/workflows/trusted-gates.yml Outdated
Comment on lines +174 to +179
- name: List the pull request's changed paths and labels
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
PR_LABELS: ${{ toJSON(github.event.pull_request.labels) }}
Comment thread .github/workflows/trusted-gates.yml
mechanical and self-contained.
queuedAt: 2026-08-26T00:00:00Z
slaDays: 30
reviewBy: 2027-02-26
mbeacom and others added 3 commits August 26, 2026 18:01
… escape printed paths

Three findings from an independent security review of the privileged workflow,
against a fork-author-with-no-write-access threat model. Two were bypasses.

A stale acknowledgment authorized later pushes. The label was read from the
event payload and never bound to a commit, so: open a small, plausible
scripts/ change, get it acknowledged, then push a workflow edit. The
synchronize run saw the same label and reported success over gate paths nobody
had looked at. Nothing else invalidated it — the main ruleset has no
pull_request rule, so there is not even stale-review dismissal to inherit.

Closed by dismiss-stale-acknowledgment, which removes the label on every push,
and by reading labels from the API rather than the pre-dismissal payload. The
timestamp alternative was rejected: commit dates are author-controlled, so
GIT_COMMITTER_DATE would make a stale acknowledgment look fresh. Dismissal
depends on no attacker-controlled value.

.github/CODEOWNERS was unprotected while the root file was, and GitHub
resolves .github/ first — so adding one supersedes the protected file without
touching it. All three locations GitHub honors are covered, asserted against a
stated list rather than against the surface list, which cannot see a surface
that was never added.

Attacker-chosen paths could forge workflow commands: git permits a newline in
a filename and the runner trims leading whitespace before testing for the ::
prefix, so indentation was not protection. ::stop-commands:: now wraps both
steps and printed paths escape every control and format character.
JSON.stringify was insufficient — it leaves U+200B as invisible as it found
it, which the test caught.

Also removed a fail-open in the dismissal: piping gh into `grep -q` lets a
match close the pipe, gh dies of SIGPIPE, and under pipefail the pipeline
reports failure — reading as "the label is gone" exactly when it is present.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Mark Beacom <m@beacom.dev>
undefined/f.json and undefined/l.json were left by a verification probe whose
output path env var was unset, and were swept in by `git add -A`.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Mark Beacom <m@beacom.dev>
… ordered step

The separate dismiss-stale-acknowledgment job needed a `needs` edge plus an
`always()` guard to survive being skipped on non-push events. Dismissal only
has to happen before the labels are read, and step order gives that ordering
directly, so the machinery existed solely to reproduce across a job boundary
something a step boundary provides for free.

The cost is that gate-integrity now carries pull-requests: write. It is
contained — that job executes no repository code and nothing from the pull
request — and it removes an ordering race rather than adding one.

A correction, because the first version of this message asserted a cause that
was not true. I saw no workflow runs for two pushed commits, checked that
other apps' check suites had been created for the same SHA while
github-actions' had not, and concluded the head was being rejected — then
attributed it to this job's hyphenated `needs.dismiss-stale-acknowledgment`
dereference. Both claims were wrong. The runs were queued, not refused:
a61d811 reports three runs once they arrived, roughly five minutes after I
looked, and bf6cf5c has none only because the next push superseded it.

That is ADR-0016's subject exactly — an absence read as a fact, "I could not
see any runs" rendered as "no runs were created" — committed by an author who
had spent the session reading that record. The refactor is kept because it is
simpler on its own merits, not because it fixed anything.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Mark Beacom <m@beacom.dev>
@mbeacom
mbeacom force-pushed the mbeacom-ci-gates-repository-trust branch from 21ea500 to 6d988c4 Compare August 26, 2026 22:11
mbeacom and others added 5 commits August 26, 2026 18:12
ADR-0016 collects instances of a check or a claim reporting "nothing" when it
means "nothing visible". This session produced one, and it is worth more
written down than quietly corrected.

Two pushes appeared to produce no workflow runs; for the same head SHA other
apps' check suites existed while github-actions' did not, which is what a
rejected workflow file looks like. A cause was then attributed to a hyphenated
job id in a needs.<id>.result dereference and written into a commit message as
though established. The runs were queued, not refused.

Every individual observation was accurate — the suites really were missing at
the moment they were read. The defect was treating a read of a system with
latency as a final state, which is ADR-0016's own sentence.

Recorded here rather than in ADR-0016 itself, to avoid editing an accepted
record from a change about something else.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Mark Beacom <m@beacom.dev>
…hat they cover

Two material defects from adversarial review, both verified before fixing.

Retargeting a pull request's base fires `pull_request_target: edited` with
`changes.base` — not `synchronize`, which only fires when the head moves. The
workflow listened for neither. Because the head SHA does not change on a
retarget, the check runs computed against the old base stayed the latest
results for that SHA and kept the required contexts green, while the commit
range and the changed-file set both belonged to a base nothing had examined,
and the acknowledgment carried over to a diff nobody acknowledged.

`edited` is now an activity type, so both gates rerun; trusted-dco reports the
base move rather than printing an identical-looking range; and the
acknowledgment is dismissed when `changes.base` is present but not on a title
or body edit, because a control that fires on noise gets waved through on
signal.

The second defect was in the claims, not the mechanism. The workflow and
ADR-0035 both asserted that every route to neutering an advisory gate ran
through a protected path. It does not: ci.yml reaches typecheck, build, lint,
release:pack, check:deps, check:freeze-hashes, check:doc-pins, check:clause8,
check:no-spike-heuristics, check:site-grammar and adr lint through
`bun run <name>`, so the root manifest redirects eleven invocations across
three required contexts without touching GATE_SURFACES; self-dogfood runs adr
check out of packages/cli.

The claim is narrowed to what is true — the trusted gates are complete because
they invoke script paths directly, and the advisory gates cannot be made
trustworthy by any path list because they execute the pull request's own code.
The unprotected routes are enumerated in DOCUMENTED_UNPROTECTED_ROUTES and
pinned by a test, so the gap cannot move without the documentation moving with
it. Protecting package.json was considered and rejected: it would put the
acknowledgment on a weekly Dependabot bump while still not reaching the code
those gates run.

New assertions observed failing on the exact regressions they defend, by
mutating the file and rerunning: removing `edited` fires two; unwiring
changes.base fires the dismissal-scope case; un-paginating the label listing
fires its own; adding package.json to GATE_SURFACES fires five including the
documented-gap test.

Related fixes: ADR-0035 affects now covers all three CODEOWNERS locations;
operations doc §2.1 records that pull requests already open when the contexts
are added need a new event before they can report, with the command to confirm
it; the dismissal verification paginates like the files listing; and an empty
--expected-files fails as missing rather than coercing to zero, since
Number('') is 0 and Number.isInteger(0) is true.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Mark Beacom <m@beacom.dev>
… cancelling it

Two HIGH findings from re-review of the remediation. Both carried an
acknowledgment onto a head it was never granted for.

Close, push, reopen. Dismissal enumerated the actions that trigger it and
`reopened` was not among them. GitHub delivers no pull_request_target event for
a push to a closed pull request, and an author can close their own — so get a
small change acknowledged, close, push the workflow edit, reopen. The reopened
event arrives with the new head SHA, the dismissal step is skipped because its
condition did not name that action, and the label is still there. That restored
the full name-shadowing capability the gate exists to block. This repository's
own operations document had been describing the route as a feature, telling
operators to close and reopen to get a fresh event, which is how a live bypass
sat in plain sight.

Cancel the run that would dismiss. concurrency used cancel-in-progress: true.
The dismissal is the first step of gate-integrity, but a job needs a runner
before step one executes, so an author could push — queueing the run that would
delete their label — then immediately edit the title, an event they can fire at
will that shares the group and takes the in-shell early exit without
dismissing. The title edit cancelled the push's run before its DELETE ran. A
control whose job is to fire on a push must not be abortable by the pusher.

Dismissal is now an exclusion list naming only labeled and unlabeled, the two
activity types that cannot move the commit range, so anything added to types:
later dismisses by default. cancel-in-progress is false; both jobs are short
and install nothing, so queueing costs almost nothing.

Enumerating the cases that trigger a safety control produced three holes across
three reviews — edited, then reopened, and a near miss on opened — because
every enumeration is a claim of completeness over a set someone else controls.
A test now derives the expected set from the trigger list rather than restating
it.

Also fixed a case-folding inconsistency between two halves of one control: the
dismissal verification matched the label name case-sensitively while
classifyGateChanges matches it case-insensitively, so a label created as
Gate-Change-Acknowledged would have verified as absent while the gate still
honoured it.

Observed failing by mutation: reverting to the enumeration fires 4 assertions,
restoring cancel-in-progress fires its own, removing ascii_downcase fires its
own. One assertion was strengthened first — "the condition does not mention
reopened" is true of the correct exclusion list and of the enumeration that
omitted it, so it passed against the bug it existed to catch. It now evaluates
the condition instead of pattern-matching it.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Mark Beacom <m@beacom.dev>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Mark Beacom <m@beacom.dev>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Mark Beacom <m@beacom.dev>
@mbeacom
mbeacom merged commit 4d70b8a into main Aug 27, 2026
13 checks passed
@mbeacom
mbeacom deleted the mbeacom-ci-gates-repository-trust branch August 27, 2026 00:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gate-change-acknowledged A maintainer has seen and accepted this PR's change to the CI gate surface (ADR-0035) github_actions Pull requests that update GitHub Actions code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CI gates are executed from the PR checkout, so a PR can neuter the check that certifies it

2 participants