changelog: seed ## Unreleased with all three section headings, and let the -dev bump write them #135

Closed
opened 2026-07-20 12:59:21 +00:00 by dan-claude-bot · 2 comments
dan-claude-bot commented 2026-07-20 12:59:21 +00:00 (Migrated from github.com)

## Unreleased should always carry ### Added, ### Changed and ### Fixed, even when empty — and the -dev bump should be what seeds them.

The problem

Every open PR writes its entry under ## Unreleased, and the section starts out with no subsection headings. So the first PR to need ### Fixed creates it, and any other PR that also needs ### Fixed creates it too — at the same position, in the same otherwise-empty region. Git sees two sides both inserting at one anchor and calls it a conflict.

That makes the conflict rate a function of how many PRs are open, not of whether they have anything to do with each other. Two changes that touch entirely different files, in different subsystems, still collide in CHANGELOG.md.

It compounds: each merge re-conflicts every PR still open, so a batch of N PRs costs on the order of rebases rather than N.

Evidence from a real batch

A recent batch in heavy-duty/rig merged seven entries — 1 Added, 3 Changed, 3 Fixed — across eight PRs. Nearly every rebase in it hit CHANGELOG.md, and not one of those conflicts was a genuine disagreement about content. Two shapes showed up:

  1. Disjoint sections. One side ### Changed, the other ### Fixed. They compose perfectly; the conflict exists only because both had to create their heading. This proposal eliminates these entirely.
  2. Same section. Two ### Fixed bullets landing at the same anchor. This proposal does not fix these — see Limits.

The worst single case was shape 1 with an ordering twist: a PR adding ### Added conflicted against ### Fixed, and resolving it correctly meant hoisting the new section to the top, because this file orders Added → Changed → Fixed. Concatenating would have produced out-of-order release notes that nothing would have caught. With the headings pre-seeded, that judgment call does not exist — the entry goes under a heading that is already in the right place.

Proposal

1. ## Unreleased always carries all three headings, even when empty:

## Unreleased

### Added

### Changed

### Fixed

A PR then only ever appends a bullet under an existing heading. It never creates a heading, never chooses where a section goes, and never reorders anything.

2. The -dev bump commit is what seeds them. The release flow already re-arms VERSION to X.Y.(Z+1)-dev automatically after a release; that same commit should write the fresh ## Unreleased with the three empty headings.

This is the part that makes the whole thing safe. Release notes are extracted verbatim between ## <ver> and the next ## (changelog_section in .github/scripts/release-lib.sh), so if empty headings lived in a stamped section they would ship into the published release body. Seeding them on the -dev side means the template only ever exists in ## Unreleased, and a stamped section only ever contains real entries.

It also moves the changelog re-arm from a manual ceremony step into the automation that already re-arms VERSION — which is a bonus, not a side effect: the re-arm exists because a forgotten manual one fails silently and misattributes entries to a shipped release.

The guard this breaks, and how

The release flow asserts that the section it is about to publish is non-empty. With seeded headings, a section containing only three empty headings is textually non-empty — so a genuinely empty release would sail past that check.

The guard must test for at least one real entry (a - bullet), not for bytes. Landing the seeding without this is a net loss: it trades a noisy, self-announcing conflict for a silent hole in a release gate.

Limits — stated honestly

  • Same-section collisions still conflict. Two PRs both appending under ### Fixed still insert at the same anchor. This converts "conflicts between nearly every pair" into "conflicts only within a section", and leaves only the trivial resolution shape (keep both bullets, no ordering decision).
  • It is not the complete fix. That would be per-PR changelog fragment files (a changelog.d/ directory, one file per PR, assembled at release time), which makes conflicts structurally impossible because no two PRs touch the same file. That is a bigger change and deserves its own issue if the cheap fix proves insufficient. This one is a few lines and no new concepts.

Acceptance

  • ## Unreleased carries all three headings on main
  • the -dev bump commit seeds them, so a stamped section never contains an empty heading
  • the release-notes guard requires a real entry, not just non-empty bytes
  • CONTRIBUTING.md says "append under the right heading" rather than "add an entry under ## Unreleased"
  • a published release body is verified to carry no empty headings

Filed in all three repos

box, rig and cast share this release flow and this failure. Whichever lands first should be the reference the other two follow, so the three do not drift.

`## Unreleased` should always carry `### Added`, `### Changed` and `### Fixed`, even when empty — and the `-dev` bump should be what seeds them. ## The problem Every open PR writes its entry under `## Unreleased`, and the section starts out with **no** subsection headings. So the first PR to need `### Fixed` *creates* it, and any other PR that also needs `### Fixed` creates it too — at the same position, in the same otherwise-empty region. Git sees two sides both inserting at one anchor and calls it a conflict. That makes the conflict rate a function of how many PRs are open, not of whether they have anything to do with each other. Two changes that touch entirely different files, in different subsystems, still collide in `CHANGELOG.md`. It compounds: each merge re-conflicts every PR still open, so a batch of *N* PRs costs on the order of *N²* rebases rather than *N*. ## Evidence from a real batch A recent batch in heavy-duty/rig merged seven entries — 1 `Added`, 3 `Changed`, 3 `Fixed` — across eight PRs. Nearly every rebase in it hit `CHANGELOG.md`, and **not one** of those conflicts was a genuine disagreement about content. Two shapes showed up: 1. **Disjoint sections.** One side `### Changed`, the other `### Fixed`. They compose perfectly; the conflict exists only because both had to *create* their heading. **This proposal eliminates these entirely.** 2. **Same section.** Two `### Fixed` bullets landing at the same anchor. **This proposal does not fix these** — see Limits. The worst single case was shape 1 with an ordering twist: a PR adding `### Added` conflicted against `### Fixed`, and resolving it correctly meant *hoisting* the new section to the top, because this file orders `Added → Changed → Fixed`. Concatenating would have produced out-of-order release notes that nothing would have caught. With the headings pre-seeded, that judgment call does not exist — the entry goes under a heading that is already in the right place. ## Proposal **1. `## Unreleased` always carries all three headings**, even when empty: ```markdown ## Unreleased ### Added ### Changed ### Fixed ``` A PR then only ever *appends a bullet under an existing heading*. It never creates a heading, never chooses where a section goes, and never reorders anything. **2. The `-dev` bump commit is what seeds them.** The release flow already re-arms `VERSION` to `X.Y.(Z+1)-dev` automatically after a release; that same commit should write the fresh `## Unreleased` with the three empty headings. This is the part that makes the whole thing safe. Release notes are extracted verbatim between `## <ver>` and the next `## ` (`changelog_section` in `.github/scripts/release-lib.sh`), so if empty headings lived in a *stamped* section they would ship into the published release body. Seeding them on the `-dev` side means the template only ever exists in `## Unreleased`, and a stamped section only ever contains real entries. It also moves the changelog re-arm from a manual ceremony step into the automation that already re-arms `VERSION` — which is a bonus, not a side effect: the re-arm exists *because* a forgotten manual one fails silently and misattributes entries to a shipped release. ## The guard this breaks, and how The release flow asserts that the section it is about to publish is **non-empty**. With seeded headings, a section containing only three empty headings is textually non-empty — so a genuinely empty release would sail past that check. **The guard must test for at least one real entry (a `- ` bullet), not for bytes.** Landing the seeding without this is a net loss: it trades a noisy, self-announcing conflict for a silent hole in a release gate. ## Limits — stated honestly - **Same-section collisions still conflict.** Two PRs both appending under `### Fixed` still insert at the same anchor. This converts "conflicts between nearly every pair" into "conflicts only within a section", and leaves only the trivial resolution shape (keep both bullets, no ordering decision). - **It is not the complete fix.** That would be per-PR changelog fragment files (a `changelog.d/` directory, one file per PR, assembled at release time), which makes conflicts structurally impossible because no two PRs touch the same file. That is a bigger change and deserves its own issue if the cheap fix proves insufficient. This one is a few lines and no new concepts. ## Acceptance - [ ] `## Unreleased` carries all three headings on `main` - [ ] the `-dev` bump commit seeds them, so a stamped section never contains an empty heading - [ ] the release-notes guard requires a real entry, not just non-empty bytes - [ ] `CONTRIBUTING.md` says "append under the right heading" rather than "add an entry under `## Unreleased`" - [ ] a published release body is verified to carry no empty headings ## Filed in all three repos box, rig and cast share this release flow and this failure. Whichever lands first should be the reference the other two follow, so the three do not drift.
dan-claude-bot commented 2026-07-20 12:59:37 +00:00 (Migrated from github.com)
Siblings: heavy-duty/rig#86, heavy-duty/cast#126.
claude-bot-andresmgsl added the
ready
label 2026-08-17 22:30:34 +00:00

Triage: closing — the deliverable this issue names can no longer be built here, and its substance has landed (and been superseded) upstream.

The spec targets the -dev bump in box's own .github/scripts/release-lib.sh. That file is gone: #164 delegated release governance to the shared ceremony — .github/workflows/release.yml is now a thin uses: heavy-duty/ceremony/.github/workflows/release.yml@0.1.0 caller — so the re-arm this issue wants to extend is ceremony code, not box code. No box PR can implement it.

Upstream, both halves already happened:

  • heavy-duty/ceremony#98 implemented exactly this proposal: the re-arm seeds ### Added / ### Changed / ### Fixed.
  • heavy-duty/ceremony#114 / #115 / #117 then superseded that design entirely with changelog.d fragments — each PR writes its own file, which eliminates both conflict shapes (disjoint sections and same-section bullets), not just shape 1.

At the 0.1.0 pin box consumes today neither exists yet (lib/changelog.sh@0.1.0 only extracts sections), so the conflict pain described here is real until box bumps its ceremony pin — but that bump is a different deliverable with its own contract (it swaps the whole changelog workflow over to fragments), minted when box takes it. Keeping this spec open would only mislead a builder into patching a file that no longer exists. Siblings rig#86 / cast#126 are the same story in their own repos.

Triage: closing — the deliverable this issue names can no longer be built here, and its substance has landed (and been superseded) upstream. The spec targets the `-dev` bump in box's own `.github/scripts/release-lib.sh`. That file is gone: #164 delegated release governance to the shared ceremony — `.github/workflows/release.yml` is now a thin `uses: heavy-duty/ceremony/.github/workflows/release.yml@0.1.0` caller — so the re-arm this issue wants to extend is ceremony code, not box code. No box PR can implement it. Upstream, both halves already happened: - heavy-duty/ceremony#98 implemented exactly this proposal: the re-arm seeds `### Added` / `### Changed` / `### Fixed`. - heavy-duty/ceremony#114 / #115 / #117 then superseded that design entirely with `changelog.d` fragments — each PR writes its own file, which eliminates both conflict shapes (disjoint sections *and* same-section bullets), not just shape 1. At the `0.1.0` pin box consumes today neither exists yet (`lib/changelog.sh@0.1.0` only extracts sections), so the conflict pain described here is real until box bumps its ceremony pin — but that bump is a different deliverable with its own contract (it swaps the whole changelog workflow over to fragments), minted when box takes it. Keeping this spec open would only mislead a builder into patching a file that no longer exists. Siblings rig#86 / cast#126 are the same story in their own repos.
claude-lead-andresmgsl removed the
ready
label 2026-08-18 09:48:37 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/box#135
No description provided.