chore(ci): build specs with Specter v0.15.0 and verify the archive - #418
Merged
Conversation
The pin moves from v0.13.2 to v0.15.0, and CI now checks what it installs. Both installers piped curl straight into tar, so the bytes CI executed were never checked against anything. They now download the whole archive, compare it to a pinned SHA-256, and extract only on a match. A mismatch is treated as a supply-chain signal, not a download problem. The spec-sync installer keeps its source fallback for an unreachable archive, because that is an availability failure, but a mismatched digest exits non-zero and never reaches that fallback: folding both into one condition would let tampered bytes silently turn into a source build. The spec-coverage-strict installer has no fallback and gains none; an unreachable archive is already fatal there and a mismatch now is too. Both blocks share the version and the digest, and both assert the installed binary reports the version that was pinned, so SPECTER_INSTALL_URL cannot substitute a different build. specter.yaml drops system.tier. v0.15.0 reports it as inert and deprecated, and it was: every spec declares its own tier, so the field supplied no default for anything. The comment claiming otherwise now says what is true, and the domain tiers stay as the assertions they always were. Verified against the founder-installed v0.15.0 binary, itself extracted from an archive matching the published digest: 151 specs parse, resolve, check strict and meet their thresholds, and the deprecation warning is gone.
The archive path checked the version of what it installed; the go install fallback did not. It wrote installed=true as soon as the build command returned zero, so a source build that produced some other version was reported as a successful install of the pinned one. CI never caught this because CI takes the archive path. A source build now clears the same bar as the archive. After go install returns, the fallback requires an executable at $HOME/go/bin/specter and requires it to report exactly the pinned version, and both checks run before any step output is written. A wrong version fails there rather than falling through to the next module path, because a build that succeeds while producing the wrong version is a contract violation, not the availability problem the fallback exists to absorb; retrying would let a second attempt mask it. Both archive paths now read /usr/local/bin/specter directly instead of letting PATH decide, so the assertion names the file that was just installed. Separately, the step that merely printed the version is now an assertion, and it deliberately checks the PATH-resolved binary: that is the executable the remaining steps invoke, and the two install methods land it in different directories, so resolving through PATH is what makes the check cover both. Verified by running the four run: blocks extracted verbatim from this file. A source build reporting 0.14.9 exits 1 and writes no outputs; deleting the new guard makes that same case exit 0 and write installed=true, so the guard is what catches it. go install succeeding while leaving no binary also exits 1. Digest mismatch still extracts nothing and never reaches the fallback, and the two jobs keep their different answers to an unreachable archive: spec-sync falls back, spec-coverage-strict stays fatal.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Moves the pinned Specter from v0.13.2 to v0.15.0, and makes CI verify the
archive it installs.
What changed
Three files. No runtime Go, no product specs, no
api/, noVERSIONbump..github/workflows/ci.ymlSPECTER_SHA256, both installers verify before extractingspecter.yamlsystem.tierkey that v0.15.0 reports as deprecatedCHANGELOG.mdUnreleased / Changedentry; no lines removedWhy the digest
Both installers piped
curlstraight intotar. The bytes CI executed werenever compared to anything, so a compromised or swapped archive would have been
extracted and run. Each installer now downloads the whole archive to a temp
dir, compares it to the pinned SHA-256, and extracts only on a match. A
trapremoves the temp dir on every exit path. There are zero
curl | tarpipes leftin the workflow.
A mismatch is treated as a supply-chain signal, not a download problem:
spec-synckeeps itsgo installfallback, but only for an unreachablearchive, which is an availability failure. A digest mismatch exits non-zero
and never reaches the fallback. Folding both into one condition would let
tampered bytes silently become a source build.
spec-coverage-stricthas no fallback and gains none. An unreachablearchive was already fatal there; a mismatch is now too.
Both blocks also assert that the installed binary reports the version that was
pinned, so
SPECTER_INSTALL_URLcannot substitute a different build.Why
system.tiergoesv0.15.0 reports the key as inert and deprecated, and it was: every spec declares
its own tier, so the field supplied a default for nothing. The comment claiming
otherwise now says what is true. Domain membership and the per-domain tiers are
unchanged — they were always assertions, not defaults.
Verification
Against the founder-installed v0.15.0 binary, itself extracted from an archive
whose digest matches the pin (
af4e7129…):check --strict, and meet their tier thresholdssystem.tierbrings it back — 1 → 0)go test ./...across 84 packages, including-race;golangci-lint0 issuesdocs-check,docs-style,docs-style-sync,manpage-check,cli-smoke,comment-lintall passInstaller harness
Both
run:blocks were extracted verbatim from the workflow YAML viayaml.safe_load, withsudo,go, andspecterstubbed, then exercised:spec-syncspec-coverage-strictgo installgo installgo installgo installThe mismatch path names both digests and states why it will not fall back.
Note on the inline annotation
detect-secretsflags the pin as a high-entropy hex string. It carries thedocumented
pragma: allowlist secretannotation with a comment saying why: apublished release digest is meant to be public. The pin itself is not weakened.