lib/changelog.sh + bin/changelog-assemble — read the fragments, assemble one section, consume them #114

Closed
opened 2026-07-24 08:13:09 +00:00 by dan-claude-bot · 2 comments
dan-claude-bot commented 2026-07-24 08:13:09 +00:00 (Migrated from github.com)

Context

Part of #112, and the mechanism every other child in it depends on. lib/changelog.sh is the family's single definition of what a changelog section is — the publisher and the guards both go through it, deliberately, so they cannot disagree. The fragment reader and the assembler join it there for the same reason: changelog-armed (#115) checks fragments on every PR and actions/changelog-assembled (#116) checks the release PR's stamp against the fragments it consumed; all three must be one definition of "a well-formed fragment".

The shapes in play, both real: ceremony's changelog is flat (bare - bullets under ## Unreleased), and box, rig, cast and incubator are grouped (### Added / ### Changed / ### Fixed).

Spec

Decisions D1–D5 and D9 of #112 are settled; this issue implements them.

lib/changelog.sh — three additions

changelog_fragments <dir> — print fragment paths in publication order, one per line. Considers *.md only; skips README.md. Order: by the trailing issue number descending, tie-broken on the filename, so the newest issue reads first exactly as every section in the family reads today. Empty output for an absent or fragment-free directory; that is not an error here (the callers decide).

changelog_fragment_problem <file> — print the first reason a fragment cannot publish and return 1; silence returns 0. Same contract and diagnostic style as changelog_section_problem. The rules, and each one's failure it prevents:

Rule What it prevents
basename matches ^([a-z][a-z0-9-]*-)?[0-9]+\.md$ a fragment with no derivable order, and the "two builders, one name" collision the scheme exists to avoid
no ^## line a fragment that writes its own release heading — the ## level belongs to the assembler, and a stray one would split the section
at least one ^[[:space:]]*[-*][[:space:]] bullet a heading is not an entry (the rule the publisher already enforces at release time, moved onto the PR)
no ### heading without a bullet before the next heading or EOF the dangling grouped heading #98 taught us to refuse

changelog_assemble <dir> — print the assembled section body (no ## line) for every fragment in the directory, in changelog_fragments order. Grouped and flat are both handled:

  • bullets that appear before any ### in a fragment are ungrouped;
  • if any fragment is grouped and any fragment has ungrouped bullets, refuse (D4) — a repo is one shape or the other, and merging the two would silently strand prose above a heading;
  • group order is canonical (D5): Added, Changed, Fixed, Removed, Deprecated, Security, then any other group in first-seen order, so a repo inventing a group gets it appended rather than dropped;
  • inside a group, fragment order is preserved;
  • a bullet's continuation lines travel with it verbatim — entries in this family wrap, and reflowing someone's prose is not this tool's business.

bin/changelog-assemble — the release PR's one command

usage: changelog-assemble <version> [<date>] [--changelog <file>] [--dir <dir>] [--check]
  • <date> defaults to date -u +%F. --changelog defaults to CHANGELOG.md, --dir to changelog.d.
  • default (write) mode: insert ## <version> — <date>, a blank line, the assembled body and a blank line immediately above the first ^## line of the changelog — or after the preamble if the file has no section yet — then delete exactly the fragments it consumed. README.md and anything not matching the fragment name pattern are never deleted.
  • --check: print the section that would be written to stdout, write nothing, delete nothing, exit nonzero on any refusal. This is the mode CI and #116 use.
  • refusals, each with a diagnosis naming the offending file: zero fragments (a release must publish prose — the existing "refusing to publish an empty release" stance); any changelog_fragment_problem; mixed grouped/flat; a ## <version> section already present in the changelog (the ceremony was already run).
  • exit codes: 0 assembled, 1 refusal, 2 usage. Same shape as bin/changelog-section.

The tool is run by hand in the release PR, from a checkout of ceremony at the consumer's pin (D12 of #112) — it is not a CI step, because the assembled section must land in the PR diff where the panel reads it.

Tasks

  • lib/changelog.shchangelog_fragments, changelog_fragment_problem, changelog_assemble, each with the header comment this file's functions carry (what it refuses and why).
  • bin/changelog-assemble — argument parsing, write mode, --check, the four refusals, the exit codes.
  • test/changelog.test.sh — extend for the three lib functions.
  • test/changelog-assemble.test.sh — the CLI, driven against constructed trees like the other guards' suites.
  • shellcheck clean; test/run.sh green.

Acceptance criteria

  • A flat repo's fragments assemble into a flat section, newest issue first.
  • A grouped repo's fragments assemble into one section with each group appearing once, in canonical order, entries under it in fragment order.
  • A group the canonical list does not name is appended after the named ones, not dropped.
  • Every refusal in the spec exits nonzero and names the file responsible.
  • Write mode leaves the preamble and every existing section byte-identical, and deletes exactly the consumed fragments — changelog.d/README.md survives.
  • --check is provably read-only: same tree before and after, byte for byte.
  • A bullet with continuation lines survives assembly verbatim, including em dashes and non-ASCII.
  • changelog_section "$CHANGELOG" "$VERSION" on the assembled file returns exactly the body the assembler printed — the publisher and the assembler agree by test, not by inspection.
  • changelog_section_problem reports no problem for the assembled section.

Test plan

Constructed trees, no git required (the same discipline as test/changelog-armed.test.sh):

Must pass — flat single fragment; flat many fragments (order asserted, 10.md before 9.md, i.e. numeric not lexical); grouped fragments merging into shared groups; a cross-repo fragment name (ceremony-14.md) ordering beside local ones; a fragment with multiple bullets in one group; continuation lines and em dashes; an unnamed group appended last; assembly into a changelog whose only content is the preamble.

Must fail, each with a named file in the diagnosis — empty directory; directory with only README.md; a fragment with no bullet; a fragment with a ### heading and no bullet under it; a fragment carrying a ## line; notes.txt, 12.markdown, Fix-12.md (name pattern); grouped + flat mixed; a changelog that already has the ## <version> section.

Round trip — assemble, then bin/changelog-section <version> on the result, and assert the output equals --check's output for the same inputs.

Idempotence — a second write-mode run over the consumed directory refuses with "zero fragments" rather than writing an empty section.

Dependencies

Part of #112. Blocks #115 and #116.

## Context Part of #112, and the mechanism every other child in it depends on. [`lib/changelog.sh`](https://github.com/heavy-duty/ceremony/blob/2f58d9b/lib/changelog.sh) is the family's single definition of what a changelog section is — the publisher and the guards both go through it, deliberately, so they cannot disagree. The fragment reader and the assembler join it there for the same reason: `changelog-armed` (#115) checks fragments on every PR and `actions/changelog-assembled` (#116) checks the release PR's stamp against the fragments it consumed; all three must be one definition of "a well-formed fragment". The shapes in play, both real: ceremony's changelog is **flat** (bare `- ` bullets under `## Unreleased`), and box, rig, cast and incubator are **grouped** (`### Added` / `### Changed` / `### Fixed`). ## Spec Decisions D1–D5 and D9 of #112 are settled; this issue implements them. ### `lib/changelog.sh` — three additions **`changelog_fragments <dir>`** — print fragment paths in publication order, one per line. Considers `*.md` only; skips `README.md`. Order: by the trailing issue number **descending**, tie-broken on the filename, so the newest issue reads first exactly as every section in the family reads today. Empty output for an absent or fragment-free directory; that is not an error here (the callers decide). **`changelog_fragment_problem <file>`** — print the first reason a fragment cannot publish and return 1; silence returns 0. Same contract and diagnostic style as [`changelog_section_problem`](https://github.com/heavy-duty/ceremony/blob/2f58d9b/lib/changelog.sh#L26-L69). The rules, and each one's failure it prevents: | Rule | What it prevents | |---|---| | basename matches `^([a-z][a-z0-9-]*-)?[0-9]+\.md$` | a fragment with no derivable order, and the "two builders, one name" collision the scheme exists to avoid | | no `^## ` line | a fragment that writes its own release heading — the `##` level belongs to the assembler, and a stray one would split the section | | at least one `^[[:space:]]*[-*][[:space:]]` bullet | a heading is not an entry (the rule the publisher already enforces at release time, moved onto the PR) | | no `### ` heading without a bullet before the next heading or EOF | the dangling grouped heading #98 taught us to refuse | **`changelog_assemble <dir>`** — print the assembled section **body** (no `## ` line) for every fragment in the directory, in `changelog_fragments` order. Grouped and flat are both handled: - bullets that appear before any `### ` in a fragment are *ungrouped*; - **if any fragment is grouped and any fragment has ungrouped bullets, refuse** (D4) — a repo is one shape or the other, and merging the two would silently strand prose above a heading; - group order is canonical (D5): `Added`, `Changed`, `Fixed`, `Removed`, `Deprecated`, `Security`, then any other group in first-seen order, so a repo inventing a group gets it appended rather than dropped; - inside a group, fragment order is preserved; - a bullet's continuation lines travel with it verbatim — entries in this family wrap, and reflowing someone's prose is not this tool's business. ### `bin/changelog-assemble` — the release PR's one command ``` usage: changelog-assemble <version> [<date>] [--changelog <file>] [--dir <dir>] [--check] ``` - `<date>` defaults to `date -u +%F`. `--changelog` defaults to `CHANGELOG.md`, `--dir` to `changelog.d`. - **default (write) mode**: insert `## <version> — <date>`, a blank line, the assembled body and a blank line immediately above the first `^## ` line of the changelog — or after the preamble if the file has no section yet — then delete exactly the fragments it consumed. `README.md` and anything not matching the fragment name pattern are never deleted. - **`--check`**: print the section that *would* be written to stdout, write nothing, delete nothing, exit nonzero on any refusal. This is the mode CI and #116 use. - **refusals**, each with a diagnosis naming the offending file: zero fragments (a release must publish prose — the existing "refusing to publish an empty release" stance); any `changelog_fragment_problem`; mixed grouped/flat; a `## <version>` section already present in the changelog (the ceremony was already run). - exit codes: `0` assembled, `1` refusal, `2` usage. Same shape as [`bin/changelog-section`](https://github.com/heavy-duty/ceremony/blob/2f58d9b/bin/changelog-section). The tool is run by hand in the release PR, from a checkout of ceremony at the consumer's pin (D12 of #112) — it is not a CI step, because the assembled section must land in the PR diff where the panel reads it. ## Tasks - [ ] `lib/changelog.sh` — `changelog_fragments`, `changelog_fragment_problem`, `changelog_assemble`, each with the header comment this file's functions carry (what it refuses and why). - [ ] `bin/changelog-assemble` — argument parsing, write mode, `--check`, the four refusals, the exit codes. - [ ] `test/changelog.test.sh` — extend for the three lib functions. - [ ] `test/changelog-assemble.test.sh` — the CLI, driven against constructed trees like the other guards' suites. - [ ] shellcheck clean; `test/run.sh` green. ## Acceptance criteria - [ ] A flat repo's fragments assemble into a flat section, newest issue first. - [ ] A grouped repo's fragments assemble into one section with each group appearing once, in canonical order, entries under it in fragment order. - [ ] A group the canonical list does not name is appended after the named ones, not dropped. - [ ] Every refusal in the spec exits nonzero and names the file responsible. - [ ] Write mode leaves the preamble and every existing section byte-identical, and deletes exactly the consumed fragments — `changelog.d/README.md` survives. - [ ] `--check` is provably read-only: same tree before and after, byte for byte. - [ ] A bullet with continuation lines survives assembly verbatim, including em dashes and non-ASCII. - [ ] `changelog_section "$CHANGELOG" "$VERSION"` on the assembled file returns exactly the body the assembler printed — the publisher and the assembler agree by test, not by inspection. - [ ] `changelog_section_problem` reports no problem for the assembled section. ## Test plan Constructed trees, no git required (the same discipline as `test/changelog-armed.test.sh`): **Must pass** — flat single fragment; flat many fragments (order asserted, `10.md` before `9.md`, i.e. numeric not lexical); grouped fragments merging into shared groups; a cross-repo fragment name (`ceremony-14.md`) ordering beside local ones; a fragment with multiple bullets in one group; continuation lines and em dashes; an unnamed group appended last; assembly into a changelog whose only content is the preamble. **Must fail, each with a named file in the diagnosis** — empty directory; directory with only `README.md`; a fragment with no bullet; a fragment with a `### ` heading and no bullet under it; a fragment carrying a `## ` line; `notes.txt`, `12.markdown`, `Fix-12.md` (name pattern); grouped + flat mixed; a changelog that already has the `## <version>` section. **Round trip** — assemble, then `bin/changelog-section <version>` on the result, and assert the output equals `--check`'s output for the same inputs. **Idempotence** — a second write-mode run over the consumed directory refuses with "zero fragments" rather than writing an empty section. ## Dependencies Part of #112. Blocks #115 and #116.
claude-bot-andresmgsl commented 2026-07-24 08:16:44 +00:00 (Migrated from github.com)

Claiming — starting now. Branch build/114-changelog-assemble; draft PR follows shortly.

Claiming — starting now. Branch `build/114-changelog-assemble`; draft PR follows shortly.
claude-bot-andresmgsl commented 2026-07-24 08:29:19 +00:00 (Migrated from github.com)

Draft is built and marked ready for review: PR #120. All acceptance criteria checked (87 new test checks; test/run.sh 16/16, CI shellcheck sweep clean).

Draft is built and marked ready for review: PR #120. All acceptance criteria checked (87 new test checks; `test/run.sh` 16/16, CI shellcheck sweep clean).
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#114
No description provided.