diff --git a/.github/labeler.yml b/.github/labeler.yml index ea86a49..9271aa4 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -16,6 +16,7 @@ scope:release-flow: - bin/** - VERSION - CHANGELOG.md + - changelog.d/** - drills/** - test/decide.test.sh - test/facts.test.sh diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 763e6fa..81c601b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -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/.md` — the exact prose to publish, + never an edit to `CHANGELOG.md` — or no behavior change, stated here. ## Round log diff --git a/.github/workflows/release-exercise.yml b/.github/workflows/release-exercise.yml index 28ee10f..90201c4 100644 --- a/.github/workflows/release-exercise.yml +++ b/.github/workflows/release-exercise.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bcfbc14..ac2cfc7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/BUILDER.md b/BUILDER.md index 47aa8bf..4ce7949 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -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/.md`, + named for the authorizing 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.** diff --git a/CHANGELOG.md b/CHANGELOG.md index ae287da..d297992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 #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 0–24h 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/.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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1384972..7b9cd76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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/.md` — the exact prose to publish, nothing else + (cross-repo work names it `-.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 diff --git a/README.md b/README.md index e343232..3ffaa7e 100644 --- a/README.md +++ b/README.md @@ -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/.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 `.md` + or `-.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. [L333–L337](.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 [L346–L349](.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. diff --git a/bin/changelog-section b/bin/changelog-section index 82c1e1c..a203451 100755 --- a/bin/changelog-section +++ b/bin/changelog-section @@ -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 diff --git a/changelog.d/101.md b/changelog.d/101.md new file mode 100644 index 0000000..fae50f9 --- /dev/null +++ b/changelog.d/101.md @@ -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). diff --git a/changelog.d/104.md b/changelog.d/104.md new file mode 100644 index 0000000..8428e43 --- /dev/null +++ b/changelog.d/104.md @@ -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). diff --git a/changelog.d/105.md b/changelog.d/105.md new file mode 100644 index 0000000..df8841f --- /dev/null +++ b/changelog.d/105.md @@ -0,0 +1 @@ +- `labels-reconcile` — warn once per sweep when a repository lacks labels declared by the pinned core taxonomy (#105). diff --git a/changelog.d/109.md b/changelog.d/109.md new file mode 100644 index 0000000..326c6f6 --- /dev/null +++ b/changelog.d/109.md @@ -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). diff --git a/changelog.d/112.md b/changelog.d/112.md new file mode 100644 index 0000000..29738cf --- /dev/null +++ b/changelog.d/112.md @@ -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). diff --git a/changelog.d/113.md b/changelog.d/113.md new file mode 100644 index 0000000..35c5087 --- /dev/null +++ b/changelog.d/113.md @@ -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). diff --git a/changelog.d/114.md b/changelog.d/114.md new file mode 100644 index 0000000..160da0f --- /dev/null +++ b/changelog.d/114.md @@ -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). diff --git a/changelog.d/115.md b/changelog.d/115.md new file mode 100644 index 0000000..324eb08 --- /dev/null +++ b/changelog.d/115.md @@ -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). diff --git a/changelog.d/116.md b/changelog.d/116.md new file mode 100644 index 0000000..1acdd6a --- /dev/null +++ b/changelog.d/116.md @@ -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). diff --git a/changelog.d/126.md b/changelog.d/126.md new file mode 100644 index 0000000..e6558e9 --- /dev/null +++ b/changelog.d/126.md @@ -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). diff --git a/changelog.d/51.md b/changelog.d/51.md new file mode 100644 index 0000000..688a2c1 --- /dev/null +++ b/changelog.d/51.md @@ -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). diff --git a/changelog.d/52.md b/changelog.d/52.md new file mode 100644 index 0000000..cafdc0b --- /dev/null +++ b/changelog.d/52.md @@ -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). diff --git a/changelog.d/57.md b/changelog.d/57.md new file mode 100644 index 0000000..b79fb23 --- /dev/null +++ b/changelog.d/57.md @@ -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 #N` replaces the `Closes #N` that cannot cross repos (#57). diff --git a/changelog.d/58.md b/changelog.d/58.md new file mode 100644 index 0000000..791b399 --- /dev/null +++ b/changelog.d/58.md @@ -0,0 +1 @@ +- `actions/runner-isolated` — a `pull_request`-triggered job may never run on a self-hosted runner (#58). diff --git a/changelog.d/61.md b/changelog.d/61.md new file mode 100644 index 0000000..9edee59 --- /dev/null +++ b/changelog.d/61.md @@ -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). diff --git a/changelog.d/68.md b/changelog.d/68.md new file mode 100644 index 0000000..f5ad678 --- /dev/null +++ b/changelog.d/68.md @@ -0,0 +1 @@ +- `offsite` — protect claimed issues whose PR lives in another repository from the claim-reclaim clock (#68). diff --git a/changelog.d/69.md b/changelog.d/69.md new file mode 100644 index 0000000..45a4d80 --- /dev/null +++ b/changelog.d/69.md @@ -0,0 +1 @@ +- `issueflow-reconcile` — nudge once when an `offsite` flag outlives every visible cross-referenced PR (#69). diff --git a/changelog.d/72.md b/changelog.d/72.md new file mode 100644 index 0000000..34824e7 --- /dev/null +++ b/changelog.d/72.md @@ -0,0 +1 @@ +- Ruling doctrine — define every human-owned trigger, the fixed escalation shape, and the 0–24h builder-to-triage ladder (#72). diff --git a/changelog.d/73.md b/changelog.d/73.md new file mode 100644 index 0000000..db95180 --- /dev/null +++ b/changelog.d/73.md @@ -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). diff --git a/changelog.d/74.md b/changelog.d/74.md new file mode 100644 index 0000000..0d81292 --- /dev/null +++ b/changelog.d/74.md @@ -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). diff --git a/changelog.d/77.md b/changelog.d/77.md new file mode 100644 index 0000000..956ff97 --- /dev/null +++ b/changelog.d/77.md @@ -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). diff --git a/changelog.d/84.md b/changelog.d/84.md new file mode 100644 index 0000000..b5e00fe --- /dev/null +++ b/changelog.d/84.md @@ -0,0 +1 @@ +- `attention` — add the issue-only, hand-set assignee-demand flag to the core label taxonomy (#84). diff --git a/changelog.d/85.md b/changelog.d/85.md new file mode 100644 index 0000000..42ef042 --- /dev/null +++ b/changelog.d/85.md @@ -0,0 +1 @@ +- `attention` doctrine — define its assignee-owned pickup, ack, queue and clock semantics across labels, triage, and builder roles (#85). diff --git a/changelog.d/86.md b/changelog.d/86.md new file mode 100644 index 0000000..8b6f008 --- /dev/null +++ b/changelog.d/86.md @@ -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). diff --git a/changelog.d/91.md b/changelog.d/91.md new file mode 100644 index 0000000..89cd4bc --- /dev/null +++ b/changelog.d/91.md @@ -0,0 +1 @@ +- `issueflow-reconcile` — a triage-authored issue arrival stands down with exit 0 instead of killing the run before the sweep (#91). diff --git a/changelog.d/93.md b/changelog.d/93.md new file mode 100644 index 0000000..7902fac --- /dev/null +++ b/changelog.d/93.md @@ -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). diff --git a/changelog.d/95.md b/changelog.d/95.md new file mode 100644 index 0000000..0c933bc --- /dev/null +++ b/changelog.d/95.md @@ -0,0 +1 @@ +- `labels-reconcile` — grant callers private-repo check reads and warn when an entire PR sweep is blind (#95). diff --git a/changelog.d/98.md b/changelog.d/98.md new file mode 100644 index 0000000..1c200cb --- /dev/null +++ b/changelog.d/98.md @@ -0,0 +1 @@ +- Changelog publication — count entries instead of bytes, refuse dangling grouped headings, and seed grouped re-arms with Added/Changed/Fixed (#98). diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 0000000..1c529b2 --- /dev/null +++ b/changelog.d/README.md @@ -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 — `.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. diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 2b285bc..5ad6ee8 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -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/.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@ - uses: heavy-duty/ceremony/actions/changelog-monotonic@ + # 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@ - uses: heavy-duty/ceremony/actions/drill-recorded@ # 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/.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/.md`, named for the authorizing issue — + `-.md` for cross-repo work carrying `Part of #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 https://github.com/heavy-duty/ceremony /tmp/ceremony +/tmp/ceremony/bin/changelog-assemble +``` + +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 diff --git a/drills/README.md b/drills/README.md index cbbe0d0..b80ea32 100644 --- a/drills/README.md +++ b/drills/README.md @@ -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 diff --git a/test/changelog.test.sh b/test/changelog.test.sh index f4fb5b5..c9d531e 100755 --- a/test/changelog.test.sh +++ b/test/changelog.test.sh @@ -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"