From 4977e66427a3cd22c1d495658f2acfc21b55a66e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 12 Oct 2026 14:41:34 -0400 Subject: [PATCH 1/2] feat(release): publish OpenCode 2 GitHub Releases without taking Latest --- .github/workflows/notify-discord.yml | 2 + .github/workflows/publish.yml | 23 ++++- docs/release/v1-v2-isolation.md | 147 +++++++++++++++++++++++++++ script/check-github-latest.ts | 25 +++++ script/publish.ts | 18 ++++ services/update/README.md | 10 ++ services/update/src/index.test.ts | 88 ++++++++++++++++ services/update/src/index.ts | 2 + 8 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 docs/release/v1-v2-isolation.md create mode 100755 script/check-github-latest.ts create mode 100644 services/update/src/index.test.ts diff --git a/.github/workflows/notify-discord.yml b/.github/workflows/notify-discord.yml index 0b2b1cde051b..073673f51dbf 100644 --- a/.github/workflows/notify-discord.yml +++ b/.github/workflows/notify-discord.yml @@ -6,6 +6,8 @@ on: jobs: notify: + # V2 releases are announced by the publish workflow's notify-discord-v2 job. + if: startsWith(github.event.release.tag_name, 'v1.') runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Send nicely-formatted embed to Discord diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index eb6e1c3b2b38..e9e89a6acd4b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -644,6 +644,7 @@ jobs: OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_RELEASE: ${{ needs.version.outputs.release }} OPENCODE_RELEASE_NOTES: ${{ (github.ref_name == 'dev' && inputs.release_notes) || '' }} + OPENCODE_GITHUB_RELEASE: ${{ (github.ref_name == 'dev' && vars.OPENCODE_V2_GITHUB_RELEASES) || '' }} AUR_KEY: ${{ secrets.AUR_KEY }} GITHUB_TOKEN: ${{ steps.committer.outputs.token }} GH_REPO: ${{ needs.version.outputs.repo }} @@ -655,6 +656,24 @@ jobs: CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + verify-github-latest: + needs: + - version + - publish + if: ${{ !cancelled() && github.repository == 'anomalyco/opencode' && github.ref_name == 'dev' && needs.version.outputs.release && needs.publish.result == 'success' }} + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + + - uses: ./.github/actions/setup-bun + + # OpenCode 1 clients resolve updates through GitHub's Latest release, which must stay on 1.x. + - name: Verify GitHub Latest release is still V1 + run: ./script/check-github-latest.ts + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + notify-discord-v2: needs: - version @@ -662,11 +681,11 @@ jobs: if: ${{ !cancelled() && github.repository == 'anomalyco/opencode' && github.ref_name == 'dev' && needs.version.outputs.release && needs.publish.result == 'success' }} runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - # Unlike dev, V2 publishes a tag rather than a GitHub Release event. + # notify-discord.yml only announces V1 releases, so V2 is announced once, here. - name: Announce V2 release in Discord uses: SethCohen/github-releases-to-discord@24d166886aee4646d448c8a389ff9e1ebcab3682 # v1.20.0 with: webhook_url: ${{ secrets.DISCORD_WEBHOOK }} release_name: OpenCode V2 ${{ needs.version.outputs.tag }} release_body: ${{ inputs.release_notes }} - release_html_url: https://github.com/${{ github.repository }}/tree/${{ needs.version.outputs.tag }} + release_html_url: https://github.com/${{ github.repository }}/${{ (vars.OPENCODE_V2_GITHUB_RELEASES == 'true' && 'releases/tag') || 'tree' }}/${{ needs.version.outputs.tag }} diff --git a/docs/release/v1-v2-isolation.md b/docs/release/v1-v2-isolation.md new file mode 100644 index 000000000000..f0ab86dc8f6e --- /dev/null +++ b/docs/release/v1-v2-isolation.md @@ -0,0 +1,147 @@ +# OpenCode 1 / OpenCode 2 release isolation + +OpenCode 2 releases are published as GitHub Releases in `anomalyco/opencode`, the same repository as +OpenCode 1. OpenCode 1 clients must keep discovering only 1.x releases. Moving to OpenCode 2 stays an +explicit step (`opencode upgrade --major` or the OpenCode 2.0 offer in `/update`). + +## How OpenCode 1 finds releases + +Every shipped OpenCode 1 path that reads GitHub uses the release marked **Latest**, not the newest release: + +| Consumer | Source | +| ------------------------------------------------------------ | ------------------------------------------------------------------------ | +| CLI, curl / yarn / unknown installs (1.0.204 through 1.19.0) | `api.github.com/repos/anomalyco/opencode/releases/latest` | +| `opencode.ai/install` (redirects to the `v1` branch file) | `releases/latest` API and `releases/latest/download/` | +| Electron desktop (1.2.17+) | electron-updater GitHub provider, `allowPrerelease: false` → Latest | +| Tauri desktop (before 1.2.17) | `releases/latest/download/latest.json` | +| GitHub Action (`github/action.yml`) | `releases/latest` API, then `opencode.ai/install` | +| Scoop main bucket | `checkver: github` → Latest | +| npm / bun / pnpm installs, Homebrew tap | `opencode-ai` dist-tags and `anomalyco/tap/opencode`, which are 1.x only | + +So, as long as Latest stays on a 1.x release, publishing OpenCode 2 GitHub Releases does not reach +OpenCode 1 clients. Two defaults would break that: `gh release create` without `--latest` uses +"automatic" (the highest version wins), and publishing a draft through the API defaults +`make_latest` to `true`. OpenCode 2 releases therefore always pass `--latest=false`. + +### Verified behaviour + +Checked on 2026-10-12 in the private scratch repository `thdxr/latest-flag-test`: + +| Step | `GET /releases/latest` | +| ----------------------------------------------------------------------- | ---------------------- | +| `v1.0.0` created as Latest, then `v2.0.0` created with `--latest=false` | `v1.0.0` | +| `gh release edit v2.0.0 --latest=false` | `v1.0.0` | +| `v2.3.0` created with `--verify-tag --latest=false` | `v1.0.1` | +| draft `v2.1.0` published with `gh release edit --draft=false` (no flag) | **`v2.1.0`** | +| `v2.2.0` created with `gh release create` (no flag) | **`v2.2.0`** | +| draft `v1.0.1` published with `gh release edit --draft=false --latest` | `v1.0.1` | + +GraphQL `isLatest` agreed in every case. The web redirects (`github.com//releases/latest` and +`releases/latest/download/`) need a browser session on a private repository, so they were checked +on public repositories whose newest release is not Latest (for example `envoyproxy/envoy`, where +`v1.36.13` is newer than the Latest `v1.39.3`). Both redirects, and the JSON response electron-updater +requests, resolved to the Latest release. + +## Changes + +- **OpenCode 2 (`dev`):** + - `script/publish.ts` creates or edits the GitHub Release for each stable tag with + `--latest=false`, using the reviewed release notes. It attaches no assets. + - This only runs when the repository variable `OPENCODE_V2_GITHUB_RELEASES` is `true`. + - The `verify-github-latest` job in `publish.yml` runs `script/check-github-latest.ts`, which fails + unless Latest is still a `v1.` release. + - `notify-discord.yml` only announces `v1.` releases; the publish workflow announces OpenCode 2. + - The update service accepts `refs/heads/v1` publishes, only to the `v1` channel. +- **OpenCode 1 (`v1`):** + - The CLI discovers curl, yarn and unknown installs through `opencode.ai/update/api/v1/cli/github`, + with a 10s timeout, and falls back to the `opencode-ai` npm dist-tag. It never reads GitHub's Latest. + - Every install method ignores versions outside the installed major version, and only newer + versions count as updates. OpenCode 2 is still offered through the existing major upgrade. + - The installer reads the `v1` channel, falls back to the newest `v1.*` GitHub release, and refuses + `VERSION` / `--version` 2.x or later. + - The OpenCode 1 publish keeps Latest with `--latest` and publishes `github`, `npm` and + `homebrew` artifacts to the `v1` channel. + +## Rollout + +Each step needs approval; none of them is automatic. + +1. **Verify `--latest=false` behaviour.** Done (see above). +2. **Merge the OpenCode 2 PR.** It stays inert for releases until the variable is set in step 6. The + verification job runs on every stable OpenCode 2 release from then on. +3. **Deploy the update service.** Push `dev` to `production` (`deploy-update.yml`), or run + `bun run deploy` in `services/update`. No migration is needed. +4. **Seed the `v1` channel with 1.19.0** so the installer has a release before 1.19.1 ships. The + publish endpoint only accepts OIDC tokens from the publish workflow, so seed through D1: + + ```sh + cd services/update + bunx wrangler d1 execute opencode-update --remote --command " + INSERT INTO artifact (channel, name, distribution, version, metadata, active, minimum, time_created, time_updated) + VALUES + ('v1', 'cli', 'github', '1.19.0', '{\"tag\":\"v1.19.0\"}', 1, 0, strftime('%s','now') * 1000, strftime('%s','now') * 1000), + ('v1', 'cli', 'npm', '1.19.0', '{\"package\":\"opencode-ai\"}', 1, 0, strftime('%s','now') * 1000, strftime('%s','now') * 1000), + ('v1', 'cli', 'homebrew', '1.19.0', '{\"package\":\"anomalyco/tap/opencode\"}', 1, 0, strftime('%s','now') * 1000, strftime('%s','now') * 1000)" + curl -s https://opencode.ai/update/api/v1/cli/github + ``` + +5. **Merge the OpenCode 1 PR and release 1.19.1.** Merging changes `opencode.ai/install` right away, + because it redirects to the `v1` branch file. Release with the publish workflow on `v1` (bump + `patch`). Check that: + - the job publishes to the `v1` channel; + - `curl -s https://opencode.ai/update/api/v1/cli/github` returns 1.19.1; + - `gh api repos/anomalyco/opencode/releases/latest --jq .tag_name` returns `v1.19.1`. +6. **Start publishing OpenCode 2 GitHub Releases:** + `gh variable set OPENCODE_V2_GITHUB_RELEASES --repo anomalyco/opencode --body true`. On the next + stable OpenCode 2 release, confirm the release exists, is not Latest, and that `verify-github-latest` + passed. + - Optionally backfill existing tags without moving Latest: + `gh release create v2.0.27 --repo anomalyco/opencode --verify-tag --latest=false --notes-file `. + - Run `./script/check-github-latest.ts` afterwards. + +## Flip criteria + +Keep Latest on 1.x until all of these are true: + +- Few clients older than 1.19.1 remain on curl, yarn or unknown installs. Those clients still read + GitHub's Latest. Clients before 1.19.0 only show a notice for a major release, but `opencode upgrade` + would then try to install it. 1.19.0 shows it as a normal update in `/update`. +- `opencode.ai/install` and the GitHub Action are updated to install OpenCode 2 (or pin OpenCode 1). +- The Scoop main bucket is coordinated. Its `opencode` manifest tracks GitHub Latest, and its + autoupdate URLs expect V1 asset names. +- OpenCode 1 desktop users are handled deliberately. After the flip, electron-updater fetches + `latest*.yml` from the OpenCode 2 release. Those files are absent, so update checks fail rather than + install anything. + +Flip with: + +```sh +gh release edit v2.. --repo anomalyco/opencode --latest +``` + +At the same time: + +- change OpenCode 1's `script/publish.ts` to `--latest=false`, so later 1.x patches don't take Latest back; +- remove or invert the `verify-github-latest` job in `dev`. + +## Caveats + +- **Homebrew Core already ships OpenCode 2.** `formulae.brew.sh` reports `opencode` 2.0.25, so + OpenCode 1.19.0 and earlier, installed from Homebrew Core, show it as a normal update. From 1.19.1, + OpenCode 1 ignores it and offers OpenCode 2 through the major upgrade instead. `brew upgrade` still + installs whatever Homebrew Core ships, independently of OpenCode. +- **Chocolatey** is community maintained. If its maintainer tracks GitHub Latest it stays on 1.x until + the flip; OpenCode 1.19.1 and later ignore 2.x versions from its feed either way. +- **Clients before 1.0.204** use the `opencode-ai` npm dist-tags for every install method except + Homebrew Core. Those dist-tags stay on 1.x because OpenCode 2 is published as `@opencode/cli`. + +## Rollback + +- **OpenCode 2 GitHub Releases:** `gh variable delete OPENCODE_V2_GITHUB_RELEASES --repo anomalyco/opencode` + stops new releases. Existing releases can stay, since they are not Latest. If Latest ever moves, + `./script/check-github-latest.ts` prints the exact `gh release edit --latest` command. +- **Update service:** OpenCode 1 clients fall back to npm when the `v1` channel fails, and the installer + falls back to GitHub. To withdraw a bad `v1` release, activate the previous one on the admin page + (`opencode.ai/update/admin`). +- **OpenCode 1 client:** revert the PR on `v1`, which also restores the previous `opencode.ai/install`, + and release a new patch. diff --git a/script/check-github-latest.ts b/script/check-github-latest.ts new file mode 100755 index 000000000000..17c1f99c2110 --- /dev/null +++ b/script/check-github-latest.ts @@ -0,0 +1,25 @@ +#!/usr/bin/env bun + +import { $ } from "bun" + +// OpenCode 1 clients, its installer, desktop updater, and GitHub Action resolve updates through GitHub's +// Latest release. Until the V1 → V2 flip, Latest must stay on a 1.x release. +const repo = process.env["GH_REPO"] || "anomalyco/opencode" +const latest = (await $`gh api repos/${repo}/releases/latest --jq .tag_name`.text()).trim() + +if (/^v1\./.test(latest)) { + console.log(`GitHub Latest release for ${repo} is ${latest}`) + process.exit(0) +} + +const v1 = ( + await $`gh api repos/${repo}/releases --paginate --jq ${".[] | select(.draft | not) | select(.prerelease | not) | .tag_name"}`.text() +) + .split("\n") + .filter((tag) => /^v1\.\d+\.\d+$/.test(tag)) + .sort((a, b) => Bun.semver.order(b.slice(1), a.slice(1)))[0] + +console.error(`::error::GitHub Latest release for ${repo} is ${latest}, but it must stay on a v1.x release.`) +console.error("OpenCode 1 clients would offer this release as a normal update. Restore the newest V1 release:") +console.error(` gh release edit ${v1 ?? ""} --repo ${repo} --latest`) +process.exit(1) diff --git a/script/publish.ts b/script/publish.ts index 4944ff45cca8..719d41c8125d 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -2,6 +2,8 @@ import { Script } from "@opencode/script" import { $ } from "bun" +import os from "os" +import path from "path" import { fileURLToPath } from "url" console.log("=== publishing ===\n") @@ -116,3 +118,19 @@ if (Script.release) { console.log("\n=== desktop ===\n") await $`bun ./packages/desktop/scripts/publish.ts` } + +// Gated by the OPENCODE_V2_GITHUB_RELEASES repository variable so the V1/V2 rollout controls when this starts. +// See docs/release/v1-v2-isolation.md. +if (Script.release && !Script.preview && process.env["OPENCODE_GITHUB_RELEASE"] === "true") { + console.log("\n=== github release ===\n") + // OpenCode 1 clients and installers resolve updates through GitHub's Latest release, so V2 releases + // never take that flag and carry no assets. The publish workflow verifies Latest afterwards. + const notes = process.env["OPENCODE_RELEASE_NOTES"]?.trim() ?? "" + const file = path.join(os.tmpdir(), `opencode-release-notes-${tag}.md`) + await Bun.write(file, notes) + if ((await $`gh release view ${tag} --json tagName`.quiet().nothrow()).exitCode === 0) { + await $`gh release edit ${tag} --title ${tag} --draft=false --prerelease=false --latest=false ${notes ? ["--notes-file", file] : []}` + } else { + await $`gh release create ${tag} --verify-tag --title ${tag} --notes-file ${file} --latest=false` + } +} diff --git a/services/update/README.md b/services/update/README.md index b81affab8cc1..2df5bf5a9b97 100644 --- a/services/update/README.md +++ b/services/update/README.md @@ -123,6 +123,16 @@ byte size. Desktop records additionally contain `metadata.manifests`, with the CDN URLs and the original Electron update checksums. Existing `npm`, `github`, and `aur` distributions are published independently. +OpenCode 1 publishes only from `refs/heads/v1`, and only to the `v1` channel, so OpenCode 1 +clients never resolve an OpenCode 2 release. OpenCode 2 refs cannot publish to `v1`. OpenCode 1 +sends the same `opencode/latest//cli` User-Agent shape as OpenCode 2, so `v1` requests +are treated as cross-channel and do not apply minimum releases. See +[`docs/release/v1-v2-isolation.md`](../../docs/release/v1-v2-isolation.md). + +```sh +curl 'https://opencode.ai/update/api/v1/cli/github' +``` + GitHub Actions publishes artifacts through `POST https://opencode.ai/update/api/publish` using a short-lived OIDC token with audience `https://update.opencode.ai`. The original hostname and token audience remain supported. The Worker accepts only tokens signed by diff --git a/services/update/src/index.test.ts b/services/update/src/index.test.ts new file mode 100644 index 000000000000..e42657a35d18 --- /dev/null +++ b/services/update/src/index.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test" +import worker, { channelsForRef, validGitHubClaims } from "./index" + +const claims = (ref: string) => ({ + repository: "anomalyco/opencode", + repository_id: "975734319", + repository_owner_id: "66570915", + workflow_ref: `anomalyco/opencode/.github/workflows/publish.yml@${ref}`, + ref, + sha: "sha", + run_id: "1", + run_attempt: "1", + actor: "opencode", +}) + +describe("channelsForRef", () => { + test("OpenCode 1 publishes only to the v1 channel", () => { + expect(channelsForRef("refs/heads/v1")).toEqual(["v1"]) + }) + + test("OpenCode 2 refs never publish to the v1 channel", () => { + for (const ref of [ + "refs/heads/dev", + "refs/heads/v2", + "refs/heads/beta", + "refs/heads/ci", + "refs/heads/snapshot-v1", + ]) { + expect(channelsForRef(ref)).not.toContain("v1") + } + }) + + test("unknown refs publish nowhere", () => { + expect(channelsForRef("refs/heads/v1-hotfix")).toEqual([]) + expect(channelsForRef("refs/tags/v1.19.0")).toEqual([]) + }) +}) + +describe("validGitHubClaims", () => { + test("accepts the v1 publish workflow", () => { + expect(validGitHubClaims(claims("refs/heads/v1"))).toBe(true) + }) + + test("rejects a workflow from another ref", () => { + expect(validGitHubClaims({ ...claims("refs/heads/v1"), workflow_ref: claims("refs/heads/dev").workflow_ref })).toBe( + false, + ) + }) +}) + +const rows = [ + { channel: "latest", name: "cli", distribution: "github", version: "2.0.27" }, + { channel: "v1", name: "cli", distribution: "github", version: "1.19.0" }, +].map((row) => ({ ...row, metadata: "{}", active: 1, minimum: 0, time_created: 0, time_updated: 0 })) + +// Answers the artifact lookups the public API makes, keyed by their bound parameters. +const db = { + prepare: () => ({ + bind: (...params: unknown[]) => ({ + first: async () => null, + all: async () => ({ + results: rows.filter((row) => + [row.channel, row.name, row.distribution].every((v, i) => params[i] === undefined || params[i] === v), + ), + }), + }), + }), +} + +const request = (path: string) => + worker.fetch( + new Request(`https://opencode.ai/update${path}`, { headers: { "User-Agent": "opencode/latest/1.18.0/cli" } }), + { DB: db as unknown as D1Database, EVENTS: { send: async () => {} } as never }, + { waitUntil: () => {} }, + ) + +describe("v1 channel", () => { + test("serves OpenCode 1 releases to OpenCode 1 clients", async () => { + const response = await request("/api/v1/cli/github") + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ channel: "v1", version: "1.19.0" }) + }) + + test("is separate from the OpenCode 2 latest channel", async () => { + const response = await request("/api/latest/cli/github") + expect(await response.json()).toMatchObject({ channel: "latest", version: "2.0.27" }) + }) +}) diff --git a/services/update/src/index.ts b/services/update/src/index.ts index 234c53554de9..ef55b50dc900 100644 --- a/services/update/src/index.ts +++ b/services/update/src/index.ts @@ -518,6 +518,8 @@ export function validGitHubClaims(claims: JWTPayload): claims is GitHubClaims { } export function channelsForRef(ref: string) { + // OpenCode 1 publishes only to its own channel so its clients never resolve OpenCode 2 releases. + if (ref === "refs/heads/v1") return ["v1"] if (ref === "refs/heads/dev") return ["dev", "latest"] if (ref === "refs/heads/v2") return ["dev", "latest"] if (ref === "refs/heads/beta") return ["beta"] From 893829c43576c03e5ce9f8b1581513c20fdb97d3 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 12 Oct 2026 14:42:40 -0400 Subject: [PATCH 2/2] docs(release): link the isolation PRs --- docs/release/v1-v2-isolation.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/release/v1-v2-isolation.md b/docs/release/v1-v2-isolation.md index f0ab86dc8f6e..433770b5cbcf 100644 --- a/docs/release/v1-v2-isolation.md +++ b/docs/release/v1-v2-isolation.md @@ -44,7 +44,7 @@ requests, resolved to the Latest release. ## Changes -- **OpenCode 2 (`dev`):** +- **OpenCode 2 (`dev`, #54903):** - `script/publish.ts` creates or edits the GitHub Release for each stable tag with `--latest=false`, using the reviewed release notes. It attaches no assets. - This only runs when the repository variable `OPENCODE_V2_GITHUB_RELEASES` is `true`. @@ -52,7 +52,7 @@ requests, resolved to the Latest release. unless Latest is still a `v1.` release. - `notify-discord.yml` only announces `v1.` releases; the publish workflow announces OpenCode 2. - The update service accepts `refs/heads/v1` publishes, only to the `v1` channel. -- **OpenCode 1 (`v1`):** +- **OpenCode 1 (`v1`, #54904):** - The CLI discovers curl, yarn and unknown installs through `opencode.ai/update/api/v1/cli/github`, with a 10s timeout, and falls back to the `opencode-ai` npm dist-tag. It never reads GitHub's Latest. - Every install method ignores versions outside the installed major version, and only newer