diff --git a/.github/scripts/changelog-monotonic.sh b/.github/scripts/changelog-monotonic.sh old mode 100644 new mode 100755 index 4cb999e..7bbdd5a --- a/.github/scripts/changelog-monotonic.sh +++ b/.github/scripts/changelog-monotonic.sh @@ -69,26 +69,18 @@ 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 " (Uniqueness on HEAD already passed; it is containment that cannot run.)" >&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.)" + echo "changelog-monotonic: containment SKIPPED — $*" + echo " (Uniqueness on HEAD already ran and passed — only the deleted-heading" + echo " half needs the history. 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: '## ...' where looks like a # version. Field $2, the same split changelog_section() uses, so the two # cannot disagree about what a section header is. 'Unreleased' fails the @@ -100,14 +92,6 @@ headings_raw() { } 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 box#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 @@ -167,6 +151,35 @@ EOF exit 1 fi +# --- everything below needs the HISTORY -------------------------------------- +# Uniqueness is settled. What follows is containment, which compares HEAD +# against the merge base and therefore genuinely depends on the base ref, the +# merge base, and the base blob. Each of those can be unavailable for reasons +# that are not the author's fault (a shallow clone, a fork checkout without the +# upstream remote, the commit that first adds the changelog), so each degrades +# rather than failing — which is exactly why the uniqueness half must NOT live +# down here (#98; fixed upstream in heavy-duty/box#143, where rig's copy of +# this script came from). It asks nothing of the history, and gating it behind +# these conditions let a duplicate exit 0 on a message about deletion. + +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 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 (uniqueness on HEAD already passed)." + exit 0 +} + base_headings="$(printf '%s\n' "$base_file" | headings)" head_headings="$(headings < "$changelog")" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 590e2f6..e1e624f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,21 +51,32 @@ jobs: run: bash test/labels-reconcile.sh - name: release-flow tests run: bash test/release.sh - # No SHIPPED release heading was deleted (#98). Its own step rather than - # a line inside test/release.sh: that suite drives the arming rule - # against constructed VERSION + CHANGELOG.md trees that are not git + # No SHIPPED release heading was deleted or DUPLICATED (#98). Its own step + # rather than a line inside test/release.sh: that suite drives the arming + # rule against constructed VERSION + CHANGELOG.md trees that are not git # repos, and this assert needs a git history — folding it in would make # those cases skip or lie. It is also a DIFFERENT invariant: arming 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' + # fact about this tree, monotonicity is a fact about this tree versus its + # merge base. STRICT=1 so a checkout that cannot reach the base ref fails + # here instead of skipping quietly forever. + # + # NOT pull-request-only, and that is the #98 fix at the workflow level. + # The two halves have different vacuity: DELETION is vacuous on a push to + # main (the merge base IS HEAD), but DUPLICATION is vacuous on no tree at + # all, so gating the whole script on `pull_request` left a duplicate that + # reached main by any other route unasserted forever. + # + # The `|| github.ref_name` fallback is load-bearing, not defensive. On a + # push event `github.base_ref` is EMPTY, so the argument would collapse to + # a bare `origin/`, which does not resolve — and STRICT=1 correctly + # promotes that to a hard failure, turning every push to main red. With + # the fallback it resolves to the pushed branch, whose merge base with + # HEAD is HEAD or its parent: containment passes vacuously, exactly as the + # old `if` intended, while uniqueness now runs on every push. + - name: no shipped changelog heading was deleted or duplicated env: CHANGELOG_MONOTONIC_STRICT: '1' - run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref }}" + run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref || github.ref_name }}" # Kept SEPARATE from `check` on purpose: this job pulls a Postgres image and # stands up throwaway containers, and a slow image pull must never delay the diff --git a/CHANGELOG.md b/CHANGELOG.md index bb512e1..fca6ff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,41 @@ on the way to cutting its first release, and this file starts there. same class: no conflict, no red run, found only by a human reading the published notes. +- **...and the uniqueness half no longer sits behind conditions it does not + depend on** (#98, heavy-duty/box#143) — containment is a property of a DIFF + and genuinely needs the base ref, the merge base and the base blob. + Uniqueness is a property of **HEAD alone** and needs none of the three. It + sat downstream of all of them anyway, so every degradation path returned + success on a tree carrying a duplicate in plain sight. + + The base-blob case was the worst of the three, because it was not a skip at + all: a branch that *introduces* `CHANGELOG.md` hit a bare `exit 0` on a + message that was true about deletion and silent about the duplicate in front + of it. `STRICT=1` could not reach it — STRICT guards the two `skip()` calls, + and that path is not one of them. Off CI the two skips had the same shape, so + a shallow clone or an unpacked tarball would not look at a duplicate the + author was about to push. + + That inverted the value of the two halves. Deletion is the failure that needs + a diff to see; duplication is the one `changelog_section()` actually + mis-renders, stopping dead at the second copy and truncating the release's + real body. The half with the live extraction bug behind it was the half with + the most ways to silently not run. + + 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: + `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 + code, since the clean base-absent tree is green either way — asserting only + the code is what let the original ship. `changelog-monotonic.sh` is also now + `100755`, matching cast's copy of the same file. + - **An unreadable check rollup no longer reads as "nothing is failing"** (#90) — when `gh pr view` failed, the fallback left the `statusCheckRollup` key absent entirely, and `(.statusCheckRollup // [])` collapsed that into the diff --git a/test/release.sh b/test/release.sh index e08b094..7766bec 100644 --- a/test/release.sh +++ b/test/release.sh @@ -306,8 +306,9 @@ check "monotonic: the re-armed ceremony tree passes too" 0 "" mono "$T" # of checks exists to refuse. So STRICT flips exactly that case red. mono_noref() { local d="$1"; shift; ( cd "$d" && env "$@" bash "$MONO" no/such/ref 2>&1 ); } T="$(monorepo noref)" -check "monotonic: an unresolvable base ref SKIPS locally" 0 "SKIPPED" mono_noref "$T" -check "monotonic: ...and the skip says nothing was checked" 0 "Nothing was checked" mono_noref "$T" +check "monotonic: an unresolvable base ref SKIPS containment locally" 0 "containment SKIPPED" mono_noref "$T" +check "monotonic: ...and the skip says uniqueness already ran, not that nothing did" 0 \ + "already ran and passed" mono_noref "$T" check "monotonic: ...but is a FAILURE under STRICT=1 (what CI sets)" 1 "STRICT=1" \ mono_noref "$T" CHANGELOG_MONOTONIC_STRICT=1 check "monotonic: ...and the STRICT failure blames the checkout, not the script" 1 \ @@ -320,19 +321,97 @@ T="$(monorepo nofile)" check "monotonic: a missing changelog file is an error, never a skip" 1 "no such file" \ bash -c 'cd "$1" && bash "$2" base nope.md 2>&1' _ "$T" "$MONO" +# --- #98: uniqueness is a property of HEAD, so nothing base-side may gate it -- +# Containment needs the merge base. Uniqueness needs only the file in front of +# it. As first written (and as inherited from heavy-duty/box, fixed there in +# box#144 for box#143) the duplicate check sat DOWNSTREAM of the base-ref, +# merge-base and base-blob conditions, so each of the degradation paths below +# exited 0 on a tree carrying a duplicate in plain sight — the base-blob one +# not even through skip(), but a bare `exit 0` that STRICT could not reach. +# +# These cases pin the ORDER, which is the actual invariant. Every monorepo +# fixture above commits MONO_BASE on 'base', so no case up there ever reaches +# the base-absent branch at all; and asserting the exit code alone is what let +# the original ship, since the clean base-absent case is green either way. +mononocl() { # mononocl -> a repo whose 'base' has NO changelog, on 'work' + local d="$WORK/mono-$1"; mkdir -p "$d" + git -C "$d" init -q -b base + git -C "$d" config user.email harness@example.invalid + git -C "$d" config user.name harness + printf '%s\n' '# rig' > "$d/README.md" + git -C "$d" add README.md + git -C "$d" commit -qm 'base: no changelog yet' + git -C "$d" checkout -q -b work + printf '%s' "$d" +} +monoadd() { # monoadd — the branch INTRODUCES CHANGELOG.md + local d="$1"; shift + printf '%s\n' "$@" > "$d/CHANGELOG.md" + git -C "$d" add CHANGELOG.md + git -C "$d" commit -qm 'work: introduce the changelog' +} + +# The changelog is absent at the merge base AND the branch introduces a +# duplicate. Before the fix this exited 0 on "nothing could have been deleted". +T="$(mononocl 98-newdup)" +monoadd "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \ + '- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \ + '- **A shipped thing** (#1) — prose.' +check "monotonic: a duplicate introduced where the base had NO changelog is CAUGHT (#98)" 1 \ + "DUPLICATE release heading" mono "$T" +check "monotonic: ...and STRICT does not change that (it was never a skip)" 1 \ + "DUPLICATE release heading" mono "$T" CHANGELOG_MONOTONIC_STRICT=1 +# ...and the clean counterpart still passes, now SAYING uniqueness ran. Without +# this the case above could be satisfied by failing the base-absent path +# outright, which would redden every changelog-introducing branch. +T="$(mononocl 98-newok)" +monoadd "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \ + '- **A shipped thing** (#1) — prose.' +check "monotonic: ...while a CLEAN introduced changelog still passes" 0 \ + "nothing could have been deleted" mono "$T" +check "monotonic: ...saying uniqueness was checked, not that nothing was" 0 \ + "uniqueness on HEAD already passed" mono "$T" + +# No git at all (a tarball, an unpacked release): uniqueness still has +# everything it needs, so a duplicate is caught rather than skipped past. +mkdir -p "$WORK/mono-98-nogit" +printf '%s\n' '# Changelog' '' '## 0.2.0 — 2026-07-19' '' \ + '## 0.2.0 — 2026-07-19' > "$WORK/mono-98-nogit/CHANGELOG.md" +check "monotonic: a duplicate OUTSIDE a git work tree is caught (#98)" 1 \ + "DUPLICATE release heading" mono "$WORK/mono-98-nogit" + +# An unresolvable base ref: same — the skip belongs to containment, not to the +# script, so uniqueness has already run by the time skip() is reachable. +T="$(monorepo 98-nobase)" +monowrite "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \ + '- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \ + '- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \ + '- **The first thing** (#0) — prose.' +check "monotonic: a duplicate is caught even when the base ref will not resolve (#98)" 1 \ + "DUPLICATE release heading" mono_noref "$T" + # --- ci.yml: the monotonic step is actually wired (#98) ---------------------- # The guard runs from ci.yml, not from this suite, so pin the wiring the same # way release.yml's is pinned — a script nothing invokes is not a check. CIY="$ROOT/.github/workflows/ci.yml" check "ci.yml: runs the monotonic guard" 0 "" \ grep -q "changelog-monotonic.sh" "$CIY" -check "ci.yml: ...on pull requests only (on a push to main it is vacuous)" 0 "" \ - grep -qF "github.event_name == 'pull_request'" "$CIY" check "ci.yml: ...with STRICT=1, so a skip is red rather than quietly green" 0 "" \ grep -qF "CHANGELOG_MONOTONIC_STRICT: '1'" "$CIY" # shellcheck disable=SC2016 # the $-string is a literal in the target file check "ci.yml: ...against the PR's base branch" 0 "" \ - grep -qF 'origin/${{ github.base_ref }}' "$CIY" + grep -qF 'origin/${{ github.base_ref' "$CIY" +# The step must NOT be pull-request-only. Deletion is vacuous on a push to main +# (the merge base IS HEAD), but DUPLICATION is vacuous on no tree at all, so +# gating the whole script left a duplicate reaching main by any other route +# 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" +# 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" # Without full history the base ref does not resolve, and STRICT turns that # into a red run — so the fetch depth is load-bearing, not incidental. check "ci.yml: the checkout has full history (the base ref must resolve)" 0 "" \