diff --git a/AGENTS.md b/AGENTS.md index 50ad7deb..202c8be3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,8 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them): `pnpm gen:bindings`. - `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion benches vs budgets). Budgets: `perf/budgets.json`. +- `pnpm release:prepare ` — the release commit's changes: app version everywhere, + `Cargo.lock`, and the plugin API snapshot when `wit/` moved to a new version. ## Before opening a PR diff --git a/package.json b/package.json index 1e6dee1f..82767df5 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "dev:status": "node scripts/dev-status.ts", "e2e": "playwright test", "perf": "node perf/run.ts", - "perf:rust": "node perf/rust.ts" + "perf:rust": "node perf/rust.ts", + "release:prepare": "node scripts/release-prepare.ts" }, "simple-git-hooks": { "pre-commit": "pnpm lint-staged" diff --git a/plugins/README.md b/plugins/README.md index b4ebf4e7..5a206244 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -978,9 +978,10 @@ Checked automatically: released version `wit/` must match it exactly, so additions start with a version bump. Each snapshot passed this check against the one before, so one snapshot covers every release. -- Releasing: when the `wit/` version changes, copy `wit/*.wit` over - `tests/api/released/` in the release commit. The release workflow refuses a - tag whose `wit/` version differs from the snapshot's. +- Releasing: `pnpm release:prepare ` makes the release commit's + changes; when the `wit/` version changed, it copies `wit/*.wit` over + `tests/api/released/`. The release workflow refuses a tag whose `wit/` + version differs from the snapshot's. - `crates/ide-plugin/tests/api_compat.rs`: the versions agree, the SDK's copy matches, the version check (rules 4, 5); a simulated next-minor host (every import stubbed at the newer version, plus extra functions and an extra diff --git a/scripts/release-prepare.test.ts b/scripts/release-prepare.test.ts new file mode 100644 index 00000000..efc2c7d4 --- /dev/null +++ b/scripts/release-prepare.test.ts @@ -0,0 +1,84 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + SNAPSHOT, + apiVersion, + cargoVersion, + parseVersion, + setCargoVersion, + setJsonVersion, + syncApiSnapshot, +} from "./release-prepare.ts"; + +const dirs: string[] = []; +afterEach(() => dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true }))); + +function repo(wit: Record, snapshot: Record): string { + const root = mkdtempSync(join(tmpdir(), "release-prepare-")); + dirs.push(root); + for (const [dir, files] of [ + ["wit", wit], + [SNAPSHOT, snapshot], + ] as const) { + mkdirSync(join(root, dir), { recursive: true }); + for (const [name, text] of Object.entries(files)) writeFileSync(join(root, dir, name), text); + } + return root; +} +const pkg = (version: string) => `package openidea:plugin@${version};\n`; + +describe("versions", () => { + it("accepts x.y.z with or without a leading v", () => { + expect(parseVersion("0.1.38")).toBe("0.1.38"); + expect(parseVersion("v0.1.38")).toBe("0.1.38"); + expect(() => parseVersion("0.1")).toThrow(); + expect(() => parseVersion(undefined)).toThrow(); + }); + + it("changes only the workspace version in Cargo.toml", () => { + const toml = [ + "[workspace]", + 'members = ["crates/*"]', + "", + "[workspace.package]", + 'authors = ["a"]', + 'version = "0.1.37"', + 'edition = "2024"', + "", + "[workspace.dependencies]", + 'foo = { version = "0.1.37" }', + "", + ].join("\n"); + expect(cargoVersion(toml)).toBe("0.1.37"); + const next = setCargoVersion(toml, "0.1.38"); + expect(cargoVersion(next)).toBe("0.1.38"); + expect(next).toContain('foo = { version = "0.1.37" }'); + expect(next.split("\n")).toHaveLength(toml.split("\n").length); + }); + + it("keeps a JSON file's formatting", () => { + const json = '{\n "name": "openidea",\n "version": "0.1.37",\n "private": true\n}\n'; + expect(setJsonVersion(json, "0.1.38")).toBe(json.replace("0.1.37", "0.1.38")); + }); +}); + +describe("plugin API snapshot", () => { + it("copies wit/ over the snapshot when the API version moved, dropping stale files", () => { + const root = repo( + { "plugin.wit": pkg("1.2.0"), "indexing.wit": pkg("1.2.0") }, + { "plugin.wit": pkg("1.1.0"), "old.wit": pkg("1.1.0"), "README.md": "kept" }, + ); + expect(syncApiSnapshot(root)).toEqual({ from: "1.1.0", to: "1.2.0", copied: true }); + expect(readdirSync(join(root, SNAPSHOT)).sort()).toEqual(["README.md", "indexing.wit", "plugin.wit"]); + expect(apiVersion(readFileSync(join(root, SNAPSHOT, "plugin.wit"), "utf8"))).toBe("1.2.0"); + }); + + it("leaves the snapshot alone when the API is unchanged", () => { + const root = repo({ "plugin.wit": pkg("1.1.0") + "// edited\n" }, { "plugin.wit": pkg("1.1.0") }); + expect(syncApiSnapshot(root)).toEqual({ from: "1.1.0", to: "1.1.0", copied: false }); + expect(readFileSync(join(root, SNAPSHOT, "plugin.wit"), "utf8")).toBe(pkg("1.1.0")); + }); +}); diff --git a/scripts/release-prepare.ts b/scripts/release-prepare.ts new file mode 100644 index 00000000..ae6cd3ac --- /dev/null +++ b/scripts/release-prepare.ts @@ -0,0 +1,114 @@ +/** + * Prepares the release commit: `pnpm release:prepare 0.1.38` sets the app version in + * Cargo.toml, package.json and src-tauri/tauri.conf.json, refreshes Cargo.lock, and, + * when wit/ carries a newer plugin API than the released snapshot, copies wit/*.wit + * over sdk/rust/openidea-plugin/tests/api/released/ (the release workflow refuses a + * tag whose wit/ and snapshot versions differ). Commit the result as `chore: v`. + */ +import { spawnSync } from "node:child_process"; +import { copyFileSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const SNAPSHOT = "sdk/rust/openidea-plugin/tests/api/released"; + +/** `0.1.38` from `0.1.38` or `v0.1.38`; throws for anything else. */ +export function parseVersion(arg: string | undefined): string { + const version = arg?.replace(/^v/, ""); + if (!version || !/^\d+\.\d+\.\d+$/.test(version)) throw new Error(`expected a version like 0.1.38, got ${arg}`); + return version; +} + +function compare(a: string, b: string): number { + const [x, y] = [a, b].map((v) => v.split(".").map(Number)); + for (let i = 0; i < 3; i++) if (x![i] !== y![i]) return x![i]! - y![i]!; + return 0; +} + +/** The line index and value of `version` in `[workspace.package]`. */ +function workspaceVersionLine(lines: string[]): { index: number; version: string } { + const start = lines.findIndex((line) => line.trim() === "[workspace.package]"); + for (let i = start + 1; start >= 0 && i < lines.length && !lines[i]!.startsWith("["); i++) { + const match = /^version = "([^"]+)"$/.exec(lines[i]!.trim()); + if (match) return { index: i, version: match[1]! }; + } + throw new Error("Cargo.toml: no version in [workspace.package]"); +} + +export function cargoVersion(toml: string): string { + return workspaceVersionLine(toml.split("\n")).version; +} + +export function setCargoVersion(toml: string, version: string): string { + const lines = toml.split("\n"); + const { index } = workspaceVersionLine(lines); + lines[index] = `version = "${version}"`; + return lines.join("\n"); +} + +/** Replaces the top-level `"version"` of a JSON file, keeping its formatting. */ +export function setJsonVersion(json: string, version: string): string { + const current = (JSON.parse(json) as { version?: unknown }).version; + if (typeof current !== "string") throw new Error("no top-level version"); + return json.replace(`"version": "${current}"`, `"version": "${version}"`); +} + +/** `1.2.0` from a `package openidea:plugin@1.2.0;` line. */ +export function apiVersion(wit: string): string { + const match = /^package openidea:plugin@([^;]+);$/m.exec(wit); + if (!match) throw new Error("no `package openidea:plugin@…;` line"); + return match[1]!; +} + +/** + * Copies `wit/*.wit` over the snapshot when wit/ declares another API version, and + * returns the versions; leaves the snapshot alone otherwise (no API change to release). + */ +export function syncApiSnapshot(root: string): { from: string; to: string; copied: boolean } { + const wit = join(root, "wit"); + const snapshot = join(root, SNAPSHOT); + const to = apiVersion(readFileSync(join(wit, "plugin.wit"), "utf8")); + const from = apiVersion(readFileSync(join(snapshot, "plugin.wit"), "utf8")); + if (from === to) return { from, to, copied: false }; + for (const file of readdirSync(snapshot)) if (file.endsWith(".wit")) rmSync(join(snapshot, file)); + for (const file of readdirSync(wit)) if (file.endsWith(".wit")) copyFileSync(join(wit, file), join(snapshot, file)); + return { from, to, copied: true }; +} + +function edit(path: string, change: (text: string) => string) { + const before = readFileSync(path, "utf8"); + const after = change(before); + if (after === before) throw new Error(`${path}: nothing changed`); + writeFileSync(path, after); +} + +function main() { + const root = fileURLToPath(new URL("..", import.meta.url)); + const version = parseVersion(process.argv[2]); + const current = cargoVersion(readFileSync(join(root, "Cargo.toml"), "utf8")); + if (compare(version, current) <= 0) throw new Error(`${version} is not newer than the current ${current}`); + + edit(join(root, "Cargo.toml"), (text) => setCargoVersion(text, version)); + edit(join(root, "package.json"), (text) => setJsonVersion(text, version)); + edit(join(root, "src-tauri/tauri.conf.json"), (text) => setJsonVersion(text, version)); + const cargo = spawnSync("cargo", ["update", "--workspace"], { cwd: root, stdio: "inherit" }); + if (cargo.status !== 0) throw new Error("cargo update --workspace failed"); + console.log(`version ${current} -> ${version} (Cargo.toml, Cargo.lock, package.json, tauri.conf.json)`); + + const api = syncApiSnapshot(root); + console.log( + api.copied + ? `plugin API ${api.from} -> ${api.to}: copied wit/*.wit to ${SNAPSHOT}/` + : `plugin API ${api.to}: unchanged since the last release`, + ); + console.log(`next: commit as "chore: v${version}", then tag v${version}`); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + try { + main(); + } catch (error) { + console.error(`release:prepare: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } +}