fix(release-prep): idempotent dedup at PR body assembly (#272) #276
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!276
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/272-dup-section-headers"
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?
Why
release-prep.shbuilds the rolling-PR body by reading the new version's section from CHANGELOG.md viachangelog_get_section_content. Under normal flow that section is already merge-by-kind clean — the transition step composes fragments + Unreleased throughchangelog_merge_sections. But stray upstream state — manual CHANGELOG edits between cuts, prior-cycle leftovers, cross-boundary drift betweenv_Ntag andv_N+1prep — can leave duplicate### Kindheadings in a version section, which the raw section extract preserves verbatim in the PR body.The Cold-Read reviewer flagged this on tmux-tell PR #687 as "the biggest structural defect" — a reader skimming top-to-bottom exits the first
### Changedblock and doesn't know a second exists further down.What
Pipe the extracted section content through
changelog_merge_sectionsas a final normalization pass at PR body build time:Belt-and-suspenders on top of the merge already done in
changelog_transition. No-op when the section is already clean — the awk merge is bucket-by-kind and passes single-kind sections through verbatim.Coverage
Two new bats tests in
tests/changelog.bats:changelog_merge_sections: dedupes within a single file— locks in the single-file dedup contract that release-prep.sh now depends on.changelog_get_section_content + changelog_merge_sections: assembly-step dedup contract— end-to-end test that constructs a CHANGELOG with dup### Changedin a version section, runs the exact pipeline release-prep.sh uses, and asserts:### Changedheading in the output## [0.27.0]sectionFull 516-test bats suite green; shellcheck clean on touched files.
What this PR does NOT do
mainat merge time was clean; the transient buggy state observed at 2026-07-01 20:06 CEST couldn't be deterministically reproduced from the current test fixtures. The fix is deliberately defensive-at-assembly rather than root-cause-at-transition — the assembly step is the last mile before the PR body reaches operator eyes, and it's the right defensive layer regardless of upstream shape.changelog_merge_sectionssemantics. The function is used unchanged; only a new consumer (PR body assembly) is added.changelog_merge_sections. Preamble prose before the first### Kindheading is still stripped (matches prior behavior). Release-prep-produced sections never carry such prose (categorize_fragments + cc_categorize + changelog_merge_sections all emit### Kind-only structure), so this is not a regression for the intended input shape. Herald's narrative-prelude commits edit already-transitioned versioned sections onmainafter the release-prep PR has merged — they don't intersect this assembly path.release-prep.sh's existing trap chain semantics beyond addingPR_BODY_RAW_TMPto the cleanup list (mirroring the pattern the existing temp files use).Refs
🤖 Generated with Claude Code
APPROVED — reviewed at head
b1cb5a6Clean defensive-at-assembly fix. Verified at source on live state:
No-op-on-normal-path claim holds (verified, not just asserted).
changelog_merge_sectionsis not a general no-op on clean input — it canonicalizes kind order (Added/Changed/Deprecated/Removed/Fixed/Security/Internal) and strips preamble prose. Butchangelog_transitionalready builds the section body through the samechangelog_merge_sections(scripts/lib/changelog.sh:46), so the section written into CHANGELOG.md is already canonical-ordered. Re-running the identical function at PR-body time is therefore a true fixed-point no-op on the normal path. Confirmed empirically:merge_sections(canonical_section)→ byte-identical (fixed point) ✓merge_sections(merge_sections(dup))→ byte-identical (idempotent) ✓### Changed, both blocks preserved ✓The PR-body phrasing "no-op when the section is already clean" is slightly imprecise (the real property is "already-canonical + idempotent"), but the behavioral claim is correct.
Trap chain is safe. The new
trapat the assembly step replaces the priorEXITtrap and drops$SIDECAR_TMPfrom the list — but that's harmless:SIDECAR_TMPis deterministically disposed by line 369 (eithermv'd to the sidecar orrm'd) well before this trap installs, so it no longer exists at exit. The 4 long-lived temps are carried forward +PR_BODY_RAW_TMPadded correctly.Tests: full
changelog.batsgreen (42 tests); new #26/#27 pass. Fragment is brevity-compliant.Belt-and-suspenders on top of
changelog_transition's merge is the right defensive layer, and the honest "couldn't deterministically reproduce the exact upstream state" framing is the correct call. Merge-ready.