#!/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/flip/changelog.d/shape" frag flip 40.md <<'EOF' ### Added - Forty landed (#40). EOF check "sentinel: the flip release assembles grouped over a flat published section" 0 \ "consumed 1 fragment" in_tree flip 0.2.0 2026-07-24 check "sentinel: the written flip section is exact" 0 "" \ assert_file "$TMP/flip/CHANGELOG.md" \ $'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n### Added\n\n- Forty landed (#40).\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.' check "sentinel: changelog.d/shape survives consumption" 0 "" \ test -e "$TMP/flip/changelog.d/shape" tree flip-flat-frag <"$TMP/flip-flat-frag/changelog.d/shape" frag flip-flat-frag 41.md <<'EOF' - Flat forty-one (#41). EOF check "sentinel: a flat fragment under 'grouped' refuses, sentinel named" 1 \ "changelog.d/shape' declares grouped" in_tree flip-flat-frag 0.2.0 2026-07-24 tree flip-malformed <"$TMP/flip-malformed/changelog.d/shape" frag flip-malformed 42.md <<'EOF' ### Added - Forty-two (#42). EOF check "sentinel: a malformed sentinel refuses, file named" 1 \ "changelog.d/shape' declares neither shape" in_tree flip-malformed 0.2.0 2026-07-24 # --- 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 (#1). 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 (#1).' # --- the fragment predicate at release time (#262) --------------------------- # The cite rule joins changelog_fragment_problem, so it binds both callers: # the arming guard at PR time and this assembler at release time. Asserted # rather than assumed — a release that publishes an uncited entry is the # failure the PR-time guard exists to have already caught. tree uncited-release <"$TMP/flagged/NOTES.md" printf -- '- Flagged entry (#2).\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 (#2)." "$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 <<'EOF' # Changelog Preamble prose belongs to no section. ## 0.1.0 — 2026-07-01 ### Fixed - The shipped entry. EOF frag round-trip 30.md <<'EOF' ### Added - Thirty — wraps onto a continuation line with a naïve café (#30). EOF frag round-trip 29.md <<'EOF' ### Fixed - Fixed twenty-nine (#29). 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