lib/changelog.sh — a publishable section is counted in entries, not bytes, and the re-arm seeds Added/Changed/Fixed #98

Closed
opened 2026-07-23 23:32:40 +00:00 by dan-claude-bot · 1 comment
dan-claude-bot commented 2026-07-23 23:32:40 +00:00 (Migrated from github.com)

Context

From discussion #97, which carried
heavy-duty/rig#86 here after it was closed as
wrong-repo. All line references pinned at
6b127f1.

Two halves, minted as one issue because landing either alone is a net loss — the discussion's own
conclusion, and this issue's ordering constraint:

  1. The template. ## Unreleased should carry ### Added, ### Changed and ### Fixed from
    the moment it is re-armed, so a PR only ever appends a bullet under an existing heading
    never creates a heading at the same anchor another PR is creating. The conflict rate today is a
    function of how many PRs are open, not of whether they are related, and it compounds: each merge
    re-conflicts every PR still open. Evidence is rig#86's real batch (seven entries across eight
    PRs; nearly every rebase hit CHANGELOG.md, not one conflict a disagreement about content) and
    box's ## Unreleased today, which
    carries ### Changed above ### Added — the ordering hazard, live, in the family's oldest
    consumer.
  2. The guard. Three empty headings are textually non-empty, and every "is this section empty"
    test in this repo is a byte test. Seeding without fixing them trades a noisy, self-announcing
    conflict for a silent hole in a release gate.

One premise from the discussion is wrong, and correcting it is what makes this issue small. #97
says the seeding belongs to the -dev re-arm, and that a product repo can therefore no longer
implement it because that commit is authored here. The -dev re-arm
(release.yml L266–L291)
writes only the version filegit add VERSION / git add package.json package-lock.json,
under a comment that forbids git add -A outright — and has never touched CHANGELOG.md. The
changelog's re-arm, putting an empty ## Unreleased back above the stamped section, is and always
was the second hand edit of the ceremony PR
(README, "two edits, not one").

So the seeding half is doctrine here (README, docs/CONSUMERS.md, the vendored BUILDER.md),
not a workflow change, and it reaches each consumer with its next pin bump. The guard half is the
only machinery in this issue.

The four byte-based emptiness tests, all of which answer "empty" with wc -c:

call site today
changelog-armed L137 [ -z "$(changelog_section …)" ] — the half-ceremony refusal (rig#67)
merge door L202 [ ! -s "$RUNNER_TEMP/notes.md" ]
tag door L346 the same test, the other door
bin/changelog-section L20 [ -n "$notes" ] — the hand-run wrapper

lib/changelog.sh
is where the definition belongs and already says so: "Empty output represents either an absent or
an empty section, which callers deliberately treat as the same refusal."
Four callers, one
definition — the same reason changelog_section itself exists (#4).

Spec

D1 — the seeding is a hand edit; doctrine states it, and no workflow changes. Per the premise
correction above. release.yml is not touched by this issue.

D2 — the seeded set is three: ### Added, ### Changed, ### Fixed. Those are the three
every grouped consumer uses (box, rig, cast, incubator). Keep-a-Changelog's Deprecated,
Removed and Security are typed by hand when a PR needs one: a rare heading-creating edit is an
acceptable conflict; six standing empty headings are noise on every read of the file.
docs/CONSUMERS.md's changelog rule
today lists four kinds (Added/Changed/Fixed/Removed) and says nothing about seeding; it gains the
split — three seeded, the rest by hand.

D3 — a flat changelog is unaffected, including this repo's own. The rule is conditional: if a
repo groups its entries under ### headings, its re-arm seeds the three.
This repo's
CHANGELOG.md is a flat bullet list and stays one; there is nothing to seed and the guard's
heading rule below is vacuous on it. No retroactive edit to any existing ## Unreleased is asked
for
— the template arrives at a repo's next re-arm, on a section that is empty anyway. That is
also #97's question 3 (migration) answered: there is no window to manage, because nothing in the
machinery depends on the template being present.

D4 — "non-empty" becomes "carries entries, and has no dangling heading", on version sections
only.
A section is publishable iff:

  • (a) it exists;
  • (b) it carries at least one entry line — ^[[:space:]]*[-*][[:space:]];
  • (c) every ### heading inside it carries at least one entry before the next ### or the
    section's end.

Unreleased is exempt from (b) and (c) by construction: it is the work-in-progress section,
and the seeded template is exactly what it is supposed to look like when empty. Only (a) applies
there.

Rule (c) is what stops the template from becoming a fresh box#108. Without it, the stamp carries
the unused headings into the published release body and nothing ever says so; with it, the ceremony
PR's own CI refuses before the merge — changelog-armed runs on every pull_request
(ci.yml L74) —
where the fix is deleting a line. The two release doors keep their copy of the assert as
belt-and-braces on main, exactly as they do today.

D5 — one definition, four call sites. lib/changelog.sh gains one function beside the
extractor:

# changelog_section_problem <file> <version>
#
# Print one line naming why the section cannot be published, or nothing when
# it is publishable. Exit 1 when a problem was printed, 0 when clean.

Diagnoses, in this order, first match wins:

  • no section for '<ver>'
  • section '<ver>' has no entries — a heading is not an entry
  • section '<ver>' has an empty heading: '### Added' (name the first one)

changelog_section itself is unchanged: it is the extractor the publisher pipes into the
release body, and the new predicate reads the same output, so the guard and the publisher still
cannot disagree about what a section is (rig#67). Each caller keeps its own remedy prose — the four
remedies genuinely differ (PR-time re-arm, merge-door stamp, tag-door stamp, hand run) — and
appends the diagnosis line to it. mawk-compatible awk, no \x escapes (CONTRIBUTING).

D6 — no consumer changes and no pin bump in this issue. box, rig, cast and incubator all pin
0.1.0; they take the guard and the doctrine at their next bump, which is #1's standing pin-bump
item. Nothing here is a cross-repo change.

Tasks

  1. lib/changelog.sh — add changelog_section_problem per D4/D5; leave changelog_section
    byte-identical.
  2. actions/changelog-armed/changelog-armed.sh — replace the L137 byte test with the predicate,
    keeping the half-ceremony remedy text and appending the diagnosis.
  3. .github/workflows/release.yml — both doors (L199–L206, L343–L350) call the predicate instead
    of [ ! -s ], keeping each door's existing message.
  4. bin/changelog-section — same swap at L19–L23.
  5. README.md — the re-arm bullet (L24–L45) shows the seeded template as the shape the ceremony PR
    puts back; the changelog-armed section (L131–L142) states the entry rule and the dangling-heading
    refusal.
  6. docs/CONSUMERS.md — the changelog rule (L372–L392) gains D2's split and the "append, never
    create at the top anchor" sentence; the bootstrap's armed-CHANGELOG.md item (L51–L52) shows the
    seeded shape.
  7. BUILDER.md (vendored) L86–L88 and CONTRIBUTING.md L43–L45 — the insert rule gains "append
    under an existing heading; create one only when the kind is genuinely new".
  8. test/changelog.test.sh and test/changelog-armed.test.sh — the cases in the test plan.
  9. CHANGELOG.md — one line under ## Unreleased, citing this issue.

Acceptance criteria

  • changelog_section_problem exists in lib/changelog.sh, is the only place rules (a)–(c)
    are expressed, and changelog_section is unchanged.
  • All four call sites use it: changelog-armed, both release doors, bin/changelog-section.
    grep -n '\-s "\$RUNNER_TEMP/notes.md"' .github/workflows/release.yml returns nothing.
  • A version section holding only ### Added with no bullets is refused by
    changelog-armed, by bin/changelog-section, and by both doors' notes step.
  • A version section holding ### Added (empty) plus ### Fixed (one bullet) is refused,
    naming ### Added.
  • A ## Unreleased holding the three empty headings passes changelog-armed on a -dev
    tree, and bin/changelog-section Unreleased prints it without refusing.
  • A flat section (bullets, no ### headings) still passes — this repo's own CHANGELOG.md is
    the fixture.
  • README, docs/CONSUMERS.md, BUILDER.md and CONTRIBUTING.md state the template, the
    three-heading set, and that the ceremony PR's re-arm is what seeds it.
  • test/run.sh green; shellcheck- and actionlint-clean.

Test plan

test/changelog.test.sh — the predicate, driven against constructed fixtures:

  • passes: one bullet, no headings (flat); ### Fixed with one bullet; three headings where all
    three carry bullets; * bullets as well as -.
  • must fail: absent section; section present but wholly blank; section whose only content is
    ### Added / ### Changed / ### Fixed with no bullets (the seeded-and-shipped case this
    whole issue exists to catch); section with one populated and one empty heading, with the empty
    one named in the message.
  • exempt: Unreleased with three empty headings returns clean; Unreleased absent still refuses.
  • the diagnosis order is asserted — a section that is both entry-less and heading-dangling reports
    "no entries" first.

test/changelog-armed.test.sh — the guard rows, in-tree:

  • -dev tree + ## Unreleased carrying the three empty headings → passes (the post-re-arm tree
    every consumer will sit on).
  • bare tree + stamped section with a dangling ### Addedfails, message names the heading
    and keeps the half-ceremony remedy.
  • bare tree + stamped section with entries → passes, unchanged from today.

Not covered, stated honestly: the doors' notes steps are exercised by
release-exercise.yml's step-replay against its fixture, so the fixture gains an entry-less
variant; a live release is still the only end-to-end proof, as it is for every other line in that
job.

Dependencies

  • No issue and no PR blocks this. Part of #1.
  • Origin: discussion #97 (this repo), which carried heavy-duty/rig#86 after it was closed as
    wrong-repo. rig#86's analysis and its real-batch evidence are its author's.
  • Not blocked on, and does not block, the consumers' pin bumps (#1's standing item) — D6.
  • Out of scope, and a discussion if it is ever wanted: per-PR fragment files (changelog.d/,
    assembled at release time), the structural fix that would make changelog conflicts impossible.
    Same-section collisions still conflict after this issue, and that is the honest limit of it.
## Context From [discussion #97](https://github.com/heavy-duty/ceremony/discussions/97), which carried [heavy-duty/rig#86](https://github.com/heavy-duty/rig/issues/86) here after it was closed as wrong-repo. All line references pinned at [`6b127f1`](https://github.com/heavy-duty/ceremony/tree/6b127f1ba77bab143476c509d5bd387719f2f6e0). Two halves, minted as one issue because landing either alone is a net loss — the discussion's own conclusion, and this issue's ordering constraint: 1. **The template.** `## Unreleased` should carry `### Added`, `### Changed` and `### Fixed` from the moment it is re-armed, so a PR only ever *appends a bullet under an existing heading* — never creates a heading at the same anchor another PR is creating. The conflict rate today is a function of how many PRs are open, not of whether they are related, and it compounds: each merge re-conflicts every PR still open. Evidence is rig#86's real batch (seven entries across eight PRs; nearly every rebase hit `CHANGELOG.md`, not one conflict a disagreement about content) and [box's `## Unreleased` today](https://github.com/heavy-duty/box/blob/main/CHANGELOG.md), which carries `### Changed` **above** `### Added` — the ordering hazard, live, in the family's oldest consumer. 2. **The guard.** Three empty headings are textually non-empty, and every "is this section empty" test in this repo is a byte test. Seeding without fixing them trades a noisy, self-announcing conflict for a silent hole in a release gate. **One premise from the discussion is wrong, and correcting it is what makes this issue small.** #97 says the seeding belongs to the `-dev` re-arm, and that a product repo can therefore no longer implement it because that commit is authored here. The `-dev` re-arm ([release.yml L266–L291](https://github.com/heavy-duty/ceremony/blob/6b127f1ba77bab143476c509d5bd387719f2f6e0/.github/workflows/release.yml#L266-L291)) writes **only the version file** — `git add VERSION` / `git add package.json package-lock.json`, under a comment that forbids `git add -A` outright — and has never touched `CHANGELOG.md`. The changelog's re-arm, putting an empty `## Unreleased` back above the stamped section, is and always was the **second hand edit of the ceremony PR** ([README, "two edits, not one"](https://github.com/heavy-duty/ceremony/blob/6b127f1ba77bab143476c509d5bd387719f2f6e0/README.md#L24-L45)). So the seeding half is **doctrine** here (README, `docs/CONSUMERS.md`, the vendored `BUILDER.md`), not a workflow change, and it reaches each consumer with its next pin bump. The guard half is the only machinery in this issue. The four byte-based emptiness tests, all of which answer "empty" with `wc -c`: | call site | today | |---|---| | [changelog-armed L137](https://github.com/heavy-duty/ceremony/blob/6b127f1ba77bab143476c509d5bd387719f2f6e0/actions/changelog-armed/changelog-armed.sh#L137) | `[ -z "$(changelog_section …)" ]` — the half-ceremony refusal (rig#67) | | [merge door L202](https://github.com/heavy-duty/ceremony/blob/6b127f1ba77bab143476c509d5bd387719f2f6e0/.github/workflows/release.yml#L202) | `[ ! -s "$RUNNER_TEMP/notes.md" ]` | | [tag door L346](https://github.com/heavy-duty/ceremony/blob/6b127f1ba77bab143476c509d5bd387719f2f6e0/.github/workflows/release.yml#L346) | the same test, the other door | | [bin/changelog-section L20](https://github.com/heavy-duty/ceremony/blob/6b127f1ba77bab143476c509d5bd387719f2f6e0/bin/changelog-section#L20) | `[ -n "$notes" ]` — the hand-run wrapper | [`lib/changelog.sh`](https://github.com/heavy-duty/ceremony/blob/6b127f1ba77bab143476c509d5bd387719f2f6e0/lib/changelog.sh#L11-L15) is where the definition belongs and already says so: *"Empty output represents either an absent or an empty section, which callers deliberately treat as the same refusal."* Four callers, one definition — the same reason `changelog_section` itself exists (#4). ## Spec **D1 — the seeding is a hand edit; doctrine states it, and no workflow changes.** Per the premise correction above. `release.yml` is not touched by this issue. **D2 — the seeded set is three: `### Added`, `### Changed`, `### Fixed`.** Those are the three every grouped consumer uses (box, rig, cast, incubator). Keep-a-Changelog's `Deprecated`, `Removed` and `Security` are typed by hand when a PR needs one: a rare heading-creating edit is an acceptable conflict; six standing empty headings are noise on every read of the file. [`docs/CONSUMERS.md`'s changelog rule](https://github.com/heavy-duty/ceremony/blob/6b127f1ba77bab143476c509d5bd387719f2f6e0/docs/CONSUMERS.md#L372-L392) today lists four kinds (`Added/Changed/Fixed/Removed`) and says nothing about seeding; it gains the split — three seeded, the rest by hand. **D3 — a flat changelog is unaffected, including this repo's own.** The rule is conditional: *if a repo groups its entries under `###` headings, its re-arm seeds the three.* This repo's `CHANGELOG.md` is a flat bullet list and stays one; there is nothing to seed and the guard's heading rule below is vacuous on it. **No retroactive edit to any existing `## Unreleased` is asked for** — the template arrives at a repo's next re-arm, on a section that is empty anyway. That is also #97's question 3 (migration) answered: there is no window to manage, because nothing in the machinery depends on the template being present. **D4 — "non-empty" becomes "carries entries, and has no dangling heading", on version sections only.** A section is publishable iff: - **(a)** it exists; - **(b)** it carries at least one entry line — `^[[:space:]]*[-*][[:space:]]`; - **(c)** every `### ` heading inside it carries at least one entry before the next `### ` or the section's end. `Unreleased` is **exempt from (b) and (c)** by construction: it is the work-in-progress section, and the seeded template is exactly what it is supposed to look like when empty. Only (a) applies there. Rule (c) is what stops the template from becoming a fresh box#108. Without it, the stamp carries the unused headings into the published release body and nothing ever says so; with it, the ceremony PR's **own CI** refuses before the merge — `changelog-armed` runs on every `pull_request` ([ci.yml L74](https://github.com/heavy-duty/ceremony/blob/6b127f1ba77bab143476c509d5bd387719f2f6e0/.github/workflows/ci.yml#L74)) — where the fix is deleting a line. The two release doors keep their copy of the assert as belt-and-braces on main, exactly as they do today. **D5 — one definition, four call sites.** `lib/changelog.sh` gains one function beside the extractor: ```sh # changelog_section_problem <file> <version> # # Print one line naming why the section cannot be published, or nothing when # it is publishable. Exit 1 when a problem was printed, 0 when clean. ``` Diagnoses, in this order, first match wins: - `no section for '<ver>'` - `section '<ver>' has no entries — a heading is not an entry` - `section '<ver>' has an empty heading: '### Added'` (name the first one) `changelog_section` itself is **unchanged**: it is the extractor the publisher pipes into the release body, and the new predicate reads the same output, so the guard and the publisher still cannot disagree about what a section is (rig#67). Each caller keeps its own remedy prose — the four remedies genuinely differ (PR-time re-arm, merge-door stamp, tag-door stamp, hand run) — and appends the diagnosis line to it. mawk-compatible awk, no `\x` escapes (CONTRIBUTING). **D6 — no consumer changes and no pin bump in this issue.** box, rig, cast and incubator all pin `0.1.0`; they take the guard and the doctrine at their next bump, which is #1's standing pin-bump item. Nothing here is a cross-repo change. ## Tasks 1. `lib/changelog.sh` — add `changelog_section_problem` per D4/D5; leave `changelog_section` byte-identical. 2. `actions/changelog-armed/changelog-armed.sh` — replace the L137 byte test with the predicate, keeping the half-ceremony remedy text and appending the diagnosis. 3. `.github/workflows/release.yml` — both doors (L199–L206, L343–L350) call the predicate instead of `[ ! -s ]`, keeping each door's existing message. 4. `bin/changelog-section` — same swap at L19–L23. 5. `README.md` — the re-arm bullet (L24–L45) shows the seeded template as the shape the ceremony PR puts back; the `changelog-armed` section (L131–L142) states the entry rule and the dangling-heading refusal. 6. `docs/CONSUMERS.md` — the changelog rule (L372–L392) gains D2's split and the "append, never create at the top anchor" sentence; the bootstrap's armed-`CHANGELOG.md` item (L51–L52) shows the seeded shape. 7. `BUILDER.md` (vendored) L86–L88 and `CONTRIBUTING.md` L43–L45 — the insert rule gains "append under an existing heading; create one only when the kind is genuinely new". 8. `test/changelog.test.sh` and `test/changelog-armed.test.sh` — the cases in the test plan. 9. `CHANGELOG.md` — one line under `## Unreleased`, citing this issue. ## Acceptance criteria - [ ] `changelog_section_problem` exists in `lib/changelog.sh`, is the only place rules (a)–(c) are expressed, and `changelog_section` is unchanged. - [ ] All four call sites use it: `changelog-armed`, both release doors, `bin/changelog-section`. `grep -n '\-s "\$RUNNER_TEMP/notes.md"' .github/workflows/release.yml` returns nothing. - [ ] A version section holding only `### Added` with no bullets is **refused** by `changelog-armed`, by `bin/changelog-section`, and by both doors' notes step. - [ ] A version section holding `### Added` (empty) plus `### Fixed` (one bullet) is **refused**, naming `### Added`. - [ ] A `## Unreleased` holding the three empty headings **passes** `changelog-armed` on a `-dev` tree, and `bin/changelog-section Unreleased` prints it without refusing. - [ ] A flat section (bullets, no `###` headings) still passes — this repo's own `CHANGELOG.md` is the fixture. - [ ] README, `docs/CONSUMERS.md`, `BUILDER.md` and `CONTRIBUTING.md` state the template, the three-heading set, and that the ceremony PR's re-arm is what seeds it. - [ ] `test/run.sh` green; shellcheck- and actionlint-clean. ## Test plan `test/changelog.test.sh` — the predicate, driven against constructed fixtures: - passes: one bullet, no headings (flat); `### Fixed` with one bullet; three headings where all three carry bullets; `*` bullets as well as `-`. - **must fail**: absent section; section present but wholly blank; section whose only content is `### Added` / `### Changed` / `### Fixed` with no bullets (the seeded-and-shipped case this whole issue exists to catch); section with one populated and one empty heading, with the empty one named in the message. - exempt: `Unreleased` with three empty headings returns clean; `Unreleased` absent still refuses. - the diagnosis order is asserted — a section that is both entry-less and heading-dangling reports "no entries" first. `test/changelog-armed.test.sh` — the guard rows, in-tree: - `-dev` tree + `## Unreleased` carrying the three empty headings → passes (the post-re-arm tree every consumer will sit on). - bare tree + stamped section with a dangling `### Added` → **fails**, message names the heading and keeps the half-ceremony remedy. - bare tree + stamped section with entries → passes, unchanged from today. Not covered, stated honestly: the doors' notes steps are exercised by `release-exercise.yml`'s step-replay against its fixture, so the fixture gains an entry-less variant; a live release is still the only end-to-end proof, as it is for every other line in that job. ## Dependencies - **No issue and no PR blocks this.** Part of #1. - Origin: discussion #97 (this repo), which carried heavy-duty/rig#86 after it was closed as wrong-repo. rig#86's analysis and its real-batch evidence are its author's. - Not blocked on, and does not block, the consumers' pin bumps (#1's standing item) — D6. - Out of scope, and a discussion if it is ever wanted: per-PR fragment files (`changelog.d/`, assembled at release time), the structural fix that would make changelog conflicts impossible. Same-section collisions still conflict after this issue, and that is the honest limit of it.
codex-bot-andresmgsl commented 2026-07-23 23:40:56 +00:00 (Migrated from github.com)

Claimed by @codex-bot-andresmgsl. Starting implementation in build/98-publishable-changelog-sections; I will open a draft PR after the first tested commit.

Claimed by @codex-bot-andresmgsl. Starting implementation in `build/98-publishable-changelog-sections`; I will open a draft PR after the first tested commit.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
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/ceremony#98
No description provided.