From 4ce1ab50aabf482a67b60442754802e517e4f1fe Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 15:19:47 +0000 Subject: [PATCH 01/10] feat: merging a release-labeled PR is the release (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/release.yml | 123 +++++++++++++++++++++++++++++++--- CHANGELOG.md | 14 ++++ CONTRIBUTING.md | 29 +++++--- test/release.sh | 43 ++++++++++++ 4 files changed, 191 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e9ed7a5..63160ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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/). +# - 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/). 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" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ed2a8e..d1862bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 36701e5..384277d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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/` 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 diff --git a/test/release.sh b/test/release.sh index f223b9d..6e19686 100644 --- a/test/release.sh +++ b/test/release.sh @@ -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 -- 2.45.2 From d08ec8c6f698243503b1c807ea49df9c2828e085 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 15:23:56 +0000 Subject: [PATCH 02/10] fix: the release label's two meanings part ways in a decide step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LABELS.md gives 'release' to release-flow WORK as well as to the ceremony PR — including every PR that improves this very workflow. The old assert pair turned each of those merges into a red run on main. The fused decide step reads the version against the PR base and answers all four states: -dev unchanged = work, green NOTICE no-op; bare unchanged but already released = work in the post-release window, same no-op; -dev-but-changed and bare-unchanged-never-released = half-ceremonies, refused loudly; bare-and-changed = the ceremony. Later steps gate on its output. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 59 +++++++++++++++++++++++++---------- test/release.sh | 20 +++++++++--- 2 files changed, 59 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63160ca..21c4033 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,32 +88,57 @@ jobs: # 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) + # The decide step — asserts 1+2 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 — this very PR included. + # The version tells them apart. A `-dev` VERSION left UNTOUCHED by the + # PR is release-flow work: a green no-op, not a red run on main every + # time the flow itself is improved. Everything in between is a + # half-ceremony and dies loudly: + # -dev, unchanged → work under the label: NOTICE + green no-op + # -dev, changed → a bump that forgot to leave -dev: refuse + # bare, unchanged, + # already released → work merged in the post-release window + # (ceremony landed, the -dev bump has not): + # NOTICE + green no-op + # bare, unchanged, + # never released → the label says ship, the tree names an + # unshipped version this PR did not mint: + # genuinely ambiguous, refuse + # 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_ver="$(git show "$BASE_SHA:VERSION")" + case "$ver" in + *-dev) + if [ "$base_ver" = "$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 "VERSION changed ('$base_ver' -> '$ver') but is still -dev — half a ceremony; a release PR ships a bare X.Y.Z. Refusing." >&2 + exit 1 ;; + esac 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 + if gh release view "$ver" -R "$GITHUB_REPOSITORY" >/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." >&2 exit 1 fi + echo "ceremony=yes" >> "$GITHUB_OUTPUT" # 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 + if: steps.decide.outputs.ceremony == 'yes' run: | . .github/scripts/release-lib.sh ver="$(cat VERSION)" @@ -126,6 +151,7 @@ jobs: # 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) + if: steps.decide.outputs.ceremony == 'yes' env: GH_TOKEN: ${{ github.token }} run: | @@ -142,6 +168,7 @@ jobs: # assert 3 extracted. (GITHUB_TOKEN-created tag: no recursive # workflow runs — see the job comment.) - name: tag the merge commit and publish the release + if: steps.decide.outputs.ceremony == 'yes' env: GH_TOKEN: ${{ github.token }} run: | diff --git a/test/release.sh b/test/release.sh index 6e19686..5bdfaa1 100644 --- a/test/release.sh +++ b/test/release.sh @@ -141,10 +141,22 @@ 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" +# 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): work under +# the label is a green NOTICE no-op — in the -dev steady state and in the +# post-release window (bare, unchanged, already released) — while every +# half-ceremony refuses. Pin each verdict's message 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 half-ceremony (-dev but changed) refuses" 0 "" \ + grep -qF "half a ceremony" "$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" +# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately +check "release.yml: decide gates every later step on ceremony=yes" 0 "" \ + bash -c '[ "$(grep -cF "if: steps.decide.outputs.ceremony == '\''yes'\''" "$1")" -ge 3 ]' _ "$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 "" \ -- 2.45.2 From c0cd4a1b151cbfa69bbf454c9b983cb91b9cc72c Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 15:42:27 +0000 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20a=20-dev=20endstate=20is=20always?= =?UTF-8?q?=20work=20=E2=80=94=20the=20post-release=20bump=20must=20not=20?= =?UTF-8?q?run=20red?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four-state table called '-dev but changed' half a ceremony and refused — but that state IS the mandatory post-release bump PR (bare -> X.Y.(Z+1)-dev after every release), a red run on main once per release, forever. A tree that ends -dev is by definition not a release: every such merge is work, green NOTICE no-op. Red now guards only bare endstates. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 9 ++++++--- test/release.sh | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21c4033..4545593 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -97,7 +97,9 @@ jobs: # time the flow itself is improved. Everything in between is a # half-ceremony and dies loudly: # -dev, unchanged → work under the label: NOTICE + green no-op - # -dev, changed → a bump that forgot to leave -dev: refuse + # -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, # already released → work merged in the post-release window # (ceremony landed, the -dev bump has not): @@ -121,8 +123,9 @@ jobs: echo "ceremony=no" >> "$GITHUB_OUTPUT" exit 0 fi - echo "VERSION changed ('$base_ver' -> '$ver') but is still -dev — half a ceremony; a release PR ships a bare X.Y.Z. Refusing." >&2 - exit 1 ;; + echo "NOTICE: VERSION changed ('$base_ver' -> '$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" = "$ver" ]; then if gh release view "$ver" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then diff --git a/test/release.sh b/test/release.sh index 5bdfaa1..b2c2133 100644 --- a/test/release.sh +++ b/test/release.sh @@ -148,8 +148,8 @@ check "release.yml: only the 'release' label carries the intent" 0 "" \ # half-ceremony refuses. Pin each verdict's message 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 half-ceremony (-dev but changed) refuses" 0 "" \ - grep -qF "half 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 "" \ -- 2.45.2 From 44717863a11764f71224189a290284f5bbc794b2 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 15:44:27 +0000 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20the=20merge=20door=20rides=20pushe?= =?UTF-8?q?s=20to=20main=20=E2=80=94=20fork=20PR=20tokens=20are=20read-onl?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 blocker (grok, claude on box#97's twin): a pull_request run from a public fork gets a read-only GITHUB_TOKEN — permissions: cannot raise it — and every ceremony PR this org merges is cross-repo from the bot fork, so the tag create would 403 after green asserts, red on main per release. The door now triggers on push to main (in-repo event, full token): the decide step reads the version transition from event.before (first-parent fallback for the all-zeros edge), and the release label — still the operator's declared intent — is read via the API off the merge commit's PR. A transition with no labeled PR behind it refuses. The two doors now split on the pushed ref: tags to the tag door, main to this one. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 46 +++++++++++++++++++++++------------ test/release.sh | 23 ++++++++++++------ 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4545593..b32361f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,8 +22,15 @@ on: # 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] + push: + # The merge-is-the-release path (#47) rides pushes to MAIN, not + # pull_request events, for one load-bearing reason the first review + # round caught: 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] permissions: @@ -31,9 +38,9 @@ permissions: 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' + # The tag-push path — a pushed TAG ref. The merge path (a pushed main + # head) is release-on-merge below; the two doors split on the ref. + if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -72,21 +79,17 @@ jobs: # 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') + if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest env: - MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} + MERGE_SHA: ${{ github.sha }} + BASE_SHA: ${{ github.event.before }} 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 }} + # The pushed head is what ships. Full history so the before-side + # VERSION is readable for the interlock below. + ref: ${{ github.sha }} fetch-depth: 0 # The decide step — asserts 1+2 fused, because the `release` label # carries TWO legitimate meanings (LABELS.md: "release flow and @@ -115,6 +118,9 @@ jobs: GH_TOKEN: ${{ github.token }} run: | ver="$(cat VERSION)" + # event.before is all-zeros on a branch-create push; the merge + # commit's first parent is main the instant before, either way. + if ! git cat-file -e "$BASE_SHA" 2>/dev/null; then BASE_SHA="$(git rev-parse "$MERGE_SHA^1")"; fi base_ver="$(git show "$BASE_SHA:VERSION")" case "$ver" in *-dev) @@ -136,6 +142,16 @@ jobs: 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." >&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 itself lives on a fork — see the trigger + # comment). No release-labeled PR behind this commit = a version + # transition nobody declared: refuse. + if ! gh api "repos/$GITHUB_REPOSITORY/commits/$MERGE_SHA/pulls" \ + -q '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' | grep -qx true; then + echo "VERSION transitioned ('$base_ver' -> '$ver') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR (#47), not a bare push. Refusing." >&2 + exit 1 + fi echo "ceremony=yes" >> "$GITHUB_OUTPUT" # Assert 3 — the changelog names exactly this version, and the one # extractor (shared with the tag job and test/release.sh) gets a diff --git a/test/release.sh b/test/release.sh index b2c2133..ebf5c3d 100644 --- a/test/release.sh +++ b/test/release.sh @@ -135,12 +135,20 @@ check "release.yml: the assert precedes the create" \ # 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" +# The merge door rides pushes to MAIN, not pull_request events: a fork PR's +# pull_request 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 (#48 round 1). The +# label — the operator's intent — is read via the API off the merge commit. +check "release.yml: the merge door rides pushes to main (fork-token-proof)" 0 "" \ + grep -qF "branches: [main]" "$RY" +check "release.yml: ...and the doors split on the ref (tag door takes tags)" 0 "" \ + grep -qF "startsWith(github.ref, 'refs/tags/')" "$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/$MERGE_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" # 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): work under # the label is a green NOTICE no-op — in the -dev steady state and in the @@ -169,7 +177,8 @@ 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" +# shellcheck disable=SC2016 # the $-string is a literal in the target file +check "release.yml: ...at the pushed main head (github.sha = the merge commit)" 0 "" mjob_has 'sha="$MERGE_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 -- 2.45.2 From 053e12d51e73c4ecb789d421cd7e242ade49cfeb Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 16:02:53 +0000 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20the=20release=20re-arms=20main=20?= =?UTF-8?q?=E2=80=94=20the=20-dev=20bump=20folds=20into=20the=20release=20?= =?UTF-8?q?act?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator decision: the post-release bump PR is ceremony debris — a derivable one-liner with no judgment for a review to add. After tag + publish, the same job computes X.Y.(Z+1)-dev and pushes it to main directly (a GITHUB_TOKEN push fires no workflows, so no recursion and no red run); if branch protection refuses, the step opens the bump PR itself, loudly. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 31 +++++++++++++++++++++++++++++++ test/release.sh | 6 ++++++ 2 files changed, 37 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b32361f..67b49f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -197,3 +197,34 @@ jobs: gh release create "$ver" --verify-tag \ --title "$ver" --notes-file "$RUNNER_TEMP/notes.md" \ -R "$GITHUB_REPOSITORY" + # The post-release bump, folded into the release act (#47 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; and 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 diff --git a/test/release.sh b/test/release.sh index ebf5c3d..08eba26 100644 --- a/test/release.sh +++ b/test/release.sh @@ -179,6 +179,12 @@ check "release.yml: the merge job API-creates the tag itself" 0 "" \ mjob_has "git/refs" # shellcheck disable=SC2016 # the $-string is a literal in the target file check "release.yml: ...at the pushed main head (github.sha = the merge commit)" 0 "" mjob_has 'sha="$MERGE_SHA"' +# 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" 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 -- 2.45.2 From 4b5d62e405ad0592f4d2e54c538ff0e17b56d875 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 16:14:13 +0000 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20one=20on.push=20key=20=E2=80=94=20?= =?UTF-8?q?YAML=20last-key-wins=20had=20dropped=20the=20tag=20door?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grok's round-2 catch: two sibling push: maps under on: leave only the second (branches: [main]) alive — the tag-push fallback stopped triggering entirely. Both filters now live under one push key, the jobs still split on the pushed ref, and a pin counts exactly one on.push. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67b49f2..6d9ca3c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,19 +18,20 @@ name: release # pure-bash tree, GitHub's source tarball for the tag IS the package # (install.sh downloads archive/refs/tags/). on: + # ONE push key, both filters — YAML maps are last-key-wins, so a second + # sibling `push:` would silently REPLACE the first and kill a door + # (grok's round-2 catch: the tag fallback had stopped triggering). 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: ['**'] - push: # The merge-is-the-release path (#47) rides pushes to MAIN, not - # pull_request events, for one load-bearing reason the first review - # round caught: 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. + # pull_request events: a pull_request run from a public FORK gets a + # READ-ONLY GITHUB_TOKEN — `permissions:` cannot raise that ceiling — + # and every ceremony PR this org merges is cross-repo from the bot + # fork; the tag create would 403 after green asserts. A push to main + # is an in-repo event with the full write token, whoever authored the + # PR. The jobs split on the pushed ref. branches: [main] permissions: -- 2.45.2 From 0dbcad2e3af85c48b69d7516311c3ca75b587558 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 16:14:42 +0000 Subject: [PATCH 07/10] test: pin exactly one on.push key Co-Authored-By: Claude Fable 5 --- test/release.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/release.sh b/test/release.sh index 08eba26..6a59278 100644 --- a/test/release.sh +++ b/test/release.sh @@ -142,6 +142,11 @@ check "release.yml: the assert precedes the create" \ # label — the operator's intent — is read via the API off the merge commit. check "release.yml: the merge door rides pushes to main (fork-token-proof)" 0 "" \ grep -qF "branches: [main]" "$RY" +# YAML maps are last-key-wins: a second sibling push: key silently replaces +# the first and kills a door (grok's round-2 catch — the tag fallback had +# stopped triggering). Exactly ONE push key may exist. +check "release.yml: exactly one on.push key (duplicate keys drop a door)" 0 "1" \ + grep -cE '^ push:' "$RY" check "release.yml: ...and the doors split on the ref (tag door takes tags)" 0 "" \ grep -qF "startsWith(github.ref, 'refs/tags/')" "$RY" # shellcheck disable=SC2016 # the $-string is a literal in the target file -- 2.45.2 From 700e2185eb7d7ff481317d44eab5db7cca94aed1 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 16:34:29 +0000 Subject: [PATCH 08/10] fix: pull-requests scope for the door's two PR-API calls; docs catch up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cast-twin blocker (claude-bot): a declared permissions: block zeroes unspecified scopes, so the label read and the bump fallback's gh pr create could only 403 — every genuine ceremony would end red at the label check. pull-requests: write added, consumers named. CONTRIBUTING step 3 and the changelog entry now tell the shipped story: push-to-main door, event.before interlock, self-re-arm, manual-path bump stays the operator's. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 6 +++++- CHANGELOG.md | 31 ++++++++++++++++++------------- CONTRIBUTING.md | 9 ++++++--- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d9ca3c..cf37c93 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,11 @@ on: branches: [main] permissions: - contents: write + contents: write # 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 jobs: release: diff --git a/CHANGELOG.md b/CHANGELOG.md index d1862bb..27383db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,19 +36,24 @@ 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. +- **Merging a release-labeled PR IS the release — and the release re-arms + main itself** (#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 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). Then, in the same job, it + API-creates the tag at the merge commit, publishes with the extracted + notes — 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. 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 384277d..ecccb28 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,9 +61,12 @@ top of #32/box#83's tag flow): 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/` layout. +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, loudly). A dev install therefore never impersonates the + release in the `versions/` layout. On the *manual* tag path the + bump stays yours: open the one-line PR after publishing. 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 -- 2.45.2 From 0b6318fe240f1951ce95e28b586172107bba7615 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 16:34:56 +0000 Subject: [PATCH 09/10] chore: the permissions comment must not pre-match the ordering pin's grep Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf37c93..898007b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,7 @@ on: branches: [main] permissions: - contents: write # tag ref + gh release create + the bump push + contents: write # the tag ref, the release publish, 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`. -- 2.45.2 From e533e863224eb406f70ba9136cf2b74af003ba0c Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 16:35:50 +0000 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20issues:=20write=20=E2=80=94=20the?= =?UTF-8?q?=20fallback=20PR's=20--label=20rides=20the=20issues=20API=20(la?= =?UTF-8?q?bels.yml=20precedent)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 898007b..2ce4ea5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,9 @@ permissions: # 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: release: -- 2.45.2