From b58ce5524f84b63a72ae5852575b102623366598 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 15:19:31 +0000 Subject: [PATCH 1/8] feat: merging a release-labeled PR is the release (#96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/release.yml | 110 +++++++++++++++++++++++++++++++--- CHANGELOG.md | 22 +++++++ CONTRIBUTING.md | 27 ++++++--- test/release.sh | 39 ++++++++++++ 4 files changed, 181 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6426936..9047242 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,32 @@ name: release -# The release publisher (#83), on a bare X.Y.Z tag push (the 0.6.0 tag set -# the precedent — no 'v' prefix). Two facts, then one act: the tag must name -# the tree's own VERSION (a mismatch fails loudly and creates NOTHING — a -# wrong release is worse than a missing one), and the release body is that -# version's CHANGELOG.md section (.github/scripts/release-notes.sh, shared -# with test/release.sh) — the curated prose, not the generated PR list. No -# assets are uploaded: for a pure-bash tree, GitHub's source tarball for the -# tag IS the package, and install.sh downloads exactly that. +# The release publisher — two doors into the same act (#83, #96): +# +# * The merge door (#96): merging the `release`-labeled PR into main IS the +# release. The label is the intent, the version transition is the +# interlock — VERSION at the merge commit must be non-`-dev` AND must have +# changed in this PR, so a mislabeled ordinary PR fails loudly and creates +# NOTHING. The job then tags the merge commit via the API and publishes, +# in the SAME job on purpose: a GITHUB_TOKEN-created tag does not trigger +# other workflows (GitHub's anti-recursion), so that tag can never re-enter +# the tag door below and double-publish — publishing here is the only +# chance, and the no-existing-tag/release assert covers a manual tag +# racing the merge. +# +# * The tag door (#83) stays as the documented manual fallback and backfill, +# on a bare X.Y.Z tag push (the 0.6.0 tag set the precedent — no 'v' +# prefix). The tag must name the tree's own VERSION (a mismatch fails +# loudly and creates NOTHING — a wrong release is worse than a missing +# one). +# +# Both doors publish the release body from that version's CHANGELOG.md +# section (.github/scripts/release-notes.sh, shared with test/release.sh) — +# the curated prose, not the generated PR list. No assets are uploaded: for a +# pure-bash tree, GitHub's source tarball for the tag IS the package, and +# install.sh downloads exactly that. on: + pull_request: + types: [closed] + branches: [main] push: # Every tag, not a shape filter (rig's precedent): a tag that mismatches # VERSION — a habitual v0.7.0, a typo — must fail the assert LOUDLY @@ -15,10 +34,83 @@ on: tags: ["**"] permissions: - contents: write # gh release create + contents: write # create the tag ref + gh release create jobs: + # The merge door (#96). Closed-unmerged never fires, and a merged PR + # without the hand-set `release` label (LABELS.md: automation never guesses + # intent) is skipped. The label is read from the event payload, not the + # API, so no pull-requests permission is needed. + release-on-merge: + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + contains(github.event.pull_request.labels.*.name, 'release') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # The merge commit plus its first parent (fetch-depth: 2): the + # first parent is main the instant before this PR landed, which the + # changed-in-this-PR assert compares against. (The payload's + # base.sha can be stale; the merge commit's first parent cannot.) + ref: ${{ github.event.pull_request.merge_commit_sha }} + fetch-depth: 2 + - name: VERSION at the merge commit must be a release, not -dev + run: | + ver="$(cat VERSION)" + case "$ver" in + *-dev) + echo "VERSION is '$ver' — still -dev, so this merge is not a release ceremony, whatever its label says — creating nothing." >&2 + exit 1 ;; + esac + - name: VERSION must have CHANGED in this PR — the -dev interlock + # Only the release PR moves VERSION off -dev. A mislabeled ordinary + # PR merged while main already carries a release version dies here, + # loudly, instead of re-releasing whatever VERSION says. + run: | + ver="$(cat VERSION)" + base="$(git show HEAD^1:VERSION)" + if [ "$base" = "$ver" ]; then + echo "VERSION '$ver' did not change in this PR (the base commit already carried it) — this is not the release PR — creating nothing." >&2 + exit 1 + fi + - name: release notes — the version's own CHANGELOG.md section + # release-notes.sh fails loudly on a missing/empty section, which + # fails the release here — before anything is created. + run: | + bash .github/scripts/release-notes.sh "$(cat VERSION)" > "$RUNNER_TEMP/notes.md" + cat "$RUNNER_TEMP/notes.md" + - name: nothing may exist yet — no tag, no release (idempotency) + env: + GH_TOKEN: ${{ github.token }} + run: | + ver="$(cat VERSION)" + if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$ver" --silent 2>/dev/null; then + echo "tag '$ver' already exists — a manual tag beat this run, or this is a re-run of a published release — creating nothing." >&2 + exit 1 + fi + if gh release view "$ver" --json name >/dev/null 2>&1; then + echo "release '$ver' already exists — creating nothing." >&2 + exit 1 + fi + - name: tag the merge commit, then publish — one job, on purpose + # Same job as the asserts: the GITHUB_TOKEN-created tag triggers no + # workflows (GitHub's anti-recursion), so the tag door cannot fire + # off it — this step is the release's only chance to publish. + env: + GH_TOKEN: ${{ github.token }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + ver="$(cat VERSION)" + gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$ver" -f "sha=$MERGE_SHA" + gh release create "$ver" --verify-tag --title "$ver" --notes-file "$RUNNER_TEMP/notes.md" + + # The tag door (#83) — the manual fallback and backfill, unchanged. Gated + # to the push event so a closed PR (the trigger above) never runs it + # against a branch ref. release: + if: github.event_name == 'push' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index a9fe1d0..38767f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,28 @@ History before 0.5.0 lives in git and in [drill/RUNS.md](drill/RUNS.md), which records not just what changed but what each drill run proved. +## Unreleased + +### Added + +- **Merging the release PR IS the release** (#96) — the 0.7.0 ceremony ended + in an absence: the release PR merged with four approvals and nothing + happened, correctly, because publishing hung off a separate, manual, + silent-when-forgotten tag push — a failure shape with no error and no red + X. The ship decision already lives in the release PR (the one PR whose + whole diff is "the version leaves `-dev`"), so `release.yml` now fires on + a merged, `release`-labeled PR into main: the label is the intent, the + version transition is the interlock — `VERSION` at the merge commit must + be non-`-dev` AND must have changed in this PR, so a mislabeled ordinary + PR fails loudly and creates nothing — the notes must extract from the + changelog, and no tag or release may exist yet. Then, in the same job, it + tags the merge commit via the API and publishes; same-job on purpose, + because a `GITHUB_TOKEN`-created tag triggers no workflows, which is also + what makes double-publish impossible. The tag-push path stays unchanged as + the documented manual fallback and backfill (it shipped 0.7.0 itself). + `test/release.sh` grep-pins the merged+labeled gate, all four asserts, and + the same-job tag+publish in the same daemon-free, fail-closed style. + ## 0.7.0 — 2026-07-19 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aff4a2e..b8eb0a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,9 @@ labels tell you where everything is without opening anything. ## Releases -A release is a PR, then a tag ([#83](https://github.com/heavy-duty/box/issues/83)): +A release is a PR, and merging it ships it +([#96](https://github.com/heavy-duty/box/issues/96), building on +[#83](https://github.com/heavy-duty/box/issues/83)): 1. **The release PR** — `release: X.Y.Z`, labeled `release` — bumps `VERSION` from `X.Y.Z-dev` and stamps the `## Unreleased` section with version + @@ -52,13 +54,22 @@ A release is a PR, then a tag ([#83](https://github.com/heavy-duty/box/issues/83 the full drill on real hardware, recorded in [drill/RUNS.md](drill/RUNS.md) — CI proves the tier's semantics on every PR, a release still proves the boundary. -2. **Merge, then tag the merge commit** bare `X.Y.Z` — no `v` prefix, the - `0.6.0` tag set the precedent — and push the tag. - [release.yml](.github/workflows/release.yml) takes it from there: it - asserts the tag names the tree's own `VERSION` (a mismatch fails loudly - and creates nothing) and publishes the GitHub release with that version's - `CHANGELOG.md` section as the body. No assets — the source tarball for - the tag is the package, and `install.sh` downloads exactly that. +2. **The maintainer's merge IS the release.** + [release.yml](.github/workflows/release.yml) fires on the merged, + `release`-labeled PR and asserts before creating anything: `VERSION` at + the merge commit is non-`-dev` **and changed in this PR** (the `-dev` + interlock — a mislabeled ordinary PR fails loudly and creates nothing), + the version's `CHANGELOG.md` section extracts non-empty, and no tag or + release exists for it yet. Then, in the same job, it tags the merge + commit bare `X.Y.Z` (no `v` prefix, the `0.6.0` precedent) and publishes + the GitHub release with that section as the body. No assets — the source + tarball for the tag is the package, and `install.sh` downloads exactly + that. + + *Manual fallback/backfill*: the tag-push path stays. Tagging the merge + commit bare `X.Y.Z` by hand and pushing the tag still publishes the same + way (release.yml asserts the tag names the tree's own `VERSION`) — for + backfills, or the day the merge path is red. 3. **Immediately after: bump `main`'s `VERSION` to `X.Y.(Z+1)-dev`.** Not cosmetic — the versioned layout names install trees after `VERSION`, so a `main` install without the bump would land in `versions/X.Y.Z` and diff --git a/test/release.sh b/test/release.sh index 3331faa..f5d7229 100644 --- a/test/release.sh +++ b/test/release.sh @@ -112,6 +112,45 @@ check "release.yml: the body comes from the shared extraction script" 0 "" \ check "release.yml: the release is bound to the pushed tag (--verify-tag)" 0 "" \ grep -qF -- '--verify-tag' "$RY" +# --------------------------------------------------------------------------- +# release.yml, the merge door (#96) — merging the release-labeled PR IS the +# release. Same daemon-free discipline: the gate, the four asserts, and the +# same-job tag+publish are grep-pinned, fail-closed. +# --------------------------------------------------------------------------- +check "release.yml: the tag-push trigger is still present (manual fallback)" 0 "" \ + grep -qF 'tags: ["**"]' "$RY" +check "release.yml: fires on closed pull requests..." 0 "" \ + grep -qF 'types: [closed]' "$RY" +check "release.yml: ...into main" 0 "" \ + grep -qF 'branches: [main]' "$RY" +check "release.yml: the merge door gates on merged == true (closed-unmerged never fires)" 0 "" \ + grep -qF 'github.event.pull_request.merged == true' "$RY" +check "release.yml: ...AND on the release label, read from the event payload" 0 "" \ + grep -qF "contains(github.event.pull_request.labels.*.name, 'release')" "$RY" +check "release.yml: the tag door runs only on a push (a closed PR never reaches it)" 0 "" \ + grep -qF "github.event_name == 'push'" "$RY" +check "release.yml: assert — VERSION at the merge commit is non--dev" 0 "" \ + grep -qF '*-dev)' "$RY" +check "release.yml: assert — VERSION changed IN THIS PR (first parent vs merge)" 0 "" \ + grep -qF 'git show HEAD^1:VERSION' "$RY" +check "release.yml: assert — no existing tag for the version" 0 "" \ + grep -qF 'git/ref/tags/' "$RY" +check "release.yml: assert — no existing release for the version" 0 "" \ + grep -qF 'gh release view' "$RY" +check "release.yml: BOTH doors extract notes via the shared script" 0 "2" \ + grep -cF 'bash .github/scripts/release-notes.sh' "$RY" +check "release.yml: every failing assert creates NOTHING (both doors)" 0 "5" \ + grep -cF 'creating nothing' "$RY" +check "release.yml: the merge door creates the tag ref via the API..." 0 "" \ + grep -qF 'ref=refs/tags/' "$RY" +# shellcheck disable=SC2016 # the $-string is a literal in the target file +check "release.yml: ...at the MERGE commit" 0 "" \ + grep -qF 'sha=$MERGE_SHA' "$RY" +check "release.yml: BOTH doors publish bound to an existing tag (--verify-tag)" 0 "2" \ + grep -cF -- '--verify-tag' "$RY" +check "release.yml: tag + publish share one job (the anti-recursion shape)" 0 "" \ + grep -qF 'anti-recursion' "$RY" + # --------------------------------------------------------------------------- # 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 From 420e8e7f3216c4fa3b8dd867fc452ceb65807c07 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 15:25:52 +0000 Subject: [PATCH 2/8] fix: the release label's two meanings part ways in a decide step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/release.yml | 52 ++++++++++++++++++++++++++--------- test/release.sh | 16 +++++++++++ 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9047242..7621c95 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,32 +56,57 @@ jobs: # base.sha can be stale; the merge commit's first parent cannot.) ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 2 - - name: VERSION at the merge commit must be a release, not -dev - run: | - ver="$(cat VERSION)" - case "$ver" in - *-dev) - echo "VERSION is '$ver' — still -dev, so this merge is not a release ceremony, whatever its label says — creating nothing." >&2 - exit 1 ;; - esac - - name: VERSION must have CHANGED in this PR — the -dev interlock - # Only the release PR moves VERSION off -dev. A mislabeled ordinary - # PR merged while main already carries a release version dies here, - # loudly, instead of re-releasing whatever VERSION says. + # The decide step — the version asserts fused, because the `release` + # label carries TWO legitimate meanings (LABELS.md: "release flow and + # version/packaging work"): the ceremony PR that ships a version, and + # ordinary work ON the release machinery — the PR that added this very + # job included. The version tells them apart, in four states: + # -dev, unchanged → work under the label: green NOTICE + # no-op, not a red run per infra PR + # -dev, changed → a bump that forgot to leave -dev: + # half a ceremony, refuse + # bare, unchanged, released → work merged in the post-release + # window (ceremony landed, the -dev + # bump has not): green NOTICE no-op + # bare, unchanged, UNreleased→ the label says ship but this PR did + # not mint the version: refuse to guess + # bare, changed → the ceremony: proceed + - name: 'decide: ceremony, or release-flow work under the label?' + id: decide + env: + GH_TOKEN: ${{ github.token }} run: | ver="$(cat VERSION)" base="$(git show HEAD^1:VERSION)" + case "$ver" in + *-dev) + if [ "$base" = "$ver" ]; then + echo "NOTICE: VERSION '$ver' is -dev and unchanged by this PR — release-flow work under the release label, not a ceremony. Nothing to publish." + echo "ceremony=no" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "VERSION changed ('$base' -> '$ver') but is still -dev — half a ceremony; a release PR ships a bare X.Y.Z — creating nothing." >&2 + exit 1 ;; + esac if [ "$base" = "$ver" ]; then - echo "VERSION '$ver' did not change in this PR (the base commit already carried it) — this is not the release PR — creating nothing." >&2 + if gh release view "$ver" --json name >/dev/null 2>&1; then + echo "NOTICE: VERSION '$ver' is already released and unchanged by this PR — release-flow work merged in the post-release window (before the -dev bump). Nothing to publish." + echo "ceremony=no" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "VERSION '$ver' is bare, unchanged by this PR, and never released — the label says ship but this PR did not mint the version. Refusing to guess — creating nothing." >&2 exit 1 fi + echo "ceremony=yes" >> "$GITHUB_OUTPUT" - name: release notes — the version's own CHANGELOG.md section + if: steps.decide.outputs.ceremony == 'yes' # release-notes.sh fails loudly on a missing/empty section, which # fails the release here — before anything is created. run: | bash .github/scripts/release-notes.sh "$(cat VERSION)" > "$RUNNER_TEMP/notes.md" cat "$RUNNER_TEMP/notes.md" - name: nothing may exist yet — no tag, no release (idempotency) + if: steps.decide.outputs.ceremony == 'yes' env: GH_TOKEN: ${{ github.token }} run: | @@ -95,6 +120,7 @@ jobs: exit 1 fi - name: tag the merge commit, then publish — one job, on purpose + if: steps.decide.outputs.ceremony == 'yes' # Same job as the asserts: the GITHUB_TOKEN-created tag triggers no # workflows (GitHub's anti-recursion), so the tag door cannot fire # off it — this step is the release's only chance to publish. diff --git a/test/release.sh b/test/release.sh index f5d7229..9db7c1d 100644 --- a/test/release.sh +++ b/test/release.sh @@ -150,6 +150,22 @@ check "release.yml: BOTH doors publish bound to an existing tag (--verify-tag)" grep -cF -- '--verify-tag' "$RY" check "release.yml: tag + publish share one job (the anti-recursion shape)" 0 "" \ grep -qF 'anti-recursion' "$RY" +# The decide step tells the label's two meanings apart (LABELS.md gives +# `release` to release-flow WORK as well as to the ceremony PR — the PR +# that added the merge door included): work under the label no-ops GREEN — +# in the -dev steady state and in the post-release window (bare, unchanged, +# already released) — while every half-ceremony refuses. Pin each verdict +# and the gating output. +check "release.yml: decide — dev-tree work no-ops green (not a red run per infra PR)" 0 "" \ + grep -qF "release-flow work under the release label, not a ceremony" "$RY" +check "release.yml: decide — a half-ceremony (-dev but changed) refuses" 0 "" \ + grep -qF "half a ceremony" "$RY" +check "release.yml: decide — post-release-window work no-ops green" 0 "" \ + grep -qF "release-flow work merged in the post-release window" "$RY" +check "release.yml: decide — bare, unchanged, never released refuses to guess" 0 "" \ + grep -qF "Refusing to guess" "$RY" +check "release.yml: decide gates every later merge-door step on ceremony=yes" 0 "3" \ + grep -cF "if: steps.decide.outputs.ceremony == 'yes'" "$RY" # --------------------------------------------------------------------------- # latest_release_tag — extracted from install.sh (the source-the-pure-function From 13aa499dada9b2603917e7899f8bc4e97e03198a Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 15:40:48 +0000 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20a=20-dev=20endstate=20is=20always=20?= =?UTF-8?q?work=20=E2=80=94=20the=20post-release=20bump=20must=20not=20run?= =?UTF-8?q?=20red?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/release.yml | 11 +++++++---- test/release.sh | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7621c95..59bc87d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,8 +63,10 @@ jobs: # job included. The version tells them apart, in four states: # -dev, unchanged → work under the label: green NOTICE # no-op, not a red run per infra PR - # -dev, changed → a bump that forgot to leave -dev: - # half a ceremony, refuse + # -dev, changed → still a dev tree, so still work — + # the post-release bump PR above all + # (bare -> -dev after every release): + # green NOTICE no-op # bare, unchanged, released → work merged in the post-release # window (ceremony landed, the -dev # bump has not): green NOTICE no-op @@ -85,8 +87,9 @@ jobs: echo "ceremony=no" >> "$GITHUB_OUTPUT" exit 0 fi - echo "VERSION changed ('$base' -> '$ver') but is still -dev — half a ceremony; a release PR ships a bare X.Y.Z — creating nothing." >&2 - exit 1 ;; + echo "NOTICE: VERSION changed ('$base' -> '$ver') and still ends -dev — a dev tree is by definition not a release. This is work (the post-release bump, a renumber); nothing to publish." + echo "ceremony=no" >> "$GITHUB_OUTPUT" + exit 0 ;; esac if [ "$base" = "$ver" ]; then if gh release view "$ver" --json name >/dev/null 2>&1; then diff --git a/test/release.sh b/test/release.sh index 9db7c1d..875c427 100644 --- a/test/release.sh +++ b/test/release.sh @@ -158,8 +158,8 @@ check "release.yml: tag + publish share one job (the anti-recursion shape)" 0 "" # and the gating output. check "release.yml: decide — dev-tree work no-ops green (not a red run per infra PR)" 0 "" \ grep -qF "release-flow work under the release label, not a ceremony" "$RY" -check "release.yml: decide — a half-ceremony (-dev but changed) refuses" 0 "" \ - grep -qF "half a ceremony" "$RY" +check "release.yml: decide — a -dev endstate is always work (the bump PR no-ops green)" 0 "" \ + grep -qF "a dev tree is by definition not a release" "$RY" check "release.yml: decide — post-release-window work no-ops green" 0 "" \ grep -qF "release-flow work merged in the post-release window" "$RY" check "release.yml: decide — bare, unchanged, never released refuses to guess" 0 "" \ From 00e36b94369165ea1af15488fe9c78b33faab1e9 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 15:41:21 +0000 Subject: [PATCH 4/8] test: the creating-nothing count follows the -dev-endstate change (5 -> 4) Co-Authored-By: Claude Fable 5 --- test/release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/release.sh b/test/release.sh index 875c427..3b4486a 100644 --- a/test/release.sh +++ b/test/release.sh @@ -139,7 +139,7 @@ check "release.yml: assert — no existing release for the version" 0 "" \ grep -qF 'gh release view' "$RY" check "release.yml: BOTH doors extract notes via the shared script" 0 "2" \ grep -cF 'bash .github/scripts/release-notes.sh' "$RY" -check "release.yml: every failing assert creates NOTHING (both doors)" 0 "5" \ +check "release.yml: every failing assert creates NOTHING (both doors)" 0 "4" \ grep -cF 'creating nothing' "$RY" check "release.yml: the merge door creates the tag ref via the API..." 0 "" \ grep -qF 'ref=refs/tags/' "$RY" From 89345f7de3e0e5de4d57ee9083a2f4a6f9dd1b6d Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 15:46:03 +0000 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20the=20merge=20door=20rides=20pushes?= =?UTF-8?q?=20to=20main=20=E2=80=94=20fork=20PR=20tokens=20are=20read-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/release.yml | 48 ++++++++++++++++++++++------------- test/release.sh | 26 +++++++++++-------- 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59bc87d..84ad328 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,10 +24,16 @@ name: release # pure-bash tree, GitHub's source tarball for the tag IS the package, and # install.sh downloads exactly that. on: - pull_request: - types: [closed] - branches: [main] push: + # The merge door rides pushes to MAIN, not pull_request events, for one + # load-bearing reason the first review round caught (#97): a workflow + # run triggered by a pull_request from a public FORK gets a READ-ONLY + # GITHUB_TOKEN — `permissions:` cannot raise that ceiling — and every + # ceremony PR this org has ever merged is cross-repo from the bot fork. + # The asserts would pass and the tag create would 403, red on main, + # every release. A push to main is an in-repo event with the full write + # token, whoever authored the PR. + branches: [main] # Every tag, not a shape filter (rig's precedent): a tag that mismatches # VERSION — a habitual v0.7.0, a typo — must fail the assert LOUDLY # below, not be silently skipped by a pattern that didn't match. @@ -37,24 +43,20 @@ permissions: contents: write # create the tag ref + gh release create jobs: - # The merge door (#96). Closed-unmerged never fires, and a merged PR - # without the hand-set `release` label (LABELS.md: automation never guesses - # intent) is skipped. The label is read from the event payload, not the - # API, so no pull-requests permission is needed. + # The merge door (#96), riding pushes to main (see the trigger comment: + # fork PRs get a read-only token on pull_request events). The hand-set + # `release` label (LABELS.md: automation never guesses intent) is read via + # the API off the merge commit's PR, inside the decide step below. release-on-merge: - if: >- - github.event_name == 'pull_request' && - github.event.pull_request.merged == true && - contains(github.event.pull_request.labels.*.name, 'release') + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: - # The merge commit plus its first parent (fetch-depth: 2): the - # first parent is main the instant before this PR landed, which the - # changed-in-this-PR assert compares against. (The payload's - # base.sha can be stale; the merge commit's first parent cannot.) - ref: ${{ github.event.pull_request.merge_commit_sha }} + # The pushed head plus its first parent (fetch-depth: 2): the + # first parent is main the instant before the PR landed, which the + # changed-in-this-PR assert compares against. + ref: ${{ github.sha }} fetch-depth: 2 # The decide step — the version asserts fused, because the `release` # label carries TWO legitimate meanings (LABELS.md: "release flow and @@ -100,6 +102,16 @@ jobs: echo "VERSION '$ver' is bare, unchanged by this PR, and never released — the label says ship but this PR did not mint the version. Refusing to guess — creating nothing." >&2 exit 1 fi + # The version transitioned — now the LABEL, the operator's declared + # intent, read via the API because a push event carries no PR + # payload (and the PR lives on a fork — the trigger comment). No + # merged, release-labeled PR behind this commit = a transition + # nobody declared: refuse. + if ! gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \ + -q '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' | grep -qx true; then + echo "VERSION transitioned ('$base' -> '$ver') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR (#96), not a bare push — creating nothing." >&2 + exit 1 + fi echo "ceremony=yes" >> "$GITHUB_OUTPUT" - name: release notes — the version's own CHANGELOG.md section if: steps.decide.outputs.ceremony == 'yes' @@ -129,7 +141,7 @@ jobs: # off it — this step is the release's only chance to publish. env: GH_TOKEN: ${{ github.token }} - MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + MERGE_SHA: ${{ github.sha }} run: | ver="$(cat VERSION)" gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$ver" -f "sha=$MERGE_SHA" @@ -139,7 +151,7 @@ jobs: # to the push event so a closed PR (the trigger above) never runs it # against a branch ref. release: - if: github.event_name == 'push' + if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/test/release.sh b/test/release.sh index 3b4486a..f5976d9 100644 --- a/test/release.sh +++ b/test/release.sh @@ -119,16 +119,22 @@ check "release.yml: the release is bound to the pushed tag (--verify-tag)" 0 "" # --------------------------------------------------------------------------- check "release.yml: the tag-push trigger is still present (manual fallback)" 0 "" \ grep -qF 'tags: ["**"]' "$RY" -check "release.yml: fires on closed pull requests..." 0 "" \ - grep -qF 'types: [closed]' "$RY" -check "release.yml: ...into main" 0 "" \ +# The merge door rides pushes to MAIN, not pull_request events: a fork PR +# run gets a read-only GITHUB_TOKEN (permissions: cannot raise it), and +# every ceremony PR this org merges is cross-repo from the bot fork — the +# tag create would 403 after green asserts (#97 round 1). The label — the +# operator's intent — is read via the API off the merge commit's PR. +check "release.yml: the merge door rides pushes to main (fork-token-proof)" 0 "" \ grep -qF 'branches: [main]' "$RY" -check "release.yml: the merge door gates on merged == true (closed-unmerged never fires)" 0 "" \ - grep -qF 'github.event.pull_request.merged == true' "$RY" -check "release.yml: ...AND on the release label, read from the event payload" 0 "" \ - grep -qF "contains(github.event.pull_request.labels.*.name, 'release')" "$RY" -check "release.yml: the tag door runs only on a push (a closed PR never reaches it)" 0 "" \ - grep -qF "github.event_name == 'push'" "$RY" +check "release.yml: the doors split on the ref — tags to the tag door..." 0 "" \ + grep -qF "startsWith(github.ref, 'refs/tags/')" "$RY" +check "release.yml: ...main to the merge door" 0 "" \ + grep -qF "github.ref == 'refs/heads/main'" "$RY" +# shellcheck disable=SC2016 # the $-string is a literal in the target file +check "release.yml: the release label is read via the API off the merge commit" 0 "" \ + grep -qF 'commits/$GITHUB_SHA/pulls' "$RY" +check "release.yml: a transition without a labeled PR refuses" 0 "" \ + grep -qF "no merged, release-labeled PR is behind this commit" "$RY" check "release.yml: assert — VERSION at the merge commit is non--dev" 0 "" \ grep -qF '*-dev)' "$RY" check "release.yml: assert — VERSION changed IN THIS PR (first parent vs merge)" 0 "" \ @@ -139,7 +145,7 @@ check "release.yml: assert — no existing release for the version" 0 "" \ grep -qF 'gh release view' "$RY" check "release.yml: BOTH doors extract notes via the shared script" 0 "2" \ grep -cF 'bash .github/scripts/release-notes.sh' "$RY" -check "release.yml: every failing assert creates NOTHING (both doors)" 0 "4" \ +check "release.yml: every failing assert creates NOTHING (both doors)" 0 "5" \ grep -cF 'creating nothing' "$RY" check "release.yml: the merge door creates the tag ref via the API..." 0 "" \ grep -qF 'ref=refs/tags/' "$RY" From 03e1a8c5b74bb82fd94990a0fed9d45442ccee43 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 16:04:22 +0000 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20the=20release=20re-arms=20main=20?= =?UTF-8?q?=E2=80=94=20the=20-dev=20bump=20folds=20into=20the=20release=20?= =?UTF-8?q?act?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/release.yml | 31 +++++++++++++++++++++++++++++++ test/release.sh | 8 +++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84ad328..fe2ad40 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -146,6 +146,37 @@ jobs: ver="$(cat VERSION)" gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$ver" -f "sha=$MERGE_SHA" gh release create "$ver" --verify-tag --title "$ver" --notes-file "$RUNNER_TEMP/notes.md" + # The post-release bump, folded into the release act (#96 followup — + # operator decision: a mechanical one-liner deserves no PR of its + # own). X.Y.(Z+1)-dev is arithmetic, not judgment: derived, committed + # straight to main with this job's token. A GITHUB_TOKEN push fires + # no workflows (anti-recursion), so the bump triggers neither this + # door nor a red run; should branch protection ever refuse the direct + # push, the step opens the bump PR itself and says so, loudly, + # instead of leaving main armed to impersonate the release. + - name: bump main to the next -dev — the release re-arms main itself + if: steps.decide.outputs.ceremony == 'yes' + env: + GH_TOKEN: ${{ github.token }} + run: | + ver="$(cat VERSION)" + next="$(printf '%s' "$ver" | awk -F. '{ printf "%s.%s.%s-dev", $1, $2, $3 + 1 }')" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git fetch origin main + git checkout -B main origin/main + printf '%s\n' "$next" > VERSION + git add VERSION + git commit -m "chore: bump main to $next — a dev install must not impersonate $ver" + if ! git push origin main; then + echo "direct push refused (branch protection?) — opening the bump PR instead" >&2 + git checkout -b "chore/bump-$next" + git push origin "chore/bump-$next" + gh pr create -R "$GITHUB_REPOSITORY" --head "chore/bump-$next" \ + --title "chore: bump main to $next" \ + --body "The post-release re-arm, opened by release.yml because the direct push was refused. One file, one line." \ + --label release + fi # The tag door (#83) — the manual fallback and backfill, unchanged. Gated # to the push event so a closed PR (the trigger above) never runs it diff --git a/test/release.sh b/test/release.sh index f5976d9..8e8a5e9 100644 --- a/test/release.sh +++ b/test/release.sh @@ -170,8 +170,14 @@ check "release.yml: decide — post-release-window work no-ops green" 0 "" \ grep -qF "release-flow work merged in the post-release window" "$RY" check "release.yml: decide — bare, unchanged, never released refuses to guess" 0 "" \ grep -qF "Refusing to guess" "$RY" -check "release.yml: decide gates every later merge-door step on ceremony=yes" 0 "3" \ +check "release.yml: decide gates every later merge-door step on ceremony=yes" 0 "4" \ grep -cF "if: steps.decide.outputs.ceremony == 'yes'" "$RY" +# The release re-arms main itself: the post-release -dev bump is arithmetic, +# not judgment, so it rides the same job — direct push, PR fallback. +check "release.yml: the release bumps main to the next -dev itself" 0 "" \ + grep -qF "bump main to the next -dev" "$RY" +check "release.yml: ...with a PR fallback when the direct push is refused" 0 "" \ + grep -qF "opening the bump PR instead" "$RY" # --------------------------------------------------------------------------- # latest_release_tag — extracted from install.sh (the source-the-pure-function From b89ed144fe6f2706e4b40733925f2aa9bfad6b98 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 16:33:41 +0000 Subject: [PATCH 7/8] fix: pull-requests scope for the door's two PR-API calls; docs catch up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The permission-starvation blocker found on the cast twin (claude-bot, cast#112 round 4) is identical here: a declared permissions: block zeroes every unspecified scope, so the decide step's label read and the bump fallback's gh pr create could only 403 — every genuine ceremony would end red at the label check. pull-requests: write added with the consumers named. CONTRIBUTING step 3 and the changelog entry now tell the shipped story: push-to-main door, event.before interlock, self-re-arm with the manual path's bump staying the operator's. Re-runs wording nit taken. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 8 +++++-- CHANGELOG.md | 41 ++++++++++++++++++++--------------- CONTRIBUTING.md | 12 ++++++---- 3 files changed, 38 insertions(+), 23 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe2ad40..18810ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,11 @@ on: tags: ["**"] permissions: - contents: write # create the tag ref + gh release create + contents: write # create the tag ref + gh release create + the bump push + # Two consumers (a declared permissions: block zeroes every unspecified + # scope): the decide step's label read (commits//pulls) and the bump + # fallback's `gh pr create --label`. + pull-requests: write jobs: # The merge door (#96), riding pushes to main (see the trigger comment: @@ -120,7 +124,7 @@ jobs: run: | bash .github/scripts/release-notes.sh "$(cat VERSION)" > "$RUNNER_TEMP/notes.md" cat "$RUNNER_TEMP/notes.md" - - name: nothing may exist yet — no tag, no release (idempotency) + - name: nothing may exist yet — no tag, no release (re-runs refuse loudly) if: steps.decide.outputs.ceremony == 'yes' env: GH_TOKEN: ${{ github.token }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 38767f0..f238a23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,23 +7,30 @@ which records not just what changed but what each drill run proved. ### Added -- **Merging the release PR IS the release** (#96) — the 0.7.0 ceremony ended - in an absence: the release PR merged with four approvals and nothing - happened, correctly, because publishing hung off a separate, manual, - silent-when-forgotten tag push — a failure shape with no error and no red - X. The ship decision already lives in the release PR (the one PR whose - whole diff is "the version leaves `-dev`"), so `release.yml` now fires on - a merged, `release`-labeled PR into main: the label is the intent, the - version transition is the interlock — `VERSION` at the merge commit must - be non-`-dev` AND must have changed in this PR, so a mislabeled ordinary - PR fails loudly and creates nothing — the notes must extract from the - changelog, and no tag or release may exist yet. Then, in the same job, it - tags the merge commit via the API and publishes; same-job on purpose, - because a `GITHUB_TOKEN`-created tag triggers no workflows, which is also - what makes double-publish impossible. The tag-push path stays unchanged as - the documented manual fallback and backfill (it shipped 0.7.0 itself). - `test/release.sh` grep-pins the merged+labeled gate, all four asserts, and - the same-job tag+publish in the same daemon-free, fail-closed style. +- **Merging the release PR IS the release — and the release re-arms main + itself** (#96) — the 0.7.0 ceremony ended in an absence: the release PR + merged with four approvals and nothing happened, correctly, because + publishing hung off a separate, manual, silent-when-forgotten tag push — + a failure shape with no error and no red X. The ship decision already + lives in the release PR (the one PR whose whole diff is "the version + leaves `-dev`"), so `release.yml` now fires on pushes to main + (fork-sourced ceremony PRs get a read-only token on `pull_request` + events), reading the transition from the push itself: `event.before` to + the pushed head. A decide step answers four states — release-flow *work* + merged under the `release` label (`-dev` endstates, the post-release + window) no-ops green with a NOTICE; the two genuinely ambiguous bare + states refuse loudly; a true transition then requires a merged, + `release`-labeled PR behind the commit (read via the API — the label is + the operator's declared intent) before anything is created. Then, in the + same job, it tags the merge commit via the API, publishes — and bumps + main to `X.Y.(Z+1)-dev` itself, direct push with a loud open-a-PR + fallback, so no follow-up bump PR exists on the paved road. Same-job on + purpose: a `GITHUB_TOKEN`-created tag triggers no workflows, which is + also what makes double-publish impossible. The tag-push path stays + unchanged as the documented manual fallback and backfill (it shipped + 0.7.0 itself). `test/release.sh` grep-pins the gate, every decide + verdict, the single `on.push` key, and the same-job tag+publish+re-arm + in the same daemon-free, fail-closed style. ## 0.7.0 — 2026-07-19 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b8eb0a9..394985f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,10 +70,14 @@ A release is a PR, and merging it ships it commit bare `X.Y.Z` by hand and pushing the tag still publishes the same way (release.yml asserts the tag names the tree's own `VERSION`) — for backfills, or the day the merge path is red. -3. **Immediately after: bump `main`'s `VERSION` to `X.Y.(Z+1)-dev`.** Not - cosmetic — the versioned layout names install trees after `VERSION`, so a - `main` install without the bump would land in `versions/X.Y.Z` and - impersonate the release you just cut. +3. **The release re-arms main itself**: the same workflow run bumps + `VERSION` to `X.Y.(Z+1)-dev` and pushes the commit straight to main — + no follow-up PR (it opens one only if branch protection refuses the + direct push, and says so loudly). Not cosmetic — the versioned layout + names install trees after `VERSION`, so a `main` install without the + bump would land in `versions/X.Y.Z` and impersonate the release just + cut. On the *manual* tag path the bump stays yours: open the one-line + PR after publishing. ## Labels — who sets what From c8150f2d5b59cfad9dde0d83dbc5d236f9b89539 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 16:35:39 +0000 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20issues:=20write=20=E2=80=94=20the=20?= =?UTF-8?q?fallback=20PR's=20--label=20rides=20the=20issues=20API=20(label?= =?UTF-8?q?s.yml=20precedent)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 18810ba..37b7e72 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,9 @@ permissions: # scope): the decide step's label read (commits//pulls) and the bump # fallback's `gh pr create --label`. pull-requests: write + # ...and the --label on that fallback PR rides the ISSUES API (labels.yml + # grants the same pair for the same reason). + issues: write jobs: # The merge door (#96), riding pushes to main (see the trigger comment: