mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(updates): gate releases on minimum client versions
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -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)]),
|
||||
|
||||
@@ -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=<version>`. Existing clients fall back
|
||||
to `User-Agent: opencode/<version>`. 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`
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<ArtifactRow, "metadata" | "active"> & {
|
||||
type Artifact = Omit<ArtifactRow, "metadata" | "active" | "minimum"> & {
|
||||
metadata: unknown
|
||||
active: boolean
|
||||
minimum: boolean
|
||||
}
|
||||
|
||||
type ArtifactInput = Pick<ArtifactRow, "channel" | "name" | "distribution" | "version"> & {
|
||||
@@ -28,7 +31,8 @@ type ArtifactInput = Pick<ArtifactRow, "channel" | "name" | "distribution" | "ve
|
||||
|
||||
const identifier = /^[a-zA-Z0-9._-]{1,64}$/
|
||||
const version = /^[a-zA-Z0-9.+_-]{1,128}$/
|
||||
const select = "SELECT channel, name, distribution, version, metadata, active, time_created, time_updated FROM artifact"
|
||||
const select =
|
||||
"SELECT channel, name, distribution, version, metadata, active, minimum, time_created, time_updated FROM artifact"
|
||||
const audience = "https://update.opencode.ai"
|
||||
const githubKeys = createRemoteJWKSet(new URL("https://token.actions.githubusercontent.com/.well-known/jwks"))
|
||||
|
||||
@@ -55,7 +59,8 @@ export default {
|
||||
|
||||
if (url.pathname === "/") return json({ service: "opencode-updates" })
|
||||
if (url.pathname === "/admin" && request.method === "GET") return admin(request, env)
|
||||
if (url.pathname === "/admin/activate" && request.method === "POST") return activateArtifact(request, env)
|
||||
if (url.pathname === "/admin/activate" && request.method === "POST") return markArtifact(request, env, "active")
|
||||
if (url.pathname === "/admin/minimum" && request.method === "POST") return markArtifact(request, env, "minimum")
|
||||
if (url.pathname === "/api/publish" && request.method === "POST") return publishArtifact(request, env)
|
||||
if (request.method !== "GET") return new Response("Method not allowed", { status: 405 })
|
||||
|
||||
@@ -65,37 +70,74 @@ export default {
|
||||
}
|
||||
|
||||
const resolved = resolveChannel(path[0])
|
||||
if (path.length === 1) return channel(env.DB, resolved)
|
||||
if (path.length === 2) return artifactName(env.DB, resolved, path[1])
|
||||
return artifactDistribution(env.DB, resolved, path[1], path[2])
|
||||
const current = url.searchParams.get("current") ?? request.headers.get("User-Agent")?.match(/^opencode\/(.*)$/)?.[1]
|
||||
if (path.length === 1) return channel(env.DB, resolved, current)
|
||||
if (path.length === 2) return artifactName(env.DB, resolved, path[1], current)
|
||||
return artifactDistribution(env.DB, resolved, path[1], path[2], current)
|
||||
},
|
||||
} satisfies ExportedHandler<Env>
|
||||
|
||||
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<ArtifactRow>()
|
||||
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<ArtifactRow>()
|
||||
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<ArtifactRow>()
|
||||
.all<ArtifactRow>()
|
||||
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) {
|
||||
<td><code>${escape(artifact.distribution)}</code></td>
|
||||
<td><code>${escape(artifact.version)}</code></td>
|
||||
<td>${new Date(artifact.time_created).toISOString()}</td>
|
||||
<td>${artifact.active ? '<span class="badge">Active</span>' : '<span class="badge" data-variant="secondary">Inactive</span>'}</td>
|
||||
<td>${artifact.active ? '<span class="badge">Active</span>' : '<span class="badge" data-variant="secondary">Inactive</span>'}
|
||||
${artifact.minimum ? '<span class="badge">Minimum</span>' : ""}</td>
|
||||
<td>
|
||||
${
|
||||
artifact.active
|
||||
@@ -130,6 +173,14 @@ async function admin(request: Request, env: Env) {
|
||||
<button class="btn" data-size="sm" data-variant="outline" type="submit">Activate</button>
|
||||
</form>`
|
||||
}
|
||||
<form action="/admin/minimum" method="post">
|
||||
<input type="hidden" name="channel" value="${escape(artifact.channel)}">
|
||||
<input type="hidden" name="name" value="${escape(artifact.name)}">
|
||||
<input type="hidden" name="distribution" value="${escape(artifact.distribution)}">
|
||||
<input type="hidden" name="version" value="${escape(artifact.version)}">
|
||||
<input type="hidden" name="enabled" value="${artifact.minimum ? "0" : "1"}">
|
||||
<button class="btn" data-size="sm" data-variant="outline" type="submit">${artifact.minimum ? "Clear minimum" : "Set minimum"}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>`,
|
||||
)
|
||||
@@ -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<string, unknown>): Omit<ArtifactInput, "metadata
|
||||
}
|
||||
|
||||
function decodeArtifact(row: ArtifactRow): Artifact {
|
||||
return { ...row, metadata: decodeMetadata(row.metadata), active: row.active === 1 }
|
||||
return { ...row, metadata: decodeMetadata(row.metadata), active: row.active === 1, minimum: row.minimum === 1 }
|
||||
}
|
||||
|
||||
function decodeMetadata(input: string) {
|
||||
@@ -379,8 +440,8 @@ function isRecord(input: unknown): input is Record<string, unknown> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user