diff --git a/.github/scripts/bump-sdk.sh b/.github/scripts/bump-sdk.sh new file mode 100755 index 0000000..6028718 --- /dev/null +++ b/.github/scripts/bump-sdk.sh @@ -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" <" + 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 diff --git a/.github/workflows/bump-openrouter-sdk.yaml b/.github/workflows/bump-openrouter-sdk.yaml new file mode 100644 index 0000000..3f086ea --- /dev/null +++ b/.github/workflows/bump-openrouter-sdk.yaml @@ -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 }} + 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 diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 0927388..e11f9fd 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -61,6 +61,7 @@ jobs: - run: pnpm run test - name: Version PR or Publish (changesets) + id: changesets if: > github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.mode == 'version') @@ -74,6 +75,15 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # Record the npm version before a manual publish so the dispatch step can + # tell whether this run actually published a new @openrouter/agent (the + # changesets/action `published` output only exists for the push / + # mode=version path, not this manual one). + - name: Capture pre-publish version (manual publish) + id: pre-publish + if: github.event_name == 'workflow_dispatch' && inputs.mode == 'publish' && !inputs.dry-run + run: echo "version=$(npm view @openrouter/agent version)" >> "$GITHUB_OUTPUT" + - name: Publish (workflow_dispatch, live) if: github.event_name == 'workflow_dispatch' && inputs.mode == 'publish' && !inputs.dry-run run: pnpm exec changeset publish --no-git-checks @@ -90,3 +100,50 @@ jobs: run: pnpm -r publish --dry-run --access public --provenance --no-git-checks env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # HOP B trigger: when @openrouter/agent is actually published, tell the + # monorepo (openrouter-web) to bump its pinned @openrouter/agent for + # server tools. Two publish paths produce a real publish: + # 1. changesets/action on push (Version PR merged) → `published` output + # 2. manual workflow_dispatch mode=publish → detect via npm version diff + - name: Resolve published @openrouter/agent version + id: published + if: ${{ !inputs.dry-run }} + run: | + set -euo pipefail + VERSION="" + + # Path 1: changesets/action publish leg. + if [ "${{ steps.changesets.outputs.published }}" = "true" ]; then + VERSION=$(printf '%s' '${{ steps.changesets.outputs.publishedPackages }}' \ + | python3 -c "import sys,json; pkgs=json.load(sys.stdin); print(next((p['version'] for p in pkgs if p['name']=='@openrouter/agent'), ''))") + fi + + # Path 2: manual live publish — compare npm version before/after. + if [ -z "$VERSION" ] && [ -n "${{ steps.pre-publish.outputs.version }}" ]; then + AFTER="$(npm view @openrouter/agent version)" + if [ "$AFTER" != "${{ steps.pre-publish.outputs.version }}" ]; then + VERSION="$AFTER" + fi + fi + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + if [ -n "$VERSION" ]; then + echo "Published @openrouter/agent $VERSION" + else + echo "No new @openrouter/agent publish detected — no dispatch" + fi + + - name: Dispatch monorepo bump + if: ${{ !inputs.dry-run && steps.published.outputs.version != '' }} + env: + # Reuse the org's cross-repo PAT; needs contents:write on + # OpenRouterTeam/openrouter-web to be accepted. + GH_TOKEN: ${{ secrets.SUBTREE_PUSH_PAT }} + run: | + set -euo pipefail + gh api repos/OpenRouterTeam/openrouter-web/dispatches \ + -f event_type=openrouter-agent-published \ + -F "client_payload[version]=${{ steps.published.outputs.version }}" \ + -F "client_payload[source_run_url]=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + echo "Dispatched openrouter-agent-published (version ${{ steps.published.outputs.version }}) to openrouter-web"