Skip to content
Open
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
2 changes: 2 additions & 0 deletions .github/workflows/notify-discord.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 21 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -655,18 +656,36 @@ 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
- 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:
# 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 }}
147 changes: 147 additions & 0 deletions docs/release/v1-v2-isolation.md
Original file line number Diff line number Diff line change
@@ -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/<asset>` |
| 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/<repo>/releases/latest` and
`releases/latest/download/<asset>`) 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`, #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`.
- 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`, #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
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 <notes>`.
- 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.<x>.<y> --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 <v1 tag> --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.
25 changes: 25 additions & 0 deletions script/check-github-latest.ts
Original file line number Diff line number Diff line change
@@ -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 ?? "<newest v1.x tag>"} --repo ${repo} --latest`)
process.exit(1)
18 changes: 18 additions & 0 deletions script/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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`
}
}
10 changes: 10 additions & 0 deletions services/update/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>/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
Expand Down
88 changes: 88 additions & 0 deletions services/update/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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 = [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The v1 channel block (this fake D1 and the two worker.fetch tests) also passes on the base branch. Serving a channel by name already worked; the change is only in channelsForRef. The mock also does its own channel filtering, so these tests mostly check the mock. They could be dropped, leaving the channelsForRef and validGitHubClaims tests to cover the change.

{ 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" })
})
})
2 changes: 2 additions & 0 deletions services/update/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading