XRAPH/Writing/Green because it never ran

Green because it never ran

Moving CI out of five repositories and into one shared workflow was meant to be a consolidation. It found six checks that had been passing without checking anything.

Published
Aug 2026
Length
6 min read
Systems
3

#What the migration was for

Over Thursday, Friday and Saturday I moved continuous integration out of individual repositories and into reusable workflows in one place. The Go phase covered confy, vessel and go-utils, each of which traded about 470 lines of per-repository CI for about 74. The Rust phase, which is Saturday's work, covered farp-rust and octopus and shipped as rust-ci.yml in xraph/workflows v1.6.0, hardened in v1.7.0, extended in v1.8.0.

The consolidation itself went roughly as planned. The interesting part was the side effect. Moving a check forces you to state what it does, and stating what six of them did revealed that they did nothing. All six had been green. None of them was found by a failing run.

#The modules nobody compiled

farp has six discovery modules under discovery/, each its own Go module. That means go test ./... at the repository root never reaches them, which I knew, and had assumed was covered somewhere else. It was covered by the post-merge release job, which is to say it was covered after the code had already landed on main.

The advisory sweep is what made this visible. Clearing eleven critical advisories in farp meant bumping x/crypto to v0.52.0 in discovery/mdns (eight of the eleven, one of them coming in through a 2019 pseudo-version) and x/crypto plus grpc 1.59.0 to 1.79.3 in discovery/etcd. Those dependency versions require a newer toolchain, so both modules ended up declaring go 1.25.0. Every check passed. The commit merged. Then the release job, running Go 1.23 with the toolchain pinned by actions/setup-go, said this:

1go: go.mod requires go >= 1.25.0 (running go 1.23.12; GOTOOLCHAIN=local)

Two defects compounding. The toolchain pin is the one that produced the error message, and it was the easy fix: the release job now uses the highest go directive across the set, since the other four discovery modules sit at 1.23.0 and build fine on a 1.25 toolchain. The real defect is that a go directive bump reached main without anything trying to compile it. The fix is a job that builds every discovery module on every pull request:

1set -uo pipefail
2failed=0
3while IFS= read -r modfile; do
4 dir=$(dirname "$modfile")
5 echo "=== $dir ==="
6 if ! ( cd "$dir" && go vet ./... && go test -race ./... ); then
7 echo "::error::discovery module $dir failed"
8 failed=1
9 fi
10done < <(find discovery -name go.mod | sort)
11exit $failed

Two things in there are deliberate. Modules are enumerated with find rather than hardcoded, because the release job's fixed list is exactly what would skip a new submodule. And the loop visits every module before exiting non-zero, so one broken module does not hide the state of the other five.

#The artifact with nothing in it

The docs job uploads rustdoc output. actions/upload-artifact defaults if-no-files-found to warn, so a docs command that writes somewhere other than target/doc produces a green run and an empty artifact. Nobody downloads the documentation artifact on a normal day, so this would have sat there indefinitely. It now uses if-no-files-found: error.

The same commit gave the artifact a configurable name. A monorepo that calls the workflow twice would collide on a constant one, and artifact names cannot contain a slash, so the working directory cannot simply be interpolated into it.

#The audit that was opted out of

cargo-audit requires a Cargo.lock. Library crates conventionally gitignore theirs. The workflow's first version handled that by having farp pass skip-audit: true, which I wrote myself and which meant a green pipeline over an unaudited crate. The fallback now generates a lockfile when there is not one:

1if [ -f Makefile ] && make -n audit >/dev/null 2>&1; then
2 echo 'run=make audit' >> "$GITHUB_OUTPUT"
3 echo 'source=Makefile target audit' >> "$GITHUB_OUTPUT"
4else
5 echo 'run=[ -f Cargo.lock ] || cargo generate-lockfile; cargo audit' >> "$GITHUB_OUTPUT"
6 echo 'source=fallback cargo audit' >> "$GITHUB_OUTPUT"
7fi

farp is now audited in production with its lockfile still gitignored. The default is on and a caller has to work to turn it off, which is the right way round for a security check.

#The jobs that decided and did not say

Every job in these workflows probes for a Makefile target and falls back to the language's own command, so that a repository with a Makefile keeps using it and one without gets sensible defaults. That resolution is the whole design, and it is invisible from the outside: a green test job looks identical whether it ran make test or cargo test, or whether the Makefile target it found was a stub.

The gating jobs already reported their resolution to the step summary. The two non-gating ones, test-extended and docs, resolved a command and then said nothing, which contradicted both the README and the plan. Every job now prints what it chose, so a run summary reads:

1- fmt: Makefile target fmt
2- test: Makefile target test
3- clippy: fallback cargo clippy
4- audit: fallback cargo audit
5- build: fallback cargo build
A check that will not tell you what it decided to run is a check you have to take on trust, and the point of moving CI into one place was to stop taking ten repositories on trust.

That output is from a fixture added in v1.8.0. Until then the Makefile branch of the probe had no test in the repository that owns it: it was proven by an octopus CI run, which is evidence that vanishes the moment octopus changes its Makefile. The fixture defines fmt and test and deliberately not clippy, audit, build or docs, so the mixed resolution above is what a passing self-test looks like.

#What went wrong that I did not plan for

The plan for this work introduced a gating regression, and I want to record that in the order it happened rather than as a lesson. Bundling all five shared jobs behind a single caller and gating the required check on it meant that a nightly-toolchain or rustdoc failure would redden a required check. octopus's pre-migration policy deliberately excluded exactly those from gating. I caught it in a final review of my own diff, not from a run, and the fix is an only-extended input that lets a caller split one invocation into a gating call and a non-gating one.

octopus also turned out to have been red on main for seven consecutive nightly runs since 2026-07-26, from causes that predated any of this. One was a real advisory, RUSTSEC-2026-0204, cleared by crossbeam-epoch 0.9.18 to 0.9.20. The other was a lint: clippy::question_mark grew a new case in 1.97 covering if-let-else chains, and flagged an explicit return None in the router's proxy_spec.rs. CI runs stable, my machine was on 1.96, so a repository that had changed nothing went red on a toolchain bump. The rewrite to the question mark operator is semantically identical in a function returning Option, and the 84 router tests pass either way. I fixed both causes in their own pull request, then rebased the migration onto the green base rather than merging it past a red required check. That merge removed 149 lines of per-repository CI.

The last one is unrelated to CI and is my favourite. The design and plan documents for all of this work live in the dtl repository under docs/superpowers, and dtl is a published Go module. Those 5,062 lines were shipping inside the module zip that go get downloads. I removed them going forward rather than rewriting history, because v1.0.1's tree contains them and its h1 hash is already recorded in sum.golang.org. Moving that tag would break go get github.com/xraph/[email protected] with a permanent checksum mismatch, which is a worse outcome than a fat module zip.

#Still open

The Rust jobs pin the toolchain action to dtolnay/rust-toolchain@master, a moving branch on a third-party action, now at five sites plus octopus's own checks. That is a supply-chain hole I have written down twice and not closed. Pinning a commit SHA means adopting an update mechanism to move those pins, and I have not set one up.

farp lost a cargo tarpaulin coverage step in the migration. It ran under continue-on-error: true and gated nothing, so dropping it cost no signal anyone was acting on, and the honest description is that farp now has no coverage number rather than an ignored one.

The Node phase covers six repositories that are predominantly private Next.js applications needing CI and deployment rather than package publishing, which is a different workflow shape from anything built so far. The Dart phase after it has one consumer. Neither is started.

The thing I have no answer for is the general case. Six checks were green while checking nothing, and every one was found by a person reading a diff during a migration that happened to touch them. The step-summary reporting helps a reader who is already looking. I do not have a check that finds checks which check nothing, and I am not sure what one would look like beyond the four specific guards here: build every module, fail on an empty artifact, generate the lockfile rather than skipping the audit, and make every job say what it resolved.