From c05d07cd730095fcab129a6bbb05c1ac7d22cf39 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 7 Sep 2026 16:46:44 -0400 Subject: [PATCH] feat(updates): gate releases on minimum client versions --- bun.lock | 8 ++ packages/cli/src/services/updater.ts | 2 +- packages/updates/README.md | 21 ++++ .../migrations/0003_artifact_minimum.sql | 5 + packages/updates/package.json | 4 +- packages/updates/src/index.ts | 117 +++++++++++++----- 6 files changed, 127 insertions(+), 30 deletions(-) create mode 100644 packages/updates/migrations/0003_artifact_minimum.sql diff --git a/bun.lock b/bun.lock index e9051a8624a..16f51aa7c64 100644 --- a/bun.lock +++ b/bun.lock @@ -967,11 +967,13 @@ "version": "1.18.4", "dependencies": { "jose": "6.0.11", + "semver": "catalog:", }, "devDependencies": { "@cloudflare/workers-types": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", + "@types/semver": "catalog:", "@typescript/native-preview": "catalog:", "wrangler": "4.110.0", }, @@ -6394,6 +6396,10 @@ "@opencode-ai/ui/vite-plugin-solid": ["vite-plugin-solid@2.11.10", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw=="], + "@opencode-ai/updates/@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], + + "@opencode-ai/updates/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@opencode-ai/updates/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="], "@opencode-ai/web/@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-xhJptF5tU2k5eo70nIMyL1Udma0CqmUEnGSlGyFflLqSY82CRQI6nWZ/xZt0ZvmXuErUjIx0YYQNfZsz5CNjLQ=="], @@ -8820,6 +8826,8 @@ "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@opencode-ai/updates/wrangler/miniflare/sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "@opencode-ai/web/@astrojs/cloudflare/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], "@opencode-ai/web/@astrojs/cloudflare/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], diff --git a/packages/cli/src/services/updater.ts b/packages/cli/src/services/updater.ts index 172f80fcff5..d4a1711f67f 100644 --- a/packages/cli/src/services/updater.ts +++ b/packages/cli/src/services/updater.ts @@ -123,7 +123,7 @@ const make = Effect.gen(function* () { const response = yield* Effect.tryPromise({ try: (signal) => fetch( - `https://update.opencode.ai/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm`, + `https://update.opencode.ai/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm?current=${encodeURIComponent(OPENCODE_VERSION)}`, { headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` }, signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]), diff --git a/packages/updates/README.md b/packages/updates/README.md index c2f7bbcb0e6..39d55c6aaec 100644 --- a/packages/updates/README.md +++ b/packages/updates/README.md @@ -8,6 +8,27 @@ curl 'https://update.opencode.ai/api/latest/cli' curl 'https://update.opencode.ai/api/latest/cli/npm' ``` +## Minimum releases + +Each channel/name/distribution can mark one retained artifact as `minimum`, independently +of its `active` artifact. Set or clear it from the admin page. Publishing a new active +release preserves the minimum marker. + +Clients send their running version as `?current=`. Existing clients fall back +to `User-Agent: opencode/`. A caller below the minimum receives that exact +artifact; callers at or above it receive the active artifact. An unparseable caller +version receives the minimum. Requests with neither version source receive the active +artifact. All three public API paths apply the same selection and use `Cache-Control: +no-store` because responses can depend on the User-Agent. + +Version comparison uses semver, normalizing preview run numbers to numeric prerelease +identifiers and historical `next` versions to `beta`. The `/api/next` channel also +resolves to `beta`. + +Choose a minimum that older clients can install and that can itself consume the active +release. For the CLI package migration, retain a package-aware release published as +`@opencode-ai/cli` as the minimum before activating releases under `@opencode/cli`. + The `/admin*` route must be protected by a Cloudflare Access self-hosted application. Configure the application with: - Public hostname: `update.opencode.ai` diff --git a/packages/updates/migrations/0003_artifact_minimum.sql b/packages/updates/migrations/0003_artifact_minimum.sql new file mode 100644 index 00000000000..4990711cd18 --- /dev/null +++ b/packages/updates/migrations/0003_artifact_minimum.sql @@ -0,0 +1,5 @@ +ALTER TABLE artifact ADD COLUMN minimum INTEGER NOT NULL DEFAULT 0 CHECK (minimum IN (0, 1)); + +CREATE UNIQUE INDEX artifact_minimum +ON artifact (channel, name, distribution) +WHERE minimum = 1; diff --git a/packages/updates/package.json b/packages/updates/package.json index 5225434eb72..26989707e4b 100644 --- a/packages/updates/package.json +++ b/packages/updates/package.json @@ -12,12 +12,14 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "jose": "6.0.11" + "jose": "6.0.11", + "semver": "catalog:" }, "devDependencies": { "@cloudflare/workers-types": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", + "@types/semver": "catalog:", "@typescript/native-preview": "catalog:", "wrangler": "4.110.0" } diff --git a/packages/updates/src/index.ts b/packages/updates/src/index.ts index efd0623d595..e87591c18e9 100644 --- a/packages/updates/src/index.ts +++ b/packages/updates/src/index.ts @@ -1,5 +1,6 @@ import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose" import type { Pipeline } from "cloudflare:pipelines" +import semver from "semver" interface Env { DB: D1Database @@ -13,13 +14,15 @@ type ArtifactRow = { version: string metadata: string active: number + minimum: number time_created: number time_updated: number } -type Artifact = Omit & { +type Artifact = Omit & { metadata: unknown active: boolean + minimum: boolean } type ArtifactInput = Pick & { @@ -28,7 +31,8 @@ type ArtifactInput = Pick -async function channel(db: D1Database, channel: string) { +async function channel(db: D1Database, channel: string, current: string | undefined) { const result = await db - .prepare(`${select} WHERE channel = ? AND active = 1 ORDER BY name, distribution`) + .prepare(`${select} WHERE channel = ? AND (active = 1 OR minimum = 1) ORDER BY name, distribution`) .bind(channel) .all() - if (!result.results.length) return json({ error: "Channel not found" }, 404) - return cached({ channel, artifacts: result.results.map(decodeArtifact) }) + const artifacts = selectArtifacts(result.results, current) + if (!artifacts.length) return json({ error: "Channel not found" }, 404) + return updateResponse({ channel, artifacts: artifacts.map(decodeArtifact) }) } -async function artifactName(db: D1Database, channel: string, name: string) { +async function artifactName(db: D1Database, channel: string, name: string, current: string | undefined) { const result = await db - .prepare(`${select} WHERE channel = ? AND name = ? AND active = 1 ORDER BY distribution`) + .prepare(`${select} WHERE channel = ? AND name = ? AND (active = 1 OR minimum = 1) ORDER BY distribution`) .bind(channel, name) .all() - if (!result.results.length) return json({ error: "Artifact not found" }, 404) - return cached({ channel, name, artifacts: result.results.map(decodeArtifact) }) + const artifacts = selectArtifacts(result.results, current) + if (!artifacts.length) return json({ error: "Artifact not found" }, 404) + return updateResponse({ channel, name, artifacts: artifacts.map(decodeArtifact) }) } -async function artifactDistribution(db: D1Database, channel: string, name: string, distribution: string) { - const artifact = await db - .prepare(`${select} WHERE channel = ? AND name = ? AND distribution = ? AND active = 1`) +async function artifactDistribution( + db: D1Database, + channel: string, + name: string, + distribution: string, + current: string | undefined, +) { + const result = await db + .prepare(`${select} WHERE channel = ? AND name = ? AND distribution = ? AND (active = 1 OR minimum = 1)`) .bind(channel, name, distribution) - .first() + .all() + const artifact = selectArtifacts(result.results, current)[0] if (!artifact) return json({ error: "Artifact not found" }, 404) - return cached(decodeArtifact(artifact)) + return updateResponse(decodeArtifact(artifact)) +} + +function selectArtifacts(rows: ArtifactRow[], current: string | undefined) { + const caller = current === undefined ? undefined : releaseVersion(current) + return rows + .filter((row) => row.active === 1) + .map((active) => { + if (current === undefined) return active + const minimum = rows.find( + (row) => row.minimum === 1 && row.name === active.name && row.distribution === active.distribution, + ) + if (!minimum) return active + const floor = releaseVersion(minimum.version) + if (!floor) return minimum + return !caller || semver.lt(caller, floor) ? minimum : active + }) +} + +function releaseVersion(input: string) { + // Preview builds use a hyphen before the run number. Normalize it to a numeric + // prerelease identifier, and compare historical next builds in the beta channel. + return semver.valid( + input.replace( + /^(v?0\.0\.0)-(next|beta|dev)-(\d+)(?=\.|$)/, + (_, core, channel, build) => `${core}-${resolveChannel(channel)}.${build}`, + ), + ) } async function admin(request: Request, env: Env) { @@ -117,7 +159,8 @@ async function admin(request: Request, env: Env) { ${escape(artifact.distribution)} ${escape(artifact.version)} ${new Date(artifact.time_created).toISOString()} - ${artifact.active ? 'Active' : 'Inactive'} + ${artifact.active ? 'Active' : 'Inactive'} + ${artifact.minimum ? 'Minimum' : ""} ${ artifact.active @@ -130,6 +173,14 @@ async function admin(request: Request, env: Env) { ` } +
+ + + + + + +
`, ) @@ -154,7 +205,7 @@ async function admin(request: Request, env: Env) { th, td { padding: .8rem 1rem; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; } th { color: var(--muted-foreground); font-size: .75rem; font-weight: 500; text-transform: uppercase; letter-spacing: .08em; } tbody tr:last-child td { border-bottom: 0; } - td form { margin: 0; } + td form { margin: 0; display: inline-block; } .pagination { display: flex; align-items: center; justify-content: space-between; gap: 1rem; border-top: 1px solid var(--border); padding: 1rem; } .pagination p { color: var(--muted-foreground); font-size: .875rem; } .pagination nav { display: flex; gap: .5rem; } @@ -216,7 +267,7 @@ async function publishArtifact(request: Request, env: Env) { return json({ published: true }) } -async function activateArtifact(request: Request, env: Env) { +async function markArtifact(request: Request, env: Env, flag: "active" | "minimum") { const invalid = validMutation(request) if (invalid) return invalid const form = await request.formData() @@ -227,6 +278,10 @@ async function activateArtifact(request: Request, env: Env) { version: form.get("version"), }) if (key instanceof Response) return key + const enabled = flag === "minimum" && form.get("enabled") === "0" ? 0 : 1 + if (flag === "minimum" && enabled && !releaseVersion(key.version)) { + return json({ error: "Minimum must have a comparable release version" }, 400) + } const exists = await env.DB.prepare( "SELECT 1 FROM artifact WHERE channel = ? AND name = ? AND distribution = ? AND version = ?", ) @@ -234,10 +289,16 @@ async function activateArtifact(request: Request, env: Env) { .first() if (!exists) return json({ error: "Artifact not found" }, 404) await env.DB.batch([ - deactivateStatement(env.DB, key), + ...(enabled + ? [ + env.DB.prepare( + `UPDATE artifact SET ${flag} = 0 WHERE channel = ? AND name = ? AND distribution = ? AND ${flag} = 1`, + ).bind(key.channel, key.name, key.distribution), + ] + : []), env.DB.prepare( - "UPDATE artifact SET active = 1, time_updated = ? WHERE channel = ? AND name = ? AND distribution = ? AND version = ?", - ).bind(Date.now(), key.channel, key.name, key.distribution, key.version), + `UPDATE artifact SET ${flag} = ?, time_updated = ? WHERE channel = ? AND name = ? AND distribution = ? AND version = ?`, + ).bind(enabled, Date.now(), key.channel, key.name, key.distribution, key.version), ]) return Response.redirect(new URL("/admin", request.url), 303) } @@ -300,7 +361,7 @@ function parseKey(input: Record): Omit { return typeof input === "object" && input !== null && !Array.isArray(input) } -function cached(value: unknown) { - return json(value, 200, { "Cache-Control": "public, max-age=60" }) +function updateResponse(value: unknown) { + return json(value, 200, { "Cache-Control": "no-store" }) } function json(value: unknown, status = 200, headers?: HeadersInit) {