#!/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