chore(release): add patch release plan

This commit is contained in:
Vincent Koc 2026-04-27 21:51:04 -07:00
parent bdef58eeb9
commit 527b2eae82
No known key found for this signature in database
5 changed files with 272 additions and 0 deletions

View File

@ -7,11 +7,13 @@
- Add grouped root facades: `pluginRoot`, `fixtureSuites`, `staticInspection`, `reports`, `contracts`, `ci`, `runtime`, and `synthetic`.
- Expose contract capture, contract coverage, CI rollup, runtime profile, ref/profile diff, import-loop, and synthetic probe helpers from the root package API.
- Add a release follow-through guard that fails when Crabpot scripts regress to the legacy `advanced.js` bundle.
- Add a package-contents release guard for npm tarball entrypoints, examples, and README assets.
### Changed
- Move Crabpot integration scripts to the root public API while keeping Crabpot as the fixture corpus and report consumer.
- Keep generic artifact writing out of the root API; Crabpot-owned runner scripts write their own JSON outputs.
- Ship the README banner asset in the npm package so the published README does not reference a missing local image.
- Document the grouped root import path for embedding harnesses without turning the README into a full API dump.
## 0.3.0 - 2026-04-27

View File

@ -81,6 +81,15 @@ Draft release notes from the current `Unreleased` section with:
npm run release:notes
```
Preview the exact patch-release edits without changing files:
```bash
npm run release:plan
```
That prints the next patch version, release tag, changelog heading, Crabpot
source ref, and post-publish Crabpot package pin.
## Crabpot follow-through
Before tagging a release, update Crabpot's `pluginInspectorRef` to the

View File

@ -52,6 +52,7 @@
"check": "npm test && npm run release:contents",
"release:crabpot": "node scripts/check-crabpot-followthrough.mjs",
"release:contents": "node scripts/check-package-contents.mjs",
"release:plan": "node scripts/release-plan.mjs",
"release:readiness": "npm run release:local && npm run release:crabpot",
"release:local": "npm run check",
"release:notes": "node scripts/release-notes.mjs --unreleased",

203
scripts/release-plan.mjs Normal file
View File

@ -0,0 +1,203 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { extractReleaseNotes } from "./release-notes.mjs";
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const options = parseArgs(process.argv.slice(2));
const root = path.resolve(options.root);
const packageJson = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
const changelogText = readFileSync(path.join(root, "CHANGELOG.md"), "utf8");
const plan = buildReleasePlan({
packageName: packageJson.name,
packageVersion: packageJson.version,
changelogText,
releaseRef: options.releaseRef ?? gitHead(root),
releaseDate: options.date,
nextVersion: options.version,
});
if (options.json) {
console.log(JSON.stringify(plan, null, 2));
} else {
printReleasePlan(plan);
}
if (plan.status === "fail") {
process.exitCode = 1;
}
}
export function buildReleasePlan({
packageName = "@openclaw/plugin-inspector",
packageVersion,
changelogText,
releaseRef,
releaseDate = today(),
nextVersion,
}) {
const version = nextVersion ?? bumpPatch(packageVersion);
const checks = [
{
id: "version-advance",
status: compareVersions(version, packageVersion) > 0 ? "pass" : "fail",
message: `${version} is newer than ${packageVersion}`,
expected: `>${packageVersion}`,
actual: version,
},
{
id: "changelog-unreleased",
status: hasUnreleasedNotes(changelogText) ? "pass" : "fail",
message: "CHANGELOG.md has non-empty Unreleased notes",
},
];
return {
status: checks.some((check) => check.status === "fail") ? "fail" : "pass",
packageName,
currentVersion: packageVersion,
nextVersion: version,
releaseDate,
releaseRef,
tagName: `v${version}`,
changelogHeading: `## ${version} - ${releaseDate}`,
crabpotSourceRef: releaseRef,
crabpotPackagePin: `${packageName}@${version}`,
releaseNotes: safeReleaseNotes(changelogText),
checks,
steps: [
`update package.json version to ${version}`,
`move CHANGELOG.md Unreleased notes to "${`## ${version} - ${releaseDate}`}"`,
`set crabpot pluginInspectorRef to ${releaseRef}`,
"run npm run release:readiness",
`create and push annotated tag ${`v${version}`}`,
`after npm publish, set crabpot pluginInspectorPackage to ${packageName}@${version}`,
"run npm run release:crabpot -- --published",
],
};
}
function hasUnreleasedNotes(changelogText) {
try {
return extractReleaseNotes({ changelogText, version: "Unreleased" })
.split(/\r?\n/)
.some((line) => line.trim().startsWith("- "));
} catch {
return false;
}
}
function safeReleaseNotes(changelogText) {
try {
return extractReleaseNotes({ changelogText, version: "Unreleased" });
} catch {
return "";
}
}
function bumpPatch(version) {
const match = version?.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
throw new Error(`cannot infer next patch version from ${version}`);
}
return `${match[1]}.${match[2]}.${Number(match[3]) + 1}`;
}
function compareVersions(left, right) {
const leftParts = parseVersion(left);
const rightParts = parseVersion(right);
for (let index = 0; index < 3; index += 1) {
if (leftParts[index] !== rightParts[index]) {
return leftParts[index] - rightParts[index];
}
}
return 0;
}
function parseVersion(version) {
const match = version?.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
throw new Error(`invalid semver version: ${version}`);
}
return match.slice(1).map(Number);
}
function today() {
return new Date().toISOString().slice(0, 10);
}
function gitHead(root) {
return execFileSync("git", ["rev-parse", "HEAD"], {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
}
function parseArgs(argv) {
const options = {
root: ".",
date: today(),
json: false,
releaseRef: undefined,
version: undefined,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--root") {
options.root = argv[index + 1];
index += 1;
continue;
}
if (arg === "--date") {
options.date = argv[index + 1];
index += 1;
continue;
}
if (arg === "--json") {
options.json = true;
continue;
}
if (arg === "--ref") {
options.releaseRef = argv[index + 1];
index += 1;
continue;
}
if (arg === "--version") {
options.version = argv[index + 1];
index += 1;
continue;
}
if (!options.version) {
options.version = arg;
continue;
}
throw new Error(`unknown argument: ${arg}`);
}
return options;
}
function printReleasePlan(plan) {
console.log(`release plan: ${plan.status}`);
console.log(`package: ${plan.packageName}`);
console.log(`current version: ${plan.currentVersion}`);
console.log(`next version: ${plan.nextVersion}`);
console.log(`release date: ${plan.releaseDate}`);
console.log(`release ref: ${plan.releaseRef}`);
console.log(`tag: ${plan.tagName}`);
for (const check of plan.checks) {
console.log(`- ${check.status.toUpperCase()} ${check.id}: ${check.message}`);
if (check.status === "fail" && check.actual !== check.expected) {
console.log(` expected: ${check.expected}`);
console.log(` actual: ${check.actual}`);
}
}
console.log("steps:");
for (const step of plan.steps) {
console.log(`- ${step}`);
}
}

57
test/release-plan.test.js Normal file
View File

@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { buildReleasePlan } from "../scripts/release-plan.mjs";
const changelog = [
"# Changelog",
"",
"## Unreleased",
"",
"### Changed",
"",
"- Tighten package contents.",
"",
"## 0.3.0 - 2026-04-27",
"",
].join("\n");
test("release plan infers the next patch version", () => {
const plan = buildReleasePlan({
packageVersion: "0.3.0",
changelogText: changelog,
releaseDate: "2026-04-28",
releaseRef: "abc123",
});
assert.equal(plan.status, "pass");
assert.equal(plan.nextVersion, "0.3.1");
assert.equal(plan.tagName, "v0.3.1");
assert.equal(plan.changelogHeading, "## 0.3.1 - 2026-04-28");
assert.equal(plan.crabpotSourceRef, "abc123");
assert.equal(plan.crabpotPackagePin, "@openclaw/plugin-inspector@0.3.1");
});
test("release plan rejects non-advancing versions", () => {
const plan = buildReleasePlan({
packageVersion: "0.3.0",
nextVersion: "0.3.0",
changelogText: changelog,
releaseDate: "2026-04-28",
releaseRef: "abc123",
});
assert.equal(plan.status, "fail");
assert.equal(plan.checks.find((check) => check.id === "version-advance").status, "fail");
});
test("release plan requires unreleased changelog bullets", () => {
const plan = buildReleasePlan({
packageVersion: "0.3.0",
changelogText: "# Changelog\n\n## Unreleased\n",
releaseDate: "2026-04-28",
releaseRef: "abc123",
});
assert.equal(plan.status, "fail");
assert.equal(plan.checks.find((check) => check.id === "changelog-unreleased").status, "fail");
});