Publish the commit the gates qualified, not whatever the tag says now - #500
Conversation
Four prerequisite jobs proved things about a commit, and each one resolved the
tag name separately to decide which commit that was. A tag is a mutable ref, so
two boundaries could get two answers: a ref move left the ancestry and CI gates
accepting the original commit while a fresh clone of the same tag landed on
another main descendant, every check green. The release could then ship a commit
nothing had checked -- the defect these gates were built to remove, rebuilt one
level up by the gates themselves.
The canonical commit is now fixed once and consumed everywhere. The anchor is
the event sha rather than the live tag, because resolving the name in the first
job would leave a window before that job starts in which a move redefines the
whole release. The tag name survives only as the version label and as the key
the final binding check looks up.
That last check re-reads the live tag from the remote immediately before
publication and refuses a move, a deletion, or an absence. It is what
establishes that the release ships the qualified commit, together with the
ruleset holding the tag still. `--verify-tag` means publication can never create
the ref it was meant to verify.
`--target` is passed and nothing relies on it. It is documented as the target
used when the command creates a tag automatically; what the CLI does with it
when the tag already exists is not something this change asserts either way.
The claim is the narrow, provable one: it does not establish that the tag equals
the canonical commit and cannot retarget an existing tag. It is defence for one
future mistake -- if someone drops `--verify-tag`, the implicit creation that
re-enables lands on the qualified commit rather than the default branch.
It asks for both `refs/tags/<v>` and `refs/tags/<v>^{}`. `ls-remote` returns
only the tag object for an annotated tag unless the peeled ref is named --
confirmed against this repository's own v0.7.0, where one pattern gives the tag
object and only the peeled pattern gives the commit. A single pattern would have
compared the next annotated release against its tag object and refused it for a
reason unrelated to the commit.
Three controls cover three intervals and none substitutes for another. Event-sha
propagation and the binding check catch drift that happened before the check
ran. The `v*` ruleset -- update and deletion denied, no bypass actors -- is what
holds the window between that check and `gh release create` closed, because the
check has already run by then and cannot see a move landing there. Immutable
releases protect the release once it exists. The check does not make the ruleset
unnecessary; it covers the interval the ruleset does not, and the reverse.
The parser refuses ambiguity rather than resolving it. Two sources, a repeated
source flag, a remote alongside a seam, two rows for one ref, a peeled ref with
no tag object, or a ref nobody asked for: each is a caller or a payload that
does not identify one commit, and picking the convenient row would turn a guess
into a verification. Only the shape git actually produces is accepted.
Limit: the window between the binding check and `gh release create` is not itself covered by a check -- the ruleset is what holds it closed, and a bypass actor added to that ruleset would reopen it silently
Ruled-out: anchoring the resolver on the live tag and relying on the ruleset alone | a ruleset is repository state that can be edited, and a gate that is correct only while a setting holds is a setting, not a gate
Ruled-out: comparing the tag object sha returned by a single ls-remote pattern | every annotated release would be refused, and the first fix for that would likely have been to trust the name again
Warn: the canonical sha is carried as a job output, so a future job added without `needs: release-target` silently reverts to resolving the name -- the workflow tests assert the consumption, not merely the dependency names
Blast: system
Undo: easy
Certainty: firm
Verified: the release-gate suites pass and 17 of the 20 first new cases failed against the workflow as it stood; a tag moved before the resolver leaves the canonical commit at the event sha and publication refuses the mismatch, a move or deletion between boundaries refuses, a missing tag refuses without creating it, an annotated tag is accepted by its peeled commit, seven ambiguous or partial listings refuse while the one shape git produces passes, and both controls read back from GitHub as active and enabled. The same-name case drives the gates rather than their premises: the ancestry gate and the exact-head CI gate both pass for the original commit with six successful required check runs, still pass after the tag moves, and only the binding check sees that the tag no longer names it
Provenance: authored
Record-Id: r-canonsha499
CommitLore — record lintTrailers: clean — 1 commit in Active constraints for the paths this PR touchesLimits (5)
Ruled out (14)
Warnings (7)
Trailer violations fail this check. Active constraints are informational — they are what the repository already decided, not a verdict on this PR. |
Adversarial probe: glob ambiguity in the tag lookup — not a defect
A remote carrying both One row. The And those three cannot appear in a tag name at all: So a release tag can never carry a metacharacter, and the pattern is always effectively literal. Closed twice over regardless: if a listing ever did return refs beyond No change to the head — |
Exact-head production gate: PASSCandidate: The #499 release-publication defect is closed on this exact head:
Exact-head verification:
Adversarial coverage includes pre-resolver ref movement, movement or deletion between boundaries, a same-name tag on another main commit while the original CI remains green, missing tags, lightweight and annotated tags, duplicate rows, unexpected refs, incomplete peeled listings, and malformed payloads. Each unsafe or ambiguous case fails closed. Verdict: PASS. This exact head is approved for merge. After merge, release publication remains gated on successful CI at the merge commit; |
Closes #499. Base is the
mainmerge commit9a5a91a0; exact head46ab6569223fc7d5b0bf33f3a1b30bcdf297e9bb.No
v0.7.1tag or release exists. Publication is held pending an independent exact-head gate PASS on this head.The failure
Four prerequisite jobs proved things about a commit, and each one resolved the tag name separately to decide which commit that was. A tag is a mutable ref, so two boundaries could get two answers — a ref move left the ancestry and CI gates accepting the original commit while a fresh clone of the same tag landed on another
maindescendant, every check green. The release could then ship a commit nothing had checked: the defect these gates were built to remove, rebuilt one level up by the gates themselves.The correction
The canonical commit is fixed once and consumed everywhere. The anchor is the event SHA, not the live tag — resolving the name in the first job would leave a window before that job starts in which a move redefines the whole release. The tag name survives only as the version label and as the key the final binding check looks up.
release-targetgithub.sha, peeled to a commitversion-consistencyexact-head-ciinstall-gate--no-checkout, detaches onto the canonical SHA, assertsHEADlanded therepublishscripts/check-tag-binding.mjsqueries bothrefs/tags/<v>andrefs/tags/<v>^{}.ls-remotereturns only the tag object for an annotated tag unless the peeled ref is named — confirmed against this repository's ownv0.7.0, where one pattern gives7f2aa4e2…and only the peeled pattern gives the commit1ec65718…. A single pattern would have refused every annotated release by comparing its tag object.The parser refuses ambiguity rather than resolving it: two sources, a repeated source flag, a remote alongside a seam, two rows for one ref, a peeled ref with no tag object, or a ref nobody asked for. Picking the convenient row would turn a guess into a verification.
What is load-bearing, stated precisely
gh release createv*ruleset,updateanddeletiondenied, no bypass actors--targetis not load-bearing and nothing relies on it.ghdocuments it as the target used when the command creates a tag automatically; what the CLI does with it when the tag already exists is not asserted here either way. The proven property is narrow: it does not establish that the tag equals the canonical commit and cannot retarget an existing tag. It is passed as defence for one future mistake — if someone drops--verify-tag, the implicit creation that re-enables lands on the qualified commit rather than the default branch.The binding check does not make the ruleset unnecessary. It catches drift that happened before it ran; the ruleset closes the window it cannot see.
Evidence at this exact head
Clean worktree, clean
npm ci, single run, Node v24.18.0:17 of the first 20 new cases fail against the workflow as it stood. The same-name case drives the gates rather than their premises: ancestry and exact-head CI both pass for the original commit with six successful required check runs, still pass after the tag moves, and only the binding check sees that the tag no longer names it.
Repository controls, read back from the API: