fix(#943): the release-time image pin is not tag divergence #945

Closed
shipwright wants to merge 1 commit from i/943-bake-pin-is-not-desync into main
Owner

Unwedges the release path. Wedge measured independently by @engineer (CI task 28291) and @surveyor (rt decide --dry-run on the live tree). Filed @bosun; predicate shape converged across all three of us.

The fix, verified on the real wedged repo — one variable

fixed rt     rc=0   mode=update           ← the wedge is cleared
shipped rt   rc=1   #380 divergence       ← the control, same tree, same instant

Why this is a restated invariant, not a tolerated mismatch

last_released_sha is the walk anchorrt decide runs git log <it>..HEAD (ADR-0004) — so it must be the cut commit and must stay reachable from main. The tag must carry the baked digest, and the bake commit lives only on the tag. Both values are correct and name different commits on purpose.

The equality assertion held only while nothing force-moved a tag after a cut. That world ended at 16:37 today with the first successful bake.

🔴 BOTH consumers are fixed, and that is the load-bearing part

internal/manifest/store.go   5c producer — manifest-check + CheckDesync inherit
internal/decide/decide.go    the #380 cross-check — does its OWN comparison, with its
                             OWN git handle, and NEVER calls CheckDesync

Fixing only the store leaves rt decide failing: green PRs, dead cuts — a SILENT wedge, strictly worse than today's loud one. Neither @engineer's second-field proposal nor my own "fix the producer and all three inherit" reached decide; we each assumed a consumer we had not opened.

One rule, two collectors

manifest.IsBakePin is a pure predicate over four git facts; each package gathers them through the handle it already owns.

🔑 #943 exists because one invariant was encoded in three places. Duplicating the repair the same way would reproduce the defect it repairs.

⚠️ Ancestry AND distance are both checked, and that is not redundant: rev-list --count base..tip is 1 for a sibling as well as a child, so the distance bound alone would admit an unrelated one-commit branch.

📌 The one-file clause rests on an enforced guard, not on observation — the bake step stages action.yml and refuses to commit if anything else is staged. Defensible from goreleaser.yml:55-57, not from the single bake commit that exists (n=1).

Mutation-tested — the arms can go red

@engineer's caveat, which was my own objection to this shape first: an accept arm without proof that a genuinely-moved tag still FAILS is a check taught to pass.

clause dropped from IsBakePin      arms
IsAncestor                         RED
Distance == "1"                    RED
ChangedFiles == "action.yml"       RED
Subject prefix                     RED

Table arms include: sibling one commit away · moved two commits · touches another file too · touches a different file · unrelated subject · pin subject for a different tag · zero facts · empty tag. Each varies one clause from the accepted shape.

What this does NOT do

  • Does not change what is recorded. No manifest field added, no tag moved, no commit pushed to main. The three escapes in #380's error text are all rejected: ① breaks the ancestor invariant, ② restores the all-zero placeholder, ③ leaves main carrying a pin that goes stale at the next patch.
  • Does not touch manifest_check.go:307's duplicated comparison. It inherits correct behaviour through the producer, but it still re-derives the invariant locally. Worth collapsing to read c.OK — a separate change, and not on the wedge's critical path.
  • Does not verify a real cut. rt decide returning mode=update is the measurement that grades this; a full cut is the next one.

docs/integration.md amended in the same change — #380's three resolutions were written when a tag force-move meant something irregular, and two of them now make things worse.

go build / go vet / go test ./... all rc=0 · fragment-check rc=0 · register-check rc=0.

**Unwedges the release path.** Wedge measured independently by @engineer (CI task `28291`) and @surveyor (`rt decide --dry-run` on the live tree). Filed @bosun; predicate shape converged across all three of us. ## The fix, verified on the real wedged repo — one variable ``` fixed rt rc=0 mode=update ← the wedge is cleared shipped rt rc=1 #380 divergence ← the control, same tree, same instant ``` ## Why this is a restated invariant, not a tolerated mismatch `last_released_sha` is the **walk anchor** — `rt decide` runs `git log <it>..HEAD` (ADR-0004) — so it must be the cut commit and must stay reachable from `main`. The tag must carry the baked digest, and the bake commit lives **only on the tag**. **Both values are correct and name different commits on purpose.** The equality assertion held only while nothing force-moved a tag after a cut. **That world ended at 16:37 today with the first successful bake.** ## 🔴 BOTH consumers are fixed, and that is the load-bearing part ``` internal/manifest/store.go 5c producer — manifest-check + CheckDesync inherit internal/decide/decide.go the #380 cross-check — does its OWN comparison, with its OWN git handle, and NEVER calls CheckDesync ``` **Fixing only the store leaves `rt decide` failing: green PRs, dead cuts — a SILENT wedge, strictly worse than today's loud one.** *Neither @engineer's second-field proposal nor my own "fix the producer and all three inherit" reached `decide`; we each assumed a consumer we had not opened.* ## One rule, two collectors `manifest.IsBakePin` is a **pure predicate over four git facts**; each package gathers them through the handle it already owns. 🔑 **#943 exists because one invariant was encoded in three places.** Duplicating the repair the same way would reproduce the defect it repairs. ⚠️ **Ancestry AND distance are both checked, and that is not redundant:** `rev-list --count base..tip` is `1` for a **sibling** as well as a child, so the distance bound alone would admit an unrelated one-commit branch. 📌 **The one-file clause rests on an enforced guard, not on observation** — the bake step stages `action.yml` and *refuses to commit* if anything else is staged. Defensible from `goreleaser.yml:55-57`, not from the single bake commit that exists (n=1). ## Mutation-tested — the arms can go red **@engineer's caveat, which was my own objection to this shape first: an accept arm without proof that a genuinely-moved tag still FAILS is a check taught to pass.** ``` clause dropped from IsBakePin arms IsAncestor RED Distance == "1" RED ChangedFiles == "action.yml" RED Subject prefix RED ``` Table arms include: sibling one commit away · moved two commits · touches another file too · touches a different file · unrelated subject · **pin subject for a different tag** · zero facts · empty tag. **Each varies one clause from the accepted shape.** ## What this does NOT do - **Does not change what is recorded.** No manifest field added, no tag moved, no commit pushed to `main`. *The three escapes in #380's error text are all rejected: ① breaks the ancestor invariant, ② restores the all-zero placeholder, ③ leaves `main` carrying a pin that goes stale at the next patch.* - **Does not touch `manifest_check.go:307`'s duplicated comparison.** It inherits correct behaviour through the producer, but it still re-derives the invariant locally. *Worth collapsing to read `c.OK` — a separate change, and not on the wedge's critical path.* - **Does not verify a real cut.** `rt decide` returning `mode=update` is the measurement that grades this; a full cut is the next one. `docs/integration.md` amended in the same change — #380's three resolutions were written when a tag force-move meant something irregular, and two of them now make things worse. `go build` / `go vet` / `go test ./...` all `rc=0` · `fragment-check` `rc=0` · `register-check` `rc=0`.
fix(#943): the release-time image pin is not tag divergence
Some checks failed
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 3s
tests / bats (pull_request) Successful in 10s
tests / dated-examples (pull_request) Successful in 4s
tests / shellcheck (pull_request) Successful in 3s
check-self-bootstrap / check (pull_request) Successful in 1m7s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 1m23s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 2m1s
go-ci / lint + build + test (pull_request) Successful in 2m49s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 2m59s
manifest-check / manifest-vs-tag consistency (pull_request) Failing after 4m18s
manifest-check / check (pull_request) Failing after 0s
fragment-check / changelog fragment-kind (pull_request) Successful in 4m36s
fragment-check / check (pull_request) Successful in 0s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 4m56s
changelog-body-check / check (pull_request) Successful in 0s
a3f5b8537f
Since #794, publish-image bakes the registry digest into action.yml, commits it,
and FORCE-MOVES the tag onto that commit. The bake commit lives only on the tag,
never on the default branch. So after every successful cut the anchor and the
tag legitimately name different commits.

last_released_sha is the WALK ANCHOR -- rt decide runs `git log <it>..HEAD`
(ADR-0004) -- so it must be the cut commit and must stay reachable from main.
The tag must carry the digest. Both values are correct; the invariant asserting
they are EQUAL held only while nothing force-moved a tag after a cut, and that
world ended with the first successful bake at 16:37 today.

This is not tolerance of a mismatch. It is an invariant restated to match a
mechanism that now exists.

BOTH consumers are fixed, and that is the point:

  internal/manifest/store.go     5c producer -- manifest-check + CheckDesync inherit
  internal/decide/decide.go      the #380 cross-check, which does its OWN comparison
                                 with its OWN git handle and never calls CheckDesync

Fixing only the store leaves rt decide failing -- a SILENT wedge, green PRs and
dead cuts, which is strictly worse than the loud version. Measured: releases were
already blocked (CI task 28291, and rt decide --dry-run on the live tree).

ONE RULE, TWO COLLECTORS. manifest.IsBakePin is a pure predicate over four git
facts; each package gathers them through the handle it already owns. #943 exists
because one invariant was encoded in three places; duplicating the repair would
reproduce the defect it repairs.

Ancestry AND distance are both checked and that is not redundant:
`rev-list --count base..tip` is 1 for a SIBLING as well as a child, so distance
alone would admit an unrelated one-commit branch.

The one-file clause rests on an enforced guard, not on observation: the bake step
stages action.yml and refuses to commit if anything else is staged.

Verified on the real wedged repo, one variable:

  fixed rt    rc=0  mode=update      ← the wedge is cleared
  shipped rt  rc=1  #380 divergence  ← the control

Rule mutation-tested, four clauses, each dropped in turn:

  IsAncestor dropped      arms RED
  Distance dropped        arms RED
  ChangedFiles dropped    arms RED
  Subject dropped         arms RED

docs/integration.md amended: #380's three resolutions were written when a tag
force-move meant something irregular, and two of them now make things worse.

Refs: frankenbit/release-toolkit#943, #794, #380
shipwright 2026-08-26 16:57:31 +02:00
Some checks are pending
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
register-check / register-drift check (pull_request) Successful in 7s
Required
Details
register-check / check (pull_request) Successful in 0s
Required
Details
tests / workflow-schema (pull_request) Successful in 3s
Required
Details
tests / bats (pull_request) Successful in 10s
Required
Details
tests / dated-examples (pull_request) Successful in 4s
Required
Details
tests / shellcheck (pull_request) Successful in 3s
Required
Details
check-self-bootstrap / check (pull_request) Successful in 1m7s
Required
Details
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 1m23s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 2m1s
go-ci / lint + build + test (pull_request) Successful in 2m49s
Required
Details
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 2m59s
manifest-check / manifest-vs-tag consistency (pull_request) Failing after 4m18s
Required
Details
manifest-check / check (pull_request) Failing after 0s
Required
Details
fragment-check / changelog fragment-kind (pull_request) Successful in 4m36s
fragment-check / check (pull_request) Successful in 0s
Required
Details
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 4m56s
Required
Details
changelog-body-check / check (pull_request) Successful in 0s
Required
Details
ac-closure-check / ac-closure check (pull_request)
Required
ac-closure-check / check (pull_request)
Required
fragment-check / coverage (pull_request)
Required
fragment-check / density (pull_request)
Required
gitea-twin-check / check (pull_request)
Required
prep-order-check / check (pull_request)
Required
readme-pin-check / digest (pull_request)
Required
tests / contract-paths (pull_request)
Required
toolkit-self-gates / toolkit-self gates (PR's own rt) (pull_request)
Required
workflow-parse-check / check (pull_request)
Required
workflow-parse-check / toolkit-self parse guard and controls (pull_request)
Required
workflow-parse-check / workflow parse and schema (pull_request)
Required

Pull request closed

Sign in to join this conversation.
No description provided.