Merge pull request #126 from dan-claude-bot/fix/changelog-heading-monotonicity
fix: refuse a PR that deletes a shipped changelog heading
This commit is contained in:
commit
add1c2a142
5 changed files with 423 additions and 0 deletions
200
.github/scripts/changelog-monotonic.sh
vendored
Executable file
200
.github/scripts/changelog-monotonic.sh
vendored
Executable file
|
|
@ -0,0 +1,200 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# changelog-monotonic.sh [<base-ref>] [<changelog>] — assert that no SHIPPED
|
||||
# release heading was DELETED by this branch: the set of '^## X.Y.Z' headings
|
||||
# on HEAD must be a SUPERSET of the set at the merge base.
|
||||
#
|
||||
# The failure it exists to catch (#122, caught in review of #118) leaves no
|
||||
# trace either. An author adding an entry under '## Unreleased' REPLACES the
|
||||
# line below it instead of inserting above it:
|
||||
#
|
||||
# -## 0.8.0 — 2026-07-19
|
||||
# +## Unreleased
|
||||
# +
|
||||
# +### Fixed
|
||||
# +
|
||||
# +- **An entry**
|
||||
#
|
||||
# git merges that cleanly — it is a one-line edit inside a file nobody has
|
||||
# touched concurrently — and the shipped section's whole body is silently
|
||||
# absorbed into '## Unreleased'. 0.8.0 no longer HAS a section; the notes
|
||||
# anchor release-notes.sh extracts by is gone, and the next release cut from
|
||||
# that state republishes 0.8.0's prose as if it were new.
|
||||
#
|
||||
# changelog-armed.sh is green on exactly that tree, correctly: it asks only
|
||||
# whether the TOP section agrees with VERSION, and deleting '## 0.8.0' leaves
|
||||
# '## Unreleased' on top. It is not wrong, it is narrow — it guards ONE
|
||||
# heading, the one a PR is about to write under. This guards the REST of the
|
||||
# file, the part no single tree can be asked about at all, because "a heading
|
||||
# disappeared" is not a property of a tree — it is a property of a DIFF.
|
||||
#
|
||||
# The rule, and why it needs no tuning: release headings are APPEND-ONLY. The
|
||||
# ceremony (#96) adds one and never removes one; nothing else in the documented
|
||||
# flow (CONTRIBUTING.md, "Releases") touches them. So SUPERSET is exact — it
|
||||
# has no legitimate violation to carve an exception for. The stamp is covered
|
||||
# for free: rewriting '## Unreleased' -> '## X.Y.Z — DATE' ADDS X.Y.Z and
|
||||
# removes no X.Y.Z heading, because 'Unreleased' is not one. '## Unreleased'
|
||||
# is deliberately NOT in the set this guards — changelog-armed.sh owns that
|
||||
# heading, keyed on VERSION, and the ceremony legitimately consumes it.
|
||||
#
|
||||
# A file of its own, NOT a clause inside changelog-armed.sh, for three
|
||||
# reasons. Its input is different (a git history, not two files). Its
|
||||
# degradation is different (no base ref is a SKIP, not a failure). And
|
||||
# changelog-armed.sh is driven by test/release.sh against constructed
|
||||
# two-file trees that are not git repos at all — folding a git-dependent
|
||||
# assert into it would make every one of those cases either skip or lie.
|
||||
# Same discipline as release-notes.sh: its own file so a test can drive it.
|
||||
|
||||
base_ref="${1:-${CHANGELOG_MONOTONIC_BASE:-origin/main}}"
|
||||
changelog="${2:-CHANGELOG.md}"
|
||||
|
||||
# Fail-closed switch: CI sets it, so a SKIP that would be a sensible local
|
||||
# degradation becomes a red run there instead. A guard that can silently
|
||||
# stop guarding is the failure shape this whole family of checks exists to
|
||||
# refuse, so the skip path is loud and CI refuses to take it at all.
|
||||
strict="${CHANGELOG_MONOTONIC_STRICT:-0}"
|
||||
|
||||
skip() {
|
||||
if [ "$strict" = "1" ]; then
|
||||
echo "changelog-monotonic: $* — and CHANGELOG_MONOTONIC_STRICT=1, so this is a FAILURE, not a skip." >&2
|
||||
echo " CI sets STRICT because a guard that quietly stops guarding is worse than no guard." >&2
|
||||
echo " Fix the checkout, not this script: the base ref must be fetched (fetch-depth: 0)." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "changelog-monotonic: SKIPPED — $*"
|
||||
echo " (Nothing was checked. In CI this same condition is a hard failure.)"
|
||||
exit 0
|
||||
}
|
||||
|
||||
[ -f "$changelog" ] || { echo "changelog-monotonic: no such file: $changelog" >&2; exit 1; }
|
||||
|
||||
git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|
||||
|| skip "not inside a git work tree, so there is no history to compare against"
|
||||
|
||||
git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null \
|
||||
|| skip "base ref '$base_ref' does not resolve here (a shallow clone, or a fork checkout without the upstream remote)"
|
||||
|
||||
merge_base="$(git merge-base "$base_ref" HEAD 2>/dev/null || true)"
|
||||
[ -n "$merge_base" ] \
|
||||
|| skip "no merge base between '$base_ref' and HEAD (unrelated histories, or a clone too shallow to reach one)"
|
||||
|
||||
# The set of RELEASE headings: '## <token> ...' where <token> looks like a
|
||||
# version. Field $2, the same split changelog-armed.sh and release-notes.sh
|
||||
# use, so the three cannot disagree about what a section header is.
|
||||
# 'Unreleased' fails the shape and is excluded by construction.
|
||||
headings_raw() {
|
||||
awk '
|
||||
/^## / && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+/ { print $2 }
|
||||
'
|
||||
}
|
||||
headings() { headings_raw | sort -u; }
|
||||
|
||||
# The changelog may not exist at the merge base at all (the commit that adds
|
||||
# it). Nothing to have deleted, so nothing to assert.
|
||||
base_file="$(git show "$merge_base:$changelog" 2>/dev/null || true)"
|
||||
[ -n "$base_file" ] || {
|
||||
echo "changelog-monotonic: $changelog does not exist at the merge base ($(git rev-parse --short "$merge_base")) — nothing could have been deleted."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# --- uniqueness on HEAD (the #118 class) -------------------------------------
|
||||
# Containment catches a DELETED heading. It cannot catch a DUPLICATED one: the
|
||||
# duplicate is head-side SURPLUS, and `comm -23` (base minus head) is blind to
|
||||
# extras on the head side — with or without `sort -u`, base {0.8.0} minus head
|
||||
# {0.8.0, 0.8.0} is empty. Multiset comparison does not close it either, for
|
||||
# the same reason. The assert that does is uniqueness of version headings ON
|
||||
# HEAD, kept alongside containment rather than replacing it.
|
||||
#
|
||||
# This is the shape #118's bad rebase actually produced: two
|
||||
# `## 0.8.0 — 2026-07-19` headings with the incoming entry between them. Every
|
||||
# other guard stayed green — markers absent, changelog-armed.sh happy (the top
|
||||
# section was still right), tests and shellcheck clean — while
|
||||
# release-notes.sh re-armed its grab on the second heading and folded post-cut
|
||||
# prose into the shipped release body.
|
||||
#
|
||||
# Nothing legitimate repeats a version heading: the ceremony stamps a NEW
|
||||
# version, and 'Unreleased' fails the version shape and never reaches here.
|
||||
dupes="$(headings_raw < "$changelog" | sort | uniq -d)"
|
||||
if [ -n "$dupes" ]; then
|
||||
{
|
||||
echo "changelog-monotonic: $changelog has DUPLICATE release heading(s):"
|
||||
echo
|
||||
printf '%s\n' "$dupes" | sed 's/^/ ## /'
|
||||
echo
|
||||
cat <<EOF
|
||||
Each version heading must appear exactly once. A repeat splits one release
|
||||
into two same-named sections, and release-notes.sh re-arms its extraction on
|
||||
every matching '## ' line — so the published body for that version absorbs
|
||||
whatever sits between the copies, and an entry stranded there is dropped from
|
||||
the NEXT release's notes as well.
|
||||
|
||||
This is the #118 shape: an entry meant for '## Unreleased' was inserted after
|
||||
a shipped heading, and the heading re-added below it. The fix is one heading,
|
||||
with the entry above it under '## Unreleased':
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Your entry**
|
||||
|
||||
## $(printf '%s\n' "$dupes" | head -1) — DATE <- exactly once
|
||||
|
||||
Quick check on any changelog-touching rebase:
|
||||
|
||||
diff <(git show origin/main:$changelog | grep '^## ') <(grep '^## ' $changelog)
|
||||
EOF
|
||||
} >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
base_headings="$(printf '%s\n' "$base_file" | headings)"
|
||||
head_headings="$(headings < "$changelog")"
|
||||
|
||||
# comm -23: lines in the base set that are NOT in the head set — exactly the
|
||||
# headings this branch removed.
|
||||
missing="$(comm -23 <(printf '%s\n' "$base_headings") <(printf '%s\n' "$head_headings"))"
|
||||
|
||||
if [ -n "$missing" ]; then
|
||||
{
|
||||
echo "changelog-monotonic: this branch DELETES release heading(s) from $changelog:"
|
||||
echo
|
||||
printf '%s\n' "$missing" | sed 's/^/ ## /'
|
||||
echo
|
||||
cat <<EOF
|
||||
Present at the merge base ($(git rev-parse --short "$merge_base")), absent on HEAD.
|
||||
|
||||
Release headings are APPEND-ONLY. The ceremony adds one (#96); nothing ever
|
||||
legitimately removes one. So this is not a judgement call — it is a defect,
|
||||
and almost always the same one (#122, caught in review of #118): an entry
|
||||
written under '## Unreleased' REPLACED the heading below it instead of being
|
||||
inserted ABOVE it. The shipped section's body is now sitting under
|
||||
'## Unreleased', and the version it belonged to has no section at all.
|
||||
|
||||
Nothing else will say so. git merges that edit cleanly — no conflict, no
|
||||
signal — and changelog-armed.sh stays green, because the TOP section is
|
||||
still the right one for this VERSION. The damage surfaces at the NEXT
|
||||
release, when release-notes.sh cannot find the section it extracts by
|
||||
heading, or worse, republishes the absorbed prose as if it were new.
|
||||
|
||||
The fix is to put the heading back and INSERT above it, never over it:
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Your entry**
|
||||
|
||||
## $(printf '%s\n' "$missing" | head -1) — DATE <- untouched, still here
|
||||
|
||||
If you are genuinely renaming a released version, that is a rewrite of
|
||||
history this guard is meant to stop; say so in the PR and change the guard
|
||||
deliberately, in its own commit.
|
||||
EOF
|
||||
} >&2
|
||||
exit 1
|
||||
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"
|
||||
25
.github/workflows/ci.yml
vendored
25
.github/workflows/ci.yml
vendored
|
|
@ -8,6 +8,18 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# fetch-depth: 0, for the changelog-monotonic step below and only
|
||||
# for it. That check is about a DIFF — which release headings the
|
||||
# merge base had — so it needs the base branch's history present,
|
||||
# and the default depth-1 checkout has none of it. An explicit
|
||||
# `git fetch origin <base>` would be narrower, but it has to be
|
||||
# right on both event types and on fork PRs, and getting it subtly
|
||||
# wrong degrades to a SKIP (a guard that silently stops guarding —
|
||||
# the exact failure this repo keeps refusing). Full history on a
|
||||
# pure-bash tree costs a second; the STRICT flag below turns any
|
||||
# remaining skip red rather than green.
|
||||
fetch-depth: 0
|
||||
- name: shellcheck
|
||||
# -x follows `source`/`.` directives; box has no lib split today, but the
|
||||
# flag costs nothing and keeps the invocation identical to rig's.
|
||||
|
|
@ -56,6 +68,19 @@ jobs:
|
|||
# says which check found the drift without anyone reading a suite.
|
||||
- name: changelog is armed for the next entry
|
||||
run: bash .github/scripts/changelog-armed.sh
|
||||
# ...and no SHIPPED release heading was deleted (#122). Its own step for
|
||||
# the same reason as the one above — when it goes red the log names the
|
||||
# invariant that broke — but a DIFFERENT invariant: armed is a fact
|
||||
# about this tree, monotonicity is a fact about this tree versus its
|
||||
# merge base. Pull requests only: on a push to main the merge base IS
|
||||
# HEAD, so the assert is vacuous and would only add a green step that
|
||||
# proves nothing. STRICT=1 so a checkout that cannot reach the base ref
|
||||
# fails here instead of skipping quietly forever.
|
||||
- name: no shipped changelog heading was deleted
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
CHANGELOG_MONOTONIC_STRICT: '1'
|
||||
run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref }}"
|
||||
|
||||
# The multi-user rehearsal, on a REAL incus — a GitHub runner is root on a
|
||||
# disposable VM, which is exactly the substrate the rehearsal needs. It runs
|
||||
|
|
|
|||
33
CHANGELOG.md
33
CHANGELOG.md
|
|
@ -81,6 +81,39 @@ which records not just what changed but what each drill run proved.
|
|||
a shopt subtlety hides one. `eof_guard_sweep` itself carried the identical
|
||||
blind spot — it rebuilds the same glob — and is widened the same way.
|
||||
|
||||
- **A PR can no longer delete a shipped changelog section and stay green**
|
||||
(#122) — caught in review of #118, where an entry added under
|
||||
`## Unreleased` *replaced* the line `## 0.8.0 — 2026-07-19` instead of
|
||||
being inserted above it. The whole shipped 0.8.0 record was absorbed into
|
||||
`## Unreleased`, git merged it cleanly — a one-line edit, no conflict, no
|
||||
signal — and `changelog-armed.sh` was green on that exact tree, correctly:
|
||||
it asks only whether the TOP section agrees with `VERSION`, and
|
||||
`## Unreleased` was still on top. The damage would have surfaced at the
|
||||
next release, when `release-notes.sh` could no longer find the section it
|
||||
extracts by heading, or worse, republished the absorbed prose as new.
|
||||
`.github/scripts/changelog-monotonic.sh` asserts the complementary
|
||||
invariant on every PR: release headings are **append-only**, so the set of
|
||||
`## X.Y.Z` headings on a branch must be a **superset** of the set at its
|
||||
merge base. A separate script rather than a clause in `changelog-armed.sh`
|
||||
because "a heading disappeared" is a property of a DIFF, not of a tree —
|
||||
and because `changelog-armed.sh` is driven against constructed non-git
|
||||
fixtures that could not express it. The ceremony's stamp passes by
|
||||
construction (it adds `X.Y.Z`, removes none), and no base ref to compare
|
||||
against is a loud SKIP locally but a hard failure in CI, which sets
|
||||
`CHANGELOG_MONOTONIC_STRICT=1` and checks out with `fetch-depth: 0` so the
|
||||
guard can never quietly stop guarding.
|
||||
|
||||
Review of this PR found the guard's first cut incomplete, and the gap is the
|
||||
shape the incident *actually* had. Containment catches a **deleted** heading;
|
||||
it cannot catch a **duplicated** one, because the duplicate is head-side
|
||||
surplus and `comm -23` (base minus head) is blind to extras on the head side
|
||||
— with or without `sort -u`, and multiset comparison does not close it for
|
||||
the same reason. So the guard now also asserts that version headings are
|
||||
**unique on HEAD**, alongside containment rather than instead of it. Nothing
|
||||
legitimate repeats one: the ceremony stamps a new version, and `Unreleased`
|
||||
fails the version shape. Both trees are pinned in `test/release.sh` — the
|
||||
deletion near-miss and the real duplicate — each with `changelog-armed.sh`
|
||||
asserted green on it, which is the whole reason this script exists.
|
||||
|
||||
## 0.8.0 — 2026-07-19
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,22 @@ A release is a PR, and merging it ships it
|
|||
the release itself makes. Do it in the ceremony PR and main is never
|
||||
disarmed at all.
|
||||
|
||||
**Release headings are append-only.** When you add your entry under
|
||||
`## Unreleased`, *insert above* the heading below it — never type over
|
||||
that line. Replacing `## 0.8.0 — 2026-07-19` with your own `## Unreleased`
|
||||
block deletes a shipped section: its prose is absorbed into `Unreleased`,
|
||||
`release-notes.sh` can no longer find the version it extracts by heading,
|
||||
and the next release republishes the absorbed prose as if it were new. git
|
||||
merges that edit cleanly and `changelog-armed.sh` stays green on it — the
|
||||
top section is still the right one — so
|
||||
[.github/scripts/changelog-monotonic.sh](.github/scripts/changelog-monotonic.sh)
|
||||
asserts the other half on every PR: the set of `## X.Y.Z` headings on your
|
||||
branch must be a **superset** of the set at the merge base
|
||||
([#122](https://github.com/heavy-duty/box/issues/122), caught in review of
|
||||
[#118](https://github.com/heavy-duty/box/pull/118)). The ceremony's own
|
||||
stamp passes it by construction — rewriting `## Unreleased` into
|
||||
`## X.Y.Z — DATE` adds a heading and removes none.
|
||||
|
||||
This PR is where the release ritual hangs:
|
||||
the full drill on real hardware, recorded in
|
||||
[drill/RUNS.md](drill/RUNS.md) — CI proves the tier's semantics on every
|
||||
|
|
|
|||
149
test/release.sh
149
test/release.sh
|
|
@ -278,6 +278,155 @@ check "CONTRIBUTING: the ceremony re-arms '## Unreleased' after stamping" 0 "" \
|
|||
check "CONTRIBUTING: ...and names the guard that enforces it" 0 "" \
|
||||
grep -qF 'changelog-armed.sh' "$ROOT/CONTRIBUTING.md"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# changelog-monotonic.sh (#122) — no SHIPPED release heading may be DELETED.
|
||||
#
|
||||
# The complement of the guard above, and the reason it is a separate script:
|
||||
# changelog-armed.sh asks about ONE tree ("does the top section agree with
|
||||
# VERSION?"), which is exactly why it was green on #118's broken branch — the
|
||||
# top section was still '## Unreleased'. "A heading disappeared" is not a
|
||||
# property of a tree at all; it is a property of a DIFF. So these cases are
|
||||
# driven against real, constructed GIT REPOS with a base commit and a branch
|
||||
# commit, not the two-file trees above — a fixture without history cannot
|
||||
# express the failure being guarded.
|
||||
#
|
||||
# The #118 shape is reconstructed verbatim as the first case: the line
|
||||
# '## 0.8.0 — 2026-07-19' replaced by an entry written under '## Unreleased'.
|
||||
# The ceremony's own stamp is driven right beside it, because a rule that
|
||||
# fires on the stamp is unshippable for the same reason rig#44 and cast#108
|
||||
# were — that pair, not the failing case alone, is what makes this a design.
|
||||
# ---------------------------------------------------------------------------
|
||||
MONO="$ROOT/.github/scripts/changelog-monotonic.sh"
|
||||
check "changelog-monotonic: runnable bash" 0 "" bash -n "$MONO"
|
||||
|
||||
# grepo <name> <base-changelog-lines...> — a git repo whose `main` carries the
|
||||
# given changelog, left checked out on a branch `pr` off it. Prints the dir.
|
||||
grepo() {
|
||||
local d="$WORK/$1"; shift
|
||||
mkdir -p "$d"
|
||||
git -C "$d" init -q -b main
|
||||
git -C "$d" config user.email test@example.invalid
|
||||
git -C "$d" config user.name test
|
||||
{ echo "# Changelog"; echo; printf '%s\n' "$@"; } > "$d/CHANGELOG.md"
|
||||
git -C "$d" add CHANGELOG.md
|
||||
git -C "$d" commit -qm base
|
||||
git -C "$d" checkout -q -b pr
|
||||
echo "$d"
|
||||
}
|
||||
# head_changelog <dir> <lines...> — the PR branch's version of the file
|
||||
head_changelog() {
|
||||
local d="$1"; shift
|
||||
{ echo "# Changelog"; echo; printf '%s\n' "$@"; } > "$d/CHANGELOG.md"
|
||||
git -C "$d" commit -qam head
|
||||
}
|
||||
mono() { local d="$1"; shift; ( cd "$d" && bash "$MONO" "$@" ); }
|
||||
mono_strict() { local d="$1"; shift; ( cd "$d" && CHANGELOG_MONOTONIC_STRICT=1 bash "$MONO" "$@" ); }
|
||||
|
||||
# --- the #118 incident, reconstructed --------------------------------------
|
||||
G="$(grepo mono-118 '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '### Added' '' '- **Shipped prose**')"
|
||||
head_changelog "$G" '## Unreleased' '' '### Fixed' '' '- **An entry**' '' '### Added' '' '- **Shipped prose**'
|
||||
check "monotonic: a DELETED release heading fails (the #118 near-miss)" 1 "DELETES release heading" mono "$G" main
|
||||
check "monotonic: ...and names the heading that vanished" 1 "## 0.8.0" mono "$G" main
|
||||
check "monotonic: ...and the shape of the mistake (replaced, not inserted)" 1 "instead of being" mono "$G" main
|
||||
check "monotonic: ...and why nothing else says so (git merges it cleanly)" 1 "git merges that edit cleanly" mono "$G" main
|
||||
# The whole point of the issue: the OTHER guard is green on this same tree.
|
||||
# Pinned here so a future 'just widen changelog-armed.sh' cannot quietly
|
||||
# delete the reason this script exists.
|
||||
check "monotonic: ...on a tree changelog-armed.sh calls FINE (the #122 gap)" 0 "agrees" \
|
||||
bash "$ARMED" "$G/CHANGELOG.md" "$ROOT/VERSION"
|
||||
|
||||
# --- the #118 incident as it ACTUALLY happened: a DUPLICATED heading -------
|
||||
# The deletion case above is the near-miss. What the bad rebase really produced
|
||||
# was two '## 0.8.0 — 2026-07-19' headings with the incoming entry stranded
|
||||
# between them. Containment cannot see this: the duplicate is head-side
|
||||
# SURPLUS, and `comm -23` (base minus head) is blind to extras on the head side
|
||||
# — with or without `sort -u`, and multiset comparison does not close it for
|
||||
# the same reason. Uniqueness on HEAD is the assert that does.
|
||||
G="$(grepo mono-dup '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '### Added' '' '- **Shipped prose**')"
|
||||
head_changelog "$G" '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '### Fixed' '' '- **An entry**' '' '## 0.8.0 — 2026-07-19' '' '### Added' '' '- **Shipped prose**'
|
||||
check "monotonic: a DUPLICATED release heading fails (the #118 shape)" 1 "DUPLICATE release heading" mono "$G" main
|
||||
check "monotonic: ...and names the repeated heading" 1 "## 0.8.0" mono "$G" main
|
||||
check "monotonic: ...and says what a repeat does to release-notes extraction" 1 "re-arms its extraction" mono "$G" main
|
||||
# Containment alone is green on this exact tree — nothing was deleted. Pinned
|
||||
# so a future simplification cannot collapse the two asserts into one.
|
||||
# shellcheck disable=SC2016 # $1/$2 are the inner shell's positionals, not ours
|
||||
check "monotonic: ...on a tree where NOTHING was deleted (containment is blind)" 1 "" \
|
||||
bash -c 'cd "$1" && bash "$2" main 2>&1 | grep -q "DELETES release heading"' _ "$G" "$MONO"
|
||||
# And, as with the deletion case, the other guard calls this tree fine.
|
||||
check "monotonic: ...on a tree changelog-armed.sh calls FINE" 0 "agrees" \
|
||||
bash "$ARMED" "$G/CHANGELOG.md" "$ROOT/VERSION"
|
||||
|
||||
# --- the release ceremony's stamp: an ADD, never a removal -----------------
|
||||
# '## Unreleased' -> '## 0.8.1 — DATE' adds 0.8.1 and removes no X.Y.Z
|
||||
# heading, because 'Unreleased' is not one. A false positive here would make
|
||||
# every release unshippable — the rig#44 / cast#108 failure, one guard over.
|
||||
G="$(grepo mono-stamp '## Unreleased' '' '- **Pending**' '' '## 0.8.0 — 2026-07-19' '' '- **Shipped**')"
|
||||
head_changelog "$G" '## Unreleased' '' '## 0.8.1 — 2026-07-20' '' '- **Pending**' '' '## 0.8.0 — 2026-07-19' '' '- **Shipped**'
|
||||
check "monotonic: the ceremony stamp passes (adds a heading, removes none)" 0 "still present" mono "$G" main
|
||||
|
||||
# --- the ordinary entry, done right ----------------------------------------
|
||||
G="$(grepo mono-ok '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '- **Shipped**')"
|
||||
head_changelog "$G" '## Unreleased' '' '### Fixed' '' '- **An entry**' '' '## 0.8.0 — 2026-07-19' '' '- **Shipped**'
|
||||
check "monotonic: an entry INSERTED above the top section passes" 0 "still present" mono "$G" main
|
||||
check "monotonic: ...and counts what it actually checked" 0 "all 1 release heading" mono "$G" main
|
||||
|
||||
# --- deletion is caught anywhere in the file, not just at the top ----------
|
||||
G="$(grepo mono-mid '## 0.8.0 — 2026-07-19' '' '- **a**' '' '## 0.7.0 — 2026-07-18' '' '- **b**' '' '## 0.6.0 — 2026-07-17' '' '- **c**')"
|
||||
head_changelog "$G" '## 0.8.0 — 2026-07-19' '' '- **a**' '' '- **b**' '' '## 0.6.0 — 2026-07-17' '' '- **c**'
|
||||
check "monotonic: a heading deleted MID-FILE is caught too" 1 "## 0.7.0" mono "$G" main
|
||||
# ...and only the deleted one is named, so the message points at the edit.
|
||||
notes_only_070() { ! mono "$1" main 2>&1 | grep -qE '^ ## 0\.(6|8)\.0$'; }
|
||||
check "monotonic: ...naming only the heading that went missing" 0 "" notes_only_070 "$G"
|
||||
|
||||
# --- a rewritten Unreleased is NOT a violation (changelog-armed owns it) ---
|
||||
G="$(grepo mono-unrel '## Unreleased' '' '- **Pending**' '' '## 0.8.0 — 2026-07-19' '' '- **Shipped**')"
|
||||
head_changelog "$G" '## 0.8.0 — 2026-07-19' '' '- **Shipped**'
|
||||
check "monotonic: a deleted '## Unreleased' is NOT this guard's business" 0 "still present" mono "$G" main
|
||||
|
||||
# --- a changelog that did not exist at the base ----------------------------
|
||||
G="$(grepo mono-new 'No sections yet.')"
|
||||
head_changelog "$G" '## 0.1.0 — 2026-07-20' '' '- **First**'
|
||||
check "monotonic: a base with no release headings passes (nothing to delete)" 0 "all 0 release heading" mono "$G" main
|
||||
|
||||
# --- degradation: no base to compare against -------------------------------
|
||||
# A local run may genuinely have no base ref. That must SKIP loudly, not fail
|
||||
# spuriously (which would make the script un-runnable off CI) and not pass
|
||||
# silently (which is the failure shape this repo keeps refusing). CI closes
|
||||
# the hole from the other side with STRICT.
|
||||
G="$(grepo mono-nobase '## 0.8.0 — 2026-07-19' '' '- **Shipped**')"
|
||||
check "monotonic: an unresolvable base ref SKIPS, saying nothing was checked" 0 "SKIPPED" mono "$G" no-such-ref
|
||||
check "monotonic: ...naming the base ref it could not resolve" 0 "no-such-ref" mono "$G" no-such-ref
|
||||
check "monotonic: ...and warning that CI treats it as a failure" 0 "hard failure" mono "$G" no-such-ref
|
||||
check "monotonic: STRICT turns that skip into a red run" 1 "is a FAILURE, not a skip" mono_strict "$G" no-such-ref
|
||||
check "monotonic: ...and points at the checkout, not the script" 1 "fetch-depth: 0" mono_strict "$G" no-such-ref
|
||||
# Outside a work tree at all (a tarball, an unpacked release).
|
||||
mkdir -p "$WORK/mono-nogit"
|
||||
printf '%s\n' '# Changelog' '' '## 0.8.0 — 2026-07-19' > "$WORK/mono-nogit/CHANGELOG.md"
|
||||
check "monotonic: outside a git work tree it skips rather than erroring" 0 "SKIPPED" \
|
||||
mono "$WORK/mono-nogit" main
|
||||
check "monotonic: a missing changelog refuses by path (never a skip)" 1 "no such file" \
|
||||
bash "$MONO" main "$WORK/nope.md"
|
||||
|
||||
# --- 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" \
|
||||
mono "$ROOT" HEAD
|
||||
|
||||
# The guard is only a guard if CI runs it — and only if CI runs it with the
|
||||
# history it needs. Fail-closed pins on all three, since a depth-1 checkout
|
||||
# would silently downgrade every run to the SKIP path.
|
||||
check "ci.yml: runs the changelog-monotonic guard" 0 "" \
|
||||
grep -qF 'changelog-monotonic.sh' "$ROOT/.github/workflows/ci.yml"
|
||||
check "ci.yml: ...with full history, or the merge base is unreachable" 0 "" \
|
||||
grep -qF 'fetch-depth: 0' "$ROOT/.github/workflows/ci.yml"
|
||||
check "ci.yml: ...and STRICT, so a skip is a red run and not a green one" 0 "" \
|
||||
grep -qF 'CHANGELOG_MONOTONIC_STRICT' "$ROOT/.github/workflows/ci.yml"
|
||||
check "CONTRIBUTING: names the append-only rule for release headings" 0 "" \
|
||||
grep -qF 'changelog-monotonic.sh' "$ROOT/CONTRIBUTING.md"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# latest_release_tag — extracted from install.sh (the source-the-pure-function
|
||||
# trick) and driven against a shim curl. The shim serves the ONE seam the
|
||||
|
|
|
|||
Loading…
Reference in a new issue