test: changelog-monotonic contract suite — constructed git histories
Every case from #6's test list: the documented-flow passes (insert-above, the stamp, prose-only), the box#122 deletion shape, the box#118 duplicate (proven to run before every history gate, the box#143 ordering), rc distinctness, both STRICT degradations, the file-first-added edge, the vacuous push-to-main shape, and the action's env wiring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
37962402f0
commit
7424ef21bf
1 changed files with 274 additions and 0 deletions
274
test/changelog-monotonic.test.sh
Normal file
274
test/changelog-monotonic.test.sh
Normal file
|
|
@ -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 <name> — 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 <name> — 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
|
||||
Loading…
Reference in a new issue