diff --git a/test/changelog-monotonic.test.sh b/test/changelog-monotonic.test.sh new file mode 100644 index 0000000..f0e50c6 --- /dev/null +++ b/test/changelog-monotonic.test.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +# Contract tests for actions/changelog-monotonic (issue #6). Unlike the +# armed guard's two-file trees, every containment case here needs a real +# constructed git repo — "a heading disappeared" is a property of a DIFF, +# so the fixture is a history: base commit, mutation commit, compare. The +# uniqueness cases deliberately run WITHOUT usable history, because running +# before anything history-dependent is the box#143 lesson under test. +# 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-monotonic/changelog-monotonic.sh" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# repo — a git repo whose first commit holds the changelog arriving +# on stdin, with a 'base' branch pinned there. Later mutations move HEAD; +# 'base' stays put, standing in for origin/main. +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 + cat >"$dir/CHANGELOG.md" + git -C "$dir" add CHANGELOG.md + git -C "$dir" commit -qm base + git -C "$dir" branch base +} + +# mutate — replace the repo's changelog with stdin and commit on HEAD. +mutate() { + local dir="$TMP/$1" + cat >"$dir/CHANGELOG.md" + git -C "$dir" commit -qam mutate +} + +run() { local dir="$1"; shift; (cd "$TMP/$dir" && bash "$SCRIPT" "$@"); } +run_strict() { local dir="$1"; shift; (cd "$TMP/$dir" && CHANGELOG_MONOTONIC_STRICT=1 bash "$SCRIPT" "$@"); } + +# The shared base shape: an armed changelog with two shipped releases. +base_changelog() { + cat <<'EOF' +# Changelog + +## Unreleased + +## 0.8.0 — 2026-07-19 + +- Shipped entry. + +## 0.7.0 — 2026-07-01 + +- Older entry. +EOF +} + +# --- the documented flow passes ---------------------------------------------- + +repo insert-above < <(base_changelog) +mutate insert-above <<'EOF' +# Changelog + +## Unreleased + +### Fixed + +- **A new entry, inserted above.** + +## 0.8.0 — 2026-07-19 + +- Shipped entry. + +## 0.7.0 — 2026-07-01 + +- Older entry. +EOF +check "entry inserted above an existing heading passes" 0 "still present" \ + run insert-above base + +repo stamp < <(base_changelog) +mutate stamp <<'EOF' +# Changelog + +## Unreleased + +## 0.9.0 — 2026-07-22 + +- The section that was Unreleased, now stamped. + +## 0.8.0 — 2026-07-19 + +- Shipped entry. + +## 0.7.0 — 2026-07-01 + +- Older entry. +EOF +check "the ceremony stamp (adds a heading, removes none) passes" 0 "still present" \ + run stamp base + +repo prose-only < <(base_changelog) +mutate prose-only <<'EOF' +# Changelog + +## Unreleased + +## 0.8.0 — 2026-07-19 + +- Shipped entry, reworded without touching any heading. + +## 0.7.0 — 2026-07-01 + +- Older entry. +EOF +check "an unrelated heading-less edit passes" 0 "still present" \ + run prose-only base + +# --- deletion (the box#122 shape) ---------------------------------------------- + +# The exact bad edit: the entry's '## Unreleased' block REPLACED the +# '## 0.8.0' heading line, absorbing that release's body. +repo deleted < <(base_changelog) +mutate deleted <<'EOF' +# Changelog + +## Unreleased + +## Unreleased + +### Fixed + +- **An entry.** + +- Shipped entry. + +## 0.7.0 — 2026-07-01 + +- Older entry. +EOF +check "a deleted shipped heading fails" 1 "DELETES" run deleted base +check "the deletion failure names the missing version" 1 "0.8.0" run deleted base +check "the deletion failure teaches insert-above-never-over" 1 "INSERT above" \ + run deleted base + +# --- duplication (the box#118 class) — runs even with NO usable base ---------- + +repo duped < <(base_changelog) +mutate duped <<'EOF' +# Changelog + +## Unreleased + +## 0.8.0 — 2026-07-19 + +### Fixed + +- **An entry that belonged under Unreleased.** + +## 0.8.0 — 2026-07-19 + +- Shipped entry. + +## 0.7.0 — 2026-07-01 + +- Older entry. +EOF +check "a duplicated heading on HEAD fails" 1 "DUPLICATE" run duped base +# Ordering is the box#143 lesson: uniqueness must not sit behind the history +# gates. An unresolvable base ref plus STRICT=1 would be a strict failure — +# the duplicate must win, because it needs no history at all. +check "the duplicate fails even when the base ref cannot resolve" 1 "DUPLICATE" \ + run duped no-such-ref +check "the duplicate beats the STRICT failure for the same broken ref" 1 "DUPLICATE" \ + run_strict duped no-such-ref + +mkdir -p "$TMP/duped-plain" +cp "$TMP/duped/CHANGELOG.md" "$TMP/duped-plain/CHANGELOG.md" +check "the duplicate fails even outside a git repo entirely" 1 "DUPLICATE" \ + run duped-plain + +# --- whole-version set members ------------------------------------------------- + +# 0.7.0 and 0.7.0-rc1 are distinct members: "renaming" the rc section to the +# bare version deletes the rc heading, and the bare one cannot stand in. +repo rc < <(cat <<'EOF' +# Changelog + +## Unreleased + +## 0.7.0-rc1 — 2026-06-20 + +- The candidate's entry. +EOF +) +mutate rc <<'EOF' +# Changelog + +## Unreleased + +## 0.7.0 — 2026-07-01 + +- The candidate's entry. +EOF +check "0.7.0 never satisfies a deleted 0.7.0-rc1" 1 "0.7.0-rc1" run rc base + +# --- degradation: the loud skip and the STRICT refusal ------------------------- + +repo no-base < <(base_changelog) +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" +base_changelog >"$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 ----------------------------------------------------------- + +# The commit that first ADDS the changelog: nothing existed at the merge +# base, so nothing could have been deleted. +mkdir -p "$TMP/newfile" +git -C "$TMP/newfile" init -q -b main +git -C "$TMP/newfile" config user.email ci@example.invalid +git -C "$TMP/newfile" config user.name ci +printf 'seed\n' >"$TMP/newfile/README" +git -C "$TMP/newfile" add README +git -C "$TMP/newfile" commit -qm seed +git -C "$TMP/newfile" branch base +base_changelog >"$TMP/newfile/CHANGELOG.md" +git -C "$TMP/newfile" add CHANGELOG.md +git -C "$TMP/newfile" commit -qm add-changelog +check "changelog absent at the merge base passes with the honest notice" 0 \ + "does not exist at the merge base" run newfile base + +# Push-to-main shape: the merge base IS HEAD, containment is vacuous by +# construction, and the success line must say which half actually ran. +repo vacuous < <(base_changelog) +check "merge base IS HEAD: vacuous containment named honestly" 0 "vacuous" \ + run vacuous HEAD + +check "missing changelog fails" 1 "no such file" run vacuous base NOPE.md + +# --- the action's wiring: inputs arrive as env vars ------------------------------ + +# A non-default changelog name and base env prove the env vars are honored +# the way action.yml passes them, not the defaults. +mkdir -p "$TMP/env-tree" +git -C "$TMP/env-tree" init -q -b main +git -C "$TMP/env-tree" config user.email ci@example.invalid +git -C "$TMP/env-tree" config user.name ci +printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Entry.\n' >"$TMP/env-tree/NOTES.md" +git -C "$TMP/env-tree" add NOTES.md +git -C "$TMP/env-tree" commit -qm base +git -C "$TMP/env-tree" branch fixture-base +printf '# Changelog\n\n## Unreleased\n\n## 0.1.0 — 2026-07-01\n\n- Entry.\n' >"$TMP/env-tree/NOTES.md" +git -C "$TMP/env-tree" commit -qam mutate +env_tree() { + (cd "$TMP/env-tree" && \ + CHANGELOG_MONOTONIC_BASE=fixture-base CHANGELOG=NOTES.md CHANGELOG_MONOTONIC_STRICT=1 \ + bash "$SCRIPT") +} +check "env vars drive the script the way action.yml does" 0 "still present" env_tree + +summary