From 8dd9800951d0a43d5a840971052e387ca6a99697 Mon Sep 17 00:00:00 2001 From: Haider Date: Wed, 2 Sep 2026 10:26:48 +0530 Subject: [PATCH] fix(release): never let a prerelease version fall back to the `latest` dist-tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel fallback in `packages/script/src/index.ts` was not prerelease-aware. `release.yml` publishes with `OPENCODE_VERSION: ${{ github.ref_name }}`, so for a beta tag that is `v0.10.0-beta.1`. If `OPENCODE_CHANNEL` ever failed to reach the step, the chain fell through to the `OPENCODE_VERSION` branch, saw a version that does not start with `0.0.0-`, and returned "latest" — moving the stable dist-tag onto a beta and auto-upgrading every existing user on next launch. That is the exact outcome the beta channel exists to prevent. It has not happened: `release.yml` sets `OPENCODE_CHANNEL` from `contains(github.ref_name, '-') && 'beta' || 'latest'`, which never evaluates empty, there is exactly one publish step and it carries the variable, and all three `npm publish` calls pass `--tag ${Script.channel}` with none publishing bare. So today the hazard is masked entirely by one workflow expression. That is thin protection for a whole-user-base blast radius, because the documented recovery (`npm dist-tag add …@ latest`) needs npm publish credentials most of the team does not hold. Detection without the ability to remediate is not a safety net. Extracted the decision into `packages/script/src/channel.ts` as a pure `resolveChannel`, so it can be tested without importing the module that checks the bun version, shells out to git and fetches the registry at import time. A semver prerelease now resolves to `beta`; `0.0.0-` preview builds still fall through to the branch-name channel, and ordinary stable versions still resolve to `latest`. Tests in `packages/opencode/test/release/channel.test.ts` cover both directions plus the preview path. Mutation-verified: restoring the old `return "latest"` fails exactly the prerelease regression test. Closes #1233 Co-Authored-By: Claude Opus 5 --- .../opencode/test/release/channel.test.ts | 52 +++++++++++++++++++ packages/script/src/channel.ts | 32 ++++++++++++ packages/script/src/index.ts | 7 +-- 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/release/channel.test.ts create mode 100644 packages/script/src/channel.ts diff --git a/packages/opencode/test/release/channel.test.ts b/packages/opencode/test/release/channel.test.ts new file mode 100644 index 0000000000..624102f98e --- /dev/null +++ b/packages/opencode/test/release/channel.test.ts @@ -0,0 +1,52 @@ +/** + * Which npm dist-tag a publish lands on. + * + * Getting this wrong in the `latest` direction moves every existing user onto + * whatever was published, and the documented recovery + * (`npm dist-tag add …@ latest`) needs publish credentials most of the + * team does not hold — so the decision is pinned here rather than left to an + * inline expression whose only protection is an env var reaching one workflow + * step. See #1233. + */ + +import { describe, test, expect } from "bun:test" +import { resolveChannel } from "../../../script/src/channel" + +describe("resolveChannel: a prerelease must never reach `latest`", () => { + test("an explicit channel always wins", () => { + // This is the path the release workflow actually takes. + expect(resolveChannel({ OPENCODE_CHANNEL: "beta", OPENCODE_VERSION: "v0.10.0-beta.1" })).toBe("beta") + expect(resolveChannel({ OPENCODE_CHANNEL: "latest", OPENCODE_VERSION: "v0.10.0" })).toBe("latest") + }) + + test("a prerelease tag falls back to `beta`, not `latest`", () => { + // The regression this guards. With OPENCODE_CHANNEL unset, the version is + // the tag name — and before the fix, `0.10.0-beta.1` failed the `0.0.0-` + // test and returned "latest", moving the stable dist-tag onto a beta. + for (const v of ["v0.10.0-beta.1", "0.10.0-beta.1", "v1.0.0-rc.2", "v0.9.6-beta.10"]) { + expect(resolveChannel({ OPENCODE_VERSION: v })).toBe("beta") + } + }) + + test("a plain release version still resolves to `latest`", () => { + // The fix must not push ordinary stable releases off `latest`. + for (const v of ["v0.10.0", "0.10.0", "v1.2.3"]) { + expect(resolveChannel({ OPENCODE_VERSION: v })).toBe("latest") + } + }) + + test("`0.0.0-` preview builds still defer to the branch channel", () => { + // These carry a `-` too, but they must keep falling through to the + // branch-name channel rather than being captured as `beta`. + expect(resolveChannel({ OPENCODE_VERSION: "0.0.0-release/v0.10.0-202609012234" })).toBeNull() + expect(resolveChannel({ OPENCODE_VERSION: "v0.0.0-somebranch-123" })).toBeNull() + }) + + test("an explicit bump means a stable release", () => { + expect(resolveChannel({ OPENCODE_BUMP: "minor" })).toBe("latest") + }) + + test("no signal at all defers to the caller", () => { + expect(resolveChannel({})).toBeNull() + }) +}) diff --git a/packages/script/src/channel.ts b/packages/script/src/channel.ts new file mode 100644 index 0000000000..cda2549aba --- /dev/null +++ b/packages/script/src/channel.ts @@ -0,0 +1,32 @@ +// altimate_change start — extracted from the CHANNEL IIFE in ./index.ts so the +// dist-tag decision can be tested without importing that module, which checks +// the bun version, shells out to git and fetches the npm registry at import. +// +// This decides which npm dist-tag a publish lands on. Getting it wrong in the +// `latest` direction moves every existing user onto whatever was published, and +// recovery needs npm credentials most of the team does not hold — so it is +// worth having as a pure, tested function rather than an inline expression. +// (#1233) + +export type ChannelEnv = { + OPENCODE_CHANNEL?: string | undefined + OPENCODE_BUMP?: string | undefined + OPENCODE_VERSION?: string | undefined +} + +/** Resolve the npm dist-tag, or null when the caller should fall back to the + * current git branch name (the local/preview path). */ +export function resolveChannel(env: ChannelEnv): string | null { + if (env.OPENCODE_CHANNEL) return env.OPENCODE_CHANNEL + if (env.OPENCODE_BUMP) return "latest" + if (env.OPENCODE_VERSION) { + const version = env.OPENCODE_VERSION.replace(/^v/, "") + // `0.0.0-` preview builds keep falling through to the branch-name channel. + if (version.startsWith("0.0.0-")) return null + // A semver prerelease belongs on `beta`, never `latest`. Before this, a + // `v0.10.0-beta.1` tag reaching this branch returned "latest" and would + // have auto-upgraded the entire stable user base onto a beta. + return version.includes("-") ? "beta" : "latest" + } + return null +} diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts index 41c0aa959e..a441cfbf40 100644 --- a/packages/script/src/index.ts +++ b/packages/script/src/index.ts @@ -1,6 +1,7 @@ import { $ } from "bun" import semver from "semver" import path from "path" +import { resolveChannel } from "./channel" const rootPkgPath = path.resolve(import.meta.dir, "../../../package.json") const rootPkg = await Bun.file(rootPkgPath).json() @@ -24,9 +25,9 @@ const env = { OPENCODE_RELEASE: process.env["OPENCODE_RELEASE"], } const CHANNEL = await (async () => { - if (env.OPENCODE_CHANNEL) return env.OPENCODE_CHANNEL - if (env.OPENCODE_BUMP) return "latest" - if (env.OPENCODE_VERSION && !env.OPENCODE_VERSION.replace(/^v/, "").startsWith("0.0.0-")) return "latest" + // altimate_change — see ./channel.ts for why this is a separate pure function. (#1233) + const resolved = resolveChannel(env) + if (resolved) return resolved return await $`git branch --show-current`.text().then((x) => x.trim()) })() const IS_PREVIEW = CHANNEL !== "latest"