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/** - bin/**
- VERSION - VERSION
- CHANGELOG.md - CHANGELOG.md
- changelog.d/**
- drills/** - drills/**
- test/decide.test.sh - test/decide.test.sh
- test/facts.test.sh - test/facts.test.sh

View file

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

View file

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

View file

@ -344,7 +344,7 @@ jobs:
# shellcheck source=/dev/null # shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/changelog.sh" . "$CEREMONY_DIR/lib/changelog.sh"
if ! diagnosis="$(changelog_section_problem CHANGELOG.md "$VER")"; then 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 printf '%s\n' "$diagnosis" >&2
exit 1 exit 1
fi 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 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 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. amended by triage — do not silently ship less than the issue says.
- Every behavior change adds one line to `CHANGELOG.md` under - Every behavior change writes one fragment, `changelog.d/<issue>.md`,
`## Unreleased` — in a grouped changelog, append under an existing heading named for the authorizing issue (`<repo>-<issue>.md` when the work is
and create one only when the kind is genuinely new; insert **above** the cross-repo) — the exact prose that will be published, nothing else: `- `
heading below, never over it (the monotonic guard's whole reason to exist). 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 - 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. not optional: the issue's test plan is the floor, not the ceiling.
- **Scope discipline: the PR does the issue — whole, and nothing else.** - **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 The curated history of the ceremony itself. Each release's section is
published verbatim as that release's body (lib/changelog.sh extracts it), published verbatim as that release's body (lib/changelog.sh extracts it),
so entries say what changed, cite the issue, and stop. 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
## Unreleased file — and the release PR assembles them into the next section here
(`bin/changelog-assemble`, #112).
- `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).
## 0.1.0 — 2026-07-22 ## 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 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 `Closes #N` in the body. Drafts are invisible to the reviewer panel on
purpose. Every behavior change adds one line to `CHANGELOG.md` under purpose. Every behavior change writes one fragment,
`## Unreleased` (in a grouped changelog, append under an existing heading `changelog.d/<issue>.md` — the exact prose to publish, nothing else
and create one only when the kind is genuinely new; insert **above** the (cross-repo work names it `<repo>-<issue>.md`; a grouped repo puts its
heading below — never type over it; the monotonic guard exists because of `### Added` / `### Changed` / `### Fixed` headings inside the fragment).
exactly that edit). 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. 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, 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 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` 1. **The version goes bare**: `X.Y.Z-dev``X.Y.Z`
([lib/version.sh](lib/version.sh)). ([lib/version.sh](lib/version.sh)).
2. **The changelog is stamped *and re-armed* — two edits, not one** 2. **The changelog section is assembled — one edit, produced by the tool**
(box#108). `## Unreleased` becomes `## X.Y.Z — DATE`, and an **empty (#112). Entries never accumulate in `CHANGELOG.md`: each PR wrote one
`## Unreleased` goes back on top**, immediately above it. When the repo fragment file, `changelog.d/<issue>.md`, and the ceremony PR runs
groups entries, the ceremony PR seeds its three standing headings: [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 There is no second edit: the old stamp *re-armed* — put an empty
## Unreleased `## Unreleased` back on top — because every PR inserted at that one
shared anchor, and between the stamp and the re-arm a PR authored
### Added *before* the release landed its entry under whatever now occupied the
position — **the section that just shipped** — cleanly, no conflict,
### Changed no signal (box#108; confirmed cross-repo as rig#66). Fragments make
that failure structurally impossible rather than guarded-against: a
### Fixed fragment merged after the release simply sits in the directory and is
assembled into the *next* section. There is no anchor left to misplace,
## 0.7.1 — 2026-07-19 and nothing to re-arm — the directory is always armed.
### 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.
3. **The drill record is present**: `drills/X.Y.Z.md`, non-blank — the 3. **The drill record is present**: `drills/X.Y.Z.md`, non-blank — the
evidence the release rests on 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 the generated PR list
([lib/changelog.sh](lib/changelog.sh) is the one canonical extractor) — ([lib/changelog.sh](lib/changelog.sh) is the one canonical extractor) —
and re-arms main by bumping to `X.Y.(Z+1)-dev` 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: the transcription because humans err silently and machines fail loudly:
**everything asserts its way to certainty and fails loudly, creating **everything asserts its way to certainty and fails loudly, creating
nothing** — a wrong release is worse than a missing one, so every failed nothing** — a wrong release is worse than a missing one, so every failed
@ -129,7 +123,7 @@ refuse (#1 constraint 8).
## The guards ## 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 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 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 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 ### changelog-armed — main never sits disarmed
**The rule** ([actions/changelog-armed/changelog-armed.sh](actions/changelog-armed/changelog-armed.sh#L27-L36)), **The rule** ([actions/changelog-armed/changelog-armed.sh](actions/changelog-armed/changelog-armed.sh)),
keyed on the tree's version: 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`. - `-dev` tree → the top section **must** be `## Unreleased`.
- bare tree (the ceremony PR and its merge) → the top section may be - 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). uses, so the two cannot disagree about what a section is).
**The incident**: box#108 / rig#66 — the silent mislanding described **The incident**: box#108 / rig#66 — the silent mislanding described
[above](#what-a-release-is). **Red means** a PR entry has nowhere safe to [above](#what-a-release-is). Fragment mode retires the incident's
land, or a stamped version would publish no entries or a dangling grouped mechanism outright; legacy mode guards it. **Red means** a PR entry has
heading; **the fix** is to re-arm the top or delete/populate the named nowhere safe to land — a missing marker, a surviving `## Unreleased`, a
heading before publishing. 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 **Do not "simplify" this to "always require `## Unreleased`".** The
unconditional form is false by construction on the ceremony PR's own tree 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 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. 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 One consequence worth knowing before it happens, legacy mode only: a
and forgets to re-arm still passes this guard — a bare tree is allowed to ceremony PR that stamps and forgets to re-arm still passes this guard — a
be stamped. It goes red **the moment the automatic `-dev` bump lands on bare tree is allowed to be stamped. It goes red **the moment the automatic
main** ([the script](actions/changelog-armed/changelog-armed.sh#L37-L42)). `-dev` bump lands on main**. The guard does not block the release; it
The guard does not block the release; it refuses to let main *sit* refuses to let main *sit* disarmed, which is the window a late PR falls
disarmed, which is the window a late PR falls into. 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 ### 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: rule needs no tuning because release headings are append-only by doctrine:
the ceremony adds one and nothing ever legitimately removes one — so the ceremony adds one and nothing ever legitimately removes one — so
superset has no exception to carve. The ceremony's own stamp passes by superset has no exception to carve. The ceremony's own stamp passes by
construction: rewriting `## Unreleased` into `## X.Y.Z — DATE` adds a construction: the assembler writes a new `## X.Y.Z — DATE` heading and
heading and removes none (`Unreleased` is not a version heading; it is removes none. Fragment mode changes nothing here (#112 D10): fragments add
[changelog-armed](#changelog-armed--main-never-sits-disarmed)'s business). 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 **The incidents**: box#122 (caught in review of box#118) — an author
adding an entry under `## Unreleased` **replaced** the heading below it 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 [L333L337](.github/workflows/release.yml#L333-L337). The message is the
remedy. 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 [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 ### Red main that is not the release workflow
Consumer CI runs its guard steps on pushes to main too (this repo's 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 [ci.yml](.github/workflows/ci.yml) does the same). The one guard red an
operator will actually meet on main is operator will actually meet on main is **changelog-armed after a re-arm
**changelog-armed after a re-arm was forgotten**: the ceremony stamped was forgotten — legacy mode only**: the ceremony stamped without putting
without putting `## Unreleased` back, the release's own `-dev` bump `## Unreleased` back, the release's own `-dev` bump landed, and the guard
landed, and the guard now says (first line): now says (first line):
> changelog-armed: the version is '$ver' (a development tree) but the top > changelog-armed: the version is '$ver' (a development tree) but the top
> section of $changelog is: … > section of $changelog is: …
The fix is a one-line PR: add an empty `## Unreleased` above the stamped The fix is a one-line PR: add an empty `## Unreleased` above the stamped
section. The full message section. The full message carries the same instruction. Fragment mode has
([the script](actions/changelog-armed/changelog-armed.sh#L87-L101)) no re-arm to forget, so it has no equivalent red on main — its refusals
carries the same instruction. (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 ## 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: their war stories, in the headers of the scripts they bind:
[release.yml](.github/workflows/release.yml#L1-L109), [release.yml](.github/workflows/release.yml#L1-L109),
[lib/decide.sh](lib/decide.sh#L1-L74), [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; [guard scripts](actions/). The comments are the documentation of record;
this README is their operator-facing cut. this README is their operator-facing cut.

View file

@ -17,7 +17,7 @@ fi
} }
if ! diagnosis="$(changelog_section_problem "$changelog" "$ver")"; then 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 printf 'changelog-section: %s\n' "$diagnosis" >&2
exit 1 exit 1
fi 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` [lib/decide.sh](../lib/decide.sh#L70-L74)). Bootstrapping at `-dev`
keeps the repo clear of it entirely. (`package-json` backend: the keeps the repo clear of it entirely. (`package-json` backend: the
`version` field, same rule.) `version` field, same rule.)
2. **An armed `CHANGELOG.md`**: a preamble plus an empty `## Unreleased` 2. **An armed changelog: a `CHANGELOG.md` preamble plus `changelog.d/`.**
section for the first entries to land under. If the repo groups entries, The changelog file starts as preamble only — no section; the first
seed the shape the ceremony PR will restore at every re-arm: 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 Fragment mode is **unreleased** and not in `0.1.0`. A consumer pinned
## Unreleased to `0.1.0` bootstraps the legacy shape instead — the preamble plus an
empty `## Unreleased` section for entries to land under — and converts
### Added on the pin bump to the first tag carrying fragment mode; never mix
refs to adopt it early.
### Changed
### Fixed
```
3. **`drills/README.md`** defining what a drill *means* in this repo — 3. **`drills/README.md`** defining what a drill *means* in this repo —
each repo names its own each repo names its own
([the drill doctrine](../README.md#the-drill-doctrine)). Plain ([the drill doctrine](../README.md#the-drill-doctrine)). Plain
@ -73,13 +77,19 @@ the machinery at all:
```yaml ```yaml
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
# changelog-monotonic compares HEAD against the merge base; a # changelog-monotonic and changelog-assembled compare HEAD
# checkout that cannot resolve it is a hard failure in CI, not # against the merge base; a checkout that cannot resolve it is
# a skip (a guard that can quietly stop guarding is the failure # a hard failure in CI, not a skip (a guard that can quietly
# shape these checks exist to refuse). # stop guarding is the failure shape these checks exist to
# refuse).
fetch-depth: 0 fetch-depth: 0
- uses: heavy-duty/ceremony/actions/changelog-armed@<pinned-tag> - uses: heavy-duty/ceremony/actions/changelog-armed@<pinned-tag>
- uses: heavy-duty/ceremony/actions/changelog-monotonic@<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> - uses: heavy-duty/ceremony/actions/drill-recorded@<pinned-tag>
# Unreleased: runner-isolated is not in 0.1.0. Adopt this step with # 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. # 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 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 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 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 consumer. In particular, `0.1.0` carries `changelog-armed`,
plus `docs-sync`, but not `runner-isolated`. `changelog-monotonic` and `drill-recorded` plus `docs-sync`, but not
`changelog-assembled` or `runner-isolated`.
6. **Labels automation** (optional but recommended): the caller from 6. **Labels automation** (optional but recommended): the caller from
[Labels automation](#labels-automation), plus `.github/labels.conf` [Labels automation](#labels-automation), plus `.github/labels.conf`
(panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the (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 per [The artifact hook](#the-artifact-hook). No hook → the source
tarball is the package. tarball is the package.
From there the flow is the doctrine: ordinary PRs add their changelog From there the flow is the doctrine: ordinary PRs write their fragment,
line, the ceremony PR makes the ceremony PR makes
[the three stamps](../README.md#what-a-release-is), a human merges, the [the three stamps](../README.md#what-a-release-is), a human merges, the
machine transcribes. 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 `panel=` roster line and the repo's `scope:*` rows
([the format](#labels-automation)). `.github/labeler.yml` stays as ([the format](#labels-automation)). `.github/labeler.yml` stays as
it is (path globs are inherently repo-specific). 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: - [ ] Delete the now-shadowed copies — zero shared scripts remain:
`.github/scripts/release-notes.sh` (box, cast) or `.github/scripts/release-notes.sh` (box, cast) or
`release-lib.sh` (rig), `changelog-armed.sh` (box), `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 changelog house style if it differs from
[the portable rule](#the-changelog-rule). [the portable rule](#the-changelog-rule).
- [ ] What stays, per repo, forever: `VERSION` (or the `package.json` - [ ] 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/labels.conf`, the optional
`.github/actions/release-artifact/` — the full kept-vs-moved table `.github/actions/release-artifact/` — the full kept-vs-moved table
is in [#1](https://github.com/heavy-duty/ceremony/issues/1). 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 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: CONTRIBUTING may sharpen it, but this is the floor the guards assume:
- **Every PR that changes behavior adds one line** under `## Unreleased`. - **Every PR that changes behavior writes one fragment**:
- **Grouped changelogs keep three standing headings:** `### Added`, `changelog.d/<issue>.md`, named for the authorizing issue —
`### Changed`, and `### Fixed`. The ceremony PR's hand-edited re-arm seeds `<repo>-<issue>.md` for cross-repo work carrying `Part of <repo>#N`
all three; append under one instead of creating a heading at the top so the name is known at claim time and two builders can only collide by
anchor. Create `Deprecated`, `Removed`, or `Security` only when a change working the same issue (#112 D2). Never an edit to `CHANGELOG.md`: the
genuinely needs that rarer kind. release PR assembles the section
- **Insert above the heading below — never type over it.** Replacing a ([below](#assembling-a-release-section)).
shipped `## X.Y.Z` heading with your entry deletes that release's - **The fragment is the prose, not a description of it** (#112 D3): the
section, silently; this exact edit is why the exact lines that will be published — no front-matter, no `## ` heading
[monotonic guard](../README.md#changelog-monotonic--shipped-headings-are-append-only) (that one is the assembler's to write). `changelog-armed` refuses a
exists (box#122). 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 - **One line: say what changed, and stop.** Lead with the surface, not
the mechanism — "`state:needs-human` is set at handoff" beats "the the mechanism — "`state:needs-human` is set at handoff" beats "the
labels workflow now also wakes on `labeled`". The why and the how labels workflow now also wakes on `labeled`". The why and the how
belong in the PR body, where anyone chasing the reasoning already goes. belong in the PR body, where anyone chasing the reasoning already goes.
- **Cite the issue or PR**`(#141)`. - **Cite the issue or PR**`(#141)`.
- **Mark a breaking change** with a leading `BREAKING:`. - **Mark a breaking change** with a leading `BREAKING:`.
- Flat changelogs remain flat; the standing-heading rule applies only when a - A repo not yet on fragment mode — no `changelog.d/` — keeps the legacy
repo already groups its entries. 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 ## 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 carrying the candidate tree with `CEREMONY_SELF_REF` rewritten to the
candidate SHA in every pin carrier, and records that one-line deviation candidate SHA in every pin carrier, and records that one-line deviation
in its record. From the second release on, this paragraph is moot. in its record. From the second release on, this paragraph is moot.
3. Give it a fixture `VERSION` / `CHANGELOG.md` / `drills/` in the armed 3. Give it a fixture `VERSION` / `CHANGELOG.md` / `changelog.d/` /
state (`X.Y.Z-dev`, `## Unreleased` on top). `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: 4. Exercise both doors, one probe at a time:
1. a merge-door ceremony publishes exactly one release and re-arms main 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 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 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 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 \ check "wrapper refuses a heading-only version section" 1 \
"section '1.5.0' has no entries — a heading is not an entry" \ "section '1.5.0' has no entries — a heading is not an entry" \
"$WRAPPER" 1.5.0 "$PROBLEM_FIXTURE" "$WRAPPER" 1.5.0 "$PROBLEM_FIXTURE"