From e4a85d134f28b34a36843b7412d56baa41ae6643 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 27 Aug 2026 16:57:55 +0900 Subject: [PATCH 1/3] fix(release): stop dev from carrying a version behind its own releases dev accumulated 254 commits without touching package.json, so its version string (2.32.1-preview.20260825) ended up BEHIND the published latest dist-tag (2.33.0) once v2.33.0 was cut from main. One stale line, two failure modes: - assertChannelVersionMovesForward refuses the string as a channel regression, so a release cut from this tree cannot publish at all. - merging dev into main resolves package.json to main's side, yielding a tree that claims to be the already-published 2.33.0 - a silent duplicate rather than a loud failure. 2.34.0 is the target: npm returns E404 for it, no v2.34.0 tag exists, and it moves latest forward. Minor rather than patch follows the precedent in 32529c2b2 - the range carries new providers, new config surfaces, GUI work and adapter contract changes, not only fixes. The regression test is the part that lasts. 32529c2b2 repaired this same condition by hand and nothing has enforced it since. tests/release-version-line asserts the in-tree version is strictly ahead of the highest local release tag, and was driven red against the exact string dev carried before this commit. It reads local tags rather than the npm registry so it needs no network and no edit per release, and reports nothing when no tags are present, because actions/checkout fetches none by default and an empty set cannot prove a regression. It imports compareReleaseTags from scripts/release-notes rather than scripts/release: the latter parses process.argv and calls process.exit at module scope, so importing it from a test kills the runner. This does not publish, tag, or promote. scripts/release.ts still refuses to run anywhere but main/preview. --- package.json | 2 +- tests/release-version-line.test.ts | 97 ++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 tests/release-version-line.test.ts diff --git a/package.json b/package.json index 7b3d03972eb..fd0601a9fcd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.32.1-preview.20260825", + "version": "2.34.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", diff --git a/tests/release-version-line.test.ts b/tests/release-version-line.test.ts new file mode 100644 index 00000000000..fb1a5a6cdce --- /dev/null +++ b/tests/release-version-line.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { compareReleaseTags } from "../scripts/release-notes"; + +const repoRoot = fileURLToPath(new URL("../", import.meta.url)); + +/** + * The in-tree version must never sit BEHIND a version this repository has already + * released. + * + * This is not hypothetical. dev accumulated 254 commits without ever touching + * package.json, so its version string (2.32.1-preview.20260825) fell behind the + * published latest dist-tag (2.33.0) once v2.33.0 was cut from main. One stale line, + * two distinct failure modes: + * + * - assertChannelVersionMovesForward in scripts/release.ts refuses the string as a + * channel regression, so a release cut from that tree cannot publish at all. + * - merging dev into main resolves package.json to main's side, producing a tree that + * claims to be the ALREADY-PUBLISHED 2.33.0 - a silent duplicate instead of a loud + * failure. + * + * Commit 32529c2b2 repaired precisely this by hand once, and nothing has enforced it + * since. The assertion reads the local tag set rather than the npm registry, so it needs + * no network and no edit at each release. + * + * compareReleaseTags comes from scripts/release-notes and not from scripts/release: the + * latter parses process.argv and calls process.exit at module scope, so importing it from + * a test kills the runner. release-notes guards its CLI behind import.meta.main. + */ + +/** Release tags shaped vX.Y.Z[-pre]. Non-release tags are ignored, not an error. */ +const RELEASE_TAG = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; + +function localReleaseTags(): string[] { + const result = Bun.spawnSync(["git", "tag", "--list", "v*"], { cwd: repoRoot }); + // A tarball install, a shallow CI checkout, or a missing git binary all land here. + // Absent tags cannot prove a regression, so the check reports nothing rather than + // inventing a failure. + if (result.exitCode !== 0) return []; + return new TextDecoder() + .decode(result.stdout) + .split("\n") + .map((line) => line.trim()) + .filter((line) => RELEASE_TAG.test(line)); +} + +function inTreeVersion(): string { + const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8"); + const version = (JSON.parse(raw) as { version?: unknown }).version; + if (typeof version !== "string") throw new Error("package.json has no string version"); + return version; +} + +/** Newest tag by SemVer precedence, or null when the set is empty. */ +function highestReleaseTag(tags: string[]): string | null { + if (tags.length === 0) return null; + const sorted = [...tags].sort(compareReleaseTags); + return sorted[sorted.length - 1] ?? null; +} + +describe("release version line", () => { + test("package.json carries a parseable SemVer version", () => { + expect(inTreeVersion()).toMatch(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/); + }); + + test("the in-tree version is strictly ahead of every tagged release", () => { + const tags = localReleaseTags(); + if (tags.length === 0) { + // Shallow checkout: actions/checkout fetches no tags by default. Reporting nothing + // keeps this honest instead of asserting against an empty set that always passes. + expect(tags).toEqual([]); + return; + } + + const version = inTreeVersion(); + const highest = highestReleaseTag(tags); + expect(highest).not.toBeNull(); + + const ordering = compareReleaseTags("v" + version, highest!); + expect( + ordering, + "package.json version " + version + " is not ahead of the highest release tag " + + highest + + ". A release cut from this tree is either rejected as a channel regression or " + + "silently republishes an already-published version. Bump package.json.", + ).toBeGreaterThan(0); + }); + + test("a version behind the tag set is detected rather than tolerated", () => { + // Proves the comparison above is not vacuous: the same helper, given the exact string + // dev actually carried, must order it BEHIND the release that stranded it. + expect(compareReleaseTags("v2.32.1-preview.20260825", "v2.33.0")).toBeLessThan(0); + expect(compareReleaseTags("v2.33.0", "v2.33.0")).toBe(0); + expect(compareReleaseTags("v2.34.0", "v2.33.0")).toBeGreaterThan(0); + }); +}); From a223a2bbf8689cd56146d8705eb7bf1053419ddf Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 27 Aug 2026 17:20:02 +0900 Subject: [PATCH 2/3] fix(release): make the version-line guard bite on release commits and in CI An adversarial review of the first cut found two ways the new check was weaker than it looked, and both are fixed here. CI never fetched tags. actions/checkout takes no tags by default, so the tag-based comparison read an EMPTY set on every CI run and passed on anything - the precise regression it exists to catch would have ridden through green. The two jobs that run the suite (the four Linux shards and the unsharded macOS control) now fetch tags explicitly. The other jobs do not run this file and are left alone. Strictly-ahead was the wrong rule. On a release commit package.json SHOULD equal the newest tag - main sits at 2.33.0 with v2.33.0 pointing at it, preview at 2.33.0-preview.20260825 - so the first version would have turned every released commit red the moment it reached main. Equality is now legal exactly when the tag names the commit under test, and a duplicate anywhere else. Verified both ways in a throwaway worktree: green on the real v2.33.0 commit, red with the same version on its parent. Two more comparator cases are pinned, because the asymmetry is easy to get backwards: a prerelease of a FUTURE version is ahead (2.35.0-preview.1 may sit on dev while v2.34.0 is newest), while a prerelease of the SAME core is behind its own stable release. Also checked and NOT changed: src/generated/compatibility-version.json hashes package.json, but it is gitignored and regenerated on demand, so the bump carries no lockstep obligation. No tracked file outside package.json holds the product version. --- .github/workflows/ci.yml | 12 +++++ tests/release-version-line.test.ts | 71 +++++++++++++++++++++++------- 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 874b252c3c1..15686c7fdcc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -267,6 +267,12 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + # tests/release-version-line.test.ts compares package.json against the + # newest release tag. actions/checkout fetches no tags by default, so + # without this the check reads an empty tag set and passes on anything - + # the exact regression it exists to catch would ride through CI green. + fetch-tags: true + fetch-depth: 0 - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -454,6 +460,12 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + # tests/release-version-line.test.ts compares package.json against the + # newest release tag. actions/checkout fetches no tags by default, so + # without this the check reads an empty tag set and passes on anything - + # the exact regression it exists to catch would ride through CI green. + fetch-tags: true + fetch-depth: 0 - name: Setup project Bun uses: ./.github/actions/setup-project-bun diff --git a/tests/release-version-line.test.ts b/tests/release-version-line.test.ts index fb1a5a6cdce..92cd0bc9ba0 100644 --- a/tests/release-version-line.test.ts +++ b/tests/release-version-line.test.ts @@ -32,14 +32,20 @@ const repoRoot = fileURLToPath(new URL("../", import.meta.url)); /** Release tags shaped vX.Y.Z[-pre]. Non-release tags are ignored, not an error. */ const RELEASE_TAG = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +function git(args: string[]): { ok: boolean; out: string } { + const result = Bun.spawnSync(["git", ...args], { cwd: repoRoot }); + return { + ok: result.exitCode === 0, + out: new TextDecoder().decode(result.stdout).trim(), + }; +} + function localReleaseTags(): string[] { - const result = Bun.spawnSync(["git", "tag", "--list", "v*"], { cwd: repoRoot }); - // A tarball install, a shallow CI checkout, or a missing git binary all land here. - // Absent tags cannot prove a regression, so the check reports nothing rather than - // inventing a failure. - if (result.exitCode !== 0) return []; - return new TextDecoder() - .decode(result.stdout) + const result = git(["tag", "--list", "v*"]); + // A tarball install or a missing git binary lands here. Absent tags cannot prove a + // regression, so the check reports nothing rather than inventing a failure. + if (!result.ok) return []; + return result.out .split("\n") .map((line) => line.trim()) .filter((line) => RELEASE_TAG.test(line)); @@ -59,31 +65,57 @@ function highestReleaseTag(tags: string[]): string | null { return sorted[sorted.length - 1] ?? null; } +/** + * True when the given tag names the commit under test. + * + * This is the difference between "equal is legal" and "equal is a duplicate". On a + * release commit — main at v2.33.0, preview at v2.33.0-preview.20260825 — package.json + * SHOULD equal the highest tag, and a test that demanded strictly-ahead everywhere would + * turn every released commit red. On any other commit, equal means the tree claims a + * version that is already published. + */ +function tagPointsAtHead(tag: string): boolean { + const head = git(["rev-parse", "HEAD^{commit}"]); + const tagged = git([`rev-parse`, `${tag}^{commit}`]); + return head.ok && tagged.ok && head.out.length > 0 && head.out === tagged.out; +} + describe("release version line", () => { test("package.json carries a parseable SemVer version", () => { expect(inTreeVersion()).toMatch(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/); }); - test("the in-tree version is strictly ahead of every tagged release", () => { + test("the in-tree version is never behind a released one", () => { const tags = localReleaseTags(); - if (tags.length === 0) { - // Shallow checkout: actions/checkout fetches no tags by default. Reporting nothing - // keeps this honest instead of asserting against an empty set that always passes. - expect(tags).toEqual([]); - return; - } + // A checkout with no tags cannot answer the question. CI fetches tags explicitly for + // the jobs that run this suite (see .github/workflows/ci.yml), so an empty set here + // means a tarball or a hand-made shallow clone, not a silently unprotected pipeline. + if (tags.length === 0) return; const version = inTreeVersion(); const highest = highestReleaseTag(tags); expect(highest).not.toBeNull(); const ordering = compareReleaseTags("v" + version, highest!); + if (ordering === 0) { + // Legal only on the release commit that tag names. + expect( + tagPointsAtHead(highest!), + "package.json version " + version + " equals release tag " + highest + + ", but this commit is not the one that tag names. The tree claims an " + + "already-published version: publishing is refused as a duplicate. Bump " + + "package.json.", + ).toBe(true); + return; + } + expect( ordering, - "package.json version " + version + " is not ahead of the highest release tag " + + "package.json version " + version + " is BEHIND the highest release tag " + highest + - ". A release cut from this tree is either rejected as a channel regression or " + - "silently republishes an already-published version. Bump package.json.", + ". A release cut from this tree is rejected as a channel regression, and " + + "merging into main resolves package.json to main's side and silently " + + "republishes. Bump package.json.", ).toBeGreaterThan(0); }); @@ -93,5 +125,10 @@ describe("release version line", () => { expect(compareReleaseTags("v2.32.1-preview.20260825", "v2.33.0")).toBeLessThan(0); expect(compareReleaseTags("v2.33.0", "v2.33.0")).toBe(0); expect(compareReleaseTags("v2.34.0", "v2.33.0")).toBeGreaterThan(0); + // A prerelease of a FUTURE version is ahead, not behind: dev may legitimately carry + // 2.35.0-preview.1 while v2.34.0 is the newest tag. + expect(compareReleaseTags("v2.35.0-preview.1", "v2.34.0")).toBeGreaterThan(0); + // ...but a prerelease of the SAME core is behind its own stable release. + expect(compareReleaseTags("v2.34.0-preview.1", "v2.34.0")).toBeLessThan(0); }); }); From 7297bc802c3ab31fa18f6c2b8cbb84771b864745 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 27 Aug 2026 17:24:32 +0900 Subject: [PATCH 3/3] ci: fetch tags without cloning full history for the version-line check The first fix paired fetch-tags with fetch-depth: 0, which clones every commit to answer a question about refs. A shallow fetch already brings each tag and its target commit, and that is all the check reads: the tag list, plus whether the newest tag names HEAD. The second read only matters on a release commit, where the tag points at HEAD and the commit is therefore present by definition. Verified rather than assumed. In a fresh git init with a depth=1 fetch, a plain ref fetch gives 0 tags; adding the tag refspec at the same depth gives all 212, and v2.33.0 resolves to ec51e42d7. tests/release-version-line.test.ts then passes in that shallow clone, which is the shape CI now has. --- .github/workflows/ci.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15686c7fdcc..572a63401f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -271,8 +271,13 @@ jobs: # newest release tag. actions/checkout fetches no tags by default, so # without this the check reads an empty tag set and passes on anything - # the exact regression it exists to catch would ride through CI green. + # + # Tags only, not full history: `fetch-depth: 0` would clone every commit to + # answer a question about refs. A shallow fetch still brings each tag and its + # target commit, which is all the check reads - the tag list, and whether the + # newest tag names HEAD. That second read only happens on a release commit, + # where the tag points at HEAD and the commit is present by definition. fetch-tags: true - fetch-depth: 0 - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -464,8 +469,13 @@ jobs: # newest release tag. actions/checkout fetches no tags by default, so # without this the check reads an empty tag set and passes on anything - # the exact regression it exists to catch would ride through CI green. + # + # Tags only, not full history: `fetch-depth: 0` would clone every commit to + # answer a question about refs. A shallow fetch still brings each tag and its + # target commit, which is all the check reads - the tag list, and whether the + # newest tag names HEAD. That second read only happens on a release commit, + # where the tag points at HEAD and the commit is present by definition. fetch-tags: true - fetch-depth: 0 - name: Setup project Bun uses: ./.github/actions/setup-project-bun