fix(changelog-monotonic): report containment vacuous when the base IS HEAD
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 <noreply@anthropic.com>
This commit is contained in:
parent
7ffc30bacc
commit
c199e8fd04
3 changed files with 78 additions and 11 deletions
15
.github/scripts/changelog-monotonic.sh
vendored
15
.github/scripts/changelog-monotonic.sh
vendored
|
|
@ -228,4 +228,17 @@ EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
count="$(printf '%s\n' "$base_headings" | grep -c . || true)"
|
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
|
||||||
|
|
|
||||||
20
CHANGELOG.md
20
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
|
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
|
exists, before any git access, under a boundary comment saying so. The skip
|
||||||
messages say *containment* skipped and that uniqueness already passed, so a
|
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
|
skip no longer claims nothing was checked — and the **success** line got the
|
||||||
gated to `pull_request` — deletion is vacuous on a push to main, but
|
same treatment, because dropping the gate below made `merge_base == HEAD` a
|
||||||
duplication is vacuous on no tree, so a duplicate reaching main by any other
|
routine path rather than a degradation. On a push to main containment
|
||||||
route went unasserted. That gate could not simply be dropped:
|
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`
|
`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
|
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
|
`github.ref_name`. The regression cases pin the ORDER rather than the exit
|
||||||
|
|
|
||||||
|
|
@ -230,11 +230,37 @@ mono() { # mono <dir> [VAR=val ...] — run the guard there, base ref 'base'
|
||||||
( cd "$d" && env "$@" bash "$MONO" base 2>&1 )
|
( cd "$d" && env "$@" bash "$MONO" base 2>&1 )
|
||||||
}
|
}
|
||||||
|
|
||||||
# An untouched branch: the head heading-set equals the base's, so containment
|
# An untouched branch with NO commit of its own: 'work' still points at the
|
||||||
# holds trivially. The green message names the count it checked, because a
|
# base commit, so the merge base IS HEAD and containment compared the file
|
||||||
# guard that prints nothing is indistinguishable from one that did nothing.
|
# 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)"
|
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
|
# The legitimate edit this guard must never object to: a new entry INSERTED
|
||||||
# above the shipped heading, which is left alone.
|
# 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:
|
# 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
|
# `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.
|
# 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
|
# 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 "" \
|
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"
|
grep -qF 'github.base_ref || github.ref_name' "$CIY"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue