ci(release): gate the tag point — a tag behind main silently drops work (#366) - #376
ci(release): gate the tag point — a tag behind main silently drops work (#366)#376avrabe wants to merge 2 commits into
Conversation
…rk (#366) Two releases shipped short and nothing noticed. v0.31.0 was cut 5m06s before a parser fix (9557dd0, "reject C-style radix literals") merged to main; v0.33.0 excluded two commits the same way. Both had `check-versions` green. `check-versions` cannot catch this, and not by oversight — it measures the version *window*. Cargo.toml reads X.Y.Z from the bump commit until the next bump, and that window spans both the commits a tag included and the ones it left out, so the invariant is circular with respect to the question. The first gate I designed was also wrong, and issue #366 records it so it is not re-proposed: asking "which release does this commit belong to" makes the verdict a function of what has merged since, and a gate whose answer drifts as unrelated commits land is not a gate. So this asks the question that was actually at stake — is anything already finished being left out, and did you say so? Given tag vX.Y.Z at commit C with tag date T: skip if C is not an ancestor of origin/main (release-branch tag); otherwise enumerate C..origin/main restricted to --before=T; empty passes; non-empty passes if the tag message states a tag point, and fails otherwise, listing what was dropped. The --before=T restriction is what makes it both race-proof (a merge landing seconds after the tag push postdates T and cannot fail the job) and idempotent (both inputs are immutable, so a re-run months later returns the same verdict). Backtested over all 42 v0.* tags by running this script, not a transcription of it: 39 clean passes, 1 acknowledged pass (v0.34.0, whose message already states its tag point), 2 fails — v0.31.0 and v0.33.0, exactly the two confirmed bad ones. No false positives across 40 good tags. Mutation-tested for non-vacuity: two scratch tags on the identical commit with the identical 8-commit excluded set, differing only in whether the message says "tag point", produce PASS and FAIL. The acknowledgement branch is load-bearing, not incidental. It lives in tools/ci/ rather than inline in the YAML specifically so the backtest exercises the shipped artifact. A backtest of transcribed shell proves the transcription works. Wired into create-release's `needs`, so it gates the publish rather than the builds: a red verdict stops everything outward-facing (publish-vsix already needs create-release) while the maintainer still gets full build feedback to act on. What it does not do is stated in the script header rather than left to be discovered: it cannot verify the acknowledgement is true — it greps for a stated tag point, so "tag point: whatever" passes. It is a speed bump against autopilot, not a proof. Its value is forcing the tagger to look at the list, which is precisely what did not happen twice. Scope is CI plumbing with no rivet artifact, matching the #353/#363/#364 precedent — this repo carries no REQ-CI-* ids. Flagged so the omission reads as a choice. Closes #366 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rivet verification gate✅ 20/20 passed
Filter: Failed artifacts(none) Updated automatically by |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Same cold-subagent audit as the required-context gate. Three of five claims about this script were refuted, one severely: the gate could be switched off silently, by ordinary mistakes, on exactly the sloppier path. 1. A LIGHTWEIGHT TAG MADE IT VACUOUS — unconditional PASS. `creatordate` on a lightweight tag is the tagged commit's OWN committer date, so `--before=$T` asks for commits descended from C that predate C: on a linear history, always empty. Demonstrated on this repo, same commit 945799a tagged both ways — annotated FAIL (13 excluded), lightweight PASS. Compounding: `%(contents)` on a lightweight tag returns the COMMIT message, so the acknowledgement could be satisfied by text the tagger never wrote. The header called this "a slightly weaker check". It was no check. The trigger is `git tag v0.36.0` instead of `git tag -s`. Now rejected outright, pointing at `git tag -s`. Both counterexamples exit 1. 2. THE ACKNOWLEDGEMENT DISARMED ITSELF. Rule 4 grepped the whole message for `tag[ -]point`. The commit that ADDS this gate has the subject "ci(release): gate the tag point — …", and release-notes tag messages here list commit subjects verbatim (v0.35.0's does). The first release whose notes quoted that subject would auto-acknowledge. Now anchored to the start of a line: a quoted subject sits mid-line after a hash, an acknowledgement is something the tagger began a line with. A trailer-only form (`Tag-Point:`) was tried and rejected — it fails v0.34.0, whose "Tag point is the bump commit ff470a2" is specific and correct. A gate that rejects real acknowledgements teaches people to route around it. 3. NOT IDEMPOTENT, AND IT OVER-REPORTED. `--before` filters COMMITTER date — when a commit was written — but the question is when it LANDED on main. A branch dated the 3rd and merged the 10th injects commits "dated before T" into a range they were not reachable from at tag time. Built in a scratch repo: byte-identical tag object, PASS at tag time, FAIL re-run after the merge. That is the drift #366 rejected in its first design, reintroduced one level down — and it is a FALSE ACCUSATION against a tagger who did tag the tip of main, refuting the header's claim to err "in the PERMISSIVE direction". That sentence is deleted. Fixed with `--first-parent`: the spine holds exactly the squash and merge commits, whose committer dates ARE their landing times, so `--before` becomes exact. The same scratch repo now gives PASS/PASS. Detection is untouched — v0.31.0's missed commit 9557dd0 is a squash commit on the spine. This repo squash-merges, which made the defect latent; main carries 91 merge commits, so latent is not impossible. Backtest over all 42 tags is unchanged end-to-end: 39 PASS, 1 acknowledged (v0.34.0), 2 FAIL (v0.31.0, v0.33.0) — the two real misses #366 was filed for. The holes closed without moving a single verdict on real history. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clean-room audit: this gate could be switched off silently, three waysSame cold subagent, same instruction — refute, don't confirm. It confirmed A1 1. A lightweight tag made the gate vacuous — unconditional PASS
Compounding: The trigger is 2. The acknowledgement disarmed itselfRule 4 grepped the whole tag message for Now anchored to the start of a line — a quoted subject sits mid-line after a 3. Not idempotent — and it over-reported, contradicting my soundness claim
That is the verdict-drift #366 explicitly rejected in its first design, Fix: Detection is untouched: v0.31.0's missed commit VerificationBacktest over all 42 tags, unchanged end-to-end: 39 PASS, 1 acknowledged The holes closed without moving a single verdict on real history. 🤖 Generated with Claude Code |
|
Superseded by #387, merged as This branch was not merged directly — the six guardrail PRs were rebuilt onto one batch branch ( Verified on Closing as superseded, not as unmerged work. Reopen if anything here is missing from |
Closes #366.
What broke, twice
9557dd0—fix(parser): reject C-style radix literals (0x/0b/0o) instead of silently parsing to 0Both with
check-versionsgreen.Why
check-versionscould never have caught itNot an oversight — a category error. That job measures the version window: it asserts the tag matches
Cargo.toml, andCargo.tomlreadsX.Y.Zfrom the bump commit until the next bump. That window spans both the commits a tag included and the ones it silently left out. The invariant is circular with respect to the question being asked.The gate I designed first was also wrong
Recorded in #366 so it isn't re-proposed. Asking "which release does this commit belong to" makes the verdict a function of what has merged since — a gate whose answer drifts as unrelated commits land is not a gate. Filtering by tagger date doesn't rescue it either: under that design the stragglers predate the tagger date anyway, because the tag itself was held.
What it actually asks
Given tag
vX.Y.Zat commitCwith tag dateT:Cnot an ancestor oforigin/main→ skip (release-branch tag)C..origin/main, restricted to--before=TStep 2's
--before=Tis doing two jobs. Race-proof: a merge landing seconds after the tag push postdatesTand cannot fail the job. Idempotent: both inputs are immutable, so a re-run months later returns the same verdict.Evidence
Backtest — all 42
v0.*tags, running the shipped script, not a transcription of it:The two fails are exactly the two independently-confirmed bad tags. No false positives across 40 good tags. v0.34.0 passes via rule 4 because its message already says "Tag point is the bump commit ff470a2 … deliberately held after the bump merged, under the batching policy" — the acknowledgement convention already exists in how these tags are written; the gate enforces a habit that was optional and got skipped exactly when it mattered.
The real failure output names the cause directly:
Mutation test — is rule 4 load-bearing, or does v0.34.0 pass for some other reason? Two scratch tags on the identical commit
ff470a2, yielding the identical 8-commit excluded set, differing only in whether the message contains "tag point":Distinct inputs → distinct verdicts. (Both scratch tags deleted;
git tag -l 'zz-*'→ 0.)Two deliberate structural choices
It lives in
tools/ci/check-tag-point.sh, not inline in the YAML. A backtest of shell transcribed into a workflow proves the transcription works. Putting it in a file means the 42-tag backtest and the mutation test exercise the artifact that actually ships — the same lesson as the withdrawn #327 certificate, where an oracle that couldn't be run against distinct inputs certified nothing.It gates
create-release, not the builds. A red verdict stops everything outward-facing (publish-vsixalreadyneeds: create-release) while the maintainer still gets full build feedback to act on.What this does not do
Stated in the script header rather than left to be discovered:
"tag point: whatever"passes. A speed bump against autopilot, not a proof. Its value is forcing the tagger to look at the list — precisely what did not happen twice.Scope
CI plumbing, no rivet artifact — matching the #353/#363/#364 precedent; this repo carries no
REQ-CI-*ids. Flagged so the omission reads as a choice, not a miss. It also keeps this PR clear ofartifacts/requirements.yaml, which #368/#373/#374 are all currently touching.🤖 Generated with Claude Code