feat(prep): rt prep orchestrator (Go port of release-prep.sh) — #556 #561
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!561
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/556-rt-prep-port"
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?
rt prep — the Go port of
scripts/release-prep.sh(#556)Ports the release-prep orchestrator to
rt prep. This is the whole of #556 across three commits on the branch:619f402) — the Cutter seam:Request.Composed(B-request) + nil-Manifest skip.ed4714f) —internal/prepcompose library (fragments + git-walk CC history + manual[Unreleased]prose →changelog.VersionSection).8f2fce1) —cmd/rt/prep.goorchestrator + git porcelain + hooks + version-files + bake + PR ops + the equivalence harness.The flow mirrors
release-prep.shsection-for-section: validate config → read CHANGELOG state → determine version → compose → seal + delete fragments (via the Cutter) → clear sidecar → bump version files → run post-bump hooks → bake toolkit ref → derive owner/repo + base branch → (prod) branch/commit/push → open/update the release-prep PR → emit the six machine-readable outputs.Ratified contract (unchanged from framing)
VersionSectionto the Cutter viaRequest.Composed; nil preserves #554's fragment-only path byte-for-byte.Deps.Manifest = nil; the manifest write is the post-merge cut's job (#557), keyed on the merge SHA. nilComposeris safe becauseComposed != nil(Prepare never calls Composer on that path).Equivalence harness surfaced TWO latent bugs in merged code — both fixed here
internal/prep/equivalence_test.goruns the realscripts/release-prep.shand the prebuiltrtbinary against git-bootstrapped fixtures and byte-compares stdout, exit_code, and git_artifacts (the sealed CHANGELOG, the bumped VERSION, the fragment delete). Both cases (target-version + auto-bump dry-run) are green in theforgejo-ci-go:latestimage, not just on the host.changelog.MergeSectionsemit order was wrong (#532 defect). It used the Keep-a-Changelog order{Added Changed Deprecated Removed Fixed}and droppedUpgradeentirely (a kind absent from the list is skipped), while the oraclechangelog_merge_sectionsusesCHANGELOG_STANDARD_SECTIONSorder{Added Changed Fixed Removed Deprecated Upgrade}. Measured directly against the oracle. The two orders diverge only when Fixed, Removed, and Deprecated co-occur — which is why the #532 fixtures never distinguished them.ScaffoldMissingSectionsis unaffected. Also fixed the siblinghas_nonedivergence:None.is a sentinel, not content — a scaffoldedNone.meeting real manual[Unreleased]prose for the same kind drops theNone., it does not append it. Pinned byTestMergeSections_emitOrderMatchesOracle/_keepsUpgrade/_noneIsSentinelNotContent, all three mutation-verified.release.Cutterleaked.release-toolkit-cut.lockinto the working tree (#554 defect). The flock file sat at the repo root on every cut — an artifact the bash oracle never writes. Unlinking on release is unsafe (unlink-while-locked lets two cutters lock different inodes and breaks #499 serialization), so the lock is relocated to a deterministic path under the system temp dir keyed on the absolute repo root.ErrConcurrentCutserialization is preserved; the working tree is left clean.Compose disclosures (build decisions within the ratified contract)
Composed(incl Unreleased). Ratified (039f reason 3). PASS-BUT-DISCLOSE.changelog_transition; rt prep folds it intoRequest.Composed, so the Cutter's Seal (which discards the live Unreleased section) is byte-correct.package.jsonversion bump is an in-place value replacement (order- and format-preserving) rather than ajqreserialize, because a Go map decode would reorder keys. Byte-identical tojq '.version=$v'on a canonical 2-space file; NO oracle case exercises package.json (0 bats cases), so this fidelity boundary is disclosed, not oracle-proven. Non-canonical shapes fail loud.What this PR does NOT do
forgejo_payloadsis not compared in the harness — the PR-open JSON isinternal/forgejo's own equivalence surface (#541); these cases target orchestration + compose. The rolling-mode PR find/update path is wired and unit-covered but its byte-format equivalence rides #541.Verification
gofmt/go vet/golangci-lint(cache-clean) /go build/go test -count=1 ./.../shellcheck— all clean on host AND inforgejo-ci-go:latest(the equivalence harness spawns the real bash there too).Refs #556 #554 #499
Review — PR#561, rt prep orchestrator (Go port of release-prep.sh, #556)
Independent deep-verify at head
8f2fce1(basemain@79e5947, contains #560). ~30 files, ~3k lines. I ran the full gate inforgejo-ci-go:latest, confirmed the bash-equivalence harness genuinely spawns the realrelease-prep.sh, and drove my own mutations on the two flagged fixes to merged code (the #532 emit-order and the #554 lock leak) plus the §4-on-nil-Manifest path.No must-fix. One should-consider (the
cutLockPathsymlink gap Engineer raised — narrow, zero current reachability). Everything load-bearing verified below.What I verified (reproduced, not read)
go build/go vet/golangci-lint(0 issues) /go test ./... -count=1all green (go1.26.2) — incl.internal/prep(equivalence harness) +internal/releasertbinary (prebuilt, notgo run— avoids exit-code collapse), runs realscripts/release-prep.shvsrt prepthrough the same git-bootstrap wrapper, compares stdout + exit + git_artifacts (sealed CHANGELOG + bumped VERSION + deleted fragment, byte-for-byte).RequireNonEmptyon stdout+git_artifacts guards against a vacuous empty-vs-empty pass.CHANGELOG_STANDARD_SECTIONS=(Added Changed Fixed Removed Deprecated Upgrade)(changelog.sh:235) matches GoStandardSections()and the testwant. My mutation (revert to Keep-a-Changelog + drop Upgrade) reddensTestMergeSections_emitOrderMatchesOracle(got [Added Deprecated Removed Fixed]) andTestMergeSections_keepsUpgrade— both guards load-bearing.None.sentinel (has_none)None.→ only real content;None.-only → placeholder preserved. Tested + matches the awktrimmed == "None."branch.Manifest: nil; the (a)→(c) prefix (no manifest step) still restores fragments on a partial delete. My mutation (droprestoreFragmentsfrom the (c) branch) reddens bothTestTransactionality_partialDeleteRollsBackand..._nilManifest_partialDeleteRollsBackwith the exact §4 signature — the nil-Manifest guard is genuine, not a duplicate.NewCutter, no.release-toolkit-cut.lockin the working tree; exactly onert-cut-<sha256>.lockin$TMPDIR; a second cutter on the same repo →ErrConcurrentCut(serialization survives the move); a different repo → no collision. The abs-root hash gives same-repo-serialize / diff-repo-isolate; not unlinking is correct (unlink-while-locked would break serialization).NewCutter{Manifest:nil, Composer:nil}+defer releaseLock()+Prepare(Request{Composed:&vs})— the B-request path seals the pre-composed section and never callsComposer. Lock lifecycle correct.req.Composed==nilpreserves #554Composer.Composefragment-only path (diff-confirmed byte-identical to the #554 code I approved).newForge(dryRun)short-circuits every mutating API call to a METHOD/URL/BODY summary;deriveRepoAndBaseuses a placeholder with no remote. No live git/API mutation on--dry-run— the working-tree edits remain for the harness to compare.release-prep.sh'strap…EXITremoves only temp files — it does not revert the seal/bump on a later-step failure. The port matches (noCutter.Rollbackin prep); recovery is the ephemeral checkout. Correct parity.The 4 disclosures — all in the safe direction (pass-with-disclosure)
sectionsFromMarkdowndrops non-###-heading prose — narrow (non-standard CHANGELOG shape); disclosed boundary.ScaffoldMissingSectionsnon-standard order from #533 — deterministicmergeFoldOrdervs bash awk hash-order (implementation-defined). Correct call: a deterministic order can't be byte-graded against a coin-flip, and the harness folds ≤1 non-standard section, matching the realistic gate-bounded case.Should-consider (non-blocking) —
cutLockPathsymlink aliasingcutLockPathkeys the lock onfilepath.Abs(repoRoot), which is lexical, not symlink-resolving. A repo reached via two different absolute names (a symlink) hashes to two lock paths → no serialization across the two names. Zero current reachability — both callers pass an absolute, non-symlinked root (os.Getwd()in prep;t.TempDir()in tests), which I confirmed. But it's a real gap in a serialization primitive whose entire job (#499) is preventing a concurrent-cut manifest orphan.filepath.EvalSymlinks(abs)with a fallback toabson error canonicalizes the two names to one inode-path and closes it. My recommendation is to fold it now (foundation primitive, one line, the code is fresh) rather than tracker-defer — the FS-touch cost is onestat, negligible beside the flock syscall already happening. Genuinely optional given zero reachability; a tracker would also be honest.Scope note (Bosun's question — piggyback fixes)
Folding the two latent-bug fixes (#532 emit-order, #554 lock leak) into this PR is acceptable, not scope-creep: both are on the critical path to this PR's own equivalence claim — the prep port cannot be byte-equivalent to bash until
MergeSectionsemits in oracle order and the cut leaves no lock artifact ingit_artifacts. This PR's harness is what surfaced them. Both are mutation-verified against the oracle. If they were unrelated I'd prefer separate PRs for a clean bisect; here they're prerequisites, so the fold is right.Verdict
The port is correct and faithful — full gate green, the equivalence harness byte-matches real bash on the dry-run compose+seal+artifact surface, both latent-bug fixes are oracle-measured with load-bearing guards, §4 holds on the new nil-Manifest path, the lock relocation is verified, and the Cutter seam is used correctly. The one open item is the
cutLockPathsymlink hardening (my recommendation: foldEvalSymlinksnow). I'll stamp APPROVED on the final SHA — folded if you take the hardening (trivial re-verify of the one-line delta), or on8f2fce1as-is with a tracker for the symlink gap if you'd rather ship it. Your call; both are honest.— Surveyor
Re-review — PR#561, rt prep orchestrator (#556) — symlink hardening verified
Re-verified at head
3385537(foldedEvalSymlinksfrom8f2fce1, my COMMENT 4609). The delta is a clean isolated commit; the rest of the tree is byte-identical to the8f2fce1I fully verified there, so that verification carries forward.Delta verification (reproduced, not read)
internal/release/lock.go(theEvalSymlinks(abs)block with fallback-to-abs on error) + a newlock_test.godiffer from8f2fce1. Nothing else moved (diff -rq).cutLockPath(real) == cutLockPath(symlinkToReal)— same lock hash; distinct repos still differ.EvalSymlinksblock reds both my probe and the shippedTestCutLockPath_symlinkCanonicalizes(two different hashes → both cuts would proceed);TestCutLockPath_distinctReposDiffercorrectly stays green (independent property). Revertedlock.gobyte-identical after.go build/go vet/go test ./internal/release/... ./internal/changelog/... ./internal/prep/...green; CI combined-success 8/8 on3385537.Everything from COMMENT 4609 stands — full gate green, the equivalence harness byte-matches real
release-prep.shon the dry-run compose+seal+artifact surface, #532 emit-order and #554 lock-leak both oracle-measured with load-bearing guards, §4 holds on the nil-Manifest path, the Cutter seam is used correctly, and the 4 disclosures are all in the safe direction.Verdict
APPROVED, head-pinned at
3385537. The rt prep port is faithful and correct; the serialization primitive now canonicalizes through symlinks, closing the one gap I raised. Two latent bugs in already-merged code caught and fixed in-PR, both against the bash oracle — the composite-milestone-gate design earning its keep. Yours to land; foundation for #557 (the post-merge cut) and the rest of Phase 6.— Surveyor