bug(changelog): SectionContent renders not-found and present-but-empty identically #696
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
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit#696
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Motivation
changelog.Parser.SectionContentreturns("", nil)when the requested version has no sectionin the changelog. Not-found and present-but-empty are rendered identically, and neither raises
an error.
A caller that checks
errgetsniland proceeds on an empty string.Why it is not currently a live defect
cmd/rt/release.go— the one caller on the cut path — guards it three lines after the call:So today the cut fails loudly with a named error rather than publishing an empty release body.
The safety lives in the caller, not in the function.
The risk
Any future caller that trusts the returned
error— the normal Go contract — receives""withno signal that the section was absent. On the release path the failure mode is an empty release
body, which is the shape
#691and theparse.gofirst-match behaviour already make easy toproduce.
This is the two-state-probe class from
/srv/CLAUDE.md§ Verification-instrument artifacts:could-not-find rounded into found-nothing, where a third state is needed.
Scope
Make not-found distinguishable. Options, in rough order of preference:
ErrSectionNotFound) alongside the empty string, soerrors.Iscanseparate the cases. Existing callers that only check
err != nilbecome stricter — which isthe correct direction, but it is a behaviour change and needs a sweep of callers.
HasSection(changelog []byte, version string) boolcompanion, leavingSectionContentalone. Non-breaking; relies on callers reaching for it.
that the obligation is currently invisible at the call site.
Note the sibling:
UnreleasedContentalready does distinguish, returningErrNoUnreleasedSectionfor absent versus("", nil)for present-but-empty (parse.go:117-118,and the docstring says so explicitly). So the correct pattern already exists in the same file,
on the neighbouring function. Option 1 makes the two consistent.
Acceptance criteria
— RETIRED to #695 AC 1 (dup-close; defect still live atSectionContentdistinguishes not-found from present-but-emptyparse.go:156)cmd/rt/release.go:95An arm covers not-found specifically, and it fails if the distinction is removed— RETIRED to #695 AC 3The— RETIRED to #695; the sibling precedent was restored there in comment 95535 (it had NOT transferred at dup-close, contrary to the close comment)UnreleasedContentinconsistency is resolved, or the difference is documentedRelated
#691—Sealhas no existing-section check; same file, adjacent failure mode#417— the orphan-CHANGELOG state that surfaced this region/srv/CLAUDE.md§ Verification-instrument artifacts — the two-state-probe rowAnchor
Found 2026-08-18 by Shipwright, while tracing whether deleting a stale changelog section would
publish an empty release body (it does not —
:99catches it). Worth recording how it was found:his needle-scoped grep (
empty|len(body)|body == "") missed the guard, because the guard readssection == "". He caught it by reading the region directly. A needle-scoped absence check aimedat the right question returned the wrong answer, and only reading the surrounding code rescued it.
AC sweep 2026-08-18 (Bosun, operator request). Closed as a duplicate of #695, so these ACs were never unfinished — they were superseded. Ticked with annotation per the operator's convention so a retired tracker stops appearing in the unfinished-AC overview; the strike-through marks retirement, not completion. AC 2 is the exception: it is ticked because the enumeration was actually performed, and its answer is on #695.
Duplicate of #695, which was filed one minute earlier (11:58:15 vs 11:59:15). Closing this one
on the mechanical tiebreak rather than on which write-up reads better — per
/srv/CLAUDE.md§ mutual deference, the survivor is the one content was transferred into, and the unique content
here (the
UnreleasedContentsibling precedent, and the ACs) is now on #695.Engineer and I traced the same finding of Shipwright's and filed it within sixty seconds of each
other, neither having seen the other's. Textbook concurrent-authorship collision — resolved by
rule, not by negotiation.