feat: merging a release-labeled PR is the release #97

Merged
dan-claude-bot merged 8 commits from feat/release-on-merge into main 2026-07-19 17:12:29 +00:00
4 changed files with 303 additions and 21 deletions

View file

@ -1,24 +1,195 @@
name: release name: release
# The release publisher (#83), on a bare X.Y.Z tag push (the 0.6.0 tag set # The release publisher — two doors into the same act (#83, #96):
# 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 # * The merge door (#96): merging the `release`-labeled PR into main IS the
# wrong release is worse than a missing one), and the release body is that # release. The label is the intent, the version transition is the
# version's CHANGELOG.md section (.github/scripts/release-notes.sh, shared # interlock — VERSION at the merge commit must be non-`-dev` AND must have
# with test/release.sh) — the curated prose, not the generated PR list. No # changed in this PR, so a mislabeled ordinary PR fails loudly and creates
# assets are uploaded: for a pure-bash tree, GitHub's source tarball for the # NOTHING. The job then tags the merge commit via the API and publishes,
# tag IS the package, and install.sh downloads exactly that. # 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: on:
push: 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 # 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 # 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. # below, not be silently skipped by a pattern that didn't match.
tags: ["**"] tags: ["**"]
permissions: permissions:
contents: write # 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/<sha>/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: jobs:
# 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.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# 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
# 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 → 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
# 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 "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
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
# 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'
# 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 (re-runs refuse loudly)
if: steps.decide.outputs.ceremony == 'yes'
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
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.
env:
GH_TOKEN: ${{ github.token }}
MERGE_SHA: ${{ github.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 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
# against a branch ref.
release: release:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View file

@ -3,6 +3,35 @@
History before 0.5.0 lives in git and in [drill/RUNS.md](drill/RUNS.md), 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. which records not just what changed but what each drill run proved.
## Unreleased
### Added
- **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 ## 0.7.0 — 2026-07-19
### Added ### Added

View file

@ -43,7 +43,9 @@ labels tell you where everything is without opening anything.
## Releases ## 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` 1. **The release PR**`release: X.Y.Z`, labeled `release` — bumps `VERSION`
from `X.Y.Z-dev` and stamps the `## Unreleased` section with version + from `X.Y.Z-dev` and stamps the `## Unreleased` section with version +
@ -52,17 +54,30 @@ 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 the full drill on real hardware, recorded in
[drill/RUNS.md](drill/RUNS.md) — CI proves the tier's semantics on every [drill/RUNS.md](drill/RUNS.md) — CI proves the tier's semantics on every
PR, a release still proves the boundary. PR, a release still proves the boundary.
2. **Merge, then tag the merge commit** bare `X.Y.Z` — no `v` prefix, the 2. **The maintainer's merge IS the release.**
`0.6.0` tag set the precedent — and push the tag. [release.yml](.github/workflows/release.yml) fires on the merged,
[release.yml](.github/workflows/release.yml) takes it from there: it `release`-labeled PR and asserts before creating anything: `VERSION` at
asserts the tag names the tree's own `VERSION` (a mismatch fails loudly the merge commit is non-`-dev` **and changed in this PR** (the `-dev`
and creates nothing) and publishes the GitHub release with that version's interlock — a mislabeled ordinary PR fails loudly and creates nothing),
`CHANGELOG.md` section as the body. No assets — the source tarball for the version's `CHANGELOG.md` section extracts non-empty, and no tag or
the tag is the package, and `install.sh` downloads exactly that. release exists for it yet. Then, in the same job, it tags the merge
3. **Immediately after: bump `main`'s `VERSION` to `X.Y.(Z+1)-dev`.** Not commit bare `X.Y.Z` (no `v` prefix, the `0.6.0` precedent) and publishes
cosmetic — the versioned layout names install trees after `VERSION`, so a the GitHub release with that section as the body. No assets — the source
`main` install without the bump would land in `versions/X.Y.Z` and tarball for the tag is the package, and `install.sh` downloads exactly
impersonate the release you just cut. 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. **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 ## Labels — who sets what

View file

@ -112,6 +112,73 @@ 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 "" \ check "release.yml: the release is bound to the pushed tag (--verify-tag)" 0 "" \
grep -qF -- '--verify-tag' "$RY" 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"
# 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 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 "" \
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"
# 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 -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 "" \
grep -qF "Refusing to guess" "$RY"
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 # 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 # trick) and driven against a shim curl. The shim serves the ONE seam the