diff --git a/.github/workflows/release-exercise.yml b/.github/workflows/release-exercise.yml index e75940a..28ee10f 100644 --- a/.github/workflows/release-exercise.yml +++ b/.github/workflows/release-exercise.yml @@ -171,12 +171,32 @@ jobs: run: | # shellcheck source=/dev/null . "$CEREMONY_DIR/lib/changelog.sh" - changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md" - if [ ! -s "$RUNNER_TEMP/notes.md" ]; then + if ! diagnosis="$(changelog_section_problem CHANGELOG.md "$VER")"; then echo "CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release" >&2 + printf '%s\n' "$diagnosis" >&2 exit 1 fi + changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md" cat "$RUNNER_TEMP/notes.md" + - name: an entry-less stamped fixture is refused by the notes predicate + working-directory: ${{ runner.temp }}/fixture + env: + VER: ${{ steps.facts.outputs.ver }} + run: | + cp CHANGELOG.md "$RUNNER_TEMP/CHANGELOG.good.md" + awk -v ver="$VER" ' + /^## / { in_section = ($2 == ver) } + in_section && /^[[:space:]]*[-*][[:space:]]/ { next } + { print } + ' "$RUNNER_TEMP/CHANGELOG.good.md" > CHANGELOG.md + # shellcheck source=/dev/null + . "$CEREMONY_DIR/lib/changelog.sh" + if diagnosis="$(changelog_section_problem CHANGELOG.md "$VER")"; then + echo "entry-less stamped section unexpectedly passed" >&2 + exit 1 + fi + printf '%s\n' "$diagnosis" | grep -F "section '$VER' has no entries" + cp "$RUNNER_TEMP/CHANGELOG.good.md" CHANGELOG.md - name: the chain must land where the fixture says it lands env: CEREMONY: ${{ steps.decide.outputs.ceremony }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e5ce98..bcfbc14 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -198,11 +198,12 @@ jobs: run: | # shellcheck source=/dev/null . "$CEREMONY_DIR/lib/changelog.sh" - changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md" - if [ ! -s "$RUNNER_TEMP/notes.md" ]; then + if ! diagnosis="$(changelog_section_problem CHANGELOG.md "$VER")"; then echo "CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release" >&2 + printf '%s\n' "$diagnosis" >&2 exit 1 fi + changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md" cat "$RUNNER_TEMP/notes.md" - name: nothing may exist yet — no tag, no release (re-runs refuse loudly) if: steps.decide.outputs.ceremony == 'yes' @@ -342,11 +343,12 @@ jobs: run: | # shellcheck source=/dev/null . "$CEREMONY_DIR/lib/changelog.sh" - changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md" - if [ ! -s "$RUNNER_TEMP/notes.md" ]; 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 + printf '%s\n' "$diagnosis" >&2 exit 1 fi + changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md" cat "$RUNNER_TEMP/notes.md" - name: artifact hook — the consumer's own release-artifact action # After the tag exists (it fired this door), before the publish — diff --git a/BUILDER.md b/BUILDER.md index 396c33d..9d442b5 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -84,8 +84,9 @@ triage bug, and the move is to say so on the issue, not to guess. 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` — insert **above** the heading below it, never over it - (the monotonic guard's whole reason to exist). + `## 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). - 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 f9814a2..ed6e793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ so entries say what changed, cite the issue, and stop. ## Unreleased +- Changelog publication — count entries instead of bytes, refuse dangling grouped headings, and seed grouped re-arms with Added/Changed/Fixed (#98). - `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). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 932cbce..f4b9654 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,8 +41,10 @@ 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` (insert **above** the heading below — never type over it; - the monotonic guard exists because of exactly that edit). + `## 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). 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 a57b9fe..e343232 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,18 @@ stamps: ([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: + `## Unreleased` goes back on top**, immediately above it. When the repo + groups entries, the ceremony PR seeds its three standing headings: ```markdown ## Unreleased + ### Added + + ### Changed + + ### Fixed + ## 0.7.1 — 2026-07-19 ### Fixed @@ -136,15 +143,19 @@ keyed on the tree's version: - `-dev` tree → the top section **must** be `## Unreleased`. - bare tree (the ceremony PR and its merge) → the top section may be `## Unreleased` (re-armed) *or* the stamped section for exactly that - version — **and** that version's section must exist and carry prose, - because it is the one about to ship (the half-ceremony refusal, rig#67: - version bumped, stamp missing — asserted through the very extractor the - publisher uses, so the two cannot disagree about what a section is). + version — **and** that version's section must exist, carry at least one + `-` or `*` entry, and have no `### ` heading without an entry before the + next heading or section end. A heading is not an entry. These publication + rules do not apply to `Unreleased`: the empty three-heading template is + deliberately valid there (the half-ceremony refusal, rig#67: version + bumped, stamp missing — asserted through the very extractor the publisher + 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; **the fix** is to re-arm: add an empty `## Unreleased` above the top -stamped section. +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. **Do not "simplify" this to "always require `## Unreleased`".** The unconditional form is false by construction on the ceremony PR's own tree diff --git a/actions/changelog-armed/changelog-armed.sh b/actions/changelog-armed/changelog-armed.sh index e0596a7..4e6130b 100644 --- a/actions/changelog-armed/changelog-armed.sh +++ b/actions/changelog-armed/changelog-armed.sh @@ -134,7 +134,7 @@ EOF # the very extractor the publisher uses — changelog_section (#4) — so the # guard and the publisher cannot disagree about what a section is or when # one counts as empty (rig#67). - if [ -z "$(changelog_section "$changelog" "$ver")" ]; then + if ! diagnosis="$(changelog_section_problem "$changelog" "$ver")"; then cat >&2 <&2 + printf 'changelog-section: %s\n' "$diagnosis" >&2 exit 1 -} +fi +notes="$(changelog_section "$changelog" "$ver")" printf '%s\n' "$notes" diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 5df1893..efad3f8 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -49,7 +49,18 @@ the machinery at all: 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. + 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: + + ```markdown + ## Unreleased + + ### Added + + ### Changed + + ### Fixed + ``` 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 @@ -382,6 +393,11 @@ 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 @@ -393,7 +409,8 @@ CONTRIBUTING may sharpen it, but this is the floor the guards assume: 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:`. -- Group under `### Added` / `### Changed` / `### Fixed` / `### Removed`. +- Flat changelogs remain flat; the standing-heading rule applies only when a + repo already groups its entries. ## Adopting the agent team flow diff --git a/lib/changelog.sh b/lib/changelog.sh index c9c052c..4489898 100644 --- a/lib/changelog.sh +++ b/lib/changelog.sh @@ -20,3 +20,48 @@ changelog_section() { found { body = 1; print } ' "$1" } + +# changelog_section_problem +# +# Print the first reason a version section cannot be published. Unreleased is +# a work-in-progress template, so its headings may deliberately be empty. +# A printed problem returns 1; silence returns 0. +changelog_section_problem() { + local file="$1" ver="$2" notes problem + + if ! awk -v ver="$ver" '/^## / && $2 == ver { found = 1; exit } END { exit !found }' "$file"; then + printf "no section for '%s'\n" "$ver" + return 1 + fi + + [ "$ver" = "Unreleased" ] && return 0 + + notes="$(changelog_section "$file" "$ver")" + if ! printf '%s\n' "$notes" | awk '/^[[:space:]]*[-*][[:space:]]/ { found = 1; exit } END { exit !found }'; then + printf "section '%s' has no entries — a heading is not an entry\n" "$ver" + return 1 + fi + + problem="$( + printf '%s\n' "$notes" | awk ' + /^### / { + if (heading != "" && !entry) { + reported = 1 + print heading + exit + } + heading = $0 + entry = 0 + next + } + heading != "" && /^[[:space:]]*[-*][[:space:]]/ { entry = 1 } + END { + if (!reported && heading != "" && !entry) print heading + } + ' + )" + if [ -n "$problem" ]; then + printf "section '%s' has an empty heading: '%s'\n" "$ver" "$problem" + return 1 + fi +} diff --git a/test/changelog-armed.test.sh b/test/changelog-armed.test.sh index 2f9dcac..b495528 100644 --- a/test/changelog-armed.test.sh +++ b/test/changelog-armed.test.sh @@ -53,6 +53,25 @@ tree dev-armed 1.2.4-dev <<'EOF' EOF check "-dev + Unreleased on top passes" 0 "agrees" in_tree dev-armed +tree dev-seeded 1.2.4-dev <<'EOF' +# Changelog + +## Unreleased + +### Added + +### Changed + +### Fixed + +## 1.2.3 — 2026-07-20 + +### Fixed + +- The shipped entry. +EOF +check "-dev + seeded empty Unreleased headings passes" 0 "agrees" in_tree dev-seeded + tree dev-stamped 1.2.4-dev <<'EOF' # Changelog @@ -90,6 +109,34 @@ tree bare-stamped 1.2.3 <<'EOF' EOF check "bare + own stamped section on top passes" 0 "agrees" in_tree bare-stamped +tree bare-dangling-heading 1.2.3 <<'EOF' +# Changelog + +## 1.2.3 — 2026-07-20 + +### Added +EOF +check "bare + dangling heading fails with the heading diagnosis" 1 \ + "section '1.2.3' has no entries — a heading is not an entry" \ + in_tree bare-dangling-heading +check "bare + dangling heading keeps the half-ceremony remedy" 1 \ + "HALF-DONE ceremony" in_tree bare-dangling-heading + +tree bare-partly-dangling 1.2.3 <<'EOF' +# Changelog + +## 1.2.3 — 2026-07-20 + +### Added + +### Fixed + +- Fixed entry. +EOF +check "bare + one empty grouped heading names the first empty heading" 1 \ + "section '1.2.3' has an empty heading: '### Added'" \ + in_tree bare-partly-dangling + tree bare-empty-stamp 1.2.3 <<'EOF' # Changelog diff --git a/test/changelog.test.sh b/test/changelog.test.sh index 94cb0bc..99ededb 100755 --- a/test/changelog.test.sh +++ b/test/changelog.test.sh @@ -57,11 +57,101 @@ check "date-less version heading parses" 0 "" assert_section 0.4.0 '- A date-les check "empty stamped section returns empty output" 0 "" assert_section 0.5.0 "" check "missing section returns empty output" 0 "" assert_section 9.9.9 "" +PROBLEM_FIXTURE="$TMP/CHANGELOG.problems.md" +assert_problem() { + local version="$1" expected_status="$2" expected="$3" + check "predicate: $version / $expected" "$expected_status" "$expected" \ + changelog_section_problem "$PROBLEM_FIXTURE" "$version" +} + +cat >"$PROBLEM_FIXTURE" <<'EOF' +# Changelog + +## Unreleased + +### Added + +### Changed + +### Fixed + +## 1.0.0 + +- Flat dash entry. + +## 1.1.0 + +* Flat star entry. + +## 1.2.0 + +### Fixed + +- Fixed entry. + +## 1.3.0 + +### Added + +- Added entry. + +### Changed + +* Changed entry. + +### Fixed + +- Fixed entry. + +## 1.4.0 + +## 1.5.0 + +### Added + +## 1.6.0 + +### Added + +### Fixed + +- Fixed entry. +EOF + +assert_problem Unreleased 0 "" +assert_problem 1.0.0 0 "" +assert_problem 1.1.0 0 "" +assert_problem 1.2.0 0 "" +assert_problem 1.3.0 0 "" +assert_problem 1.4.0 1 "section '1.4.0' has no entries — a heading is not an entry" +assert_problem 1.5.0 1 "section '1.5.0' has no entries — a heading is not an entry" +assert_problem 1.6.0 1 "section '1.6.0' has an empty heading: '### Added'" +assert_problem 9.9.9 1 "no section for '9.9.9'" + +MISSING_UNRELEASED_FIXTURE="$TMP/CHANGELOG.missing-unreleased.md" +cat >"$MISSING_UNRELEASED_FIXTURE" <<'EOF' +# Changelog + +## 1.0.0 + +- Released entry. +EOF +check "predicate: absent Unreleased still refuses" 1 "no section for 'Unreleased'" \ + changelog_section_problem "$MISSING_UNRELEASED_FIXTURE" Unreleased + 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 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" +check "wrapper names the first dangling heading" 1 \ + "section '1.6.0' has an empty heading: '### Added'" \ + "$WRAPPER" 1.6.0 "$PROBLEM_FIXTURE" +check "wrapper prints seeded empty Unreleased without refusing" 0 "### Added" \ + "$WRAPPER" Unreleased "$PROBLEM_FIXTURE" check "wrapper requires a version" 2 "usage:" "$WRAPPER" check "wrapper refuses a missing file" 1 "no such file" "$WRAPPER" 1.0.0 "$TMP/missing.md" diff --git a/test/release-chain.test.sh b/test/release-chain.test.sh index 5973353..a83348b 100644 --- a/test/release-chain.test.sh +++ b/test/release-chain.test.sh @@ -89,11 +89,11 @@ chain() { *ceremony=yes*) # shellcheck source=lib/changelog.sh . "$ROOT/lib/changelog.sh" - notes="$(changelog_section CHANGELOG.md "$ver")" - if [ -z "$notes" ]; then - echo "chain: the changelog section for $ver is empty" >&2 + diagnosis="$(changelog_section_problem CHANGELOG.md "$ver")" || { + printf 'chain: %s\n' "$diagnosis" >&2 exit 1 - fi + } + notes="$(changelog_section CHANGELOG.md "$ver")" printf 'notes: %s\n' "$notes" ;; esac @@ -105,6 +105,31 @@ check "the ceremony merge decides ceremony=yes" 0 "ceremony=yes" \ check "the notes are the stamped section's prose" 0 \ "notes: - The entry this release ships." chain "$MERGE_SHA" "$BASE_SHA" +# The same ceremony facts with an entry-less stamped section must stop at the +# notes door, before any tag or release mutation could run. +git -C "$TMP/repo" reset -q --hard "$BASE_SHA" +printf '0.7.0\n' >"$TMP/repo/VERSION" +cat >"$TMP/repo/CHANGELOG.md" <<'EOF' +# Changelog + +## Unreleased + +## 0.7.0 — 2026-07-21 + +### Added + +## 0.6.8 — 2026-07-01 + +- An older entry. +EOF +git -C "$TMP/repo" add VERSION CHANGELOG.md +git -C "$TMP/repo" commit -qm "release: entry-less 0.7.0" +EMPTY_MERGE_SHA="$(git -C "$TMP/repo" rev-parse HEAD)" +check "the notes door refuses an entry-less stamped section" 1 \ + "section '0.7.0' has no entries" chain "$EMPTY_MERGE_SHA" "$BASE_SHA" + +git -C "$TMP/repo" reset -q --hard "$MERGE_SHA" + # The same chain on an ordinary merge: -dev, unchanged — a green NOTICE # no-op that never consults the API (the stub would refuse a release view). printf 'ordinary work\n' >"$TMP/repo/notes.txt"