diff --git a/bin/changelog-assemble b/bin/changelog-assemble new file mode 100755 index 0000000..7f09db8 --- /dev/null +++ b/bin/changelog-assemble @@ -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 [] [--changelog ] [--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 diff --git a/lib/changelog.sh b/lib/changelog.sh index 4489898..df34832 100644 --- a/lib/changelog.sh +++ b/lib/changelog.sh @@ -65,3 +65,172 @@ changelog_section_problem() { return 1 fi } + +# changelog_fragments +# +# 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 +# +# 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 '.md' or '-.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 .md or -.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 +# +# 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 +}