Repository navigation
ci(#23): add required PR-time Go/race/security gate - #34
Conversation
Adds `.github/workflows/pr-gate.yml` — workflow `PR gate`, one job
`Required PR gate` — as this repo's first PR-time verification gate. It
triggers unfiltered on every `pull_request` and on `push` to `main`, and
runs the five contractual commands sequentially in one job:
go build ./...
go vet ./...
go test -race ./...
go test -tags phase5release ./internal/securitytest -count=1
bash integration/clickhouse/tests/lib-tests.sh
Why one job with five steps rather than five jobs plus an aggregator: it
gives branch protection exactly one merge-relevant result. Any command
failing fails the job; there is no `needs` graph, no job-level `if`, and
no aggregator that could report success after a skipped dependency
(GitHub reports a condition-skipped job as successful).
Security posture: no `pull_request_target`, top-level `permissions:
contents: read` only, no `secrets.*`, no registry login, no Actions
environment, no publication, and `persist-credentials: false` on
checkout, so an untrusted PR's code never runs with repository write
authority. The two third-party actions are pinned by full commit SHA and
annotated with their exact release tags (`actions/checkout@v4.4.0`,
`actions/setup-go@v5.6.0`); `cache: false` keeps setup-go cache
restore/save off the untrusted-PR execution surface. `timeout-minutes:
15` bounds a hang with headroom for cold Linux setup, module download,
race instrumentation, and the socket-binding tests.
The real Docker ClickHouse compatibility matrix stays deliberately
outside this gate: `run-all-builds.sh` is neither invoked nor changed,
and the shell-library step (`tests/lib-tests.sh`) is Docker-daemon-free.
Shipping the gate makes existing prose false, so the same commit
reconciles it:
- `CLAUDE.md` — repo-map row for the new workflow; the "Build & test"
section now names `Required PR gate` as the mandatory PR/push gate and
keeps the two image publishers explicitly separate from verification
(a green publish is not a green gate); the future-facing linter
guidance now points at `pr-gate.yml` as the wiring target, without
adding `golangci-lint` here; "Working discipline" no longer claims
there is no PR-time CI, while keeping local verification mandatory.
- `README.md` — the "Building images" intro now describes the separate,
non-publishing third workflow.
- `skills/ship/references/repo-footguns.md` — replaces the "no PR-time
CI to wait on" guidance with head-SHA-keyed waiting on `Required PR
gate`, and states that the two publishers are not substitutes for it.
- `skills/ship/SKILL.md` step 2.5 — the coordinator must now wait for
`Required PR gate` at the exact current head SHA and treat missing,
pending, cancelled, or failed state as not ready.
Locally verified: all five commands green, plus `go vet -tags
phase5release ./internal/securitytest`; pinned actionlint v1.7.7 reports
no findings on the new workflow; `helm/ch-oauth-ldap/test.sh` passes
(0 failures), including its literal README/CLAUDE/footguns docs
assertions and the untouched-path proof. A temporary tagged fatal test
under `internal/securitytest` was proven to turn the `phase5release`
command red — showing future tagged security tests enroll automatically
with no workflow-side allowlist — then removed; that file is
deliberately not part of this commit.
Part of #23. Enabling classic branch protection to require exactly
`Required PR gate` is a follow-on rollout step, not part of this diff.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
…ed gate contract Internal review of ca79a9e found two factually inverted docs claims that the same commit introduced, plus three plan invariants that were enforced only by human static review. Two blocking inversions: - `CLAUDE.md`'s `helm/ch-oauth-ldap/test.sh` bullet said the chart gate is "like the Go gate, not run by any workflow". The Go gate *is* now run by a workflow — that is what ca79a9e added — so the comparison read backwards. It now says "unlike". - `CLAUDE.md`'s `integration/clickhouse/` repo-map row still ended "Manual/local gate, not CI", never mentioning `tests/lib-tests.sh`, which is the fifth command of the required gate. Someone editing `integration/clickhouse/lib/*.sh` would have concluded nothing there is CI-gated while in fact breaking `lib/leakscan.sh` turns `Required PR gate` red. The row now inventories `tests/lib-tests.sh` and distinguishes the manual Docker suite from the CI-run shell-library tests. `integration/clickhouse/README.md` gains the same one-sentence distinction. New contract test, per the plan author's explicit amendment: `internal/securitytest/pr_gate_contract_test.go` parses `.github/workflows/pr-gate.yml` and asserts its structure against expectations written as literals in the test source — unfiltered `pull_request` plus `push.branches: [main]` and nothing else, no `pull_request_target`, no `paths`/`paths-ignore` at any depth, top-level `permissions` exactly `{contents: read}`, exactly one job with no `if`/`needs`/`continue-on-error`, that job named exactly `Required PR gate`, the five contractual commands exactly once each in order, and every external `uses:` pinned to a full 40-hex commit SHA. No expectation is read out of the file under test, or the test would compare the file to itself and detect nothing. The action pins are checked for *immutability* (a 40-hex ref for a known action identity), not for today's specific SHA values, so a legitimate action upgrade is not test churn. It is deliberately untagged, matching `docs_contract_test.go`, `redaction_inventory_test.go` and `sdk_contract_test.go` (`release_gate_test.go` is the only `phase5release`-tagged file in the package). Untagged means it runs in plain `go test ./...` and in the gate's own `go test -race ./...` step. Its doc comment states plainly what it is not: an accidental-drift detector, not a tamper-resistance mechanism. It lives in the same pull-request-controlled tree as the workflow it inspects and runs only because that workflow runs it, so a PR that neuters or skips the job also neuters or skips this test. Real tamper resistance needs an enforcement authority outside the PR-controlled workflow (an organization-level required-workflow ruleset). What it buys is that an honest edit which quietly weakens the gate fails with a message naming the consequence. All five sabotage cases (job rename, added path filter, added continue-on-error, contents: write, unpinned checkout tag) were verified to fail with such a message. Remaining accuracy fixes: - `CLAUDE.md` (two places) hedged the tense on branch protection: `main` is still unprotected, and protection is enabled as part of #23's rollout after this merges. The substance — the job name is an externally consumed interface — is unchanged. - `CLAUDE.md` and `skills/ship/references/repo-footguns.md`: the two image publishers also carry `workflow_dispatch`, as `README.md` already stated. - `repo-footguns.md` and `skills/ship/SKILL.md` disambiguated "the gate": the local gate is `go build && go vet && go test`; hosted CI's `Required PR gate` runs a superset, cross-referenced rather than duplicated. - `CLAUDE.md` records two real limits of the gate's reach: `lib-tests.sh` is Docker-*daemon*-free but still needs the `docker` CLI on `PATH` (`lib/common.sh` calls `detect_compose_cmd` at source time and dies otherwise), and the gate never compiles `third_party/goldap`/`third_party/ldapserver`, which the root `go.mod` only `replace`s in. - `pr-gate.yml` gains one comment: if a merge queue is ever enabled on `main`, a `merge_group:` trigger must be added or queue entries stall. The trigger itself is deliberately not added. Verified: go build, go vet, go test -race ./... (includes the new untagged test), go test -tags phase5release ./internal/securitytest -count=1, bash integration/clickhouse/tests/lib-tests.sh, go vet -tags phase5release ./internal/securitytest, bash helm/ch-oauth-ldap/test.sh, and actionlint v1.7.7 on pr-gate.yml. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
…sts fail-safe
The new PR gate's first hosted Linux run (ubuntu-latest, linux/amd64, Go
1.26.7) failed at `go test -race ./...` in internal/ldap with two failures,
neither reproducible on darwin/arm64.
1. TestAdversarial_WriteDeadlineClosesStalledConnectionAndUnblocksGracefulShutdown
drained 41,943,235 bytes — the entire ~40 MiB response — and then timed
out on its own client-side read deadline. The server's write never
blocked, so WriteTimeout never fired and the mechanism under test was
never engaged. Real-socket backpressure cannot prove this property
portably: how much a non-reading peer absorbs before the server blocks is
host TCP buffer sizing (net.inet.tcp.sendspace/recvspace is 128 KiB on
darwin, where a 20 MiB write blocks at once; the hosted runner buffered
the whole thing). Any payload constant is a magic number racing the next
host's tuning — and an expensive one under -race.
The test now drives the real production server over an unbuffered
net.Pipe behind a minimal one-connection net.Listener. net.Pipe is
synchronous and completely unbuffered by construction, so a Write blocks
until the peer reads: the instant the client stops reading, the server's
flush is genuinely blocked with zero dependence on host tuning, and
net.Pipe implements the deadline setters the vendored write path needs.
Server.Serve takes a net.Listener and the vendored serve loop applies
deadlines through net.Conn, so only the transport is substituted. A
normal-sized Search response now suffices; the 20 MiB role,
fullResponseFloor and truncatedCeiling are gone. What this gives up is
end-to-end real-TCP backpressure, which was never this test's
distinctive job — protocol_test.go and friends cover real TCP
extensively, while writeMessage's SetWriteDeadline, the writer
goroutine's close-on-write-error path and Stop()'s drain all live above
the transport. The graceful-shutdown half is unchanged.
2. The ldapserver.Logger data race was a consequence of (1), not a
production defect. The reader goroutine belonged to the failing
write-deadline test and the writer was the next test's New(): a t.Fatalf
skipped that test's own ln.Close/serveErr/Stop/cancel teardown, leaking a
live server whose vendored client close() path then read the
package-level Logger global concurrently with the next New() writing it —
exactly the "at most one Server per process at a time" precondition
server.go's type doc already states. So internal/ldap/server.go is
deliberately unchanged: no init(), no sync.Once around the Logger
assignment. New() re-asserting the logging hardening on every
construction is intended, and that line is security-relevant and
machine-checked by internal/securitytest/redaction_inventory_test.go.
Instead, every hand-rolled listener + Serve goroutine in the package's
tests now goes through a shared startServing helper that registers a
sync.Once-guarded, bounded teardown with t.Cleanup before any assertion
can abort — mirroring protocol_test.go's startTestServer, preserving its
load-bearing ordering (close our own listener, synchronize on serveErr,
only then Stop()). serveErr is closed after its single send so repeated
receives are safe, which lets the tests whose subject IS graceful
shutdown keep their explicit assertions and bounds while the cleanup
becomes a no-op.
Verified: go build ./..., go vet ./..., go test -race -count=1 ./...,
go test -race -count=5 ./internal/ldap (no DATA RACE),
go test -tags phase5release ./internal/securitytest -count=1,
go vet -tags phase5release ./internal/securitytest, and
bash integration/clickhouse/tests/lib-tests.sh — all green. Sabotaging
srv.ldapSrv.WriteTimeout to 60s makes the rewritten test fail as intended
("server delivered 163 bytes of the stalled response after 2.5s of the
client reading nothing"), and that same failing run no longer produces the
Logger race in the test that follows it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
fb2f6e5 replaced this test's real-TCP, buffer-size-dependent stall with an unbuffered net.Pipe, but PATCHES.md still described the old mechanism ("a shrunk receive buffer plus a volume of ordinary, fast-completing Binds") — which was already stale before that commit and is now doubly so. Records the reason the substrate changed, since that reason is the load-bearing part: whether a real socket write blocks at all depends on host TCP buffer sizing, and the 20 MiB response that stalled immediately on macOS was delivered in full on a hosted Linux runner without ever engaging WriteTimeout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
Temporary, removed by the immediately following revert commit in this same PR. Issue #23 Definition-of-Done item 11 requires proving on real hosted CI that a deliberately failing security test makes the required check red, so the gate cannot be mistaken for advisory. Tagged `phase5release` on purpose. An earlier accidental red run on this branch already showed a `go test -race ./...` failure turning `Required PR gate` red, but that says nothing about the security step, which never even executed (the job is sequential, so later steps were skipped). This failure is invisible to `go test -race ./...` and fails only `go test -tags phase5release ./internal/securitytest -count=1`, isolating that step's own wiring to the required status. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
Reverts e37e24d, whose only purpose was to prove on real hosted CI that a failing security contract makes the required check red. Proof captured before this revert (run 33277581488, head e37e24d): 4. Build success 5. Vet success 6. Race tests success 7. Phase 5 security tests FAILURE <- TestPRGateDeliberateFailureProof 8. ClickHouse shell library skipped job "Required PR gate" FAILURE check-run context "Required PR gate" conclusion=failure Steps 4-6 passing is what makes it a proof rather than a coincidence: the red result is attributable to the security step alone. Issue #23 Definition-of-Done item 11 satisfied. Reverted with a normal inverse commit rather than a force-push, so the red run and the commit that caused it stay in this PR's history as the audit trail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
ChatGPT review pass 1Reviewed head: Findings
Focused checks
VERDICT: REVISE |
ChatGPT review of PR #34 flagged that pr-gate.yml pinned actions/checkout@v4.4.0 and actions/setup-go@v5.6.0, both still declared `using: node20` in their action.yml even though the hosted run only succeeds today because GitHub runners re-execute node20-declared actions on Node 24 as a temporary compatibility shim (Node 20 itself leaves runner images on 2026-09-23). Native node24 releases were already available for both actions. Re-pinned to actions/checkout@v7.0.1 (3d3c42e5aac5ba805825da76410c181273ba90b1) and actions/setup-go@v7.0.0 (b7ad1dad31e06c5925ef5d2fc7ad053ef454303e) — both SHAs verified against the real actions/checkout and actions/setup-go tag refs, and both actions' action.yml confirmed to declare `using: node24` at those commits. Neither upgrade changes this workflow's actual usage (checkout's `persist-credentials: false`, setup-go's `go-version-file`/`cache: false`). Added TestPRGateContract_ActionPinsAreAtOrAboveTheNode24Floor to internal/securitytest/pr_gate_contract_test.go so a future re-pin back below the node24 floor fails the gate's own contract test instead of silently riding the runner's compatibility shim again. Since the invariant this file enforces elsewhere is SHA immutability (no version readable from the pin itself), the new test reads each pin's trailing `# actions/<name>@vX.Y.Z` comment and compares it against a literal per-action floor (checkout >= v6.0.0, setup-go >= v7.0.0). Verified with a sabotage case: downgrading the checkout pin's comment to v4.4.0 makes the new test fail with the expected message; restored and re-verified green afterward. The review's second finding (PR #34's body is stale relative to its actual head) is out of scope for this change — it is a GitHub PR description edit, not a repository file, and outside this change's mutation boundary (no `gh` mutations). Left for the coordinator to update directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
Review pass 1 raised both of these and the verification pass rejected them as unreachable. That is correct about today's call sites and beside the point now that `startServing` and `singleConnListener` are shared infrastructure behind seven server-start sites: both were silent fail-opens whose contract held only because of how their one caller happened to be sequenced. singleConnListener.Accept selected over `handout` and `closed` in a single select. When several cases are ready Go picks one uniformly at random, so Close() followed by Accept() returned the queued connection roughly half the time — measured 5007/10000 before this change, 0/10000 after — violating net.Listener's contract that Accept fails once Close has been called. Accept now checks closure first in its own non-blocking select. startServing's two 15s teardown timeouts had empty bodies. A timeout on the first meant proceeding to srv.Stop() without the serveErr synchronization point the helper's own doc comment calls load-bearing — committing the exact Stop-racing-Serve data race that ordering exists to prevent — and a timeout on the second meant returning with a live Stop goroutine still able to touch the vendored server during a later test's New(). Both now t.Errorf, so a wedged Serve or Stop fails the test that caused it instead of surfacing later as an unexplained race somewhere unrelated. Neither branch executes unless something has already gone wrong, so no passing test changes behavior. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
ChatGPT review pass 2Previously reviewed head: Reviewed head: Reassessment of pass 1
Findings
Focused checks
VERDICT: REVISE |
Close four ChatGPT pass-2 findings against ci/pr-gate:
- internal/ldap/protocol_test.go: startServing's stop() fell through to
srv.Stop() after a serveErr fail-safe timeout because t.Errorf (Logf+Fail)
does not halt execution — exactly the unsynchronized Stop-racing-Serve
data race the ordering exists to prevent. Extracted the body into
runShutdownSequence, which now returns immediately after reporting a
serveErr timeout instead of proceeding to Stop().
- internal/securitytest/pr_gate_contract_test.go:
- TestPRGateContract_ActionPinsAreAtOrAboveTheNode24Floor now cross-checks
a pin's trailing version comment identity against its real `uses:`
identity (a stale/mismatched comment no longer parses a passing
version out of the wrong action's text), and corrects the
actions/checkout node24 floor to v5.0.0 (the actual first-node24
release; v6.0.0 was never it).
- TestPRGateContract_TopLevelPermissionsAreReadOnly now also checks the
job's own `permissions:` block, since GitHub Actions job-level
permissions replace rather than merge with the workflow-level grant.
- TestPRGateContract_FiveCommandsRunExactlyOnceInOrder now reduces each
run: step to the single command it actually executes (prGateScriptCommand)
and matches exactly, rather than substring-counting — a step like
`run: echo 'go build ./...'` no longer satisfies the contract.
Extracted the changed logic into small pure functions (runShutdownSequence,
pinVersionComment, checkActionNode24Floor, permissionsMapping/Match,
prGateScriptCommand) so each fix has a direct unit test exercising the
exact bypass it closes, in addition to the existing contract tests still
passing against the real files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
Pass 2 corrected the actions/checkout floor from v6.0.0 to v5.0.0 after finding the cited "first node24 release" was wrong. The same class of error remained for the other action: the comment claimed "setup-go v6.x is still node20" and set the floor at v7.0.0. Verified by fetching each action.yml at the tag boundary: actions/checkout v4.3.1 node20 -> v5.0.0 node24 (floor v5.0.0 correct) actions/setup-go v6.1.0 node20 -> v6.2.0 node24 (floor was v7.0.0) setup-go's node24 line starts mid-v6, not at the major bump. The old floor was safe (stricter than necessary) but its stated justification was false, and it would have rejected a legitimate node24 pin at v6.2.x while claiming that release was node20. Floor is now the true boundary and the comment records how it was determined, so the next person to touch a pin can check it rather than trust it. The workflow's own pin stays at setup-go v7.0.0, comfortably above. Sabotage-verified at this head, each restoring the workflow byte-identical: job-level `permissions: contents: write` -> job-level permissions check fails comment identity swapped to another action -> identity cross-check fails setup-go comment set to v6.1.0 -> node24 floor fails, naming v6.2.0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
ChatGPT review pass 3Previously reviewed head: Reviewed head: Reassessment of earlier findings
Findings
Focused checks
VERDICT: REVISE |
Close the base-branch-retarget fail-open path in the PR gate: a bare `pull_request:` trigger's default activity set (opened/synchronize/ reopened) never re-fires on a retarget, which GitHub delivers as `edited` on the same head SHA — so a PR proven green against base A could be retargeted onto protected `main` and keep that same green required check with the new merge result never tested. Add `pull_request.types: [opened, synchronize, reopened, edited]` to pr-gate.yml and update pr_gate_contract_test.go's trigger contract to require that exact `types` list (previously it forbade any `pull_request` configuration on principle). Also fix stale node24-floor version numbers in the contract test's header comment (v6.0.0/v7.0.0) to match the real, already-corrected prGateNode24FloorByAction map (v5.0.0/v6.2.0) it sits above. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
… node24 check honestly Post-cap architecture consultation returned REVISE on two test-quality points beyond its main branch-protection finding. Both are addressed here. 1. startServing's teardown no longer bounds each step with a 15s fail-safe. That machinery was introduced during THIS PR and is what created the problem: on timeout it reported via t.Errorf and returned, releasing the next test while the previous server was still live — which is precisely how a wedged Serve becomes an inexplicable ldapserver.Logger race in some unrelated test later in the run, since that global is reassigned by every New(). It traded a bounded hang for silent cross-test contamination. Teardown is now `ln.Close(); <-serveErr; srv.Stop(); cancel()` — unbounded, identical to what startTestServer used unchanged throughout the #19 series and what origin/main still has. A teardown bug must fail-stop this process rather than hand the next test a contaminated one; if Serve or Stop genuinely wedges, `go test`'s own timeout panics with a full goroutine dump, which is strictly better diagnostics than an Errorf and lands well inside pr-gate.yml's 15-minute job bound. Removes failSafeShutdownTimeout, the errorfer seam, runShutdownSequence, fakeErrorfT, and the three TestStartServing_* tests whose only subject was the timeout branches that no longer exist. Net -180 lines. 2. TestPRGateContract_ActionPinsAreAtOrAboveTheNode24Floor now states its scope limit instead of implying more than it proves. It verifies the trailing version COMMENT, not the pin: a commit SHA cannot be resolved to a version, let alone to the runtime its action.yml declares, without network access a unit test must not have. So a regressed node20 SHA carrying a plausible above-floor comment for the same action still passes. That gap is accepted deliberately — an approved identity/SHA/runtime table would make the binding real but converts every legitimate upgrade into unrelated churn and re-couples to exact SHAs, which is what pinning for immutability deliberately avoids (immutability itself is proven independently and completely by ExternalActionsArePinnedToFullCommitSHA). The check is version-comment hygiene with a floor: useful against the realistic accident, honestly not a runtime oracle. The consultation's main finding — branch protection must be strict/up-to-date rather than loose — is a rollout-configuration change, recorded in the ship log and applied when protection is enabled after merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
ChatGPT review pass 1Reviewed head: Findings
Focused checks
VERDICT: REVISE |
Rename the node24-floor pin check's test, helper, and diagnostics away from "ActionPins"/"checkActionNode24Floor" to "ActionPinVersionComments"/"checkVersionCommentNode24Floor" naming, and reword the below-floor t.Errorf to say the version *comment* claims a version rather than describing the pin itself as "pinned to" that version. The check only ever validated the human-readable trailing comment (a commit SHA cannot be resolved to a version or declared runtime offline, per its own documented scope limit) — the prior names and message overstated that as verifying the pin. The separate TestPRGateContract_ExternalActionsArePinnedToFullCommitSHA remains the real immutable-pin guarantee, unchanged. The other two accepted pass-1 findings (issue #23's ship-log rollout record still specifying strict:false; PR #34's description citing a stale head/verification audit trail) are ship-log/PR-body edits, not code changes, and are out of scope for this branch's mutation boundary (no issue or ship-log edits, no gh mutations) — left for the coordinator to apply directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
ChatGPT review pass 2Previously reviewed head: Reviewed head: Reassessment of pass 1
Finding
Focused checks
VERDICT: SHIP |
Reword the stale SCOPE LIMIT comment on TestPRGateContract_ActionPinVersionCommentsAreAtOrAboveTheNode24Floor: it still framed the limitation as "the test's name promises more than it can deliver", leftover from before the same commit's rename from TestPRGateContract_ActionPinsAreAtOrAboveTheNode24Floor. The current name already scopes itself to version comments, so state the scope limit directly without referencing the old name. (PR #34's stale "five runs" body heading is a text-only PR-description fix outside this commit's code-only mutation boundary — left for a follow-up gh pr edit, as in the prior pass.) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
ChatGPT review pass 3Previously reviewed head: Reviewed head: Reassessment of pass 2
Findings
Focused checks
VERDICT: SHIP |
The contract test's header comment read "GitHub Actions runners stopped shipping Node 20 on 2026-09-23" — past tense for a date roughly three weeks ahead. A reader before that date could reasonably conclude the removal had already happened, and therefore that the node24 floor was either moot or already breached; the opposite is true, which is exactly why the floor exists. The two other references to the same date (lines ~165 and in the diagnostic itself) already read "is withdrawn ... on", so only this one was wrong. Review declined this as a purely cosmetic nit on non-machine-checked prose, which is fair on impact. Fixed anyway: several rounds of review on this PR turned on comments and records claiming something that was not true — a version floor justified by a wrong first-node24 release, a scope limit the test's own name contradicted, a rollout table asserting a setting nobody had recorded. Leaving a knowingly-wrong tense in a deadline comment would be inconsistent with all of that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
…UB_TOKEN retarget limit Second post-cap consultation found no code-level bypass at 7006c60 but two real accuracy defects. Both are the same class this PR keeps producing: a comment asserting something that is not true. 1. The contract test claimed a `paths:` filter lets a pull request "merge unverified". That is backwards. GitHub documents that when a workflow is skipped by path or branch filtering, its associated required check stays *Pending* and blocks merging — so a path filter fails CLOSED. The genuinely fail-open case is a skipped JOB, which GitHub reports as *Success* and which therefore satisfies a required check; that is a different mechanism, already covered by TestPRGateContract_JobIsUnconditionalAndFailsClosed. Verified against GitHub's required-status-checks troubleshooting docs. The no-path-filters policy is unchanged and still correct, but for the honest reasons: universal coverage, no deadlocked PRs whose stuck required check invites someone to drop the requirement, and no judgement calls about which paths "matter" when a shared file, a dependency bump or a generated fixture moves the LDAP/security boundary in a non-obvious way. The header comment now separates all three skip modes — filtered workflow (pending, blocks), skipped job (success, satisfies), narrowed `types` (no run, stale check still satisfies) — because conflating them is what produced the wrong claim. 2. `edited` closes the retarget gap only for base changes whose events actually start Actions. Events produced with the repository's own GITHUB_TOKEN do not, so automation retargeting a PR base that way would bypass the new trigger at the event-delivery layer. Recorded as a constraint in the workflow itself, deliberately without a contract test: a YAML check cannot observe it, and pretending otherwise would be another false assurance. `strict: true` narrows the reachable cases but is not a complete substitute, since an unchanged head can already be up to date with the new base. Issue #23's normative "Required workflow" snippet still showed a bare `pull_request:`; it now carries the explicit four activity types with the rationale, since a future implementation working from that section would otherwise faithfully reproduce the unsafe trigger. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
…iagnostics Third post-cap consultation again found no code-level bypass, and again found this file claiming things that are not true. Both are refinements of the previous round's own fixes. 1. The GITHUB_TOKEN statement was too broad. "Events produced with GITHUB_TOKEN do not start new workflow runs" is the general rule, but it has documented exceptions: workflow_dispatch and repository_dispatch always create runs, and pull_request with opened/synchronize/reopened does create runs — in an APPROVAL-REQUIRED state. `edited` is not an exception, which is the part that actually matters here. The previous wording also implied automation could retarget safely if it "also produces a real synchronize". A GITHUB_TOKEN-generated synchronize yields an approval-gated run, not silent revalidation, so an operator reading that could expect automatic re-verification that does not happen. Both cases fail closed; neither revalidates automatically. Narrowed in the workflow and in issue #23. The deliberate absence of a contract test for this was confirmed correct: a structural YAML check cannot observe an event-delivery property. 2. The path-filter correction was applied only to the flagged instance. Two sibling diagnostics kept the identical fail-open misconception: - missing workflow: "nothing verifies a pull request before it merges to main" - unparseable YAML: "does not run, so PRs would merge unverified" Neither is true once `Required PR gate` is required: an unreported required check blocks the merge, and GitHub generates a FAILED run for an invalid workflow file. Both now say the gate becomes unavailable and merges stall or fail closed, and name the real hazard — dropping the requirement to unblock a stuck queue is the separate action that permits unverified merging. I swept every remaining diagnostic in the file rather than only the two named. The other three sites are already accurate and deliberately unchanged: the job rename comment and diagnostic both say merges "stall (or, if protection is dropped instead, merge unverified)", and the five-commands diagnostic's "no longer gates merges" is correct — a dropped command leaves the job running and reporting green, which genuinely does fail open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
Fourth post-cap consultation caught a material misclassification — and a
self-contradiction in the previous commit's own comment.
That comment said the GITHUB_TOKEN `edited` case and the GITHUB_TOKEN
`synchronize` case "both fail closed", then two sentences later correctly
noted that `strict: true` is "not a complete substitute, since an unchanged
head can already be up to date with the newly selected base". Both cannot be
true, and the second one is right.
The retarget case is FAIL-OPEN, and it is precisely the path the `types` list
was added to close:
1. head H already has a successful `Required PR gate`
2. automation retargets the base to `main` using GITHUB_TOKEN
3. `pull_request.edited` is produced but creates no workflow run
4. head SHA is still H — nothing invalidates the existing check
5. H already contains the current `main` tip, so `strict: true` is already
satisfied and demands no fresh run
6. the old green check on H satisfies branch protection, and the untested
new merge result merges
So "automation must not retarget with GITHUB_TOKEN" is a real prohibition
rather than a stylistic preference, and saying otherwise would mislead an
operator into treating an unsafe automation as safe. The `synchronize` case
genuinely does fail closed — it creates an approval-required run — but it is
not silent revalidation either.
Corrected in both the workflow and issue #23, with the two cases split
explicitly rather than lumped together. The deliberate absence of a contract
test remains correct and confirmed: this is an event-delivery and
branch-protection property that a structural YAML check cannot establish.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
What this does
Adds
.github/workflows/pr-gate.yml— this repository's first PR-time verificationgate. Workflow name
PR gate, single jobRequired PR gate, triggered on everypull request (unfiltered) and every push to
main, running exactly the five commandsissue #23 specifies:
Until now the repo had no PR-time CI at all: the two existing workflows only build and
push images on push to
main. This gate is a hard prerequisite for #33 (the ADR #32 LDAPparser replacement), whose security-relevant changes must not depend on a coordinator
running local commands by hand.
Part of #23.
Design decisions
One job, five sequential steps — not multiple jobs plus an aggregator. This yields
exactly one merge-relevant check name and no
needsgraph. The decisive reason: a jobskipped by a job-level condition reports success, so an aggregator is a
silent-waiver shape rather than a fail-closed one.
Required PR gateis an externally consumed interface. It is the exact check-runcontext branch protection on
mainwill require, so renaming the job means migratingbranch protection in the same change. That is now machine-enforced, not just documented.
Minimal authority over untrusted PR code.
pull_request(neverpull_request_target),permissions: contents: read,persist-credentials: false,cache: false, no secrets, no registry login, nothing published. The explicitpermissions:block is load-bearing: this repository'sdefault_workflow_permissionsis currently
write.Action pins. Both actions are pinned to full commit SHAs, each annotated with the
exact release tag it resolves to (
actions/checkout@3d3c42e5…=v7.0.1,actions/setup-go@b7ad1dad…=v7.0.0; both verified against the GitHub API), and bothare at or above the first release declaring the
node24runtime. Review pass 1caught that the original pins (
checkout@v4.4.0,setup-go@v5.6.0) both declareusing: node20, which currently runs only because the runners re-execute node20 actionson Node 24 as a compatibility shim — withdrawn when Node 20 leaves the runner images on
2026-09-23. A brand-new mandatory gate would have broken repo-wide about three weeks
after landing.
TestPRGateContract_ActionPinVersionCommentsAreAtOrAboveTheNode24Floorenforces a flooron each pin's trailing version comment — deliberately named and worded for what it can
actually prove, since a commit SHA cannot be resolved to a version or a declared runtime
without network access a unit test must not have. Immutability itself is proven
separately and completely by
TestPRGateContract_ExternalActionsArePinnedToFullCommitSHA. The two existing publisher workflows are deliberately left on floating tags —separate hardening, out of scope here.
Trigger activity types are explicit, and
editedis load-bearing. A barepull_request:defaults toopened/synchronize/reopenedonly. A base-branch retargetarrives as
editedand keeps the same head SHA, and the check-run is recorded againstthat head SHA — so a PR proven green against another base could be retargeted onto
protected
mainand carry its greenRequired PR gateacross without the new mergeresult ever being tested.
types: [opened, synchronize, reopened, edited]closes that,and the contract test now requires exactly that list.
No path filters, no allowlists. Future tests added under
internal/securitytest(including the planned
TestDependencyContract_ProductionClosureHasNoGeneralLDAP) enrollin the gate with no workflow edit.
internal/securitytest/pr_gate_contract_test.goThe plan's invariant map originally left three invariants — "all five commands control
one merge result", "no skipped aggregator can report misleading success", and
"required-check name cannot silently drift" — enforced only by human static review. In a
repository that already keeps machine-checked contracts in
internal/securitytest, thatwas the weakest link, so this PR converts them into an executable contract over the
workflow YAML: trigger shape, absence of
pull_request_target/path filters/if/needs/continue-on-error, exactly one job, the literal job name, the five commands exactly onceeach in order, and full-SHA action pinning.
Untagged, matching convention:
release_gate_test.gois the onlyphase5release-taggedfile in that package. So it runs in plain
go test ./...and in this gate's owngo test -race ./...step.Two deliberate design constraints:
out of the file under test and compares it to itself detects nothing.
^[0-9a-f]{40}$). Hardcoding today's SHAs would turn a legitimate action upgrade intounrelated test churn for no security gain.
Known limitation, stated plainly
This gate is not tamper-resistant against an authorized pull-request author. Because
the trigger is
pull_request(correctly — notpull_request_target), a PR runs its owncopy of the workflow. A PR that keeps the job name but neuters the steps, or adds a
job-level
if: false, produces a green required check that verified nothing. Thenon-obvious part is that a skipped job satisfies a required status context, so reading
the steps during review does not catch it. The new contract test does not close this
either — it lives in the same PR-controlled tree and runs only because that same workflow
runs it.
So the honest claim is: this enforces process for trusted maintainers and catches ordinary
regressions. Closing the deliberate case would require an organization-level
required-workflow ruleset sourced from outside the repository being modified — org-owner
authority, a different enforcement architecture, and out of scope for #23. The full
reasoning and the decision to accept this is recorded in the issue's ship log.
Documentation reconciliation
Shipping this gate made several committed statements false, so they are fixed in the same
change (per
CLAUDE.md's working discipline):CLAUDE.md— new repo-map rows; the gate described inBuild & test; theWorking disciplineclaim "There is no PR-time CI gate" replaced; the future-linterguidance now points at
pr-gate.yml;Unlikethe Go gate (wasLike, whichasserted the opposite of this commit); the
integration/clickhouse/row no longerimplies nothing there is CI-gated.
README.md— the new non-publishing workflow described alongside the two publishers.integration/clickhouse/README.md—tests/lib-tests.shis in CI; only the Dockersuite is manual.
skills/ship/references/repo-footguns.md,skills/ship/SKILL.md— the stale "there isno PR-time CI to wait on" guidance replaced with head-SHA-keyed waiting on
Required PR gate.Two accuracy notes now recorded in
CLAUDE.md:lib-tests.shis Docker-daemon-free butneeds the
dockerCLI onPATH(lib/common.shcallsdetect_compose_cmdat sourcetime and
dies — reproduced locally), which makes the issue body's "needs onlybash/coreutils" imprecise; and the gate never compiles
third_party/goldap/third_party/ldapserver, which is where the parser #33 replaces currently lives.Verification
All coordinator-run at head
f743963, not taken from a self-report:go build ./.../go vet ./...go test -race -count=1 ./...go test -tags phase5release ./internal/securitytest -count=1bash integration/clickhouse/tests/lib-tests.shgo vet -tags phase5release ./internal/securitytestbash helm/ch-oauth-ldap/test.shGATE RESULT: PASS (0 failures)pr-gate.ymlSabotage cases for the new contract test — each must fail, and each failure message
must name the security consequence:
Required PR GateExactlyOneJobNamedForBranchProtectionpaths: ["**.go"]added underpull_requestTriggersAreUnfilteredPRAndMainPush+NoPathFiltersAnywherecontinue-on-error: trueon the security stepJobIsUnconditionalAndFailsClosedpermissions: contents: writeTopLevelPermissionsAreReadOnlyactions/checkout@v4(unpinned)ExternalActionsArePinnedToFullCommitSHAif: falseon the job (the neutering shape above)JobIsUnconditionalAndFailsClosedAll six failed as required;
pr-gate.ymlwas restored byte-identical after each.Hosted CI
Reviewed head:
f743963.Required PR gatesucceeded at that exact SHA in run33283550959
(
pull_request, all five steps: Build, Vet, Race tests, Phase 5 security tests, ClickHouseshell library tests), as of 2026-08-30.
This section deliberately no longer tries to enumerate every run. It can't have a fixed
point: because the trigger now includes
edited, editing this description starts anothergate run, which immediately makes any exhaustive table stale again. The complete
run-by-run ledger — every run, each keyed to its head SHA — lives in the
ship log,
whose edits do not retrigger the gate and which is current as of this head. Every wait in this PR was keyed to an exact head
SHA, never to
gh run listordering.The two runs worth calling out from that ledger:
c8538af— 33276319268❌ at step 6. The gate found two real defects on its first ever run, neither
reproducible on the darwin dev platform.
e37e24d— 33277581488❌ at step 7 only, with steps 4-6 passing: the deliberate
phase5releasered prooffor Definition-of-Done item 11, reverted by a normal inverse commit so it stays in
history.
What run 1 found.
TestAdversarial_WriteDeadlineClosesStalledConnection…createdbackpressure with a 20 MiB response and a 300ms
WriteTimeout; on the runner all~40 MiB was delivered, so the deadline never fired and the mechanism under test never
engaged (darwin buffers 128 KiB; the runner buffered everything). And a
DATA RACEonldapserver.Loggerturned out to be a consequence of that failure — the testt.Fatalfs before its own teardown, leaking a live server into the next test'sNew(),violating a constraint
server.goalready documents. Both fixes are test-only;git diff origin/main..HEAD -- internal/ldap/server.gois empty. The stall now runs overan unbuffered
net.Pipe(deterministic on every host, no payload-size heuristic), and ashared
startServinghelper gives seven server-start sites fail-safe teardown registeredbefore any assertion can abort.
DoD item 11 — the deliberate red proof (run 33277581488). Steps 4 Build, 5 Vet and
6 Race tests all passed; step 7
Phase 5 security testsfailed; jobRequired PR gatefailed. Two choices make it a proof rather than a coincidence: steps 4-6passing means the red result is attributable to the security step alone, and the test
carried
//go:build phase5releaseso it was invisible togo test -race ./...and fatalonly to the security command. Run 1's accidental failure could not serve this purpose —
it failed at step 6, which skipped steps 7-8, so the security step's wiring to the
required status had never been exercised. Removed by a normal inverse commit
(
a11eac0), never a force-push, so the red run and its cause stay in this PR's historyas the audit trail.
Known limitation of the enforcement model, restated for the record
Branch protection is enabled after this merges and a successful
push-event run isobserved on
main: required context exactlyRequired PR gate,strict: true(require branches up to date),
enforce_admins: true, and no review requirement.strict: trueis load-bearing, not incidental — with loose required checks a PR greenagainst
main = B0can merge unchanged after another PR movesmainto B1, since Hitself never gets a
synchronizeevent, so a green gate would authorize an untestedB1+H merge result. That is the same fail-open class as the retarget case above, for the
far more common trigger. Read-back verifies all four fields plus the GitHub Actions app
binding, not just the context string. #33 stays blocked until all three hold: merged, green run on
main,and protection reading back the exact context. Merging this PR alone does not satisfy
that. And per the limitation above, the gate enforces process for trusted maintainers
rather than resisting deliberate tampering — closing that would need an organization-level
required-workflow ruleset sourced outside this repository.
🤖 Generated with Claude Code
https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS