From 527b2eae828eda77ce0cdc067b0d4a92c785d097 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 27 Apr 2026 21:51:04 -0700 Subject: [PATCH] chore(release): add patch release plan --- CHANGELOG.md | 2 + docs/releasing.md | 9 ++ package.json | 1 + scripts/release-plan.mjs | 203 ++++++++++++++++++++++++++++++++++++++ test/release-plan.test.js | 57 +++++++++++ 5 files changed, 272 insertions(+) create mode 100644 scripts/release-plan.mjs create mode 100644 test/release-plan.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 43bf629..74bd173 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/releasing.md b/docs/releasing.md index cf53b50..0e7f341 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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 diff --git a/package.json b/package.json index 884077e..335c1e3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/release-plan.mjs b/scripts/release-plan.mjs new file mode 100644 index 0000000..1317e26 --- /dev/null +++ b/scripts/release-plan.mjs @@ -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}`); + } +} diff --git a/test/release-plan.test.js b/test/release-plan.test.js new file mode 100644 index 0000000..197acaf --- /dev/null +++ b/test/release-plan.test.js @@ -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"); +});