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:
parent
fb7fc845bf
commit
b58ce5524f
4 changed files with 181 additions and 17 deletions
110
.github/workflows/release.yml
vendored
110
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
22
CHANGELOG.md
22
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue