diff --git a/.github/upstream.json b/.github/upstream.json new file mode 100644 index 0000000..d765348 --- /dev/null +++ b/.github/upstream.json @@ -0,0 +1,6 @@ +{ + "$comment": "What this plugin has already been checked against. The release watcher compares its findings to these and does nothing when they match, so a missed dispatch self-heals on the next scheduled run instead of being lost. Advanced by the compatibility agent, in the same pull request as any code change it made.", + "bitrouter": { + "lastProcessedRelease": "v1.0.0-alpha.27" + } +} diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml new file mode 100644 index 0000000..90b8b3d --- /dev/null +++ b/.github/workflows/automerge.yml @@ -0,0 +1,24 @@ +name: Auto-merge + +# A pull request that passes its checks merges itself. The policy — what that +# excludes, and why — lives once, in bitrouter/.github. +# +# `pull_request_target` because a pull request from a fork gets a read-only +# token on `pull_request`, and enabling auto-merge is a write. Nothing here +# checks out the pull request's code or runs anything from it: the called +# workflow only reads the diff's file names through the API and calls +# `gh pr merge --auto`. The merging itself is GitHub's, gated on the required +# checks in this repository's branch protection. + +on: + pull_request_target: + types: [opened, reopened, synchronize, labeled, unlabeled, ready_for_review] + +permissions: {} + +jobs: + enable: + uses: bitrouter/.github/.github/workflows/plugin-automerge.yml@main + permissions: + contents: write + pull-requests: write diff --git a/.github/workflows/on-bitrouter-release.yml b/.github/workflows/on-bitrouter-release.yml new file mode 100644 index 0000000..14ca8d0 --- /dev/null +++ b/.github/workflows/on-bitrouter-release.yml @@ -0,0 +1,125 @@ +name: On BitRouter release + +# A BitRouter gateway release affects this plugin through one surface: the +# wire. `test/schema.test.ts` already watches the published `/v1/models` +# contract on a daily clock, so what a release adds is everything the schema +# cannot describe — an endpoint that moved, an auth flow that changed, a +# behaviour named in the notes rather than in a type. +# +# Two ways in, deliberately. `bitrouter/bitrouter` dispatches here the moment +# it publishes, which is fast; the schedule is the backstop that notices when +# a dispatch never arrived, because a token expiring quietly is exactly the +# kind of failure that a fast path does not report. Both converge on +# `.github/upstream.json`, so whichever gets there first, the other does +# nothing. + +on: + repository_dispatch: + types: [bitrouter-release] + schedule: + - cron: "0 7 * * 1" # Mondays, after the nightly jobs have had their say + workflow_dispatch: + inputs: + tag: + description: "Force a specific BitRouter tag (default: latest release)" + required: false + +permissions: {} + +jobs: + check: + name: is there a release we have not looked at + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + tag: ${{ steps.detect.outputs.tag }} + fresh: ${{ steps.detect.outputs.fresh }} + steps: + - uses: actions/checkout@v6 + with: { persist-credentials: false } + - id: detect + env: + GH_TOKEN: ${{ github.token }} + DISPATCHED: ${{ github.event.client_payload.tag }} + FORCED: ${{ github.event.inputs.tag }} + run: | + set -euo pipefail + SEEN=$(jq -r '.bitrouter.lastProcessedRelease' .github/upstream.json) + + TAG="${FORCED:-${DISPATCHED:-}}" + if [ -z "$TAG" ]; then + TAG=$(gh api repos/bitrouter/bitrouter/releases/latest --jq .tag_name 2>/dev/null || true) + fi + if [ -z "$TAG" ] || [ "$TAG" = "null" ]; then + echo "fresh=false" >> "$GITHUB_OUTPUT" + echo "no release found for bitrouter/bitrouter" + exit 0 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + if [ "$TAG" = "$SEEN" ]; then + echo "fresh=false" >> "$GITHUB_OUTPUT" + echo "already looked at $TAG" + exit 0 + fi + + # A pull request in any state for this tag's branch counts as + # handled: it covers the window while one is open for review, and it + # respects a release somebody deliberately closed without merging. + COUNT=$(gh pr list --state all --head "chore/bitrouter-$TAG" --json number --jq length) + if [ "$COUNT" != "0" ]; then + echo "fresh=false" >> "$GITHUB_OUTPUT" + echo "a pull request for chore/bitrouter-$TAG already exists" + exit 0 + fi + + echo "fresh=true" >> "$GITHUB_OUTPUT" + echo "new BitRouter release: $TAG (last looked at: $SEEN)" + + compat: + needs: check + if: needs.check.outputs.fresh == 'true' + uses: bitrouter/.github/.github/workflows/plugin-compat.yml@main + permissions: + contents: read + secrets: + agent_token: ${{ secrets.PLUGIN_AGENT_TOKEN }} + bitrouter_api_key: ${{ secrets.BITROUTER_API_KEY }} + with: + node_version: "20" + install: npm ci + build: npm run build + test: npm test + branch: chore/bitrouter-${{ needs.check.outputs.tag }} + title: "chore: check @bitrouter/opencode against BitRouter ${{ needs.check.outputs.tag }}" + reason: >- + BitRouter released ${{ needs.check.outputs.tag }}, and this plugin + talks to that gateway. + brief: | + Read the release notes first: + + gh release view ${{ needs.check.outputs.tag }} --repo bitrouter/bitrouter --json body --jq .body + + Most releases need no change here. This plugin is coupled to the + gateway's HTTP surface and nothing else — it ships no binary, pins no + gateway version, and downloads nothing. So act only on notes that + describe a change to something it actually uses: a `/v1/*` endpoint's + path, its response shape, its auth, or the reserved `bitrouter/auto` + slug and the preset resolution behind it. + + Do NOT act on a change to the routing policy engine, the TUI, config + file handling, telemetry, or anything else that never crosses the + wire this plugin speaks over. Those move constantly and none of them + reach this package. + + The response shape has its own watcher — `test/schema.test.ts` checks + the published `/v1/models` schema daily and fails on any field that + appears or disappears. If the notes describe a models-endpoint change, + expect that suite to have caught it already; say so rather than + duplicating the work. + + Whatever you conclude, set `.bitrouter.lastProcessedRelease` in + `.github/upstream.json` to "${{ needs.check.outputs.tag }}". Do this + even when nothing else changed — it is what records that this release + was examined, and what stops the watcher from asking again every week. diff --git a/.github/workflows/on-harness-update.yml b/.github/workflows/on-harness-update.yml new file mode 100644 index 0000000..3ebe80e --- /dev/null +++ b/.github/workflows/on-harness-update.yml @@ -0,0 +1,109 @@ +name: On harness update + +# Dependabot opens the bump; CI judges it; this runs only when CI said no. +# +# The old shape of this ran an agent on every harness bump to ask whether the +# plugin was still compatible. But that question already has a cheaper and +# more reliable answer: the build typechecks this package against the new +# harness types and the suite exercises the contracts underneath them. A green +# bump needs no opinion, and under auto-merge it merges itself. +# +# So the agent runs where the cheap answer runs out — on a bump that broke +# something — and its job is not to assess but to repair. It pushes onto the +# Dependabot branch rather than opening a second pull request, so the bump and +# the fix arrive as one reviewable thing instead of two incomplete ones. +# +# `workflow_run` rather than `pull_request_target`: it runs from the default +# branch, so the workflow definition and the secrets it sees are this +# repository's own and never the pull request's. + +on: + workflow_run: + workflows: [CI] + types: [completed] + +permissions: {} + +jobs: + identify: + name: is this a harness bump that failed + runs-on: ubuntu-latest + if: >- + github.event.workflow_run.conclusion == 'failure' && + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.actor.login == 'dependabot[bot]' + permissions: + contents: read + pull-requests: read + outputs: + pr: ${{ steps.pick.outputs.pr }} + title: ${{ steps.pick.outputs.title }} + steps: + - id: pick + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + HEAD: ${{ github.event.workflow_run.head_branch }} + run: | + set -euo pipefail + PR=$(gh pr list --repo "$REPO" --head "$HEAD" --state open --json number --jq '.[0].number // empty') + if [ -z "$PR" ]; then + echo "no open pull request for $HEAD — nothing to repair" + exit 0 + fi + + TITLE=$(gh pr view "$PR" --repo "$REPO" --json title --jq .title) + + # Only the harness. A vitest or typescript bump breaking the build is + # a real signal too, but it is not what this automation is for, and + # an agent let loose on a tooling upgrade tends to rewrite more than + # it repairs. + # + # Matched on the bumped package rather than on Dependabot's label: + # the label is configured per update entry and npm allows only one + # entry per directory, so every npm bump in this repository carries + # `harness-update` whether or not it is one. The package name is the + # thing that actually distinguishes them. + case "$TITLE" in + *"@opencode-ai/"*) ;; + *) echo "#$PR does not bump the harness (\"$TITLE\") — leaving it"; exit 0 ;; + esac + + echo "pr=$PR" >> "$GITHUB_OUTPUT" + echo "title=$TITLE" >> "$GITHUB_OUTPUT" + echo "repairing #$PR" + + repair: + needs: identify + if: needs.identify.outputs.pr != '' + uses: bitrouter/.github/.github/workflows/plugin-compat.yml@main + permissions: + contents: read + secrets: + agent_token: ${{ secrets.PLUGIN_AGENT_TOKEN }} + bitrouter_api_key: ${{ secrets.BITROUTER_API_KEY }} + with: + node_version: "20" + install: npm ci + build: npm run build + test: npm test + pull_request: ${{ needs.identify.outputs.pr }} + reason: >- + Dependabot bumped the opencode harness ("${{ needs.identify.outputs.title }}") + and CI failed on the bump. + brief: | + The dependency is already bumped in this checkout — that part is + Dependabot's and is not yours to change. Do not touch `package.json` + or the lockfile, and do not pin the harness back to an older version + to make the failure go away. Reverting the bump is not a repair; if + the new version genuinely cannot be supported yet, change nothing and + say so in your report. + + Run the commands above and read the actual failure before changing + anything. The usual cause is a moved or renamed export, a changed type + signature, or a service contract this plugin implements that gained or + lost a member. Adapt this package to the new shape. + + Where the harness changed behaviour rather than types, be careful: a + change that still compiles can still be wrong. If the right adaptation + depends on intent you cannot read off the diff, leave it and flag it.