forked from heavy-duty/box
Merge pull request #97 from dan-claude-bot/feat/release-on-merge
feat: merging a release-labeled PR is the release
This commit is contained in:
commit
f2f57cca7f
4 changed files with 303 additions and 21 deletions
189
.github/workflows/release.yml
vendored
189
.github/workflows/release.yml
vendored
|
|
@ -1,24 +1,195 @@
|
|||
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:
|
||||
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
|
||||
# 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.
|
||||
tags: ["**"]
|
||||
|
||||
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:
|
||||
# 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:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
|
|||
29
CHANGELOG.md
29
CHANGELOG.md
|
|
@ -3,6 +3,35 @@
|
|||
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 — 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
|
||||
|
||||
### 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,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
|
||||
[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.
|
||||
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
|
||||
impersonate the release you just cut.
|
||||
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. **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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "" \
|
||||
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
|
||||
# trick) and driven against a shim curl. The shim serves the ONE seam the
|
||||
|
|
|
|||
Loading…
Reference in a new issue