ADR-0913: Changelog fragment renderer — splice contract is ^## \[, not ^##¶
- Status: Accepted
- Date: 2026-05-31
- Deciders: lusoris
- Tags:
release,docs,tooling
Context¶
scripts/release/concat-changelog-fragments.sh (introduced by ADR-0221) renders the ## [Unreleased] block of CHANGELOG.md from the per-fragment files under changelog.d/<section>/. The splice into CHANGELOG.md is implemented by two awk passes that use a sentinel regex to detect "end of the Unreleased block, start of the next release-please-written ## [vX.Y.Z] section."
The original sentinel was ^## [^[] — "any h2 that does not begin with an open square bracket." That regex assumed fragment bodies would contain no h2 headings. In practice, 84 of the 100+ in-tree fragments started with an in-body h2 (either the redundant section name ## Fixed or a per-PR descriptive title like ## Vulkan submit-pool migration). When the renderer spliced those fragments into CHANGELOG.md, the next --check saw the first in-body ## Foo and treated everything below it as "release history to preserve." The next --write re-rendered the full body but preserved the now-out-of-band content, inflating the file by roughly 3 kB per cycle. PR #332, PR #383, and PR #401 all noted "pre-existing CHANGELOG drift"; PR #384 / ADR-0892 identified the renderer + section hygiene angle but deferred the actual sweep.
Net state on master tip 544299fae1: CHANGELOG.md was 59,757 lines, ~95 % of which was duplicated content from prior --write cycles. The --check gate exited zero only because it compared self-similar duplicates against the in-tree file.
Decision¶
The Unreleased-block splice sentinel is ^## \[, anchored on the open square bracket that release-please always writes (and that ## [Unreleased] itself uses). Fragment bodies may legitimately contain ## or ### headers; only the bracketed ## [version] form ends the Unreleased block.
Three companion fixes ride alongside:
- Renderer-side defense-in-depth:
emit_fragment()demotes any leading-line#/##in a fragment body to**bold**pseudo-headers at render time, so a careless author cannot re-introduce the splice-contract violation. - Source-side normalisation: all 102 in-tree fragments had stray
## Section/### Sectionfirst-line headers stripped (where they duplicated the section name the renderer emits itself) or demoted from##to###(so source tree matches the splice contract directly, without relying on render-time demotion). - Unknown-subdir guard:
changelog.d/<dir>/outside the known Keep-a-Changelog set (added/,changed/,deprecated/,removed/,fixed/,security/) now emits a stderr warning rather than being silently skipped (the PR #384 / ADR-0892 motivation). The 32 fragments that lived underchangelog.d/perf/+changelog.d/performance/were moved tochangelog.d/changed/perf-<topic>.mdper the PR #384 convention.
CHANGELOG.md regenerated to 15,030 lines — a drop of ~44,727 lines of duplicated content. The Unreleased block now matches the fragment tree exactly; the legacy archive (changelog.d/_pre_fragment_legacy.md, 3,118 lines, frozen verbatim) is preserved at the top of the block.
Alternatives considered¶
| Option | Pros | Cons | Why not chosen |
|---|---|---|---|
Anchor sentinel on ^## \[ (chosen) | Matches the only header shape release-please writes; immune to any fragment-body content shape; idempotent | Requires updating both awk passes + comment | Correct fix at the contract level |
Strip all ## from fragment bodies and keep the ^## [^[] sentinel | No renderer change | Doesn't actually fix the bug — any future fragment with a ## regresses it; relies on an enforcement gate the project lacks | Source-side cleanup alone is fragile |
Quote the Unreleased block with HTML comment fences (<!-- BEGIN UNRELEASED --> ... <!-- END UNRELEASED -->) | Sentinel becomes literal, immune to all markdown shapes | Conflicts with release-please's ## [version] injection convention; would require forking release-please's release-type: simple driver | Too invasive for a regex fix |
| Replace bash+awk renderer with a Python tool | Stronger parser, structured warnings | The shell tool is small, fast, already in CI; rewrite is unjustified for one regex bug | Yagni |
Consequences¶
- Positive:
--writeis idempotent across re-runs (verified). The drift class that bit PR #332, PR #383, PR #401, PR #384 is closed at the contract level. Future fragment authors can no longer trip the bug by adding a##heading. - Positive: stderr warnings on unknown subdirs surface the PR #384 failure mode (perf/, performance/) for any future author who names a wrong directory.
- Negative: the 84 fragment edits + 32 file renames touch many in-flight branches that authored fragments under the old shape. Each rebase will surface conflict markers on the touched fragment file; conflicts resolve by
--theirson the path content (rendered body is regenerated from disk). - Neutral / follow-ups: a
--lintmode that fails on stray h1/h2 in fragment bodies is the obvious next step. Deferred — the renderer's render-time demoter already prevents the bug from recurring; a separate pre-commit gate is incremental polish.
References¶
- ADR-0221 — original fragment-renderer system
- ADR-0892 — perf/ + performance/ unknown-subdir audit (in-flight as PR #384)
- PR #332, PR #383, PR #401 — all flagged "pre-existing CHANGELOG.md drift" without rooting it
scripts/release/concat-changelog-fragments.sh— the rendererdocs/research/0913-changelog-renderer-and-drift-2026-05-31.md— audit dossier- Source:
req(user briefing 2026-05-31: "Fix the CHANGELOG.md renderer bug + 23k-line drift")