diff --git a/.github/scripts/pr-gate.sh b/.github/scripts/pr-gate.sh index 1f3db2bb..32d45e62 100755 --- a/.github/scripts/pr-gate.sh +++ b/.github/scripts/pr-gate.sh @@ -13,6 +13,28 @@ # 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") +# 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). +# 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). +# 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, +# 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 @@ -25,6 +47,16 @@ 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}" TIMEOUT="${TIMEOUT:-1800}" # 30 min overall PERRY_TIMEOUT="${PERRY_TIMEOUT:-480}" # 8 min for perry/review to appear at all @@ -48,12 +80,56 @@ 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. +# 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 + 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 # 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]) @@ -105,10 +181,17 @@ 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" REASON="$(verdict 2>/tmp/gate.state)" || true STATE="$(cat /tmp/gate.state)" @@ -116,7 +199,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 ;; @@ -131,21 +214,85 @@ 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. + 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_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 + # 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 + # 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 [ -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" + # 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 + 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 + 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: @openrouter/sdk bump <${PR_URL}|PR #${PR}> passed Perry + CI and was auto-merged." + # --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 "${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>" + 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: @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 ;; 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 - 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>" + 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 fi @@ -153,7 +300,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/scripts/verify-version-pr-scope.sh b/.github/scripts/verify-version-pr-scope.sh new file mode 100755 index 00000000..5ee26e75 --- /dev/null +++ b/.github/scripts/verify-version-pr-scope.sh @@ -0,0 +1,102 @@ +#!/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}" + +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"])')" + +# 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}')" + +echo "head_sha=${HEAD_SHA}" + +if [ -z "$FILES_NDJSON" ]; then + echo "::error::Could not read diff for ${HEAD_SHA:0:7} (empty) — refusing" >&2 + exit 1 +fi + +# 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 .)" +if [ "$ENUMERATED" != "$DECLARED" ]; then + echo "::error::Diff for ${HEAD_SHA:0:7} enumerated ${ENUMERATED} files but PR declares ${DECLARED} — 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/publish.yaml b/.github/workflows/publish.yaml index 3f5329f5..342a75fd 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -66,7 +66,14 @@ jobs: (github.event_name == 'workflow_dispatch' && inputs.mode == 'publish' && inputs.dry-run) steps: + # 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: + persist-credentials: false - uses: pnpm/action-setup@v6 @@ -106,19 +113,40 @@ 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: > 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' 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 @@ -151,6 +179,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 @@ -253,7 +291,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 new file mode 100644 index 00000000..79fa45ea --- /dev/null +++ b/.github/workflows/release-train.yaml @@ -0,0 +1,228 @@ +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 }} + 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. 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 + + 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" + 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 + # 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." + 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 + + # 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. 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' + 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 + 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} 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}" \ + -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: + 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)' + # 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 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 }} + # 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 }} + SLACK_CHANNEL_ID: ${{ secrets.CI_RELEASE_ALERT_SLACK_CHANNEL_ID }} + run: | + chmod +x .github/scripts/pr-gate.sh + ./.github/scripts/pr-gate.sh