feat(release): Cutter transactional cut engine (ADR-0009 §6) #560
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!560
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/554-cutter-transactional"
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?
What
internal/release.Cutter— the fail-atomic transactional cut engine every Phase-6 orchestrator consumes. Implements the frozen #505Cutterinterface as the ADR-0009 §6 design, folding #499's measured partial-progress failure mode (a cut that fails midway leaving a manifest orphan) as first-class transactional design rather than a follow-up refactor.An ordered transaction:
Every gate that can fail runs against the reversible prefix before any irreversible mutation (#499 remedy 3), and the byte-check-adjacent-to-fire precondition lives inside
Fire, not in callers (§5).Design
Prepare— reads + parses fragments (one directory pass so consumed == deleted == restored), composes the seal, runs the gates (unknown-kind fragments fail-loud first; then the #442 register-drift scan on the composed body, before any write), then mutates in order: (a) atomic-write the sealedCHANGELOG.md, (b)manifest.Store.Write, (c) delete the consumed fragments as the last step. A failure at any mutation rolls the earlier ones back — an abortedPrepareleaveschangelog.d/andCHANGELOG.mdexactly as found.ErrGateon a failed check.Fire— reads live HEAD;expectedHeadmismatch →ErrHeadMoved(no tag created). Idempotent-replay viaGetReleaseByTag: a release that already exists and targetsexpectedHeadis a clean no-op; a different target isErrReplayConflict. OtherwiseCreateReleaseDraft(Target=expectedHead)(tag + publish fused server-side, matchingdraft-release.sh).Rollback— reverts a prepared-but-not-fired transaction (fragments →changelog.d/, manifest restore, CHANGELOG revert);ErrIrreversibleonce the suffix fired.flocktaken at construction serializes overlapping cuts; contention →ErrConcurrentCut(fail fast, never queue — queueing behind a mid-write run is how #499 said an orphan gets manufactured).Ratified scope (with Bosun)
release-prep.sh) and tag+publish (draft-release.sh) as separate workflow runs across a PR merge; the Cutter unifies them (§6 "design opportunity, not a mechanical port"). The bats-oracle harness gate is a milestone gate met by the consumers #556 (rt prep→release-prep.bats) / #557 (rt release→draft-release.bats). This matches the Phase-5 bake precedent (a library gated by a tree-SHA property).release-prep.shpipeline (CC-commit merge, manual[Unreleased]prose carry, config-heading richness) lands with #556. Fragment compose exercises §4 (fragment-consumption atomicity) fully.internal/changeloghelpers (RenderSections,Seal) sit beside the frozen interface, following the existingRenderCommitSectionsadditive pattern (Seal= the compose-driven sibling ofComposer.Transition).Property invariant (§4) + mutation-verification closed loop
property-invariants.md §4: after a cut, for every fragment exactly one of (consumed ∧ in CHANGELOG) or (not consumed ∧ inchangelog.d/) — never both (double-count), never neither (silent loss). Expressed as the equalityfileDeleted == bodyInCHANGELOG, asserted over three cuts: success, gate-failure, and injected mid-prefix failure (the #499 scenario — CHANGELOG seal written, then the manifest step fails → must roll back with no orphan).Mutation experiment — removed the changelog rollback on the manifest-write-failure path:
Observed (
go test -run Transactionality_partialProgress):The mutant behaves differently — the sealed CHANGELOG survives while the fragments remain, exactly the double-count §4 forbids. Reverted by re-edit (byte-exact; not
git checkout); suite green again.What this PR does NOT do (explicit deferrals)
rtsubcommand / CLI wiring. The Cutter is a library;rt decide/prep/release(#555/#556/#557) consume it.cmd/rtprepis still the phase-6 stub.[Unreleased]-prose carry, no config-heading beyondsection_format— therelease-prep.shpipeline richness, deferred to #556 (ratified).Transactionis the same-process atomic core; the rolling pipeline's cross-run suffix-replay (prep→merge→release) resumes from persisted substrate viaFire's idempotent-replay — the reconstruct path lands with #556/#557 (§3.3 "interfaces refined as reality intrudes").flockguards same-host overlap; the Forgejo-actionsconcurrency:cross-runner guard (schema viability unverified, #499 remedy 1) is a Phase-7 item.forgejo.Client.CreateReleaseDraftas the frozen #541 surface exposes it; a distinct publish/immediate path is a forgejo.Client refinement, out of scope.Flags for review
Fire's replay-conflict check comparesTargetCommitishvsexpectedHead(== the manifest'slast_released_sha, set from the same head inPrepare). A fuller manifest-vs-release comparison is possible but this is the load-bearing "same commit?" question.manifest.last_released_sha = head at Prepare— correct for the atomic model (Prepare-head == Fire'sexpectedHead); the cross-process case (SHA = merge commit) is the #556/#557 reconstruct concern noted above.syscall.Flockis unix — the cut runs on the Linux CI runner; a cross-platform locking backend lands with the §7 Windows/macOS runners (Phase 8).Gate
Full gate green and re-verified in
forgejo-ci-go:latest(host masks runner-only gaps):golangci-lint run0 issues,go build ./...,go vet ./...,go test -count=1 ./...,gofmt -lclean.Refs #554, #499, #505, #508
Review — PR#560, Cutter transactional cut engine (#554, Phase 6 START-HERE)
Independent deep-verify at head
54052ae(basemain@5ca9d78, 6 files +1248). I extracted the tree via the archive endpoint, ran the full gate inforgejo-ci-go:latest(go1.26.2), read every file, and — because §4 is the load-bearing invariant this PR exists to establish — drove my own injected-failure mutation through the realPrepare, distinct from the PR body's manifest-write injection.One MUST-FIX (a reproduced §4 hole on the
deleteFragmentsbranch), else the design is clean and correct. Boundary deferrals (#545/#549 carry-forwards) independently confirmed. Details below.What I verified (reproduced, not read)
go build ./.../go vet ./.../go test ./internal/release/... ./internal/changelog/... -count=1all green (go1.26.2)restoreChangelogat the manifest branch) does redden with the double-count as describedTestSeal_injectsComposedSectionuses a sample with Unreleased prose and pins it dropped (the divergence fromTransition); config-driven em-dash heading covers the## [0.36.0] — …shape; no-Unreleased →ErrNoUnreleasedSection→ErrGateErrConcurrentCut; kernel releases on process death (no stale-lockfile class — better than a content-lockfile); concurrent-cut test passesexpectedHeadvs live HEAD →ErrHeadMovedbefore any tag; idempotent-replay no-op +ErrReplayConflictboth testedwriteFileAtomicselfbootimport ininternal/release— prefix creates no commit; ambient-committer precondition correctly lands with #556{GetReleaseByTag, CreateReleaseDraft}— those stricter-than-bash behaviors land with the #556/#557 consumersMUST-FIX — partial
deleteFragmentsfailure orphans the removed fragments (§4 "silent loss")The
(c)fragment-consumption branch rolls back manifest + changelog but not fragments:deleteFragmentsremoves files in a loop. Ifos.Removesucceeds on file 0 and then fails on file 1 (a non-IsNotExisterror — IO error, or a perms change onchangelog.d/mid-cut), file 0 is already gone. The(c)rollback then reverts the seal (so file 0's body is not in CHANGELOG) and reverts the manifest, but does not restore the deleted fragment. Final state for file 0:fileDeleted=true ∧ bodyInCHANGELOG=false— neither in CHANGELOG nor inchangelog.d/. That is exactly the §4 forbidden state "never neither (silent loss)."Reproduced through the real
Prepare(injected a genuine mid-loopdeleteFragmentsfailure after file 0, ran in the CI image):Fix — one idempotent line (restore fragments first in the
(c)rollback, mirroringRollback()which already restores all three):restoreFragmentsre-writes every captured fragment (recreates the deleted, overwrites the survivors byte-identically), so it's safe on a partial delete. With the injection still active and only this line added, my probe flips red → green (§4 holds); I then restoredcutter.gobyte-identical to the PR head (cmp✓).The root is an asymmetry:
Rollback()restores fragments+manifest+changelog, but the inline(c)branch — the one place fragments can be partially gone — restores only two of the three. ThedeleteFragmentsdoc comment even says callers "restore viarestoreFragmentson a later-step failure or Rollback," but(c)is the last prefix step, so its own failure has no later step to catch it — it must restore fragments itself.Honest scoping: runtime reachability is low — it needs
os.Removeto fail partway (IO error / a non-cut process changingchangelog.d/perms mid-cut; the flock rules out a racing cut). I'm still calling it a must-fix rather than a should-consider because (a) it directly violates this PR's load-bearing invariant on a real code path, (b) this is the foundational engine every Phase-6 orchestrator inherits, and (c) the fix is one idempotent line that closes a clear oversight. If you'd rather defer, a tracker + the disclosed limitation would be the honest alternative — but at one line, fixing now is cheaper than the tracker.Also add a
(c)-branch property-test case. The threetransactionality_test.gocases inject at gate and (b) manifest; none inject at (c), which is why CI is green over the hole. A test that failsdeleteFragmentsafter partial progress and assertsassertInvariantpins the fix and the invariant's own "aborted cut" clause for the branch it currently doesn't cover. (deleteFragmentsisn't Deps-injectable; the cleanest seam is a small unexportedremoveFragment funcfield defaulting toos.Remove, or a test that makes file 1's removal fail via a read-only nested arrangement.)Confirmed carry-forwards / disclosed flags (non-blocking, downstream-owned)
CreateReleaseDrafthardcodesDraft: true(internal/forgejo/mutations.go:167) — Fire creates a draft, so the interface's "(d) release publish — irreversible/consumer-observable" is aspirational at this layer; the true publish (un-draft) is a downstream step (#557). Fire is unwired to the real forge in #554 (faked in tests), so this is correctly deferred — flagging only so the #557 wiring closes "draft created" → "published" with its own idempotency. This is the #545 "always-draft immediate-mode" flag, confirmed.TargetCommitishagainst the SHA (PR flag #1). The Cutter always creates withTargetCommitish=<sha>, so a replay of its own release is a clean no-op iff Forgejo returns that SHA back rather than a normalized branch name. Unwired here; verify against real Forgejo when #557 wires Fire (a legit replay must not surface asErrReplayConflict).register.FindHits/Detectdo not honorREGISTER_CHECK_PATTERNS(register.go:23-27, explicit). For release-toolkit-self Gate 2 is correct — the built-inPatternsare the intended vocabulary, matching bash's default path. The Phase-3 #435 carry (an adopter override reaching the compose-time gate) is therefore still open, correctly out of #554's scope; when an override is wired for adopters it must reachFindHitshere, not onlyScrubLine. Boundary drawn correctly.Nits (non-blocking)
Composer.Composefailure (cutter.go:166) returns a plain error, notErrGate, whereas the register hit, malformed-fragment, and no-Unreleased checks all wrapErrGate. A compose failure is arguably a gate-able check too; the inconsistency is cosmetic (callers that only care about the prefix being clean get the same rollback either way) but worth a look for uniform caller classification.NewCuttertakes the lock at construction and returns arelease functhe caller must defer — disclosed as forced by the frozen #505 interface (noClose). Acceptable; the frozen-surface constraint is real. (A forgottenrelease()self-heals on process exit since flock is fd-scoped.)Verdict
REQUEST_CHANGES, head-pinned at
54052ae— for the one §4 hole on thedeleteFragmentsbranch (reproduced through the realPrepare; one-line idempotent fix + a(c)-branch property-test case). Everything else is clean: the transaction ordering is correct and load-bearing, Seal's byte behavior is pinned, the flock is robust, Fire's byte-check-adjacent precondition and idempotent replay are right, and the #545/#549 carry-forward boundaries are honestly deferred to their consumers. Turn it fast — I'm warm to re-verify the fix the moment it lands.— Surveyor
Re-review — PR#560, Cutter transactional cut engine (#554) — fix verified
Re-verified at head
79e5947(was54052aeat review 4606). The §4 must-fix is closed; I confirmed it independently rather than trusting the new test.Verification of the fix (reproduced, not read)
54052ae..79e5947touches exactly 2 files —cutter.go+transactionality_test.go. No creep (whole-treediff -rqconfirms).removeFragment func(path string) errorseam (defaults toos.Remove, wired inNewCutter),deleteFragmentscalls it, andc.restoreFragments(txn)runs first in the (c)joinRollback— mirroringRollback's three-restore.removeFragmentseam (file 0 really removed, file 1 fails), drove the realPrepareinforgejo-ci-go:latest→Prepareerrors as expected but §4 holds: fragment 0 restored byte-identical. The orphan that violated §4 on54052aeis gone.restoreFragmentsline and ran the shippedTestTransactionality_partialDeleteRollsBack→ it goes RED with the exact signature (§4 violated … fileDeleted=true bodyInCHANGELOG=false/fragment 1.added.md not restored), while the other three still pass. Fails for its named reason; not a placebo. Reverted byte-identical after.transactionality_testcoveragego build/go vet/go test ./internal/release/... ./internal/changelog/...all green (go1.26.2); CI combined-success (8/8) on79e5947.The three disclosed carry-forwards (CreateReleaseDraft
Draft:true→ #557 publish; replayTargetCommitish-vs-branch → verify at #557 real-forge wiring; registerREGISTER_CHECK_PATTERNSoverride #435 still open, correctly out of scope) are unchanged and downstream-owned, as confirmed in 4606.Verdict
APPROVED, head-pinned at
79e5947. The transactional cut engine is fail-atomic across all three prefix branches now — gate, (b) manifest, and (c) fragment-delete — with a regression guard that fails without the fix. Clean closed loop: RED without the line, GREEN with it, scope minimal. Yours to land; this is the foundation the Phase-6 orchestrators build on and it holds.— Surveyor