Skip to content

Publish the commit the gates qualified, not whatever the tag says now - #500

Merged
MongLong0214 merged 1 commit into
mainfrom
release-canonical-sha
Aug 8, 2026
Merged

Publish the commit the gates qualified, not whatever the tag says now#500
MongLong0214 merged 1 commit into
mainfrom
release-canonical-sha

Conversation

@MongLong0214

Copy link
Copy Markdown
Owner

Closes #499. Base is the main merge commit 9a5a91a0; exact head 46ab6569223fc7d5b0bf33f3a1b30bcdf297e9bb.

No v0.7.1 tag 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 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 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.

boundary consumes
release-target github.sha, peeled to a commit
version-consistency canonical SHA
exact-head-ci canonical SHA
install-gate clones --no-checkout, detaches onto the canonical SHA, asserts HEAD landed there
publish canonical SHA, then refuses unless the live tag still resolves to it

scripts/check-tag-binding.mjs queries 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 7f2aa4e2… and only the peeled pattern gives the commit 1ec65718…. 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

interval control
push → binding check event-SHA propagation, then the binding check
binding check → gh release create v* ruleset, update and deletion denied, no bypass actors
after the release exists immutable releases

--target is not load-bearing and nothing relies on it. gh documents 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:

full suite   107 files, 2,338 pass, 1 skipped
typecheck    clean
dist         rebuilds byte-identical
workflow     loads as YAML

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:

immutable-releases   {"enabled": true, "enforced_by_owner": false}
ruleset 20587790     active · refs/tags/v* · [update, deletion] · bypass: []

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
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

CommitLore — record lint

Trailers: clean — 1 commit in origin/main..46ab6569223fc7d5b0bf33f3a1b30bcdf297e9bb
Active constraints: 5 limits · 14 ruled-out · 7 warnings — from 9 records over 4 changed paths

Active constraints for the paths this PR touches

Limits (5)

  • r-canonsha499 46ab656 — 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
  • r-pubprereq 8ffb31c — the gates qualify the commit and its CI, not the tag's authorship or timing -- anyone who can push a v* tag to a qualified commit on main can still publish, and expiring or re-pointing a tag after these jobs pass is outside what any of them observe
  • r-failclosed493 3216a54 — the job runs on ubuntu-latest only, so it qualifies the POSIX install; the Windows path is still covered by install-ps1 in CI against the branch rather than against the tag
  • r-plugres483 7dab3e3 — the gate now catches the mismatch at release time, and doctor's inject-version check catches it for a user, but nothing tells a user who has never run doctor
  • r-exit065 e545dee — any new command's exit codes must be drawn from SPEC §10, not invented locally

Ruled out (14)

  • r-canonsha499 46ab656 — 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
  • r-canonsha499 46ab656 — 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
  • r-pubprereq 8ffb31c — expressing either check as an if: on publish | a condition in YAML has no test, and a release gate whose logic cannot fail in a suite is a claim rather than a control
  • r-pubprereq 8ffb31c — treating a missing required check as nothing to report | that is the empty-set inversion above, and it is exactly how a release with no CI at all would have published
  • r-failclosed493 3216a54 — reusing the workflow's checkout instead of cloning | the row asks whether a fresh clone works, and a checkout is not the thing users get
  • r-failclosed493 3216a54 — making the stale-hook row non-blocking because its fixture also fails another check | the confounding finding is separable by reading the row, and a row excused for being awkward is the fail-open being repaired
  • r-plugres483 7dab3e3 — preferring CLAUDE_PLUGIN_ROOT over PATH | it reopens how ADR-0011's ship-dist-in-the-repo interacts with the installer's wrapper, and the node-resolution advantage that motivated the current order would need re-examining rather than reversing
  • r-plugres483 7dab3e3 — comparing versions inside the run script | it sits on the hot path of every edit and would spend a second process launch there to report a condition doctor already reports
  • r-nobin284 ab2f08f — Keeping the binary classification arm for the installer's wrapper | the wrapper is a shell script that execs node, so trusting it by name would re-admit any extensionless executable called commitlore while the bundle it runs is the thing with an interpreter to check
  • r-nobin284 ab2f08f — Relaxing the dogfood Evidence check to allow a missing path | it exists to catch a citation nobody can follow, and dropping the check to accommodate a deletion would retire the guarantee instead of correcting the question it asked
  • r-nobin284 ab2f08f — Leaving src/core/paths.ts alone because ADR-0026's inventory did not name it | it was the only remaining code that read files out of a compiled artifact, so leaving it would have left the invariant false while the test asserting it passed
  • r-nobin284 ab2f08f — Keeping /dist/commitlore in gitignore in case a binary returns | an ignore rule for an artifact nothing produces is a claim that something might, and the ADR says otherwise
  • r-exit065 e545dee — leaving guard's inversion in place and only documenting it | guard and inject are the two commands wired into hooks and CI, so a script written against guard today is already trusting the number; a documented inversion is still an inversion, and it gets load-bearing the longer it sits. The surface is one boolean per command right now — cheaper to fix once than to explain forever.
  • r-7a48c3 b85d847 — a CONFLICTED lifecycle state for divergent declarations | see above — it would block work on records that have a correct answer

Warnings (7)

  • r-canonsha499 46ab656 (claim) — 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
  • r-pubprereq 8ffb31c (claim) — the required-check list is a literal, so a job renamed in the CI workflow becomes a check this gate waits for and never finds -- it fails closed, which is right, and the failure will read as a CI problem rather than a rename
  • r-failclosed493 3216a54 (claim)publish now waits on a job that clones over the network, so a transient clone failure withholds a release for a tag that is already pushed -- the tag is the durable thing and re-running the job is the recovery
  • r-plugres483 7dab3e3 (claim) — the narrowed PATH in the gate includes the interpreter's directory, so a machine whose node lives beside a commitlore wrapper still shadows the plugin and the check will pass for the wrong reason
  • r-nobin284 ab2f08f (claim) — an extensionless COMMITLORE_BIN is now ignored rather than honoured -- the hook falls through to the recorded install and to PATH, so a caller who pointed it at the wrapper still gets a working hook, by a different route
  • r-exit065 e545dee (claim) — guard's swap is a breaking change for anything scripted against the old exit 2 for "matched" — see CHANGELOG Unreleased/Breaking
  • r-7a48c3 b85d847 (claim) — these two changes were developed concurrently in one worktree and share a built dist/. Splitting them would leave one commit whose dist/ did not match its src/, so they land together and are described together

git log --follow accepts exactly one pathspec, so renames are not followed for 4 paths; query one path at a time to follow its rename chain

withheld the content of 1 record(s) graded blocked: a Verified trailer matching an injection pattern is reported, never quoted (SPEC §7)

Trailer violations fail this check. Active constraints are informational — they are what the repository already decided, not a verdict on this PR.

@MongLong0214

Copy link
Copy Markdown
Owner Author

Adversarial probe: glob ambiguity in the tag lookup — not a defect

git ls-remote treats its ref arguments as patterns, not literals, so refs/tags/<version> could in principle match more than the tag being verified. Probed rather than assumed:

A remote carrying both v9.9.9 and v9x9x9, queried for refs/tags/v9.9.9:

f105c6c5  refs/tags/v9.9.9

One row. The . matches literally — git's ref patterns are fnmatch-style, where only *, ? and [ are wildcards.

And those three cannot appear in a tag name at all:

git check-ref-format refs/tags/v1*     rejected
git check-ref-format refs/tags/v1?0    rejected
git check-ref-format refs/tags/v1[0]   rejected
git check-ref-format refs/tags/v1.0    allowed

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 refs/tags/<v> and refs/tags/<v>^{}, check-tag-binding.mjs exits 2 rather than reading a familiar row out of an unfamiliar payload. That is the unexpected refs refusal already covered by the suite.

No change to the head46ab6569223fc7d5b0bf33f3a1b30bcdf297e9bb stands. Recording the probe because a checked assumption and an unchecked one look identical afterwards.

@MongLong0214

Copy link
Copy Markdown
Owner Author

Exact-head production gate: PASS

Candidate: 46ab6569223fc7d5b0bf33f3a1b30bcdf297e9bb
Base: 9a5a91a06bec50988f9cb6ea59f3e8003a08e773

The #499 release-publication defect is closed on this exact head:

  • one canonical commit is derived from the immutable tag-push event SHA and peeled to a commit once;
  • version consistency, exact-head CI, the fresh-clone install gate, and publication all consume that exported SHA;
  • publication re-reads the live tag immediately before release creation and refuses moved, deleted, missing, wrong-target, ambiguous, partial, or malformed bindings;
  • annotated tags are compared by their peeled commit, not their tag object;
  • --verify-tag prevents publication from recreating a missing reference;
  • the active refs/tags/v* ruleset denies update and deletion with no bypass actors, and immutable releases are enabled;
  • PR Release 0.7.1 — the tier 0.7.0 announced, actually reachable #486's stale exact-head evidence and sequencing description have been corrected.

Exact-head verification:

  • focused release-tag regression suite: 30/30 PASS;
  • full suite: 107/107 files, 2,338 PASS, 1 SKIP;
  • source and benchmark typechecks: PASS;
  • deterministic build and committed distribution comparison: PASS, byte-identical;
  • conformance suite: PASS;
  • exact-head GitHub CI on Node 22 and 24, install jobs, Git matrix, and PR lint: PASS.

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; v0.7.1 must target that verified commit, and the tag-triggered release workflow must complete successfully before the release is considered published.

@MongLong0214
MongLong0214 merged commit 18fc7b3 into main Aug 8, 2026
14 checks passed
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.

Bind release publication to one protected immutable tag target

1 participant