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
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ body:
- opencode-ram-monitor
- opencode-agent-prompt-inheritance
- opencode-adversarial-review
- opencode-auto-advisor
- opencode-plugins (this monorepo itself)
- Other
validations:
Expand Down
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/feature_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ body:
- opencode-ram-monitor
- opencode-agent-prompt-inheritance
- opencode-adversarial-review
- opencode-auto-advisor
- opencode-plugins (this monorepo itself)
validations:
required: true
Expand Down
1 change: 1 addition & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- [ ] opencode-ram-monitor
- [ ] opencode-agent-prompt-inheritance
- [ ] opencode-adversarial-review
- [ ] opencode-auto-advisor
- [ ] opencode-plugins (this monorepo itself)
- [ ] Other: _______

Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,8 @@ jobs:

- name: Test
run: bun run test

# Reads npm and publishes nothing: shows what the Release job would publish, and fails here,
# before merge, on anything the guard would reject there.
- name: Release plan
run: bun run release:check
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Five packages have V2 ports, one is V2-only, and one remains V1-only. [`docs/v1-
- **MUST** keep V1 and V2 lines apart. The loader and timeline V1 releases are frozen but available by exact pin. Both lines may share the default config directory, so use separate profiles (`OPENCODE_CONFIG_DIR`) for side-by-side testing.
- **SHOULD** manage V2 targets with `opencode plugin check` / `opencode plugin update`; a restart or reload alone does not upgrade a cached target, and exact pins stay fixed.
- **MUST** keep plugin majors in step with OpenCode majors: a package in release class `vN` only ever publishes major N. Ship breaking changes within an OpenCode major as a `minor` changeset, never `major`. 3.x is reserved for OpenCode V3. `bun run release:changesets` (CI: Changesets workflow) and the publish guard enforce this.
- When a package moves to a new OpenCode major, change its `releaseClass` in both `tools/release-channels.json` and the guard's `APPROVED_POLICY` in the same PR as its `major` changeset. That bump is the only `major` the guard allows.
- When a package moves to a new OpenCode major, change its `releaseClass` in `tools/release-channels.json` in the same PR as its `major` changeset; the guard rejects a class above `CURRENT_OPENCODE_MAJOR`. That bump is the only `major` the guard allows.
- Channel source of truth: [`tools/release-channels.json`](tools/release-channels.json), enforced by [`tools/release-channel-guard.ts`](tools/release-channel-guard.ts).
- **MUST** keep `@opencode/plugin` a required peer in server plugins: the server only serves its own copy from OpenCode 2.0.25, and older hosts fail with `Cannot find package '@opencode/plugin'` when the plugin lacks one. TUI-only plugins mark it optional (`peerDependenciesMeta`) because every 2.x TUI serves `@opencode/plugin/tui` itself; a combined server + TUI package (output-styles) keeps it required. Details: [`docs/opencode-v2-plugin-runtime.md`](docs/opencode-v2-plugin-runtime.md).

Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ For changes intended for the next package release, add a changeset with `bun run
Plugin majors track OpenCode majors, not strict semver. A package in release class `vN` (see `tools/release-channels.json`) only ever publishes major N, so:

- Use `patch` for fixes and `minor` for features **and for breaking changes** within the same OpenCode major. Say "Breaking" in the changeset text so it lands in the changelog.
- Use `major` only to move a package to a new OpenCode major. Change its `releaseClass` in `tools/release-channels.json` and the guard's `APPROVED_POLICY` in the same PR.
- Use `major` only to move a package to a new OpenCode major. Change its `releaseClass` in `tools/release-channels.json` in the same PR (the guard rejects a class above the current OpenCode major, `CURRENT_OPENCODE_MAJOR` in `tools/release-channel-guard.ts`).
- A new package needs one entry in `tools/release-channels.json`; the guard rejects a workspace package without one. Its first release is a first publish (npm has never seen it), which CI's `Release plan` step shows before merge.

`bun run release:changesets` checks pending changesets and package manifests against this rule without touching the registry. The Changesets workflow runs it on pull requests.

Expand Down
3 changes: 2 additions & 1 deletion packages/opencode-auto-advisor/scripts/timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ describe("fetchMessages", () => {
expect(messages.map((m) => m.id)).toEqual(["m1", "m2", "m3", "m4", "m5", "m6"]);
expect(messages.every((m) => m.text?.length === 1_000_003)).toBe(true);
}
});
// Five rounds of 3 MB pages through real subprocesses: well over 5 s on a slow CI runner.
}, 30_000);
});

describe("bun run timeline", () => {
Expand Down
194 changes: 181 additions & 13 deletions tools/release-channel-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
checkClassMajor,
checkPendingChangesets,
createChangesetsEnvironment,
createPublicRegistryClient,
PUBLIC_REGISTRY,
type RegistryClient,
type RegistrySnapshot,
Expand Down Expand Up @@ -70,13 +71,14 @@ function snapshot(
};
}

/** An override of `null` is a package npm has never seen (a 404). */
function createRegistryClient(
overrides: Partial<Record<PackageName, RegistrySnapshot | Error>> = {},
overrides: Record<string, RegistrySnapshot | Error | null> = {},
): RegistryClient {
return async (name) => {
const override = overrides[name as PackageName];
const override = overrides[name];
if (override instanceof Error) throw override;
if (override) return override;
if (override !== undefined) return override;
const version = registryVersions[name as PackageName];
return snapshot([version], version);
};
Expand Down Expand Up @@ -371,7 +373,7 @@ describe("release-channel-guard policy matrix", () => {
);
});

test("the retired frozen policy fails exact-match validation", async () => {
test("the retired frozen policy entry is rejected as malformed", async () => {
const cwd = await createFixture();
const policyFile = path.join(cwd, "tools", "release-channels.json");
const policy = JSON.parse(await readFile(policyFile, "utf8")) as {
Expand All @@ -386,7 +388,7 @@ describe("release-channel-guard policy matrix", () => {

await expectGuardRejects(
buildReleasePlan({ cwd, registryClient: createRegistryClient() }),
"does not match the approved release class",
`invalid policy entry for ${packageNames.agents}`,
);
});

Expand Down Expand Up @@ -473,8 +475,8 @@ describe("release-channel-guard policy matrix", () => {
expect(invocations).toBe(0);
});

test("R1 the policy requires the approved V2 class for ram-monitor", async () => {
const cwd = await createFixture();
test("R1 a V1 class cannot claim a package already on major 2", async () => {
const cwd = await createFixture({ [packageNames.ram]: "2.1.0" });
const policyFile = path.join(cwd, "tools", "release-channels.json");
const policy = JSON.parse(await readFile(policyFile, "utf8")) as {
packages: Record<string, unknown>;
Expand All @@ -484,7 +486,7 @@ describe("release-channel-guard policy matrix", () => {

await expectGuardRejects(
buildReleasePlan({ cwd, registryClient: createRegistryClient() }),
"does not match the approved release class",
"must remain on V1 major 1",
);
});

Expand Down Expand Up @@ -1380,10 +1382,12 @@ class LoopbackRegistry {
private server: Server | undefined;
private address = "";

constructor() {
/** `unpublished` packages start absent, so their metadata is a 404 until published. */
constructor(unpublished: readonly string[] = []) {
for (const [name, version] of Object.entries(registryVersions) as Array<
[PackageName, string]
>) {
if (unpublished.includes(name)) continue;
this.packages.set(name, {
versions: new Set([version]),
distTags: { latest: version },
Expand Down Expand Up @@ -1434,7 +1438,9 @@ class LoopbackRegistry {
return;
}
if (request.method === "GET") {
this.respondJson(response, 200, this.readPackument(pathname));
const packument = this.readPackument(pathname);
if (packument) this.respondJson(response, 200, packument);
else this.respondJson(response, 404, { error: "Not found" });
return;
}
if (request.method === "PUT") {
Expand All @@ -1449,7 +1455,7 @@ class LoopbackRegistry {
private readPackument(pathname: string) {
const name = decodeURIComponent(pathname.slice(1)) as PackageName;
const packageState = this.packages.get(name);
if (!packageState) return { error: "E404" };
if (!packageState) return undefined;
return {
name,
"dist-tags": packageState.distTags,
Expand All @@ -1469,8 +1475,11 @@ class LoopbackRegistry {
const version = versions.at(-1);
if (!version) throw new Error(`loopback publish omitted version for ${name}`);
const tag = Object.keys(body["dist-tags"] ?? {})[0] ?? "latest";
const packageState = this.packages.get(name as PackageName);
if (!packageState) throw new Error(`loopback publish unknown package ${name}`);
let packageState = this.packages.get(name as PackageName);
if (!packageState) {
packageState = { versions: new Set(), distTags: {} };
this.packages.set(name as PackageName, packageState);
}
packageState.versions.add(version);
if (body["dist-tags"]) Object.assign(packageState.distTags, body["dist-tags"]);
this.publishes.push({ name, version, tag });
Expand Down Expand Up @@ -1593,6 +1602,165 @@ describe("R11 disposable real Changesets publication", () => {
await registry.close();
}
}, 20_000);

test("first-publishes a package the registry has never seen", async () => {
const registry = new LoopbackRegistry([packageNames.advisor]);
const registryUrl = await registry.start();
const fixture = await createFixture();
await linkChangesetsCli(fixture);
await writeFile(
path.join(fixture, "bun.lock"),
'{\n "lockfileVersion": 1,\n "workspaces": {}\n}\n',
);
const evidenceRoot = await createScratch("release-channel-registry-");
await mkdir(path.join(evidenceRoot, "home"), { recursive: true });
await mkdir(path.join(evidenceRoot, "npm-cache"), { recursive: true });
const env = { ...publishEnvironment(), ...runtimeEnvironment(evidenceRoot) };

try {
const plan = await runReleaseGuard({
cwd: fixture,
mode: "publish",
env,
registryClient: createPublicRegistryClient(`${registryUrl}/`),
runChangesets: runRealChangesets(registryUrl),
testOnlyRegistry: registryUrl,
testOnlyNoGitTag: true,
});

expect(plan.unpublished.map((item) => [item.name, item.firstPublish])).toEqual([
[packageNames.advisor, true],
]);
expect(registry.publishes).toEqual([
{ name: packageNames.advisor, version: "2.0.0", tag: "latest" },
]);
expect(registry.packages.get(packageNames.advisor)?.distTags.latest).toBe("2.0.0");
} finally {
await registry.close();
}
}, 20_000);
});

describe("new plugins", () => {
const newPlugin = "@capybearista/opencode-new-plugin";

async function addPolicyEntry(cwd: string, name: string, entry: Record<string, unknown>) {
const policyFile = path.join(cwd, "tools", "release-channels.json");
const policy = JSON.parse(await readFile(policyFile, "utf8")) as {
packages: Record<string, unknown>;
};
policy.packages[name] = entry;
await writeJson(policyFile, policy);
}

test("a package npm has never seen is planned as a first publish", async () => {
const cwd = await createFixture();
const output: string[] = [];

const plan = await runReleaseGuard({
cwd,
mode: "check",
registryClient: createRegistryClient({ [packageNames.advisor]: null }),
output: (line) => output.push(line),
});

expect(plan.channel).toBe("latest");
expect(plan.unpublished.map((item) => [item.name, item.firstPublish])).toEqual([
[packageNames.advisor, true],
]);
expect(output.join("\n")).toContain(`${packageNames.advisor}@2.0.0 first publish -> latest`);
});

test("a first publish must still use its class major", async () => {
const cwd = await createFixture({ [packageNames.advisor]: "1.0.0" });

await expectGuardRejects(
buildReleasePlan({
cwd,
registryClient: createRegistryClient({ [packageNames.advisor]: null }),
}),
"may only publish major 2 from the v2 release class",
);
});

test("a new plugin needs only its policy entry, not a guard change", async () => {
const cwd = await createFixture({}, { extraPackage: { name: newPlugin, version: "2.0.0" } });
await addPolicyEntry(cwd, newPlugin, { releaseClass: "v2", channel: "latest" });

const plan = await buildReleasePlan({
cwd,
registryClient: createRegistryClient({ [newPlugin]: null }),
});

expect(plan.unpublished.map((item) => item.name)).toEqual([newPlugin]);
expect(plan.channel).toBe("latest");
});

test("a workspace package missing from the policy is named in the error", async () => {
const cwd = await createFixture({}, { extraPackage: { name: newPlugin, version: "2.0.0" } });

await expectGuardRejects(
buildReleasePlan({ cwd, registryClient: createRegistryClient() }),
`not in tools/release-channels.json: ${newPlugin}`,
);
});

test("a policy entry without a workspace package is named in the error", async () => {
const cwd = await createFixture({}, { omit: packageNames.review });

await expectGuardRejects(
buildReleasePlan({ cwd, registryClient: createRegistryClient() }),
`not in the workspace: ${packageNames.review}`,
);
});

test("a release class above the current OpenCode major is rejected", async () => {
const cwd = await createFixture({ [packageNames.advisor]: "3.0.0" });
await addPolicyEntry(cwd, packageNames.advisor, { releaseClass: "v3", channel: "latest" });

await expectGuardRejects(
buildReleasePlan({
cwd,
registryClient: createRegistryClient({ [packageNames.advisor]: null }),
}),
"OpenCode 2",
);
});

test("malformed policy entries are rejected", async () => {
for (const entry of [
{ releaseClass: "2", channel: "latest" },
{ releaseClass: "v2", channel: "beta" },
{ releaseClass: "v2", channel: "latest", extra: true },
]) {
const cwd = await createFixture();
await addPolicyEntry(cwd, packageNames.advisor, entry);
await expectGuardRejects(
buildReleasePlan({ cwd, registryClient: createRegistryClient() }),
`invalid policy entry for ${packageNames.advisor}`,
);
}
});

test("the npm client reads a 404 as never published and fails on other statuses", async () => {
const statuses: Record<string, number> = { "/missing": 404, "/down": 503, "/auth": 401 };
const server = createServer((request, response) => {
const status = statuses[request.url ?? ""] ?? 500;
response.writeHead(status, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "status" }));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("test server did not bind");
const client = createPublicRegistryClient(`http://127.0.0.1:${address.port}/`);
try {
expect(await client("missing")).toBeNull();
await expect(client("down")).rejects.toThrow("HTTP 503");
await expect(client("auth")).rejects.toThrow("HTTP 401");
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});

test("the policy points at the public npm registry", () => {
Expand Down
Loading
Loading