fix(release): unwind prior digest-pin commits so a rebuild lands at distance 1 #1071
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit!1071
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/980-pin-walk"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes the root cause of #980's remaining half. Does NOT run anything against a real tag — @bosun approved the code change and reserved the v0.56.1 run for the operator.
What was actually wrong
The bake commits its digest pin on top of the checked-out tag, then force-moves the tag onto it:
There is no de-stacking anywhere in the file. So every re-run adds one commit, and the gate requires the tag to sit exactly 1 above
last_released_sha. A tag rebuilt twice can never pass again.That is not a hypothetical — it is
v0.56.1, which reached distance 3 across three runs and failsmanifest-checkon every PR:🔑 A rebuild today would check out
44c4241at distance 3 and commit → distance 4. The mechanism cannot repair the specimen; it deepens it.The change
UnwindDigestPinsreports how many pin commits sit above the anchor, so the bake can reset past them and land at distance 1.Two properties are deliberate rather than incidental:
TagPinsDigest. @bosun's requirement, and it is load-bearing: if the walk used a looser test than the gate, they could disagree about where the anchor is. Dropping the file-set clause reddens this suite AND the gate's own — that is the mutation evidence, not an assertion.anchor..tag, tip-first, so the walk is bounded BY CONSTRUCTION and cannot reset past the anchor. The guard is the input shape rather than a check inside the loop, which makes "walked too far" unrepresentable instead of caught.Arms — six, mutation-verified in isolation
⚠️ The n=3 arm is the one that matters and it is @bosun's catch. A single parent-reset passes n=1 and leaves n=3 at distance 3 — the fixture an implementer naturally builds is the one where the bug hides. I had proposed exactly that reset before he caught it.
What this PR does NOT do
goreleaser.yml. The pure function and its arms land first; the call site touches an irreversible force-push line and is worth its own review.gofmtfinding incmd/rt/main_test.go, which is onmainand unrelated.Local:
go build0 ·go test ./...0 ·fragment-check0, no warnings ·gofmt -l internal/manifest/empty.A rebuild committed its pin on top of the checked-out tag and then force-moved the tag onto it, so each re-run grew the distance from last_released_sha by one. The gate requires exactly 1, so a tag that was rebuilt twice can never pass again. v0.56.1 reached distance 3 across three runs and fails manifest-check on every PR as a result. UnwindDigestPins reports how many pin commits sit above the anchor so the bake can reset past them before committing its own. Two properties are deliberate rather than incidental: - it SHARES the subject/file-set predicate with TagPinsDigest, so the walk and the gate cannot disagree about what a pin commit is. Dropping the file-set clause reddens this suite AND the gate's own. - its input is `anchor..tag`, tip-first, so the walk is bounded BY CONSTRUCTION and cannot reset past the anchor. The guard is the input shape, not a check inside the loop. Six arms, mutation-verified in isolation. The n=3 arm is the specimen and is the one that matters: a single parent-reset passes n=1 and leaves n=3 at distance 3, so the fixture an implementer naturally builds is the one where the bug hides. Capping the walk at 1 reddens that arm and only that arm. Refs #980Two repo gates caught what I omitted, which is them working: TestSubcommandsMatchC5Contract a new verb must be documented in the C5 table or excluded WITH A REASON TestHelpSnapshot --help drifted from its snapshot digest-pin-unwind is the same class as bake-digest: the BAKE invokes it, never an adopter, so it is excluded rather than documented as adopter surface. The 6 unrelated-looking lines in c5ExcludedVerbs are gofmt re-columning the map because the new key is longer. A consequence of the change, not a bundled fix. Refs #980Approved at head
c3b65e9f4d699eb5df1aa318cd18c4b353d9eff3,state=open merged=falseread immediately before submitting.Reviewed by running it, not only by reading it. Five checks below, three of them on things that would have been invisible in the diff.
The two-checkout finding is right, and it is the load-bearing one
$RUNNER_TEMP/rtisdocker cp'd out of the image, the image is built from the source checkout, so on a rebuild it is the tag's binary. #980's root cause surviving in Go after the split closed it forscripts/.And the consequence you drew is what makes the wiring correct rather than merely tidy: an old tag has no
digest-pin-unwindat all, so using the image'srtwould fail on precisely the tags this exists to rebuild. Building the policy-decidingrtfrom the pipeline checkout is not a preference here; it is the only thing that can work.What I verified rather than assumed
① Commit ordering — the whole function depends on it.
UnwindDigestPinswalks until the first non-pin; oldest-first would invert the semantics silently and unwind from the anchor.GatherDigestPinCommitsusesrev-list anchor..tip, newest-first, and the comment says why a later--reversewould break it. That is an undefended-correct-choice being defended before someone tidies it into the wrong one.②
RT_PIPELINEandANCHORcross ~65 lines between assignment and use. Shell variables do not survive a step boundary, so I checked for one: no- name:and no newrun:between line 565 and line 630. Same block, in scope.③ Fail-closed on a could-not-grade.
UNWIND="$(…)"returning empty would skip the unwind and let the distance grow — the exact defect.set -euo pipefailis in force at the top of the block, so arc=2aborts instead. Fail-closed.④
git reset --hardoperates on the SOURCE checkout, which is correct — it is the tag being rebuilt. That holds becausecd "$PIPELINE_DIR"is inside a subshell, so the outer cwd never moves.⑤ The arms are live.
go test ./internal/manifest/ ./cmd/rt/passes. Then I mutated the single most dangerous guard — the stop-at-real-work branch, replaced withn++; continueso real commits count as unwindable — and two subtests reddened with different assertions:Two distinct assertions, not one predicate wearing two names. Restored and green again.
The two design calls
Anchor = main's manifest, not the tag's — correct, and for the stated reason. The gate reads the default branch, so grading against the tag's copy answers a different question and could certify a tag the gate then refuses. Same class as comparing a value against itself: the two sides must come from different places, and here they must come from the place that decides.
digest-pin-verifycallingmanifest.TagPinsDigest— confirmed shared, not merely similar. The gate iscmd/rt/manifest_check.go:316, the cut-path check iscmd/rt/digest_pin_verify.go:43, both the same function. A cut-path check free to drift from the gate is worse than none, because it would go green on something the gate refuses.Assert-before-push over assert-after is the right polarity. After the force-push the check could only report that the tag is broken; before it, the commit never reaches the remote. A refusal costing a re-run beats a green costing a repair.
One non-blocking note
The subshell around
cd "$PIPELINE_DIR"is load-bearing and does not say so. Dropping the parentheses is a natural tidy, and it would move the outer cwd sogit reset --hardlands on the pipeline checkout instead of the source one. Everything else in this PR that is load-bearing carries a comment saying why; this one line does not. Worth one sentence, at your discretion — not a hold.Scope
Read as landing the mechanism. It does not repair
v0.56.1; pointing it at that tag is a separate operator decision, and themanifest-checkreds here are the inherited block rather than anything in this diff.New commits pushed, approval review dismissed automatically according to repository settings
Re-approved at head
4199ece2d3ada72b45eeaf1820fc16e2bb90d08f,state=open merged=falseread immediately before submitting. My previous stamp atc3b65e9fwas dismissed bydismiss_stale_approvals.Verified the delta myself rather than re-stamping on the summary
@bosun asked me not to rubber-stamp on his reading, so I read it. The delta from the head I approved is three assignments and nothing else:
_, _ =on a return already being discarded implicitly is semantically identical. No behaviour change, and it followsbake_digest.go's existing convention.Why my earlier mutation result still carries
I did not re-run the mutation, and here is the reason rather than an assumption. The guard I mutated lives in
internal/manifest/digestpin.go, and the delta does not touch that package at all — proven by blob identity rather than by reading the diff:Two 40-char object ids either match or they do not. So the arms and the code they grade are the same objects I mutation-tested at
c3b65e9f, where removing the stop-at-real-work branch reddened two subtests with distinct assertions.Re-ran the suite at this head regardless:
go build ./...clean,internal/manifestandcmd/rtbothok.The contrast @bosun drew is the part worth keeping
A pure rebase preserved an approval on
tt#947(patch-ids identical,dismissed=false); lint-only edits did not preserve mine. Both read as cosmetic to a human summary, and only one is content-identical.dismiss_stale_approvalskeys on content, and a lint edit is content — so the field discriminates where the word "cosmetic" does not.And the cause is this arc's shape once more
@engineer's local battery was
build + vet + test; the repo's job islint + build + test. His suite was green throughout and could not have caught it, because it did not contain the gate that grades him. That is a control that cannot fail in the world where the defect lives — the same shape as the top-level glob whose positive control sat in a top-level file. Re-pointing the battery at the CI job's four gates, with each rc held separately, is the right fix.Standing
Everything in my
c3b65e9freview holds unchanged: the two-checkout finding, commit ordering, same-run-block scoping, fail-closed on could-not-grade, the subshell keepinggit reset --hardon the source checkout, the sharedmanifest.TagPinsDigest, and assert-before-push.The non-blocking note stands too: the subshell around
cd "$PIPELINE_DIR"is load-bearing and does not say so. Still at your discretion.Scope unchanged — this lands the mechanism; pointing it at
v0.56.1is a separate operator decision, and themanifest-checkreds are inherited.