The monotonic step is the LAST step of the `check` job, so stopping only at
the next `- name:` ran the block into `rehearsal` and swallowed its job-level
`if:`. Unanchored `grep -q 'if:'` then fired on it — reintroducing the bug the
scoping was meant to fix, moved from "any step in the file" to "this step plus
the head of the next job".
Terminates on a new step OR a new job now, and the key is anchored so an `if:`
inside a `run:` line is not mistaken for a step condition.
Mutation-tested four ways: baseline green; an unrelated job gated stays green
(was the failure); the monotonic step gated fails; the step renamed trips the
companion found-the-block guard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The inner `bash -c` takes its paths as positionals, so the single quotes are
deliberate — same class as the release-notes fixtures above it, and reusing
their exact disable wording.
Mine to catch: I linted .github/scripts, bin/box and install.sh by hand, while
CI lints `bin/* **/*.sh` under globstar+dotglob, which includes test/. The
narrower local invocation is what let this through; reproduced CI's exact
command before pushing this time.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
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 #143 when one legitimately was.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Uniqueness is a property of HEAD alone — no base ref, no merge base, no base
blob. It sat downstream of all three, so every degradation path returned
success on a tree carrying a duplicate.
The base-blob path was the worst: 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 could not reach it — STRICT guards the two
skip() calls, and that is not one of them.
That inverted the two halves. Deletion needs a diff to see; duplication is the
one release-notes.sh actually mis-renders, re-arming its grab on the second
heading (#118). The half with the live extraction bug behind it had the most
ways to silently not run.
Moved, not rewritten. The skip messages now say containment skipped and that
uniqueness already passed. The CI step is no longer pull_request-only, with a
`github.ref_name` fallback because base_ref is empty on a push and a bare
`origin/` under STRICT would redden every push to main.
Found by claude-bot-andresmgsl reviewing heavy-duty/rig#99 and
heavy-duty/cast#134, which inherited the ordering from here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review found the first cut incomplete, and the gap is the shape the #118
incident actually had. Containment catches a heading that VANISHED. It cannot
catch one that was DUPLICATED: 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`. Multiset comparison does not close it either, for the same
reason: base {0.8.0} minus head {0.8.0, 0.8.0} is still empty. The assert that
does close it is uniqueness of version headings ON HEAD, kept ALONGSIDE
containment rather than replacing it, since containment remains the right
check for deletions.
That matters because the duplicate is not hypothetical. It is what a bad
rebase of #116 produced an hour before this commit: two
`## 0.8.0 - 2026-07-19` headings with the incoming entry stranded between
them. Every other guard was green on that tree -- no conflict markers,
changelog-armed.sh happy because the top section was still right for the
VERSION, tests and shellcheck clean -- while release-notes.sh re-armed its
grab on the second heading and folded post-cut prose into the shipped 0.8.0
body, and the stranded entry would have been dropped from the next release's
notes as well.
Uniqueness fires on nothing legitimate: the ceremony stamps a NEW version, and
'Unreleased' fails the version shape before it reaches here.
Both trees are now pinned in test/release.sh side by side -- 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 separately. The
duplicate case also pins that the containment arm stays silent on it, so a
future simplification cannot collapse the two asserts into one.
Proven non-vacuous: stubbing the uniqueness check to empty turns the suite red
on exactly the three new assertions (117/3); restoring it returns 120/0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
changelog-armed.sh asks only whether the TOP section agrees with VERSION.
A PR that replaces '## X.Y.Z — DATE' with its own '## Unreleased' block —
git merges the edit cleanly, and a shipped section is silently absorbed into
Unreleased. The damage only surfaces at the next release, when
release-notes.sh cannot find the section it extracts by heading.
Add changelog-monotonic.sh: the set of '^## X.Y.Z' headings on a branch must
be a superset of the set at the merge base. Release headings are append-only,
so the rule has no legitimate violation — and the ceremony's stamp passes by
construction, adding X.Y.Z and removing none.
Its own script, not a clause in changelog-armed.sh: the input is a git
history rather than two files, no base ref is a SKIP rather than a failure,
and changelog-armed.sh is driven by test/release.sh against constructed
non-git trees that cannot express the failure at all.
CI checks out with fetch-depth: 0 and sets CHANGELOG_MONOTONIC_STRICT=1, so
an unreachable base ref goes red there instead of degrading to the skip a
local run is allowed.
Closes#122
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bare-VERSION branch left the top heading unconstrained on purpose — both
ceremony shapes have to stay legal, which is the rig#44 / cast#108 lesson. A
review round on the sibling fix found the gap that asymmetry leaves: a tree
with VERSION bumped, '## Unreleased' still populated on top, and no stamped
section for that version makes the wrong-number test false on its first
clause, short-circuits, and passes. release.yml then refuses at publish time
— after the merge, on main, with the release already half-shipped.
So the bare branch now also requires the section it is about to publish to
exist and be non-empty, asserted by running release-notes.sh itself so the
guard and the publisher cannot drift over what a section is. The message is
distinct from the wrong-number case: a missing stamp is not a misnumbered
one. Matches heavy-duty/rig#67.
test/release.sh constructs the half-ceremony tree and the stamped-but-empty
tree and drives the real script at both — all three new assertions fail
against the previous guard — while the re-armed and un-re-armed ceremony
trees stay green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stamping '## Unreleased' away left main with no heading for a PR
authored before the release to land in, so its entry merged cleanly
into the section that just shipped. The ceremony now re-arms, and
changelog-armed.sh enforces it keyed on VERSION -- so the ceremony
PR's own bare-VERSION tree stays legal (rig#44 / cast#108).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Operator decision: the post-release bump PR is ceremony debris — a
derivable one-liner with no judgment for a review to add. After tag +
publish, the same job computes X.Y.(Z+1)-dev and pushes it to main
directly (a GITHUB_TOKEN push fires no workflows: no recursion, no red
run); if branch protection refuses, the step opens the bump PR itself,
loudly. #98 is the last hand-made bump.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round-1 blocker (claude + grok): a pull_request run from a public fork
gets a read-only GITHUB_TOKEN — permissions: cannot raise it — and every
ceremony PR this org merges is cross-repo from the bot fork, so the tag
create would 403 after green asserts, red on main per release. The door
now triggers on push to main (in-repo event, full token); the decide step
keeps the first-parent version interlock, and the release label — still
the operator's declared intent — is read via the API off the merge
commit's PR. A transition with no labeled PR behind it refuses. The doors
split on the pushed ref: tags to the tag door, main to the merge door.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The four-state table called '-dev but changed' half a ceremony and
refused — but that state IS the mandatory post-release bump PR
(bare -> X.Y.(Z+1)-dev after every release), which would have put a red
run on main once per release, forever. A tree that ends -dev is by
definition not a release: every such merge is work, and no-ops green
with a NOTICE. The red verdicts now guard only bare endstates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LABELS.md gives 'release' to release-flow WORK as well as to the ceremony
PR — the PR that added the merge door included. The old assert pair turned
every such merge into a red run on main. The fused decide step reads
VERSION against the merge commit's first parent and answers all states:
-dev unchanged = work, green NOTICE no-op; bare unchanged but already
released = work in the post-release window, same no-op; -dev-but-changed
and bare-unchanged-never-released = half-ceremonies, refused loudly;
bare-and-changed = the ceremony. Later steps gate on its output. Five new
pins in test/release.sh cover each verdict and the gating.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 0.7.0 ceremony exposed the gap: the release PR merged with four
approvals and nothing happened, correctly, because publishing hung off a
separate, manual, silent-when-forgotten tag push — the worst failure
shape, no error and no red X. The ship decision already lives in the
release PR, so the merge now IS the release.
release.yml grows a second door: pull_request closed on main, gated on
merged == true AND the hand-set release label (read from the event
payload — no extra permission). Four asserts, in order, each fail-loud
and creating nothing: VERSION at the merge commit is non--dev; VERSION
changed in this PR (merge vs first parent — the -dev interlock that
kills a mislabeled ordinary PR); the version's CHANGELOG.md section
extracts non-empty via the existing release-notes.sh; and no tag or
release exists yet. Then, in the same job, it creates the tag ref at
the merge commit via the API and publishes with gh release create
--verify-tag. Same-job on purpose: a GITHUB_TOKEN-created tag triggers
no workflows (GitHub's anti-recursion), so the tag door can never fire
off it and double-publish, and the no-existing assert covers a manual
tag racing the merge. The tag-push path stays step-for-step identical
as the documented manual fallback and backfill, gated to the push event
so a closed PR never runs it against a branch ref.
CONTRIBUTING.md's Releases section now reads "the maintainer's merge IS
the release", with the manual tag ritual kept as the fallback.
test/release.sh grep-pins the merged+labeled gate, all four asserts,
the same-job tag+publish, and that the tag-push trigger survives — in
the same daemon-free, fail-closed style.
Fixes#96
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test/release.sh, wired into CI beside the other suites and never
touching the network: release-notes.sh is driven against a fixture
changelog carrying every boundary (Unreleased must not leak, the
version is matched whole so 0.7.0 never grabs 0.7.0-rc1, a missing
or empty section refuses) AND against the real CHANGELOG.md, so the
header format cannot drift under release.yml. latest_release_tag is
extracted from install.sh and driven against a shim curl serving
canned redirects — including the no-releases redirect and a network
failure. And the three channels are REAL install.sh runs against
throwaway roots with the shim standing in for GitHub: the default
resolves and downloads the tag tarball (never a branch), a pin skips
the probe, BOX_REF=main falls back tag → branch in that order, and a
failed resolution refuses loudly having downloaded nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>