feat: fragment reader, well-formedness predicate, assembler, and bin/changelog-assemble

lib/changelog.sh gains changelog_fragments (publication order: trailing
issue number descending, filename tie-break), changelog_fragment_problem
(the release-time rules moved onto the PR that writes the fragment, #112
D9), and changelog_assemble (canonical group order per D5, one shape per
repo per D4). bin/changelog-assemble folds changelog.d/ into one release
section, deletes exactly what it consumed, and --check proves the body
without touching the tree.

Part of #112. Closes #114 groundwork; tests follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
claude-bot-andresmgsl 2026-07-24 08:25:44 +00:00
parent 2f58d9bf54
commit fffff75b80
2 changed files with 290 additions and 0 deletions

121
bin/changelog-assemble Executable file
View file

@ -0,0 +1,121 @@
#!/usr/bin/env bash
# Fold the changelog.d/ fragments into one release section (#112, #114).
# Run by hand in the release PR, from a checkout of ceremony at the
# consumer's pin — deliberately not a CI step, because the assembled
# section must land in the PR diff where the panel reads it.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=lib/changelog.sh
source "$ROOT/lib/changelog.sh"
usage() {
echo "usage: changelog-assemble <version> [<date>] [--changelog <file>] [--dir <dir>] [--check]" >&2
exit 2
}
refuse() {
printf 'changelog-assemble: %s\n' "$1" >&2
exit 1
}
ver=""
stamp=""
changelog="CHANGELOG.md"
dir="changelog.d"
checkmode=0
while [ $# -gt 0 ]; do
case "$1" in
--changelog)
[ $# -ge 2 ] || usage
changelog="$2"
shift 2
;;
--dir)
[ $# -ge 2 ] || usage
dir="$2"
shift 2
;;
--check)
checkmode=1
shift
;;
-*)
usage
;;
*)
if [ -z "$ver" ]; then
ver="$1"
elif [ -z "$stamp" ]; then
stamp="$1"
else
usage
fi
shift
;;
esac
done
[ -n "$ver" ] || usage
[ -n "$stamp" ] || stamp="$(date -u +%F)"
[ -f "$changelog" ] || refuse "no such file: $changelog"
# Every entry in the directory must be a publishable fragment. A stray file
# in a machine-assembled directory is a mistake to surface, never to skip —
# except README.md, the directory's marker (#112 D1). This runs before the
# zero-fragments check so a directory holding only 'notes.txt' names the
# stray file instead of claiming emptiness.
for f in "$dir"/*; do
[ -e "$f" ] || continue
[ "${f##*/}" = "README.md" ] && continue
if ! diagnosis="$(changelog_fragment_problem "$f")"; then
refuse "$diagnosis"
fi
done
fragments="$(changelog_fragments "$dir")"
[ -n "$fragments" ] || refuse "zero fragments in '$dir' — a release publishes prose; refusing to publish an empty release"
if ! body="$(changelog_assemble "$dir")"; then
refuse "$body"
fi
# Whole-version match, as everywhere in this family: 0.2.0-rc1 in the
# changelog never blocks assembling 0.2.0.
if awk -v ver="$ver" '/^## / && $2 == ver { found = 1; exit } END { exit !found }' "$changelog"; then
refuse "$changelog already has a section for '$ver' — the ceremony was already run"
fi
if [ "$checkmode" = 1 ]; then
# --check prints the body, not the heading: the body is the invariant a
# caller can compare — #116 checks it against the release PR's stamped
# section, whose date the PR chose, not the day the check runs.
printf '%s\n' "$body"
exit 0
fi
heading="## $ver — $stamp"
lineno="$(grep -n -m1 '^## ' "$changelog" | cut -d: -f1)" || lineno=""
tmp="$(mktemp "$changelog.XXXXXX")"
if [ -n "$lineno" ]; then
{
head -n "$((lineno - 1))" "$changelog"
printf '%s\n\n%s\n\n' "$heading" "$body"
tail -n +"$lineno" "$changelog"
} >"$tmp"
else
# No section yet: the whole file is preamble, and the section goes after it.
{
cat "$changelog"
printf '\n%s\n\n%s\n' "$heading" "$body"
} >"$tmp"
fi
mv "$tmp" "$changelog"
count=0
while IFS= read -r f; do
rm -- "$f"
count=$((count + 1))
done <<<"$fragments"
printf "changelog-assemble: wrote '%s' to %s, consumed %d fragment(s)\n" "$heading" "$changelog" "$count" >&2

View file

@ -65,3 +65,172 @@ changelog_section_problem() {
return 1 return 1
fi fi
} }
# changelog_fragments <dir>
#
# Print fragment paths in publication order, one per line: trailing issue
# number descending — newest issue first, the way every section in this
# family already reads — tie-broken on the filename. Considers *.md only
# and skips README.md, the marker that keeps the directory trackable when
# it holds no fragments (#112 D1). An absent or fragment-free directory
# prints nothing and succeeds: whether "no fragments" is a problem belongs
# to the caller — the assembler refuses an empty release, the arming guard
# is satisfied by the directory existing.
changelog_fragments() {
local dir="$1" f base num
[ -d "$dir" ] || return 0
for f in "$dir"/*.md; do
[ -e "$f" ] || continue
base="${f##*/}"
[ "$base" = "README.md" ] && continue
num="${base%.md}"
num="${num##*[!0-9]}"
[ -n "$num" ] || num=0
printf '%s\t%s\t%s\n' "$num" "$base" "$f"
done | sort -t "$(printf '\t')" -k1,1nr -k2,2 | cut -f3-
}
# changelog_fragment_problem <file>
#
# Print the first reason a fragment cannot publish and return 1; silence
# returns 0. The same contract as changelog_section_problem, moved onto the
# PR that writes the fragment (#112 D9): a fragment is checkable the moment
# it exists, so malformedness fails the PR that wrote it, not the release
# that consumes it. The rules, and the failure each refuses:
# - name '<issue>.md' or '<repo>-<issue>.md': anything else has no
# derivable order, and an invented name is the "two builders, one
# filename" collision the naming scheme exists to avoid (#112 D2);
# - no '## ' line: the section heading is the assembler's to write, and
# a smuggled one would split the published section;
# - at least one bullet: a heading is not an entry — the rule the
# publisher enforces at release time, moved onto the PR;
# - no '### ' heading without a bullet before the next heading or EOF:
# the dangling grouped heading #98 taught us to refuse.
changelog_fragment_problem() {
local file="$1" base problem
base="${file##*/}"
if ! printf '%s\n' "$base" | grep -qE '^([a-z][a-z0-9-]*-)?[0-9]+\.md$'; then
printf "fragment '%s' is not named for its issue — want <issue>.md or <repo>-<issue>.md\n" "$file"
return 1
fi
if grep -q '^## ' "$file"; then
printf "fragment '%s' carries a '## ' heading — the section heading is the assembler's to write\n" "$file"
return 1
fi
if ! grep -qE '^[[:space:]]*[-*][[:space:]]' "$file"; then
printf "fragment '%s' has no entries — a heading is not an entry\n" "$file"
return 1
fi
problem="$(
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
}
' "$file"
)"
if [ -n "$problem" ]; then
printf "fragment '%s' has an empty heading: '%s'\n" "$file" "$problem"
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="" ungrouped_in="" chunk g seen="" ordered="" body first=1
fragments="$(changelog_fragments "$dir")"
[ -n "$fragments" ] || return 0
while IFS= read -r f; do
if [ -z "$grouped_in" ] && grep -q '^### ' "$f"; then
grouped_in="$f"
fi
if [ -z "$ungrouped_in" ] && awk '
/^### / { exit(found ? 0 : 1) }
/^[[:space:]]*[-*][[:space:]]/ { found = 1 }
END { exit(found ? 0 : 1) }' "$f"; then
ungrouped_in="$f"
fi
done <<<"$fragments"
if [ -n "$grouped_in" ] && [ -n "$ungrouped_in" ]; then
if [ "$grouped_in" = "$ungrouped_in" ]; then
printf "fragment '%s' mixes grouped headings and ungrouped bullets — a repo is one shape or the other\n" "$grouped_in"
else
printf "fragment '%s' is grouped but fragment '%s' is not — a repo is one shape or the other\n" "$grouped_in" "$ungrouped_in"
fi
return 1
fi
if [ -z "$grouped_in" ]; then
while IFS= read -r f; do
chunk="$(awk 'body || !/^[[:space:]]*$/ { body = 1; print }' "$f")"
[ -n "$chunk" ] || continue
printf '%s\n' "$chunk"
done <<<"$fragments"
return 0
fi
while IFS= read -r f; do
while IFS= read -r g; do
printf '%s' "$seen" | grep -qFx -- "$g" || seen="$seen$g$nl"
done < <(awk '/^### / { name = substr($0, 5); sub(/[[:space:]]+$/, "", name); print name }' "$f")
done <<<"$fragments"
for g in Added Changed Fixed Removed Deprecated Security; do
printf '%s' "$seen" | grep -qFx -- "$g" && ordered="$ordered$g$nl"
done
while IFS= read -r g; do
[ -n "$g" ] || continue
case "$g" in
Added | Changed | Fixed | Removed | Deprecated | Security) ;;
*) ordered="$ordered$g$nl" ;;
esac
done <<<"$seen"
while IFS= read -r g; do
[ -n "$g" ] || continue
body=""
while IFS= read -r f; do
chunk="$(awk -v want="$g" '
/^### / { name = substr($0, 5); sub(/[[:space:]]+$/, "", name); ingroup = (name == want); next }
ingroup' "$f" | awk 'body || !/^[[:space:]]*$/ { body = 1; print }')"
[ -n "$chunk" ] || continue
body="${body:+$body$nl}$chunk"
done <<<"$fragments"
[ -n "$body" ] || continue
[ "$first" = 1 ] || printf '\n'
printf '### %s\n\n%s\n' "$g" "$body"
first=0
done <<<"$ordered"
return 0
}