#!/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