docs: README describes the assembled stamp and the four guards

Stamp 2 is one assembler-produced edit; changelog-armed's rule is stated
mode-first (fragment, then legacy, #112 D7/D8/D9); changelog-assembled
gets its operator section (#116); monotonic records D10; the two rewritten
error strings and the retired re-arm recovery follow (#117).
This commit is contained in:
claude-bot-andresmgsl 2026-07-24 10:22:12 +00:00
parent 819dc7d992
commit c6e9afee87
4 changed files with 105 additions and 58 deletions

View file

@ -344,7 +344,7 @@ jobs:
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/changelog.sh"
if ! diagnosis="$(changelog_section_problem CHANGELOG.md "$VER")"; then
echo "CHANGELOG.md has no '## $VER' section — stamp the Unreleased section in the release PR before tagging; refusing to publish an empty release" >&2
echo "CHANGELOG.md has no '## $VER' section — run changelog-assemble in the release PR before tagging; refusing to publish an empty release" >&2
printf '%s\n' "$diagnosis" >&2
exit 1
fi

157
README.md
View file

@ -21,36 +21,29 @@ stamps:
1. **The version goes bare**: `X.Y.Z-dev``X.Y.Z`
([lib/version.sh](lib/version.sh)).
2. **The changelog is stamped *and re-armed* — two edits, not one**
(box#108). `## Unreleased` becomes `## X.Y.Z — DATE`, and an **empty
`## Unreleased` goes back on top**, immediately above it. When the repo
groups entries, the ceremony PR seeds its three standing headings:
2. **The changelog section is assembled — one edit, produced by the tool**
(#112). Entries never accumulate in `CHANGELOG.md`: each PR wrote one
fragment file, `changelog.d/<issue>.md`, and the ceremony PR runs
[bin/changelog-assemble](bin/changelog-assemble) — by hand, on purpose,
so the section lands in the PR's diff where the panel reads it (#112
D12; a consumer's exact invocation is in
[docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)).
The tool folds every fragment into a new `## X.Y.Z — DATE` section on
top and deletes the fragments it consumed; the
[assembled guard](#changelog-assembled--the-stamp-is-exactly-the-fragments)
replays that run and refuses a stamp that is not byte-for-byte the
fragments' assembly.
```markdown
## Unreleased
### Added
### Changed
### Fixed
## 0.7.1 — 2026-07-19
### Fixed
...
```
The second edit is not cosmetic and not deferrable. Between the stamp
and the next re-creation of that heading, main has no `## Unreleased`. A
PR authored *before* the release wrote its entry under that heading;
with the heading gone, git lands the entry under whatever now occupies
the position — **the section that just shipped** — and it merges
cleanly, no conflict, no signal. The changelog then credits a released
version with a change it does not contain, and nothing but a human
reading the file will ever say so (box#108; confirmed cross-repo as
rig#66). The [armed guard](#changelog-armed--main-never-sits-disarmed)
exists because of exactly this edit.
There is no second edit: the old stamp *re-armed* — put an empty
`## Unreleased` back on top — because every PR inserted at that one
shared anchor, and between the stamp and the re-arm a PR authored
*before* the release landed its entry under whatever now occupied the
position — **the section that just shipped** — cleanly, no conflict,
no signal (box#108; confirmed cross-repo as rig#66). Fragments make
that failure structurally impossible rather than guarded-against: a
fragment merged after the release simply sits in the directory and is
assembled into the *next* section. There is no anchor left to misplace,
and nothing to re-arm — the directory is always armed.
3. **The drill record is present**: `drills/X.Y.Z.md`, non-blank — the
evidence the release rests on
@ -70,7 +63,8 @@ the version's own changelog section as the body — the curated prose, never
the generated PR list
([lib/changelog.sh](lib/changelog.sh) is the one canonical extractor) —
and re-arms main by bumping to `X.Y.(Z+1)-dev`
([release.yml](.github/workflows/release.yml#L266-L300)). The machine does
([release.yml](.github/workflows/release.yml#L266-L300)) — the version is
the only re-arm left; the changelog needs none (#112). The machine does
the transcription because humans err silently and machines fail loudly:
**everything asserts its way to certainty and fails loudly, creating
nothing** — a wrong release is worse than a missing one, so every failed
@ -129,7 +123,7 @@ refuse (#1 constraint 8).
## The guards
Three composite actions run in every consumer's CI (and in this repo's
Four composite actions run in every consumer's CI (and in this repo's
own). Shared shape: version-keyed where the tree's state matters, loud
where it fails, and **a file of its own so a test can drive it**. The full
war stories are in the scripts' header comments — authoritative and longer
@ -137,8 +131,28 @@ than this; what follows is the operator's cut.
### changelog-armed — main never sits disarmed
**The rule** ([actions/changelog-armed/changelog-armed.sh](actions/changelog-armed/changelog-armed.sh#L27-L36)),
keyed on the tree's version:
**The rule** ([actions/changelog-armed/changelog-armed.sh](actions/changelog-armed/changelog-armed.sh)),
keyed on the tree's shape, then its version. In **fragment mode**
`changelog.d/` exists, the arming property moved onto the directory
(#112 D7):
- always → the marker `changelog.d/README.md` must exist (what keeps the
directory tracked when it holds no fragments), no `## Unreleased`
section may survive in `CHANGELOG.md` (a second anchor with no owner),
and every fragment must be publishable on its own — named `<issue>.md`
or `<repo>-<issue>.md`, no `## ` heading, at least one bullet, no
`### ` heading without an entry. A malformed fragment fails the PR that
wrote it, not the release that consumes it (#112 D9).
- `-dev` tree → nothing more. The directory **is** the arming: the next
PR's entry is a new file, and a new file always has somewhere to land.
- bare tree (the ceremony PR and its merge) → every fragment must be
consumed, and the top section must be the stamped, publishable section
for exactly that version. Fragment mode has no re-armed shape — there
is nothing left to re-arm.
In **legacy mode** — no `changelog.d/` — the version-keyed rules stand
verbatim; both shapes stay supported so a consumer adopts fragments on a
pin bump, on its own schedule (#112 D8):
- `-dev` tree → the top section **must** be `## Unreleased`.
- bare tree (the ceremony PR and its merge) → the top section may be
@ -152,10 +166,12 @@ keyed on the tree's version:
uses, so the two cannot disagree about what a section is).
**The incident**: box#108 / rig#66 — the silent mislanding described
[above](#what-a-release-is). **Red means** a PR entry has nowhere safe to
land, or a stamped version would publish no entries or a dangling grouped
heading; **the fix** is to re-arm the top or delete/populate the named
heading before publishing.
[above](#what-a-release-is). Fragment mode retires the incident's
mechanism outright; legacy mode guards it. **Red means** a PR entry has
nowhere safe to land — a missing marker, a surviving `## Unreleased`, a
malformed fragment — or a stamped version would publish no entries, a
dangling grouped heading, or fewer fragments than it consumed; the
message names the fix in every case.
**Do not "simplify" this to "always require `## Unreleased`".** The
unconditional form is false by construction on the ceremony PR's own tree
@ -164,12 +180,36 @@ to revert exactly that
([the script's header](actions/changelog-armed/changelog-armed.sh#L8-L16)).
The version-keyed form is what rig and cast get back by adopting this repo.
One consequence worth knowing before it happens: a ceremony PR that stamps
and forgets to re-arm still passes this guard — a bare tree is allowed to
be stamped. It goes red **the moment the automatic `-dev` bump lands on
main** ([the script](actions/changelog-armed/changelog-armed.sh#L37-L42)).
The guard does not block the release; it refuses to let main *sit*
disarmed, which is the window a late PR falls into.
One consequence worth knowing before it happens, legacy mode only: a
ceremony PR that stamps and forgets to re-arm still passes this guard — a
bare tree is allowed to be stamped. It goes red **the moment the automatic
`-dev` bump lands on main**. The guard does not block the release; it
refuses to let main *sit* disarmed, which is the window a late PR falls
into. Fragment mode has no such window: with no re-arm step there is
nothing to forget.
### changelog-assembled — the stamp is exactly the fragments
**The rule**
([actions/changelog-assembled/changelog-assembled.sh](actions/changelog-assembled/changelog-assembled.sh)):
on a release PR in fragment mode, the stamped `## X.Y.Z` section must be
**byte-for-byte** what the fragments it consumed assemble to. The guard
reads the fragments as of the merge base (they are gone from HEAD — that
is the point of the ceremony), replays `changelog-assemble --check` over
that set, and diffs the result against HEAD's section body. Every tree it
does not apply to — a `-dev` tree, legacy mode, no consumed fragments —
passes with a green `NOTICE`, so a non-ceremony PR is never red here.
**The failure it catches** (#116): assembly is a hand-run step by design —
the section must land in the PR's diff where the panel reads it (#112 D12)
— and a mis-run hand step leaves no trace. Drop one fragment from the
deletion and its entry is simply absent from the release: armed is green,
monotonic is green, and the publisher happily publishes the shortened
section. Hand-edit one word of the assembled prose and the published
history quietly stops being what the authors wrote. The replay is what
makes a hand-run step safe. **This guard needs history** — same stance as
the monotonic guard: `fetch-depth: 0`, and in CI an unresolvable base is a
hard failure, not a skip.
### changelog-monotonic — shipped headings are append-only
@ -180,9 +220,12 @@ the set at the merge base, and no heading may appear twice on HEAD. The
rule needs no tuning because release headings are append-only by doctrine:
the ceremony adds one and nothing ever legitimately removes one — so
superset has no exception to carve. The ceremony's own stamp passes by
construction: rewriting `## Unreleased` into `## X.Y.Z — DATE` adds a
heading and removes none (`Unreleased` is not a version heading; it is
[changelog-armed](#changelog-armed--main-never-sits-disarmed)'s business).
construction: the assembler writes a new `## X.Y.Z — DATE` heading and
removes none. Fragment mode changes nothing here (#112 D10): fragments add
no `## ` heading, and `Unreleased` was never in the guard's set — it is
not a version heading; it is
[changelog-armed](#changelog-armed--main-never-sits-disarmed)'s business —
which is why a repo's adoption PR can delete it and stay green.
**The incidents**: box#122 (caught in review of box#118) — an author
adding an entry under `## Unreleased` **replaced** the heading below it
@ -357,27 +400,31 @@ open.
[L333L337](.github/workflows/release.yml#L333-L337). The message is the
remedy.
> CHANGELOG.md has no '## $VER' section — stamp the Unreleased section in the release PR before tagging; refusing to publish an empty release
> CHANGELOG.md has no '## $VER' section — run changelog-assemble in the release PR before tagging; refusing to publish an empty release
[L346L349](.github/workflows/release.yml#L346-L349). The tagged tree was
never stamped. Stamp first, then delete and re-push the tag.
never stamped. Assemble the section
([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)),
then delete and re-push the tag.
### Red main that is not the release workflow
Consumer CI runs its guard steps on pushes to main too (this repo's
[ci.yml](.github/workflows/ci.yml) does the same). The one guard red an
operator will actually meet on main is
**changelog-armed after a re-arm was forgotten**: the ceremony stamped
without putting `## Unreleased` back, the release's own `-dev` bump
landed, and the guard now says (first line):
operator will actually meet on main is **changelog-armed after a re-arm
was forgotten — legacy mode only**: the ceremony stamped without putting
`## Unreleased` back, the release's own `-dev` bump landed, and the guard
now says (first line):
> changelog-armed: the version is '$ver' (a development tree) but the top
> section of $changelog is: …
The fix is a one-line PR: add an empty `## Unreleased` above the stamped
section. The full message
([the script](actions/changelog-armed/changelog-armed.sh#L87-L101))
carries the same instruction.
section. The full message carries the same instruction. Fragment mode has
no re-arm to forget, so it has no equivalent red on main — its refusals
(a missing marker, a surviving `## Unreleased`, a malformed or unconsumed
fragment) all fire on the PR that caused them, where the author is still
holding it.
## Design lineage

View file

@ -17,7 +17,7 @@ fi
}
if ! diagnosis="$(changelog_section_problem "$changelog" "$ver")"; then
echo "changelog-section: $changelog has no section for '$ver' — the release PR stamps the Unreleased section with version + date BEFORE the tag" >&2
echo "changelog-section: $changelog has no section for '$ver' — the release PR assembles the section (changelog-assemble) with version + date BEFORE the tag" >&2
printf 'changelog-section: %s\n' "$diagnosis" >&2
exit 1
fi

View file

@ -143,7 +143,7 @@ WRAPPER="$ROOT/bin/changelog-section"
check "wrapper publishes the requested body" 0 "The seven-oh entry" "$WRAPPER" 0.7.0 "$FIXTURE"
check "wrapper refuses an empty section" 1 "no section for '0.5.0'" "$WRAPPER" 0.5.0 "$FIXTURE"
check "wrapper refuses an absent section" 1 "no section for '9.9.9'" "$WRAPPER" 9.9.9 "$FIXTURE"
check "wrapper explains how the release PR fixes refusal" 1 "stamps the Unreleased section" "$WRAPPER" 9.9.9 "$FIXTURE"
check "wrapper explains how the release PR fixes refusal" 1 "assembles the section" "$WRAPPER" 9.9.9 "$FIXTURE"
check "wrapper refuses a heading-only version section" 1 \
"section '1.5.0' has no entries — a heading is not an entry" \
"$WRAPPER" 1.5.0 "$PROBLEM_FIXTURE"