fix: assert no shipped changelog heading is deleted or duplicated #134
4 changed files with 711 additions and 0 deletions
250
.github/scripts/changelog-monotonic.sh
vendored
Executable file
250
.github/scripts/changelog-monotonic.sh
vendored
Executable 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
|
||||
38
.github/workflows/ci.yml
vendored
38
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
70
CHANGELOG.md
70
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue