chore(release): prepare the release commit with one command (#1672)

pnpm release:prepare <version> sets the app version in Cargo.toml, package.json and tauri.conf.json, refreshes Cargo.lock's workspace entries, and copies wit/*.wit over the plugin API snapshot when wit/ declares a newer API. The release workflow refused a tag with a stale snapshot, but the copy itself was a manual step.
This commit is contained in:
l0ng-ai authored and GitHub committed 2026-11-06 10:30:56 +08:00
1 parent ed08b96382
commit 37f0897eb7
5 files changed
+206 -4

No files matched your search

+2
View File
@@ -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 <version>` — 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
+2 -1
View File
@@ -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"
+4 -3
View File
@@ -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 <version>` 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
+84
View File
@@ -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<string, string>, snapshot: Record<string, string>): 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"));
});
});
+114
View File
@@ -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<version>`.
*/
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:[email protected];` 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);
}
}