ci: add the GitHub Actions pipeline and a toolchain parity gate - #19
Conversation
Greptile SummaryAdds a GitHub Actions CI pipeline and enforces parity between CI and the repository’s pinned development toolchain.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| .github/workflows/ci.yml | Adds the remote gate and Postgres lane; the prior exit-status, mutable-action, and mutable-image issues are fixed at the current head. |
| tools/toolchain/parity-core.ts | Implements fail-closed parsing and exact runtime/store-path parity checks; the prior silent omission path now throws. |
| tools/toolchain/parity.ts | Connects repository pin parsing, Nix identity resolution, command discovery, and nonzero parity reporting. |
| tools/toolchain/parity-core.test.ts | Covers parser rejection, parity mismatch handling, unverifiable tools, store-path boundaries, and Postgres image consistency. |
| tools/toolchain/gate-tools.nix | Resolves the parsed development-shell attributes from the locked nixpkgs source into the CI tool environment. |
| go/internal/pgtest/pgtest.go | Pins local container-backed Postgres tests to the same immutable image digest used by CI. |
| .moon/workspace.yml | Registers the toolchain parity project so its tasks participate in workspace-wide CI. |
| tools/toolchain/moon.yml | Defines parity tests and checks as CI tasks with inputs covering both Postgres image references. |
| .github/dependabot.yml | Adds reviewed weekly updates for immutable GitHub Actions pins. |
Reviews (5): Last reviewed commit: "fix(ci): pin the Postgres service image ..." | Re-trigger Greptile
|
Correcting my own attribution comment on this PR: I wrote that the remaining The conclusion holds. The evidence I gave for it did not, and the file count is exactly the part that made it sound safe. The claim that is actually checkable, in three measurements:
So the harness change cannot be upstream of those failures in CI, and the identical-failure-set comparison across three heads is valid because it was run against identical database bytes — not because the two arms happened to name the same tag. Worth stating explicitly: the digest pin is a content no-op today. Credit where due — this was caught by another lane reading the file list instead of my summary of it. A count of files is not a statement about what they do, and the number was the wrong instrument for the claim. |
The gates already existed and worked; nothing invoked them remotely. A PR merged on two bots' opinions with no build, test, lint, or drift check, and GitHub reported a CLEAN merge state over an empty check array — indistinguishable from a passing gate. One job runs `moon run :ci`, not a matrix. A matrix would give per-task check names, but only by enumerating projects in YAML — a second source of truth for something .moon/workspace.yml owns, which goes stale silently. That exact failure has already happened here at the project-map layer. `moon run :ci` cannot go quiet: a project is gated the moment it is registered. A second job runs the real-Postgres suites against a service container. They are build-tagged `pgtest`, so the main gate never compiles them, and the harness skips rather than fails without a database — so the job asserts afterwards that its tests actually ran. The runtimes come from native setup-* actions reading .prototools; the nixpkgs-provided tools have no equivalent action, so CI resolves the same derivations the dev shell does from the revision devenv.lock pins. Reading a version from a file does not prove the runner got it, so a parity gate checks the toolchain actually on PATH and fails the build on a skew. Its two halves need two methods: .prototools pins have a literal to compare, while the devenv.nix packages have none anywhere in the repo and two of them report no version at all — those are verified by resolving each command to the pinned derivation, which also catches an ambient binary of the same version shadowing it. A tool the gate cannot check is reported and fails; skipping it would make the green meaningless. Refs SEA-1507 Co-Authored-By: seal <noreply@sealedsecurity.com>
The runner's default TMPDIR is /home/runner/work/_temp — 22 bytes before a test's own name-derived path — which pushes the socket-binding suites past the 108-byte AF_UNIX sun_path cap and fails them with a bare EINVAL from bind(2). Reproduced locally by pointing TMPDIR at an equally deep path. The cap is real and the code's guards against it live elsewhere; a runner's choice of temp root is not a reason to redesign the sockets, so it is fixed where it originates. Refs SEA-1507 Co-Authored-By: seal <noreply@sealedsecurity.com>
The pgtest job piped `go test` into `tee`, so the step returned tee's
exit 0 and a test failure was discarded. Run 30329986363 reported
`success` while its own log carried `panic: test timed out after 10m0s`,
`FAIL .../internal/runnerhub 600.075s` and a failing
`TestServeShutdownWithLiveCommsSubscriberReturnsClean` — the exact
false green this pipeline exists to end, in the pipeline itself.
Capture to a file, replay it, exit on go test's own status. `|| rc=$?`
because the step runs under `bash -e`, which would otherwise abort
before the log is printed. An explicit `-timeout 15m` keeps a slow
suite from dying on the default 10m, where the panic buries the
failure that caused it.
The two failures that surfaced are real and belong to other lanes;
they are with their owners.
Also, each a way for a green to mean less than it looks:
- The skip guard grepped a bare string duplicated from pgtest.go with
nothing binding them, and counted every `--- PASS` in the module —
a total in the hundreds that stays high even if every real-Postgres
suite vanished. Derive the skip text from pgtest.go (a reword now
fails loudly instead of matching nothing) and require an `ok` line
per package that actually calls RequireDSN.
- `.prototools` values were interpolated into a shell script through
`${{ }}`, which substitutes before the shell parses. The file is
PR-editable. Pass the pin via env, and refuse a value that is not a
version string or a parse that yields no pins — a malformed file
otherwise leaves every `setup-*` silently on its own default.
- Actions were pinned to mutable tags, which their owners can repoint
at arbitrary code. Pin all seven to commit SHAs; add Dependabot so
they move through review rather than rotting.
- `accept-flake-config = true` let any evaluated flake supply its own
substituters AND trusted keys, and forks/devenv/flake.nix carries
exactly such a block. Declare the two caches the forks need here
instead, so that trust lives in a reviewed file.
- parseDevenvPackages silently dropped dotted attribute paths, leaving
a tool both uninstalled in CI and unreported by the gate. Throw.
- The merge-base step's comment was false: checkout does populate
refs/remotes/origin/*. Correct it, and enforce the ref with
rev-parse so a missing base fails here rather than passing
vacuously inside `buf breaking`.
Refs SEA-1507
Co-Authored-By: seal <noreply@sealedsecurity.com>
The parser threw on a dotted attribute path and silently dropped every other non-bare form. A parenthesised call, an interpolation and a quoted string all parsed to nothing while the bare entries kept the nonempty guard satisfied — so a tool could be absent from what CI installs AND absent from what the gate verifies, which is the false green the throw exists to prevent. Refusal moves to the default branch. Enumerating the bad shapes leaves every shape nobody thought of falling through to a silent drop. No entry in devenv.nix is affected today; all thirteen are bare. Refs SEA-1507 Co-Authored-By: seal <noreply@sealedsecurity.com>
The CI service and the local pgtest harness each named `postgres:16-alpine`, a mutable tag, and each carried a comment asserting the two run the same image. Nothing enforced that. An upstream repoint would have let CI and a local run exercise different databases while both comments still claimed parity — and the suites assert on generated-tsvector and websearch_to_tsquery behavior, which is version-sensitive. Pins both to sha256:57c72fd2 (postgres 16.14, digest confirmed against the registry and a local pull), and adds a test asserting the two are equal and that neither has drifted back to a bare tag. The two files are inputs to that task, so editing either re-runs the assertion instead of serving a cached green. Refs SEA-1507 Co-Authored-By: seal <noreply@sealedsecurity.com>
4677aa6 to
de2d883
Compare

The gates already existed and worked. Nothing invoked them remotely:
.github/held onlysecret_scanning.yml,branches/main/protectionwas 404,rulesetswas[]. GitHub reportedmergeStateStatus: CLEANover an empty check array on every PR — indistinguishable from a passing gate, and several lanes nearly reported their PRs as CI-green off that string. This was never an authoring problem; it was a triggering problem.One job, not a matrix
The central structural choice, and I am not hedging it:
gateis a single job runningmoon run :ci.A matrix fanning tasks into separate jobs is genuinely better on two axes — per-task check names branch protection could require individually, and failure attribution visible from the check list rather than the log. It was rejected on one decisive ground: a matrix can only be built by enumerating projects (or tasks) in YAML, which makes the workflow a second source of truth for something
.moon/workspace.ymlalready owns. That list goes stale silently.This is not hypothetical here.
.moon/workspace.yml:33-39documents the identical failure one layer down: a fork tree shipped a functional-CImoon.ymlwith no entry in the project map, so it read as covered and gated nothing, and no single PR's CI could catch it because the tree and the map arrived on separate branches. A YAML enumeration recreates exactly that at the CI layer — add a project, forget the matrix row, and the gate goes quiet about it while still reporting green.moon run :cicannot go quiet. It also directly satisfies the acceptance criterion: the arrivingoh-my-pi-fork(#14) needs no edit to this workflow.The costs are real and accepted: one check name to require, and the failing task named in the log rather than the check list. moon prints the failing target and only its output, so attribution costs one click.
pgtestis a second job — not for attribution, but for blast radius. It is the one lane with an external dependency, and a Postgres service failing to come up must never red the hermetic gate.Where CI's toolchain comes from
Per the standing decision: native
setup-*actions, reading their versions out of.prototoolsat run time rather than repeating them in YAML, so the file stays the single source.The nixpkgs-provided tools (buf, protoc, the Go analysis battery, biome, markdownlint) have no
setup-*action that could reproduce a nixpkgs pin, and picking a close-enough release from another channel is precisely the drift this PR exists to reject. So one step resolves the same derivations the dev shell does, from the nixpkgs revisiondevenv.lockpins (tools/toolchain/gate-tools.nix), and puts them on PATH. Nix is the mechanism for that step; it is not the toolchain strategy — the runtimes come from the native actions. The attribute list is parsed out ofdevenv.nix, never hand-listed, so adding a tool to the dev shell extends CI with no workflow edit.The parity gate, and the asymmetry it is built around
Reading a version from a file does not prove the runner got it.
tools/toolchain/parity.tsruns before the gate (and again inside:ciastoolchain-parity:parity, so the pre-push hook checks it too) and fails the build — never warns.The two halves of the toolchain are pinned two ways, so they are checked two ways:
.prototools— bun, node, moon, godevenv.nix— everything elserealpathof each command must land inside the derivation thedevenv.lock-pinned nixpkgs buildsStore-path identity was chosen for the second half for three reasons. It is the only method that works for
go-licensesandnilaway, which implement no version flag at all. It is uniform across the whole half. And it is strictly stronger than comparing version strings — it identifies the exact derivation, so an ambient binary that happens to report the same version is still caught (demonstrated below).Coverage, stated honestly
27 checks, one per command, across every tool the dev shell provides. Per command, not per attribute, because PATH resolves commands and the attribute name often is not the command name (
protobufshipsprotoc;bufships theprotoc-gen-buf-*plugins) — those extra binaries would be invisible at attribute granularity.Every tool named in the issue is covered:
bun,node,moon,goproto(+proto-shim),buf(+protoc-gen-buf-breaking,protoc-gen-buf-lint),protobuf(protoc+ 7 sibling binaries),protoc-gen-go,protoc-gen-connect-go,golangci-lint,govulncheck,go-licenses,nilaway,biome,markdownlint-cli2,curl(+wcurl)What it cannot check, and why it is still not a silent gap. Nothing is skipped. A tool the gate cannot verify is reported
UNVERIFIABLEand fails the build — a green over an unchecked tool is the exact failure this repo keeps hitting. The two genuine limits, both handled rather than omitted:go-licensesandnilawaycannot self-report a version (go-licenses --version→flag provided but not defined;nilawaylikewise). They are fully covered by store-path, which is why that is the method for the whole half — their derivation identity is checkable even though their self-report is not.@bufbuild/protoc-gen-esis not covered. It is a bun devDep resolved through the lockfile, not a PATH tool from either pin source, so neither method reaches it. Its exact-pin discipline is enforced bycompass-proto:driftinstead — a float restamps every generated header and reds drift on 6 files. Worth flagging: the live^2.12.1atpackages/compass-client/package.json:16is currently masked by the lockfile. Deliberately not fixed here — out of scope and separately tracked.The gate also refuses to run vacuously: if either parse returns zero entries, it exits 1 rather than reporting a pass over nothing.
Demonstrated red-capable — three skews, both halves
A parity gate that has never failed is indistinguishable from one that checks nothing.
A —
.prototoolshalf. Skewed against a frozen PATH, which is the CI condition (the runner installs its toolchain independently of the file; editing it locally would otherwise self-heal viaproto installon shell entry):B — nixpkgs half: an ambient binary shadowing the pinned derivation. The shadow reports the same version the pin resolves to, so a version-string comparison would call this a pass:
C — a tool added to the dev shell that CI's PATH lacks. Must be loud, never skipped:
Note case C's count moving 27 → 28: adding a tool to
devenv.nixextended the gate with no edit to the script, the nix expression, or the workflow.Per
AGENTS.md, the logic is TypeScript run viabun, split pure/shell:parity-core.tsis total functions over strings with no I/O,parity.tsis the execution shell.bun test tools/toolchain/— 28 pass, 0 fail, including that an unverifiable verdict fails the report and that a sibling store path sharing the expected prefix does not match. It is registered as thetoolchain-paritymoon project so its own tests are gated: an unverified gate is not a gate.The one real bug the pipeline caught, on its first run
Run 1's
gatefailed oncompass-go:test— five socket-binding tests, allbind: invalid argument. Triaged rather than papered over:git diff origin/main -- 'go/**'is empty; this PR changes no Go source.TMPDIRis/home/runner/work/_temp— 22 bytes before the test's own name-derived path — which pushes the socket path to 114 bytes against the 107-byte usable AF_UNIXsun_pathbudget. Under/tmpthe same path is 95.TMPDIRat an equally deep path: the same test fails with the identical error, and passes without it.TMPDIR: /tmpon both jobs. The cap is real and the code's guards against it live elsewhere (fix(runner): bound the agent socket path under the AF_UNIX sun_path cap #12); one runner's choice of temp root is not a reason to redesign the sockets.No
continue-on-error, no retries. This is the pipeline doing its job on day one.Verification
gategreen,pgtestRED — and the earlier green was false. Thepgtestjob originally rango test … | tee, so the step took tee's exit status and the job was structurally incapable of failing. Run 30329986363 reportedpgtestpass while its own log contains--- FAIL: TestServeShutdownWithLiveCommsSubscriberReturnsCleanandFAIL github.com/…/go/internal/runnerhub 600.075s(a 10-minute hang).15b75d8e4replaced the pipe with capture-then-exit "$rc"; the job has been honestly red ever since. The red is not a regression — it is the gate working for the first time.compass-proto,compass-go,compass-client,compass-agent,compass-ui, androottask, plustoolchain-parity. Baseline 35 + the 5 this PR adds.:cion the runner —devenv-fork:build(7m 2s) andnix2container-fork:build, not assumed from the registration.pgtestassertions genuinely executed: 722--- PASSlines against the service Postgres, zero skips. The job asserts both itself — the harness skips rather than fails without a database, so a service that never came up would otherwise pass silently. (The earlier body said409 … passed; that figure came from the pre-15b75d8e4run whose exit status was masked, so it counted passes in a job that could not report the concurrent failures.)direnv exec . moon run :ci --force→Tasks: 40 completed, zero failures.bun test tools/toolchain/→ 28 pass.direnv exec . git push, so thehkpre-push hook ran the gate from the real push context (Tasks: 41 completed).Required checks on
main— for MattA workflow that is not required is a gate nobody has to pass. The
seal-agenttoken hasadmin: false(gh api repos/sealedsecurity/compass --jq .permissions), so this cannot be applied from here. No repo settings were touched.The check names below are read off the actual run, not inferred from the YAML — a required check whose name matches nothing is a permanent block:
Both report under the
github-actionsapp with exactly those names. After merging, apply:Or via Settings → Rules → Rulesets → New branch ruleset: target
Default branch, enforcementActive, enable Require a pull request before merging and Require status checks to pass, tick Require branches to be up to date before merging, and add exactly two checks —gateandpgtest— both from the GitHub Actions app.Order matters: apply this only after this PR merges, or
mainhas a required check no commit on it has ever produced.Docs
docs/architecture/build-and-ci.mdsaid there was no remote pipeline — a recent correction that this PR makes false again. Swept the tree for CI claims rather than trusting one file:README.md(the "CI scaffold" status line and the gate paragraph),CONTRIBUTING.md(whose coverage list was independently stale — it claimed the-race/lint/vuln battery was still pending when it ships today), and the architecture doc.AGENTS.md's claims were already accurate and are left alone.The doc is explicit about what the pipeline does not gate: the upstream test suites vendored inside the forks (each fork's registered task is its own
nix build), and any live UI↔server path (everycompass-uitask runs against fixtures).Out of scope, deliberately
Per the issue's own list: the Woodpecker generator, the CI image, and the publish script are not ported. The orphaned upstream fork suites, the UI↔server live-path job, and the vendored
moon.ymlprose sweep stay follow-ups. Thepackages/compass-client/package.json:16^2.12.1float is left as-is and separately tracked.Rebase hazard:
.moon/workspace.ymlconflicts with #20, and a wrong resolution is silentThis PR registers
toolchain-parity: 'tools/toolchain'in.moon/workspace.yml. #20 registersstamp-gate: 'tools/stamp-gate'on an adjacent line.git merge-treereports 2 conflicts (.moon/workspace.yml,bun.lock);package.jsonauto-merges because both sides add"tools/*"identically.Both keys are wanted. Resolve by keeping both — never by picking a side.
The reason this needs saying in the body rather than being left to the rebaser: an unregistered project's tasks do not fail, they cease to exist. Measured on this branch by deleting my own key, exactly as a bad resolution would:
Five tasks disappear and CI stays green. A rebaser looking at a two-line conflict has no signal that one line is a CI gate, and no signal afterwards either — the build passes, so nothing surfaces the loss. Same class as a rename that carries a defect forward: a correct-looking resolution that removes an enforcement mechanism without reddening anything.
Verified the combined tree works before claiming it: merging both, regenerating
bun.lockwithbun install --lockfile-onlyrather than hand-resolving it, gives 45 tasks / 0 failed withtoolchain-parity:test34 pass andstamp-gate:test9 pass. No ordering is required between #19 and #20 in either direction.Refs SEA-1507
Co-Authored-By: seal noreply@sealedsecurity.com