diff --git a/CHANGELOG.md b/CHANGELOG.md index d8760aa..45b2f7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). - Changelog publication — count entries instead of bytes, refuse dangling grouped headings, and seed grouped re-arms with Added/Changed/Fixed (#98). - `labels-reconcile` — grant callers private-repo check reads and warn when an entire PR sweep is blind (#95). diff --git a/test/changelog-assemble.test.sh b/test/changelog-assemble.test.sh new file mode 100644 index 0000000..c21fc1a --- /dev/null +++ b/test/changelog-assemble.test.sh @@ -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 — 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 — 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 <"$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 <"$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 <"$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