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"