From b2e7febf08583573a4f2f8cdb1279ceff7a0a424 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 20 Jul 2026 20:00:48 +0000 Subject: [PATCH 1/4] fix: catch a deleted or duplicated release heading in CHANGELOG.md (#98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arming rule (#66) guards ONE heading — does the top section agree with VERSION? — and is silent about the rest of the file. The failure that lives there is an author adding an entry under `## Unreleased` who replaces the shipped heading below it instead of inserting above it. git merges the one-line edit cleanly, `changelog_armed()` stays green (correctly: the top section is still right), and the shipped release loses its section entirely. It surfaces a whole release later, when release.yml refuses to publish a section `changelog_section()` can no longer find by heading. "A heading disappeared" is a property of a DIFF, not of a tree, so this is its own script rather than a clause in the arming check — which is also driven from test/release.sh against constructed non-git VERSION + CHANGELOG pairs that could not express it. The rule needs no tuning: release headings are append-only, so SUPERSET is exact, and the ceremony's stamp passes by construction because `Unreleased` fails the version shape. Ported from heavy-duty/box#122, with box's second half intact: containment cannot catch a DUPLICATED heading, since the duplicate is head-side surplus and `comm -23` is blind to extras there — so uniqueness on HEAD is asserted alongside it. rig's symptom differs from box's and the comments say so: box's extractor re-arms on every `## ` line and ABSORBS what sits between the copies, while rig's `changelog_section()` has `if (found) exit` and TRUNCATES at the second copy, dropping the release's real body. Wired on pull requests only (on a push to main the merge base is HEAD, so the assert is vacuous), with CHANGELOG_MONOTONIC_STRICT=1 and fetch-depth: 0 so a checkout that cannot reach the base ref fails rather than skipping quietly forever. Co-Authored-By: Claude Opus 4.8 --- .github/scripts/changelog-monotonic.sh | 218 +++++++++++++++++++++++++ .github/workflows/ci.yml | 27 +++ CHANGELOG.md | 42 +++++ test/release.sh | 148 +++++++++++++++++ 4 files changed, 435 insertions(+) create mode 100644 .github/scripts/changelog-monotonic.sh 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 From 7ffc30bacc59fb28ebc02efa48a6717e8cd7e2d0 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 20 Jul 2026 20:22:43 +0000 Subject: [PATCH 2/4] fix(changelog-monotonic): check uniqueness before anything base-side (#98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uniqueness is a property of HEAD alone — no base ref, no merge base, no base blob. It sat downstream of all three, so every degradation path returned success on a tree carrying a duplicate. The base-blob path was the worst: a branch that introduces CHANGELOG.md hit a bare `exit 0` on a message that was true about deletion and silent about the duplicate in front of it. STRICT could not reach it — STRICT guards the two skip() calls, and that is not one of them. That inverted the two halves. Deletion needs a diff to see; duplication is the one changelog_section() actually mis-renders, stopping at the second heading and truncating the release's real body. The half with the live extraction bug behind it had the most ways to silently not run. Moved, not rewritten. The skip messages now say containment skipped and that uniqueness already passed. The CI step is no longer pull_request-only, with a `github.ref_name` fallback because base_ref is empty on a push and a bare `origin/` under STRICT would redden every push to main. The script is also now 100755, matching cast's copy of the same file. Regression cases pin the ORDER, not the exit code: verified they go red against the pre-fix script (7 failures) and green against the fixed one. Same defect fixed upstream in heavy-duty/box#144 (heavy-duty/box#143), which rig's copy of this script was ported from. Found by claude-bot-andresmgsl and codex-bot-andresmgsl reviewing #99. Co-Authored-By: Claude Opus 4.8 --- .github/scripts/changelog-monotonic.sh | 53 +++++++++------ .github/workflows/ci.yml | 33 ++++++---- CHANGELOG.md | 35 ++++++++++ test/release.sh | 89 ++++++++++++++++++++++++-- 4 files changed, 174 insertions(+), 36 deletions(-) mode change 100644 => 100755 .github/scripts/changelog-monotonic.sh diff --git a/.github/scripts/changelog-monotonic.sh b/.github/scripts/changelog-monotonic.sh old mode 100644 new mode 100755 index 4cb999e..7bbdd5a --- a/.github/scripts/changelog-monotonic.sh +++ b/.github/scripts/changelog-monotonic.sh @@ -69,26 +69,18 @@ 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: SKIPPED — $*" - echo " (Nothing was checked. In CI this same condition is a hard failure.)" + 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; } -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 @@ -100,14 +92,6 @@ headings_raw() { } 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 @@ -167,6 +151,35 @@ EOF 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 (#98; fixed upstream in heavy-duty/box#143, where rig's copy of +# this script came from). It asks nothing of the history, and gating it behind +# these conditions let a duplicate exit 0 on a message about deletion. + +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")" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 590e2f6..e1e624f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,21 +51,32 @@ 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 + # No SHIPPED release heading was deleted or DUPLICATED (#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' + # 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 #98 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 }}" + run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref || github.ref_name }}" # 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 bb512e1..fca6ff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,41 @@ on the way to cutting its first release, and this file starts there. same class: no conflict, no red run, found only by a human reading the published notes. +- **...and the uniqueness half no longer sits behind conditions it does not + depend on** (#98, heavy-duty/box#143) — containment is a property of a DIFF + and genuinely needs the base ref, the merge base and the base blob. + Uniqueness is a property of **HEAD alone** and needs none of the three. It + sat downstream of all of them anyway, so every degradation path returned + success on a tree carrying 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` hit 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 value of the two halves. Deletion is the failure that needs + a diff to see; duplication is the one `changelog_section()` actually + mis-renders, stopping dead at the second copy and truncating the release's + real body. 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, under a boundary comment saying so. The skip + messages say *containment* skipped and that uniqueness already passed, so a + skip no longer claims nothing was checked. The CI step 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`. The regression cases pin the ORDER rather than the exit + code, since the clean base-absent tree is green either way — asserting only + the code is what let the original ship. `changelog-monotonic.sh` is also now + `100755`, matching cast's copy of the same file. + - **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 e08b094..7766bec 100644 --- a/test/release.sh +++ b/test/release.sh @@ -306,8 +306,9 @@ check "monotonic: the re-armed ceremony tree passes too" 0 "" mono "$T" # 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: an unresolvable base ref SKIPS containment locally" 0 "containment SKIPPED" mono_noref "$T" +check "monotonic: ...and the skip says uniqueness already ran, not that nothing did" 0 \ + "already ran and passed" 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 \ @@ -320,19 +321,97 @@ T="$(monorepo nofile)" 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" +# --- #98: 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. As first written (and as inherited from heavy-duty/box, fixed there in +# box#144 for box#143) 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 through skip(), but a bare `exit 0` that STRICT could not reach. +# +# These cases pin the ORDER, which is the actual invariant. Every monorepo +# fixture above commits MONO_BASE on 'base', so no case up there ever reaches +# the base-absent branch at all; and asserting the exit code alone is what let +# the original ship, since the clean base-absent case is green either way. +mononocl() { # mononocl -> a repo whose 'base' has NO changelog, 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' '# rig' > "$d/README.md" + git -C "$d" add README.md + git -C "$d" commit -qm 'base: no changelog yet' + git -C "$d" checkout -q -b work + printf '%s' "$d" +} +monoadd() { # monoadd — the branch INTRODUCES CHANGELOG.md + local d="$1"; shift + printf '%s\n' "$@" > "$d/CHANGELOG.md" + git -C "$d" add CHANGELOG.md + git -C "$d" commit -qm 'work: introduce the changelog' +} + +# The changelog is absent at the merge base AND the branch introduces a +# duplicate. Before the fix this exited 0 on "nothing could have been deleted". +T="$(mononocl 98-newdup)" +monoadd "$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.' +check "monotonic: a duplicate introduced where the base had NO changelog is CAUGHT (#98)" 1 \ + "DUPLICATE release heading" mono "$T" +check "monotonic: ...and STRICT does not change that (it was never a skip)" 1 \ + "DUPLICATE release heading" mono "$T" CHANGELOG_MONOTONIC_STRICT=1 +# ...and the clean counterpart still passes, now SAYING uniqueness ran. Without +# this the case above could be satisfied by failing the base-absent path +# outright, which would redden every changelog-introducing branch. +T="$(mononocl 98-newok)" +monoadd "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \ + '- **A shipped thing** (#1) — prose.' +check "monotonic: ...while a CLEAN introduced changelog still passes" 0 \ + "nothing could have been deleted" mono "$T" +check "monotonic: ...saying uniqueness was checked, not that nothing was" 0 \ + "uniqueness on HEAD already passed" mono "$T" + +# No git at all (a tarball, an unpacked release): uniqueness still has +# everything it needs, so a duplicate is caught rather than skipped past. +mkdir -p "$WORK/mono-98-nogit" +printf '%s\n' '# Changelog' '' '## 0.2.0 — 2026-07-19' '' \ + '## 0.2.0 — 2026-07-19' > "$WORK/mono-98-nogit/CHANGELOG.md" +check "monotonic: a duplicate OUTSIDE a git work tree is caught (#98)" 1 \ + "DUPLICATE release heading" mono "$WORK/mono-98-nogit" + +# An unresolvable base ref: same — the skip belongs to containment, not to the +# script, so uniqueness has already run by the time skip() is reachable. +T="$(monorepo 98-nobase)" +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 duplicate is caught even when the base ref will not resolve (#98)" 1 \ + "DUPLICATE release heading" mono_noref "$T" + # --- 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" + grep -qF 'origin/${{ github.base_ref' "$CIY" +# The step must NOT be pull-request-only. 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 left a duplicate reaching main by any other route +# unasserted. Dropping the gate is only safe with the ref_name fallback: +# `github.base_ref` is EMPTY on a push, a bare `origin/` does not resolve, and +# STRICT=1 promotes that to a hard failure on every push to main. +check "ci.yml: the monotonic step is NOT gated to pull_request (#98)" 1 "" \ + grep -qF "if: github.event_name == 'pull_request'" "$CIY" +# shellcheck disable=SC2016 # the $-string is a literal in the target file +check "ci.yml: ...and falls back to ref_name, so a push has a base to resolve" 0 "" \ + grep -qF 'github.base_ref || github.ref_name' "$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 "" \ From c199e8fd0498361e963cfd86fb459137095dbe12 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 20 Jul 2026 20:49:02 +0000 Subject: [PATCH 3/4] fix(changelog-monotonic): report containment vacuous when the base IS HEAD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping the pull_request gate made merge_base == HEAD a routine path rather than a degradation, and the success line did not follow. On every push to main the step printed "all N release heading(s) at the merge base are still present" — a containment claim on the one event where deletion is undetectable, since the comparison is the file against itself. That is the dishonesty this PR fixed in the skip messages, surviving in the success message. The line now has two forms: containment vacuous, naming uniqueness as the half that ran, or the existing containment wording when a real base exists. Both pinned, plus a negative that the two do not collapse. The "an untouched branch passes" case turned out to be this exact shape — its fixture never commits on 'work', so it was asserting containment on a self-comparison. Its assertion moved to uniqueness's count, and a companion case with a real base now carries the containment wording it used to claim. Also scopes the ci.yml negative pin to the monotonic step's own block. As a file-wide grep it forbade any FUTURE step from being pull_request-gated and would have failed citing #98 when one legitimately was. A companion check keeps the awk extractor from matching nothing and going tautological. Ported from heavy-duty/box#144 (box@94d830c). Co-Authored-By: Claude Opus 4.8 --- .github/scripts/changelog-monotonic.sh | 15 ++++++- CHANGELOG.md | 20 ++++++++-- test/release.sh | 54 +++++++++++++++++++++++--- 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/.github/scripts/changelog-monotonic.sh b/.github/scripts/changelog-monotonic.sh index 7bbdd5a..b684723 100755 --- a/.github/scripts/changelog-monotonic.sh +++ b/.github/scripts/changelog-monotonic.sh @@ -228,4 +228,17 @@ EOF 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" +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 in #98 — +# 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/CHANGELOG.md b/CHANGELOG.md index fca6ff6..38cc733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,10 +74,22 @@ on the way to cutting its first release, and this file starts there. Fixed by moving, not rewriting: uniqueness now runs directly after the file exists, before any git access, under a boundary comment saying so. The skip messages say *containment* skipped and that uniqueness already passed, so a - skip no longer claims nothing was checked. The CI step 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: + skip no longer claims nothing was checked — and the **success** line got the + same treatment, because dropping the gate below 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. Both forms are pinned, including a negative that the + two do not collapse. + + The CI step 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. The pin that holds that + open is scoped to the step's own block: as a file-wide grep it forbade any + FUTURE step in `ci.yml` from being `pull_request`-gated, and a companion + check keeps the block extractor from silently matching nothing and turning + the negative into a tautology. 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`. The regression cases pin the ORDER rather than the exit diff --git a/test/release.sh b/test/release.sh index 7766bec..d8a4c94 100644 --- a/test/release.sh +++ b/test/release.sh @@ -230,11 +230,37 @@ mono() { # mono [VAR=val ...] — run the guard there, base ref 'base' ( 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. +# An untouched branch with NO commit of its own: 'work' still points at the +# base commit, so the merge base IS HEAD and containment compared the file +# against itself. That is the vacuous path (#98), not a containment result — +# the green message therefore names uniqueness, the half that actually ran. +# A guard that prints nothing is indistinguishable from one that did nothing, +# but a guard that prints the WRONG half is worse: it is a false receipt. T="$(monorepo clean)" -check "monotonic: an untouched branch passes" 0 "all 2 release heading(s)" mono "$T" +check "monotonic: an untouched branch passes" 0 "uniqueness on HEAD checked 2" mono "$T" +check "monotonic: ...saying containment was VACUOUS, not that it verified 2" 0 \ + "containment vacuous" mono "$T" +# A negative, because the point is that the two wordings do NOT collapse: with +# the pull_request gate gone (#98) this is the shape of EVERY push to main, and +# "are still present" there would be a containment claim on the one event where +# deletion is undetectable by construction. +# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately +check "monotonic: ...and never claims the headings are still present" 1 "" \ + bash -c 'cd "$1" && bash "$2" base | grep -q "are still present"' _ "$T" "$MONO" + +# The same shape against a REAL base — an unrelated commit on 'work', the +# changelog untouched — which is what an untouched-changelog PR branch +# actually looks like. Here containment genuinely ran and held, so this is +# the case that pins the containment wording and its count. The two forms +# must not collapse into one another. +T="$(monorepo clean-realbase)" +printf '%s\n' '# rig' > "$T/README.md" +git -C "$T" add README.md +git -C "$T" commit -qm 'work: an unrelated commit, changelog untouched' +check "monotonic: an untouched changelog on a REAL base reports containment" 0 \ + "all 2 release heading(s)" mono "$T" +check "monotonic: ...and says they are still present, the containment claim" 0 \ + "are still present" mono "$T" # The legitimate edit this guard must never object to: a new entry INSERTED # above the shipped heading, which is left alone. @@ -407,8 +433,24 @@ check "ci.yml: ...against the PR's base branch" 0 "" \ # unasserted. Dropping the gate is only safe with the ref_name fallback: # `github.base_ref` is EMPTY on a push, a bare `origin/` does not resolve, and # STRICT=1 promotes that to a hard failure on every push to main. -check "ci.yml: the monotonic step is NOT gated to pull_request (#98)" 1 "" \ - grep -qF "if: github.event_name == 'pull_request'" "$CIY" +# +# Scoped to the step's OWN block, deliberately. As a file-wide grep this +# negative forbade any FUTURE step in ci.yml from being pull_request-gated and +# would have failed citing #98 when one legitimately was — #98 constrains this +# step, not the file. The companion check below is what keeps the awk honest: +# an extractor that matched nothing would turn the negative into a tautology +# that passes forever, including after someone renames the step and re-adds +# the gate. +mono_step_block() { + awk '/^ - name: no shipped changelog heading/ {f=1; print; next} + f && /^ - name: / {exit} + f {print}' "$CIY" +} +mono_step_gated() { mono_step_block | grep -q 'if:'; } +check "ci.yml: the monotonic step itself is NOT pull_request-gated (#98)" 1 "" \ + mono_step_gated +check "ci.yml: ...and the block was actually found (guards the awk above)" 0 \ + "changelog-monotonic" mono_step_block # shellcheck disable=SC2016 # the $-string is a literal in the target file check "ci.yml: ...and falls back to ref_name, so a push has a base to resolve" 0 "" \ grep -qF 'github.base_ref || github.ref_name' "$CIY" From 1db7779b03f4e63b5dfcfa4ee5912c7b99067b00 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 20 Jul 2026 20:56:57 +0000 Subject: [PATCH 4/4] test: terminate the ci.yml step block at the job boundary too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The monotonic step is the LAST step of its job, so stopping only at the next `- name:` ran the block into the job below and swallowed that job level `if:`. Unanchored `grep -q "if:"` then fired on it — the same bug the scoping was meant to fix, moved from "any step in the file" to "this step plus the head of the next job". Terminates on a new step OR a new job now, and the key is anchored so an `if:` inside a `run:` line is not mistaken for a step condition. Found by claude-bot-andresmgsl on heavy-duty/box#144; this port carried the identical awk. Co-Authored-By: Claude Opus 4.8 --- test/release.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/release.sh b/test/release.sh index d8a4c94..df95703 100644 --- a/test/release.sh +++ b/test/release.sh @@ -441,12 +441,18 @@ check "ci.yml: ...against the PR's base branch" 0 "" \ # an extractor that matched nothing would turn the negative into a tautology # that passes forever, including after someone renames the step and re-adds # the gate. +# Terminates on a new STEP or a new JOB. The job boundary is not optional: the +# monotonic step is the LAST step of its job, so stopping only at the next +# `- name:` runs the block into the job below and swallows that job's +# level `if:` — the same bug this scoping fixed, moved from "any step in the +# file" to "this step plus the head of the next job" (found on box#144). mono_step_block() { awk '/^ - name: no shipped changelog heading/ {f=1; print; next} - f && /^ - name: / {exit} + f && (/^ - / || /^ [^ ]/) {exit} f {print}' "$CIY" } -mono_step_gated() { mono_step_block | grep -q 'if:'; } +# Anchored: an `if:` inside a `run:` line is not a step condition. +mono_step_gated() { mono_step_block | grep -q '^ if:'; } check "ci.yml: the monotonic step itself is NOT pull_request-gated (#98)" 1 "" \ mono_step_gated check "ci.yml: ...and the block was actually found (guards the awk above)" 0 \