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"