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
109 changes: 109 additions & 0 deletions .github/scripts/bump-sdk.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
#
# bump-sdk.sh — bump @openrouter/sdk in packages/agent and open the PR branch.
#
# Run by .github/workflows/bump-openrouter-sdk.yaml. Edits the dependency to the
# caret floor of the target version, relocks, writes a changeset, closes any
# prior bot bump PRs, commits, and pushes a new branch.
#
# Inputs (env):
# TARGET_VERSION required — the @openrouter/sdk version to bump to
# GH_TOKEN required — token with repo write (App token / PAT), so the
# opened PR triggers Perry + CI (GITHUB_TOKEN would not)
#
# Outputs (written to $GITHUB_OUTPUT):
# branch the pushed branch name (empty when noop)
# noop "true" when already at target (no branch/PR needed)
#
# Read the value with `pnpm exec` is avoided on purpose — plain node/jq only.

set -euo pipefail

: "${TARGET_VERSION:?TARGET_VERSION is required}"

REPO="OpenRouterTeam/typescript-agent"
PKG_JSON="packages/agent/package.json"
DEP="@openrouter/sdk"
BRANCH_PREFIX="sdk-bot/bump-openrouter-sdk-"
DESIRED_RANGE="^${TARGET_VERSION}"

out() { echo "$1=$2" >> "${GITHUB_OUTPUT:-/dev/stdout}"; }

# --- No-op guard: already at the desired caret floor? -----------------------
CURRENT_RANGE="$(node -p "require('./${PKG_JSON}').dependencies['${DEP}']")"
echo "Current ${DEP} range: ${CURRENT_RANGE} | desired: ${DESIRED_RANGE}"
if [ "$CURRENT_RANGE" = "$DESIRED_RANGE" ]; then
echo "Already at ${DESIRED_RANGE} — nothing to do"
out noop true
out branch ""
exit 0
fi

# --- Edit the dependency range ----------------------------------------------
node -e "
const fs = require('fs');
const p = './${PKG_JSON}';
const json = JSON.parse(fs.readFileSync(p, 'utf8'));
json.dependencies['${DEP}'] = '${DESIRED_RANGE}';
fs.writeFileSync(p, JSON.stringify(json, null, 2) + '\n');
console.log('Set ${DEP} to ${DESIRED_RANGE} in ${PKG_JSON}');
"

# --- Relock (FULL install; @openrouter/sdk is an onlyBuiltDependency) --------
# A full install ensures pnpm-lock.yaml matches what the PR's own
# `pnpm install --frozen-lockfile` CI step will expect.
pnpm install --no-frozen-lockfile

# --- Changeset (patch bump of @openrouter/agent) ----------------------------
# Written directly rather than via `changeset add` so it is non-interactive and
# deterministic. An empty changeset would not bump the version, so include the
# package + summary explicitly.
mkdir -p .changeset
CHANGESET_FILE=".changeset/sdk-bump-$(date +%Y%m%d-%H%M%S).md"
cat > "$CHANGESET_FILE" <<EOF
---
"@openrouter/agent": patch
---

Bump ${DEP} to ${TARGET_VERSION}
EOF
echo "Wrote changeset ${CHANGESET_FILE}"

# --- Git identity + branch ---------------------------------------------------
git config user.name 'OpenRouter SDK Bot'
git config user.email 'sdk-bot@openrouter.ai'

BRANCH="${BRANCH_PREFIX}$(date +%Y%m%d-%H%M%S)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] BRANCH uses a second $(date) call — if the second rolls between this and the CHANGESET_FILE timestamp at line 62, the two names diverge (cosmetically only; git adds the file via $CHANGESET_FILE, so nothing breaks). Assigning a single TIMESTAMP=$(date +%Y%m%d-%H%M%S) at the top of the script and reusing it would keep both names in sync.

Reviewed at 8c1196c


# --- Close prior bot bump PRs (keep at most one open) ------------------------
# Mirrors the close-prior pattern in openrouter-web's sdk-release-prs.yaml.
PRIOR_JSON=$(gh pr list \
--repo "$REPO" \
--state open \
--search "head:${BRANCH_PREFIX}" \
--limit 500 \
--json number \
--jq '.[].number' || true)

if [ -n "$PRIOR_JSON" ]; then
mapfile -t PRIOR <<< "$PRIOR_JSON"
echo "Closing ${#PRIOR[@]} prior bot bump PR(s) superseded by this run"
for N in "${PRIOR[@]}"; do
gh pr close "$N" --repo "$REPO" --delete-branch \
--comment "Superseded by a newer @openrouter/sdk bump" \
|| echo "::warning::Failed to close PR #$N (continuing)"
sleep 1 # stay under GitHub secondary rate limits
done
else
echo "No prior bot bump PRs to close"
fi

# --- Commit + push -----------------------------------------------------------
git checkout -b "$BRANCH"
git add "$PKG_JSON" pnpm-lock.yaml "$CHANGESET_FILE"
git commit -m "chore: bump ${DEP} to ${TARGET_VERSION} [sdk-bot]"
git push origin "$BRANCH"

out noop false
out branch "$BRANCH"
echo "Pushed ${BRANCH}"
161 changes: 161 additions & 0 deletions .github/scripts/pr-gate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#!/usr/bin/env bash
#
# pr-gate.sh — poll a bump PR until Perry + CI reach a terminal state, then
# squash-merge it (when AUTO_MERGE=true) or alert and leave it red.
#
# This is the self-gating auto-merge: GitHub-native `gh pr merge --auto` cannot
# be relied on because the repo has no required status checks, so we poll the
# verdict ourselves. The verdict mirrors ~/.claude/skills/get-pr-reviewed's
# pr_status.sh — reimplemented here in pure gh + python3 (no Claude in the loop).
#
# Inputs (env):
# PR required — PR number
# REPO required — owner/name
# GH_TOKEN required — token with merge permission
# AUTO_MERGE "true" to merge on PASS; anything else = report-only
# 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
#
# Exit: 0 on PASS (merged or report-only). Non-zero on FAIL/TIMEOUT so the run
# surfaces red in the Actions UI.

set -euo pipefail

: "${PR:?PR is required}"
: "${REPO:?REPO is required}"

INTERVAL="${INTERVAL:-30}"
TIMEOUT="${TIMEOUT:-1800}" # 30 min overall
PERRY_TIMEOUT="${PERRY_TIMEOUT:-480}" # 8 min for perry/review to appear at all
SETTLE="${SETTLE:-45}"

AI_REVIEWERS='perry/review|Devin Review|Graphite / AI Reviews|codex|claude'

slack() {
# slack "<text>"
local text="$1"
if [ -z "${SLACK_BOT_TOKEN:-}" ] || [ -z "${SLACK_CHANNEL_ID:-}" ]; then
echo "(slack not configured; would have posted) $text"
return 0
fi
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 (continuing)"
}

PR_URL="${GITHUB_SERVER_URL:-https://github.com}/${REPO}/pull/${PR}"

# 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 '{}')"
AI_REVIEWERS="$AI_REVIEWERS" python3 - "$checks" "$meta" <<'PY'
import sys, json, os, re
checks = json.loads(sys.argv[1])
meta = json.loads(sys.argv[2])
ai = re.compile(os.environ["AI_REVIEWERS"])
FAIL = {"FAILURE","ERROR","CANCELLED","TIMED_OUT","ACTION_REQUIRED","STARTUP_FAILURE"}
PENDING = {"PENDING","IN_PROGRESS","QUEUED","EXPECTED","WAITING"}
PASS_REVIEW = {"SUCCESS","NEUTRAL","SKIPPED"}

reasons = []
ci_pending = False
perry_present = False
perry_terminal = False

for c in checks:
name, state = c["name"], c["state"]
if ai.search(name):
if name == "perry/review":
perry_present = True
if state not in PENDING:
perry_terminal = True
if state not in PASS_REVIEW and state not in PENDING:
print(f"FAIL_REVIEWER", file=sys.stderr)
print(f"reviewer {name}={state}")
sys.exit(0)
else:
if state in FAIL:
print("FAIL_CI", file=sys.stderr)
print(f"CI {name}={state}")
sys.exit(0)
if state in PENDING:
ci_pending = True

if meta.get("reviewDecision") == "CHANGES_REQUESTED":
print("FAIL_REVIEWER", file=sys.stderr)
print("reviewDecision=CHANGES_REQUESTED")
sys.exit(0)

# Not failing. Decide PASS vs PENDING.
if ci_pending:
print("PENDING", file=sys.stderr); print("CI still running"); sys.exit(0)
if not (perry_present and perry_terminal):
print("PENDING", file=sys.stderr); print("waiting for perry/review"); sys.exit(0)
if meta.get("mergeable") != "MERGEABLE":
print("PENDING", file=sys.stderr); print(f"mergeable={meta.get('mergeable')}"); sys.exit(0)
print("PASS", file=sys.stderr); print("all green")
PY
}

echo "Gating PR #${PR} on ${REPO} (timeout ${TIMEOUT}s, interval ${INTERVAL}s)"
START=$(date +%s)
LAST_REASON=""

while :; do
NOW=$(date +%s); ELAPSED=$((NOW - START))

REASON="$(verdict 2>/tmp/gate.state)" || true
STATE="$(cat /tmp/gate.state)"
[ "$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>"
echo "::error::PR #${PR} blocked: ${REASON}"
exit 1
;;
PASS)
# Confirm once more after a short settle window so a momentary "all green"
# before a reviewer (re)posts cannot trip an early merge.
sleep "$SETTLE"
CONFIRM_REASON="$(verdict 2>/tmp/gate.state2)" || true
CONFIRM_STATE="$(cat /tmp/gate.state2)"
if [ "$CONFIRM_STATE" != "PASS" ]; then
echo "Settle re-check changed verdict to ${CONFIRM_STATE} (${CONFIRM_REASON}); continuing to poll"
LAST_REASON=""
continue
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."
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."
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>"
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>"
echo "::error::Gate timed out after ${TIMEOUT}s (last: ${REASON})"
exit 1
fi
sleep "$INTERVAL"
done
121 changes: 121 additions & 0 deletions .github/workflows/bump-openrouter-sdk.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
name: Bump @openrouter/sdk

# HOP A of the SDK release chain. Triggered by typescript-sdk after it publishes
# a new @openrouter/sdk to npm. Opens a PR bumping the dependency in
# packages/agent, lets Perry + CI run, then auto-merges on green (or alerts and
# leaves the PR red on failure). Merging the PR feeds the changesets release
# flow in publish.yaml, which cuts a new @openrouter/agent and dispatches HOP B.

on:
repository_dispatch:
types: [openrouter-sdk-published]
workflow_dispatch:
inputs:
version:
description: "@openrouter/sdk version to bump to (blank = npm latest)"
required: false
type: string
dry_run:
description: "Open the PR and run the gate, but do not merge"
required: false
default: false
type: boolean

permissions:
contents: write
pull-requests: write

concurrency:
group: bump-openrouter-sdk
cancel-in-progress: false

jobs:
bump:
runs-on: ubuntu-latest
outputs:
branch: ${{ steps.bump.outputs.branch }}
noop: ${{ steps.bump.outputs.noop }}
pr_number: ${{ steps.open.outputs.pr_number }}
steps:
# SUBTREE_PUSH_PAT (a PAT, not GITHUB_TOKEN) so the opened PR triggers
# Perry + CI — GITHUB_TOKEN would suppress those downstream runs. This is
# the org's established cross-repo PAT (the same one the monorepo uses to
# push into the SDK repos).
- uses: actions/checkout@v4
with:
token: ${{ secrets.SUBTREE_PUSH_PAT }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The bump job passes token: SUBTREE_PUSH_PAT at checkout so that the subsequent git push in bump-sdk.sh is authenticated as the PAT rather than GITHUB_TOKEN; the gate job omits the token (correct — it only needs read access, and the merge-step GH_TOKEN env var handles authentication separately). Consider a brief comment here to explain the asymmetry for the next reader.

Reviewed at 8917615

fetch-depth: 0

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- name: Resolve target version
id: ver
run: |
set -euo pipefail
V="${{ github.event.client_payload.version || github.event.inputs.version }}"
if [ -z "$V" ]; then
V="$(npm view @openrouter/sdk version)"
fi
echo "version=$V" >> "$GITHUB_OUTPUT"
echo "Target @openrouter/sdk version: $V"

- name: Bump, relock, changeset, push branch
id: bump
env:
TARGET_VERSION: ${{ steps.ver.outputs.version }}
GH_TOKEN: ${{ secrets.SUBTREE_PUSH_PAT }}
run: |
chmod +x .github/scripts/bump-sdk.sh
./.github/scripts/bump-sdk.sh

- name: Open PR
id: open
if: steps.bump.outputs.noop != 'true'
env:
GH_TOKEN: ${{ secrets.SUBTREE_PUSH_PAT }}
run: |
set -euo pipefail
PR_URL=$(gh pr create \
--repo OpenRouterTeam/typescript-agent \
--base main \
--head "${{ steps.bump.outputs.branch }}" \
--title "chore: bump @openrouter/sdk to ${{ steps.ver.outputs.version }}" \
--body "$(cat <<'EOF'
Automated bump of `@openrouter/sdk` from the SDK release chain.

Source: ${{ github.event.client_payload.source_run_url || github.event.inputs.version && 'manual workflow_dispatch' }}

This PR will be auto-merged once Perry and CI pass. On failure it is
left open for a human. [sdk-bot]
EOF
)")
echo "Created $PR_URL"
echo "pr_number=$(echo "$PR_URL" | grep -oE '[0-9]+$')" >> "$GITHUB_OUTPUT"

gate:
needs: bump
if: needs.bump.outputs.noop != 'true' && needs.bump.outputs.pr_number != ''
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4

- name: Wait for Perry + CI, then merge or alert
env:
GH_TOKEN: ${{ secrets.SUBTREE_PUSH_PAT }}
PR: ${{ needs.bump.outputs.pr_number }}
REPO: OpenRouterTeam/typescript-agent
AUTO_MERGE: ${{ github.event.inputs.dry_run != 'true' }}
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
Loading
Loading