From 94d830cdb34a1bff2ed5f03745a99da94e721bfb Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 20 Jul 2026 20:35:01 +0000 Subject: [PATCH] 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. 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 #143 when one legitimately was. Co-Authored-By: Claude Opus 4.8 --- .github/scripts/changelog-monotonic.sh | 15 +++++++++++- CHANGELOG.md | 11 +++++++-- test/release.sh | 34 +++++++++++++++++++++++--- 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/.github/scripts/changelog-monotonic.sh b/.github/scripts/changelog-monotonic.sh index 836357d..999e74e 100755 --- a/.github/scripts/changelog-monotonic.sh +++ b/.github/scripts/changelog-monotonic.sh @@ -209,4 +209,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 — 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 b127df5..11e5e9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,8 +120,15 @@ which records not just what changed but what each drill run proved. 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. 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 + 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 diff --git a/test/release.sh b/test/release.sh index 806e0d8..e34ee5c 100644 --- a/test/release.sh +++ b/test/release.sh @@ -457,12 +457,31 @@ G="$(grepo mono-143-nobase '## 0.8.0 — 2026-07-19' '' '- **Shipped**')" head_changelog "$G" '## 0.8.0 — 2026-07-19' '' '- **a**' '' '## 0.8.0 — 2026-07-19' '' '- **b**' check "monotonic: a duplicate is caught even when the base ref will not resolve (#143)" 1 "DUPLICATE release heading" mono "$G" no-such-ref +# --- the push-to-main shape: containment vacuous, uniqueness real ---------- +# With the pull_request gate gone (#143), 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 this pins which one each event gets. +G="$(grepo mono-vacuous '## 0.8.0 — 2026-07-19' '' '- **Shipped**')" +check "monotonic: HEAD as its own base reports containment VACUOUS, not verified" 0 "containment vacuous" mono "$G" HEAD +check "monotonic: ...and names uniqueness as the half that actually ran" 0 "uniqueness on HEAD checked" mono "$G" HEAD +check "monotonic: ...and does NOT claim headings were still present" 1 "" \ + bash -c 'cd "$1" && bash "$2" HEAD | grep -q "are still present"' _ "$G" "$MONO" +# The PR shape keeps the containment wording — the two must not collapse. +head_changelog "$G" '## 0.8.0 — 2026-07-19' '' '- **Shipped**' '' '## 0.9.0 — 2026-07-20' '' '- **New**' +check "monotonic: a real base still reports containment, naming the count" 0 "still present" mono "$G" main + # --- and the real tree, through the real script ---------------------------- # HEAD as its own base: the merge base is HEAD, so the sets are identical by # construction. Proves the script runs against the actual CHANGELOG.md and # parses its real headings, without depending on an `origin/main` that a # fresh clone or a detached CI checkout may not have. -check "monotonic: THIS tree passes against itself (the parser meets reality)" 0 "still present" \ +# HEAD as its own base is now the VACUOUS-containment path (#143), so the +# assertion moved to uniqueness's count — which is the stronger proof of the +# original intent anyway: it says the parser read the REAL CHANGELOG.md and +# found real headings in it, rather than that a self-comparison came out equal. +check "monotonic: THIS tree passes against itself (the parser meets reality)" 0 "uniqueness on HEAD checked" \ mono "$ROOT" HEAD # The guard is only a guard if CI runs it — and only if CI runs it with the @@ -478,8 +497,17 @@ check "ci.yml: ...and STRICT, so a skip is a red run and not a green one" 0 "" \ # tree — and dropping that gate is only safe with the base-ref fallback, since # `github.base_ref` is empty on a push and a bare `origin/` under STRICT is a # hard failure on every push to main. -check "ci.yml: the monotonic step is not gated to pull_request (#143)" 1 "" \ - grep -qF "if: github.event_name == 'pull_request'" "$ROOT/.github/workflows/ci.yml" +# Scoped to the step's OWN block, deliberately. A file-wide negative would +# forbid any FUTURE step in ci.yml from being pull_request-gated and would fail +# citing #143 when one legitimately is — #143 constrains this step, not the file. +mono_step_block() { + awk '/^ - name: no shipped changelog heading/ {f=1; print; next} + f && /^ - name: / {exit} + f {print}' "$ROOT/.github/workflows/ci.yml" +} +mono_step_gated() { mono_step_block | grep -q 'if:'; } +check "ci.yml: the monotonic step itself is not pull_request-gated (#143)" 1 "" mono_step_gated +check "ci.yml: ...and the block was actually found (guards the awk above)" 0 "changelog-monotonic" mono_step_block 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' "$ROOT/.github/workflows/ci.yml" check "CONTRIBUTING: names the append-only rule for release headings" 0 "" \