forked from heavy-duty/box
fix: the merge door rides pushes to main — fork PR tokens are read-only
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 <noreply@anthropic.com>
This commit is contained in:
parent
00e36b9436
commit
89345f7de3
2 changed files with 46 additions and 28 deletions
48
.github/workflows/release.yml
vendored
48
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue