fix: the merge door rides pushes to main — fork PR tokens are read-only
Round-1 blocker (grok; claude's box 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 transition from event.before (first- parent fallback for the all-zeros edge) and the release label — still the operator's declared intent — via the API off the merge commit's PR. A transition with no labeled PR behind it refuses. The steps split on the pushed ref: tags to the tag path, main to the merge path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
fcb7ce3b7d
commit
84bd9f341f
2 changed files with 55 additions and 30 deletions
68
.github/workflows/release.yml
vendored
68
.github/workflows/release.yml
vendored
|
|
@ -26,11 +26,15 @@ on:
|
||||||
# assert LOUDLY below, not be silently skipped by a pattern that didn't
|
# assert LOUDLY below, not be silently skipped by a pattern that didn't
|
||||||
# match.
|
# match.
|
||||||
tags: ["**"]
|
tags: ["**"]
|
||||||
pull_request:
|
push:
|
||||||
# The merge-is-the-release path (#111). `closed` is the only type that
|
# The merge-is-the-release path (#111) rides pushes to MAIN, not
|
||||||
# can mean "merged"; the job gate below drops closed-unmerged and
|
# pull_request events, for one load-bearing reason the first review
|
||||||
# unlabeled closures.
|
# round caught: a workflow run triggered by a pull_request from a
|
||||||
types: [closed]
|
# 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:
|
||||||
|
|
@ -38,28 +42,27 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
# Tag pushes always enter (the asserts below are the filter). PR
|
# Tag pushes and main pushes both enter (the asserts below are the
|
||||||
# closures enter only when the PR actually MERGED and carries the
|
# filter); the steps split on the ref. The hand-set `release` label
|
||||||
# hand-set `release` label (LABELS.md: `release` is the operator's —
|
# (LABELS.md: `release` is the operator's — automation never guesses
|
||||||
# automation never guesses intent).
|
# intent) is read via the API off the merge commit's PR, inside the
|
||||||
if: >-
|
# decide step — a push event carries no PR payload, and the PR itself
|
||||||
github.event_name == 'push' ||
|
# lives on a fork (the trigger comment).
|
||||||
(github.event.pull_request.merged == true &&
|
if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main'
|
||||||
contains(github.event.pull_request.labels.*.name, 'release'))
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# Tag push: the tag. Merged PR: the MERGE COMMIT on main — the
|
# Either door: the pushed ref — a tag, or main's new head (the
|
||||||
# exact tree the maintainer shipped, which the tag created below
|
# merge commit the maintainer shipped, which the tag created
|
||||||
# will name.
|
# below will name).
|
||||||
ref: ${{ github.event.pull_request.merge_commit_sha || github.ref }}
|
ref: ${{ github.sha }}
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
cache: npm
|
cache: npm
|
||||||
- name: "tag push: the tag must name package.json's version"
|
- name: "tag push: the tag must name package.json's version"
|
||||||
if: github.event_name == 'push'
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
run: |
|
run: |
|
||||||
ver="$(node -p 'require("./package.json").version')"
|
ver="$(node -p 'require("./package.json").version')"
|
||||||
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
|
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
|
||||||
|
|
@ -94,14 +97,17 @@ jobs:
|
||||||
# bare, changed → the ceremony: proceed
|
# bare, changed → the ceremony: proceed
|
||||||
- name: 'decide: ceremony, or release-flow work under the label?'
|
- name: 'decide: ceremony, or release-flow work under the label?'
|
||||||
id: decide
|
id: decide
|
||||||
if: github.event_name == 'pull_request'
|
if: github.ref == 'refs/heads/main'
|
||||||
env:
|
env:
|
||||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
BASE_SHA: ${{ github.event.before }}
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
# Versions read via node, never regex (the pkg_version discipline).
|
# Versions read via node, never regex (the pkg_version discipline).
|
||||||
ver="$(node -p 'require("./package.json").version')"
|
ver="$(node -p 'require("./package.json").version')"
|
||||||
git fetch --depth=1 origin "$BASE_SHA"
|
# event.before is all-zeros on a branch-create push; the pushed
|
||||||
|
# head's first parent is main the instant before, either way.
|
||||||
|
case "$BASE_SHA" in *[!0]*) ;; *) BASE_SHA="$(git rev-parse "$GITHUB_SHA^1")" ;; esac
|
||||||
|
git fetch --depth=1 origin "$BASE_SHA" || true
|
||||||
git show "$BASE_SHA:package.json" > "$RUNNER_TEMP/base-package.json"
|
git show "$BASE_SHA:package.json" > "$RUNNER_TEMP/base-package.json"
|
||||||
base="$(node -p 'require(process.env.RUNNER_TEMP + "/base-package.json").version')"
|
base="$(node -p 'require(process.env.RUNNER_TEMP + "/base-package.json").version')"
|
||||||
case "$ver" in
|
case "$ver" in
|
||||||
|
|
@ -125,10 +131,20 @@ jobs:
|
||||||
echo "(If this PR was mislabeled, drop the label; if it was meant to release, it forgot the bump. The 0.1.0 first-release edge ships by manual tag — #111.)" >&2
|
echo "(If this PR was mislabeled, drop the label; if it was meant to release, it forgot the bump. The 0.1.0 first-release edge ships by manual tag — #111.)" >&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 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 (#111), not a bare push — creating nothing." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo "ceremony=yes" >> "$GITHUB_OUTPUT"
|
echo "ceremony=yes" >> "$GITHUB_OUTPUT"
|
||||||
echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV"
|
echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV"
|
||||||
- name: release notes — the version's own CHANGELOG.md section
|
- name: release notes — the version's own CHANGELOG.md section
|
||||||
if: github.event_name == 'push' || steps.decide.outputs.ceremony == 'yes'
|
if: startsWith(github.ref, 'refs/tags/') || steps.decide.outputs.ceremony == 'yes'
|
||||||
# Assert 3 on the merge path, the same fact on the tag path:
|
# Assert 3 on the merge path, the same fact on the tag path:
|
||||||
# release-notes.sh fails loudly on a missing/empty section, which
|
# release-notes.sh fails loudly on a missing/empty section, which
|
||||||
# fails the release here — before anything is created.
|
# fails the release here — before anything is created.
|
||||||
|
|
@ -136,10 +152,10 @@ jobs:
|
||||||
bash .github/scripts/release-notes.sh "$RELEASE_VERSION" > "$RUNNER_TEMP/notes.md"
|
bash .github/scripts/release-notes.sh "$RELEASE_VERSION" > "$RUNNER_TEMP/notes.md"
|
||||||
cat "$RUNNER_TEMP/notes.md"
|
cat "$RUNNER_TEMP/notes.md"
|
||||||
- name: "merged release PR: nothing exists yet, then tag the merge commit"
|
- name: "merged release PR: nothing exists yet, then tag the merge commit"
|
||||||
if: github.event_name == 'pull_request' && steps.decide.outputs.ceremony == 'yes'
|
if: github.ref == 'refs/heads/main' && steps.decide.outputs.ceremony == 'yes'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
|
MERGE_SHA: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
# Assert 4 — no tag and no release exist for this version. Re-runs
|
# Assert 4 — no tag and no release exist for this version. Re-runs
|
||||||
# stay idempotent, and a manual race (an operator who tagged by
|
# stay idempotent, and a manual race (an operator who tagged by
|
||||||
|
|
@ -161,7 +177,7 @@ jobs:
|
||||||
gh api "repos/$GITHUB_REPOSITORY/git/refs" \
|
gh api "repos/$GITHUB_REPOSITORY/git/refs" \
|
||||||
-f "ref=refs/tags/$RELEASE_VERSION" -f "sha=$MERGE_SHA"
|
-f "ref=refs/tags/$RELEASE_VERSION" -f "sha=$MERGE_SHA"
|
||||||
- name: build the prebuilt dist asset
|
- name: build the prebuilt dist asset
|
||||||
if: github.event_name == 'push' || steps.decide.outputs.ceremony == 'yes'
|
if: startsWith(github.ref, 'refs/tags/') || steps.decide.outputs.ceremony == 'yes'
|
||||||
# Build ONCE, in CI — the whole point of the asset (#96): the
|
# Build ONCE, in CI — the whole point of the asset (#96): the
|
||||||
# installer's release channels never run npm or tsc. Deliberately no
|
# installer's release channels never run npm or tsc. Deliberately no
|
||||||
# check/tests here: ci.yml already gated the merge commit this
|
# check/tests here: ci.yml already gated the merge commit this
|
||||||
|
|
@ -176,7 +192,7 @@ jobs:
|
||||||
cp -R bin dist node_modules package.json "$RUNNER_TEMP/stage/cast-$RELEASE_VERSION/"
|
cp -R bin dist node_modules package.json "$RUNNER_TEMP/stage/cast-$RELEASE_VERSION/"
|
||||||
tar -C "$RUNNER_TEMP/stage" -czf "$RUNNER_TEMP/cast-$RELEASE_VERSION.tgz" "cast-$RELEASE_VERSION"
|
tar -C "$RUNNER_TEMP/stage" -czf "$RUNNER_TEMP/cast-$RELEASE_VERSION.tgz" "cast-$RELEASE_VERSION"
|
||||||
- name: create the release
|
- name: create the release
|
||||||
if: github.event_name == 'push' || steps.decide.outputs.ceremony == 'yes'
|
if: startsWith(github.ref, 'refs/tags/') || steps.decide.outputs.ceremony == 'yes'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
|
|
@ -176,13 +176,22 @@ describe("release.yml", () => {
|
||||||
expect(RY).toContain('tags: ["**"]');
|
expect(RY).toContain('tags: ["**"]');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("triggers on closed PRs into main, gated on merged AND the release label (#111)", () => {
|
it("the merge door rides pushes to main — fork PR tokens are read-only (#111 r1)", () => {
|
||||||
expect(RY).toContain("types: [closed]");
|
// 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 — the tag create would 403 after
|
||||||
|
// green asserts. The door triggers on push to main; the doors split on
|
||||||
|
// the pushed ref; the release label — still the operator's declared
|
||||||
|
// intent — is read via the API off the merge commit's PR, and a
|
||||||
|
// transition with no labeled PR behind it refuses.
|
||||||
expect(RY).toContain("branches: [main]");
|
expect(RY).toContain("branches: [main]");
|
||||||
expect(RY).toContain("github.event.pull_request.merged == true");
|
expect(RY).toContain("startsWith(github.ref, 'refs/tags/')");
|
||||||
|
expect(RY).toContain("github.ref == 'refs/heads/main'");
|
||||||
|
expect(RY).toContain("commits/$GITHUB_SHA/pulls");
|
||||||
expect(RY).toContain(
|
expect(RY).toContain(
|
||||||
"contains(github.event.pull_request.labels.*.name, 'release')",
|
"no merged, release-labeled PR is behind this commit",
|
||||||
);
|
);
|
||||||
|
expect(RY).not.toContain("pull_request:");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("asserts tag == package.json version, and the assert precedes the create", () => {
|
it("asserts tag == package.json version, and the assert precedes the create", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue