diff --git a/.github/scripts/bump-sdk.sh b/.github/scripts/bump-sdk.sh index 6028718..06c2b21 100755 --- a/.github/scripts/bump-sdk.sh +++ b/.github/scripts/bump-sdk.sh @@ -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. @@ -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" @@ -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" < "$CHANGESET_FILE" </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}" +} + +case "$PHASE" in + prepare) prepare ;; + remote) remote ;; + all) + prepare + if [ "$NOOP" != "true" ]; then remote; fi + ;; + *) echo "::error::Unknown PHASE '${PHASE}'"; exit 1 ;; +esac diff --git a/.github/scripts/pr-gate.sh b/.github/scripts/pr-gate.sh index 32d45e6..fa38c17 100755 --- a/.github/scripts/pr-gate.sh +++ b/.github/scripts/pr-gate.sh @@ -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}" @@ -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 @@ -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 @@ -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 diff --git a/.github/workflows/bump-openrouter-sdk.yaml b/.github/workflows/bump-openrouter-sdk.yaml index c28bb3e..5a5855c 100644 --- a/.github/workflows/bump-openrouter-sdk.yaml +++ b/.github/workflows/bump-openrouter-sdk.yaml @@ -37,11 +37,14 @@ jobs: noop: ${{ steps.bump.outputs.noop }} pr_number: ${{ steps.open.outputs.pr_number }} steps: - # GH_TOKEN is a PAT (not the Actions GITHUB_TOKEN) so the opened PR - # triggers Perry + CI — GITHUB_TOKEN would suppress those downstream runs. + # No credentials persisted: the prepare phase below runs `pnpm install`, + # which executes dependency lifecycle scripts that must not be able to + # read a write-capable token out of .git/config (same reasoning as + # publish.yaml's checkout). The App token is minted only after that + # phase and passed per-command to the push. - uses: actions/checkout@v6 with: - token: ${{ secrets.GH_TOKEN }} + persist-credentials: false fetch-depth: 0 - uses: pnpm/action-setup@v6 @@ -62,20 +65,73 @@ jobs: echo "version=$V" >> "$GITHUB_OUTPUT" echo "Target @openrouter/sdk version: $V" - - name: Bump, relock, changeset, push branch + # Phase 1: edit + relock + changeset + local commit. Runs pnpm install + # (lifecycle scripts) — deliberately tokenless. + - name: Bump, relock, changeset (no token) id: bump env: TARGET_VERSION: ${{ steps.ver.outputs.version }} - GH_TOKEN: ${{ secrets.GH_TOKEN }} + PHASE: prepare run: | chmod +x .github/scripts/bump-sdk.sh ./.github/scripts/bump-sdk.sh + # Short-lived App token (same App as publish.yaml / release-train.yaml), + # minted only now that lifecycle scripts are done. An App token (not the + # Actions GITHUB_TOKEN) so the opened PR triggers Perry + CI — + # GITHUB_TOKEN would suppress those downstream runs. Replaces the + # GH_TOKEN PAT the enterprise lifetime policy killed. + - name: Mint release-bot token + id: app-token + if: steps.bump.outputs.noop != 'true' + continue-on-error: true + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} + owner: OpenRouterTeam + repositories: typescript-agent + + # Same mint-failure paging as publish.yaml / release-train.yaml: this + # workflow is repository_dispatch-driven with nobody watching the run, + # so a broken App credential must page, not just redden the Actions UI. + - name: Alert if token minting failed + if: steps.bump.outputs.noop != 'true' && steps.app-token.outcome != 'success' + env: + SLACK_BOT_TOKEN: ${{ secrets.CI_RELEASE_ALERT_SLACK_BOT_TOKEN }} + SLACK_CHANNEL_ID: ${{ secrets.CI_RELEASE_ALERT_SLACK_CHANNEL_ID }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + TEXT=":rotating_light: SDK bump: could not mint the release-bot App token (RELEASE_BOT_APP_ID/RELEASE_BOT_PRIVATE_KEY missing or App uninstalled?). The @openrouter/sdk bump chain is stalled. <${RUN_URL}|run>" + echo "::error::App token minting failed — SDK bump chain stalled." + if [ -n "${SLACK_BOT_TOKEN:-}" ] && [ -n "${SLACK_CHANNEL_ID:-}" ]; then + 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" + else + echo "(slack not configured; would have posted) $TEXT" + fi + exit 1 + + # Phase 2: close superseded PRs + push. Token passed per-command; never + # persisted to .git/config. + - name: Close prior PRs, push branch + if: steps.bump.outputs.noop != 'true' + env: + TARGET_VERSION: ${{ steps.ver.outputs.version }} + PHASE: remote + BRANCH: ${{ steps.bump.outputs.branch }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: ./.github/scripts/bump-sdk.sh + - name: Open PR id: open if: steps.bump.outputs.noop != 'true' env: - GH_TOKEN: ${{ secrets.GH_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | set -euo pipefail PR_URL=$(gh pr create \ @@ -105,9 +161,46 @@ jobs: steps: - uses: actions/checkout@v6 + # Minted per-job: installation tokens cannot be passed between jobs + # (GitHub drops job outputs that contain secrets), and the bump job's + # token may expire during a long gate anyway (1h lifetime, 30m poll). + - name: Mint release-bot token + id: app-token + continue-on-error: true + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} + owner: OpenRouterTeam + repositories: typescript-agent + + # Same mint-failure paging as the bump job above: at this point a bump + # PR is already open and unmerged, so a silent stall leaves it dangling. + - name: Alert if token minting failed + if: steps.app-token.outcome != 'success' + env: + SLACK_BOT_TOKEN: ${{ secrets.CI_RELEASE_ALERT_SLACK_BOT_TOKEN }} + SLACK_CHANNEL_ID: ${{ secrets.CI_RELEASE_ALERT_SLACK_CHANNEL_ID }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + PR: ${{ needs.bump.outputs.pr_number }} + run: | + set -euo pipefail + TEXT=":rotating_light: SDK bump: could not mint the App token for the gate job — PR #${PR} is open but ungated/unmerged. Merge it manually or re-run after fixing the App credentials. <${RUN_URL}|run>" + echo "::error::App token minting failed — bump PR #${PR} left ungated." + if [ -n "${SLACK_BOT_TOKEN:-}" ] && [ -n "${SLACK_CHANNEL_ID:-}" ]; then + 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" + else + echo "(slack not configured; would have posted) $TEXT" + fi + exit 1 + - name: Wait for Perry + CI, then merge or alert env: - GH_TOKEN: ${{ secrets.GH_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} PR: ${{ needs.bump.outputs.pr_number }} REPO: OpenRouterTeam/typescript-agent AUTO_MERGE: ${{ github.event.inputs.dry_run != 'true' }} diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index da21e2d..06a8b17 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -67,9 +67,9 @@ jobs: inputs.mode == 'publish' && inputs.dry-run) steps: # No credentials persisted: install/build/test below execute dependency - # lifecycle scripts and test code, which must not be able to read a - # long-lived cross-repo PAT out of .git/config. Git push credentials are - # injected by the "Configure git push credentials" step *after* those, + # lifecycle scripts and test code, which must not be able to read the + # cross-repo App token out of .git/config. Push credentials are injected + # by the "Configure git push credentials" step *after* those, # immediately before the only steps that push. - uses: actions/checkout@v6 with: @@ -113,27 +113,90 @@ jobs: - run: pnpm run test + # Short-lived GitHub App installation token replacing the old GH_TOKEN + # PAT (which the OpenRouter enterprise now rejects for lifetime > 366 + # days — the failure that silently stalled releases in Aug 2026). Same + # pattern as the port repos' openrouter-port-bot. Expires after 1 hour + # and is auto-revoked at job end, so the exfiltration surface the PAT + # reviews worried about shrinks to a job-scoped credential. + # + # Two tokens, deliberately: the release itself must never depend on the + # HOP dispatch targets. This one is scoped to THIS repo only and gates + # the publish path (fails loudly if the App/installation is missing). + # The cross-repo dispatch token below is minted separately, best-effort, + # matching the HOP steps' own "must not fail the release" contract. + # + # Known constraint: App tokens cannot push changes under + # .github/workflows/ without the App also granting `workflows: write` + # (the push is rejected with "refusing to allow a GitHub App to create + # or update workflow"). The Version PR and SDK-bump branches never + # touch workflow files today; if one ever must, grant that permission + # on the App rather than working around the rejected push. + # Skipped only on the mode=publish dry-run leg: that leg publishes + # nothing, pushes nothing, and dispatches nothing, so a broken App + # setup must not block it — it's the diagnostic escape hatch you'd + # reach for while fixing exactly that. NOT skipped on mode=version + + # dry-run: the changesets step ignores dry-run entirely (see the job + # `if` comment) and runs for real, so it needs the real token. + - name: Mint release-bot token + id: app-token + if: ${{ !(inputs.dry-run && inputs.mode == 'publish') }} + continue-on-error: true + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} + owner: OpenRouterTeam + repositories: typescript-agent + + # Same mint-failure paging as the train: this workflow runs on every + # push to main with nobody watching, so a broken App credential must + # page rather than just turn the run red. Alert, then stop the job — + # nothing downstream can succeed without the token. + - name: Alert if token minting failed + if: ${{ !(inputs.dry-run && inputs.mode == 'publish') && steps.app-token.outcome != 'success' }} + env: + SLACK_BOT_TOKEN: ${{ secrets.CI_RELEASE_ALERT_SLACK_BOT_TOKEN }} + SLACK_CHANNEL_ID: ${{ secrets.CI_RELEASE_ALERT_SLACK_CHANNEL_ID }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + TEXT=":rotating_light: Release publish: could not mint the release-bot App token (RELEASE_BOT_APP_ID/RELEASE_BOT_PRIVATE_KEY missing or App uninstalled?). Releases are stalled until this is fixed. <${RUN_URL}|run>" + echo "::error::App token minting failed — releases are stalled." + if [ -n "${SLACK_BOT_TOKEN:-}" ] && [ -n "${SLACK_CHANNEL_ID:-}" ]; then + 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" + else + echo "(slack not configured; would have posted) $TEXT" + fi + exit 1 + # Deliberately after install/build/test (see the checkout comment). # changesets/action pushes changeset-release/main (and release tags on # the publish leg) with plain `git push`, which uses the remote's - # embedded credentials. It must be the PAT, not the Actions + # embedded credentials. It must be the App token, not the Actions # GITHUB_TOKEN: GITHUB_TOKEN-attributed pushes never trigger workflows, # so Version PR updates would get no CI / perry/review and the release # train (release-train.yaml) could merge on checks from the first - # revision. - - name: Configure git push credentials (PAT) + # revision. App-token pushes trigger workflows normally. + - name: Configure git push credentials (App token) + if: ${{ !(inputs.dry-run && inputs.mode == 'publish') }} run: | git remote set-url origin \ - "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${{ github.repository }}.git" + "https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/${{ github.repository }}.git" - name: Version PR or Publish (changesets) id: changesets if: > github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.mode == 'version') - # SHA-pinned (= v1.9.0): this step receives the cross-repo PAT and - # runs with it in .git/config, so a floating tag would let a - # compromised action release exfiltrate it. Bump deliberately. + # SHA-pinned (= v1.9.0): this step receives the cross-repo App token + # and runs with it in .git/config, so a floating tag would let a + # compromised action release exfiltrate it (bounded to the token's + # 1-hour lifetime, but still). Bump deliberately. uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d with: title: 'chore: version packages' @@ -148,12 +211,12 @@ jobs: version: pnpm run version publish: pnpm exec changeset publish --no-git-checks env: - # PAT, not the Actions GITHUB_TOKEN: PRs created with GITHUB_TOKEN - # never trigger other workflows, so the Version Packages PR would get - # no CI and no perry/review — and the release-train workflow - # (release-train.yaml) gates its auto-merge on exactly those checks. - # Same reasoning as the checkout token in bump-openrouter-sdk.yaml. - GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + # App token, not the Actions GITHUB_TOKEN: PRs created with + # GITHUB_TOKEN never trigger other workflows, so the Version Packages + # PR would get no CI and no perry/review — and the release-train + # workflow (release-train.yaml) gates its auto-merge on exactly those + # checks. Same reasoning as in bump-openrouter-sdk.yaml. + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} # No NODE_AUTH_TOKEN / NPM_TOKEN: auth comes from OIDC trusted # publishing. changesets/action logs "No NPM_TOKEN found, but OIDC is # available" and leaves .npmrc alone; npm then exchanges the Actions @@ -187,9 +250,11 @@ jobs: run: git push origin --tags # Nothing below pushes via git (the HOP dispatches use `gh api` with - # GH_TOKEN from env), so drop the PAT from .git/config the moment the + # the App token from env), so drop it from .git/config the moment the # last push consumer is done. `always()`: scrub even when a publish - # step failed, since later/rerun steps still see the workspace. + # step failed, since later/rerun steps still see the workspace. The + # token also expires on its own within the hour; this just narrows the + # in-job window. - name: Scrub git push credentials if: always() run: | @@ -208,6 +273,11 @@ jobs: if: github.event_name == 'workflow_dispatch' && inputs.mode == 'publish' && inputs.dry-run run: pnpm -r publish --dry-run --access public --no-git-checks + # Cross-repo token for the HOP B/C dispatches only. Minted separately + # from the publish token and best-effort (continue-on-error): the + # packages are already on npm when the dispatches run, so a repo missing + # from the App installation must degrade to a skipped notification with + # a warning — exactly like a failed dispatch — never a red release. # 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: @@ -241,19 +311,114 @@ jobs: echo "No new @openrouter/agent publish detected — no dispatch" fi + # Minted AFTER publish detection and gated on it: publish.yaml runs on + # every push to main, and most runs release nothing — minting (and + # potentially paging about) dispatch tokens on those runs would be + # noise. These steps only run when a version actually shipped. + # + # One narrowly-scoped token PER dispatch target, each best-effort. + # Not one shared mint: an explicit multi-repo list is all-or-nothing + # (one uninstalled port repo kills the unrelated monorepo notification + # too), and an owner-wide mint hands the job a token for every repo the + # App is installed on. Per-target mints get both properties — a missing + # target degrades only its own notification, and each token can touch + # exactly one repo. + - name: Mint dispatch token (openrouter-web) + id: web-token + if: ${{ !cancelled() && !inputs.dry-run && steps.published.outputs.version != '' }} + continue-on-error: true + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} + owner: OpenRouterTeam + repositories: openrouter-web + + - name: Mint dispatch token (python-agent) + id: py-token + if: ${{ !cancelled() && !inputs.dry-run && steps.published.outputs.version != '' }} + continue-on-error: true + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} + owner: OpenRouterTeam + repositories: python-agent + + - name: Mint dispatch token (go-agent) + id: go-token + if: ${{ !cancelled() && !inputs.dry-run && steps.published.outputs.version != '' }} + continue-on-error: true + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} + owner: OpenRouterTeam + repositories: go-agent + + # Degraded ≠ silent: annotations alone are invisible on a green + # scheduled run, so also page the release-alert channel (no-op until + # the CI_RELEASE_ALERT_* secrets exist, like the train's alerts). + - name: Warn about unavailable dispatch tokens + if: ${{ !cancelled() && !inputs.dry-run && steps.published.outputs.version != '' && (steps.web-token.outcome == 'failure' || steps.py-token.outcome == 'failure' || steps.go-token.outcome == 'failure') }} + env: + SLACK_BOT_TOKEN: ${{ secrets.CI_RELEASE_ALERT_SLACK_BOT_TOKEN }} + SLACK_CHANNEL_ID: ${{ secrets.CI_RELEASE_ALERT_SLACK_CHANNEL_ID }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + MISSING="" + [ "${{ steps.web-token.outcome }}" = "failure" ] && { echo "::warning::No dispatch token for openrouter-web — its HOP B notification will be skipped."; MISSING="$MISSING openrouter-web"; } + [ "${{ steps.py-token.outcome }}" = "failure" ] && { echo "::warning::No dispatch token for python-agent — its HOP C notification will be skipped."; MISSING="$MISSING python-agent"; } + [ "${{ steps.go-token.outcome }}" = "failure" ] && { echo "::warning::No dispatch token for go-agent — its HOP C notification will be skipped."; MISSING="$MISSING go-agent"; } + TEXT=":warning: Release publish: could not mint dispatch token(s) for${MISSING} (App not installed there?). Their release notifications were skipped — trigger downstream flows manually. <${RUN_URL}|run>" + if [ -n "${SLACK_BOT_TOKEN:-}" ] && [ -n "${SLACK_CHANNEL_ID:-}" ]; then + 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" + else + echo "(slack not configured; would have posted) $TEXT" + fi + + # continue-on-error for the same reason as HOP C: the packages are on + # npm by now, and a missing openrouter-web installation surfaces as a + # skipped mint / 404 — degrade with a warning, never a red release. - name: Dispatch monorepo bump - if: ${{ !inputs.dry-run && steps.published.outputs.version != '' }} + if: ${{ !inputs.dry-run && steps.published.outputs.version != '' && steps.web-token.outcome == 'success' }} + continue-on-error: true env: - # Cross-repo PAT; needs contents:write on - # OpenRouterTeam/openrouter-web so the repository_dispatch is accepted. - GH_TOKEN: ${{ secrets.GH_TOKEN }} + # openrouter-web-scoped App token; its contents:write is what makes + # the repository_dispatch accepted. + GH_TOKEN: ${{ steps.web-token.outputs.token }} + SLACK_BOT_TOKEN: ${{ secrets.CI_RELEASE_ALERT_SLACK_BOT_TOKEN }} + SLACK_CHANNEL_ID: ${{ secrets.CI_RELEASE_ALERT_SLACK_CHANNEL_ID }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} 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" + # if/else rather than a bare call: under continue-on-error a bare + # failure leaves a green job with no annotation — the warning + + # Slack page are what make the dropped notification visible (same + # pattern as the per-repo loop in the HOP C step). + if 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]=${RUN_URL}"; then + echo "Dispatched openrouter-agent-published (version ${{ steps.published.outputs.version }}) to openrouter-web" + else + echo "::warning::Failed to dispatch openrouter-agent-published to openrouter-web. Bump its pinned @openrouter/agent manually or re-dispatch from its own workflow." + TEXT=":warning: Release publish: @openrouter/agent ${{ steps.published.outputs.version }} shipped but the openrouter-web bump dispatch failed — bump it manually. <${RUN_URL}|run>" + if [ -n "${SLACK_BOT_TOKEN:-}" ] && [ -n "${SLACK_CHANNEL_ID:-}" ]; then + 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" + else + echo "(slack not configured; would have posted) $TEXT" + fi + fi # HOP C trigger: tell the Python and Go ports of @openrouter/agent that a # new version shipped, so each opens a PR porting the delta. Those repos run @@ -282,9 +447,12 @@ jobs: if: ${{ !cancelled() && !inputs.dry-run && steps.published.outputs.version != '' }} continue-on-error: true env: - # Same cross-repo PAT as HOP B; additionally needs contents:write on - # OpenRouterTeam/python-agent and OpenRouterTeam/go-agent. - GH_TOKEN: ${{ secrets.GH_TOKEN }} + # The tag lookup reads THIS repo, so it uses the typescript-agent- + # scoped publish token; each dispatch uses its own target-scoped + # token (empty when that repo's mint failed → skipped with warning). + GH_TOKEN: ${{ steps.app-token.outputs.token }} + PY_TOKEN: ${{ steps.py-token.outputs.token }} + GO_TOKEN: ${{ steps.go-token.outputs.token }} VERSION: ${{ steps.published.outputs.version }} SOURCE_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | @@ -304,18 +472,35 @@ jobs: # this repo ever goes private and the fallback would fire every time. # gh authenticates from GH_TOKEN in env, independent of .git/config. ENCODED_TAG="$(jq -rn --arg t "$TAG" '$t|@uri')" - if gh api "repos/${{ github.repository }}/git/ref/tags/${ENCODED_TAG}" >/dev/null 2>&1; then + # Distinguish "tag missing" (404) from auth/API failures (401 if the + # 1h App token expired on a very slow run, 5xx, network): both fall + # back to the commit SHA — same tree — but a triage-misleading + # "tag not on origin" message on a token failure would send someone + # hunting the wrong problem. + HTTP_STATUS="$(gh api "repos/${{ github.repository }}/git/ref/tags/${ENCODED_TAG}" >/dev/null 2>&1; echo $?)" + if [ "$HTTP_STATUS" = "0" ]; then REF="$TAG" else REF="${{ github.sha }}" - echo "::warning::${TAG} is not on origin; dispatching ref=${REF} instead." + if gh api rate_limit >/dev/null 2>&1; then + echo "::warning::${TAG} is not on origin; dispatching ref=${REF} instead." + else + echo "::warning::Could not query the tag (token expired or API failure — not necessarily a missing tag); dispatching ref=${REF} instead." + fi fi - for REPO in python-agent go-agent; do + for ENTRY in "python-agent:${PY_TOKEN}" "go-agent:${GO_TOKEN}"; do + REPO="${ENTRY%%:*}" + TOKEN="${ENTRY#*:}" + if [ -z "$TOKEN" ]; then + echo "::warning::No token for OpenRouterTeam/${REPO} (mint skipped/failed) — not dispatching. Trigger its Upstreamer Port workflow manually with ref=${REF}, or wait for its weekly cron." + FAILED="$FAILED ${REPO}" + continue + fi # -f (--raw-field) everywhere: -F treats values starting with `@` # (like TAG) as filenames to read, which would error out before the # request is even sent. - if gh api "repos/OpenRouterTeam/${REPO}/dispatches" \ + if GH_TOKEN="$TOKEN" gh api "repos/OpenRouterTeam/${REPO}/dispatches" \ -f event_type=openrouter-agent-published \ -f "client_payload[version]=${VERSION}" \ -f "client_payload[ref]=${REF}" \ diff --git a/.github/workflows/release-train.yaml b/.github/workflows/release-train.yaml index 79fa45e..d393dd2 100644 --- a/.github/workflows/release-train.yaml +++ b/.github/workflows/release-train.yaml @@ -52,10 +52,52 @@ jobs: steps: - uses: actions/checkout@v6 + # Short-lived App installation token (same App as publish.yaml / + # bump-openrouter-sdk.yaml). Replaces the old GH_TOKEN PAT, which the + # enterprise's 366-day-lifetime policy started rejecting — that outage + # surfaced as every train run failing at the first gh call. Only this + # repo is needed here: the train reads, gates, and merges locally. + # + # continue-on-error + the guard step below: a mint failure (missing + # var/secret, uninstalled App, token-API outage) must page like every + # other failure mode in this workflow — a bare failing first step would + # kill the run before any alerting exists, recreating exactly the + # silent 2026-08 PAT stall this migration is fixing. + - name: Mint release-bot token + id: app-token + continue-on-error: true + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} + owner: OpenRouterTeam + repositories: typescript-agent + + - name: Alert if token minting failed + if: steps.app-token.outcome != 'success' + env: + SLACK_BOT_TOKEN: ${{ secrets.CI_RELEASE_ALERT_SLACK_BOT_TOKEN }} + SLACK_CHANNEL_ID: ${{ secrets.CI_RELEASE_ALERT_SLACK_CHANNEL_ID }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + TEXT=":rotating_light: Release train: could not mint the release-bot App token (RELEASE_BOT_APP_ID/RELEASE_BOT_PRIVATE_KEY missing or App uninstalled?). Releases are stalled until this is fixed. <${RUN_URL}|run>" + echo "::error::App token minting failed — releases are stalled." + if [ -n "${SLACK_BOT_TOKEN:-}" ] && [ -n "${SLACK_CHANNEL_ID:-}" ]; then + 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" + else + echo "(slack not configured; would have posted) $TEXT" + fi + exit 1 + - name: Find Version Packages PR id: find env: - GH_TOKEN: ${{ secrets.GH_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} SLACK_BOT_TOKEN: ${{ secrets.CI_RELEASE_ALERT_SLACK_BOT_TOKEN }} SLACK_CHANNEL_ID: ${{ secrets.CI_RELEASE_ALERT_SLACK_CHANNEL_ID }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -175,7 +217,7 @@ jobs: id: scope if: steps.find.outputs.pr_number != '' && steps.find.outputs.held != 'true' env: - GH_TOKEN: ${{ secrets.GH_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} PR: ${{ steps.find.outputs.pr_number }} SLACK_BOT_TOKEN: ${{ secrets.CI_RELEASE_ALERT_SLACK_BOT_TOKEN }} SLACK_CHANNEL_ID: ${{ secrets.CI_RELEASE_ALERT_SLACK_CHANNEL_ID }} @@ -202,7 +244,7 @@ jobs: - name: Gate and merge Version Packages PR if: steps.find.outputs.pr_number != '' && steps.find.outputs.held != 'true' env: - GH_TOKEN: ${{ secrets.GH_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} PR: ${{ steps.find.outputs.pr_number }} # REPO comes from the workflow-level env block. # On schedule runs `inputs` is empty, so dry_run != true → AUTO_MERGE=true.