fix: assert no shipped changelog heading is deleted or duplicated #134

Merged
dan-claude-bot merged 5 commits from fix/changelog-monotonic into main 2026-07-20 23:37:02 +00:00
4 changed files with 711 additions and 0 deletions

250
.github/scripts/changelog-monotonic.sh vendored Executable file
View file

@ -0,0 +1,250 @@
#!/usr/bin/env bash
set -euo pipefail
# changelog-monotonic.sh [<base-ref>] [<changelog>] — assert that no SHIPPED
# release heading was DELETED by this branch: the set of '^## X.Y.Z' headings
# on HEAD must be a SUPERSET of the set at the merge base.
#
# Ported from box (heavy-duty/box#122, caught in review of box#118) for #133,
# because cast's release-notes.sh carries the exact awk shape that made box#118
# dangerous. The failure it exists to catch leaves no trace either. An author
# adding an entry under '## Unreleased' REPLACES the line below it instead of
# inserting above it:
#
# -## 0.1.1 — 2026-07-19
# +## Unreleased
# +
# +### Fixed
# +
# +- **An entry**
#
# git merges that cleanly — it is a one-line edit inside a file nobody has
# touched concurrently — and the shipped section's whole body is silently
# absorbed into '## Unreleased'. 0.1.1 no longer HAS a section; the notes
# anchor release-notes.sh extracts by is gone, and the next release cut from
# that state republishes 0.1.1's prose as if it were new.
#
# The ARMING rule (test/release.test.ts, "the changelog is armed for the next
# entry (rig#66)") is green on exactly that tree, correctly: it asks only
# whether the TOP section agrees with package.json's version, and deleting
# '## 0.1.1' leaves '## Unreleased' on top. It is not wrong, it is narrow — it
# guards ONE heading, the one a PR is about to write under. This guards the
# REST of the file, the part no single tree can be asked about at all, because
# "a heading disappeared" is not a property of a tree — it is a property of a
# DIFF.
#
# The rule, and why it needs no tuning: release headings are APPEND-ONLY. The
# ceremony (#111) adds one and never removes one; nothing else in the
# documented flow (CONTRIBUTING.md, "Releasing") touches them. So SUPERSET is
# exact — it has no legitimate violation to carve an exception for. The stamp
# is covered for free: rewriting '## Unreleased' -> '## X.Y.Z — DATE' ADDS
# X.Y.Z and removes no X.Y.Z heading, because 'Unreleased' is not one.
# '## Unreleased' is deliberately NOT in the set this guards — the arming rule
# owns that heading, keyed on package.json's version, and the ceremony
# legitimately consumes it.
#
# A file of its own, NOT a clause inside the arming assertions, for three
# reasons. Its input is different (a git history, not two files). Its
# degradation is different (no base ref is a SKIP, not a failure). And the
# arming assertions run against constructed in-memory changelog strings that
# are not git repos at all — folding a git-dependent assert into them would
# make every one of those cases either skip or lie. Same discipline as
# release-notes.sh: its own file so test/release.test.ts can drive it.
base_ref="${1:-${CHANGELOG_MONOTONIC_BASE:-origin/main}}"
changelog="${2:-CHANGELOG.md}"
# Fail-closed switch: CI sets it, so a SKIP that would be a sensible local
# degradation becomes a red run there instead. A guard that can silently
# stop guarding is the failure shape this whole family of checks exists to
# refuse, so the skip path is loud and CI refuses to take it at all.
strict="${CHANGELOG_MONOTONIC_STRICT:-0}"
skip() {
if [ "$strict" = "1" ]; then
echo "changelog-monotonic: $* — and CHANGELOG_MONOTONIC_STRICT=1, so this is a FAILURE, not a skip." >&2
echo " CI sets STRICT because a guard that quietly stops guarding is worse than no guard." >&2
echo " (Uniqueness on HEAD already passed; it is containment that cannot run.)" >&2
echo " Fix the checkout, not this script: the base ref must be fetched (fetch-depth: 0)." >&2
exit 1
fi
echo "changelog-monotonic: containment SKIPPED — $*"
echo " (Uniqueness on HEAD already ran and passed — only the deleted-heading"
echo " half needs the history. In CI this same condition is a hard failure.)"
exit 0
}
[ -f "$changelog" ] || { echo "changelog-monotonic: no such file: $changelog" >&2; exit 1; }
# The set of RELEASE headings: '## <token> ...' where <token> looks like a
# version. Field $2, the same split the arming rule and release-notes.sh use,
# so the three cannot disagree about what a section header is. 'Unreleased'
# fails the shape and is excluded by construction.
headings_raw() {
awk '
/^## / && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+/ { print $2 }
'
}
headings() { headings_raw | sort -u; }
# --- uniqueness on HEAD (the box#118 class) ----------------------------------
# Containment catches a DELETED heading. It cannot catch a DUPLICATED one: the
# duplicate is head-side SURPLUS, and `comm -23` (base minus head) is blind to
# extras on the head side — with or without `sort -u`, base {0.1.1} minus head
# {0.1.1, 0.1.1} is empty. Multiset comparison does not close it either, for
# the same reason. The assert that does is uniqueness of version headings ON
# HEAD, kept alongside containment rather than replacing it.
#
# cast is the MORE exposed of the two repos here (#133). release-notes.sh
# extracts with:
#
# /^## / { grab = ($2 == ver); next }
# grab { print }
#
# There is no `exit`. `grab` re-arms on every matching '## ' line, so two
# '## 0.1.1' headings make the published body ABSORB whatever sits between the
# copies — and an entry stranded there is dropped from the NEXT release's notes
# as well. (rig's extractor has `if (found) exit`, so it truncates instead of
# absorbing — same class, milder symptom. cast has the absorbing one.)
#
# This is the shape box#118's bad rebase actually produced: two
# '## 0.8.0 — 2026-07-19' headings with the incoming entry between them. Every
# other guard stayed green — the arming rule happy (the top section was still
# right), tests and `bash -n` clean — while release-notes.sh re-armed its grab
# on the second heading and folded post-cut prose into the shipped release
# body. Note the arming rule's "double re-arm" case counts duplicate
# '## Unreleased' headings only; duplicate VERSION headings, the ones that
# reach release-notes.sh, are this script's.
#
# Nothing legitimate repeats a version heading: the ceremony stamps a NEW
# version, and 'Unreleased' fails the version shape and never reaches here.
dupes="$(headings_raw < "$changelog" | sort | uniq -d)"
if [ -n "$dupes" ]; then
{
echo "changelog-monotonic: $changelog has DUPLICATE release heading(s):"
echo
printf '%s\n' "$dupes" | sed 's/^/ ## /'
echo
cat <<EOF
Each version heading must appear exactly once. A repeat splits one release
into two same-named sections, and release-notes.sh re-arms its extraction on
every matching '## ' line — so the published body for that version absorbs
whatever sits between the copies, and an entry stranded there is dropped from
the NEXT release's notes as well.
This is the box#118 shape: an entry meant for '## Unreleased' was inserted
after a shipped heading, and the heading re-added below it. The fix is one
heading, with the entry above it under '## Unreleased':
## Unreleased
### Fixed
- **Your entry**
## $(printf '%s\n' "$dupes" | head -1) — DATE <- exactly once
Quick check on any changelog-touching rebase:
diff <(git show origin/main:$changelog | grep '^## ') <(grep '^## ' $changelog)
EOF
} >&2
exit 1
fi
# --- everything below needs the HISTORY --------------------------------------
# Uniqueness is settled. What follows is containment, which compares HEAD
# against the merge base and therefore genuinely depends on the base ref, the
# merge base, and the base blob. Each of those can be unavailable for reasons
# that are not the author's fault (a shallow clone, a fork checkout without the
# upstream remote, the commit that first adds the changelog), so each degrades
# rather than failing — which is exactly why the uniqueness half must NOT live
# down here (#133, box#143). It asks nothing of the history, and gating it
# behind these conditions let a duplicate exit 0 on a message about deletion.
#
# That ordering mattered MORE here than anywhere. cast's release-notes.sh has
# no `exit`, so `grab` re-arms on every matching '## ' line and a duplicate
# makes the published body ABSORB whatever sits between the copies — the live
# extraction bug this guard exists for. The half with that bug behind it was
# the half with the most ways to silently not run.
git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|| skip "not inside a git work tree, so there is no history to compare against"
git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null \
|| skip "base ref '$base_ref' does not resolve here (a shallow clone, or a fork checkout without the upstream remote)"
merge_base="$(git merge-base "$base_ref" HEAD 2>/dev/null || true)"
[ -n "$merge_base" ] \
|| skip "no merge base between '$base_ref' and HEAD (unrelated histories, or a clone too shallow to reach one)"
# The changelog may not exist at the merge base at all (the commit that adds
# it). Nothing to have deleted, so nothing to assert.
base_file="$(git show "$merge_base:$changelog" 2>/dev/null || true)"
[ -n "$base_file" ] || {
echo "changelog-monotonic: $changelog does not exist at the merge base ($(git rev-parse --short "$merge_base")) — nothing could have been deleted (uniqueness on HEAD already passed)."
exit 0
}
base_headings="$(printf '%s\n' "$base_file" | headings)"
head_headings="$(headings < "$changelog")"
# comm -23: lines in the base set that are NOT in the head set — exactly the
# headings this branch removed.
missing="$(comm -23 <(printf '%s\n' "$base_headings") <(printf '%s\n' "$head_headings"))"
if [ -n "$missing" ]; then
{
echo "changelog-monotonic: this branch DELETES release heading(s) from $changelog:"
echo
printf '%s\n' "$missing" | sed 's/^/ ## /'
echo
cat <<EOF
Present at the merge base ($(git rev-parse --short "$merge_base")), absent on HEAD.
Release headings are APPEND-ONLY. The ceremony adds one (#111); nothing ever
legitimately removes one. So this is not a judgement call — it is a defect,
and almost always the same one (#133, box#122): an entry written under
'## Unreleased' REPLACED the heading below it instead of being inserted
ABOVE it. The shipped section's body is now sitting under '## Unreleased',
and the version it belonged to has no section at all.
Nothing else will say so. git merges that edit cleanly — no conflict, no
signal — and the arming rule stays green, because the TOP section is still
the right one for this VERSION. The damage surfaces at the NEXT release,
when release-notes.sh cannot find the section it extracts by heading, or
worse, republishes the absorbed prose as if it were new.
The fix is to put the heading back and INSERT above it, never over it:
## Unreleased
### Fixed
- **Your entry**
## $(printf '%s\n' "$missing" | head -1) — DATE <- untouched, still here
If you are genuinely renaming a released version, that is a rewrite of
history this guard is meant to stop; say so in the PR and change the guard
deliberately, in its own commit.
EOF
} >&2
exit 1
fi
count="$(printf '%s\n' "$base_headings" | grep -c . || true)"
head_count="$(printf '%s\n' "$head_headings" | grep -c . || true)"
# The success line has two honest forms, because this step now runs on two
# shapes of event. On a push to main the merge base IS HEAD: containment
# compared the file against itself and asserted nothing, and deletion is
# undetectable on that event by construction. Reporting "all N still present"
# there would be the same dishonesty the skip messages were fixed for (#133) —
# a log claiming a check that did no work. Uniqueness is the half that actually
# ran, so that is the half the line names.
if [ "$merge_base" = "$(git rev-parse HEAD)" ]; then
echo "changelog-monotonic: containment vacuous (the merge base IS HEAD, so nothing could have been deleted between them) — uniqueness on HEAD checked $head_count release heading(s)."
else
echo "changelog-monotonic: all $count release heading(s) at the merge base ($(git rev-parse --short "$merge_base")) are still present in $changelog"
fi

View file

@ -10,6 +10,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# fetch-depth: 0, for the changelog-monotonic step below and only
# for it. That check is about a DIFF — which release headings the
# merge base had — so it needs the base branch's history present,
# and the default depth-1 checkout has none of it. An explicit
# `git fetch origin <base>` would be narrower, but it has to be
# right on both event types and on fork PRs, and getting it subtly
# wrong degrades to a SKIP (a guard that silently stops guarding —
# the exact failure this repo keeps refusing). Full history on a
# tree this size costs a second; the STRICT flag below turns any
# remaining skip red rather than green.
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "22"
@ -25,6 +37,32 @@ jobs:
- name: labels state-machine tests
run: bash test/labels-reconcile.sh
# ...and no SHIPPED release heading was deleted or DUPLICATED (#133;
# box#122's guard, box#143's ordering fix). Its own step so that when it
# goes red the log names the invariant that broke — and a DIFFERENT
# invariant from the arming rule npm test carries: arming is a fact
# about this tree, monotonicity is a fact about this tree versus its
# merge base. STRICT=1 so a checkout that cannot reach the base ref
# fails here instead of skipping quietly forever.
#
# NOT pull-request-only, and that is the #133 fix at the workflow level.
# The two halves have different vacuity: DELETION is vacuous on a push
# to main (the merge base IS HEAD), but DUPLICATION is vacuous on no
# tree at all, so gating the whole script on `pull_request` left a
# duplicate that reached main by any other route unasserted forever.
#
# The `|| github.ref_name` fallback is load-bearing, not defensive. On a
# push event `github.base_ref` is EMPTY, so the argument would collapse
# to a bare `origin/`, which does not resolve — and STRICT=1 correctly
# promotes that to a hard failure, turning every push to main red. With
# the fallback it resolves to the pushed branch, whose merge base with
# HEAD is HEAD or its parent: containment passes vacuously, exactly as
# the old `if` intended, while uniqueness now runs on every push.
- name: no shipped changelog heading was deleted or duplicated
env:
CHANGELOG_MONOTONIC_STRICT: "1"
run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref || github.ref_name }}"
# The installer, proven by RUNNING it — CAST_INSTALL_SOURCE points it at
# this checkout, so CI proves the installer under review (the versioned
# layout, the current symlink, the PATH chain, the uninstall's absence

View file

@ -49,6 +49,76 @@ actually cutting it, and this file starts there.
### Fixed
- **A PR that deletes a shipped release heading is now CI-red** (#133,
heavy-duty/box#122) — `.github/scripts/changelog-monotonic.sh` asserts that
the set of `## X.Y.Z` headings on HEAD is a superset of the set at the merge
base, and that no version heading appears twice. Wired into `ci.yml` on
every event, with `CHANGELOG_MONOTONIC_STRICT=1` and `fetch-depth: 0` so a
checkout that cannot reach the base ref fails loudly rather than skipping
quietly forever.
- **...and a duplicate heading no longer slips through on the paths where the
guard cannot see the base** (#133, heavy-duty/box#143) — the uniqueness half
is a property of HEAD alone, but it sat downstream of the base-ref,
merge-base and base-blob conditions, so each of those degradations returned
success on a tree with a duplicate in plain sight.
The base-blob case was the worst of the three because it was not a skip at
all: a branch that *introduces* `CHANGELOG.md` exited 0 through a bare
`exit 0`, on a message that was true about deletion and silent about the
duplicate in front of it. `STRICT=1` could not reach it — STRICT guards the
two `skip()` calls, and that path is not one of them. Off CI the two skips
had the same shape, so a shallow clone or an unpacked tarball would not look
at a duplicate the author was about to push.
That inverted the two halves, and it inverted them hardest here. Deletion is
the failure that needs a diff to see; duplication is the one cast's
`release-notes.sh` actually mis-renders, and cast has the ABSORBING
extractor — no `exit`, so `grab` re-arms on the second heading and the
published body swallows whatever sits between the copies (heavy-duty/box#118).
The half with the live extraction bug behind it was the half with the most
ways to silently not run.
Fixed by moving, not rewriting: uniqueness now runs directly after the file
exists, before any git access. The skip messages say *containment* skipped
and that uniqueness already passed, so a skip no longer claims nothing was
checked — and the success line got the same treatment, because dropping the
gate made `merge_base == HEAD` a routine path rather than a degradation. On a
push to main containment compares the file against itself and asserts
nothing, so the line now reports containment *vacuous* and names uniqueness
as the half that ran, instead of claiming N headings were verified present by
a comparison that could not have detected their absence.
The guard is also no longer gated to `pull_request` — deletion is
vacuous on a push to main, but duplication is vacuous on no tree, so a
duplicate reaching main by any other route went unasserted. That gate could
not simply be dropped: `github.base_ref` is empty on a push, and a bare
`origin/` under `STRICT=1` is a hard failure on every push to main, so the
base ref falls back to `github.ref_name`.
Found by `claude-bot-andresmgsl` and `codex-bot-andresmgsl` reviewing #134;
cast inherited the ordering from box, fixed there in heavy-duty/box#144.
The failure it catches leaves no trace. An author adding an entry under
`## Unreleased` types *over* the heading below it instead of inserting above
it — a one-line edit, in a file nobody touched concurrently, so git merges it
cleanly with no conflict and no signal. The arming rule stays green and is
not wrong to: the top section is still the right one for the version. But
the shipped section's body is now sitting under `## Unreleased`, and the
version it belonged to has no section at all. Nothing surfaces until the
*next* release, when `release-notes.sh` cannot find the section it extracts
by heading — or worse, republishes the absorbed prose as if it were new.
The uniqueness half matters more here than in box. `release-notes.sh`'s awk
has no `exit`, so `grab` re-arms on every matching `## ` line: two
`## 0.1.1` headings make the published body **absorb** whatever sits between
the copies, and an entry stranded there is dropped from the next release's
notes as well. Containment alone cannot see it — a duplicate is head-side
surplus, and base-minus-head is blind to extras on the head side — so
uniqueness on HEAD is asserted alongside it. `## Unreleased` is deliberately
outside the guarded set: the arming rule owns that heading, and the ceremony
legitimately consumes it.
- **A label the repo does not have no longer takes the whole edit down with
it** — `gh issue edit --add-label` rejects the *entire* call on one unknown
name, applying nothing. Batching state and blockers into a single edit (for

View file

@ -26,15 +26,18 @@ import { describe, expect, it } from "vitest";
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const NOTES = join(ROOT, ".github/scripts/release-notes.sh");
const MONOTONIC = join(ROOT, ".github/scripts/changelog-monotonic.sh");
function run(
cmd: string,
args: string[],
env: Record<string, string> = {},
cwd?: string,
): Promise<{ code: number; output: string }> {
return new Promise((resolve) => {
const child = spawn(cmd, args, {
stdio: ["ignore", "pipe", "pipe"],
cwd,
env: { ...process.env, ...env },
});
let output = "";
@ -376,6 +379,356 @@ describe("the changelog is armed for the next entry (rig#66)", () => {
});
});
// --- no SHIPPED release heading was deleted (#133) --------------------------
// The arming block above guards ONE heading — the top one, the one a PR is
// about to write under — and is keyed on a single tree. This guards the REST
// of the file, which no single tree can be asked about: "a heading
// disappeared" is not a property of a tree, it is a property of a DIFF. So the
// fixtures here are real throwaway git repos with a base branch and a PR
// branch, and the assertions drive the REAL script the CI step runs, the same
// way the block above drives the real release-notes.sh.
//
// Two halves, and they catch different shapes. CONTAINMENT catches a DELETED
// heading (base's set must be a subset of HEAD's). It cannot catch a
// DUPLICATED one — a duplicate is head-side SURPLUS, and base-minus-head is
// blind to extras on the head side — so UNIQUENESS on HEAD is asserted
// alongside it. The duplicate half matters more in cast than in box:
// release-notes.sh has no `exit`, so `grab` re-arms on every matching '## '
// line and two copies of a version heading make the published body ABSORB
// whatever sits between them.
describe("changelog-monotonic.sh — release headings are append-only (#133)", () => {
const dated = (v: string) => `## ${v} — 2026-07-19`;
const body = "\n\n- **An entry** — prose.\n";
/** The base branch's changelog: two shipped releases under an Unreleased. */
const BASE = `# Changelog\n\n## Unreleased\n\n${dated("0.1.1")}${body}\n${dated("0.1.0")}${body}`;
const git = (repo: string, ...args: string[]) =>
execFileSync("git", args, { cwd: repo, encoding: "utf8" });
/**
* A throwaway repo with `base` carrying BASE, checked out on a PR branch
* whose CHANGELOG.md is `head` (unchanged when omitted).
*/
function repoWith(head?: string): string {
const repo = mkdtempSync(join(tmpdir(), "cast-monotonic-"));
git(repo, "init", "-q");
git(repo, "config", "user.email", "test@example.com");
git(repo, "config", "user.name", "test");
git(repo, "checkout", "-q", "-b", "base");
writeFileSync(join(repo, "CHANGELOG.md"), BASE);
git(repo, "add", "CHANGELOG.md");
git(repo, "commit", "-qm", "base");
git(repo, "checkout", "-q", "-b", "pr");
if (head !== undefined) {
writeFileSync(join(repo, "CHANGELOG.md"), head);
git(repo, "add", "CHANGELOG.md");
git(repo, "commit", "-qm", "the PR");
}
return repo;
}
const check = (
repo: string,
env: Record<string, string> = {},
base = "base",
) => run("bash", [MONOTONIC, base], env, repo);
it("a branch that touches nothing passes, and says how many headings it checked", async () => {
// A branch that touches nothing has HEAD as its own merge base, which is
// now the VACUOUS-containment path (#133), so the count this asserts moved
// to uniqueness's — which serves the stated intent better anyway: it says
// the parser read the file and found real headings in it, rather than that
// a comparison of the file against itself came out equal.
const r = await check(repoWith());
expect(r.code).toBe(0);
expect(r.output).toContain(
"uniqueness on HEAD checked 2 release heading(s)",
);
});
// --- the push-to-main shape: containment vacuous, uniqueness real --------
// With the pull_request gate gone (#133), merge_base == HEAD is a ROUTINE
// path, not a degradation. Containment compares the file against itself and
// asserts nothing, so a line reading "all N still present" would claim a
// check that did no work — the same dishonesty the skip messages were fixed
// for. The success line therefore has two forms, and these pin which one
// each event shape gets, including that they do not collapse into one.
it("HEAD as its own base reports containment VACUOUS, not verified", async () => {
const r = await check(repoWith(), {}, "HEAD");
expect(r.code).toBe(0);
expect(r.output).toContain("containment vacuous");
});
it("...and names uniqueness as the half that actually ran", async () => {
const r = await check(repoWith(), {}, "HEAD");
expect(r.output).toContain("uniqueness on HEAD checked");
});
it("...and does NOT claim the headings were still present", async () => {
const r = await check(repoWith(), {}, "HEAD");
expect(r.output).not.toContain("are still present");
});
it("...while a REAL base still reports containment, naming the count", async () => {
// The PR shape. The two wordings must not collapse into one.
const good = BASE.replace(
"## Unreleased\n",
"## Unreleased\n\n### Fixed\n\n- **A new entry**\n",
);
const r = await check(repoWith(good));
expect(r.code).toBe(0);
expect(r.output).toContain("all 2 release heading(s)");
expect(r.output).toContain("are still present");
expect(r.output).not.toContain("containment vacuous");
});
it("adding an entry the CORRECT way — above the heading, never over it — passes", async () => {
const good = BASE.replace(
"## Unreleased\n",
"## Unreleased\n\n### Fixed\n\n- **A new entry**\n",
);
const r = await check(repoWith(good));
expect(r.code).toBe(0);
});
it("goes RED when an entry REPLACED the shipped heading below it — the #133 failure, exactly", async () => {
// The one-line edit git merges cleanly and nothing else notices: the
// author typed over `## 0.1.1 — …` instead of inserting above it.
const clobbered = BASE.replace(
`${dated("0.1.1")}`,
"## Unreleased\n\n### Fixed\n\n- **An entry**",
);
const r = await check(repoWith(clobbered));
expect(r.code).toBe(1);
expect(r.output).toContain("DELETES release heading(s)");
expect(r.output).toContain("## 0.1.1");
expect(r.output).toContain("APPEND-ONLY");
// 0.1.0, untouched, must not be accused.
expect(r.output).not.toContain(" ## 0.1.0");
});
it("goes RED on a DUPLICATED version heading — the case containment cannot see", async () => {
// Head-side surplus: base {0.1.0, 0.1.1} minus head is still empty, so
// only the uniqueness half catches this. It is also the case the arming
// rule's "double re-arm" test does NOT cover — that one counts duplicate
// '## Unreleased' headings, not duplicate VERSION headings, and it is the
// version ones that reach release-notes.sh.
const twice = BASE.replace(
`${dated("0.1.1")}${body}`,
`${dated("0.1.1")}${body}\n${dated("0.1.1")}${body}`,
);
const r = await check(repoWith(twice));
expect(r.code).toBe(1);
expect(r.output).toContain("DUPLICATE release heading(s)");
expect(r.output).toContain("## 0.1.1");
expect(r.output).toContain("absorbs");
});
it("the duplicate half survives the entry sitting BETWEEN the copies — the absorbing shape", async () => {
// What release-notes.sh would publish for 0.1.1 if this landed: its own
// prose, the stranded entry, AND the second copy's prose. Asserted with
// the real extractor, so the consequence is the actual one.
const absorbing = BASE.replace(
`${dated("0.1.1")}${body}`,
`${dated("0.1.1")}${body}\n- **A stranded entry**\n\n${dated("0.1.1")}${body}`,
);
const repo = repoWith(absorbing);
const r = await check(repo);
expect(r.code).toBe(1);
const notes = await run("bash", [
NOTES,
"0.1.1",
join(repo, "CHANGELOG.md"),
]);
expect(notes.output).toContain("A stranded entry");
});
it("'## Unreleased' is NOT in the guarded set — the ceremony legitimately consumes it", async () => {
// The release stamp: Unreleased becomes 0.2.0. That ADDS a version
// heading and removes none, and the Unreleased that disappeared is not a
// version heading at all. The arming rule owns that one.
const stamped = `${BASE.replace("## Unreleased\n", `${dated("0.2.0")}${body}\n`)}`;
const r = await check(repoWith(stamped));
expect(r.code).toBe(0);
// And deleting Unreleased outright — a disarmed tree, red under the
// arming rule — is still not this guard's business.
const disarmed = BASE.replace("## Unreleased\n\n", "");
expect((await check(repoWith(disarmed))).code).toBe(0);
});
/**
* A repo whose `base` has NO changelog at all the PR INTRODUCES the file.
* The merge-base blob is absent, which is the degradation path that used to
* `exit 0` before uniqueness had run (#133, box#143).
*/
function repoIntroducing(head: string): string {
const repo = mkdtempSync(join(tmpdir(), "cast-monotonic-new-"));
git(repo, "init", "-q");
git(repo, "config", "user.email", "test@example.com");
git(repo, "config", "user.name", "test");
git(repo, "checkout", "-q", "-b", "base");
writeFileSync(join(repo, "README.md"), "# hi\n");
git(repo, "add", "README.md");
git(repo, "commit", "-qm", "base");
git(repo, "checkout", "-q", "-b", "pr");
writeFileSync(join(repo, "CHANGELOG.md"), head);
git(repo, "add", "CHANGELOG.md");
git(repo, "commit", "-qm", "add the changelog");
return repo;
}
it("a changelog absent at the merge base is nothing-to-have-deleted, not a failure", async () => {
const r = await check(repoIntroducing(BASE));
expect(r.code).toBe(0);
expect(r.output).toContain("does not exist at the merge base");
});
// --- #133: uniqueness is a property of HEAD, so nothing base-side may gate
// it. Containment needs the merge base; uniqueness needs only the file in
// front of it. Before this fix the duplicate check sat DOWNSTREAM of the
// base-ref, merge-base and base-blob conditions, so each of the degradation
// paths below exited 0 on a tree carrying a duplicate in plain sight — the
// base-blob one not even via skip(), but a bare `exit 0` that STRICT could
// not reach. These cases pin the ORDER, which is the actual invariant;
// asserting the exit code alone is what let the original ship (the
// base-absent case above was green before and after).
//
// The inversion mattered most here: cast's release-notes.sh re-arms `grab`
// on every '## ' line, so duplication is the half with a LIVE extraction bug
// behind it — and it was the half with the most ways to silently not run.
it("a duplicate introduced where the base had NO changelog is caught (#133)", async () => {
const dup = `# Changelog\n\n## Unreleased\n\n${dated("0.1.1")}${body}\n- **A stranded entry**\n\n${dated("0.1.1")}${body}`;
const r = await check(repoIntroducing(dup));
expect(r.code).toBe(1);
expect(r.output).toContain("DUPLICATE release heading(s)");
expect(r.output).toContain("## 0.1.1");
// The old message must NOT be what this tree gets.
expect(r.output).not.toContain("nothing could have been deleted");
});
it("...and STRICT does not change that — it was never a skip", async () => {
const dup = `# Changelog\n\n## Unreleased\n\n${dated("0.1.1")}${body}\n${dated("0.1.1")}${body}`;
const r = await check(repoIntroducing(dup), {
CHANGELOG_MONOTONIC_STRICT: "1",
});
expect(r.code).toBe(1);
expect(r.output).toContain("DUPLICATE release heading(s)");
});
it("...while a CLEAN introduced changelog still passes, SAYING uniqueness ran", async () => {
const r = await check(repoIntroducing(BASE));
expect(r.code).toBe(0);
expect(r.output).toContain("nothing could have been deleted");
expect(r.output).toContain("uniqueness on HEAD already passed");
});
it("a duplicate OUTSIDE a git work tree is caught (#133)", async () => {
// No git at all — a tarball, an unpacked release. Uniqueness still has
// everything it needs; only containment does not.
const dir = mkdtempSync(join(tmpdir(), "cast-monotonic-nogit-"));
writeFileSync(
join(dir, "CHANGELOG.md"),
`# Changelog\n\n${dated("0.1.1")}${body}\n${dated("0.1.1")}${body}`,
);
const r = await run("bash", [MONOTONIC, "base"], {}, dir);
expect(r.code).toBe(1);
expect(r.output).toContain("DUPLICATE release heading(s)");
});
it("a duplicate is caught even when the base ref will not resolve (#133)", async () => {
const twice = BASE.replace(
`${dated("0.1.1")}${body}`,
`${dated("0.1.1")}${body}\n${dated("0.1.1")}${body}`,
);
const r = await check(repoWith(twice), {}, "origin/no-such-branch");
expect(r.code).toBe(1);
expect(r.output).toContain("DUPLICATE release heading(s)");
expect(r.output).not.toContain("containment SKIPPED");
});
it("a missing changelog refuses by path — never a silent pass", async () => {
const r = await run("bash", [MONOTONIC, "base", "nope.md"], {}, repoWith());
expect(r.code).toBe(1);
expect(r.output).toContain("no such file");
});
// The fail-closed switch, both directions. A guard that can quietly stop
// guarding is the failure shape this whole family of checks refuses, so the
// degradation that is sensible locally must be RED in CI.
it("an unresolvable base ref SKIPS CONTAINMENT locally — not everything (#133)", async () => {
const r = await check(repoWith(), {}, "origin/no-such-branch");
expect(r.code).toBe(0);
expect(r.output).toContain("containment SKIPPED");
// ...and it must not claim nothing was checked: uniqueness already ran.
expect(r.output).toContain("already ran and passed");
expect(r.output).not.toContain("Nothing was checked");
});
it("...and the SAME condition is a hard FAILURE under STRICT=1, naming fetch-depth", async () => {
const r = await check(
repoWith(),
{ CHANGELOG_MONOTONIC_STRICT: "1" },
"origin/no-such-branch",
);
expect(r.code).toBe(1);
expect(r.output).toContain("CHANGELOG_MONOTONIC_STRICT=1");
expect(r.output).toContain("fetch-depth: 0");
expect(r.output).not.toContain("SKIPPED");
// Even here the message must scope itself to containment (#133).
expect(r.output).toContain("it is containment that cannot run");
});
// The wiring, pinned the same way release.yml's is — the script existing is
// no use if CI stops running it, and every clause here is load-bearing.
it("ci.yml runs it on EVERY event, STRICT, against the base ref, with full history", () => {
const CI = readFileSync(join(ROOT, ".github/workflows/ci.yml"), "utf8");
expect(CI).toContain(".github/scripts/changelog-monotonic.sh");
// #133: NOT pull-request-only. Deletion is vacuous on a push to main, but
// duplication is vacuous on no tree — gating the whole script left a
// duplicate that reached main by any other route unasserted forever.
//
// Scoped to the step's OWN block, deliberately. As a file-wide negative it
// would forbid any FUTURE step in ci.yml from being pull_request-gated and
// would fail citing #133 when one legitimately is — #133 constrains this
// step, not the file. The companion assert below keeps the extractor from
// silently matching nothing and turning the negative into a tautology.
// Bounded by the next STEP *or* the next JOB. The job boundary is not
// optional: the monotonic step is the LAST step of its job, so splitting on
// steps alone runs the block into the job below and swallows that job's
// level `if:` — reintroducing the bug this scoping fixed, moved from "any
// step in the file" to "this step plus the head of the next job".
const ciLines = CI.split("\n");
const monoStart = ciLines.findIndex((l) =>
/^ {6}- name: no shipped changelog heading/.test(l),
);
const after = ciLines.slice(monoStart + 1);
const monoEnd = after.findIndex(
(l) => /^ {6}- /.test(l) || /^ {2}\S/.test(l),
);
const monoBlock =
monoStart < 0
? undefined
: [
ciLines[monoStart],
...after.slice(0, monoEnd < 0 ? after.length : monoEnd),
].join("\n");
expect(monoBlock).toBeDefined();
expect(monoBlock).toContain("changelog-monotonic.sh");
// Anchored: an `if:` inside a `run:` line is not a step condition.
expect(monoBlock).not.toMatch(/^ {8}if:/m);
// ...and dropping that gate is only safe WITH the fallback: on a push
// `github.base_ref` is empty, a bare `origin/` does not resolve, and
// STRICT promotes that to a hard failure on every push to main.
expect(CI).toContain('"origin/${{ github.base_ref || github.ref_name }}"');
expect(CI).toContain("CHANGELOG_MONOTONIC_STRICT");
// ...which is only reachable because the checkout has the base history.
expect(CI).toContain("fetch-depth: 0");
});
});
// --- release.yml — the wiring, pinned --------------------------------------
// The workflow itself only runs on a tag push upstream, so its load-bearing
// pieces are pinned here, fail-closed (the house discipline: the labels