diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6484500..af2d2d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,5 +35,7 @@ jobs: - name: Test run: deno task test - - 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 e8b3a0c..024ea8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -310,12 +310,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 @@ -420,9 +421,9 @@ the run. covers packaging and `tests/model_route_test.ts` drives the platform model route against a scripted spawn. `tests/host-simulator.ts` drives the plugin the way Ora's host does and needs a real CLI, staged or on PATH. CI runs - `check`, `lint`, `test` and the archive integrity tests, none of which needs a - CLI. 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 + `check`, `lint`, `test` and the `scripts/` tests, none of which needs a CLI. + 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. diff --git a/deno.json b/deno.json index 42d0e10..89c56a2 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 tests/model_route_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 tests/model_route_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 --allow-env tests/package_test.ts tests/model_route_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(); 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