feat: merging a release-labeled PR is the release (#96)

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>
This commit is contained in:
dan-claude-bot 2026-07-19 15:19:31 +00:00
parent fb7fc845bf
commit b58ce5524f
4 changed files with 181 additions and 17 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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