Merge pull request #99 from codex-bot-andresmgsl/build/98-publishable-changelog-sections

feat: validate publishable changelog sections
This commit is contained in:
Daniel Marin 2026-07-24 07:59:02 +01:00 committed by GitHub
commit ad04eaf990
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 291 additions and 27 deletions

View file

@ -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 }}

View file

@ -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 —

View file

@ -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.**

View file

@ -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).

View file

@ -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

View file

@ -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

View file

@ -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 <<EOF
changelog-armed: the version is '$ver' but $changelog has no non-empty
section for '$ver'. The top section is:
@ -150,6 +150,8 @@ changelog-armed: the version is '$ver' but $changelog has no non-empty
The fix is the ceremony's first edit: stamp '## Unreleased' into
'## $ver — DATE', then put an empty '## Unreleased' back above it.
$diagnosis
EOF
exit 1
fi

View file

@ -16,9 +16,10 @@ fi
exit 1
}
notes="$(changelog_section "$changelog" "$ver")"
[ -n "$notes" ] || {
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
printf 'changelog-section: %s\n' "$diagnosis" >&2
exit 1
}
fi
notes="$(changelog_section "$changelog" "$ver")"
printf '%s\n' "$notes"

View file

@ -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

View file

@ -20,3 +20,48 @@ changelog_section() {
found { body = 1; print }
' "$1"
}
# changelog_section_problem <file> <version>
#
# 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
}

View file

@ -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

View file

@ -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"

View file

@ -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"