fix: catch a deleted or duplicated release heading in CHANGELOG.md #99

Merged
dan-claude-bot merged 4 commits from fix/changelog-monotonic into main 2026-07-20 23:36:37 +00:00
3 changed files with 78 additions and 11 deletions
Showing only changes of commit c199e8fd04 - Show all commits

View file

@ -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

View file

@ -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

View file

@ -230,11 +230,37 @@ mono() { # mono <dir> [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"