From 89345f7de3e0e5de4d57ee9083a2f4a6f9dd1b6d Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 15:46:03 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20the=20merge=20door=20rides=20pushes=20to?= =?UTF-8?q?=20main=20=E2=80=94=20fork=20PR=20tokens=20are=20read-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 blocker (claude + grok): 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 keeps the first-parent version interlock, 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 doors split on the pushed ref: tags to the tag door, main to the merge door. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 48 ++++++++++++++++++++++------------- test/release.sh | 26 +++++++++++-------- 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59bc87d..84ad328 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,10 +24,16 @@ name: release # 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: + # 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. @@ -37,24 +43,20 @@ permissions: 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. + # 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.event_name == 'pull_request' && - github.event.pull_request.merged == true && - contains(github.event.pull_request.labels.*.name, 'release') + if: github.ref == 'refs/heads/main' 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 }} + # 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 @@ -100,6 +102,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 — 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' @@ -129,7 +141,7 @@ jobs: # 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 }} + MERGE_SHA: ${{ github.sha }} run: | ver="$(cat VERSION)" gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$ver" -f "sha=$MERGE_SHA" @@ -139,7 +151,7 @@ jobs: # to the push event so a closed PR (the trigger above) never runs it # against a branch ref. release: - if: github.event_name == 'push' + if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/test/release.sh b/test/release.sh index 3b4486a..f5976d9 100644 --- a/test/release.sh +++ b/test/release.sh @@ -119,16 +119,22 @@ check "release.yml: the release is bound to the pushed tag (--verify-tag)" 0 "" # --------------------------------------------------------------------------- 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 "" \ +# 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 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: 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 "" \ @@ -139,7 +145,7 @@ 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 "4" \ +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"