diff --git a/.github/scripts/changelog-monotonic.sh b/.github/scripts/changelog-monotonic.sh new file mode 100755 index 0000000..295b570 --- /dev/null +++ b/.github/scripts/changelog-monotonic.sh @@ -0,0 +1,250 @@ +#!/usr/bin/env bash +set -euo pipefail + +# changelog-monotonic.sh [] [] — 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: '## ...' where 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 <&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 <&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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91e357c..dac6604 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 ` 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 040af86..8929dce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,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 diff --git a/test/release.test.ts b/test/release.test.ts index 38f699c..368d352 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -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 = {}, + 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 = {}, + 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