From ef658f52a453d3b2e1a61b29b096542ddcdffc91 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl <304681515+codex-bot-andresmgsl@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:12:52 +0000 Subject: [PATCH 1/3] feat: centralize changelog shape validation --- lib/changelog.sh | 78 +++++++++++++++++++++++++++++++++--------- test/changelog.test.sh | 59 ++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 17 deletions(-) diff --git a/lib/changelog.sh b/lib/changelog.sh index df34832..a484857 100644 --- a/lib/changelog.sh +++ b/lib/changelog.sh @@ -149,24 +149,17 @@ changelog_fragment_problem() { fi } -# changelog_assemble +# changelog_shape_problem # -# Print the assembled section body — no '## ' line; that heading belongs to -# the caller — for every fragment in changelog_fragments order. Assumes each -# fragment already passed changelog_fragment_problem; the one property only -# the whole set can show is shape: a repo is grouped or flat, never both -# (#112 D4), because merging the shapes would silently strand ungrouped -# bullets, so a mix prints a diagnosis naming the offending fragments and -# returns 1. Group order is canonical (#112 D5): Added, Changed, Fixed, -# Removed, Deprecated, Security, then any other group in first-seen order — -# appended, never dropped. Inside a group, fragment order is preserved, and -# a bullet's continuation lines travel with it verbatim: entries in this -# family wrap, and reflowing someone's prose is not this tool's business. -# An empty directory prints nothing and succeeds; refusing an empty release -# is the caller's stance, not this function's. -changelog_assemble() { - local dir="$1" nl=$'\n' - local fragments f grouped_in="" ungrouped_in="" chunk g seen="" ordered="" body first=1 +# Print the first reason a fragment set cannot publish and return 1; silence +# returns 0. Shape is a set-level property (#157 D3), so this is the one +# definition shared by the PR-time guard and the release-time assembler: +# fragments may not mix grouped headings with ungrouped bullets, and a +# non-empty set must match the newest published section when one exists. +changelog_shape_problem() { + local changelog="$1" dir="$2" + local fragments f grouped_in="" ungrouped_in="" published="" published_body="" + fragments="$(changelog_fragments "$dir")" [ -n "$fragments" ] || return 0 @@ -191,6 +184,57 @@ changelog_assemble() { return 1 fi + if [ -f "$changelog" ]; then + published="$(awk '$1 == "##" && $2 != "Unreleased" { print $2; exit }' "$changelog")" + fi + [ -n "$published" ] || return 0 + + published_body="$(changelog_section "$changelog" "$published")" + if printf '%s\n' "$published_body" | grep -q '^### '; then + if [ -n "$ungrouped_in" ]; then + printf "fragment '%s' is flat but newest published section '%s' in '%s' is grouped — a repo is one shape or the other\n" \ + "$ungrouped_in" "$published" "$changelog" + return 1 + fi + elif [ -n "$grouped_in" ]; then + printf "fragment '%s' is grouped but newest published section '%s' in '%s' is flat — a repo is one shape or the other\n" \ + "$grouped_in" "$published" "$changelog" + return 1 + fi +} + +# changelog_assemble +# +# Print the assembled section body — no '## ' line; that heading belongs to +# the caller — for every fragment in changelog_fragments order. Assumes each +# fragment already passed changelog_fragment_problem; the one property only +# the whole set can show is shape: a repo is grouped or flat, never both +# (#112 D4), because merging the shapes would silently strand ungrouped +# bullets, so a mix prints a diagnosis naming the offending fragments and +# returns 1. Group order is canonical (#112 D5): Added, Changed, Fixed, +# Removed, Deprecated, Security, then any other group in first-seen order — +# appended, never dropped. Inside a group, fragment order is preserved, and +# a bullet's continuation lines travel with it verbatim: entries in this +# family wrap, and reflowing someone's prose is not this tool's business. +# An empty directory prints nothing and succeeds; refusing an empty release +# is the caller's stance, not this function's. +changelog_assemble() { + local dir="$1" nl=$'\n' + local fragments f grouped_in="" chunk g seen="" ordered="" body first=1 diagnosis + fragments="$(changelog_fragments "$dir")" + [ -n "$fragments" ] || return 0 + + if ! diagnosis="$(changelog_shape_problem "" "$dir")"; then + printf '%s\n' "$diagnosis" + return 1 + fi + + grouped_in="$(printf '%s\n' "$fragments" | while IFS= read -r f; do + if grep -q '^### ' "$f"; then + printf '%s\n' "$f" + break + fi + done)" if [ -z "$grouped_in" ]; then while IFS= read -r f; do chunk="$(awk 'body || !/^[[:space:]]*$/ { body = 1; print }' "$f")" diff --git a/test/changelog.test.sh b/test/changelog.test.sh index c9d531e..8754407 100755 --- a/test/changelog.test.sh +++ b/test/changelog.test.sh @@ -338,4 +338,63 @@ check "assemble: one fragment mixing both shapes is refused, file named" 1 \ "'$AX/7.md' mixes grouped headings and ungrouped bullets" \ changelog_assemble "$AX" +# --- the fragment-set shape predicate (#159) --------------------------------- + +SHAPE_CHANGELOG="$TMP/CHANGELOG.shape.md" +SHAPE_DIR="$TMP/shape-fragments" +mkdir -p "$SHAPE_DIR" + +cat >"$SHAPE_CHANGELOG" <<'EOF' +# Changelog + +## 2.0.0 — 2026-07-24 + +- Newest section is flat. + +## 1.0.0 — 2026-07-01 + +### Fixed + +- Older section is grouped. +EOF +printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md" +check "shape: flat set matches newest flat published section" 0 "" \ + changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" + +cat >"$SHAPE_DIR/1.md" <<'EOF' +### Fixed + +- Grouped fragment. +EOF +check "shape: grouped set names its conflict with newest flat published section" 1 \ + "fragment '$SHAPE_DIR/1.md' is grouped but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is flat" \ + changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" + +cat >"$SHAPE_CHANGELOG" <<'EOF' +# Changelog + +## 2.0.0 — 2026-07-24 + +### Fixed + +- Newest section is grouped. +EOF +printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md" +check "shape: flat set names its conflict with newest grouped published section" 1 \ + "fragment '$SHAPE_DIR/1.md' is flat but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is grouped" \ + changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" + +cat >"$SHAPE_DIR/1.md" <<'EOF' +### Fixed + +- Grouped fragment. +EOF +check "shape: grouped set matches newest grouped published section" 0 "" \ + changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" +check "shape: consistent set with no published section passes" 0 "" \ + changelog_shape_problem "$TMP/no-such-changelog" "$SHAPE_DIR" +rm "$SHAPE_DIR/1.md" +check "shape: empty fragment set makes the anchor rule vacuous" 0 "" \ + changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" + summary From fdf544b39041853da69731329fedcce0d3c6d937 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl <304681515+codex-bot-andresmgsl@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:14:35 +0000 Subject: [PATCH 2/3] feat: enforce changelog shape in guard and assembler --- actions/changelog-armed/changelog-armed.sh | 5 ++ bin/changelog-assemble | 4 ++ test/changelog-armed.test.sh | 60 ++++++++++++++++++++++ test/changelog-assemble.test.sh | 38 ++++++++++++-- 4 files changed, 102 insertions(+), 5 deletions(-) diff --git a/actions/changelog-armed/changelog-armed.sh b/actions/changelog-armed/changelog-armed.sh index 0e4b0ba..258bb45 100644 --- a/actions/changelog-armed/changelog-armed.sh +++ b/actions/changelog-armed/changelog-armed.sh @@ -96,6 +96,11 @@ if [ -d "$fragments_dir" ]; then fi done <<<"$fragments" + if ! diagnosis="$(changelog_shape_problem "$changelog" "$fragments_dir")"; then + printf 'changelog-armed: %s\n' "$diagnosis" >&2 + exit 1 + fi + if version_is_dev "$ver"; then echo "changelog-armed: version '$ver' agrees with fragment mode ($fragments_dir)" exit 0 diff --git a/bin/changelog-assemble b/bin/changelog-assemble index 7f09db8..746f085 100755 --- a/bin/changelog-assemble +++ b/bin/changelog-assemble @@ -76,6 +76,10 @@ done fragments="$(changelog_fragments "$dir")" [ -n "$fragments" ] || refuse "zero fragments in '$dir' — a release publishes prose; refusing to publish an empty release" +if ! diagnosis="$(changelog_shape_problem "$changelog" "$dir")"; then + refuse "$diagnosis" +fi + if ! body="$(changelog_assemble "$dir")"; then refuse "$body" fi diff --git a/test/changelog-armed.test.sh b/test/changelog-armed.test.sh index 2fd7320..07124a2 100644 --- a/test/changelog-armed.test.sh +++ b/test/changelog-armed.test.sh @@ -296,6 +296,66 @@ EOF check "fragment -dev + well-formed grouped fragment passes" 0 "fragment mode" \ in_tree fragments-dev-grouped +fragment_tree fragments-dev-mixed 1.2.4-dev <<'EOF' +# Changelog + +## 1.2.3 — 2026-07-20 + +- The shipped entry. +EOF +printf '%s\n' "- Flat fragment." >"$TMP/fragments-dev-mixed/changelog.d/114.md" +cat >"$TMP/fragments-dev-mixed/changelog.d/115.md" <<'EOF' +### Fixed + +- Grouped fragment. +EOF +check "fragment mode refuses mixed shapes with the shared assembler diagnosis" 1 \ + "fragment 'changelog.d/115.md' is grouped but fragment 'changelog.d/114.md' is not" \ + in_tree fragments-dev-mixed + +fragment_tree fragments-dev-all-grouped-over-flat 1.2.4-dev <<'EOF' +# Changelog + +## 1.2.3 — 2026-07-20 + +- The shipped entry. +EOF +cat >"$TMP/fragments-dev-all-grouped-over-flat/changelog.d/115.md" <<'EOF' +### Fixed + +- Grouped fragment. +EOF +check "fragment mode refuses an all-grouped set over a flat published section" 1 \ + "changelog.d/115.md' is grouped but newest published section '1.2.3'" \ + in_tree fragments-dev-all-grouped-over-flat + +fragment_tree fragments-dev-flat-over-grouped 1.2.4-dev <<'EOF' +# Changelog + +## 1.2.3 — 2026-07-20 + +### Fixed + +- The shipped entry. +EOF +printf '%s\n' "- Flat fragment." >"$TMP/fragments-dev-flat-over-grouped/changelog.d/115.md" +check "fragment mode refuses a flat set over a grouped published section" 1 \ + "changelog.d/115.md' is flat but newest published section '1.2.3'" \ + in_tree fragments-dev-flat-over-grouped + +fragment_tree fragments-dev-no-published 1.2.4-dev <<'EOF' +# Changelog + +Preamble only. +EOF +cat >"$TMP/fragments-dev-no-published/changelog.d/115.md" <<'EOF' +### Fixed + +- Grouped fragment. +EOF +check "fragment mode accepts a consistent set with no published section" 0 \ + "fragment mode" in_tree fragments-dev-no-published + fragment_tree fragments-unreleased 1.2.4-dev <<'EOF' # Changelog diff --git a/test/changelog-assemble.test.sh b/test/changelog-assemble.test.sh index c21fc1a..34f625b 100644 --- a/test/changelog-assemble.test.sh +++ b/test/changelog-assemble.test.sh @@ -99,8 +99,16 @@ check "flat: numeric-descending order (10.md before 9.md), cross-repo name besid # --- grouped write: canonical order, unnamed group appended ------------------ -tree grouped < Date: Fri, 24 Jul 2026 16:17:02 +0000 Subject: [PATCH 3/3] test: cover grouped anchored release replay --- changelog.d/159.md | 1 + test/changelog-assembled.test.sh | 1 + 2 files changed, 2 insertions(+) create mode 100644 changelog.d/159.md diff --git a/changelog.d/159.md b/changelog.d/159.md new file mode 100644 index 0000000..2212ff8 --- /dev/null +++ b/changelog.d/159.md @@ -0,0 +1 @@ +- Make `changelog-armed` reject fragment shape drift on the PR that introduces it. diff --git a/test/changelog-assembled.test.sh b/test/changelog-assembled.test.sh index aae4cb4..d43e18b 100644 --- a/test/changelog-assembled.test.sh +++ b/test/changelog-assembled.test.sh @@ -85,6 +85,7 @@ check "faithful flat ceremony: the section is byte-for-byte the assembly" 0 \ "byte-for-byte" run faithful-flat base seed_flat faithful-grouped +sed -i '/^- The shipped entry/i ### Fixed\\\n' "$TMP/faithful-grouped/CHANGELOG.md" printf -- '### Fixed\n\n- Fixed twenty-one.\n' >"$TMP/faithful-grouped/changelog.d/21.md" printf -- '### Added\n\n- Added twenty.\n\n### Docs\n\n- Docs twenty.\n' >"$TMP/faithful-grouped/changelog.d/20.md" rm "$TMP/faithful-grouped/changelog.d/12.md" "$TMP/faithful-grouped/changelog.d/9.md"