Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
38 changes: 18 additions & 20 deletions .github/workflows/upstream.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -72,28 +75,23 @@ 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
echo "::error::${TAG} already exists; the version was bumped without a release"
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"
Expand Down
19 changes: 10 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@
"@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",
"simulate": "deno run --allow-run --allow-read --allow-env --allow-net tests/host-simulator.ts",
"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": {
Expand Down
48 changes: 48 additions & 0 deletions scripts/bump-version.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
88 changes: 88 additions & 0 deletions scripts/bump-version.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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();
11 changes: 6 additions & 5 deletions scripts/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading