fix: the merge door rides pushes to main — fork PR tokens are read-only

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 <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-19 15:44:27 +00:00
parent c0cd4a1b15
commit 44717863a1
2 changed files with 47 additions and 22 deletions

View file

@ -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

View file

@ -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