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
198 changes: 123 additions & 75 deletions .github/scripts/bump-sdk.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,19 @@
#
# 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)
# GH_TOKEN required in the remote phase — token with repo write (App
# token), so the opened PR triggers Perry + CI (GITHUB_TOKEN
# would not)
# PHASE optional — "prepare" (edit/relock/changeset/commit; needs
# NO token, safe to run with lifecycle scripts), "remote"
# (close prior PRs + push; needs GH_TOKEN; expects BRANCH in
# env from the prepare phase), or "all" (default, both).
# The workflow runs the phases as separate steps so the App
# token is never in env or .git/config while `pnpm install`
# executes dependency lifecycle scripts.
#
# Outputs (written to $GITHUB_OUTPUT):
# branch the pushed branch name (empty when noop)
# branch the 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.
Expand All @@ -21,6 +29,8 @@ set -euo pipefail

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

PHASE="${PHASE:-all}"

REPO="OpenRouterTeam/typescript-agent"
PKG_JSON="packages/agent/package.json"
DEP="@openrouter/sdk"
Expand All @@ -29,81 +39,119 @@ 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
# Set by prepare(). A separate flag rather than prepare's exit status: the
# callers would have to invoke prepare in a conditional (`if prepare` /
# `prepare || true`), and bash disables `set -e` inside a function called
# that way — a failed relock would then sail on to commit/push a broken
# bump PR instead of failing the run. With the flag, prepare is invoked
# plainly (set -e fully active inside) and only the no-op is special-cased.
NOOP=false

prepare() {
# --- 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 ""
NOOP=true
return 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. This executes
# dependency lifecycle scripts — which is why this phase must run with no
# token in env or .git/config.
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}"
echo "Wrote changeset ${CHANGESET_FILE}"

# --- Git identity + local commit -------------------------------------------
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)"
git checkout -b "$BRANCH"
git add "$PKG_JSON" pnpm-lock.yaml "$CHANGESET_FILE"
git commit -m "chore: bump ${DEP} to ${TARGET_VERSION} [sdk-bot]"

out noop false
out branch "$BRANCH"
echo "Committed ${BRANCH} locally (not yet pushed)"
}

remote() {
: "${GH_TOKEN:?GH_TOKEN is required for the remote phase}"
: "${BRANCH:?BRANCH is required for the remote phase}"

# --- 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

# --- Push -------------------------------------------------------------------
# Credential via GIT_CONFIG_* environment (the env equivalent of `git -c`)
# rather than embedded in the URL or passed as an argument: URL tokens and
# -c values both leak through argv (/proc/<pid>/cmdline) and set -x
# tracing. Env vars are visible only to this process tree, and nothing
# token-bearing persists in .git/config.
AUTH_B64="$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')"
GIT_CONFIG_COUNT=1 \
GIT_CONFIG_KEY_0="http.https://github.com/.extraheader" \
GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${AUTH_B64}" \
git push "https://github.com/${REPO}.git" "$BRANCH"
echo "Pushed ${BRANCH}"
Comment thread
LukasParke marked this conversation as resolved.
}

case "$PHASE" in
prepare) prepare ;;
remote) remote ;;
all)
prepare
if [ "$NOOP" != "true" ]; then remote; fi
;;
*) echo "::error::Unknown PHASE '${PHASE}'"; exit 1 ;;
Comment thread
LukasParke marked this conversation as resolved.
esac
33 changes: 26 additions & 7 deletions .github/scripts/pr-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,14 @@ fi
GATE_LABEL="${GATE_LABEL:-@openrouter/sdk bump}"

INTERVAL="${INTERVAL:-30}"
TIMEOUT="${TIMEOUT:-1800}" # 30 min overall
TIMEOUT="${TIMEOUT:-1800}" # 30 min per vetted head (resets on head adoption)
# Hard wall-clock ceiling that NEVER resets, unlike TIMEOUT: the head-adoption
# path restarts TIMEOUT per fresh head, so repeated changesets/action refreshes
# could otherwise keep the loop alive past the 1-hour lifetime of the App
# installation token the caller minted — after which every gh call 401s and
# the failure mode turns confusing. Default 50 min leaves headroom to alert
# cleanly while the token still works.
MAX_WALL="${MAX_WALL:-3000}"
PERRY_TIMEOUT="${PERRY_TIMEOUT:-480}" # 8 min for perry/review to appear at all
SETTLE="${SETTLE:-45}"

Expand Down Expand Up @@ -179,8 +186,9 @@ print("PASS", file=sys.stderr); print("all green")
PY
}

echo "Gating PR #${PR} on ${REPO} (timeout ${TIMEOUT}s, interval ${INTERVAL}s)"
echo "Gating PR #${PR} on ${REPO} (timeout ${TIMEOUT}s, max wall ${MAX_WALL}s, interval ${INTERVAL}s)"
START=$(date +%s)
WALL_START=$START # never reset — see MAX_WALL above
# 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
Expand All @@ -191,6 +199,22 @@ LAST_REASON=""
while :; do
NOW=$(date +%s); ELAPSED=$((NOW - START)); PERRY_ELAPSED=$((NOW - PERRY_START))

# Deadlines at the TOP of the loop, before any branch can `continue` past
# them: the settle re-check path loops back whenever the verdict flips away
# from PASS, so bottom-of-loop checks would let a PR oscillating green/
# not-green spin past both deadlines until the job's own 6-hour limit —
# long after the job's App token expired.
if [ "$ELAPSED" -ge "$TIMEOUT" ]; then
slack ":warning: ${GATE_LABEL} <${PR_URL}|PR #${PR}> did not settle within ${TIMEOUT}s (last: ${LAST_REASON:-none}). Not merging. <${RUN_URL:-$PR_URL}|run>"
echo "::error::Gate timed out after ${TIMEOUT}s (last: ${LAST_REASON:-none})"
exit 1
fi
if [ $((NOW - WALL_START)) -ge "$MAX_WALL" ]; then
slack ":warning: ${GATE_LABEL} <${PR_URL}|PR #${PR}> hit the ${MAX_WALL}s wall-clock ceiling (repeated head refreshes?) — stopping before the job credential expires. Re-run to continue gating. <${RUN_URL:-$PR_URL}|run>"
echo "::error::Gate hit the ${MAX_WALL}s wall-clock ceiling (last: ${LAST_REASON:-none})"
exit 1
fi

check_hold "during the gate"

REASON="$(verdict 2>/tmp/gate.state)" || true
Expand Down Expand Up @@ -299,10 +323,5 @@ while :; do
;;
esac

if [ "$ELAPSED" -ge "$TIMEOUT" ]; then
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
sleep "$INTERVAL"
done
Loading