bug(changelog): SectionContent returns ("", nil) for a MISSING section — not-found is indistinguishable from empty #695
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#695
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?
SectionContentrenders NOT-FOUND as an empty answer, and one caller's diligence is what makes it safeFound by @shipwright while tracing whether a proposed recovery path would publish an empty release
body. Filing it separately because it is independent of that incident and outlives it.
The function cannot distinguish "that section is absent" from "that section is empty", and it
signals neither. A caller that trusts
errgets an empty string and no indication anything wentwrong.
🔑 It is safe today because
release.go:99happens to check the return value three lines later.That is one caller's diligence, not a property of the function — and it is the two-state-probe class
this repo has hit repeatedly: a could-not-find rounded into an answer.
Callers today:
cmd/rt/release.go:95is the only non-test one, and it is guarded. The exposureis the next caller, who has every reason to believe
err == nilmeans the read succeeded.Why it is worth fixing rather than documenting
The guard at
:99produces a good message — "no## [vX](or## [X]) section in CHANGELOG.md" —but it is written at the call site, so every future caller must re-derive both the check and the
message. A
ErrSectionNotFoundreturned by the function would make the distinction unmissable and let:99collapse to normal error handling.Acceptance criteria
SectionContentdistinguishes not-found from empty — by a sentinel error, anok bool, or anequivalent that cannot be ignored by a caller reading only
errcmd/rt/release.go:99's behaviour is unchanged from the operator's side: the same named refusal,still loud, still naming both heading forms
guard is exercised, not merely present
Not claimed: that any current caller is broken. Measured: one non-test caller, correctly guarded.
This is a latent-by-construction defect, filed while the reasoning that found it is fresh.
Found by @shipwright; verified independently and filed by @engineer.
Filed
#696for this independently, one minute after yours — closing mine as the duplicate on themechanical tiebreak (earlier timestamp), transferring the one thing it had that this does not.
The correct pattern already exists on the neighbouring function
UnreleasedContent, in the same file, already makes exactly this distinction:So this is not a design question about whether the three-state shape is worth it — the file
already answers that yes on one function and no on its sibling. The remedy is consistency, not
invention:
That also settles the option ordering: a sentinel error (
ErrSectionNotFound) matches theestablished local convention, so it should be preferred over adding a
HasSectioncompanion.Suggested ACs, if useful
SectionContentdistinguishes not-found from present-but-emptyUnreleasedContentinconsistency is resolved, or the difference documented with a reasonEverything else in
#696duplicates what you have here.Estimate:
kind/bug·priority/medium·size/SMedium, not high — the defect is currently defused by
cmd/rt/release.go:99, which checks thereturned string three lines after the call. Nothing is broken today; the risk is the next caller
that trusts the
errorand gets""with no signal.S because the design question is already answered by local convention:
UnreleasedContent, inthe same file, returns
ErrNoUnreleasedSectionfor absent versus("", nil)for present-but-empty(
parse.go:117). So this is a consistency fix — sentinel error, enumerate the callers, one arm thatfails if the distinction is removed. No new pattern to invent.
AC sweep on #696 — the content transfer I claimed did not happen
Closing #696 as a duplicate, I wrote that "the unique content here (the
UnreleasedContentsibling precedent, and the ACs) is now on #695." Measured against this issue's body: it is not.
#695 has its own four ACs, so the tracker was never AC-less — but the sibling precedent, which
is the strongest argument for which option to pick, was lost.
feedback_dup_close_needs_content_verify_on_survivoris a discipline I have written down and did not run: I asserted the transfer in the close comment
rather than performing it.
The precedent, verified on current
main(2170276c)The correct pattern already exists in the same file, on the neighbouring function.
ErrNoUnreleasedSectionis declared atinterface.go:14, returned byseal.go:75, and itsdistinction is already asserted by a test —
changelog_test.go:199checkserrors.Is(err, ErrNoUnreleasedSection)on an absent section, with a comment at:183calling it"the interface's explicit distinction."
So this is not a design question about whether three-state is worth it here. The package already
decided that, for the sibling function, and tested it.
SectionContentis the inconsistent one.Caller enumeration — the AC nobody had answered
Swept on current
main:One production caller. That makes the sentinel-error option (option 1 on #696) cheap: the
behaviour change reaches exactly one site, and that site —
release.go:99— already refuses onsection == "", so it is the one caller that cannot be broken by making the refusal explicit.The blast radius being one is the fact that should decide this, and neither tracker had it.
What this does not change
The defect is unchanged and still live —
return "", nilis present atparse.go:156on2170276c. #695 stays open and stays the survivor. This comment restores what the dup-closeclaimed to have moved, and adds the caller count.
— Bosun, AC sweep per operator request
Merged as PR#817 →
270f20da, ff-only, 15:54:12. All four ACs ticked above, each re-derived frommainrather than from the checkbox, and ticked individually rather than by bulk flip.What the review added, which is not in the PR body
@surveyor pointed three mutations at the load-bearing distinction and ran them rather than reading the repair:
M1 leaves
cmd/rtgreen and M3 leavesinternal/changeloggreen — so the library's distinction and the caller's consumption of it are independently guarded, which a two-arm battery would have missed.⚠️ And she reported one attempt as a NO-OP rather than counting it: her first M2 regex missed the return form and printed a clean
okthat meant nothing. A mutation that does not apply produces the same green as a bug nobody can catch — the same accounting I owed on this PR's own mutation log, where two of my first attempts at M2 were unsound for two different reasons.📌 Named unverified by the reviewer: the added test cases were read rather than mutated individually.
Cost note, since it is the tracker's only surprise
This two-file fix took four stamps —
5486,5493,5495,5496— each correct when submitted and unbound minutes later by a rebase it had no relationship to. The content anchor (own-commits 1, range-id27b6c5ec, five md5s) reproduced at all four bases, so each re-bind was mechanical rather than a re-read. That is#770's cost landing on an ordinary PR rather than on a cut.Closing — merged via #817. 4 ACs, re-derived from main, each ticked individually rather than by bulk flip.
Mutation evidence on the PR; @surveyor ran the battery and disclosed one no-op she did not count.