-
Notifications
You must be signed in to change notification settings - Fork 8
ci: auto-bump @openrouter/sdk and dispatch monorepo on release #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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)" | ||
|
|
||
| # --- 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}" | ||
| 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 |
| 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 }} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] The bump job passes Reviewed at |
||
| 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 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nit]
BRANCHuses a second$(date)call — if the second rolls between this and theCHANGESET_FILEtimestamp at line 62, the two names diverge (cosmetically only; git adds the file via$CHANGESET_FILE, so nothing breaks). Assigning a singleTIMESTAMP=$(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