From c33567686ba4bba3beab85f581459fc5d75cef25 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Fri, 24 Jul 2026 09:30:58 +0000 Subject: [PATCH] test: drive changelog-assembled against constructed histories --- test/changelog-assembled.test.sh | 325 +++++++++++++++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 test/changelog-assembled.test.sh diff --git a/test/changelog-assembled.test.sh b/test/changelog-assembled.test.sh new file mode 100644 index 0000000..73b0a64 --- /dev/null +++ b/test/changelog-assembled.test.sh @@ -0,0 +1,325 @@ +#!/usr/bin/env bash +# Contract tests for actions/changelog-assembled (issue #116). Like the +# monotonic guard's suite, every applicable case is a constructed git repo — +# "the section matches the fragments it consumed" is a property of a DIFF: +# the fragments are gone from HEAD's tree by construction, so the fixture is +# a history: a base commit holding the fragments and a -dev version, a HEAD +# commit holding the ceremony's edit, and a 'base' branch standing in for +# origin/main. 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" + +SCRIPT="$ROOT/actions/changelog-assembled/changelog-assembled.sh" +ARMED="$ROOT/actions/changelog-armed/changelog-armed.sh" +MONOTONIC="$ROOT/actions/changelog-monotonic/changelog-monotonic.sh" +ASSEMBLE="$ROOT/bin/changelog-assemble" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +init_repo() { + local dir="$TMP/$1" + mkdir -p "$dir" + git -C "$dir" init -q -b main + git -C "$dir" config user.email ci@example.invalid + git -C "$dir" config user.name ci +} + +# commit_base — commit the tree as the merge base and pin 'base' there. +commit_base() { + git -C "$TMP/$1" add -A + git -C "$TMP/$1" commit -qm base + git -C "$TMP/$1" branch base +} + +commit_head() { + git -C "$TMP/$1" add -A + git -C "$TMP/$1" commit -qm head +} + +# seed_flat — the shared pre-ceremony tree: an armed changelog, two +# flat fragments (numeric order will put 12 before 9), a -dev version. +seed_flat() { + local name="$1" dir="$TMP/$1" + init_repo "$name" + mkdir -p "$dir/changelog.d" + printf 'Machine-assembled; see heavy-duty/ceremony#112.\n' >"$dir/changelog.d/README.md" + cat >"$dir/CHANGELOG.md" <<'EOF' +# Changelog + +Preamble prose belongs to no section. + +## 0.1.0 — 2026-07-01 + +- The shipped entry. +EOF + printf '0.1.1-dev\n' >"$dir/VERSION" + printf -- '- Twelve landed.\n' >"$dir/changelog.d/12.md" + printf -- '- Nine landed, and its prose wraps onto a\n continuation line.\n' >"$dir/changelog.d/9.md" + commit_base "$name" +} + +# ceremony — the faithful release edit on the working +# tree, exactly as a human runs it: the real assembler in write mode, then +# the bare version stamp. Deliberately does NOT commit, so a case can break +# the tree before committing HEAD. +ceremony() { + local name="$1" ver="$2" stamp="$3" + (cd "$TMP/$name" && "$ASSEMBLE" "$ver" "$stamp" >/dev/null 2>&1) || return 1 + printf '%s\n' "$ver" >"$TMP/$name/VERSION" +} + +run() { local name="$1"; shift; (cd "$TMP/$name" && bash "$SCRIPT" "$@"); } +run_strict() { local name="$1"; shift; (cd "$TMP/$name" && CHANGELOG_ASSEMBLED_STRICT=1 bash "$SCRIPT" "$@"); } + +# --- the documented flow passes ---------------------------------------------- + +seed_flat faithful-flat +ceremony faithful-flat 0.2.0 2026-07-24 +commit_head faithful-flat +check "faithful flat ceremony: the section is byte-for-byte the assembly" 0 \ + "byte-for-byte" run faithful-flat base + +seed_flat faithful-grouped +printf -- '### Fixed\n\n- Fixed twenty-one.\n' >"$TMP/faithful-grouped/changelog.d/21.md" +printf -- '### Added\n\n- Added twenty.\n\n### Docs\n\n- Docs twenty.\n' >"$TMP/faithful-grouped/changelog.d/20.md" +rm "$TMP/faithful-grouped/changelog.d/12.md" "$TMP/faithful-grouped/changelog.d/9.md" +git -C "$TMP/faithful-grouped" add -A +git -C "$TMP/faithful-grouped" commit -qm regroup +git -C "$TMP/faithful-grouped" branch -f base +ceremony faithful-grouped 0.2.0 2026-07-24 +commit_head faithful-grouped +check "faithful grouped ceremony passes" 0 "byte-for-byte" run faithful-grouped base + +# The date is HEAD's to choose: a stamp nowhere near today must not read as +# a prose difference — the comparison is body against body, headings (and +# so dates) never enter it. +seed_flat old-date +ceremony old-date 0.2.0 2020-01-01 +commit_head old-date +check "the stamp's date never enters the comparison" 0 "byte-for-byte" \ + run old-date base + +# --- inapplicable trees: green NOTICE, never a silent skip ------------------- + +seed_flat ordinary-add +printf -- '- Thirteen incoming.\n' >"$TMP/ordinary-add/changelog.d/13.md" +commit_head ordinary-add +check "-dev PR adding a fragment: green NOTICE" 0 "NOTICE" run ordinary-add base + +seed_flat ordinary-none +printf 'code\n' >"$TMP/ordinary-none/code.txt" +commit_head ordinary-none +check "-dev PR touching no fragment: green NOTICE" 0 "NOTICE" run ordinary-none base + +seed_flat ordinary-del +rm "$TMP/ordinary-del/changelog.d/12.md" +commit_head ordinary-del +check "-dev PR even deleting a fragment: green NOTICE" 0 "NOTICE" run ordinary-del base + +# Legacy mode: no changelog.d/ at the merge base — always a NOTICE, even on +# a release tree, because the mid-adoption ceremony edits the changelog by +# hand and there is no fragment set for its section to answer to. +init_repo legacy +printf '# Changelog\n\n## Unreleased\n\n- An entry.\n' >"$TMP/legacy/CHANGELOG.md" +printf '0.1.1-dev\n' >"$TMP/legacy/VERSION" +commit_base legacy +printf '# Changelog\n\n## 0.2.0 — 2026-07-24\n\n- An entry.\n' >"$TMP/legacy/CHANGELOG.md" +printf '0.2.0\n' >"$TMP/legacy/VERSION" +commit_head legacy +check "legacy repo (no changelog.d at base): green NOTICE, even on a release tree" 0 \ + "NOTICE" run legacy base + +# The un-rearmed window: a PR branched right after a release merges sits on +# a bare version whose section was stamped at its MERGE BASE — it is not +# the ceremony and must not be asked to answer for one. +seed_flat post-release +ceremony post-release 0.2.0 2026-07-24 +git -C "$TMP/post-release" add -A +git -C "$TMP/post-release" commit -qm release +git -C "$TMP/post-release" branch -f base +printf 'code\n' >"$TMP/post-release/code.txt" +commit_head post-release +check "a PR atop the un-rearmed release: green NOTICE (this branch did not stamp)" 0 \ + "NOTICE" run post-release base + +# --- the refusals ------------------------------------------------------------ + +# The issue's headline failure: one fragment kept out of the ceremony — it +# survives at HEAD and its entry is absent from the section. Both refusals +# fire: the diff names the missing entry, the survivor list names the file. +seed_flat dropped +mv "$TMP/dropped/changelog.d/9.md" "$TMP/dropped/9.md.hold" +ceremony dropped 0.2.0 2026-07-24 +mv "$TMP/dropped/9.md.hold" "$TMP/dropped/changelog.d/9.md" +commit_head dropped +check "a fragment kept out of the ceremony fails" 1 "" run dropped base +check "the dropped-entry diff names the missing entry" 1 "Nine landed" \ + run dropped base +check "the surviving fragment is listed by path" 1 "changelog.d/9.md" \ + run dropped base + +# The same drop, but the fragment was deleted anyway: its prose vanished +# without ever being published. Only the diff can say so. +seed_flat vanished +mv "$TMP/vanished/changelog.d/9.md" "$TMP/vanished/9.md.hold" +ceremony vanished 0.2.0 2026-07-24 +rm "$TMP/vanished/9.md.hold" +commit_head vanished +check "a deleted fragment whose entry never landed fails" 1 "Nine landed" \ + run vanished base + +seed_flat edited +ceremony edited 0.2.0 2026-07-24 +sed -i 's/Twelve landed/Twelve allegedly landed/' "$TMP/edited/CHANGELOG.md" +commit_head edited +check "a hand-edited entry fails with a unified diff" 1 "+++" run edited base +check "the edit is visible in the diff" 1 "allegedly" run edited base +check "the diff failure teaches redo-with-the-tool, never hand-edit" 1 \ + "never to hand-edit" run edited base + +# Re-ordering away from the canonical order: the section is hand-built with +# the right entries in the wrong order (the assembler puts 12 before 9). +seed_flat reordered +rm "$TMP/reordered/changelog.d/12.md" "$TMP/reordered/changelog.d/9.md" +cat >"$TMP/reordered/CHANGELOG.md" <<'EOF' +# Changelog + +Preamble prose belongs to no section. + +## 0.2.0 — 2026-07-24 + +- Nine landed, and its prose wraps onto a + continuation line. +- Twelve landed. + +## 0.1.0 — 2026-07-01 + +- The shipped entry. +EOF +printf '0.2.0\n' >"$TMP/reordered/VERSION" +commit_head reordered +check "re-ordered entries fail" 1 "NOT what the fragments" run reordered base + +# A faithful assembly that forgot one deletion: the section is right, the +# directory is not — only the survivor refusal fires. +seed_flat survivor +ceremony survivor 0.2.0 2026-07-24 +printf -- '- Nine landed, and its prose wraps onto a\n continuation line.\n' >"$TMP/survivor/changelog.d/9.md" +commit_head survivor +check "a surviving fragment with its entry present fails" 1 "STILL PRESENT" \ + run survivor base +check "the survivor refusal names the file" 1 "changelog.d/9.md" \ + run survivor base + +# Fragments consumed, section never stamped: the prose went nowhere. +seed_flat halfdone +rm "$TMP/halfdone/changelog.d/12.md" "$TMP/halfdone/changelog.d/9.md" +printf '0.2.0\n' >"$TMP/halfdone/VERSION" +commit_head halfdone +check "fragments consumed but no section stamped fails" 1 \ + "non-empty section for '0.2.0'" run halfdone base + +# A release stamped out of a fragment-free directory: the replay refuses the +# way the real assembler would have — an empty release. +seed_flat empty-release +rm "$TMP/empty-release/changelog.d/12.md" "$TMP/empty-release/changelog.d/9.md" +git -C "$TMP/empty-release" add -A +git -C "$TMP/empty-release" commit -qm consume-early +git -C "$TMP/empty-release" branch -f base +cat >"$TMP/empty-release/CHANGELOG.md" <<'EOF' +# Changelog + +Preamble prose belongs to no section. + +## 0.2.0 — 2026-07-24 + +- Prose from nowhere. + +## 0.1.0 — 2026-07-01 + +- The shipped entry. +EOF +printf '0.2.0\n' >"$TMP/empty-release/VERSION" +commit_head empty-release +check "a section stamped from zero fragments fails on the replay's refusal" 1 \ + "zero fragments" run empty-release base + +check "missing changelog fails" 1 "no such file" run faithful-flat base NOPE.md + +# --- the trio interaction row (the issue's whole argument) ------------------- +# On the faithful tree all three guards are green; on the dropped-entry tree +# armed is green (the section exists and has prose), monotonic is green (no +# heading was deleted) — this guard is the ONLY one that goes red. + +run_armed() { (cd "$TMP/$1" && bash "$ARMED"); } +run_monotonic() { local name="$1"; shift; (cd "$TMP/$name" && bash "$MONOTONIC" "$@"); } + +check "trio, faithful tree: changelog-armed green" 0 "agrees" run_armed faithful-flat +check "trio, faithful tree: changelog-monotonic green" 0 "still present" \ + run_monotonic faithful-flat base +check "trio, faithful tree: changelog-assembled green" 0 "byte-for-byte" \ + run faithful-flat base +check "trio, dropped-entry tree: changelog-armed stays green" 0 "agrees" \ + run_armed dropped +check "trio, dropped-entry tree: changelog-monotonic stays green" 0 "still present" \ + run_monotonic dropped base +check "trio, dropped-entry tree: changelog-assembled is the only red" 1 "" \ + run dropped base + +# --- degradation: the loud skip and the STRICT refusal ----------------------- + +seed_flat no-base +ceremony no-base 0.2.0 2026-07-24 +commit_head no-base +check "base ref missing, STRICT=0: loud skip, exit 0" 0 "SKIPPED" \ + run no-base does-not-exist +check "base ref missing, STRICT=1: hard failure" 1 "FAILURE" \ + run_strict no-base does-not-exist +check "the STRICT failure names the checkout fix, not the script" 1 "fetch-depth: 0" \ + run_strict no-base does-not-exist + +mkdir -p "$TMP/plain" +printf '# Changelog\n\n## Unreleased\n' >"$TMP/plain/CHANGELOG.md" +check "not a git repo, STRICT=0: loud skip" 0 "not inside a git work tree" \ + run plain +check "not a git repo, STRICT=1: hard failure" 1 "FAILURE" run_strict plain + +# --- the honest edges -------------------------------------------------------- + +# Push-to-main shape: the merge base IS HEAD, nothing was consumed between +# them, and the success line must say so instead of claiming a comparison. +seed_flat vacuous +ceremony vacuous 0.2.0 2026-07-24 +commit_head vacuous +check "merge base IS HEAD: vacuous, named honestly" 0 "vacuous" run vacuous HEAD + +# --- the action's wiring: inputs arrive as env vars -------------------------- + +# Non-default names for everything action.yml passes, STRICT included, prove +# the env vars are honored the way the composite sets them. +init_repo env-tree +mkdir -p "$TMP/env-tree/frags" +printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/env-tree/NOTES.md" +printf '0.1.1-dev\n' >"$TMP/env-tree/VERSION" +printf -- '- Flagged entry.\n' >"$TMP/env-tree/frags/2.md" +git -C "$TMP/env-tree" add -A +git -C "$TMP/env-tree" commit -qm base +git -C "$TMP/env-tree" branch fixture-base +(cd "$TMP/env-tree" && "$ASSEMBLE" 0.2.0 2026-07-24 --changelog NOTES.md --dir frags >/dev/null 2>&1) +printf '0.2.0\n' >"$TMP/env-tree/VERSION" +git -C "$TMP/env-tree" add -A +git -C "$TMP/env-tree" commit -qm head +env_tree() { + (cd "$TMP/env-tree" && \ + CHANGELOG_ASSEMBLED_BASE=fixture-base CHANGELOG=NOTES.md \ + CHANGELOG_ASSEMBLED_DIR=frags VERSION_SOURCE=file \ + CHANGELOG_ASSEMBLED_STRICT=1 bash "$SCRIPT") +} +check "env vars drive the script the way action.yml does" 0 "byte-for-byte" env_tree + +summary