diff --git a/fork/manifest.json b/fork/manifest.json index a11d63c33520..3c7139cc8dbc 100644 --- a/fork/manifest.json +++ b/fork/manifest.json @@ -346,6 +346,11 @@ "marker": "fork: 5s was not enough headroom on a mu", "note": "Fork edit to an upstream file (+11/-1 vs baseline). Marker is a string the fork's edit introduces and upstream does not contain, so a merge that reverts the edit fails fork:verify." }, + { + "file": "packages/core/src/installation/version.ts", + "marker": "InstallationReleaseChannels", + "note": "Fork edit to an upstream file. Adds InstallationIsRelease (channel is latest/beta/prod) alongside the existing InstallationLocal, shared by database.ts's session-db path and upgrade.ts's self-upgrade eligibility check so any non-release build — not just literally channel \"local\" — is treated as never having been published. Marker is a string the fork's edit introduces and upstream does not contain, so a merge that reverts the edit fails fork:verify." + }, { "file": "packages/core/src/database/sqlite.bun.ts", "marker": "fork: this DB file is shared by every co", @@ -408,8 +413,8 @@ }, { "file": "packages/opencode/src/cli/upgrade.ts", - "marker": "InstallationLocal", - "note": "Fork edit to an upstream file (+8/-1 vs baseline). Marker is a string the fork's edit introduces and upstream does not contain, so a merge that reverts the edit fails fork:verify." + "marker": "InstallationIsRelease", + "note": "Fork edit to an upstream file. Skips the self-upgrade check for any non-release channel (was only the literal \"local\" channel, which missed a local build on the fork's own \"dev\" channel — confirmed live: it read as needing a release patch and silently curl-installed over a running dev binary mid-session). Marker is a string the fork's edit introduces and upstream does not contain, so a merge that reverts the edit fails fork:verify." }, { "file": "packages/opencode/src/command/index.ts", diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index 9b72f7a8632c..7679d63701af 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -7,7 +7,7 @@ import { Global } from "../global" import { Flag } from "../flag/flag" import { isAbsolute, join } from "path" import { DatabaseMigration } from "./migration" -import { InstallationChannel } from "../installation/version" +import { InstallationChannel, InstallationIsRelease } from "../installation/version" import { LayerNode } from "../effect/layer-node" import { makeGlobalNode } from "../effect/app-node" @@ -57,7 +57,7 @@ export function path() { return join(Global.Path.data, Flag.OPENCODE_DB) } if ( - ["latest", "beta", "prod"].includes(InstallationChannel) || + InstallationIsRelease || process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" || process.env.OPENCODE_DISABLE_CHANNEL_DB === "true" ) diff --git a/packages/core/src/installation/version.ts b/packages/core/src/installation/version.ts index 25d9cd99aa6c..2462c277c9b9 100644 --- a/packages/core/src/installation/version.ts +++ b/packages/core/src/installation/version.ts @@ -6,3 +6,11 @@ declare global { export const InstallationVersion = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" export const InstallationChannel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" export const InstallationLocal = InstallationChannel === "local" +// The only channels a real published release ever carries. Anything else — +// "local", "dev", or a one-off OPENCODE_CHANNEL override — is a build that +// was never uploaded anywhere, so comparing its version against the latest +// published release is meaningless and self-upgrading over it is actively +// harmful (it silently replaces a binary someone is deliberately running for +// its own changes, mid-session, with unrelated published code). +export const InstallationReleaseChannels = ["latest", "beta", "prod"] +export const InstallationIsRelease = InstallationReleaseChannels.includes(InstallationChannel) diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts index 95160d310478..69c282843321 100644 --- a/packages/opencode/src/cli/upgrade.ts +++ b/packages/opencode/src/cli/upgrade.ts @@ -2,17 +2,23 @@ import { Config } from "@/config/config" import { AppRuntime } from "@/effect/app-runtime" import { Flag } from "@opencode-ai/core/flag/flag" import { Installation } from "@/installation" -import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" +import { InstallationIsRelease, InstallationVersion } from "@opencode-ai/core/installation/version" import { GlobalBus } from "@/bus/global" export async function upgrade() { - // A local dev build (working-tree dist, channel "local") must never be - // nudged toward a release build — 0.0.0-dev-* vs 1.x always reads as a - // major update. Only real installs (curl/brew/npm channels) check. + // Only a real published release (channel latest/beta/prod) has a + // meaningful version to compare against "the latest release" in the first + // place. A local or dev build — including a local build on the stable + // "dev" channel (see build-local.ts) — was never published, so the + // comparison is meaningless at best and actively harmful at worst: it can + // read as needing "a patch" and silently curl-install a real release over + // a binary someone is deliberately running for its own changes, mid-session + // (confirmed live 2026-09-19 — replaced a running dev build and corrupted + // the TUI's rendered screen with the installer's raw progress output). // OPENCODE_ALWAYS_NOTIFY_UPDATE is the explicit test hook and overrides - // this: it forces the toast even on a dev build, for verifying the - // notification path without installing a release. - if (InstallationLocal && !Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) return + // this: it forces the toast even on a non-release build, for verifying the + // notification path without installing anything. + if (!InstallationIsRelease && !Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) return const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return const method = await Installation.method()