diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6426936..37b7e72 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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//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 diff --git a/CHANGELOG.md b/CHANGELOG.md index a9fe1d0..f238a23 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aff4a2e..394985f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/test/release.sh b/test/release.sh index 3331faa..8e8a5e9 100644 --- a/test/release.sh +++ b/test/release.sh @@ -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