Skip to content

chore(ci): build specs with Specter v0.15.0 and verify the archive - #418

Merged
remyluslosius merged 2 commits into
mainfrom
chore/specter-v0.15.0
Sep 11, 2026
Merged

chore(ci): build specs with Specter v0.15.0 and verify the archive#418
remyluslosius merged 2 commits into
mainfrom
chore/specter-v0.15.0

Conversation

@remyluslosius

Copy link
Copy Markdown
Contributor

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/, no VERSION bump.

File Change
.github/workflows/ci.yml Version pin, new SPECTER_SHA256, both installers verify before extracting
specter.yaml Drops the inert system.tier key that v0.15.0 reports as deprecated
CHANGELOG.md One Unreleased / Changed entry; no lines removed

Why the digest

Both installers piped curl straight into tar. The bytes CI executed were
never 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 trap
removes the temp dir on every exit path. There are zero curl | tar pipes left
in the workflow.

A mismatch is treated as a supply-chain signal, not a download problem:

  • spec-sync keeps its go install fallback, but only for an unreachable
    archive, 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-strict has no fallback and gains none. An unreachable
    archive 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_URL cannot substitute a different build.

Why system.tier goes

v0.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…):

  • 151 specs parse, resolve, check --strict, and meet their tier thresholds
  • The deprecation warning is gone (restoring system.tier brings it back — 1 → 0)
  • go test ./... across 84 packages, including -race; golangci-lint 0 issues
  • docs-check, docs-style, docs-style-sync, manpage-check, cli-smoke,
    comment-lint all pass

Installer harness

Both run: blocks were extracted verbatim from the workflow YAML via
yaml.safe_load, with sudo, go, and specter stubbed, then exercised:

Case spec-sync spec-coverage-strict
Digest matches exit 0, tarball, no go install exit 0
Digest mismatch exit 1, no go install exit 1, no go install
Archive unavailable exit 0 via go install exit 37 (fatal, as before)
One character of the pin changed exit 1 exit 1
Binary reports a different version exit 1 exit 1

The mismatch path names both digests and states why it will not fall back.

Note on the inline annotation

detect-secrets flags the pin as a high-entropy hex string. It carries the
documented pragma: allowlist secret annotation with a comment saying why: a
published release digest is meant to be public. The pin itself is not weakened.

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.
@remyluslosius
remyluslosius merged commit 0152886 into main Sep 11, 2026
20 checks passed
@remyluslosius
remyluslosius deleted the chore/specter-v0.15.0 branch September 11, 2026 14:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant