ci: a release preflight, because the tag is already public when CI refuses

THE WORKFLOW WAS NEVER BROKEN. v0.1.0-rc3 was pushed to all three remotes
while every crate still said 0.1.0-rc2; dist matches a tag against the root
package version, refused with "This workspace doesn't have anything for dist
to Release!", and the four jobs behind `plan` skipped. Proved in both
directions locally: `dist plan --tag=v0.1.0-rc2` gets past the check and
`--tag=v0.1.0-rc3` reproduces the CI error exactly.

So this changes no trigger and no job graph. What it adds is a check that runs
BEFORE the irreversible step. dist named the right answer in its own error
text; the problem is when that answer arrives -- a tag is public and immutable
by the time CI reads it, so the repair is a new version rather than an edit.

scripts/release-preflight.sh refuses a dirty tree, compares the intended tag
against the root package version, runs `dist plan` as a second opinion when
dist is on PATH (and says so when it is not, since an absent dist must not
read as a pass), and prints the tag-and-push commands only once it passes.

THE TEST'S FIRST VERSION WAS VACUOUS AND THE INJECTION IS WHAT SHOWED IT.
release-preflight-test.sh originally asserted only "did it refuse". Every
negative case still passed with the tag comparison replaced by `if false;`,
because the script refuses a DIRTY WORKING TREE first and this repo's tree is
almost always dirty -- bd auto-exports .beads/issues.jsonl on nearly every
command. Four of five checks were answering "is the tree dirty" while claiming
to answer "does it catch a bad tag".

It now runs the negative cases against a clean throwaway checkout AND asserts
the refusal names the version mismatch, so a refusal for the wrong reason
fails. Two rival faults were injected against the repaired version -- the
comparison disabled, and `expected` derived from the tag itself -- and each
produces four failures and exit 1.

That is design-principles section 7's "a check that looks like the answer,
answering a neighbouring question", found inside a checker written to prevent
that class, within an hour of writing it. Recorded in context/code/tooling.md.

No version is bumped and no tag is cut here, per the user's instruction to
make the workflow work without releasing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
Edward Langley
2026-09-21 22:20:34 -07:00
parent f34334cfca
commit aa4e901bd2
4 changed files with 274 additions and 0 deletions
+54
View File
@@ -325,3 +325,57 @@ build already did some of that. Closing it properly means no two runs holding a
`github-pages` artifact across a deployment, which is a real change to the job
graph with real costs and is not attempted here. A retry is **not** a fix: the
artifact is gone rather than transiently unlistable, so a retry cannot find it.
### 4. The release preflight, and why a loud failure was not enough
`scripts/release-preflight.sh` answers, before a tag exists, the question the
Release workflow answers after.
**What happened.** `v0.1.0-rc3` was pushed to all three remotes while every
crate still said `0.1.0-rc2`. dist matches a tag against the root package's
version, refused with *"This workspace doesn't have anything for dist to
Release!"*, and the four jobs behind `plan` skipped. **Nothing was wrong with
the workflow**, which is worth stating plainly because the instinct on a failed
release run is to edit the YAML. The trigger is correct, the pattern matches,
the workflow is enabled; `dist plan --tag=v0.1.0-rc2` succeeds today and
`--tag=v0.1.0-rc3` reproduces the CI error exactly, which is the whole
diagnosis and both halves of it.
**Why a loud, precise failure still cost something.** dist named the right
answer in its own error text — *"`--tag=v0.1.0-rc2` will Announce: improvise"*.
The problem is not the message, it is **when** it arrives: a tag is public and
immutable by the time CI reads it, so the repair is a new version rather than an
edit, and the tag that failed stays on three remotes forever. A check that runs
after the irreversible step is a check in the wrong place, however good its
message. So the preflight runs *before* tagging and prints the tag command only
once it has passed — the honest path made the easy one, the same shape as
`Basis` having no public constructor (gotcha 43).
**The primary check is textual and the `dist plan` run is a second opinion.**
An absent `dist` must not read as a pass, so the script says which of the two
ran. It also tolerates a locally-installed dist whose version differs from the
one pinned in `dist-workspace.toml`: that stops dist before it plans, and
reporting it as a refusal would train the reader to ignore refusals.
**Only the root package's version can disagree with a tag.**
`dist-workspace.toml` says `members = ["cargo:."]`, so `gp-tree` sitting at
`0.1.0` while everything else is on `0.1.0-rc2` is not a finding. The script
lists such crates and says so rather than failing, because a deliberate split
should stay deliberate and a silent one should be visible.
**The test's first version was vacuous, and the fault injection is what showed
it.** `scripts/release-preflight-test.sh` originally asserted only *did it
refuse*. Every negative case passed with the tag comparison replaced by
`if false;` — because the script refuses a **dirty working tree** first, and
this repo's tree is almost always dirty, bd auto-exporting `.beads/issues.jsonl`
on nearly every command. Four of five checks were answering *is the tree dirty*
while claiming to answer *does it catch a bad tag*. The test now runs the
negative cases against a clean throwaway checkout **and asserts the refusal
names the version mismatch**, so a refusal for the wrong reason is a failure
rather than a pass. Two rival faults were injected against the repaired
version — the comparison disabled, and `expected` derived from the tag itself —
and each produces four failures and exit 1.
That is `context/design-principles.md` §7's "a check that looks like the answer,
answering a neighbouring question", found in a checker written to prevent
exactly that class, within an hour of writing it.