Merge pull request #163 from codex-bot-andresmgsl/build/159-changelog-shape-guard

feat: fail shape drift on the introducing PR
This commit is contained in:
Daniel Marin 2026-07-24 17:56:58 +01:00 committed by GitHub
commit a7aedfd081
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 224 additions and 22 deletions

View file

@ -96,6 +96,11 @@ if [ -d "$fragments_dir" ]; then
fi fi
done <<<"$fragments" 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 if version_is_dev "$ver"; then
echo "changelog-armed: version '$ver' agrees with fragment mode ($fragments_dir)" echo "changelog-armed: version '$ver' agrees with fragment mode ($fragments_dir)"
exit 0 exit 0

View file

@ -76,6 +76,10 @@ done
fragments="$(changelog_fragments "$dir")" fragments="$(changelog_fragments "$dir")"
[ -n "$fragments" ] || refuse "zero fragments in '$dir' — a release publishes prose; refusing to publish an empty release" [ -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 if ! body="$(changelog_assemble "$dir")"; then
refuse "$body" refuse "$body"
fi fi

1
changelog.d/159.md Normal file
View file

@ -0,0 +1 @@
- Make `changelog-armed` reject fragment shape drift on the PR that introduces it.

View file

@ -149,24 +149,17 @@ changelog_fragment_problem() {
fi fi
} }
# changelog_assemble <dir> # changelog_shape_problem <changelog> <fragments-dir>
# #
# Print the assembled section body — no '## ' line; that heading belongs to # Print the first reason a fragment set cannot publish and return 1; silence
# the caller — for every fragment in changelog_fragments order. Assumes each # returns 0. Shape is a set-level property (#157 D3), so this is the one
# fragment already passed changelog_fragment_problem; the one property only # definition shared by the PR-time guard and the release-time assembler:
# the whole set can show is shape: a repo is grouped or flat, never both # fragments may not mix grouped headings with ungrouped bullets, and a
# (#112 D4), because merging the shapes would silently strand ungrouped # non-empty set must match the newest published section when one exists.
# bullets, so a mix prints a diagnosis naming the offending fragments and changelog_shape_problem() {
# returns 1. Group order is canonical (#112 D5): Added, Changed, Fixed, local changelog="$1" dir="$2"
# Removed, Deprecated, Security, then any other group in first-seen order — local fragments f grouped_in="" ungrouped_in="" published="" published_body=""
# 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
fragments="$(changelog_fragments "$dir")" fragments="$(changelog_fragments "$dir")"
[ -n "$fragments" ] || return 0 [ -n "$fragments" ] || return 0
@ -191,6 +184,57 @@ changelog_assemble() {
return 1 return 1
fi 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 <dir>
#
# 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 if [ -z "$grouped_in" ]; then
while IFS= read -r f; do while IFS= read -r f; do
chunk="$(awk 'body || !/^[[:space:]]*$/ { body = 1; print }' "$f")" chunk="$(awk 'body || !/^[[:space:]]*$/ { body = 1; print }' "$f")"

View file

@ -296,6 +296,66 @@ EOF
check "fragment -dev + well-formed grouped fragment passes" 0 "fragment mode" \ check "fragment -dev + well-formed grouped fragment passes" 0 "fragment mode" \
in_tree fragments-dev-grouped 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' fragment_tree fragments-unreleased 1.2.4-dev <<'EOF'
# Changelog # Changelog

View file

@ -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 ------------------ # --- grouped write: canonical order, unnamed group appended ------------------
tree grouped <<EOF tree grouped <<'EOF'
$BASE_CHANGELOG # Changelog
Preamble prose belongs to no section.
## 0.1.0 — 2026-07-01
### Fixed
- The shipped entry.
EOF EOF
frag grouped 21.md <<'EOF' frag grouped 21.md <<'EOF'
### Fixed ### Fixed
@ -133,7 +141,7 @@ check "grouped: write mode assembles the same section" 0 "consumed 3 fragment" \
in_tree grouped 0.2.0 2026-07-24 in_tree grouped 0.2.0 2026-07-24
check "grouped: the written file is exact" 0 "" \ check "grouped: the written file is exact" 0 "" \
assert_file "$TMP/grouped/CHANGELOG.md" \ assert_file "$TMP/grouped/CHANGELOG.md" \
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n'"$GROUPED_BODY"$'\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.' $'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n'"$GROUPED_BODY"$'\n\n## 0.1.0 — 2026-07-01\n\n### Fixed\n\n- The shipped entry.'
# --- a changelog holding only its preamble ----------------------------------- # --- a changelog holding only its preamble -----------------------------------
@ -281,6 +289,18 @@ check "grouped + flat mixed refuses, both files named" 1 "6.md" \
check "the mixed refusal names the flat side too" 1 "5.md" \ check "the mixed refusal names the flat side too" 1 "5.md" \
in_tree mixed 0.2.0 in_tree mixed 0.2.0
tree grouped-over-flat <<EOF
$BASE_CHANGELOG
EOF
frag grouped-over-flat 6.md <<'EOF'
### Added
- Grouped six.
EOF
check "an all-grouped set over a flat published section refuses before assembly" 1 \
"fragment 'changelog.d/6.md' is grouped but newest published section '0.1.0'" \
in_tree grouped-over-flat 0.2.0
tree already <<'EOF' tree already <<'EOF'
# Changelog # Changelog
@ -324,8 +344,16 @@ check "--dir without a value is a usage error" 2 "usage:" in_tree flat-one 0.2.0
# --- round trip: the publisher and the assembler agree by test --------------- # --- round trip: the publisher and the assembler agree by test ---------------
tree round-trip <<EOF tree round-trip <<'EOF'
$BASE_CHANGELOG # Changelog
Preamble prose belongs to no section.
## 0.1.0 — 2026-07-01
### Fixed
- The shipped entry.
EOF EOF
frag round-trip 30.md <<'EOF' frag round-trip 30.md <<'EOF'
### Added ### Added

View file

@ -85,6 +85,7 @@ check "faithful flat ceremony: the section is byte-for-byte the assembly" 0 \
"byte-for-byte" run faithful-flat base "byte-for-byte" run faithful-flat base
seed_flat faithful-grouped 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 -- '### 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" 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" rm "$TMP/faithful-grouped/changelog.d/12.md" "$TMP/faithful-grouped/changelog.d/9.md"

View file

@ -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" \ "'$AX/7.md' mixes grouped headings and ungrouped bullets" \
changelog_assemble "$AX" 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 summary