Upstreamer Port #2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Upstreamer Port | |
| # Ports @openrouter/agent into this repo. | |
| # | |
| # The port tracks upstream's **default-branch HEAD**, not the latest published npm | |
| # release. Release tracking sounds safer but produces exactly the failure this | |
| # pipeline exists to prevent: upstream can sit for weeks with large unreleased | |
| # work on main (doom-loop detection, #73, was ~7.5k lines) and the port stays | |
| # blind to it, then absorbs the whole delta in one automated run touching the most | |
| # load-bearing modules. Tracking HEAD keeps each delta small enough to review. | |
| # | |
| # Consequence to keep in mind: the port is then routinely AHEAD of the latest | |
| # release, so its declared version legitimately lags upstream's package.json. The | |
| # verifier reports that rather than failing, and publishing is gated on it — see | |
| # the Package Version section of .upstreamer/upstreamer.md. | |
| # | |
| # Three triggers, all resolving to HEAD unless given an explicit ref: | |
| # 1. Weekly cron — the primary path now that releases are not the trigger. | |
| # 2. repository_dispatch from typescript-agent's publish.yaml on a new npm | |
| # release. Still useful as a "something just shipped, sync promptly" nudge, | |
| # but it no longer pins the ref to that release tag: doing so would port | |
| # BACKWARDS once the port is ahead of the release. scripts/upstream refuses | |
| # an ancestor ref outright. | |
| # 3. Manual dispatch, optionally with an explicit ref. | |
| # | |
| # Opens a PR. Never pushes to main. A failed parity eval leaves | |
| # .upstreamer/state.yaml unchanged, so the next run retries the same delta. | |
| on: | |
| repository_dispatch: | |
| types: [openrouter-agent-published] | |
| schedule: | |
| - cron: "23 6 * * 1" | |
| workflow_dispatch: | |
| inputs: | |
| ref: | |
| description: "Upstream ref to port (blank = upstream default branch HEAD)" | |
| required: false | |
| type: string | |
| force: | |
| description: "Re-run even if the upstream commit is unchanged" | |
| required: false | |
| default: false | |
| type: boolean | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| actions: write # to dispatch ci.yaml onto the generated PR branch | |
| concurrency: | |
| group: upstreamer-port | |
| cancel-in-progress: false | |
| jobs: | |
| port: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 150 | |
| env: | |
| # Surfaced as env because the `secrets` context is NOT available in a | |
| # step-level `if:` (only github/needs/strategy/matrix/job/runner/env/vars/ | |
| # steps/inputs are). Referencing secrets.* there evaluates to empty and the | |
| # condition silently never matches — so the App-token gate below tests this | |
| # variable instead. Only ever compared against '' ; never echoed. | |
| HAS_APP_KEY: ${{ secrets.PORT_BOT_PRIVATE_KEY != '' }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - uses: oven-sh/setup-bun@v2 | |
| - name: Install opencode | |
| run: bun install -g opencode-ai | |
| - name: Set up language toolchain | |
| uses: ./.github/actions/port-toolchain | |
| # Blank ref = upstream default-branch HEAD, which scripts/upstream resolves | |
| # itself. That is the normal case for both the cron and a publish dispatch. | |
| # | |
| # Only an EXPLICIT manual `ref` input is honored. The publish dispatch's | |
| # client_payload.ref is deliberately ignored: it carries the release tag, | |
| # which is an ancestor of HEAD once the port is ahead of the release, so | |
| # honoring it would revert landed work. The dispatch still does its real | |
| # job — waking the pipeline promptly after a release — it just syncs to HEAD | |
| # like every other trigger. (scripts/upstream also refuses an ancestor ref | |
| # outright, so this is defense in depth, not the only guard.) | |
| - name: Resolve target ref | |
| id: target | |
| run: | | |
| set -euo pipefail | |
| REF="${{ inputs.ref }}" | |
| if [ -n "$REF" ]; then | |
| echo "Explicit ref requested: $REF" | |
| else | |
| PAYLOAD_REF="${{ github.event.client_payload.ref }}" | |
| if [ -n "$PAYLOAD_REF" ]; then | |
| echo "::notice::Ignoring dispatch payload ref '$PAYLOAD_REF' — this port tracks upstream HEAD, and a release tag is an ancestor once the port is ahead of it. Syncing to HEAD instead." | |
| fi | |
| echo "No explicit ref — porting upstream default-branch HEAD." | |
| fi | |
| echo "ref=$REF" >> "$GITHUB_OUTPUT" | |
| - name: Run port | |
| env: | |
| # Provide these in repo settings: | |
| # Secret OPENROUTER_API_KEY — sk-or-... key opencode uses for inference | |
| # Variable OPENCODE_MODEL — e.g. openrouter/~anthropic/claude-opus-latest | |
| OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} | |
| OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }} | |
| UPSTREAMER_TIMEOUT_SECONDS: 7200 | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${OPENROUTER_API_KEY:-}" ]; then | |
| echo "::error::OPENROUTER_API_KEY secret is not set. See .upstreamer/port.env.example." | |
| exit 1 | |
| fi | |
| # Only pass --ref when there is actually a ref. `--ref ""` is not the | |
| # same as omitting it: the arg parser consumes the empty value and the | |
| # script would target an empty ref instead of defaulting to HEAD. | |
| args=() | |
| REF="${{ steps.target.outputs.ref }}" | |
| [ -n "$REF" ] && args+=(--ref "$REF") | |
| [ "${{ inputs.force }}" = "true" ] && args+=(--force) | |
| ./scripts/upstream ${args[@]+"${args[@]}"} | |
| - name: Check for changes | |
| id: diff | |
| run: | | |
| if [ -n "$(git status --porcelain -- . ':!tmp')" ]; then | |
| echo "changed=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "changed=false" >> "$GITHUB_OUTPUT" | |
| echo "No changes — upstream unchanged or port was a no-op." | |
| fi | |
| # State only advances when the verifier AND the parity eval passed, so an | |
| # unchanged state file next to a changed tree means the port did not pass. | |
| # Label the PR accordingly instead of letting it look green. | |
| - name: Detect eval failure | |
| if: steps.diff.outputs.changed == 'true' | |
| id: gate | |
| run: | | |
| if git diff --quiet -- .upstreamer/state.yaml; then | |
| echo "passed=false" >> "$GITHUB_OUTPUT" | |
| echo "::warning::state.yaml did not advance — parity eval did not pass. See .upstreamer/eval-report.md." | |
| else | |
| echo "passed=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| # Mint a GitHub App installation token so the PR is created by the App | |
| # rather than by the native GITHUB_TOKEN. | |
| # | |
| # Why this exists: GitHub does not trigger workflows from events created | |
| # with GITHUB_TOKEN (recursion guard), so a PR opened with it gets no | |
| # pull_request-event checks — and `main`'s required status checks are | |
| # satisfied ONLY by pull_request-event runs. Measured on PR #24: the commit | |
| # had 14 check-runs, the PR's rollup showed 7; the workflow_dispatch half was | |
| # invisible to branch protection. So the previous "dispatch ci.yaml | |
| # explicitly" workaround produced green runs that could never satisfy the | |
| # required checks, leaving an automated port PR permanently unmergeable. | |
| # | |
| # An App installation token is not recursion-guarded, so the PR gets real | |
| # pull_request checks. Preferred over a PAT: scoped to this repo, not tied to | |
| # a person's account, and independently revocable. | |
| # | |
| # Optional by design — see the fallback below. | |
| - name: Mint App token | |
| id: app-token | |
| # Gate on BOTH halves. Gating on the App ID alone is a trap: the ID is a | |
| # variable and the key is a secret, so they are added in separate places | |
| # and one routinely lands first (it did here — the ID arrived first). With | |
| # only the ID set, this step would run and fail on the missing key, turning | |
| # a working fallback into a broken pipeline — worse than no App at all. | |
| if: >- | |
| steps.diff.outputs.changed == 'true' | |
| && vars.PORT_BOT_APP_ID != '' | |
| && env.HAS_APP_KEY == 'true' | |
| uses: actions/create-github-app-token@v1 | |
| with: | |
| app-id: ${{ vars.PORT_BOT_APP_ID }} | |
| private-key: ${{ secrets.PORT_BOT_PRIVATE_KEY }} | |
| - name: Open PR | |
| id: open-pr | |
| if: steps.diff.outputs.changed == 'true' | |
| uses: peter-evans/create-pull-request@v6 | |
| with: | |
| # App token when configured; GITHUB_TOKEN otherwise. With the fallback | |
| # the PR still opens, but its checks will not attach — the guard step | |
| # below says so loudly rather than leaving a silently stuck PR. | |
| token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} | |
| branch: upstreamer/sync | |
| delete-branch: true | |
| title: >- | |
| ${{ steps.gate.outputs.passed == 'true' | |
| && 'port: sync with @openrouter/agent upstream' | |
| || 'port: sync with @openrouter/agent upstream (EVAL FAILED — do not merge)' }} | |
| commit-message: "port: sync with @openrouter/agent upstream" | |
| labels: >- | |
| ${{ steps.gate.outputs.passed == 'true' | |
| && 'upstreamer, automated' | |
| || 'upstreamer, automated, eval-failed' }} | |
| body: | | |
| Automated Upstreamer port of `@openrouter/agent` into this repo. | |
| - Contract: `.upstreamer/upstreamer.md` | |
| - Run log: `.upstreamer/logs/` | |
| - Parity eval: `.upstreamer/eval-report.md` | |
| - Parity eval passed: **${{ steps.gate.outputs.passed }}** | |
| Review the diff as a port, not as a normal PR: check behavioral parity | |
| against the TypeScript reference, not just that it compiles. If | |
| `.upstreamer/state.yaml` did not advance, the eval did not pass and this | |
| PR must not be merged as-is. | |
| # Fallback path only. With the App configured, the PR above already has real | |
| # pull_request checks and nothing here runs. | |
| # | |
| # Without it, the PR exists but can never satisfy `main`'s required checks. | |
| # Dispatching ci.yaml still gives a human something to read, but the run does | |
| # NOT attach to the PR — so say that plainly instead of leaving a green-looking | |
| # PR that will not merge and no explanation of why. | |
| - name: Trigger CI on the port PR (no App token — checks will not attach) | |
| if: >- | |
| steps.diff.outputs.changed == 'true' | |
| && steps.open-pr.outputs.pull-request-operation != 'none' | |
| && steps.app-token.outputs.token == '' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| echo "::warning::PORT_BOT_APP_ID / PORT_BOT_PRIVATE_KEY are not configured, so this PR was opened with GITHUB_TOKEN and will receive NO pull_request-event checks. main's required status checks cannot be satisfied, so the PR cannot merge as-is. The dispatched run below is informational only. Configure the App (see PORTING.md) or close and reopen the PR by hand to generate real checks." | |
| gh workflow run ci.yaml --repo "$GITHUB_REPOSITORY" --ref upstreamer/sync | |
| - name: Upload logs | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: upstreamer-logs | |
| path: .upstreamer/logs/ | |
| if-no-files-found: ignore |