diff --git a/.github/scripts/changelog-monotonic.sh b/.github/scripts/changelog-monotonic.sh new file mode 100644 index 0000000..4cb999e --- /dev/null +++ b/.github/scripts/changelog-monotonic.sh @@ -0,0 +1,218 @@ +#!/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. +# +# The failure it exists to catch (#98; ported from heavy-duty/box#122, which +# was caught in review of box#118) leaves no trace. An author adding an entry +# under '## Unreleased' REPLACES the line below it instead of inserting above +# it: +# +# -## 0.2.0 — 2026-07-19 +# +## Unreleased +# + +# +### Fixed +# + +# +- **An entry** +# +# git merges that cleanly — it is a one-line edit inside a file nobody has +# touched concurrently — so there is no conflict and no signal. 0.2.0's whole +# body is now sitting under '## Unreleased', and 0.2.0 has no section at all. +# +# The arming rule is green on exactly that tree, correctly. changelog_armed() +# in test/release.sh asks only whether the TOP section agrees with VERSION, +# and deleting '## 0.2.0' 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 damage surfaces at the next release, in changelog_section() +# (.github/scripts/release-lib.sh), which anchors on the heading: +# +# awk -v ver="$2" ' +# /^## / { if (found) exit; found = ($2 == ver); next } +# ... +# +# No heading, no section — and release.yml's "refusing to publish an empty +# release" assert is the first thing that notices, one whole release too late. +# +# The rule, and why it needs no tuning: release headings are APPEND-ONLY. The +# ceremony (CONTRIBUTING, "Releases") adds one and never removes one; nothing +# else in the documented flow 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 VERSION, and the ceremony legitimately consumes it. +# +# A file of its own, NOT a clause inside test/release.sh's arming check, 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 rule is driven by test/release.sh against constructed VERSION + +# CHANGELOG.md trees that are not git repos at all — folding a git-dependent +# assert into it would make every one of those cases either skip or lie. +# Same discipline as release-lib.sh: its own file so a test 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 " Fix the checkout, not this script: the base ref must be fetched (fetch-depth: 0)." >&2 + exit 1 + fi + echo "changelog-monotonic: SKIPPED — $*" + echo " (Nothing was checked. In CI this same condition is a hard failure.)" + exit 0 +} + +[ -f "$changelog" ] || { echo "changelog-monotonic: no such file: $changelog" >&2; exit 1; } + +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 set of RELEASE headings: '## ...' where looks like a +# version. Field $2, the same split changelog_section() uses, so the two +# 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; } + +# 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." + exit 0 +} + +# --- 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.2.0} minus head +# {0.2.0, 0.2.0} 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. +# +# This is the shape box#118's bad rebase produced: two `## 0.2.0 — 2026-07-19` +# headings with an incoming entry between them. Every other guard stays green +# — conflict markers absent, the arming rule happy (the top section is still +# right), tests and shellcheck clean. +# +# rig's symptom differs from box's, and the difference matters. box's +# release-notes.sh RE-ARMS its grab on every matching '## ' line, so a +# duplicate makes it ABSORB whatever sits between the copies. rig's +# changelog_section() has `if (found) exit`, so it stops dead at the second +# copy instead: a duplicate TRUNCATES. The published body is only what sits +# BETWEEN the two headings, and everything under the second copy — the real +# body of that release — is silently dropped. Different symptom, same class: +# no conflict, no red run, discovered only by a human reading the published +# notes. +# +# 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 + +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)" +echo "changelog-monotonic: all $count release heading(s) at the merge base ($(git rev-parse --short "$merge_base")) are still present in $changelog" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0465a53..590e2f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,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 + # pure-bash tree costs a second; the STRICT flag below turns any + # remaining skip red rather than green. + fetch-depth: 0 - name: shellcheck # -x follows the `source=SCRIPTDIR/...` directives into commands/lib/. # globstar so a script in a new subdirectory is linted without anyone @@ -39,6 +51,21 @@ jobs: run: bash test/labels-reconcile.sh - name: release-flow tests run: bash test/release.sh + # No SHIPPED release heading was deleted (#98). Its own step rather than + # a line inside test/release.sh: that suite drives the arming rule + # against constructed VERSION + CHANGELOG.md trees that are not git + # repos, and this assert needs a git history — folding it in would make + # those cases skip or lie. It is also a DIFFERENT invariant: arming is a + # fact about this tree, monotonicity is a fact about this tree versus + # its merge base. Pull requests only: on a push to main the merge base + # IS HEAD, so the assert is vacuous and would only add a green step that + # proves nothing. STRICT=1 so a checkout that cannot reach the base ref + # fails here instead of skipping quietly forever. + - name: no shipped changelog heading was deleted + if: github.event_name == 'pull_request' + env: + CHANGELOG_MONOTONIC_STRICT: '1' + run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref }}" # Kept SEPARATE from `check` on purpose: this job pulls a Postgres image and # stands up throwaway containers, and a slow image pull must never delay the diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f0a755..bb512e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,48 @@ on the way to cutting its first release, and this file starts there. ### Fixed +- **Deleting a shipped release heading from `CHANGELOG.md` is now caught, on + every PR** (#98, heavy-duty/box#122) — the arming rule (#66) asks one + question about **one** heading: does the top section agree with `VERSION`? + It says nothing about the rest of the file, and the failure that lives there + is an author adding an entry under `## Unreleased` who *replaces* the line + `## 0.2.0 — 2026-07-19` below it instead of inserting above it. git merges + that cleanly — a one-line edit in a file nobody touched concurrently, so no + conflict and no signal — and `changelog_armed()` stays green on that exact + tree, correctly: `## Unreleased` is still on top and still right for + `VERSION`. Verified here rather than assumed: the arming rule was run + against a deliberately mangled copy of this repo's real tree and passed, + while `changelog_section()` extracted `0.2.0` to zero lines. The damage + would have surfaced a whole release later, when `release.yml` refused to + publish an empty section it could no longer find by heading. + + `.github/scripts/changelog-monotonic.sh` asserts the complementary + invariant: release headings are **append-only**, so the set of `## X.Y.Z` + headings on a branch must be a **superset** of the set at its merge base. + Its own script, not a clause in the arming check, because "a heading + disappeared" is a property of a DIFF, not of a tree — and because the arming + rule is driven from `test/release.sh` against constructed `VERSION` + + `CHANGELOG.md` pairs that are not git repos and could not express it. The + ceremony's stamp passes by construction (it *adds* `X.Y.Z` and removes none; + `Unreleased` fails the version shape and is deliberately outside the guarded + set), and no reachable base ref is a loud SKIP locally but a hard failure in + CI, which sets `CHANGELOG_MONOTONIC_STRICT=1` and now checks out with + `fetch-depth: 0` so the guard can never quietly stop guarding. + + It carries box's second half too: containment catches a **deleted** heading + but cannot catch a **duplicated** one, since 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`, and multiset comparison does not close it for + the same reason. So version headings are also asserted **unique on HEAD**. + rig's symptom there is not box's, and the guard says so in its own words: + box's extractor re-arms on every matching `## ` line and *absorbs* whatever + sits between the copies, while rig's `changelog_section()` has + `if (found) exit` and stops dead at the second copy — a duplicate + **truncates**, publishing only what sits between the two headings and + silently dropping the release's real body underneath. Different symptom, + same class: no conflict, no red run, found only by a human reading the + published notes. + - **An unreadable check rollup no longer reads as "nothing is failing"** (#90) — when `gh pr view` failed, the fallback left the `statusCheckRollup` key absent entirely, and `(.statusCheckRollup // [])` collapsed that into the diff --git a/test/release.sh b/test/release.sh index b9b1268..e08b094 100644 --- a/test/release.sh +++ b/test/release.sh @@ -190,6 +190,154 @@ check "arming: a bare VERSION whose section was never stamped FAILS" 1 "" armed T="$(armtree headless 0.2.1-dev '# Changelog' '' 'no sections here')" check "arming: a changelog with no sections FAILS" 1 "" armed "$T" +# --- the monotonicity rule: was a SHIPPED heading deleted? ------------------- +# #98. Arming asks about ONE heading — does the top section agree with +# VERSION? — so it is silent about the rest of the file. The failure it cannot +# see is an entry written under '## Unreleased' that REPLACES the heading +# below it instead of inserting above it: git merges the one-line edit +# cleanly, arming stays green (the top section is still right), and the +# shipped release loses its section entirely. "A heading disappeared" is not a +# property of a tree, it is a property of a DIFF — so unlike every check +# above, these cases need real git repos, which is why the guard is its own +# script rather than a function sourced here. +MONO="$ROOT/.github/scripts/changelog-monotonic.sh" +check "changelog-monotonic.sh: exists and is the guard under test" 0 "" test -f "$MONO" + +# The stock changelog every case below starts from: an Unreleased section and +# two shipped releases, committed on branch 'base' — which plays origin/main. +# The caller then rewrites CHANGELOG.md on 'work' and commits. +MONO_BASE=('# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \ + '- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \ + '- **The first thing** (#0) — prose.') +monorepo() { # monorepo -> prints the dir, left checked out on 'work' + local d="$WORK/mono-$1"; mkdir -p "$d" + git -C "$d" init -q -b base + git -C "$d" config user.email harness@example.invalid + git -C "$d" config user.name harness + printf '%s\n' "${MONO_BASE[@]}" > "$d/CHANGELOG.md" + git -C "$d" add CHANGELOG.md + git -C "$d" commit -qm 'base: two shipped releases' + git -C "$d" checkout -q -b work + printf '%s' "$d" +} +monowrite() { # monowrite — rewrite CHANGELOG.md and commit + local d="$1"; shift + printf '%s\n' "$@" > "$d/CHANGELOG.md" + git -C "$d" commit -qam 'work: edit the changelog' +} +mono() { # mono [VAR=val ...] — run the guard there, base ref 'base' + local d="$1"; shift + ( cd "$d" && env "$@" bash "$MONO" base 2>&1 ) +} + +# An untouched branch: the head heading-set equals the base's, so containment +# holds trivially. The green message names the count it checked, because a +# guard that prints nothing is indistinguishable from one that did nothing. +T="$(monorepo clean)" +check "monotonic: an untouched branch passes" 0 "all 2 release heading(s)" mono "$T" + +# The legitimate edit this guard must never object to: a new entry INSERTED +# above the shipped heading, which is left alone. +T="$(monorepo insert)" +monowrite "$T" '# Changelog' '' '## Unreleased' '' '### Fixed' '' \ + '- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \ + '- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \ + '- **The first thing** (#0) — prose.' +check "monotonic: an entry inserted ABOVE the shipped heading passes" 0 "" mono "$T" + +# ...and the bug itself: the same entry typed OVER '## 0.2.0'. 0.2.0's body is +# now under '## Unreleased' and 0.2.0 has no section. RED, naming the version. +T="$(monorepo deleted)" +monowrite "$T" '# Changelog' '' '## Unreleased' '' '### Fixed' '' \ + '- **A pending thing** (#2) — prose.' '' \ + '- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \ + '- **The first thing** (#0) — prose.' +check "monotonic: a DELETED shipped heading FAILS (#98)" 1 "DELETES release heading" mono "$T" +check "monotonic: ...and the failure names the version that vanished" 1 "## 0.2.0" mono "$T" + +# Deleting the OLDEST release is the same defect, not a lesser one — the set +# is a set, position in the file buys no leniency. +T="$(monorepo deleted-old)" +monowrite "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \ + '- **A shipped thing** (#1) — prose.' +check "monotonic: deleting an OLDER release heading fails too" 1 "## 0.1.0" mono "$T" + +# The duplicate half. Containment cannot catch this: the second copy is +# head-side SURPLUS and `comm -23` (base minus head) is blind to extras on the +# head side, so uniqueness-on-HEAD is a separate assert. rig's symptom is not +# box's — changelog_section() has `if (found) exit`, so it stops at the second +# copy and TRUNCATES rather than absorbing. +T="$(monorepo dupe)" +monowrite "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \ + '- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \ + '- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \ + '- **The first thing** (#0) — prose.' +check "monotonic: a DUPLICATED version heading FAILS" 1 "DUPLICATE release heading" mono "$T" +check "monotonic: ...and the failure names the repeated version" 1 "## 0.2.0" mono "$T" +# ...and that the duplicate really does truncate, so the assert above is +# guarding a live defect rather than a stylistic preference: extraction stops +# at the second copy, dropping the body that sits under it. +check "monotonic: the duplicate TRUNCATES extraction (rig's symptom, not box's)" 0 \ + "A pending thing" changelog_section "$T/CHANGELOG.md" 0.2.0 +check "monotonic: ...the real body under the second copy is dropped" 1 "" \ + sect_has "$T/CHANGELOG.md" 0.2.0 "A shipped thing" + +# '## Unreleased' is deliberately OUTSIDE the guarded set: it fails the +# version shape, so the ceremony stamping it away — the one edit that legally +# removes a top heading — is invisible here. This is the case that would make +# every release PR unshippable if the set were "all '## ' headings". +T="$(monorepo stamp)" +monowrite "$T" '# Changelog' '' '## 0.3.0 — 2026-07-20' '' \ + '- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \ + '- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \ + '- **The first thing** (#0) — prose.' +check "monotonic: stamping '## Unreleased' into a release passes (not guarded)" 0 "" mono "$T" +# ...and the ceremony's re-arm — a fresh empty Unreleased above the stamp — +# is equally fine, which is CONTRIBUTING step 1's tree. +T="$(monorepo stamp-rearmed)" +monowrite "$T" '# Changelog' '' '## Unreleased' '' '## 0.3.0 — 2026-07-20' '' \ + '- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \ + '- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \ + '- **The first thing** (#0) — prose.' +check "monotonic: the re-armed ceremony tree passes too" 0 "" mono "$T" + +# The skip path, both halves. A base ref that does not resolve is a sensible +# local degradation — and a silent one, which is the failure shape this family +# of checks exists to refuse. So STRICT flips exactly that case red. +mono_noref() { local d="$1"; shift; ( cd "$d" && env "$@" bash "$MONO" no/such/ref 2>&1 ); } +T="$(monorepo noref)" +check "monotonic: an unresolvable base ref SKIPS locally" 0 "SKIPPED" mono_noref "$T" +check "monotonic: ...and the skip says nothing was checked" 0 "Nothing was checked" mono_noref "$T" +check "monotonic: ...but is a FAILURE under STRICT=1 (what CI sets)" 1 "STRICT=1" \ + mono_noref "$T" CHANGELOG_MONOTONIC_STRICT=1 +check "monotonic: ...and the STRICT failure blames the checkout, not the script" 1 \ + "fetch-depth: 0" mono_noref "$T" CHANGELOG_MONOTONIC_STRICT=1 + +# A missing changelog is an error on any setting — it is not a degradation, +# it is a wrong invocation. +T="$(monorepo nofile)" +# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately +check "monotonic: a missing changelog file is an error, never a skip" 1 "no such file" \ + bash -c 'cd "$1" && bash "$2" base nope.md 2>&1' _ "$T" "$MONO" + +# --- ci.yml: the monotonic step is actually wired (#98) ---------------------- +# The guard runs from ci.yml, not from this suite, so pin the wiring the same +# way release.yml's is pinned — a script nothing invokes is not a check. +CIY="$ROOT/.github/workflows/ci.yml" +check "ci.yml: runs the monotonic guard" 0 "" \ + grep -q "changelog-monotonic.sh" "$CIY" +check "ci.yml: ...on pull requests only (on a push to main it is vacuous)" 0 "" \ + grep -qF "github.event_name == 'pull_request'" "$CIY" +check "ci.yml: ...with STRICT=1, so a skip is red rather than quietly green" 0 "" \ + grep -qF "CHANGELOG_MONOTONIC_STRICT: '1'" "$CIY" +# shellcheck disable=SC2016 # the $-string is a literal in the target file +check "ci.yml: ...against the PR's base branch" 0 "" \ + grep -qF 'origin/${{ github.base_ref }}' "$CIY" +# Without full history the base ref does not resolve, and STRICT turns that +# into a red run — so the fetch depth is load-bearing, not incidental. +check "ci.yml: the checkout has full history (the base ref must resolve)" 0 "" \ + grep -qF "fetch-depth: 0" "$CIY" + # --- release.yml: the pins --------------------------------------------------- # The workflow itself runs only on a tag push upstream, so pin its # load-bearing pieces the way the harness pins root-only paths (repo