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:
parent
c0cd4a1b15
commit
44717863a1
2 changed files with 47 additions and 22 deletions
46
.github/workflows/release.yml
vendored
46
.github/workflows/release.yml
vendored
|
|
@ -22,8 +22,15 @@ on:
|
||||||
# Every tag, not a shape filter: a tag that mismatches VERSION must fail
|
# 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.
|
# LOUDLY below, not be silently skipped by a pattern that didn't match.
|
||||||
tags: ['**']
|
tags: ['**']
|
||||||
pull_request:
|
push:
|
||||||
types: [closed]
|
# 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]
|
branches: [main]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|
@ -31,9 +38,9 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
# The tag-push path, gated to push events so a closed PR never lands
|
# The tag-push path — a pushed TAG ref. The merge path (a pushed main
|
||||||
# here — the merge path is release-on-merge below.
|
# head) is release-on-merge below; the two doors split on the ref.
|
||||||
if: github.event_name == 'push'
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
@ -72,21 +79,17 @@ jobs:
|
||||||
# NOTE: test/release.sh pins this block by awk-ing from
|
# NOTE: test/release.sh pins this block by awk-ing from
|
||||||
# 'release-on-merge:' to EOF — keep it the last job.
|
# 'release-on-merge:' to EOF — keep it the last job.
|
||||||
release-on-merge:
|
release-on-merge:
|
||||||
if: >-
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
github.event_name == 'pull_request' &&
|
|
||||||
github.event.pull_request.merged == true &&
|
|
||||||
contains(github.event.pull_request.labels.*.name, 'release')
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
|
MERGE_SHA: ${{ github.sha }}
|
||||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
BASE_SHA: ${{ github.event.before }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# The merge commit is what ships — not the PR merge ref, which
|
# The pushed head is what ships. Full history so the before-side
|
||||||
# stops meaning anything once the PR closes. Full history so the
|
# VERSION is readable for the interlock below.
|
||||||
# base-side VERSION is readable for the interlock below.
|
ref: ${{ github.sha }}
|
||||||
ref: ${{ github.event.pull_request.merge_commit_sha }}
|
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
# The decide step — asserts 1+2 fused, because the `release` label
|
# The decide step — asserts 1+2 fused, because the `release` label
|
||||||
# carries TWO legitimate meanings (LABELS.md: "release flow and
|
# carries TWO legitimate meanings (LABELS.md: "release flow and
|
||||||
|
|
@ -115,6 +118,9 @@ jobs:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
ver="$(cat VERSION)"
|
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")"
|
base_ver="$(git show "$BASE_SHA:VERSION")"
|
||||||
case "$ver" in
|
case "$ver" in
|
||||||
*-dev)
|
*-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
|
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
|
exit 1
|
||||||
fi
|
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"
|
echo "ceremony=yes" >> "$GITHUB_OUTPUT"
|
||||||
# Assert 3 — the changelog names exactly this version, and the one
|
# Assert 3 — the changelog names exactly this version, and the one
|
||||||
# extractor (shared with the tag job and test/release.sh) gets a
|
# extractor (shared with the tag job and test/release.sh) gets a
|
||||||
|
|
|
||||||
|
|
@ -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
|
# 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
|
# fail-loud asserts, the same-job tag+publish, and the surviving tag-push
|
||||||
# fallback.
|
# fallback.
|
||||||
check "release.yml: fires when a PR into main closes (merge = ship)" 0 "" \
|
# The merge door rides pushes to MAIN, not pull_request events: a fork PR's
|
||||||
grep -qF "pull_request:" "$RY"
|
# pull_request run gets a read-only GITHUB_TOKEN (permissions: cannot raise
|
||||||
check "release.yml: only a MERGED PR releases (closed-unmerged never fires)" 0 "" \
|
# it), and every ceremony PR this org merges is cross-repo from the bot
|
||||||
grep -qF "github.event.pull_request.merged == true" "$RY"
|
# fork — the tag create would 403 after green asserts (#48 round 1). The
|
||||||
check "release.yml: only the 'release' label carries the intent" 0 "" \
|
# label — the operator's intent — is read via the API off the merge commit.
|
||||||
grep -qF "contains(github.event.pull_request.labels.*.name, 'release')" "$RY"
|
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
|
# 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
|
# `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
|
# 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"; }
|
mjob_has() { printf '%s' "$MJOB" | grep -qF -e "$1"; }
|
||||||
check "release.yml: the merge job API-creates the tag itself" 0 "" \
|
check "release.yml: the merge job API-creates the tag itself" 0 "" \
|
||||||
mjob_has "git/refs"
|
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 "" \
|
check "release.yml: ...and publishes in the SAME job" 0 "" \
|
||||||
mjob_has "gh release create"
|
mjob_has "gh release create"
|
||||||
# Ordering, the marker-then-box idiom again: the last assert's refusal must
|
# Ordering, the marker-then-box idiom again: the last assert's refusal must
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue