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

The rig twin of heavy-duty/box#96, from the release-ceremony retro: the
tag was a separate, manual, silent-when-forgotten step, and a forgotten
tag produces no red X — the worst failure shape. The ship decision
already lives in the release PR; merging it is "ship". After that,
tagging is transcription, and transcription belongs to machines.

release.yml now also fires on pull_request closed into main, gated on
merged AND the `release` label. The job asserts in order, each fail-loud
and creating nothing: VERSION at the merge commit is non--dev; VERSION
changed in THIS PR (base vs merge — the interlock that fails a
mislabeled ordinary PR); the changelog section for that version extracts
non-empty via the existing changelog_section from release-lib.sh; and no
tag or release exists yet. Then, in the same job, it API-creates the tag
at the merge commit and publishes the release with the extracted notes.
Same-job is load-bearing: a GITHUB_TOKEN-created tag does not fire the
tag-push trigger, so the publish must live next to the tag and the
fallback job cannot double-publish; the nothing-exists assert covers a
manual race. The tag-push path survives verbatim as the documented
manual fallback and backfill, and CONTRIBUTING's Releasing section now
reads merge-is-ship with the manual tag as fallback.

test/release.sh pins the merge path in the house grep-pin style: the
merged+labeled gate, the four asserts, the same-job tag+publish (awk
from release-on-merge: to EOF), the asserts-precede-the-tag ordering,
and the surviving tag-push trigger.

Fixes #47

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-19 15:19:47 +00:00
parent 219b0fbd72
commit 4ce1ab50aa
4 changed files with 191 additions and 18 deletions

View file

@ -1,25 +1,39 @@
name: release
# The tag half of the release flow (#32; box#83's design, near-verbatim).
# A release is a PR, then a tag: the `release: X.Y.Z` PR bumps VERSION and
# stamps CHANGELOG.md's Unreleased section with version + date; after the
# merge, the merge commit is tagged bare `X.Y.Z` (no `v` prefix — box's tag
# scheme) and the tag is pushed. This workflow turns that tag into the
# GitHub release, with the changelog section as the body — the curated
# prose, never the auto-generated PR list.
# Two ways in, one release out (#47; box#96's design — the merge path — on
# top of #32/box#83's tag flow, kept verbatim as the fallback):
#
# No assets on purpose: for a pure-bash tree, GitHub's source tarball for
# the tag IS the package (install.sh downloads archive/refs/tags/<tag>).
# - MERGE (the paved road): a release is a PR — `release: X.Y.Z`, carrying
# the `release` label, bumping VERSION and stamping CHANGELOG.md's
# Unreleased section — and MERGING it is the ship decision. The
# release-on-merge job asserts its way to certainty, then tags the merge
# commit and publishes, same job. No separate, silent-when-forgotten
# tagging step: a forgotten tag produces no red X, a failed run on main
# does — of two unreliabilities, pick the loud one.
# - TAG PUSH (the manual fallback and backfill): tag the merge commit bare
# `X.Y.Z` (no `v` prefix — box's tag scheme) and push; the release job
# below turns it into the GitHub release.
#
# Either way the body is the changelog section — the curated prose, never
# the auto-generated PR list — and no assets are uploaded on purpose: for a
# pure-bash tree, GitHub's source tarball for the tag IS the package
# (install.sh downloads archive/refs/tags/<tag>).
on:
push:
# Every tag, not a shape filter: a tag that mismatches VERSION must fail
# LOUDLY below, not be silently skipped by a pattern that didn't match.
tags: ['**']
pull_request:
types: [closed]
branches: [main]
permissions:
contents: write
jobs:
release:
# The tag-push path, gated to push events so a closed PR never lands
# here — the merge path is release-on-merge below.
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -46,3 +60,94 @@ jobs:
fi
gh release create "$GITHUB_REF_NAME" --verify-tag \
--title "$GITHUB_REF_NAME" --notes "$notes"
# The merge path (#47; box#96): the `release` label is the intent, the
# VERSION transition is the interlock. Four asserts in order, each
# fail-loud and creating NOTHING, then tag + publish in this same job.
# Same-job is load-bearing: the tag is created with GITHUB_TOKEN via the
# API, and GITHUB_TOKEN-created refs do not fire `on: push: tags`
# workflows — so the publish MUST live here (nothing else would run), and
# the fallback job above CANNOT double-publish off our tag. A manually
# pushed tag racing this run is caught by the nothing-exists assert.
# NOTE: test/release.sh pins this block by awk-ing from
# 'release-on-merge:' to EOF — keep it the last job.
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
env:
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
steps:
- uses: actions/checkout@v4
with:
# The merge commit is what ships — not the PR merge ref, which
# stops meaning anything once the PR closes. Full history so the
# base-side VERSION is readable for the interlock below.
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 0
# Assert 1 — the merged tree says it is a release. A `-dev` VERSION
# here means the label lied (or the ceremony PR forgot the bump).
- name: assert the merged tree is a release (non-dev VERSION)
run: |
ver="$(cat VERSION)"
case "$ver" in
*-dev)
echo "VERSION '$ver' is still -dev — a release PR ships a bare X.Y.Z; refusing to release a dev tree" >&2
exit 1 ;;
esac
# Assert 2 — THIS PR is the one that changed VERSION (base vs merge).
# The `-dev` transition as a safety interlock: an ordinary PR someone
# mislabels `release` fails here loudly instead of shipping main
# under a version some earlier PR minted.
- name: assert VERSION changed in this PR (the mislabel interlock)
run: |
ver="$(cat VERSION)"
base_ver="$(git show "$BASE_SHA:VERSION")"
if [ "$base_ver" = "$ver" ]; then
echo "VERSION did not change in this PR ('$ver' before and after) — a 'release'-labeled PR must be the ceremony PR that bumps it; refusing to release" >&2
exit 1
fi
# Assert 3 — the changelog names exactly this version, and the one
# extractor (shared with the tag job and test/release.sh) gets a
# non-empty body out of it. The notes are kept for the publish.
- name: assert the changelog section for this version extracts
run: |
. .github/scripts/release-lib.sh
ver="$(cat VERSION)"
changelog_section CHANGELOG.md "$ver" > "$RUNNER_TEMP/notes.md"
if [ ! -s "$RUNNER_TEMP/notes.md" ]; then
echo "CHANGELOG.md has no '## $ver' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release" >&2
exit 1
fi
cat "$RUNNER_TEMP/notes.md"
# Assert 4 — nothing exists yet, tag or release: a re-run of this job
# (or a manual tag that beat it) must refuse, not clobber.
- name: assert no tag and no release exist yet (idempotent re-runs)
env:
GH_TOKEN: ${{ github.token }}
run: |
ver="$(cat VERSION)"
if git ls-remote --exit-code origin "refs/tags/$ver" >/dev/null 2>&1; then
echo "tag '$ver' already exists — this release already happened (or is mid-flight on the manual path); refusing to re-release" >&2
exit 1
fi
if gh release view "$ver" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "release '$ver' already exists — refusing to re-release" >&2
exit 1
fi
# Act — tag the merge commit via the API, then publish with the notes
# assert 3 extracted. (GITHUB_TOKEN-created tag: no recursive
# workflow runs — see the job comment.)
- name: tag the merge commit and publish the release
env:
GH_TOKEN: ${{ github.token }}
run: |
ver="$(cat VERSION)"
gh api -X POST "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" \
-R "$GITHUB_REPOSITORY"

View file

@ -36,6 +36,20 @@ on the way to cutting its first release, and this file starts there.
### Added
- **Merging a release-labeled PR IS the release** (#47) — the rig twin of
heavy-duty/box#96, born of the ceremony retro: the tag was a separate,
manual, silent-when-forgotten step, and a forgotten tag produces no red
X. `release.yml` now also fires when a PR into main closes, gated on
merged AND the `release` label, and asserts in order — fail-loud,
creating nothing: `VERSION` at the merge commit is non-`-dev`; `VERSION`
*changed in this PR* (the interlock that fails a mislabeled ordinary PR);
the changelog section for that version extracts non-empty via the same
`changelog_section`; no tag or release exists yet. Then, in the same job,
it API-creates the tag at the merge commit and publishes the release with
the extracted notes. A `GITHUB_TOKEN`-created tag never fires the
tag-push trigger, so the paths cannot double-publish — and that tag-push
path survives intact as the documented manual fallback and backfill.
- **Tagged releases, and an installer that installs them** (#32) — the rig
half of the flow designed in heavy-duty/box#83, near-verbatim. A release
is a PR, then a tag: the `release: X.Y.Z` PR bumps `VERSION` and stamps

View file

@ -46,20 +46,31 @@ labels tell you where everything is without opening anything.
## Releasing
A release is a PR, then a tag (#32; box#83's design):
A release is a PR, and merging it is the release (#47; box#96's design, on
top of #32/box#83's tag flow):
1. A small PR — `release: X.Y.Z` — bumps `VERSION` from `X.Y.Z-dev` and
stamps `CHANGELOG.md`'s Unreleased section as `## X.Y.Z — YYYY-MM-DD`.
CI green on it, same loop as any PR.
2. Merge, tag the merge commit bare `X.Y.Z` (no `v` prefix — box's tag
scheme), push the tag. `release.yml` asserts tag == `VERSION` (a
mismatch fails loudly and creates nothing) and creates the GitHub
release with that version's changelog section as the body. No assets —
the source tarball for the tag is the package `install.sh` downloads.
1. A small PR — `release: X.Y.Z`, carrying the `release` label — bumps
`VERSION` from `X.Y.Z-dev` and stamps `CHANGELOG.md`'s Unreleased
section as `## X.Y.Z — YYYY-MM-DD`. CI green on it, same loop as any PR.
2. Merge it — that IS the ship decision. `release.yml`'s
`release-on-merge` job asserts, in order, fail-loud, creating nothing:
the merged tree's `VERSION` is non-`-dev`; this PR is the one that
changed it (a mislabeled ordinary PR fails here); the changelog section
for that version extracts non-empty; no tag or release exists yet.
Then, same job, it tags the merge commit bare `X.Y.Z` (no `v` prefix —
box's tag scheme) and publishes the GitHub release with that section as
the body. No assets — the source tarball for the tag is the package
`install.sh` downloads.
3. A follow-up (or the next feature PR) bumps main's `VERSION` to
`X.Y.(Z+1)-dev`, so a dev install never impersonates the release in the
`versions/<v>` layout.
Manual fallback (and backfill): if the merge-path run fails, fix what it
named, then tag the merge commit `X.Y.Z` by hand and push the tag — the
original tag-push job still turns any correct tag into the release, and
the merge path's nothing-exists-yet assert keeps the two from
double-publishing.
## Labels — who sets what
The full taxonomy lives in [LABELS.md](LABELS.md). What matters day to day is

View file

@ -130,6 +130,49 @@ create_at="$(grep -n "gh release create" "$RY" | head -n1 | cut -d: -f1)"
check "release.yml: the assert precedes the create" \
0 "" test "${assert_at:-999999}" -lt "${create_at:-0}"
# --- release.yml, the merge path: the pins (#47; box#96's design) ------------
# Merging the release-labeled ceremony PR IS the release. Same grep-pin
# treatment for the merge path's load-bearing pieces: the gate, the four
# fail-loud asserts, the same-job tag+publish, and the surviving tag-push
# fallback.
check "release.yml: fires when a PR into main closes (merge = ship)" 0 "" \
grep -qF "pull_request:" "$RY"
check "release.yml: only a MERGED PR releases (closed-unmerged never fires)" 0 "" \
grep -qF "github.event.pull_request.merged == true" "$RY"
check "release.yml: only the 'release' label carries the intent" 0 "" \
grep -qF "contains(github.event.pull_request.labels.*.name, 'release')" "$RY"
check "release.yml: assert 1 — a still-dev VERSION refuses" 0 "" \
grep -qF "refusing to release a dev tree" "$RY"
check "release.yml: assert 2 — an unchanged VERSION refuses (the mislabel interlock)" 0 "" \
grep -qF "VERSION did not change in this PR" "$RY"
check "release.yml: assert 3 — an empty section refuses to publish" 0 "" \
grep -qF "refusing to publish an empty release" "$RY"
check "release.yml: assert 4 — an existing tag or release refuses (idempotent)" 0 "" \
grep -qF "refusing to re-release" "$RY"
# Same-job matters: a GITHUB_TOKEN-created tag fires no tag-push workflow,
# so the publish must live NEXT TO the tag creation. The workflow keeps
# release-on-merge as its last job (pinned by comment there) so the awk
# range runs to EOF; both acts must land inside it.
MJOB="$(awk '/^ release-on-merge:/,0' "$RY")"
mjob_has() { printf '%s' "$MJOB" | grep -qF -e "$1"; }
check "release.yml: the merge job API-creates the tag itself" 0 "" \
mjob_has "git/refs"
check "release.yml: ...at the MERGE commit" 0 "" mjob_has "merge_commit_sha"
check "release.yml: ...and publishes in the SAME job" 0 "" \
mjob_has "gh release create"
# Ordering, the marker-then-box idiom again: the last assert's refusal must
# precede the tag creation (asserts first, acts last; defaults fail closed).
massert_at="$(grep -n "refusing to re-release" "$RY" | head -n1 | cut -d: -f1)"
mtag_at="$(grep -n "git/refs" "$RY" | head -n1 | cut -d: -f1)"
check "release.yml: the merge-path asserts precede the tag" \
0 "" test "${massert_at:-999999}" -lt "${mtag_at:-0}"
# ...and the manual path SURVIVES: tag-push trigger plus a push-gated job,
# the documented fallback and backfill.
check "release.yml: the tag-push trigger survives (manual fallback intact)" 0 "" \
grep -qF "tags: ['**']" "$RY"
check "release.yml: the fallback job is gated to push events" 0 "" \
grep -qF "github.event_name == 'push'" "$RY"
# --- the installer's ref logic, extracted ------------------------------------
# install.sh must stay a single curl|bash file, so its channel functions live
# inline; extract them here and drive them for real (the valid_version awk