From c12939349b52f6a2458f7cfb73468dac9df04e7d Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:01:45 -0500 Subject: [PATCH 01/10] ci(release): scheduled release train auto-merges the Version Packages PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features were sitting unreleased between manual merges of the "chore: version packages" PR (3 changesets pending right now, oldest 5 days). Bound that latency with a twice-weekly release train: - release-train.yaml (new): Tue/Thu cron finds the open Version Packages PR by its changeset-release/main head branch, gates it on Perry + CI via the existing pr-gate.sh, and squash-merges on green — which triggers publish.yaml on push to main as usual. Alerts Slack instead of silently skipping when the PR is red, when changesets are pending on main with no Version PR (changesets/action failure), or when the PR carries the release:hold escape-hatch label. - pr-gate.sh: optional GATE_LABEL env var so Slack messages name the right flow; defaults to the existing "@openrouter/sdk bump" label. - publish.yaml: hand the changesets step the GH_TOKEN PAT instead of the Actions GITHUB_TOKEN. PRs opened with GITHUB_TOKEN never trigger other workflows, so the Version Packages PR got no CI and no perry/review — the exact checks the train gates its merge on (same reasoning as the checkout token in bump-openrouter-sdk.yaml). Co-Authored-By: Claude Fable 5 --- .github/scripts/pr-gate.sh | 14 ++- .github/workflows/publish.yaml | 7 +- .github/workflows/release-train.yaml | 143 +++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/release-train.yaml diff --git a/.github/scripts/pr-gate.sh b/.github/scripts/pr-gate.sh index 1f3db2bb..16de48e3 100755 --- a/.github/scripts/pr-gate.sh +++ b/.github/scripts/pr-gate.sh @@ -13,6 +13,8 @@ # REPO required — owner/name # GH_TOKEN required — token with merge permission # AUTO_MERGE "true" to merge on PASS; anything else = report-only +# GATE_LABEL optional — human label for Slack messages +# (default "@openrouter/sdk bump") # SLACK_BOT_TOKEN optional — Slack bot token for chat.postMessage # SLACK_CHANNEL_ID optional — Slack channel for alerts # RUN_URL optional — link back to this workflow run @@ -25,6 +27,8 @@ set -euo pipefail : "${PR:?PR is required}" : "${REPO:?REPO is required}" +GATE_LABEL="${GATE_LABEL:-@openrouter/sdk bump}" + INTERVAL="${INTERVAL:-30}" TIMEOUT="${TIMEOUT:-1800}" # 30 min overall PERRY_TIMEOUT="${PERRY_TIMEOUT:-480}" # 8 min for perry/review to appear at all @@ -116,7 +120,7 @@ while :; do case "$STATE" in FAIL_CI|FAIL_REVIEWER) - slack ":x: @openrouter/sdk bump <${PR_URL}|PR #${PR}> blocked: ${REASON}. Left open for a human. <${RUN_URL:-$PR_URL}|run>" + slack ":x: ${GATE_LABEL} <${PR_URL}|PR #${PR}> blocked: ${REASON}. Left open for a human. <${RUN_URL:-$PR_URL}|run>" echo "::error::PR #${PR} blocked: ${REASON}" exit 1 ;; @@ -134,10 +138,10 @@ while :; do if [ "${AUTO_MERGE:-false}" = "true" ]; then echo "PASS — squash-merging PR #${PR}" gh pr merge "$PR" -R "$REPO" --squash --delete-branch - slack ":white_check_mark: @openrouter/sdk bump <${PR_URL}|PR #${PR}> passed Perry + CI and was auto-merged." + slack ":white_check_mark: ${GATE_LABEL} <${PR_URL}|PR #${PR}> passed Perry + CI and was auto-merged." else echo "PASS (report-only; AUTO_MERGE!=true) — not merging PR #${PR}" - slack ":white_check_mark: @openrouter/sdk bump <${PR_URL}|PR #${PR}> is green and ready for merge." + slack ":white_check_mark: ${GATE_LABEL} <${PR_URL}|PR #${PR}> is green and ready for merge." fi exit 0 ;; @@ -145,7 +149,7 @@ while :; do # If perry/review never even shows up, the PR was likely opened with a # token that doesn't trigger it — surface that rather than hang forever. if [ "$REASON" = "waiting for perry/review" ] && [ "$ELAPSED" -ge "$PERRY_TIMEOUT" ]; then - slack ":warning: @openrouter/sdk bump <${PR_URL}|PR #${PR}>: perry/review never appeared after ${PERRY_TIMEOUT}s (token/app misconfig?). Not merging. <${RUN_URL:-$PR_URL}|run>" + slack ":warning: ${GATE_LABEL} <${PR_URL}|PR #${PR}>: perry/review never appeared after ${PERRY_TIMEOUT}s (token/app misconfig?). Not merging. <${RUN_URL:-$PR_URL}|run>" echo "::error::perry/review did not appear within ${PERRY_TIMEOUT}s" exit 1 fi @@ -153,7 +157,7 @@ while :; do esac if [ "$ELAPSED" -ge "$TIMEOUT" ]; then - slack ":warning: @openrouter/sdk bump <${PR_URL}|PR #${PR}> did not settle within ${TIMEOUT}s (last: ${REASON}). Not merging. <${RUN_URL:-$PR_URL}|run>" + slack ":warning: ${GATE_LABEL} <${PR_URL}|PR #${PR}> did not settle within ${TIMEOUT}s (last: ${REASON}). Not merging. <${RUN_URL:-$PR_URL}|run>" echo "::error::Gate timed out after ${TIMEOUT}s (last: ${REASON})" exit 1 fi diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 3f5329f5..b7ac871f 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -118,7 +118,12 @@ jobs: version: pnpm exec changeset version publish: pnpm exec changeset publish --no-git-checks env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # PAT, not the Actions GITHUB_TOKEN: PRs created with GITHUB_TOKEN + # never trigger other workflows, so the Version Packages PR would get + # no CI and no perry/review — and the release-train workflow + # (release-train.yaml) gates its auto-merge on exactly those checks. + # Same reasoning as the checkout token in bump-openrouter-sdk.yaml. + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} # No NODE_AUTH_TOKEN / NPM_TOKEN: auth comes from OIDC trusted # publishing. changesets/action logs "No NPM_TOKEN found, but OIDC is # available" and leaves .npmrc alone; npm then exchanges the Actions diff --git a/.github/workflows/release-train.yaml b/.github/workflows/release-train.yaml new file mode 100644 index 00000000..d97a26d1 --- /dev/null +++ b/.github/workflows/release-train.yaml @@ -0,0 +1,143 @@ +name: Release train + +# Scheduled auto-merge for the "chore: version packages" PR that +# changesets/action maintains (see publish.yaml). Before this existed, that PR +# waited for a human to merge it, so shipped features sat unreleased between +# manual releases. The train bounds that latency: twice a week it finds the +# Version Packages PR, gates it on Perry + CI via pr-gate.sh (the same gate the +# SDK-bump flow uses), and squash-merges on green — which triggers publish.yaml +# on push to main and cuts the actual npm release. +# +# Failure modes are alerted, never silently skipped: +# - PR exists but is red/stuck → pr-gate.sh posts to Slack and exits non-zero +# - changesets pending on main but no Version PR → Slack alert (changesets/action +# failed to open one; investigate publish.yaml runs) +# - PR labeled `release:hold` → skipped on purpose, logged + Slack notice +# +# Escape hatches: +# - add the `release:hold` label to the Version PR to pause the train +# (coordinated @openrouter/agent + @openrouter/sdk releases, etc.) +# - workflow_dispatch with dry_run to see what the train would do + +on: + schedule: + # Tue and Thu 09:23 UTC — off the :00 mark (GitHub delays on-the-hour + # crons), early enough in the EU/US-overlap day that a red gate alert + # lands while people are around to act on it. + - cron: '23 9 * * 2,4' + workflow_dispatch: + inputs: + dry_run: + description: 'Report what would happen; do not merge' + required: false + default: false + type: boolean + +permissions: + contents: write + pull-requests: write + +concurrency: + group: release-train + cancel-in-progress: false + +env: + REPO: OpenRouterTeam/typescript-agent + VERSION_PR_BRANCH: changeset-release/main # changesets/action's fixed head branch + HOLD_LABEL: release:hold + +jobs: + train: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Find Version Packages PR + id: find + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: | + set -euo pipefail + PR_JSON="$(gh pr list -R "$REPO" \ + --head "$VERSION_PR_BRANCH" \ + --state open \ + --json number,labels \ + --jq '.[0] // empty')" + + if [ -z "$PR_JSON" ]; then + echo "pr_number=" >> "$GITHUB_OUTPUT" + echo "held=false" >> "$GITHUB_OUTPUT" + echo "No open Version Packages PR." + exit 0 + fi + + PR_NUMBER="$(echo "$PR_JSON" | python3 -c 'import sys,json; print(json.load(sys.stdin)["number"])')" + HELD="$(echo "$PR_JSON" | HOLD_LABEL="$HOLD_LABEL" python3 -c 'import sys,json,os; pr=json.load(sys.stdin); print(str(any(l["name"]==os.environ["HOLD_LABEL"] for l in pr.get("labels",[]))).lower())')" + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "held=$HELD" >> "$GITHUB_OUTPUT" + echo "Found Version Packages PR #$PR_NUMBER (held=$HELD)" + + # No Version PR is only fine when there is also nothing waiting to ship. + # Pending changesets with no PR means changesets/action failed on some + # push to main (or someone closed the PR) — exactly the silent-stall case + # the train exists to catch, so alert instead of exiting quietly. + - name: Check for stranded changesets + if: steps.find.outputs.pr_number == '' + env: + SLACK_BOT_TOKEN: ${{ secrets.CI_RELEASE_ALERT_SLACK_BOT_TOKEN }} + SLACK_CHANNEL_ID: ${{ secrets.CI_RELEASE_ALERT_SLACK_CHANNEL_ID }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + PENDING="$(find .changeset -maxdepth 1 -name '*.md' ! -name 'README.md' | wc -l | tr -d ' ')" + if [ "$PENDING" = "0" ]; then + echo "No pending changesets and no Version PR — nothing to release." + exit 0 + fi + + TEXT=":warning: Release train: ${PENDING} pending changeset(s) on main but no open Version Packages PR. changesets/action may have failed — check recent publish.yaml runs. <${RUN_URL}|run>" + echo "::error::${PENDING} pending changeset(s) but no Version Packages PR" + if [ -n "${SLACK_BOT_TOKEN:-}" ] && [ -n "${SLACK_CHANNEL_ID:-}" ]; then + curl -fsS -X POST https://slack.com/api/chat.postMessage \ + -H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \ + -H "Content-type: application/json; charset=utf-8" \ + --data "$(python3 -c "import json,sys; print(json.dumps({'channel':sys.argv[1],'unfurl_links':False,'text':sys.argv[2]}))" "$SLACK_CHANNEL_ID" "$TEXT")" \ + >/dev/null || echo "::warning::Slack post failed" + else + echo "(slack not configured; would have posted) $TEXT" + fi + exit 1 + + - name: Respect release:hold + if: steps.find.outputs.pr_number != '' && steps.find.outputs.held == 'true' + env: + SLACK_BOT_TOKEN: ${{ secrets.CI_RELEASE_ALERT_SLACK_BOT_TOKEN }} + SLACK_CHANNEL_ID: ${{ secrets.CI_RELEASE_ALERT_SLACK_CHANNEL_ID }} + PR: ${{ steps.find.outputs.pr_number }} + run: | + set -euo pipefail + TEXT=":double_vertical_bar: Release train: Version Packages <${{ github.server_url }}/${REPO}/pull/${PR}|PR #${PR}> has \`${HOLD_LABEL}\` — skipping this run. Remove the label to resume." + echo "$TEXT" + if [ -n "${SLACK_BOT_TOKEN:-}" ] && [ -n "${SLACK_CHANNEL_ID:-}" ]; then + curl -fsS -X POST https://slack.com/api/chat.postMessage \ + -H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \ + -H "Content-type: application/json; charset=utf-8" \ + --data "$(python3 -c "import json,sys; print(json.dumps({'channel':sys.argv[1],'unfurl_links':False,'text':sys.argv[2]}))" "$SLACK_CHANNEL_ID" "$TEXT")" \ + >/dev/null || echo "::warning::Slack post failed" + fi + + - name: Gate and merge Version Packages PR + if: steps.find.outputs.pr_number != '' && steps.find.outputs.held != 'true' + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + PR: ${{ steps.find.outputs.pr_number }} + # REPO comes from the workflow-level env block. + # On schedule runs `inputs` is empty, so dry_run != true → AUTO_MERGE=true. + AUTO_MERGE: ${{ inputs.dry_run != true }} + GATE_LABEL: 'Release train (Version Packages)' + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + SLACK_BOT_TOKEN: ${{ secrets.CI_RELEASE_ALERT_SLACK_BOT_TOKEN }} + SLACK_CHANNEL_ID: ${{ secrets.CI_RELEASE_ALERT_SLACK_CHANNEL_ID }} + run: | + chmod +x .github/scripts/pr-gate.sh + ./.github/scripts/pr-gate.sh From d42bbc5610a9b810c360c93a7cec3f07ed5d86c7 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:19:26 -0500 Subject: [PATCH 02/10] fix(release): persist the PAT in publish.yaml checkout so Version PR updates re-trigger checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit changesets/action pushes changeset-release/main with plain `git push`, which uses checkout's persisted credentials — the default GITHUB_TOKEN, whose pushes never trigger workflows. Updates to an already-open Version Packages PR would therefore run no CI / perry/review, and the release train could merge on results from the first revision. Same pattern as the checkout token in bump-openrouter-sdk.yaml. Co-Authored-By: Claude Fable 5 --- .github/workflows/publish.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index b7ac871f..004e761b 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -66,7 +66,15 @@ jobs: (github.event_name == 'workflow_dispatch' && inputs.mode == 'publish' && inputs.dry-run) steps: + # PAT for the same reason as the changesets step below: changesets/action + # pushes the changeset-release/main branch with plain `git push`, which + # uses the credentials checkout persisted. Under the default Actions + # GITHUB_TOKEN those pushes never trigger workflows, so updates to an + # already-open Version Packages PR would run no CI / perry/review and the + # release train could merge on stale checks. - uses: actions/checkout@v6 + with: + token: ${{ secrets.GH_TOKEN }} - uses: pnpm/action-setup@v6 From e6226aff0f24f29b8da78ca19ae7beba01c017f8 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:33:01 -0500 Subject: [PATCH 03/10] fix(release): harden the release train against mid-gate holds, PAT exposure, and smuggled diffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback from PR #96: - pr-gate.sh: optional BLOCK_LABEL re-checked on every poll and immediately before merge, so a release:hold applied while the gate is mid-poll (up to 30 min) still stops the merge instead of being a snapshot read once at find time. - publish.yaml: stop persisting the cross-repo PAT through install/build/test. checkout uses persist-credentials: false; a dedicated step injects the PAT into the origin URL only after tests, immediately before the changesets step that pushes. Dependency lifecycle scripts and tests can no longer read the PAT from .git/config, while Version PR branch pushes are still PAT-attributed (and therefore still trigger CI + perry/review). - release-train.yaml: diff-scope check before gating — the Version PR may only touch .changeset/*.md, package.json, CHANGELOG.md, and pnpm-lock.yaml. Anything else on changeset-release/main refuses the unattended merge and alerts Slack. Co-Authored-By: Claude Fable 5 --- .github/scripts/pr-gate.sh | 25 ++++++++++++++++++ .github/workflows/publish.yaml | 26 ++++++++++++++----- .github/workflows/release-train.yaml | 39 ++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/.github/scripts/pr-gate.sh b/.github/scripts/pr-gate.sh index 16de48e3..49f16cc6 100755 --- a/.github/scripts/pr-gate.sh +++ b/.github/scripts/pr-gate.sh @@ -15,6 +15,10 @@ # AUTO_MERGE "true" to merge on PASS; anything else = report-only # GATE_LABEL optional — human label for Slack messages # (default "@openrouter/sdk bump") +# BLOCK_LABEL optional — PR label that pauses the gate. Re-checked on +# every poll and immediately before merging, so a hold +# added mid-poll still stops the merge. Exit 0 (deliberate +# pause, not a failure). # SLACK_BOT_TOKEN optional — Slack bot token for chat.postMessage # SLACK_CHANNEL_ID optional — Slack channel for alerts # RUN_URL optional — link back to this workflow run @@ -52,6 +56,14 @@ slack() { PR_URL="${GITHUB_SERVER_URL:-https://github.com}/${REPO}/pull/${PR}" +# True when BLOCK_LABEL is set and currently on the PR. Queried live each time +# so a hold applied while we're polling takes effect before any merge. +held() { + [ -n "${BLOCK_LABEL:-}" ] || return 1 + gh pr view "$PR" -R "$REPO" --json labels --jq '.labels[].name' 2>/dev/null \ + | grep -qxF "$BLOCK_LABEL" +} + # Returns one of: PASS PENDING FAIL_CI FAIL_REVIEWER, plus a reason line on # stderr. Reads checks + PR meta in two gh calls. verdict() { @@ -114,6 +126,12 @@ LAST_REASON="" while :; do NOW=$(date +%s); ELAPSED=$((NOW - START)) + if held; then + slack ":double_vertical_bar: ${GATE_LABEL} <${PR_URL}|PR #${PR}> has \`${BLOCK_LABEL}\` — gate paused, not merging. Remove the label to resume on the next run. <${RUN_URL:-$PR_URL}|run>" + echo "PR #${PR} carries ${BLOCK_LABEL}; exiting without merging." + exit 0 + fi + REASON="$(verdict 2>/tmp/gate.state)" || true STATE="$(cat /tmp/gate.state)" [ "$REASON" != "$LAST_REASON" ] && { echo "[$ELAPSED s] $STATE — $REASON"; LAST_REASON="$REASON"; } @@ -135,6 +153,13 @@ while :; do LAST_REASON="" continue fi + # Last-instant hold check: the settle sleep is a window in which a + # human may have applied the hold label after the loop-top check. + if held; then + slack ":double_vertical_bar: ${GATE_LABEL} <${PR_URL}|PR #${PR}> has \`${BLOCK_LABEL}\` — gate paused just before merge. Remove the label to resume on the next run. <${RUN_URL:-$PR_URL}|run>" + echo "PR #${PR} carries ${BLOCK_LABEL}; exiting without merging." + exit 0 + fi if [ "${AUTO_MERGE:-false}" = "true" ]; then echo "PASS — squash-merging PR #${PR}" gh pr merge "$PR" -R "$REPO" --squash --delete-branch diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 004e761b..24a2d7ee 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -66,15 +66,14 @@ jobs: (github.event_name == 'workflow_dispatch' && inputs.mode == 'publish' && inputs.dry-run) steps: - # PAT for the same reason as the changesets step below: changesets/action - # pushes the changeset-release/main branch with plain `git push`, which - # uses the credentials checkout persisted. Under the default Actions - # GITHUB_TOKEN those pushes never trigger workflows, so updates to an - # already-open Version Packages PR would run no CI / perry/review and the - # release train could merge on stale checks. + # No credentials persisted: install/build/test below execute dependency + # lifecycle scripts and test code, which must not be able to read a + # long-lived cross-repo PAT out of .git/config. Git push credentials are + # injected by the "Configure git push credentials" step *after* those, + # immediately before the only steps that push. - uses: actions/checkout@v6 with: - token: ${{ secrets.GH_TOKEN }} + persist-credentials: false - uses: pnpm/action-setup@v6 @@ -114,6 +113,19 @@ jobs: - run: pnpm run test + # Deliberately after install/build/test (see the checkout comment). + # changesets/action pushes changeset-release/main (and release tags on + # the publish leg) with plain `git push`, which uses the remote's + # embedded credentials. It must be the PAT, not the Actions + # GITHUB_TOKEN: GITHUB_TOKEN-attributed pushes never trigger workflows, + # so Version PR updates would get no CI / perry/review and the release + # train (release-train.yaml) could merge on checks from the first + # revision. + - name: Configure git push credentials (PAT) + run: | + git remote set-url origin \ + "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${{ github.repository }}.git" + - name: Version PR or Publish (changesets) id: changesets if: > diff --git a/.github/workflows/release-train.yaml b/.github/workflows/release-train.yaml index d97a26d1..5f06b6ee 100644 --- a/.github/workflows/release-train.yaml +++ b/.github/workflows/release-train.yaml @@ -126,6 +126,42 @@ jobs: >/dev/null || echo "::warning::Slack post failed" fi + # An unattended merge into a publish pipeline must only ever carry + # mechanical `changeset version` output. changeset-release/main is an + # ordinary branch — anything else on it (pushed by anyone with write + # access) would ride the auto-merge straight into an npm publish, and + # CI + AI review are not a reliable control against a deliberate + # smuggle. Allowlist the file shapes changesets produces and refuse + # everything else. + - name: Verify Version PR diff scope + id: scope + if: steps.find.outputs.pr_number != '' && steps.find.outputs.held != 'true' + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + PR: ${{ steps.find.outputs.pr_number }} + SLACK_BOT_TOKEN: ${{ secrets.CI_RELEASE_ALERT_SLACK_BOT_TOKEN }} + SLACK_CHANNEL_ID: ${{ secrets.CI_RELEASE_ALERT_SLACK_CHANNEL_ID }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + UNEXPECTED="$(gh pr view "$PR" -R "$REPO" --json files --jq '.files[].path' | grep -vE \ + '^(\.changeset/.*\.md|(packages/[^/]+/)?(package\.json|CHANGELOG\.md)|pnpm-lock\.yaml)$' || true)" + if [ -z "$UNEXPECTED" ]; then + echo "Diff scope OK — only version-bump paths." + exit 0 + fi + + TEXT=":no_entry: Release train: Version Packages PR #${PR} touches files outside the version-bump allowlist — NOT merging. Unexpected: $(echo "$UNEXPECTED" | head -5 | tr '\n' ' ')<${RUN_URL}|run>" + echo "::error::Unexpected files in Version PR:"; echo "$UNEXPECTED" + if [ -n "${SLACK_BOT_TOKEN:-}" ] && [ -n "${SLACK_CHANNEL_ID:-}" ]; then + curl -fsS -X POST https://slack.com/api/chat.postMessage \ + -H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \ + -H "Content-type: application/json; charset=utf-8" \ + --data "$(python3 -c "import json,sys; print(json.dumps({'channel':sys.argv[1],'unfurl_links':False,'text':sys.argv[2]}))" "$SLACK_CHANNEL_ID" "$TEXT")" \ + >/dev/null || echo "::warning::Slack post failed" + fi + exit 1 + - name: Gate and merge Version Packages PR if: steps.find.outputs.pr_number != '' && steps.find.outputs.held != 'true' env: @@ -135,6 +171,9 @@ jobs: # On schedule runs `inputs` is empty, so dry_run != true → AUTO_MERGE=true. AUTO_MERGE: ${{ inputs.dry_run != true }} GATE_LABEL: 'Release train (Version Packages)' + # Re-checked live inside pr-gate.sh on every poll and just before + # merge, so a hold applied mid-gate still stops the train. + BLOCK_LABEL: ${{ env.HOLD_LABEL }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} SLACK_BOT_TOKEN: ${{ secrets.CI_RELEASE_ALERT_SLACK_BOT_TOKEN }} SLACK_CHANNEL_ID: ${{ secrets.CI_RELEASE_ALERT_SLACK_CHANNEL_ID }} From b1b00feb296911728e6619feac873f8e702ddc97 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:40:11 -0500 Subject: [PATCH 04/10] fix(release): fail-closed diff scope, head pin for the gate, tighter allowlist, PAT scrub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round on PR #96: - release-train.yaml: diff-scope check fails closed — the file list is fetched as its own command (an API error fails the step) and an empty list is refused rather than read as "clean". Allowlist tightened to exactly what `changeset version` produces here (.changeset/*.md and packages/*/package.json|CHANGELOG.md; no root package.json, no lockfile — cf. Version PR #57). - pr-gate.sh: optional EXPECTED_HEAD pin. The train passes the head SHA the scope check vetted; the gate re-reads the head immediately before merging and refuses if it moved, closing the TOCTOU window where commits pushed to changeset-release/main during the up-to-30-min poll would merge unvetted. - publish.yaml: scrub the PAT from the origin URL (always()) right after the last git-push consumer; HOP dispatches use gh api with the env token, so nothing later needs credentials in .git/config. Co-Authored-By: Claude Fable 5 --- .github/scripts/pr-gate.sh | 15 +++++++++++++++ .github/workflows/publish.yaml | 10 ++++++++++ .github/workflows/release-train.yaml | 27 +++++++++++++++++++++++++-- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/.github/scripts/pr-gate.sh b/.github/scripts/pr-gate.sh index 49f16cc6..b826ee1c 100755 --- a/.github/scripts/pr-gate.sh +++ b/.github/scripts/pr-gate.sh @@ -19,6 +19,10 @@ # every poll and immediately before merging, so a hold # added mid-poll still stops the merge. Exit 0 (deliberate # pause, not a failure). +# EXPECTED_HEAD optional — head SHA the caller vetted (e.g. a diff-scope +# allowlist). The merge is refused if the PR head no longer +# matches, closing the TOCTOU window between the caller's +# check and the merge. Exit 1 (needs a re-run to re-vet). # SLACK_BOT_TOKEN optional — Slack bot token for chat.postMessage # SLACK_CHANNEL_ID optional — Slack channel for alerts # RUN_URL optional — link back to this workflow run @@ -160,6 +164,17 @@ while :; do echo "PR #${PR} carries ${BLOCK_LABEL}; exiting without merging." exit 0 fi + # Head pin: refuse to merge a head the caller never vetted. Checked at + # the last instant so commits pushed at any point during the poll are + # caught, not just ones present at loop start. + if [ -n "${EXPECTED_HEAD:-}" ]; then + CURRENT_HEAD="$(gh pr view "$PR" -R "$REPO" --json headRefOid --jq '.headRefOid')" + if [ "$CURRENT_HEAD" != "$EXPECTED_HEAD" ]; then + slack ":no_entry: ${GATE_LABEL} <${PR_URL}|PR #${PR}> head moved during the gate (${EXPECTED_HEAD:0:7} → ${CURRENT_HEAD:0:7}) — refusing to merge unvetted commits. Re-run the workflow to re-vet. <${RUN_URL:-$PR_URL}|run>" + echo "::error::PR #${PR} head changed ${EXPECTED_HEAD} → ${CURRENT_HEAD}; not merging." + exit 1 + fi + fi if [ "${AUTO_MERGE:-false}" = "true" ]; then echo "PASS — squash-merging PR #${PR}" gh pr merge "$PR" -R "$REPO" --squash --delete-branch diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 24a2d7ee..1124e1a5 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -176,6 +176,16 @@ jobs: continue-on-error: true run: git push origin --tags + # Nothing below pushes via git (the HOP dispatches use `gh api` with + # GH_TOKEN from env), so drop the PAT from .git/config the moment the + # last push consumer is done. `always()`: scrub even when a publish + # step failed, since later/rerun steps still see the workspace. + - name: Scrub git push credentials + if: always() + run: | + git remote set-url origin \ + "https://github.com/${{ github.repository }}.git" + # `changeset publish` has no native --dry-run. Fall back to pnpm's # recursive dry-run, which simulates publishing every workspace package # rather than only the ones changesets would pick. Output set may be diff --git a/.github/workflows/release-train.yaml b/.github/workflows/release-train.yaml index 5f06b6ee..d67e377a 100644 --- a/.github/workflows/release-train.yaml +++ b/.github/workflows/release-train.yaml @@ -144,8 +144,28 @@ jobs: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -euo pipefail - UNEXPECTED="$(gh pr view "$PR" -R "$REPO" --json files --jq '.files[].path' | grep -vE \ - '^(\.changeset/.*\.md|(packages/[^/]+/)?(package\.json|CHANGELOG\.md)|pnpm-lock\.yaml)$' || true)" + # Pin the head this check ran against; pr-gate.sh refuses to merge a + # different head (EXPECTED_HEAD below), so commits pushed during the + # up-to-30-min gate wait can't slip past this allowlist (TOCTOU). + HEAD_SHA="$(gh pr view "$PR" -R "$REPO" --json headRefOid --jq '.headRefOid')" + echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + + # Fail closed: fetch the file list as its own command so an API error + # fails the step, and treat an empty list as an error too — a Version + # PR always contains files, and "couldn't read the diff" must never + # be interpreted as "diff is clean". + FILES="$(gh pr view "$PR" -R "$REPO" --json files --jq '.files[].path')" + if [ -z "$FILES" ]; then + echo "::error::Could not read file list for PR #${PR} — refusing to merge" + exit 1 + fi + # Exactly what `changeset version` produces in this repo (cf. PR #57): + # consumed changesets + per-package version/changelog bumps. No root + # package.json (private, unversioned) and no lockfile (workspace:* + # deps don't touch it) — both would widen the unattended-merge + # surface (root package.json can carry lifecycle scripts). + UNEXPECTED="$(printf '%s\n' "$FILES" | grep -vE \ + '^(\.changeset/[^/]+\.md|packages/[^/]+/(package\.json|CHANGELOG\.md))$' || true)" if [ -z "$UNEXPECTED" ]; then echo "Diff scope OK — only version-bump paths." exit 0 @@ -174,6 +194,9 @@ jobs: # Re-checked live inside pr-gate.sh on every poll and just before # merge, so a hold applied mid-gate still stops the train. BLOCK_LABEL: ${{ env.HOLD_LABEL }} + # The head the diff-scope step vetted; pr-gate.sh refuses to merge + # any other head (TOCTOU guard for the scope allowlist). + EXPECTED_HEAD: ${{ steps.scope.outputs.head_sha }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} SLACK_BOT_TOKEN: ${{ secrets.CI_RELEASE_ALERT_SLACK_BOT_TOKEN }} SLACK_CHANNEL_ID: ${{ secrets.CI_RELEASE_ALERT_SLACK_CHANNEL_ID }} From 135b76dce7d2d7585f7bd4a6877d9533ac81fe6b Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:48:40 -0500 Subject: [PATCH 05/10] fix(release): fail-closed hold check, merge-failure alert, mid-gate re-vet, gh-api tag lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third review round on PR #96 (Perry + Devin): - pr-gate.sh held(): a failed label lookup now counts as held (fail closed) — the pre-merge hold check is the final safety control, so "couldn't read labels" must not become "no hold". - pr-gate.sh merge: gh pr merge failure now posts a Slack alert before exiting 1. Under set -e it previously exited silently — fatal for the unattended scheduled train. - pr-gate.sh EXPECTED_HEAD + SCOPE_ALLOWLIST: a head that moves mid-gate is re-vetted against the allowlist instead of hard-failing. changesets/action force-pushes the Version PR whenever main moves, so a routine refresh now adopts the new head and re-polls (checks restarted); only a diff outside the allowlist (or unreadable) refuses and alerts. Allowlist regex defined once at workflow level and shared by the scope step and the gate. - publish.yaml HOP C: tag existence check via gh api git/ref (env token) instead of `git ls-remote origin`, which after the credential scrub would silently fail if the repo ever goes private, forcing the SHA fallback every release. Co-Authored-By: Claude Fable 5 --- .github/scripts/pr-gate.sh | 56 ++++++++++++++++++++++++---- .github/workflows/publish.yaml | 8 +++- .github/workflows/release-train.yaml | 22 +++++++---- 3 files changed, 69 insertions(+), 17 deletions(-) diff --git a/.github/scripts/pr-gate.sh b/.github/scripts/pr-gate.sh index b826ee1c..7a03f9b6 100755 --- a/.github/scripts/pr-gate.sh +++ b/.github/scripts/pr-gate.sh @@ -23,6 +23,12 @@ # allowlist). The merge is refused if the PR head no longer # matches, closing the TOCTOU window between the caller's # check and the merge. Exit 1 (needs a re-run to re-vet). +# SCOPE_ALLOWLIST optional, with EXPECTED_HEAD — extended regex of allowed +# file paths. When the head moved, the gate re-vets the +# PR's files against this before refusing: a moved head +# whose full diff still matches (e.g. changesets/action +# refreshed the Version PR mid-gate) adopts the new head +# and keeps polling instead of failing the run. # SLACK_BOT_TOKEN optional — Slack bot token for chat.postMessage # SLACK_CHANNEL_ID optional — Slack channel for alerts # RUN_URL optional — link back to this workflow run @@ -64,8 +70,14 @@ PR_URL="${GITHUB_SERVER_URL:-https://github.com}/${REPO}/pull/${PR}" # so a hold applied while we're polling takes effect before any merge. held() { [ -n "${BLOCK_LABEL:-}" ] || return 1 - gh pr view "$PR" -R "$REPO" --json labels --jq '.labels[].name' 2>/dev/null \ - | grep -qxF "$BLOCK_LABEL" + # Fail closed: this is the final safety control before an unattended merge, + # so "couldn't read the labels" must not be read as "no hold". + local labels + if ! labels="$(gh pr view "$PR" -R "$REPO" --json labels --jq '.labels[].name')"; then + echo "::warning::could not read labels for PR #${PR}; treating as held (fail closed)" + return 0 + fi + printf '%s\n' "$labels" | grep -qxF "$BLOCK_LABEL" } # Returns one of: PASS PENDING FAIL_CI FAIL_REVIEWER, plus a reason line on @@ -164,21 +176,49 @@ while :; do echo "PR #${PR} carries ${BLOCK_LABEL}; exiting without merging." exit 0 fi - # Head pin: refuse to merge a head the caller never vetted. Checked at - # the last instant so commits pushed at any point during the poll are - # caught, not just ones present at loop start. + # Head pin: never merge a head nobody vetted. Checked at the last + # instant so commits pushed at any point during the poll are caught, + # not just ones present at loop start. A moved head is not necessarily + # hostile — changesets/action force-pushes the Version PR whenever + # another PR lands on main — so when SCOPE_ALLOWLIST is provided, + # re-vet the new head's full file list first and only refuse if it no + # longer fits the allowlist; otherwise adopt the new head and resume + # polling (its checks just restarted). if [ -n "${EXPECTED_HEAD:-}" ]; then CURRENT_HEAD="$(gh pr view "$PR" -R "$REPO" --json headRefOid --jq '.headRefOid')" if [ "$CURRENT_HEAD" != "$EXPECTED_HEAD" ]; then - slack ":no_entry: ${GATE_LABEL} <${PR_URL}|PR #${PR}> head moved during the gate (${EXPECTED_HEAD:0:7} → ${CURRENT_HEAD:0:7}) — refusing to merge unvetted commits. Re-run the workflow to re-vet. <${RUN_URL:-$PR_URL}|run>" + REVETTED=false + if [ -n "${SCOPE_ALLOWLIST:-}" ]; then + if FILES="$(gh pr view "$PR" -R "$REPO" --json files --jq '.files[].path')" \ + && [ -n "$FILES" ] \ + && ! printf '%s\n' "$FILES" | grep -qvE "$SCOPE_ALLOWLIST"; then + REVETTED=true + fi + fi + if [ "$REVETTED" = "true" ]; then + echo "PR #${PR} head moved ${EXPECTED_HEAD:0:7} → ${CURRENT_HEAD:0:7}; diff still within scope allowlist — adopting new head and re-polling." + EXPECTED_HEAD="$CURRENT_HEAD" + LAST_REASON="" + continue + fi + slack ":no_entry: ${GATE_LABEL} <${PR_URL}|PR #${PR}> head moved during the gate (${EXPECTED_HEAD:0:7} → ${CURRENT_HEAD:0:7}) and the new diff fails the scope allowlist (or could not be read) — refusing to merge unvetted commits. Re-run the workflow to re-vet. <${RUN_URL:-$PR_URL}|run>" echo "::error::PR #${PR} head changed ${EXPECTED_HEAD} → ${CURRENT_HEAD}; not merging." exit 1 fi fi if [ "${AUTO_MERGE:-false}" = "true" ]; then echo "PASS — squash-merging PR #${PR}" - gh pr merge "$PR" -R "$REPO" --squash --delete-branch - slack ":white_check_mark: ${GATE_LABEL} <${PR_URL}|PR #${PR}> passed Perry + CI and was auto-merged." + # Alert on merge failure too: under `set -e` a bare failing merge + # would exit before any notification — fatal for the scheduled train, + # where nobody is watching the run and the PR would sit unmerged + # until the next scheduled attempt. + if gh pr merge "$PR" -R "$REPO" --squash --delete-branch; then + slack ":white_check_mark: ${GATE_LABEL} <${PR_URL}|PR #${PR}> passed Perry + CI and was auto-merged." + else + slack ":x: ${GATE_LABEL} <${PR_URL}|PR #${PR}>: checks passed but the merge itself failed (branch protection? conflict?). Left open for a human. <${RUN_URL:-$PR_URL}|run>" + echo "::error::gh pr merge failed for PR #${PR}" + exit 1 + fi else echo "PASS (report-only; AUTO_MERGE!=true) — not merging PR #${PR}" slack ":white_check_mark: ${GATE_LABEL} <${PR_URL}|PR #${PR}> is green and ready for merge." diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 1124e1a5..fe9e64d0 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -288,7 +288,13 @@ jobs: # an unresolvable ref would make the ports fail on checkout rather than # degrade, so fall back to this run's commit SHA — which points at the # same published tree. - if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + # + # gh api rather than `git ls-remote origin`: the credential scrub above + # leaves origin tokenless, so a git-side lookup would silently fail if + # this repo ever goes private and the fallback would fire every time. + # gh authenticates from GH_TOKEN in env, independent of .git/config. + ENCODED_TAG="$(jq -rn --arg t "$TAG" '$t|@uri')" + if gh api "repos/${{ github.repository }}/git/ref/tags/${ENCODED_TAG}" >/dev/null 2>&1; then REF="$TAG" else REF="${{ github.sha }}" diff --git a/.github/workflows/release-train.yaml b/.github/workflows/release-train.yaml index d67e377a..53837a73 100644 --- a/.github/workflows/release-train.yaml +++ b/.github/workflows/release-train.yaml @@ -45,6 +45,13 @@ env: REPO: OpenRouterTeam/typescript-agent VERSION_PR_BRANCH: changeset-release/main # changesets/action's fixed head branch HOLD_LABEL: release:hold + # Exactly what `changeset version` produces in this repo (cf. Version PR + # #57): consumed changesets + per-package version/changelog bumps. No root + # package.json (private, unversioned) and no lockfile (workspace:* deps + # don't touch it) — both would widen the unattended-merge surface (root + # package.json can carry lifecycle scripts). Used by the scope step below + # AND by pr-gate.sh to re-vet a head that moves mid-gate. + SCOPE_ALLOWLIST: '^(\.changeset/[^/]+\.md|packages/[^/]+/(package\.json|CHANGELOG\.md))$' jobs: train: @@ -159,13 +166,9 @@ jobs: echo "::error::Could not read file list for PR #${PR} — refusing to merge" exit 1 fi - # Exactly what `changeset version` produces in this repo (cf. PR #57): - # consumed changesets + per-package version/changelog bumps. No root - # package.json (private, unversioned) and no lockfile (workspace:* - # deps don't touch it) — both would widen the unattended-merge - # surface (root package.json can carry lifecycle scripts). - UNEXPECTED="$(printf '%s\n' "$FILES" | grep -vE \ - '^(\.changeset/[^/]+\.md|packages/[^/]+/(package\.json|CHANGELOG\.md))$' || true)" + # SCOPE_ALLOWLIST is defined once at workflow level (see env block) + # and shared with pr-gate.sh's mid-gate re-vet. + UNEXPECTED="$(printf '%s\n' "$FILES" | grep -vE "$SCOPE_ALLOWLIST" || true)" if [ -z "$UNEXPECTED" ]; then echo "Diff scope OK — only version-bump paths." exit 0 @@ -195,8 +198,11 @@ jobs: # merge, so a hold applied mid-gate still stops the train. BLOCK_LABEL: ${{ env.HOLD_LABEL }} # The head the diff-scope step vetted; pr-gate.sh refuses to merge - # any other head (TOCTOU guard for the scope allowlist). + # any other head (TOCTOU guard for the scope allowlist) — unless + # the moved head's diff still fits SCOPE_ALLOWLIST, the routine + # changesets/action refresh case, which re-vets and re-polls. EXPECTED_HEAD: ${{ steps.scope.outputs.head_sha }} + SCOPE_ALLOWLIST: ${{ env.SCOPE_ALLOWLIST }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} SLACK_BOT_TOKEN: ${{ secrets.CI_RELEASE_ALERT_SLACK_BOT_TOKEN }} SLACK_CHANNEL_ID: ${{ secrets.CI_RELEASE_ALERT_SLACK_CHANNEL_ID }} From d05cb082af637b651afcff221d09208aee855604 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:02:52 -0500 Subject: [PATCH 06/10] fix(release): content-vet package.json diffs, paginate the file list, distinguish hold from API failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth review round on PR #96 (cortex + Devin): - New .github/scripts/verify-version-pr-scope.sh replaces the inline path allowlist. Path check unchanged, plus: (1) file list fetched via `gh api pulls/N/files --paginate`, so padding a PR past the 100-file page cap can't hide an out-of-scope path; (2) package.json patches are content-vetted — every +/- line must be the version field or an internal @openrouter/* dependency range, exactly what `changeset version` emits, so a smuggled lifecycle script in a workspace package.json is refused even though its path is allowlisted. Validated against real Version PR #57 (passes) and PR #96 itself (fails, as it should). - pr-gate.sh: SCOPE_ALLOWLIST regex replaced by SCOPE_SCRIPT — the mid-gate re-vet runs the same script as the initial check instead of a weaker path-only copy. - pr-gate.sh held(): three attempts with backoff, and an unreadable label state is now reported as an API failure (:warning:, exit 1), distinct from a deliberate hold (:double_vertical_bar:, exit 0) — a transient outage no longer produces a green run claiming someone paused the release. Both call sites share check_hold(). Co-Authored-By: Claude Fable 5 --- .github/scripts/pr-gate.sh | 84 ++++++++++++---------- .github/scripts/verify-version-pr-scope.sh | 82 +++++++++++++++++++++ .github/workflows/release-train.yaml | 47 ++++-------- 3 files changed, 143 insertions(+), 70 deletions(-) create mode 100755 .github/scripts/verify-version-pr-scope.sh diff --git a/.github/scripts/pr-gate.sh b/.github/scripts/pr-gate.sh index 7a03f9b6..6a9035b0 100755 --- a/.github/scripts/pr-gate.sh +++ b/.github/scripts/pr-gate.sh @@ -23,12 +23,13 @@ # allowlist). The merge is refused if the PR head no longer # matches, closing the TOCTOU window between the caller's # check and the merge. Exit 1 (needs a re-run to re-vet). -# SCOPE_ALLOWLIST optional, with EXPECTED_HEAD — extended regex of allowed -# file paths. When the head moved, the gate re-vets the -# PR's files against this before refusing: a moved head -# whose full diff still matches (e.g. changesets/action -# refreshed the Version PR mid-gate) adopts the new head -# and keeps polling instead of failing the run. +# SCOPE_SCRIPT optional, with EXPECTED_HEAD — path to a script (run +# with PR/REPO in env) that exits 0 iff the PR's current +# diff is safe to merge unattended. When the head moved, +# the gate re-vets by re-running it: a moved head that +# still passes (e.g. changesets/action refreshed the +# Version PR mid-gate) adopts the new head and keeps +# polling instead of failing the run. # SLACK_BOT_TOKEN optional — Slack bot token for chat.postMessage # SLACK_CHANNEL_ID optional — Slack channel for alerts # RUN_URL optional — link back to this workflow run @@ -68,16 +69,40 @@ PR_URL="${GITHUB_SERVER_URL:-https://github.com}/${REPO}/pull/${PR}" # True when BLOCK_LABEL is set and currently on the PR. Queried live each time # so a hold applied while we're polling takes effect before any merge. +# 0 = held, 1 = not held, 2 = labels unreadable after retries. Callers must +# treat 2 as "do not merge" (fail closed) but report it as a read failure and +# exit non-zero — a green run claiming a deliberate pause that nobody applied +# would hide the skipped release from the people meant to investigate. held() { [ -n "${BLOCK_LABEL:-}" ] || return 1 - # Fail closed: this is the final safety control before an unattended merge, - # so "couldn't read the labels" must not be read as "no hold". - local labels - if ! labels="$(gh pr view "$PR" -R "$REPO" --json labels --jq '.labels[].name')"; then - echo "::warning::could not read labels for PR #${PR}; treating as held (fail closed)" - return 0 - fi - printf '%s\n' "$labels" | grep -qxF "$BLOCK_LABEL" + local labels attempt + for attempt in 1 2 3; do + if labels="$(gh pr view "$PR" -R "$REPO" --json labels --jq '.labels[].name')"; then + printf '%s\n' "$labels" | grep -qxF "$BLOCK_LABEL" && return 0 || return 1 + fi + sleep $((attempt * 5)) + done + echo "::warning::could not read labels for PR #${PR} after 3 attempts" + return 2 +} + +# held, exit as appropriate; no-op when not held. $1 names the checkpoint for +# the Slack message ("during the gate" / "just before merge"). +check_hold() { + local when="$1" rc=0 + held || rc=$? + case "$rc" in + 0) + slack ":double_vertical_bar: ${GATE_LABEL} <${PR_URL}|PR #${PR}> has \`${BLOCK_LABEL}\` — gate paused ${when}. Remove the label to resume on the next run. <${RUN_URL:-$PR_URL}|run>" + echo "PR #${PR} carries ${BLOCK_LABEL}; exiting without merging." + exit 0 + ;; + 2) + slack ":warning: ${GATE_LABEL} <${PR_URL}|PR #${PR}>: could not read PR labels ${when} — refusing to merge (cannot rule out a \`${BLOCK_LABEL}\` hold). This is an API failure, not a deliberate pause. <${RUN_URL:-$PR_URL}|run>" + echo "::error::labels unreadable for PR #${PR}; failing closed without merging." + exit 1 + ;; + esac } # Returns one of: PASS PENDING FAIL_CI FAIL_REVIEWER, plus a reason line on @@ -142,11 +167,7 @@ LAST_REASON="" while :; do NOW=$(date +%s); ELAPSED=$((NOW - START)) - if held; then - slack ":double_vertical_bar: ${GATE_LABEL} <${PR_URL}|PR #${PR}> has \`${BLOCK_LABEL}\` — gate paused, not merging. Remove the label to resume on the next run. <${RUN_URL:-$PR_URL}|run>" - echo "PR #${PR} carries ${BLOCK_LABEL}; exiting without merging." - exit 0 - fi + check_hold "during the gate" REASON="$(verdict 2>/tmp/gate.state)" || true STATE="$(cat /tmp/gate.state)" @@ -171,37 +192,28 @@ while :; do fi # Last-instant hold check: the settle sleep is a window in which a # human may have applied the hold label after the loop-top check. - if held; then - slack ":double_vertical_bar: ${GATE_LABEL} <${PR_URL}|PR #${PR}> has \`${BLOCK_LABEL}\` — gate paused just before merge. Remove the label to resume on the next run. <${RUN_URL:-$PR_URL}|run>" - echo "PR #${PR} carries ${BLOCK_LABEL}; exiting without merging." - exit 0 - fi + check_hold "just before merge" # Head pin: never merge a head nobody vetted. Checked at the last # instant so commits pushed at any point during the poll are caught, # not just ones present at loop start. A moved head is not necessarily # hostile — changesets/action force-pushes the Version PR whenever - # another PR lands on main — so when SCOPE_ALLOWLIST is provided, - # re-vet the new head's full file list first and only refuse if it no - # longer fits the allowlist; otherwise adopt the new head and resume - # polling (its checks just restarted). + # another PR lands on main — so when SCOPE_SCRIPT is provided, re-vet + # the new head by re-running it and only refuse if it fails; otherwise + # adopt the new head and resume polling (its checks just restarted). if [ -n "${EXPECTED_HEAD:-}" ]; then CURRENT_HEAD="$(gh pr view "$PR" -R "$REPO" --json headRefOid --jq '.headRefOid')" if [ "$CURRENT_HEAD" != "$EXPECTED_HEAD" ]; then REVETTED=false - if [ -n "${SCOPE_ALLOWLIST:-}" ]; then - if FILES="$(gh pr view "$PR" -R "$REPO" --json files --jq '.files[].path')" \ - && [ -n "$FILES" ] \ - && ! printf '%s\n' "$FILES" | grep -qvE "$SCOPE_ALLOWLIST"; then - REVETTED=true - fi + if [ -n "${SCOPE_SCRIPT:-}" ] && PR="$PR" REPO="$REPO" "$SCOPE_SCRIPT" >/dev/null; then + REVETTED=true fi if [ "$REVETTED" = "true" ]; then - echo "PR #${PR} head moved ${EXPECTED_HEAD:0:7} → ${CURRENT_HEAD:0:7}; diff still within scope allowlist — adopting new head and re-polling." + echo "PR #${PR} head moved ${EXPECTED_HEAD:0:7} → ${CURRENT_HEAD:0:7}; new diff passes the scope check — adopting new head and re-polling." EXPECTED_HEAD="$CURRENT_HEAD" LAST_REASON="" continue fi - slack ":no_entry: ${GATE_LABEL} <${PR_URL}|PR #${PR}> head moved during the gate (${EXPECTED_HEAD:0:7} → ${CURRENT_HEAD:0:7}) and the new diff fails the scope allowlist (or could not be read) — refusing to merge unvetted commits. Re-run the workflow to re-vet. <${RUN_URL:-$PR_URL}|run>" + slack ":no_entry: ${GATE_LABEL} <${PR_URL}|PR #${PR}> head moved during the gate (${EXPECTED_HEAD:0:7} → ${CURRENT_HEAD:0:7}) and the new diff fails the scope check (or could not be read) — refusing to merge unvetted commits. Re-run the workflow to re-vet. <${RUN_URL:-$PR_URL}|run>" echo "::error::PR #${PR} head changed ${EXPECTED_HEAD} → ${CURRENT_HEAD}; not merging." exit 1 fi diff --git a/.github/scripts/verify-version-pr-scope.sh b/.github/scripts/verify-version-pr-scope.sh new file mode 100755 index 00000000..27ebecaa --- /dev/null +++ b/.github/scripts/verify-version-pr-scope.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# +# verify-version-pr-scope.sh — assert a Version Packages PR contains only +# mechanical `changeset version` output before an unattended merge. +# +# Two layers, both fail-closed: +# 1. Path allowlist: consumed changesets (.changeset/*.md) and per-package +# package.json / CHANGELOG.md. No root package.json (private, +# unversioned), no lockfile (workspace:* deps don't touch it). +# 2. Content vet for package.json: paths alone are not enough — a smuggled +# `"postinstall"` script in packages/*/package.json passes a path check, +# survives `pnpm install --frozen-lockfile` (script-only edits don't +# desync the lockfile), and executes inside the publish job where the +# npm OIDC id-token is in scope. So every changed line in a package.json +# must be a version bump or an internal @openrouter/* dependency range +# bump — exactly what `changeset version` emits (cf. Version PR #57). +# +# File list is paginated via the REST API: `gh pr view --json files` caps at +# 100 entries and sorts .changeset/ first, so a padded PR could hide an +# out-of-scope file past the page boundary. +# +# Inputs (env): PR, REPO required; GH_TOKEN for gh. +# Output: "head_sha=" on stdout (the head this check vetted). +# Exit: 0 = in scope; 1 = out of scope or the diff could not be read, with +# offending entries on stderr. + +set -euo pipefail + +: "${PR:?PR is required}" +: "${REPO:?REPO is required}" + +HEAD_SHA="$(gh pr view "$PR" -R "$REPO" --json headRefOid --jq '.headRefOid')" +echo "head_sha=${HEAD_SHA}" + +# NDJSON, one {filename, patch} per line; --paginate walks every page. +FILES_NDJSON="$(gh api "repos/${REPO}/pulls/${PR}/files" --paginate \ + --jq '.[] | {filename, patch}')" + +if [ -z "$FILES_NDJSON" ]; then + echo "::error::Could not read file list for PR #${PR} (empty) — refusing" >&2 + exit 1 +fi + +printf '%s\n' "$FILES_NDJSON" | python3 -c ' +import json, re, sys + +ALLOW = re.compile(r"^(\.changeset/[^/]+\.md|packages/[^/]+/(package\.json|CHANGELOG\.md))$") +# The only lines `changeset version` changes in a package.json: the version +# field, and internal dependency ranges when updateInternalDependencies fires. +OK_LINE = re.compile( + r"^[+-]\s*\"(version|@openrouter/[A-Za-z0-9._-]+)\":\s*\"[^\"]*\",?\s*$" +) + +bad = [] +for raw in sys.stdin: + raw = raw.strip() + if not raw: + continue + f = json.loads(raw) + name = f["filename"] + if not ALLOW.match(name): + bad.append(f"path outside allowlist: {name}") + continue + if name.endswith("package.json"): + patch = f.get("patch") + if patch is None: + # No inline patch (file too large / binary flag) — cannot vet. + bad.append(f"unvettable package.json diff: {name}") + continue + for line in patch.splitlines(): + if line.startswith(("+++", "---")) or not line.startswith(("+", "-")): + continue + if not OK_LINE.match(line): + bad.append(f"non-version change in {name}: {line[:100]}") + break + +if bad: + for b in bad: + print(f"::error::{b}", file=sys.stderr) + sys.exit(1) +print("Diff scope OK — only mechanical changeset version output.", file=sys.stderr) +' diff --git a/.github/workflows/release-train.yaml b/.github/workflows/release-train.yaml index 53837a73..a2104754 100644 --- a/.github/workflows/release-train.yaml +++ b/.github/workflows/release-train.yaml @@ -45,13 +45,6 @@ env: REPO: OpenRouterTeam/typescript-agent VERSION_PR_BRANCH: changeset-release/main # changesets/action's fixed head branch HOLD_LABEL: release:hold - # Exactly what `changeset version` produces in this repo (cf. Version PR - # #57): consumed changesets + per-package version/changelog bumps. No root - # package.json (private, unversioned) and no lockfile (workspace:* deps - # don't touch it) — both would widen the unattended-merge surface (root - # package.json can carry lifecycle scripts). Used by the scope step below - # AND by pr-gate.sh to re-vet a head that moves mid-gate. - SCOPE_ALLOWLIST: '^(\.changeset/[^/]+\.md|packages/[^/]+/(package\.json|CHANGELOG\.md))$' jobs: train: @@ -138,8 +131,11 @@ jobs: # ordinary branch — anything else on it (pushed by anyone with write # access) would ride the auto-merge straight into an npm publish, and # CI + AI review are not a reliable control against a deliberate - # smuggle. Allowlist the file shapes changesets produces and refuse - # everything else. + # smuggle. verify-version-pr-scope.sh vets paths (paginated, so a + # padded PR can't hide files past the 100-entry page) AND package.json + # patch content (only version / internal-range bumps), failing closed + # on anything unreadable. It also emits the head SHA it vetted, which + # pr-gate.sh pins so commits pushed mid-gate can't bypass this check. - name: Verify Version PR diff scope id: scope if: steps.find.outputs.pr_number != '' && steps.find.outputs.held != 'true' @@ -151,31 +147,14 @@ jobs: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -euo pipefail - # Pin the head this check ran against; pr-gate.sh refuses to merge a - # different head (EXPECTED_HEAD below), so commits pushed during the - # up-to-30-min gate wait can't slip past this allowlist (TOCTOU). - HEAD_SHA="$(gh pr view "$PR" -R "$REPO" --json headRefOid --jq '.headRefOid')" - echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" - - # Fail closed: fetch the file list as its own command so an API error - # fails the step, and treat an empty list as an error too — a Version - # PR always contains files, and "couldn't read the diff" must never - # be interpreted as "diff is clean". - FILES="$(gh pr view "$PR" -R "$REPO" --json files --jq '.files[].path')" - if [ -z "$FILES" ]; then - echo "::error::Could not read file list for PR #${PR} — refusing to merge" - exit 1 - fi - # SCOPE_ALLOWLIST is defined once at workflow level (see env block) - # and shared with pr-gate.sh's mid-gate re-vet. - UNEXPECTED="$(printf '%s\n' "$FILES" | grep -vE "$SCOPE_ALLOWLIST" || true)" - if [ -z "$UNEXPECTED" ]; then - echo "Diff scope OK — only version-bump paths." + chmod +x .github/scripts/verify-version-pr-scope.sh + if OUT="$(./.github/scripts/verify-version-pr-scope.sh)"; then + echo "$OUT" >> "$GITHUB_OUTPUT" exit 0 fi - TEXT=":no_entry: Release train: Version Packages PR #${PR} touches files outside the version-bump allowlist — NOT merging. Unexpected: $(echo "$UNEXPECTED" | head -5 | tr '\n' ' ')<${RUN_URL}|run>" - echo "::error::Unexpected files in Version PR:"; echo "$UNEXPECTED" + TEXT=":no_entry: Release train: Version Packages PR #${PR} failed the version-bump scope check (unexpected paths or non-version package.json changes) — NOT merging. <${RUN_URL}|run>" + echo "::error::Scope check failed for Version PR #${PR}" if [ -n "${SLACK_BOT_TOKEN:-}" ] && [ -n "${SLACK_CHANNEL_ID:-}" ]; then curl -fsS -X POST https://slack.com/api/chat.postMessage \ -H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \ @@ -198,11 +177,11 @@ jobs: # merge, so a hold applied mid-gate still stops the train. BLOCK_LABEL: ${{ env.HOLD_LABEL }} # The head the diff-scope step vetted; pr-gate.sh refuses to merge - # any other head (TOCTOU guard for the scope allowlist) — unless - # the moved head's diff still fits SCOPE_ALLOWLIST, the routine + # any other head (TOCTOU guard for the scope check) — unless the + # moved head passes a fresh SCOPE_SCRIPT run, the routine # changesets/action refresh case, which re-vets and re-polls. EXPECTED_HEAD: ${{ steps.scope.outputs.head_sha }} - SCOPE_ALLOWLIST: ${{ env.SCOPE_ALLOWLIST }} + SCOPE_SCRIPT: .github/scripts/verify-version-pr-scope.sh RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} SLACK_BOT_TOKEN: ${{ secrets.CI_RELEASE_ALERT_SLACK_BOT_TOKEN }} SLACK_CHANNEL_ID: ${{ secrets.CI_RELEASE_ALERT_SLACK_CHANNEL_ID }} From 08ece38d79629ba7c884e058b9338d0f9a561304 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:13:20 -0500 Subject: [PATCH 07/10] fix(release): close file-cap bypass, exclude fork PRs, pin changesets/action, fail-closed head pin, perry clock reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth review round on PR #96: - verify-version-pr-scope.sh: cross-check the enumerated file count against the PR's changed_files — the files endpoint silently caps at 3000 even with --paginate, so a padded PR could hide a path past the cap. Any mismatch refuses. - release-train.yaml find step: filter to !isCrossRepository so a fork branch named changeset-release/main can never be selected, and error if more than one same-repo PR matches instead of picking .[0]. - publish.yaml: pin changesets/action to the v1.9.0 commit SHA — the step now receives the cross-repo PAT, so a floating tag is an exfiltration vector if the action is ever compromised. - pr-gate.sh: REQUIRE_HEAD=true (set by the train) makes an empty EXPECTED_HEAD a hard error instead of silently disabling the TOCTOU pin when step-output wiring breaks. - pr-gate.sh: separate PERRY_START clock, reset when a moved head is adopted — a routine changesets refresh late in the poll no longer trips the "perry/review never appeared" timeout that measures from the run's original start. Co-Authored-By: Claude Fable 5 --- .github/scripts/pr-gate.sh | 23 ++++++++++++++++++++-- .github/scripts/verify-version-pr-scope.sh | 11 +++++++++++ .github/workflows/publish.yaml | 5 ++++- .github/workflows/release-train.yaml | 20 ++++++++++++++++--- 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/.github/scripts/pr-gate.sh b/.github/scripts/pr-gate.sh index 6a9035b0..9d87ec35 100755 --- a/.github/scripts/pr-gate.sh +++ b/.github/scripts/pr-gate.sh @@ -23,6 +23,11 @@ # allowlist). The merge is refused if the PR head no longer # matches, closing the TOCTOU window between the caller's # check and the merge. Exit 1 (needs a re-run to re-vet). +# REQUIRE_HEAD optional — "true" to make an empty/unset EXPECTED_HEAD +# a hard error instead of "no pin configured". Set this +# wherever the pin is a security control, so a broken +# output wiring fails closed rather than silently +# disabling the guard. # SCOPE_SCRIPT optional, with EXPECTED_HEAD — path to a script (run # with PR/REPO in env) that exits 0 iff the PR's current # diff is safe to merge unattended. When the head moved, @@ -42,6 +47,14 @@ set -euo pipefail : "${PR:?PR is required}" : "${REPO:?REPO is required}" +# Fail closed on missing pin where the pin is a security control: an empty +# EXPECTED_HEAD (e.g. broken step-output wiring in the calling workflow) must +# not silently downgrade to "no head verification". +if [ "${REQUIRE_HEAD:-}" = "true" ] && [ -z "${EXPECTED_HEAD:-}" ]; then + echo "::error::REQUIRE_HEAD=true but EXPECTED_HEAD is empty — refusing to gate without a vetted head." + exit 1 +fi + GATE_LABEL="${GATE_LABEL:-@openrouter/sdk bump}" INTERVAL="${INTERVAL:-30}" @@ -162,10 +175,15 @@ PY echo "Gating PR #${PR} on ${REPO} (timeout ${TIMEOUT}s, interval ${INTERVAL}s)" START=$(date +%s) +# perry/review's "never appeared" clock. Reset whenever a new head is adopted +# mid-gate: the fresh head's checks (perry included) start from scratch, so +# measuring them against the run's original start time would misreport a +# routine changesets/action refresh late in the poll as a token misconfig. +PERRY_START=$START LAST_REASON="" while :; do - NOW=$(date +%s); ELAPSED=$((NOW - START)) + NOW=$(date +%s); ELAPSED=$((NOW - START)); PERRY_ELAPSED=$((NOW - PERRY_START)) check_hold "during the gate" @@ -210,6 +228,7 @@ while :; do if [ "$REVETTED" = "true" ]; then echo "PR #${PR} head moved ${EXPECTED_HEAD:0:7} → ${CURRENT_HEAD:0:7}; new diff passes the scope check — adopting new head and re-polling." EXPECTED_HEAD="$CURRENT_HEAD" + PERRY_START=$(date +%s) # fresh head, fresh checks — restart perry's clock LAST_REASON="" continue fi @@ -240,7 +259,7 @@ while :; do PENDING) # If perry/review never even shows up, the PR was likely opened with a # token that doesn't trigger it — surface that rather than hang forever. - if [ "$REASON" = "waiting for perry/review" ] && [ "$ELAPSED" -ge "$PERRY_TIMEOUT" ]; then + if [ "$REASON" = "waiting for perry/review" ] && [ "$PERRY_ELAPSED" -ge "$PERRY_TIMEOUT" ]; then slack ":warning: ${GATE_LABEL} <${PR_URL}|PR #${PR}>: perry/review never appeared after ${PERRY_TIMEOUT}s (token/app misconfig?). Not merging. <${RUN_URL:-$PR_URL}|run>" echo "::error::perry/review did not appear within ${PERRY_TIMEOUT}s" exit 1 diff --git a/.github/scripts/verify-version-pr-scope.sh b/.github/scripts/verify-version-pr-scope.sh index 27ebecaa..fded0b7b 100755 --- a/.github/scripts/verify-version-pr-scope.sh +++ b/.github/scripts/verify-version-pr-scope.sh @@ -41,6 +41,17 @@ if [ -z "$FILES_NDJSON" ]; then exit 1 fi +# The files endpoint silently caps at 3000 entries even with --paginate, so a +# PR padded with allowlisted files could hide an out-of-scope path past the +# cap. Cross-check against the PR's own changed-files count and refuse on any +# mismatch. (A legitimate Version PR is nowhere near 3000 files.) +ENUMERATED="$(printf '%s\n' "$FILES_NDJSON" | grep -c .)" +DECLARED="$(gh api "repos/${REPO}/pulls/${PR}" --jq '.changed_files')" +if [ "$ENUMERATED" != "$DECLARED" ]; then + echo "::error::File list truncated for PR #${PR}: enumerated ${ENUMERATED} of ${DECLARED} — refusing" >&2 + exit 1 +fi + printf '%s\n' "$FILES_NDJSON" | python3 -c ' import json, re, sys diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index fe9e64d0..342a75fd 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -131,7 +131,10 @@ jobs: if: > github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.mode == 'version') - uses: changesets/action@v1 + # SHA-pinned (= v1.9.0): this step receives the cross-repo PAT and + # runs with it in .git/config, so a floating tag would let a + # compromised action release exfiltrate it. Bump deliberately. + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d with: title: 'chore: version packages' commit: 'chore: version packages' diff --git a/.github/workflows/release-train.yaml b/.github/workflows/release-train.yaml index a2104754..4972ba01 100644 --- a/.github/workflows/release-train.yaml +++ b/.github/workflows/release-train.yaml @@ -58,12 +58,23 @@ jobs: GH_TOKEN: ${{ secrets.GH_TOKEN }} run: | set -euo pipefail - PR_JSON="$(gh pr list -R "$REPO" \ + # `--head` matches by branch *name*, which a fork can also use — an + # outsider's fork PR named changeset-release/main must never be + # what the train merges. Keep only same-repo PRs, and refuse to + # guess if more than one somehow matches. + MATCHES="$(gh pr list -R "$REPO" \ --head "$VERSION_PR_BRANCH" \ --state open \ - --json number,labels \ - --jq '.[0] // empty')" + --json number,labels,isCrossRepository \ + --jq '[.[] | select(.isCrossRepository | not)]')" + COUNT="$(echo "$MATCHES" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))')" + if [ "$COUNT" -gt 1 ]; then + echo "::error::${COUNT} same-repo PRs from ${VERSION_PR_BRANCH} — refusing to pick one." + exit 1 + fi + + PR_JSON="$(echo "$MATCHES" | python3 -c 'import sys,json; m=json.load(sys.stdin); print(json.dumps(m[0]) if m else "")')" if [ -z "$PR_JSON" ]; then echo "pr_number=" >> "$GITHUB_OUTPUT" echo "held=false" >> "$GITHUB_OUTPUT" @@ -181,6 +192,9 @@ jobs: # moved head passes a fresh SCOPE_SCRIPT run, the routine # changesets/action refresh case, which re-vets and re-polls. EXPECTED_HEAD: ${{ steps.scope.outputs.head_sha }} + # The pin is a security control here — an empty EXPECTED_HEAD + # (broken output wiring) must abort the gate, not skip the pin. + REQUIRE_HEAD: 'true' SCOPE_SCRIPT: .github/scripts/verify-version-pr-scope.sh RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} SLACK_BOT_TOKEN: ${{ secrets.CI_RELEASE_ALERT_SLACK_BOT_TOKEN }} From 09c7c7f7ed87e596a859ae7a0a4e3cf278bb8dd4 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:24:39 -0500 Subject: [PATCH 08/10] fix(release): atomic vetted-head merge, base-branch pin, alert on every find-step abort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixth review round on PR #96: - verify-version-pr-scope.sh: re-read headRefOid after fetching the diff and refuse on drift, so the reported head_sha is always the revision the files came from. - pr-gate.sh: the mid-gate re-vet adopts the head_sha the scope script itself vetted (parsed from its stdout) instead of a SHA observed before the vet ran — a flip-flop push during the vet can no longer get an unvetted head adopted. The merge itself passes --match-head-commit "$EXPECTED_HEAD", making the pin atomic at the GitHub API: any head movement between the pin check and the merge is rejected server-side. - release-train.yaml find step: --base main (the release branch can be PR'd at any base; the train must only ever merge into main), and every abort path now pages — the multiple-match refusal alerts inline, and an ERR trap covers unexpected gh/python failures in both the find and stranded-changesets steps, honoring the header's "alerted, never silently skipped" contract. Co-Authored-By: Claude Fable 5 --- .github/scripts/pr-gate.sh | 23 ++++++++++++------ .github/scripts/verify-version-pr-scope.sh | 11 ++++++++- .github/workflows/release-train.yaml | 28 ++++++++++++++++++++-- 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/.github/scripts/pr-gate.sh b/.github/scripts/pr-gate.sh index 9d87ec35..3201ec20 100755 --- a/.github/scripts/pr-gate.sh +++ b/.github/scripts/pr-gate.sh @@ -221,13 +221,17 @@ while :; do if [ -n "${EXPECTED_HEAD:-}" ]; then CURRENT_HEAD="$(gh pr view "$PR" -R "$REPO" --json headRefOid --jq '.headRefOid')" if [ "$CURRENT_HEAD" != "$EXPECTED_HEAD" ]; then - REVETTED=false - if [ -n "${SCOPE_SCRIPT:-}" ] && PR="$PR" REPO="$REPO" "$SCOPE_SCRIPT" >/dev/null; then - REVETTED=true + # Adopt the SHA the scope script itself reports as vetted (it fails + # internally if the head moves while it reads the diff) — never the + # head we observed before the vet ran, which a flip-flop push during + # the vet could otherwise swap for unvetted content. + NEW_VETTED="" + if [ -n "${SCOPE_SCRIPT:-}" ]; then + NEW_VETTED="$(PR="$PR" REPO="$REPO" "$SCOPE_SCRIPT" | sed -n 's/^head_sha=//p')" || NEW_VETTED="" fi - if [ "$REVETTED" = "true" ]; then - echo "PR #${PR} head moved ${EXPECTED_HEAD:0:7} → ${CURRENT_HEAD:0:7}; new diff passes the scope check — adopting new head and re-polling." - EXPECTED_HEAD="$CURRENT_HEAD" + if [ -n "$NEW_VETTED" ]; then + echo "PR #${PR} head moved ${EXPECTED_HEAD:0:7} → ${NEW_VETTED:0:7}; new diff passes the scope check — adopting vetted head and re-polling." + EXPECTED_HEAD="$NEW_VETTED" PERRY_START=$(date +%s) # fresh head, fresh checks — restart perry's clock LAST_REASON="" continue @@ -239,11 +243,16 @@ while :; do fi if [ "${AUTO_MERGE:-false}" = "true" ]; then echo "PASS — squash-merging PR #${PR}" + # --match-head-commit makes the pin atomic: GitHub itself rejects the + # merge if the head is no longer the vetted SHA, closing the residual + # window between our pin check above and the merge API call. + MATCH_ARGS=() + [ -n "${EXPECTED_HEAD:-}" ] && MATCH_ARGS=(--match-head-commit "$EXPECTED_HEAD") # Alert on merge failure too: under `set -e` a bare failing merge # would exit before any notification — fatal for the scheduled train, # where nobody is watching the run and the PR would sit unmerged # until the next scheduled attempt. - if gh pr merge "$PR" -R "$REPO" --squash --delete-branch; then + if gh pr merge "$PR" -R "$REPO" --squash --delete-branch "${MATCH_ARGS[@]}"; then slack ":white_check_mark: ${GATE_LABEL} <${PR_URL}|PR #${PR}> passed Perry + CI and was auto-merged." else slack ":x: ${GATE_LABEL} <${PR_URL}|PR #${PR}>: checks passed but the merge itself failed (branch protection? conflict?). Left open for a human. <${RUN_URL:-$PR_URL}|run>" diff --git a/.github/scripts/verify-version-pr-scope.sh b/.github/scripts/verify-version-pr-scope.sh index fded0b7b..0a2850ba 100755 --- a/.github/scripts/verify-version-pr-scope.sh +++ b/.github/scripts/verify-version-pr-scope.sh @@ -30,12 +30,21 @@ set -euo pipefail : "${REPO:?REPO is required}" HEAD_SHA="$(gh pr view "$PR" -R "$REPO" --json headRefOid --jq '.headRefOid')" -echo "head_sha=${HEAD_SHA}" # NDJSON, one {filename, patch} per line; --paginate walks every page. FILES_NDJSON="$(gh api "repos/${REPO}/pulls/${PR}/files" --paginate \ --jq '.[] | {filename, patch}')" +# The head must not have moved while we were reading the diff — otherwise the +# head_sha we report and the files we vetted could belong to different +# revisions, and a caller adopting head_sha would be pinning an unvetted head. +HEAD_AFTER="$(gh pr view "$PR" -R "$REPO" --json headRefOid --jq '.headRefOid')" +if [ "$HEAD_AFTER" != "$HEAD_SHA" ]; then + echo "::error::PR #${PR} head moved during the scope check (${HEAD_SHA:0:7} → ${HEAD_AFTER:0:7}) — refusing" >&2 + exit 1 +fi +echo "head_sha=${HEAD_SHA}" + if [ -z "$FILES_NDJSON" ]; then echo "::error::Could not read file list for PR #${PR} (empty) — refusing" >&2 exit 1 diff --git a/.github/workflows/release-train.yaml b/.github/workflows/release-train.yaml index 4972ba01..79fa45ea 100644 --- a/.github/workflows/release-train.yaml +++ b/.github/workflows/release-train.yaml @@ -56,20 +56,42 @@ jobs: id: find env: GH_TOKEN: ${{ secrets.GH_TOKEN }} + SLACK_BOT_TOKEN: ${{ secrets.CI_RELEASE_ALERT_SLACK_BOT_TOKEN }} + SLACK_CHANNEL_ID: ${{ secrets.CI_RELEASE_ALERT_SLACK_CHANNEL_ID }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -euo pipefail + slack() { + if [ -n "${SLACK_BOT_TOKEN:-}" ] && [ -n "${SLACK_CHANNEL_ID:-}" ]; then + curl -fsS -X POST https://slack.com/api/chat.postMessage \ + -H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \ + -H "Content-type: application/json; charset=utf-8" \ + --data "$(python3 -c "import json,sys; print(json.dumps({'channel':sys.argv[1],'unfurl_links':False,'text':sys.argv[2]}))" "$SLACK_CHANNEL_ID" "$1")" \ + >/dev/null || echo "::warning::Slack post failed" + else + echo "(slack not configured; would have posted) $1" + fi + } + # This is a cron-driven workflow nobody watches: any abort in this + # step must page, not just annotate the run. ERR fires on unexpected + # gh/python failures under set -e; deliberate exits alert inline. + trap 'slack ":warning: Release train: the find step failed unexpectedly — releases may be stalled. <${RUN_URL}|run>"' ERR + # `--head` matches by branch *name*, which a fork can also use — an # outsider's fork PR named changeset-release/main must never be - # what the train merges. Keep only same-repo PRs, and refuse to - # guess if more than one somehow matches. + # what the train merges. Pin the base to main too (the branch can be + # PR'd anywhere), keep only same-repo PRs, and refuse to guess if + # more than one somehow matches. MATCHES="$(gh pr list -R "$REPO" \ --head "$VERSION_PR_BRANCH" \ + --base main \ --state open \ --json number,labels,isCrossRepository \ --jq '[.[] | select(.isCrossRepository | not)]')" COUNT="$(echo "$MATCHES" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))')" if [ "$COUNT" -gt 1 ]; then + slack ":warning: Release train: ${COUNT} same-repo PRs open from ${VERSION_PR_BRANCH} — refusing to pick one. Close the extras. <${RUN_URL}|run>" echo "::error::${COUNT} same-repo PRs from ${VERSION_PR_BRANCH} — refusing to pick one." exit 1 fi @@ -100,6 +122,8 @@ jobs: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -euo pipefail + # Cron-driven and unwatched: an unexpected abort must page too. + trap 'if [ -n "${SLACK_BOT_TOKEN:-}" ] && [ -n "${SLACK_CHANNEL_ID:-}" ]; then curl -fsS -X POST https://slack.com/api/chat.postMessage -H "Authorization: Bearer ${SLACK_BOT_TOKEN}" -H "Content-type: application/json; charset=utf-8" --data "$(python3 -c "import json,sys; print(json.dumps({\"channel\":sys.argv[1],\"unfurl_links\":False,\"text\":sys.argv[2]}))" "$SLACK_CHANNEL_ID" ":warning: Release train: stranded-changesets check failed unexpectedly. <${RUN_URL}|run>")" >/dev/null || true; fi' ERR PENDING="$(find .changeset -maxdepth 1 -name '*.md' ! -name 'README.md' | wc -l | tr -d ' ')" if [ "$PENDING" = "0" ]; then echo "No pending changesets and no Version PR — nothing to release." From 13cbe2992e8f8123c8a895106fee7c00509b7d60 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:36:53 -0500 Subject: [PATCH 09/10] fix(release): vet the immutable SHA via compare API, guard head read, fix verdict JSON fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventh review round on PR #96: - verify-version-pr-scope.sh: fetch the diff via compare/... instead of the mutable pulls/N/files endpoint. The vetted content is now cryptographically bound to the reported head_sha — a blind A→B→A flip-flop timed inside the script can no longer get revision B's files vetted under A's SHA. The endpoint-drift recheck becomes unnecessary and is removed; the enumerated-vs-declared count cross-check stays (compare caps its files array too). - pr-gate.sh: the pre-merge headRefOid read is guarded like the label read — API failure or empty result alerts Slack and exits 1 instead of dying silently under set -e. - pr-gate.sh verdict(): `$(gh ... || echo '[]')` would append the fallback AFTER real output when gh exits non-zero yet still prints JSON (exit 8 = pending, 1 = failing), corrupting the parse (verified by simulation). Capture first, substitute only when empty. Co-Authored-By: Claude Fable 5 --- .github/scripts/pr-gate.sh | 22 +++++++++++-- .github/scripts/verify-version-pr-scope.sh | 38 +++++++++++----------- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/.github/scripts/pr-gate.sh b/.github/scripts/pr-gate.sh index 3201ec20..a21c5b59 100755 --- a/.github/scripts/pr-gate.sh +++ b/.github/scripts/pr-gate.sh @@ -122,8 +122,14 @@ check_hold() { # stderr. Reads checks + PR meta in two gh calls. verdict() { local checks meta - checks="$(gh pr checks "$PR" -R "$REPO" --json name,state 2>/dev/null || echo '[]')" - meta="$(gh pr view "$PR" -R "$REPO" --json mergeable,reviewDecision 2>/dev/null || echo '{}')" + # NOT `$(cmd || echo '[]')`: gh pr checks exits non-zero when checks are + # pending (8) or failing (1) while still printing the JSON, and that form + # would append the fallback AFTER the real payload, corrupting the parse. + # Capture whatever was printed, substitute the fallback only when empty. + checks="$(gh pr checks "$PR" -R "$REPO" --json name,state 2>/dev/null)" || true + [ -n "$checks" ] || checks='[]' + meta="$(gh pr view "$PR" -R "$REPO" --json mergeable,reviewDecision 2>/dev/null)" || true + [ -n "$meta" ] || meta='{}' AI_REVIEWERS="$AI_REVIEWERS" python3 - "$checks" "$meta" <<'PY' import sys, json, os, re checks = json.loads(sys.argv[1]) @@ -219,7 +225,17 @@ while :; do # the new head by re-running it and only refuse if it fails; otherwise # adopt the new head and resume polling (its checks just restarted). if [ -n "${EXPECTED_HEAD:-}" ]; then - CURRENT_HEAD="$(gh pr view "$PR" -R "$REPO" --json headRefOid --jq '.headRefOid')" + # Guarded like the label read: a transient API failure here must + # alert and go red, not silently kill the run under set -e. An empty + # result is treated the same — we cannot verify the pin, so we don't + # merge. (--match-head-commit below would also catch a stale pin, but + # only with a live head to compare.) + if ! CURRENT_HEAD="$(gh pr view "$PR" -R "$REPO" --json headRefOid --jq '.headRefOid')" \ + || [ -z "$CURRENT_HEAD" ]; then + slack ":warning: ${GATE_LABEL} <${PR_URL}|PR #${PR}>: could not read the PR head just before merge — refusing to merge (cannot verify the vetted-head pin). API failure, not a code problem. <${RUN_URL:-$PR_URL}|run>" + echo "::error::could not read headRefOid for PR #${PR}; failing closed." + exit 1 + fi if [ "$CURRENT_HEAD" != "$EXPECTED_HEAD" ]; then # Adopt the SHA the scope script itself reports as vetted (it fails # internally if the head moves while it reads the diff) — never the diff --git a/.github/scripts/verify-version-pr-scope.sh b/.github/scripts/verify-version-pr-scope.sh index 0a2850ba..5ee26e75 100755 --- a/.github/scripts/verify-version-pr-scope.sh +++ b/.github/scripts/verify-version-pr-scope.sh @@ -29,35 +29,35 @@ set -euo pipefail : "${PR:?PR is required}" : "${REPO:?REPO is required}" -HEAD_SHA="$(gh pr view "$PR" -R "$REPO" --json headRefOid --jq '.headRefOid')" +PR_INFO="$(gh pr view "$PR" -R "$REPO" --json headRefOid,baseRefName,changedFiles)" +HEAD_SHA="$(echo "$PR_INFO" | python3 -c 'import sys,json; print(json.load(sys.stdin)["headRefOid"])')" +BASE_REF="$(echo "$PR_INFO" | python3 -c 'import sys,json; print(json.load(sys.stdin)["baseRefName"])')" +DECLARED="$(echo "$PR_INFO" | python3 -c 'import sys,json; print(json.load(sys.stdin)["changedFiles"])')" -# NDJSON, one {filename, patch} per line; --paginate walks every page. -FILES_NDJSON="$(gh api "repos/${REPO}/pulls/${PR}/files" --paginate \ - --jq '.[] | {filename, patch}')" +# Vet the diff of the immutable SHA itself (compare API), not "the PR's +# current files": with the mutable pulls/N/files endpoint, an A→B→A flip-flop +# timed inside this script could get files from revision B vetted while +# head_sha reports A. compare/... is cryptographically bound to +# HEAD_SHA, so what we vet is exactly what the caller pins and merges +# (pr-gate.sh passes it to --match-head-commit). +FILES_NDJSON="$(gh api "repos/${REPO}/compare/${BASE_REF}...${HEAD_SHA}" \ + --jq '.files[] | {filename, patch}')" -# The head must not have moved while we were reading the diff — otherwise the -# head_sha we report and the files we vetted could belong to different -# revisions, and a caller adopting head_sha would be pinning an unvetted head. -HEAD_AFTER="$(gh pr view "$PR" -R "$REPO" --json headRefOid --jq '.headRefOid')" -if [ "$HEAD_AFTER" != "$HEAD_SHA" ]; then - echo "::error::PR #${PR} head moved during the scope check (${HEAD_SHA:0:7} → ${HEAD_AFTER:0:7}) — refusing" >&2 - exit 1 -fi echo "head_sha=${HEAD_SHA}" if [ -z "$FILES_NDJSON" ]; then - echo "::error::Could not read file list for PR #${PR} (empty) — refusing" >&2 + echo "::error::Could not read diff for ${HEAD_SHA:0:7} (empty) — refusing" >&2 exit 1 fi -# The files endpoint silently caps at 3000 entries even with --paginate, so a -# PR padded with allowlisted files could hide an out-of-scope path past the -# cap. Cross-check against the PR's own changed-files count and refuse on any -# mismatch. (A legitimate Version PR is nowhere near 3000 files.) +# The compare endpoint caps the files array (~300), so a padded PR could hide +# an out-of-scope path past the cap. Cross-check against the PR's own +# changed-files count and refuse on any mismatch — covers truncation AND a +# base that moved enough to skew the diff. (A real Version PR is small; a +# false refusal here just goes red and re-vets on re-run.) ENUMERATED="$(printf '%s\n' "$FILES_NDJSON" | grep -c .)" -DECLARED="$(gh api "repos/${REPO}/pulls/${PR}" --jq '.changed_files')" if [ "$ENUMERATED" != "$DECLARED" ]; then - echo "::error::File list truncated for PR #${PR}: enumerated ${ENUMERATED} of ${DECLARED} — refusing" >&2 + echo "::error::Diff for ${HEAD_SHA:0:7} enumerated ${ENUMERATED} files but PR declares ${DECLARED} — refusing" >&2 exit 1 fi From 801252125a56af85b46321ecb4a7b73157788212 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:47:49 -0500 Subject: [PATCH 10/10] fix(release): restart the overall gate deadline when a re-vetted head is adopted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A changesets/action refresh late in the 30-minute window left the new head's CI only the remainder of the original deadline, producing a false "did not settle" page and postponing the release to the next scheduled run. Adopting a re-vetted head now restarts START alongside PERRY_START — the same reasoning already applied to the perry clock. Unbounded extension isn't a concern: adoption requires passing the scope re-vet, and out-of-scope pushes still exit 1 immediately. Co-Authored-By: Claude Fable 5 --- .github/scripts/pr-gate.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/scripts/pr-gate.sh b/.github/scripts/pr-gate.sh index a21c5b59..32d45e62 100755 --- a/.github/scripts/pr-gate.sh +++ b/.github/scripts/pr-gate.sh @@ -248,7 +248,14 @@ while :; do if [ -n "$NEW_VETTED" ]; then echo "PR #${PR} head moved ${EXPECTED_HEAD:0:7} → ${NEW_VETTED:0:7}; new diff passes the scope check — adopting vetted head and re-polling." EXPECTED_HEAD="$NEW_VETTED" - PERRY_START=$(date +%s) # fresh head, fresh checks — restart perry's clock + # Fresh head, fresh checks — restart both clocks. Leaving the + # overall deadline on the run's original start would misreport a + # refresh late in the window as "did not settle" when the new + # head's CI never had a chance to finish. Adoption requires + # passing the scope re-vet, so this cannot extend a run + # unboundedly on hostile pushes — those exit 1 above instead. + PERRY_START=$(date +%s) + START=$PERRY_START LAST_REASON="" continue fi