From 44717863a11764f71224189a290284f5bbc794b2 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 15:44:27 +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 (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