Merge pull request #125 from claude-bot-andresmgsl/build/117-changelog-d-flip

ceremony adopts changelog.d — the flag flip
This commit is contained in:
Daniel Marin 2026-07-24 12:18:23 +01:00 committed by GitHub
commit a602fd0a70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 285 additions and 161 deletions

1
.github/labeler.yml vendored
View file

@ -16,6 +16,7 @@ scope:release-flow:
- bin/**
- VERSION
- CHANGELOG.md
- changelog.d/**
- drills/**
- test/decide.test.sh
- test/facts.test.sh

View file

@ -11,8 +11,8 @@ to the issue for triage to amend, not silently unshipped. -->
## Changelog
- [ ] One line under `## Unreleased` — inserted **above** the heading below
it, never over it — or no behavior change, stated here.
- [ ] One fragment, `changelog.d/<issue>.md` — the exact prose to publish,
never an edit to `CHANGELOG.md` — or no behavior change, stated here.
## Round log

View file

@ -87,11 +87,14 @@ jobs:
echo "RELEASE_ASSETS_DIR=$RUNNER_TEMP/release-assets" >> "$GITHUB_ENV"
- name: construct the fixture consumer tree and the gh stub
# The fixture release.yml's steps run against (below): a base at
# 0.6.9-dev with an armed changelog, then the ceremony merge —
# VERSION bumped bare, Unreleased stamped. Same shape as
# test/release-chain.test.sh. The gh stub answers the one API fact
# the ceremony path consults (the merged release-labeled PR) so
# nothing here talks to GitHub.
# 0.6.9-dev armed the fragment way (#112) — changelog.d/ with its
# marker and one fragment — then the ceremony merge: VERSION bumped
# bare and the section stamped by the REAL assembler, the command
# the real ceremony PR runs by hand (#112 D12), so the exercise
# consumes the tool end to end instead of hand-writing its output.
# Same shape as test/release-chain.test.sh. The gh stub answers the
# one API fact the ceremony path consults (the merged
# release-labeled PR) so nothing here talks to GitHub.
run: |
mkdir -p "$RUNNER_TEMP/stub"
cat > "$RUNNER_TEMP/stub/gh" <<'EOF'
@ -110,31 +113,18 @@ jobs:
cat > CHANGELOG.md <<'EOF'
# Changelog
## Unreleased
- The entry this release ships.
## 0.6.8 — 2026-07-01
- An older entry.
EOF
git add VERSION CHANGELOG.md
mkdir changelog.d
printf '# changelog.d/ — assembled at release (heavy-duty/ceremony#112); the marker keeps the directory tracked.\n' > changelog.d/README.md
printf -- '- The entry this release ships.\n' > changelog.d/42.md
git add VERSION CHANGELOG.md changelog.d
git commit -qm "base"
printf '0.7.0\n' > VERSION
cat > CHANGELOG.md <<'EOF'
# Changelog
## Unreleased
## 0.7.0 — 2026-07-21
- The entry this release ships.
## 0.6.8 — 2026-07-01
- An older entry.
EOF
git add VERSION CHANGELOG.md
bash "$CEREMONY_DIR/bin/changelog-assemble" 0.7.0 2026-07-21
git add -A
git commit -qm "release: 0.7.0"
echo "FIXTURE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
- name: gather the facts — version, base version, released, labeled

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

View file

@ -105,10 +105,14 @@ triage bug, and the move is to say so on the issue, not to guess.
them as a checklist in the PR body and check them honestly as you go. If
one turns out to be wrong or unreachable, say so on the issue and get it
amended by triage — do not silently ship less than the issue says.
- Every behavior change adds one line to `CHANGELOG.md` under
`## Unreleased` — in a grouped changelog, append under an existing heading
and create one only when the kind is genuinely new; insert **above** the
heading below, never over it (the monotonic guard's whole reason to exist).
- Every behavior change writes one fragment, `changelog.d/<issue>.md`,
named for the authorizing issue (`<repo>-<issue>.md` when the work is
cross-repo) — the exact prose that will be published, nothing else: `- `
bullets, and in a grouped repo the `### Added` / `### Changed` /
`### Fixed` headings inside the fragment, creating a rarer kind only when
a change genuinely is one. Never edit `CHANGELOG.md` for an entry — the
release PR assembles the section from the fragments (#112); the monotonic
guard still refuses anything that deletes a shipped heading.
- Follow the repo's conventions file and match the code you touch. Tests are
not optional: the issue's test plan is the floor, not the ceiling.
- **Scope discipline: the PR does the issue — whole, and nothing else.**

View file

@ -2,37 +2,10 @@
The curated history of the ceremony itself. Each release's section is
published verbatim as that release's body (lib/changelog.sh extracts it),
so entries say what changed, cite the issue, and stop.
## Unreleased
- `test/changelog-assembled.test.sh` — keep the trio interaction aligned with fragment mode: a dropped entry makes armed red too, while a hand-edited section leaves assembled as the sole red (#126).
- `changelog-armed` — treat `changelog.d/` as the arming, validate every development fragment, and require bare releases to consume the directory into their exact publishable section (#115).
- `actions/changelog-assembled` — a release PR's stamped section must be byte-for-byte what the fragments it consumed assemble to, replayed from the merge base; inapplicable trees pass with a NOTICE (#116).
- `lib/changelog.sh` + `bin/changelog-assemble` — read the `changelog.d/` fragments, assemble one release section (canonical group order, one shape per repo), and consume exactly what was published (#114).
- BUILDER.md — the directed hold is the parked claim's fifth shape, its attention demand is acknowledged in the declaration comment, and its board bookkeeping covers in-flight work; TRIAGE.md no longer excludes it (#113).
- BUILDER.md — the handed-off PR is the parked claim's fourth shape, its handoff is its declaration, and shape 2 covers the round awaiting its first verdicts (#109).
- `labels-reconcile` — a degraded mergeability/checks read now logs gh's actual stderr (collapsed, bounded) beside the byte-identical counted line, and the blind-sweep warning leads with the observed reason instead of asserting the permissions cause (#101).
- `LABELS.md` — drop the vendored scope-table enumeration; the per-repo set lives in `.github/labels.conf` and the repo's own CONTRIBUTING (#104).
- Changelog publication — count entries instead of bytes, refuse dangling grouped headings, and seed grouped re-arms with Added/Changed/Fixed (#98).
- `labels-reconcile` — warn once per sweep when a repository lacks labels declared by the pinned core taxonomy (#105).
- `labels-reconcile` — grant callers private-repo check reads and warn when an entire PR sweep is blind (#95).
- `labels-reconcile` — the bootstrap now retires the six GitHub defaults `LABELS.md` publishes as deleted, tolerating both an already-absent label and a refused delete (#93).
- `issueflow-reconcile` — a triage-authored issue arrival stands down with exit 0 instead of killing the run before the sweep (#91).
- FLEET.md — the assignee's `attention` wake: one role-independent trigger ahead of every per-role list, one acked session per demand; a spec on paper until `duty.sh` polls it (#86).
- `attention` doctrine — define its assignee-owned pickup, ack, queue and clock semantics across labels, triage, and builder roles (#85).
- `attention` — add the issue-only, hand-set assignee-demand flag to the core label taxonomy (#84).
- `issueflow-reconcile` — keep cross-repo references out of local dependency decisions and require triage to resolve cross-repo blockers by hand (#61).
- `needs-ruling` — the cross-cutting flag for a pending human decision, excluded from `state:needs-human` and from the staleness sweep (#51).
- Cross-repo doctrine: the panel is the PR's repo's roster, a review request is authorization but not panel membership, and `Part of <repo>#N` replaces the `Closes #N` that cannot cross repos (#57).
- `actions/runner-isolated` — a `pull_request`-triggered job may never run on a self-hosted runner (#58).
- The sweep's `needs-ruling` invariants, one implementation for both surfaces: the issue-side staleness exemption, the bare-flag check (comment-only, the label is never removed), and the 7-day nudge to the decider (#52).
- `offsite` — protect claimed issues whose PR lives in another repository from the claim-reclaim clock (#68).
- `issueflow-reconcile` — nudge once when an `offsite` flag outlives every visible cross-referenced PR (#69).
- Ruling doctrine — define every human-owned trigger, the fixed escalation shape, and the 024h builder-to-triage ladder (#72).
- The sweep observes the escalation contract: a malformed escalation is named field-by-field, and the ladder's 12h/24h rungs each draw one comment to the flag-setter — comment-only, per-episode, both surfaces (#73).
- FLEET.md — the operator notifier's `needs-ruling` queue (one tracked message per item, edited in place across the rungs) and triage's past-24h wake condition; a spec on paper until an operator updates the box (#74).
- One issue at a time counts build work in flight: the parked claim's three shapes, its declared-never-inferred comment, and triage's duty to name a directed hold as a park (#77).
so entries say what changed, cite the issue, and stop. Entries arrive as
fragments — one `changelog.d/<issue>.md` per PR, never an edit to this
file — and the release PR assembles them into the next section here
(`bin/changelog-assemble`, #112).
## 0.1.0 — 2026-07-22

View file

@ -40,11 +40,12 @@ already proven:
1. **One issue, one PR**, opened as a **draft** while building, with
`Closes #N` in the body. Drafts are invisible to the reviewer panel on
purpose. Every behavior change adds one line to `CHANGELOG.md` under
`## Unreleased` (in a grouped changelog, append under an existing heading
and create one only when the kind is genuinely new; insert **above** the
heading below — never type over it; the monotonic guard exists because of
exactly that edit).
purpose. Every behavior change writes one fragment,
`changelog.d/<issue>.md` — the exact prose to publish, nothing else
(cross-repo work names it `<repo>-<issue>.md`; a grouped repo puts its
`### Added` / `### Changed` / `### Fixed` headings inside the fragment).
Never edit `CHANGELOG.md` for an entry — the release PR assembles the
section from the fragments (#112).
2. **When it's ready**: mark ready-for-review and request the whole panel.
3. **Rounds are answered whole.** Wait until every reviewer has a verdict in,
then answer the entire round in a **single reply**, push the fixes, and

171
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,17 @@ 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 a bare tree still carrying fragments the
stamp did not consume (`not consumed` — re-run the assembler); the
message names the fix in every case. What this guard cannot see is a
fragment that *was* consumed but whose entry the stamp omits — the
fragment is gone from HEAD, so only
[changelog-assembled](#changelog-assembled--the-stamp-is-exactly-the-fragments)'s
merge-base replay catches that loss.
**Do not "simplify" this to "always require `## Unreleased`".** The
unconditional form is false by construction on the ceremony PR's own tree
@ -164,12 +185,43 @@ 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 can leave no trace. The two failure shapes
differ, and the guards split them exactly as
[test/changelog-assembled.test.sh](test/changelog-assembled.test.sh)'s
trio rows record: leave a fragment **out of the deletion** and it survives
on HEAD, where
[changelog-armed](#changelog-armed--main-never-sits-disarmed) already
refuses the bare tree (`not consumed`) — this guard goes red too, naming
the entry the section lost. But **delete** a fragment while omitting its
entry from the stamp, or hand-edit one word of the assembled prose, and
nothing on HEAD is out of place: armed is green, monotonic is green, and
the publisher would happily publish history that is not what the authors
wrote. Only the merge-base replay catches those. 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 +232,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 +412,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
@ -391,6 +450,6 @@ constraints — each bought with an incident, none of them safe to
their war stories, in the headers of the scripts they bind:
[release.yml](.github/workflows/release.yml#L1-L109),
[lib/decide.sh](lib/decide.sh#L1-L74),
[lib/facts.sh](lib/facts.sh#L1-L24), and the three
[lib/facts.sh](lib/facts.sh#L1-L24), and the four
[guard scripts](actions/). The comments are the documentation of record;
this README is their operator-facing cut.

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

1
changelog.d/101.md Normal file
View file

@ -0,0 +1 @@
- `labels-reconcile` — a degraded mergeability/checks read now logs gh's actual stderr (collapsed, bounded) beside the byte-identical counted line, and the blind-sweep warning leads with the observed reason instead of asserting the permissions cause (#101).

1
changelog.d/104.md Normal file
View file

@ -0,0 +1 @@
- `LABELS.md` — drop the vendored scope-table enumeration; the per-repo set lives in `.github/labels.conf` and the repo's own CONTRIBUTING (#104).

1
changelog.d/105.md Normal file
View file

@ -0,0 +1 @@
- `labels-reconcile` — warn once per sweep when a repository lacks labels declared by the pinned core taxonomy (#105).

1
changelog.d/109.md Normal file
View file

@ -0,0 +1 @@
- BUILDER.md — the handed-off PR is the parked claim's fourth shape, its handoff is its declaration, and shape 2 covers the round awaiting its first verdicts (#109).

1
changelog.d/112.md Normal file
View file

@ -0,0 +1 @@
- Ceremony adopts `changelog.d/` — a PR writes one fragment per issue instead of editing `CHANGELOG.md`, the release PR assembles the section, and `## Unreleased` is gone (#112).

1
changelog.d/113.md Normal file
View file

@ -0,0 +1 @@
- BUILDER.md — the directed hold is the parked claim's fifth shape, its attention demand is acknowledged in the declaration comment, and its board bookkeeping covers in-flight work; TRIAGE.md no longer excludes it (#113).

1
changelog.d/114.md Normal file
View file

@ -0,0 +1 @@
- `lib/changelog.sh` + `bin/changelog-assemble` — read the `changelog.d/` fragments, assemble one release section (canonical group order, one shape per repo), and consume exactly what was published (#114).

1
changelog.d/115.md Normal file
View file

@ -0,0 +1 @@
- `changelog-armed` — treat `changelog.d/` as the arming, validate every development fragment, and require bare releases to consume the directory into their exact publishable section (#115).

1
changelog.d/116.md Normal file
View file

@ -0,0 +1 @@
- `actions/changelog-assembled` — a release PR's stamped section must be byte-for-byte what the fragments it consumed assemble to, replayed from the merge base; inapplicable trees pass with a NOTICE (#116).

1
changelog.d/126.md Normal file
View file

@ -0,0 +1 @@
- `test/changelog-assembled.test.sh` — keep the trio interaction aligned with fragment mode: a dropped entry makes armed red too, while a hand-edited section leaves assembled as the sole red (#126).

1
changelog.d/51.md Normal file
View file

@ -0,0 +1 @@
- `needs-ruling` — the cross-cutting flag for a pending human decision, excluded from `state:needs-human` and from the staleness sweep (#51).

1
changelog.d/52.md Normal file
View file

@ -0,0 +1 @@
- The sweep's `needs-ruling` invariants, one implementation for both surfaces: the issue-side staleness exemption, the bare-flag check (comment-only, the label is never removed), and the 7-day nudge to the decider (#52).

1
changelog.d/57.md Normal file
View file

@ -0,0 +1 @@
- Cross-repo doctrine: the panel is the PR's repo's roster, a review request is authorization but not panel membership, and `Part of <repo>#N` replaces the `Closes #N` that cannot cross repos (#57).

1
changelog.d/58.md Normal file
View file

@ -0,0 +1 @@
- `actions/runner-isolated` — a `pull_request`-triggered job may never run on a self-hosted runner (#58).

1
changelog.d/61.md Normal file
View file

@ -0,0 +1 @@
- `issueflow-reconcile` — keep cross-repo references out of local dependency decisions and require triage to resolve cross-repo blockers by hand (#61).

1
changelog.d/68.md Normal file
View file

@ -0,0 +1 @@
- `offsite` — protect claimed issues whose PR lives in another repository from the claim-reclaim clock (#68).

1
changelog.d/69.md Normal file
View file

@ -0,0 +1 @@
- `issueflow-reconcile` — nudge once when an `offsite` flag outlives every visible cross-referenced PR (#69).

1
changelog.d/72.md Normal file
View file

@ -0,0 +1 @@
- Ruling doctrine — define every human-owned trigger, the fixed escalation shape, and the 024h builder-to-triage ladder (#72).

1
changelog.d/73.md Normal file
View file

@ -0,0 +1 @@
- The sweep observes the escalation contract: a malformed escalation is named field-by-field, and the ladder's 12h/24h rungs each draw one comment to the flag-setter — comment-only, per-episode, both surfaces (#73).

1
changelog.d/74.md Normal file
View file

@ -0,0 +1 @@
- FLEET.md — the operator notifier's `needs-ruling` queue (one tracked message per item, edited in place across the rungs) and triage's past-24h wake condition; a spec on paper until an operator updates the box (#74).

1
changelog.d/77.md Normal file
View file

@ -0,0 +1 @@
- One issue at a time counts build work in flight: the parked claim's three shapes, its declared-never-inferred comment, and triage's duty to name a directed hold as a park (#77).

1
changelog.d/84.md Normal file
View file

@ -0,0 +1 @@
- `attention` — add the issue-only, hand-set assignee-demand flag to the core label taxonomy (#84).

1
changelog.d/85.md Normal file
View file

@ -0,0 +1 @@
- `attention` doctrine — define its assignee-owned pickup, ack, queue and clock semantics across labels, triage, and builder roles (#85).

1
changelog.d/86.md Normal file
View file

@ -0,0 +1 @@
- FLEET.md — the assignee's `attention` wake: one role-independent trigger ahead of every per-role list, one acked session per demand; a spec on paper until `duty.sh` polls it (#86).

1
changelog.d/91.md Normal file
View file

@ -0,0 +1 @@
- `issueflow-reconcile` — a triage-authored issue arrival stands down with exit 0 instead of killing the run before the sweep (#91).

1
changelog.d/93.md Normal file
View file

@ -0,0 +1 @@
- `labels-reconcile` — the bootstrap now retires the six GitHub defaults `LABELS.md` publishes as deleted, tolerating both an already-absent label and a refused delete (#93).

1
changelog.d/95.md Normal file
View file

@ -0,0 +1 @@
- `labels-reconcile` — grant callers private-repo check reads and warn when an entire PR sweep is blind (#95).

1
changelog.d/98.md Normal file
View file

@ -0,0 +1 @@
- Changelog publication — count entries instead of bytes, refuse dangling grouped headings, and seed grouped re-arms with Added/Changed/Fixed (#98).

10
changelog.d/README.md Normal file
View file

@ -0,0 +1,10 @@
# changelog.d/ — the next release's section, one fragment per issue
Machine-assembled by `bin/changelog-assemble` (#112): every PR that changes
behavior writes one file here — `<issue>.md`, the exact prose that will be
published, nothing else — and the release PR folds them all into the next
`## X.Y.Z — DATE` section of `CHANGELOG.md`, consuming them. Distinct
filenames never conflict, which is this directory's whole reason to exist.
This README is the marker that keeps the directory tracked when it holds no
fragments (#112 D1) — `changelog-armed` refuses a tree without it; do not
delete it.

View file

@ -48,19 +48,23 @@ the machinery at all:
[lib/decide.sh](../lib/decide.sh#L70-L74)). Bootstrapping at `-dev`
keeps the repo clear of it entirely. (`package-json` backend: the
`version` field, same rule.)
2. **An armed `CHANGELOG.md`**: a preamble plus an empty `## Unreleased`
section for the first entries to land under. If the repo groups entries,
seed the shape the ceremony PR will restore at every re-arm:
2. **An armed changelog: a `CHANGELOG.md` preamble plus `changelog.d/`.**
The changelog file starts as preamble only — no section; the first
release writes the first one. The fragments directory beside it is the
arming (#112): it carries a `README.md` marker naming the assembler and
the doctrine — take ceremony's own
[changelog.d/README.md](../changelog.d/README.md) at the pin — which is
what keeps the directory tracked while it holds no fragments and what
`changelog-armed` asserts. Every behavior-change PR then writes
`changelog.d/<issue>.md` ([The changelog rule](#the-changelog-rule));
the release PR assembles the section
([Assembling a release section](#assembling-a-release-section)).
```markdown
## Unreleased
### Added
### Changed
### Fixed
```
Fragment mode is **unreleased** and not in `0.1.0`. A consumer pinned
to `0.1.0` bootstraps the legacy shape instead — the preamble plus an
empty `## Unreleased` section for entries to land under — and converts
on the pin bump to the first tag carrying fragment mode; never mix
refs to adopt it early.
3. **`drills/README.md`** defining what a drill *means* in this repo —
each repo names its own
([the drill doctrine](../README.md#the-drill-doctrine)). Plain
@ -73,13 +77,19 @@ the machinery at all:
```yaml
- uses: actions/checkout@v4
with:
# changelog-monotonic compares HEAD against the merge base; a
# checkout that cannot resolve it is a hard failure in CI, not
# a skip (a guard that can quietly stop guarding is the failure
# shape these checks exist to refuse).
# changelog-monotonic and changelog-assembled compare HEAD
# against the merge base; a checkout that cannot resolve it is
# a hard failure in CI, not a skip (a guard that can quietly
# stop guarding is the failure shape these checks exist to
# refuse).
fetch-depth: 0
- uses: heavy-duty/ceremony/actions/changelog-armed@<pinned-tag>
- uses: heavy-duty/ceremony/actions/changelog-monotonic@<pinned-tag>
# Unreleased: changelog-assembled is not in 0.1.0. Adopt this step
# with the pin bump to the first tag that carries it; never mix
# refs. Green NOTICE on every non-release PR; on a release PR it
# asserts the stamped section is exactly the fragments it consumed.
- uses: heavy-duty/ceremony/actions/changelog-assembled@<pinned-tag>
- uses: heavy-duty/ceremony/actions/drill-recorded@<pinned-tag>
# Unreleased: runner-isolated is not in 0.1.0. Adopt this step with
# the pin bump to the first tag that carries it; never mix refs.
@ -105,8 +115,9 @@ the machinery at all:
here until a release tag ships it. If an action does not exist at the
consumer's pinned tag, adopt it with the pin bump to the first tag that
carries it; never mix a moving or newer ref into an otherwise exact-pin
consumer. In particular, `0.1.0` carries the three release guards above
plus `docs-sync`, but not `runner-isolated`.
consumer. In particular, `0.1.0` carries `changelog-armed`,
`changelog-monotonic` and `drill-recorded` plus `docs-sync`, but not
`changelog-assembled` or `runner-isolated`.
6. **Labels automation** (optional but recommended): the caller from
[Labels automation](#labels-automation), plus `.github/labels.conf`
(panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the
@ -116,8 +127,8 @@ the machinery at all:
per [The artifact hook](#the-artifact-hook). No hook → the source
tarball is the package.
From there the flow is the doctrine: ordinary PRs add their changelog
line, the ceremony PR makes
From there the flow is the doctrine: ordinary PRs write their fragment,
the ceremony PR makes
[the three stamps](../README.md#what-a-release-is), a human merges, the
machine transcribes.
@ -143,6 +154,18 @@ precisely so the machinery is safe to work on
the `panel=` roster line and the repo's `scope:*` rows
([the format](#labels-automation)). `.github/labeler.yml` stays as
it is (path globs are inherently repo-specific).
- [ ] Convert the changelog to fragments (requires a pin at the first tag
carrying fragment mode — not `0.1.0`): move every entry under
`## Unreleased` to `changelog.d/<issue>.md`, verbatim — the filename
is derivable from the entry's own `(#N)`; an entry citing several
issues goes to the file for the first cited — delete the
`## Unreleased` heading, and add the `changelog.d/README.md` marker
([bootstrap step 2](#bootstrap-a-new-repo)). Published sections stay
byte-identical; `changelog-monotonic` proves that on the conversion
PR, and `changelog-armed` refuses a surviving `## Unreleased` the
moment the directory exists. Rewrite the repo's own contributor
docs that say "add a line under `## Unreleased`" in the same PR —
split either way, main lies for as long as the split lasts.
- [ ] Delete the now-shadowed copies — zero shared scripts remain:
`.github/scripts/release-notes.sh` (box, cast) or
`release-lib.sh` (rig), `changelog-armed.sh` (box),
@ -170,7 +193,7 @@ precisely so the machinery is safe to work on
changelog house style if it differs from
[the portable rule](#the-changelog-rule).
- [ ] What stays, per repo, forever: `VERSION` (or the `package.json`
version), `CHANGELOG.md`, `drills/`, `.github/labeler.yml`,
version), `CHANGELOG.md`, `changelog.d/`, `drills/`, `.github/labeler.yml`,
`.github/labels.conf`, the optional
`.github/actions/release-artifact/` — the full kept-vs-moved table
is in [#1](https://github.com/heavy-duty/ceremony/issues/1).
@ -395,25 +418,58 @@ Bumping the pin re-syncs the mirror in the same PR —
The portable version of the family's contributor rule — the repo's own
CONTRIBUTING may sharpen it, but this is the floor the guards assume:
- **Every PR that changes behavior adds one line** under `## Unreleased`.
- **Grouped changelogs keep three standing headings:** `### Added`,
`### Changed`, and `### Fixed`. The ceremony PR's hand-edited re-arm seeds
all three; append under one instead of creating a heading at the top
anchor. Create `Deprecated`, `Removed`, or `Security` only when a change
genuinely needs that rarer kind.
- **Insert above the heading below — never type over it.** Replacing a
shipped `## X.Y.Z` heading with your entry deletes that release's
section, silently; this exact edit is why the
[monotonic guard](../README.md#changelog-monotonic--shipped-headings-are-append-only)
exists (box#122).
- **Every PR that changes behavior writes one fragment**:
`changelog.d/<issue>.md`, named for the authorizing issue —
`<repo>-<issue>.md` for cross-repo work carrying `Part of <repo>#N`
so the name is known at claim time and two builders can only collide by
working the same issue (#112 D2). Never an edit to `CHANGELOG.md`: the
release PR assembles the section
([below](#assembling-a-release-section)).
- **The fragment is the prose, not a description of it** (#112 D3): the
exact lines that will be published — no front-matter, no `## ` heading
(that one is the assembler's to write). `changelog-armed` refuses a
malformed fragment on the PR that wrote it.
- **Grouped repos group inside the fragment**: `### Added`, `### Changed`,
`### Fixed` headings with bullets under them; create `Deprecated`,
`Removed`, or `Security` only when a change genuinely needs that rarer
kind. A repo is grouped or flat, never both (#112 D4). The assembler
merges groups in canonical order — Added, Changed, Fixed, Removed,
Deprecated, Security, then anything else first-seen — and inside a
group entries read newest issue first (#112 D5).
- **One line: say what changed, and stop.** Lead with the surface, not
the mechanism — "`state:needs-human` is set at handoff" beats "the
labels workflow now also wakes on `labeled`". The why and the how
belong in the PR body, where anyone chasing the reasoning already goes.
- **Cite the issue or PR**`(#141)`.
- **Mark a breaking change** with a leading `BREAKING:`.
- Flat changelogs remain flat; the standing-heading rule applies only when a
repo already groups its entries.
- A repo not yet on fragment mode — no `changelog.d/` — keeps the legacy
floor until its conversion: one line under `## Unreleased`, inserted
**above** the heading below it, never over it (replacing a shipped
heading deletes that release's section silently — box#122, why the
[monotonic guard](../README.md#changelog-monotonic--shipped-headings-are-append-only)
exists), appended under a standing `### ` heading where the repo groups.
## Assembling a release section
The ceremony PR's changelog stamp is one command, run **by hand, never in
CI** — the assembled section must land in the release PR's diff, where
the panel reads it (#112 D12). A consumer runs the tool from a ceremony
checkout at its own pin:
```sh
git clone --depth 1 --branch <pinned-tag> https://github.com/heavy-duty/ceremony /tmp/ceremony
/tmp/ceremony/bin/changelog-assemble <X.Y.Z>
```
Run it at the repo root. It folds every `changelog.d/` fragment into a
new `## X.Y.Z — DATE` section on top of `CHANGELOG.md` (DATE is today's
UTC date; pass one as a second argument to choose it) and deletes the
fragments it consumed — commit both halves together. `--check` prints the
would-be section body without touching anything; read it before running
the real thing. In CI, `changelog-assembled` replays the run from the
merge base and refuses a stamp that is not byte-for-byte what the
fragments assemble to — a mis-run hand step fails the PR, not the
published release.
## Adopting the agent team flow

View file

@ -26,8 +26,10 @@ drill is where they run live *before* a version rests on them.
carrying the candidate tree with `CEREMONY_SELF_REF` rewritten to the
candidate SHA in every pin carrier, and records that one-line deviation
in its record. From the second release on, this paragraph is moot.
3. Give it a fixture `VERSION` / `CHANGELOG.md` / `drills/` in the armed
state (`X.Y.Z-dev`, `## Unreleased` on top).
3. Give it a fixture `VERSION` / `CHANGELOG.md` / `changelog.d/` /
`drills/` in the armed state (`X.Y.Z-dev`, the fragments directory with
its `README.md` marker plus at least one fragment for the ceremony to
consume).
4. Exercise both doors, one probe at a time:
1. a merge-door ceremony publishes exactly one release and re-arms main

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"