From b58ce5524f84b63a72ae5852575b102623366598 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 15:19:31 +0000 Subject: [PATCH] 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