From 0abe812ed0af2b71efe23999ac9dc859e71b28fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=BE=99=E5=AE=89?= <297888591+wanglongan587@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:43:13 +0800 Subject: [PATCH 1/2] build: bump suffixed plugin versions on upstream releases The nightly bump split the version with bash parameter expansion and did `$((PATCH + 1))`, so a suffixed version like 0.7.0-opencode2.1 put `0-opencode2.1` into the arithmetic and failed the whole run before any tag was pushed. Move the rule into scripts/bump-version.ts, which is tested: an unsuffixed version bumps its patch number, a suffixed one bumps the trailing counter of its suffix (0.7.0-opencode2.1 -> 0.7.0-opencode2.2), and a suffix without a counter starts at .1. A version the rule cannot describe fails with that version named instead of producing a number nobody chose. The workflow captures the script's stdout as the tag and CI runs the script tests. Also refresh this workflow's own description of what it watches: the bundle config can name a pinned npm distribution, not only the latest GitHub release. --- .github/workflows/ci.yml | 6 ++- .github/workflows/upstream.yml | 38 +++++++-------- AGENTS.md | 21 ++++---- deno.json | 3 +- scripts/bump-version.test.ts | 48 +++++++++++++++++++ scripts/bump-version.ts | 88 ++++++++++++++++++++++++++++++++++ 6 files changed, 171 insertions(+), 33 deletions(-) create mode 100644 scripts/bump-version.test.ts create mode 100644 scripts/bump-version.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adee045..a87a602 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,5 +32,7 @@ jobs: - name: Lint run: deno task lint - - name: Archive integrity tests - run: deno test scripts/upstream.test.ts + # The script tests are pure — version arithmetic and archive integrity — so they need no + # CLI, no credentials and no network. + - name: Script tests + run: deno test scripts/ diff --git a/.github/workflows/upstream.yml b/.github/workflows/upstream.yml index ea90a5d..0489020 100644 --- a/.github/workflows/upstream.yml +++ b/.github/workflows/upstream.yml @@ -1,7 +1,9 @@ name: Upstream -# Watches `anomalyco/opencode`'s GitHub releases and releases a new patch version of this plugin -# whenever upstream publishes one. +# Watches upstream for a new OpenCode CLI release and publishes a new version of this plugin +# whenever one appears. Which upstream that is — a pinned npm distribution, or the latest GitHub +# release — is `bundle.config.ts`'s choice; this workflow only acts on what `deno task sync` +# resolved. # # The release itself is not duplicated here: this workflow calls `release.yml`, which is also what # a maintainer's own tag runs. That is not only to avoid repeating the steps — a tag pushed with @@ -45,10 +47,11 @@ jobs: with: deno-version: v2.x - # `sync --check` resolves upstream's latest release and compares it against the whole lock, - # not just the tag: upstream could republish a release's assets without moving the tag, and - # that would be the hardest kind of difference to notice later. Exit 20 is "behind"; anything - # else non-zero is a real failure and must not be read as "nothing to do". + # `sync --check` resolves the upstream `bundle.config.ts` names (a pinned npm distribution, + # or the latest GitHub release) and compares it against the whole lock, not just the version: + # upstream could republish a release's assets without moving it, and that would be the + # hardest kind of difference to notice later. Exit 20 is "behind"; anything else non-zero is + # a real failure and must not be read as "nothing to do". - name: Check for a new OpenCode release id: check env: @@ -63,7 +66,7 @@ jobs: *) echo "::error::sync --check failed with $code"; exit "$code" ;; esac - - name: Pin the new upstream and bump the patch version + - name: Pin the new upstream and bump the plugin version id: bump if: steps.check.outputs.behind == 'true' env: @@ -72,16 +75,13 @@ jobs: set -euo pipefail deno task sync - # This plugin's version is its own: the patch position counts releases of this package, - # and an upstream bump is one of the things that causes one. It deliberately does not - # mirror OpenCode's own tag, which moves for reasons that have nothing to do with this - # plugin. - CURRENT=$(grep -m1 '^version *=' orax.toml | sed -E 's/^version *= *"(.*)"/\1/') - MAJOR=${CURRENT%%.*} - REST=${CURRENT#*.} - MINOR=${REST%%.*} - PATCH=${REST#*.} - NEXT="${MAJOR}.${MINOR}.$((PATCH + 1))" + # This plugin's version is its own: it counts releases of this package, and an upstream + # bump is one of the things that causes one. It deliberately does not mirror OpenCode's + # own tag, which moves for reasons that have nothing to do with this plugin. A suffixed + # line bumps the trailing counter of its suffix instead (`0.7.0-opencode2.1` -> + # `0.7.0-opencode2.2`); `scripts/bump-version.ts` owns that rule and is tested, so this + # step never does arithmetic on a version string itself. + NEXT=$(deno task bump-version) TAG="v${NEXT}" if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then @@ -89,11 +89,9 @@ jobs: exit 1 fi - sed -i -E "s/^version = \"${CURRENT}\"/version = \"${NEXT}\"/" orax.toml - UPSTREAM_TAG=$(jq -r '.tag' upstream.lock.json) echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - echo "bumping ${CURRENT} -> ${NEXT} for opencode ${UPSTREAM_TAG}" + echo "tagging ${TAG} for upstream ${UPSTREAM_TAG}" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" diff --git a/AGENTS.md b/AGENTS.md index ab46c10..a090b94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -259,12 +259,13 @@ verifies the downloaded archive against the pinned digest before extracting it, and emits one `.orax` plus one `[[targets]]` record per target. `deno task sync --check` exits 20 when the committed lock is behind the upstream -repository's latest release. The nightly `upstream.yml` treats only that code as -an update, commits the refreshed lock with a bumped `orax.toml` patch version, -tags it, then calls the reusable `release.yml`. Packaging must never resolve -"latest" itself; rebuilding an existing tag must use only its committed lock — -that is what makes a rebuild of a tag reproduce exactly the release it already -shipped. +`bundle.config.ts` names. The nightly `upstream.yml` treats only that code as an +update, commits the refreshed lock with a bumped `orax.toml` version +(`scripts/bump-version.ts`: the patch number, or the trailing counter of a +suffixed version such as `0.7.0-opencode2.1` → `0.7.0-opencode2.2`), tags it, +then calls the reusable `release.yml`. Packaging must never resolve "latest" +itself; rebuilding an existing tag must use only its committed lock — that is +what makes a rebuild of a tag reproduce exactly the release it already shipped. Bump `orax.toml` `version` before tagging: `install_local` refuses a version that is already installed and never retires older ones, so reusing a number @@ -367,10 +368,10 @@ the run. `package`. - `deno task test` runs `tests/package_test.ts`, the packaging checks that need no CLI. `tests/host-simulator.ts` drives the plugin the way Ora's host does - and needs a real CLI, staged or on PATH. CI runs only `check` and `lint`, - deliberately: type checking resolves the whole module graph, so it is also - what catches an SDK version that is not published yet, and lint stays - meaningful even while that fails. + and needs a real CLI, staged or on PATH. CI runs `check`, `lint` and the + `scripts/` tests, deliberately: type checking resolves the whole module graph, + so it is also what catches an SDK version that is not published yet, and lint + stays meaningful even while that fails. - The SDK is imported from its published JSR package and pinned in `deno.json`; keep `deno.lock` synchronized when changing the SDK version. - `scripts/package.ts` and `scripts/upstream.ts` know nothing about OpenCode diff --git a/deno.json b/deno.json index 090b4a7..8bbd48f 100644 --- a/deno.json +++ b/deno.json @@ -12,7 +12,7 @@ "@zip-js/zip-js": "jsr:@zip-js/zip-js@^2.8.61" }, "tasks": { - "check": "deno check src/main.ts scripts/package.ts scripts/sync-upstream.ts tests/host-simulator.ts tests/package_test.ts", + "check": "deno check src/main.ts scripts/bump-version.ts scripts/package.ts scripts/sync-upstream.ts tests/host-simulator.ts tests/package_test.ts", "lint": "deno lint src scripts tests bundle.config.ts", "format": "deno fmt src scripts tests bundle.config.ts deno.json README.md AGENTS.md", "test": "deno test --allow-read --allow-write tests/package_test.ts", @@ -20,6 +20,7 @@ "dev": "deno run --no-prompt --allow-run --allow-read --allow-env --allow-net src/main.ts", "build": "deno bundle src/main.ts -o dist/main.js", "sync": "deno run --allow-net --allow-read --allow-write --allow-env scripts/sync-upstream.ts", + "bump-version": "deno run --allow-read --allow-write scripts/bump-version.ts", "package": "deno run --allow-run --allow-read --allow-write --allow-env --allow-net=registry.npmjs.org scripts/package.ts" }, "compilerOptions": { diff --git a/scripts/bump-version.test.ts b/scripts/bump-version.test.ts new file mode 100644 index 0000000..0cc0112 --- /dev/null +++ b/scripts/bump-version.test.ts @@ -0,0 +1,48 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { bumpManifest, bumpVersion } from "./bump-version.ts"; + +Deno.test("bumps the patch of an unsuffixed version", () => { + assertEquals(bumpVersion("0.6.11"), "0.6.12"); + assertEquals(bumpVersion("1.0.0"), "1.0.1"); +}); + +Deno.test("bumps the trailing counter of a suffixed version", () => { + assertEquals(bumpVersion("0.7.0-opencode2.1"), "0.7.0-opencode2.2"); + assertEquals(bumpVersion("1.2.3-rc.9"), "1.2.3-rc.10"); +}); + +Deno.test("starts a counter when the suffix has none", () => { + assertEquals(bumpVersion("0.7.0-opencode2"), "0.7.0-opencode2.1"); +}); + +Deno.test("refuses a version it cannot bump predictably", () => { + for ( + const version of [ + "0.7", + "0.7.0.1", + "v0.7.0", + "0.7.0-", + "0.7.0-a..b", + "0.7.0+build", + ] + ) { + assertThrows(() => bumpVersion(version), Error, version); + } +}); + +Deno.test("rewrites the manifest's version line and nothing else", () => { + const source = 'resolver = 1\ntitle = "OpenCode"\n' + + 'version = "0.7.0-opencode2.1"\nkind = "agent"\n'; + const bumped = bumpManifest(source); + assertEquals(bumped.current, "0.7.0-opencode2.1"); + assertEquals(bumped.next, "0.7.0-opencode2.2"); + assertEquals( + bumped.text, + 'resolver = 1\ntitle = "OpenCode"\n' + + 'version = "0.7.0-opencode2.2"\nkind = "agent"\n', + ); +}); + +Deno.test("refuses a manifest that declares no version", () => { + assertThrows(() => bumpManifest('title = "OpenCode"\n'), Error); +}); diff --git a/scripts/bump-version.ts b/scripts/bump-version.ts new file mode 100644 index 0000000..28a644a --- /dev/null +++ b/scripts/bump-version.ts @@ -0,0 +1,88 @@ +/** + * Bumps this plugin's own release version, in the one place a release reads it. + * + * The version counts this plugin's releases, not the upstream CLI's: an upstream bump is one of + * the things that causes a release, but the two move independently. The nightly `upstream.yml` + * runs this after refreshing `upstream.lock.json`, and `release.yml` then refuses a tag that + * disagrees with the manifest. + * + * A suffixed experiment line keeps its own counter instead of being forced back onto the plain + * patch sequence: + * + * `0.6.11` -> `0.6.12` no suffix: the patch number moves + * `0.7.0-opencode2.1` -> `0.7.0-opencode2.2` suffix: its trailing counter moves + * `0.7.0-opencode2` -> `0.7.0-opencode2.1` no counter yet: start one + * + * Anything else is refused rather than guessed at: a version the rule cannot describe is a + * maintainer decision, and a wrong guess publishes a package under a number nobody chose. + */ + +/** The manifest `release.yml` compares the tag against, and the only file this script writes. */ +export const MANIFEST_PATH = "orax.toml"; + +/** + * `MAJOR.MINOR.PATCH`, optionally followed by a `-SUFFIX` of dot-separated identifiers. + * + * Deliberately stricter than semver's full grammar: build metadata (`+…`) and empty identifiers + * are outside what this repository's versions use, and accepting them would mean inventing a bump + * rule for something no release has carried. + */ +const VERSION = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; + +/** A `version = "…"` line, split into its prefix, value and tail so only the value changes. */ +const VERSION_LINE = /^(version *= *")([^"]*)(" *)$/m; + +/** Returns the version one automated release ahead of `version`. */ +export function bumpVersion(version: string): string { + const match = VERSION.exec(version); + if (match === null) { + throw new Error( + `${version} is not MAJOR.MINOR.PATCH[-SUFFIX]; bump it by hand`, + ); + } + const [, major, minor, patch, suffix] = match; + if (suffix === undefined) return `${major}.${minor}.${Number(patch) + 1}`; + const segments = suffix.split("."); + const last = segments[segments.length - 1]; + segments[segments.length - 1] = /^\d+$/.test(last) + ? String(Number(last) + 1) + : `${last}.1`; + return `${major}.${minor}.${patch}-${segments.join(".")}`; +} + +/** What one manifest bump changed, so a caller can report it without re-reading the file. */ +export interface ManifestBump { + current: string; + next: string; + /** The manifest with its version line bumped and every other byte untouched. */ + text: string; +} + +/** Bumps the version line of a manifest, leaving every other byte of it alone. */ +export function bumpManifest(text: string): ManifestBump { + const line = VERSION_LINE.exec(text); + if (line === null) { + throw new Error(`${MANIFEST_PATH} has no single-line version = "…" entry`); + } + const current = line[2]; + const next = bumpVersion(current); + return { + current, + next, + text: `${text.slice(0, line.index)}${line[1]}${next}${line[3]}${ + text.slice(line.index + line[0].length) + }`, + }; +} + +async function main(): Promise { + const source = await Deno.readTextFile(MANIFEST_PATH); + const bumped = bumpManifest(source); + await Deno.writeTextFile(MANIFEST_PATH, bumped.text); + // stdout carries the bare version so a workflow can tag it; the rest is a log line. + console.log(bumped.next); + console.error(`bumped ${MANIFEST_PATH} ${bumped.current} -> ${bumped.next}`); +} + +if (import.meta.main) await main(); From 6c5a64818215128e9a809f11a001962cbd3eb762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=BE=99=E5=AE=89?= <297888591+wanglongan587@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:43:13 +0800 Subject: [PATCH 2/2] docs(packaging): align archive comments with the npm source package.ts still described the Releases API digest as the integrity check for every archive and the CLI as the only entry at the archive root. Both hold for the GitHub-release source only: npm archives are verified against their SRI before they are written, and place the CLI under package/bin/. Say which source each comment applies to. --- scripts/package.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/package.ts b/scripts/package.ts index 303ba3b..0aafe65 100644 --- a/scripts/package.ts +++ b/scripts/package.ts @@ -337,17 +337,18 @@ async function buildBundledPackage( "--clobber", ); } - // Verified against the digest `deno task sync` recorded from GitHub's own Releases API, the - // same role npm's `dist.integrity` plays for the sibling `claude-code-agent` and `codex-agent` - // — this catches upstream replacing an asset under a tag this plugin already pinned. + // A GitHub-release download is checked against the digest `deno task sync` recorded from the + // Releases API, which catches upstream replacing an asset under a tag this plugin already + // pinned. An npm archive was already checked against its `dist.integrity` SRI above, before it + // was written, so only the release branch has a digest left to verify here. const actualSha256 = npmAsset ? undefined : await sha256Hex(archive); if (!npmAsset && actualSha256 !== expectedSha256) { throw new Error( `${plan.asset} sha256 ${actualSha256} does not match ${LOCK_PATH}'s pinned ${expectedSha256}; run 'deno task sync'`, ); } - // Upstream ships the CLI as the sole entry at the archive root, under the same name this - // package uses for it. + // The CLI sits at the archive root in a GitHub release and under `package/bin/` in an npm + // tarball; either way the staged file keeps the name this package asks the host to spawn. await extractEntry( archive, npmAsset