Merge pull request #120 from claude-bot-andresmgsl/build/114-changelog-assemble
lib/changelog.sh + bin/changelog-assemble — read the fragments, assemble one section, consume them
This commit is contained in:
commit
6ec9aa24fa
5 changed files with 825 additions and 0 deletions
|
|
@ -6,6 +6,7 @@ so entries say what changed, cite the issue, and stop.
|
|||
|
||||
## Unreleased
|
||||
|
||||
- `lib/changelog.sh` + `bin/changelog-assemble` — read the `changelog.d/` fragments, assemble one release section (canonical group order, one shape per repo), and consume exactly what was published (#114).
|
||||
- BUILDER.md — the handed-off PR is the parked claim's fourth shape, its handoff is its declaration, and shape 2 covers the round awaiting its first verdicts (#109).
|
||||
- `labels-reconcile` — a degraded mergeability/checks read now logs gh's actual stderr (collapsed, bounded) beside the byte-identical counted line, and the blind-sweep warning leads with the observed reason instead of asserting the permissions cause (#101).
|
||||
- Changelog publication — count entries instead of bytes, refuse dangling grouped headings, and seed grouped re-arms with Added/Changed/Fixed (#98).
|
||||
|
|
|
|||
121
bin/changelog-assemble
Executable file
121
bin/changelog-assemble
Executable 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
|
||||
169
lib/changelog.sh
169
lib/changelog.sh
|
|
@ -65,3 +65,172 @@ changelog_section_problem() {
|
|||
return 1
|
||||
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
|
||||
}
|
||||
|
|
|
|||
364
test/changelog-assemble.test.sh
Normal file
364
test/changelog-assemble.test.sh
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
#!/usr/bin/env bash
|
||||
# Contract tests for bin/changelog-assemble (issue #114). Constructed
|
||||
# fixture trees, no git repos — the same discipline as
|
||||
# test/changelog-armed.test.sh. set -u, not -e: failing commands are
|
||||
# behavior for the harness to inspect.
|
||||
set -u
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=test/harness.sh
|
||||
. "$ROOT/test/harness.sh"
|
||||
# shellcheck source=lib/changelog.sh
|
||||
. "$ROOT/lib/changelog.sh"
|
||||
|
||||
TOOL="$ROOT/bin/changelog-assemble"
|
||||
SECTION="$ROOT/bin/changelog-section"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# tree <name> — a fixture tree with changelog.d/ and its README marker;
|
||||
# the changelog body arrives on stdin.
|
||||
tree() {
|
||||
mkdir -p "$TMP/$1/changelog.d"
|
||||
printf 'Machine-assembled; see heavy-duty/ceremony#112.\n' >"$TMP/$1/changelog.d/README.md"
|
||||
cat >"$TMP/$1/CHANGELOG.md"
|
||||
}
|
||||
|
||||
# frag <tree> <name> — a fragment; body on stdin.
|
||||
frag() {
|
||||
cat >"$TMP/$1/changelog.d/$2"
|
||||
}
|
||||
|
||||
# The tool reads the consumer's tree at its working directory, so every
|
||||
# case runs from inside a constructed fixture tree.
|
||||
in_tree() {
|
||||
local dir="$1"
|
||||
shift
|
||||
(cd "$TMP/$dir" && "$TOOL" "$@")
|
||||
}
|
||||
|
||||
assert_file() {
|
||||
local file="$1" expected="$2" actual
|
||||
actual="$(cat "$file")"
|
||||
[ "$actual" = "$expected" ] || {
|
||||
printf 'wanted:\n%s\ngot:\n%s\n' "$expected" "$actual"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
BASE_CHANGELOG=$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.'
|
||||
|
||||
# --- flat write: exact bytes, exact deletions --------------------------------
|
||||
|
||||
tree flat-one <<EOF
|
||||
$BASE_CHANGELOG
|
||||
EOF
|
||||
frag flat-one 12.md <<'EOF'
|
||||
- Twelve landed.
|
||||
EOF
|
||||
check "flat: one fragment assembles and stamps" 0 "consumed 1 fragment" \
|
||||
in_tree flat-one 0.2.0 2026-07-24
|
||||
check "flat: preamble and shipped section stay byte-identical around the insert" 0 "" \
|
||||
assert_file "$TMP/flat-one/CHANGELOG.md" \
|
||||
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n- Twelve landed.\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.'
|
||||
check "flat: the consumed fragment is deleted" 1 "" \
|
||||
test -e "$TMP/flat-one/changelog.d/12.md"
|
||||
check "flat: README.md survives consumption" 0 "" \
|
||||
test -e "$TMP/flat-one/changelog.d/README.md"
|
||||
|
||||
# --- publication order: numeric, newest first, cross-repo beside local -------
|
||||
|
||||
tree flat-many <<EOF
|
||||
$BASE_CHANGELOG
|
||||
EOF
|
||||
frag flat-many 2.md <<'EOF'
|
||||
- Two.
|
||||
EOF
|
||||
frag flat-many 9.md <<'EOF'
|
||||
- Nine.
|
||||
EOF
|
||||
frag flat-many 10.md <<'EOF'
|
||||
- Ten.
|
||||
EOF
|
||||
frag flat-many ceremony-14.md <<'EOF'
|
||||
- Fourteen crossed over — naïve reflows would mangle this café's
|
||||
continuation line, so it must survive verbatim.
|
||||
EOF
|
||||
|
||||
assert_check() {
|
||||
local dir="$1" expected="$2" actual
|
||||
actual="$(in_tree "$dir" 0.2.0 2026-07-24 --check)"
|
||||
[ "$actual" = "$expected" ] || {
|
||||
printf 'wanted:\n%s\ngot:\n%s\n' "$expected" "$actual"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
check "flat: numeric-descending order (10.md before 9.md), cross-repo name beside local" 0 "" \
|
||||
assert_check flat-many $'- Fourteen crossed over — naïve reflows would mangle this café'"'"$'s\n continuation line, so it must survive verbatim.\n- Ten.\n- Nine.\n- Two.'
|
||||
|
||||
# --- grouped write: canonical order, unnamed group appended ------------------
|
||||
|
||||
tree grouped <<EOF
|
||||
$BASE_CHANGELOG
|
||||
EOF
|
||||
frag grouped 21.md <<'EOF'
|
||||
### Fixed
|
||||
|
||||
- Fixed twenty-one.
|
||||
EOF
|
||||
frag grouped 20.md <<'EOF'
|
||||
### Added
|
||||
|
||||
- Added twenty.
|
||||
- Added twenty, second bullet.
|
||||
|
||||
### Docs
|
||||
|
||||
- Docs twenty.
|
||||
EOF
|
||||
frag grouped 19.md <<'EOF'
|
||||
### Security
|
||||
|
||||
- Security nineteen.
|
||||
|
||||
### Added
|
||||
|
||||
- Added nineteen.
|
||||
EOF
|
||||
GROUPED_BODY=$'### Added\n\n- Added twenty.\n- Added twenty, second bullet.\n- Added nineteen.\n\n### Fixed\n\n- Fixed twenty-one.\n\n### Security\n\n- Security nineteen.\n\n### Docs\n\n- Docs twenty.'
|
||||
check "grouped: --check shows canonical order, multi-bullet group, unnamed group last" 0 "" \
|
||||
assert_check grouped "$GROUPED_BODY"
|
||||
check "grouped: write mode assembles the same section" 0 "consumed 3 fragment" \
|
||||
in_tree grouped 0.2.0 2026-07-24
|
||||
check "grouped: the written file is exact" 0 "" \
|
||||
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.'
|
||||
|
||||
# --- a changelog holding only its preamble -----------------------------------
|
||||
|
||||
tree preamble-only <<'EOF'
|
||||
# Changelog
|
||||
|
||||
Only preamble so far.
|
||||
EOF
|
||||
frag preamble-only 1.md <<'EOF'
|
||||
- The first entry ever.
|
||||
EOF
|
||||
check "a changelog with no section yet gets the section after the preamble" 0 "" \
|
||||
in_tree preamble-only 0.1.0 2026-07-24
|
||||
check "preamble-only write is exact" 0 "" \
|
||||
assert_file "$TMP/preamble-only/CHANGELOG.md" \
|
||||
$'# Changelog\n\nOnly preamble so far.\n\n## 0.1.0 — 2026-07-24\n\n- The first entry ever.'
|
||||
|
||||
# --- --check is provably read-only -------------------------------------------
|
||||
|
||||
tree check-readonly <<EOF
|
||||
$BASE_CHANGELOG
|
||||
EOF
|
||||
frag check-readonly 5.md <<'EOF'
|
||||
- Five.
|
||||
EOF
|
||||
cp -R "$TMP/check-readonly" "$TMP/check-readonly.before"
|
||||
check "--check prints the assembled body" 0 "Five." \
|
||||
in_tree check-readonly 0.2.0 2026-07-24 --check
|
||||
check "--check is read-only: the tree is byte-identical before and after" 0 "" \
|
||||
diff -r "$TMP/check-readonly.before" "$TMP/check-readonly"
|
||||
|
||||
# --- the date defaults to today (UTC) ----------------------------------------
|
||||
|
||||
check "date defaults without an argument" 0 "consumed 1 fragment" \
|
||||
in_tree check-readonly 0.2.0
|
||||
check "the defaulted stamp is a UTC date" 0 "" \
|
||||
grep -qE '^## 0\.2\.0 — [0-9]{4}-[0-9]{2}-[0-9]{2}$' "$TMP/check-readonly/CHANGELOG.md"
|
||||
|
||||
# --- --changelog and --dir override the defaults -----------------------------
|
||||
|
||||
mkdir -p "$TMP/flagged/frags"
|
||||
printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/flagged/NOTES.md"
|
||||
printf -- '- Flagged entry.\n' >"$TMP/flagged/frags/2.md"
|
||||
check "--changelog and --dir override the defaults" 0 "" \
|
||||
"$TOOL" 0.2.0 2026-07-24 --changelog "$TMP/flagged/NOTES.md" --dir "$TMP/flagged/frags"
|
||||
check "the flag-driven write landed in the named changelog" 0 "" \
|
||||
grep -qF -- "- Flagged entry." "$TMP/flagged/NOTES.md"
|
||||
|
||||
# --- refusals: each names the file responsible -------------------------------
|
||||
|
||||
tree empty-frags <<EOF
|
||||
$BASE_CHANGELOG
|
||||
EOF
|
||||
rm "$TMP/empty-frags/changelog.d/README.md"
|
||||
check "an empty directory refuses — a release publishes prose" 1 "zero fragments" \
|
||||
in_tree empty-frags 0.2.0
|
||||
check "the empty-directory refusal names the directory" 1 "changelog.d" \
|
||||
in_tree empty-frags 0.2.0
|
||||
|
||||
tree only-readme <<EOF
|
||||
$BASE_CHANGELOG
|
||||
EOF
|
||||
check "a README-only directory refuses with zero fragments" 1 "zero fragments" \
|
||||
in_tree only-readme 0.2.0
|
||||
|
||||
tree no-bullet <<EOF
|
||||
$BASE_CHANGELOG
|
||||
EOF
|
||||
frag no-bullet 3.md <<'EOF'
|
||||
Prose without a bullet is a heading in spirit.
|
||||
EOF
|
||||
check "a fragment with no bullet refuses, file named" 1 \
|
||||
"fragment 'changelog.d/3.md' has no entries" \
|
||||
in_tree no-bullet 0.2.0
|
||||
|
||||
tree dangling <<EOF
|
||||
$BASE_CHANGELOG
|
||||
EOF
|
||||
frag dangling 4.md <<'EOF'
|
||||
### Added
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed entry.
|
||||
EOF
|
||||
check "a dangling grouped heading refuses, file and heading named" 1 \
|
||||
"fragment 'changelog.d/4.md' has an empty heading: '### Added'" \
|
||||
in_tree dangling 0.2.0
|
||||
|
||||
tree smuggled <<EOF
|
||||
$BASE_CHANGELOG
|
||||
EOF
|
||||
frag smuggled 6.md <<'EOF'
|
||||
## 0.2.0 — 2026-07-24
|
||||
|
||||
- An entry under a smuggled heading.
|
||||
EOF
|
||||
check "a fragment carrying a '## ' line refuses, file named" 1 \
|
||||
"fragment 'changelog.d/6.md' carries a '## ' heading" \
|
||||
in_tree smuggled 0.2.0
|
||||
|
||||
tree stray-txt <<EOF
|
||||
$BASE_CHANGELOG
|
||||
EOF
|
||||
frag stray-txt 7.md <<'EOF'
|
||||
- Seven.
|
||||
EOF
|
||||
frag stray-txt notes.txt <<'EOF'
|
||||
A stray scratchpad.
|
||||
EOF
|
||||
check "a stray notes.txt refuses, file named" 1 "notes.txt" \
|
||||
in_tree stray-txt 0.2.0
|
||||
|
||||
tree stray-markdown <<EOF
|
||||
$BASE_CHANGELOG
|
||||
EOF
|
||||
frag stray-markdown 12.markdown <<'EOF'
|
||||
- Wrong extension.
|
||||
EOF
|
||||
check "12.markdown refuses on the name pattern" 1 "12.markdown" \
|
||||
in_tree stray-markdown 0.2.0
|
||||
|
||||
tree stray-case <<EOF
|
||||
$BASE_CHANGELOG
|
||||
EOF
|
||||
frag stray-case Fix-12.md <<'EOF'
|
||||
- Uppercase prefix.
|
||||
EOF
|
||||
check "Fix-12.md refuses on the name pattern" 1 "Fix-12.md" \
|
||||
in_tree stray-case 0.2.0
|
||||
|
||||
tree mixed <<EOF
|
||||
$BASE_CHANGELOG
|
||||
EOF
|
||||
frag mixed 5.md <<'EOF'
|
||||
- Flat five.
|
||||
EOF
|
||||
frag mixed 6.md <<'EOF'
|
||||
### Added
|
||||
|
||||
- Grouped six.
|
||||
EOF
|
||||
check "grouped + flat mixed refuses, both files named" 1 "6.md" \
|
||||
in_tree mixed 0.2.0
|
||||
check "the mixed refusal names the flat side too" 1 "5.md" \
|
||||
in_tree mixed 0.2.0
|
||||
|
||||
tree already <<'EOF'
|
||||
# Changelog
|
||||
|
||||
## 0.2.0 — 2026-07-20
|
||||
|
||||
- Already shipped.
|
||||
EOF
|
||||
frag already 4.md <<'EOF'
|
||||
- A late fragment.
|
||||
EOF
|
||||
check "an already-present section refuses — the ceremony was already run" 1 \
|
||||
"already has a section for '0.2.0'" \
|
||||
in_tree already 0.2.0
|
||||
|
||||
# Whole-version matching, as everywhere in this family: an rc section never
|
||||
# blocks the bare version.
|
||||
tree rc-present <<'EOF'
|
||||
# Changelog
|
||||
|
||||
## 0.2.0-rc1 — 2026-07-15
|
||||
|
||||
- The candidate's entry.
|
||||
EOF
|
||||
frag rc-present 8.md <<'EOF'
|
||||
- The real release entry.
|
||||
EOF
|
||||
check "an rc section does not block assembling the bare version" 0 "" \
|
||||
in_tree rc-present 0.2.0 2026-07-24
|
||||
|
||||
mkdir -p "$TMP/no-changelog/changelog.d"
|
||||
printf -- '- Entry.\n' >"$TMP/no-changelog/changelog.d/2.md"
|
||||
check "a missing changelog refuses" 1 "no such file" \
|
||||
in_tree no-changelog 0.2.0
|
||||
|
||||
# --- usage errors exit 2 -----------------------------------------------------
|
||||
|
||||
check "no arguments is a usage error" 2 "usage:" in_tree flat-one
|
||||
check "an unknown flag is a usage error" 2 "usage:" in_tree flat-one 0.2.0 --frobnicate
|
||||
check "a third positional is a usage error" 2 "usage:" in_tree flat-one 0.2.0 2026-07-24 extra
|
||||
check "--dir without a value is a usage error" 2 "usage:" in_tree flat-one 0.2.0 --dir
|
||||
|
||||
# --- round trip: the publisher and the assembler agree by test ---------------
|
||||
|
||||
tree round-trip <<EOF
|
||||
$BASE_CHANGELOG
|
||||
EOF
|
||||
frag round-trip 30.md <<'EOF'
|
||||
### Added
|
||||
|
||||
- Thirty — wraps onto a
|
||||
continuation line with a naïve café.
|
||||
EOF
|
||||
frag round-trip 29.md <<'EOF'
|
||||
### Fixed
|
||||
|
||||
- Fixed twenty-nine.
|
||||
EOF
|
||||
CHECKED="$(in_tree round-trip 0.2.0 2026-07-24 --check)"
|
||||
check "round trip: write mode succeeds after --check" 0 "" \
|
||||
in_tree round-trip 0.2.0 2026-07-24
|
||||
|
||||
assert_round_trip() {
|
||||
local published
|
||||
published="$( (cd "$TMP/round-trip" && "$SECTION" 0.2.0) )"
|
||||
[ "$published" = "$CHECKED" ] || {
|
||||
printf -- '--check said:\n%s\nthe publisher said:\n%s\n' "$CHECKED" "$published"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
check "round trip: bin/changelog-section returns exactly the body --check printed" 0 "" \
|
||||
assert_round_trip
|
||||
check "round trip: the assembled section reports no problem" 0 "" \
|
||||
changelog_section_problem "$TMP/round-trip/CHANGELOG.md" 0.2.0
|
||||
|
||||
# --- idempotence: the ceremony cannot run twice ------------------------------
|
||||
|
||||
check "a second write over the consumed directory refuses with zero fragments" 1 \
|
||||
"zero fragments" \
|
||||
in_tree round-trip 0.2.0 2026-07-24
|
||||
|
||||
summary
|
||||
|
|
@ -168,4 +168,174 @@ check "realistic changelog keeps its production heading shape" 0 "A mint records
|
|||
check "realistic adjacent release remains independently extractable" 0 "Merging the release PR" \
|
||||
"$WRAPPER" 0.8.0 "$REALISTIC"
|
||||
|
||||
# --- the fragment reader (#114) ----------------------------------------------
|
||||
|
||||
FRAG="$TMP/frags"
|
||||
mkdir -p "$FRAG"
|
||||
|
||||
check "fragments: absent directory is empty output, not an error" 0 "" \
|
||||
changelog_fragments "$TMP/no-such-dir"
|
||||
check "fragments: fragment-free directory is empty output, not an error" 0 "" \
|
||||
changelog_fragments "$FRAG"
|
||||
|
||||
printf 'marker\n' >"$FRAG/README.md"
|
||||
check "fragments: README.md is the directory marker, never a fragment" 0 "" \
|
||||
changelog_fragments "$FRAG"
|
||||
|
||||
printf -- '- Two.\n' >"$FRAG/2.md"
|
||||
printf -- '- Nine.\n' >"$FRAG/9.md"
|
||||
printf -- '- Ten.\n' >"$FRAG/10.md"
|
||||
printf -- '- Cross.\n' >"$FRAG/ceremony-14.md"
|
||||
printf -- '- Local fourteen.\n' >"$FRAG/14.md"
|
||||
|
||||
assert_fragments_order() {
|
||||
local expected="$1" actual
|
||||
actual="$(changelog_fragments "$FRAG" | awk -F/ '{ print $NF }' | tr '\n' ' ')"
|
||||
actual="${actual% }"
|
||||
[ "$actual" = "$expected" ] || {
|
||||
printf 'wanted: %s\ngot: %s\n' "$expected" "$actual"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
check "fragments: issue number descending (numeric, 10 before 9), filename tie-break" 0 "" \
|
||||
assert_fragments_order "14.md ceremony-14.md 10.md 9.md 2.md"
|
||||
|
||||
# --- the fragment predicate (#114) -------------------------------------------
|
||||
|
||||
PF="$TMP/frag-problems"
|
||||
mkdir -p "$PF"
|
||||
|
||||
printf -- '- Fine.\n' >"$PF/7.md"
|
||||
check "fragment predicate: a flat fragment passes" 0 "" \
|
||||
changelog_fragment_problem "$PF/7.md"
|
||||
|
||||
cat >"$PF/8.md" <<'EOF'
|
||||
### Added
|
||||
|
||||
- Grouped fine.
|
||||
EOF
|
||||
check "fragment predicate: a grouped fragment passes" 0 "" \
|
||||
changelog_fragment_problem "$PF/8.md"
|
||||
|
||||
printf -- '- Cross-repo.\n' >"$PF/ceremony-14.md"
|
||||
check "fragment predicate: a cross-repo name passes" 0 "" \
|
||||
changelog_fragment_problem "$PF/ceremony-14.md"
|
||||
|
||||
printf -- '- Bad name.\n' >"$PF/Fix-12.md"
|
||||
check "fragment predicate: an uppercase prefix is refused, file named" 1 "Fix-12.md" \
|
||||
changelog_fragment_problem "$PF/Fix-12.md"
|
||||
printf -- '- Bad name.\n' >"$PF/notes.txt"
|
||||
check "fragment predicate: a non-.md file is refused, file named" 1 "notes.txt" \
|
||||
changelog_fragment_problem "$PF/notes.txt"
|
||||
printf -- '- Bad name.\n' >"$PF/12.markdown"
|
||||
check "fragment predicate: .markdown is refused, file named" 1 "12.markdown" \
|
||||
changelog_fragment_problem "$PF/12.markdown"
|
||||
printf -- '- No number.\n' >"$PF/notes.md"
|
||||
check "fragment predicate: a name with no trailing issue number is refused" 1 "notes.md" \
|
||||
changelog_fragment_problem "$PF/notes.md"
|
||||
|
||||
cat >"$PF/20.md" <<'EOF'
|
||||
## 1.0.0 — 2026-07-24
|
||||
|
||||
- Smuggled heading.
|
||||
EOF
|
||||
check "fragment predicate: a '## ' line is refused — the heading is the assembler's" 1 \
|
||||
"the section heading is the assembler's to write" \
|
||||
changelog_fragment_problem "$PF/20.md"
|
||||
|
||||
printf '### Added\n' >"$PF/21.md"
|
||||
check "fragment predicate: no bullet anywhere is refused" 1 \
|
||||
"has no entries — a heading is not an entry" \
|
||||
changelog_fragment_problem "$PF/21.md"
|
||||
|
||||
cat >"$PF/22.md" <<'EOF'
|
||||
### Added
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed entry.
|
||||
EOF
|
||||
check "fragment predicate: a dangling grouped heading is refused, heading named" 1 \
|
||||
"has an empty heading: '### Added'" \
|
||||
changelog_fragment_problem "$PF/22.md"
|
||||
|
||||
# --- the assembler (#114) ----------------------------------------------------
|
||||
|
||||
assert_assemble() {
|
||||
local dir="$1" expected="$2" actual
|
||||
actual="$(changelog_assemble "$dir")"
|
||||
[ "$actual" = "$expected" ] || {
|
||||
printf 'wanted:\n%s\ngot:\n%s\n' "$expected" "$actual"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
AF="$TMP/assemble-flat"
|
||||
mkdir -p "$AF"
|
||||
printf 'marker\n' >"$AF/README.md"
|
||||
cat >"$AF/3.md" <<'EOF'
|
||||
- Three — an em dash, and prose that
|
||||
wraps onto a continuation line.
|
||||
EOF
|
||||
printf -- '- Ten.\n- Ten again.\n' >"$AF/10.md"
|
||||
check "assemble: flat fragments, newest issue first, prose verbatim" 0 "" \
|
||||
assert_assemble "$AF" $'- Ten.\n- Ten again.\n- Three — an em dash, and prose that\n wraps onto a continuation line.'
|
||||
|
||||
check "assemble: an empty directory is empty output — refusing is the caller's stance" 0 "" \
|
||||
changelog_assemble "$TMP/no-such-dir"
|
||||
|
||||
AG="$TMP/assemble-grouped"
|
||||
mkdir -p "$AG"
|
||||
cat >"$AG/21.md" <<'EOF'
|
||||
### Fixed
|
||||
|
||||
- Fixed twenty-one.
|
||||
EOF
|
||||
cat >"$AG/20.md" <<'EOF'
|
||||
### Added
|
||||
|
||||
- Added twenty.
|
||||
|
||||
### Docs
|
||||
|
||||
- Docs twenty.
|
||||
EOF
|
||||
cat >"$AG/19.md" <<'EOF'
|
||||
### Security
|
||||
|
||||
- Security nineteen.
|
||||
|
||||
### Added
|
||||
|
||||
- Added nineteen.
|
||||
EOF
|
||||
check "assemble: canonical group order, unnamed group appended, fragment order inside a group" 0 "" \
|
||||
assert_assemble "$AG" $'### Added\n\n- Added twenty.\n- Added nineteen.\n\n### Fixed\n\n- Fixed twenty-one.\n\n### Security\n\n- Security nineteen.\n\n### Docs\n\n- Docs twenty.'
|
||||
|
||||
AM="$TMP/assemble-mixed"
|
||||
mkdir -p "$AM"
|
||||
printf -- '- Flat five.\n' >"$AM/5.md"
|
||||
cat >"$AM/6.md" <<'EOF'
|
||||
### Added
|
||||
|
||||
- Grouped six.
|
||||
EOF
|
||||
check "assemble: mixed shapes refused, grouped side named" 1 "6.md" \
|
||||
changelog_assemble "$AM"
|
||||
check "assemble: mixed shapes refused, flat side named too" 1 "5.md" \
|
||||
changelog_assemble "$AM"
|
||||
|
||||
AX="$TMP/assemble-selfmixed"
|
||||
mkdir -p "$AX"
|
||||
cat >"$AX/7.md" <<'EOF'
|
||||
- Ungrouped lead.
|
||||
|
||||
### Added
|
||||
|
||||
- Grouped follow.
|
||||
EOF
|
||||
check "assemble: one fragment mixing both shapes is refused, file named" 1 \
|
||||
"'$AX/7.md' mixes grouped headings and ungrouped bullets" \
|
||||
changelog_assemble "$AX"
|
||||
|
||||
summary
|
||||
|
|
|
|||
Loading…
Reference in a new issue