Summary
Every hand-authored entry in RELEASE_NOTES.md is written under a ## Unreleased heading, and nothing ever renames that heading to the version being cut. The release workflows look for a literal ## <tag> heading, find nothing, and publish the generated commit log alone. So the notes we deliberately write for the cases that warrant one (an action required at upgrade time, a behavior a user will notice and did not ask for) reach no reader.
make changelog already promotes [Unreleased] to a version heading in CHANGELOG.md when cutting a release. The sibling RELEASE_NOTES.md was left out of that step.
Evidence
operator/RELEASE_NOTES.md: ## Unreleased at line 6, next heading ## operator/v0.16.1 - 2026-05-22 at line 316. Roughly 310 lines of notes have accumulated under Unreleased covering two shipped releases, operator/v0.17.0 and operator/v0.18.0. Neither release page shows any of it. operator/v0.18.0 opens directly on ## [operator/v0.18.0] - 2026-08-17, the git-cliff output.
Content stranded there includes the webhook-configuration-by-label change, WEBHOOK_SERVICE_NAME / WEBHOOK_SECRET_NAME now being chart-supplied, the webhook certificate reminting on Service rename, and the install-namespace move to nodewright. That is precisely the upgrade-affecting material the file exists to carry.
Same story in the other components, and worse:
chart/RELEASE_NOTES.md: only ## Unreleased. Four chart releases, zero promoted entries.
operator/cmd/cli/RELEASE_NOTES.md: only ## Unreleased. Six CLI releases, zero promoted entries.
agent/RELEASE_NOTES.md: no ## headings at all.
The last successful promotion was operator/v0.16.1 in May, and it was done by hand.
Why it fails silently
.github/workflows/release.yml:93-105 and .github/workflows/cli-release.yaml:106-118 extract the human entry with an awk matcher on a heading that is exactly ## <tag> or ## <tag> <suffix>:
{ h = "## " tag
if (index($0, h) == 1) { ... f = 1; next } }
/^## / { if (f) f = 0 }
f
## Unreleased does not match, HUMAN comes back empty, and the next block treats empty as "no entry for this version, that's fine":
[ -n "${HUMAN}" ] && printf '%s\n\n' "${HUMAN}"
That conditional is correct behavior for a release with genuinely no hand-authored notes, which is most of them. It just makes the miss indistinguishable from the normal case, so nobody notices until someone reads the release page months later.
Where the fix belongs
scripts/gen-changelog.sh already has everything needed at the moment of the cut. Its interactive menu takes Major / Minor / Patch / Regenerate-only and sets NEXT_VERSION; the case "$COMPONENT" block below already maps a component to its paths, including the CLI's irregular one (operator/cmd/cli, not cli/). When NEXT_VERSION is non-empty, it should also rewrite the sibling RELEASE_NOTES.md heading:
## Unreleased -> ## <component>/v<X.Y.Z> - <YYYY-MM-DD>
and open a fresh empty ## Unreleased above it, matching the shape of the existing ## operator/v0.16.1 - 2026-05-22 entries.
Two details the implementation has to respect:
- The CLI path is not
${COMPONENT}/RELEASE_NOTES.md. It is operator/cmd/cli/RELEASE_NOTES.md. cli-release.yaml hardcodes the right path; reuse gen-changelog.sh's existing component-to-path mapping rather than adding a third spelling.
- An empty
## Unreleased must promote to nothing, not to an empty section. Most releases have no hand-authored notes and should not gain a stub heading.
scripts/release-tag.sh is the other candidate hook point, since it is what actually creates the tag. Whichever it is, the two scripts should not both try to do it.
Also worth deciding
- Backfill. The ~310 stranded operator lines span v0.17.0 and v0.18.0 and would need splitting by hand to be attributed correctly. Alternative: promote the whole block under
operator/v0.18.0 and accept the imprecision, or leave it and let the next release carry it. Cheap either way, but it should be a decision rather than a default.
- A loud check. A release-time warning when
RELEASE_NOTES.md still has content under ## Unreleased at the moment a tag is cut would have caught this in May instead of August. Cheaper than the promotion automation and catches the case where someone tags without running make changelog at all.
Acceptance criteria
Summary
Every hand-authored entry in
RELEASE_NOTES.mdis written under a## Unreleasedheading, and nothing ever renames that heading to the version being cut. The release workflows look for a literal## <tag>heading, find nothing, and publish the generated commit log alone. So the notes we deliberately write for the cases that warrant one (an action required at upgrade time, a behavior a user will notice and did not ask for) reach no reader.make changelogalready promotes[Unreleased]to a version heading inCHANGELOG.mdwhen cutting a release. The siblingRELEASE_NOTES.mdwas left out of that step.Evidence
operator/RELEASE_NOTES.md:## Unreleasedat line 6, next heading## operator/v0.16.1 - 2026-05-22at line 316. Roughly 310 lines of notes have accumulated underUnreleasedcovering two shipped releases,operator/v0.17.0andoperator/v0.18.0. Neither release page shows any of it. operator/v0.18.0 opens directly on## [operator/v0.18.0] - 2026-08-17, the git-cliff output.Content stranded there includes the webhook-configuration-by-label change,
WEBHOOK_SERVICE_NAME/WEBHOOK_SECRET_NAMEnow being chart-supplied, the webhook certificate reminting on Service rename, and the install-namespace move tonodewright. That is precisely the upgrade-affecting material the file exists to carry.Same story in the other components, and worse:
chart/RELEASE_NOTES.md: only## Unreleased. Four chart releases, zero promoted entries.operator/cmd/cli/RELEASE_NOTES.md: only## Unreleased. Six CLI releases, zero promoted entries.agent/RELEASE_NOTES.md: no##headings at all.The last successful promotion was
operator/v0.16.1in May, and it was done by hand.Why it fails silently
.github/workflows/release.yml:93-105and.github/workflows/cli-release.yaml:106-118extract the human entry with an awk matcher on a heading that is exactly## <tag>or## <tag> <suffix>:{ h = "## " tag if (index($0, h) == 1) { ... f = 1; next } } /^## / { if (f) f = 0 } f## Unreleaseddoes not match,HUMANcomes back empty, and the next block treats empty as "no entry for this version, that's fine":That conditional is correct behavior for a release with genuinely no hand-authored notes, which is most of them. It just makes the miss indistinguishable from the normal case, so nobody notices until someone reads the release page months later.
Where the fix belongs
scripts/gen-changelog.shalready has everything needed at the moment of the cut. Its interactive menu takes Major / Minor / Patch / Regenerate-only and setsNEXT_VERSION; thecase "$COMPONENT"block below already maps a component to its paths, including the CLI's irregular one (operator/cmd/cli, notcli/). WhenNEXT_VERSIONis non-empty, it should also rewrite the siblingRELEASE_NOTES.mdheading:and open a fresh empty
## Unreleasedabove it, matching the shape of the existing## operator/v0.16.1 - 2026-05-22entries.Two details the implementation has to respect:
${COMPONENT}/RELEASE_NOTES.md. It isoperator/cmd/cli/RELEASE_NOTES.md.cli-release.yamlhardcodes the right path; reusegen-changelog.sh's existing component-to-path mapping rather than adding a third spelling.## Unreleasedmust promote to nothing, not to an empty section. Most releases have no hand-authored notes and should not gain a stub heading.scripts/release-tag.shis the other candidate hook point, since it is what actually creates the tag. Whichever it is, the two scripts should not both try to do it.Also worth deciding
operator/v0.18.0and accept the imprecision, or leave it and let the next release carry it. Cheap either way, but it should be a decision rather than a default.RELEASE_NOTES.mdstill has content under## Unreleasedat the moment a tag is cut would have caught this in May instead of August. Cheaper than the promotion automation and catches the case where someone tags without runningmake changelogat all.Acceptance criteria
make changelogrenames## Unreleasedto## <component>/v<X.Y.Z> - <date>in the component'sRELEASE_NOTES.md## Unreleasedis left at the top for the next cycle## Unreleasedsection with no content promotes to nothingoperator/cmd/cli/RELEASE_NOTES.mdpath is handled, using the existing component-to-path mappingrelease.ymlandcli-release.yamlawk for, verified against a real tag rather than by inspection## Unreleasedwarns rather than passing quietly