Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 157 additions & 10 deletions .github/scripts/pr-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand 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.
Comment thread
cortex-github-agent[bot] marked this conversation as resolved.
# 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
}
Comment thread
LukasParke marked this conversation as resolved.

# 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])
Expand Down Expand Up @@ -105,18 +181,25 @@ 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)"
Comment thread
LukasParke marked this conversation as resolved.
[ "$REASON" != "$LAST_REASON" ] && { echo "[$ELAPSED s] $STATE — $REASON"; LAST_REASON="$REASON"; }

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
;;
Expand All @@ -131,29 +214,93 @@ 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
Comment thread
LukasParke marked this conversation as resolved.
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
Comment thread
LukasParke marked this conversation as resolved.
Comment thread
LukasParke marked this conversation as resolved.
if [ "${AUTO_MERGE:-false}" = "true" ]; then
Comment thread
LukasParke marked this conversation as resolved.
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
;;
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
Expand Down
102 changes: 102 additions & 0 deletions .github/scripts/verify-version-pr-scope.sh
Original file line number Diff line number Diff line change
@@ -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=<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/<base>...<sha> 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)
'
Loading