From 9334015262df81efa7e4aa9d9c2a951946166adb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Mar 2026 17:37:30 -0700 Subject: [PATCH] fix: ship bundled plugin runtime sidecars --- docs/reference/RELEASING.md | 3 + scripts/lib/bundled-plugin-build-entries.mjs | 42 +++++++++++- scripts/lib/plugin-npm-release.ts | 4 +- scripts/openclaw-npm-release-check.ts | 66 ++++++++++--------- .../stage-bundled-plugin-runtime.test.ts | 29 ++++++++ test/openclaw-npm-release-check.test.ts | 31 ++++++++- test/plugin-npm-release.test.ts | 2 +- test/release-check.test.ts | 17 +++++ 8 files changed, 156 insertions(+), 38 deletions(-) diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index ddf75db9723..946e534279d 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -18,11 +18,14 @@ OpenClaw has three public release lanes: - Stable release version: `YYYY.M.D` - Git tag: `vYYYY.M.D` +- Stable correction release version: `YYYY.M.D-N` + - Git tag: `vYYYY.M.D-N` - Beta prerelease version: `YYYY.M.D-beta.N` - Git tag: `vYYYY.M.D-beta.N` - Do not zero-pad month or day - `latest` means the current stable npm release - `beta` means the current prerelease npm release +- Stable correction releases also publish to npm `latest` - Every OpenClaw release ships the npm package and macOS app together ## Release cadence diff --git a/scripts/lib/bundled-plugin-build-entries.mjs b/scripts/lib/bundled-plugin-build-entries.mjs index 7b1705c180c..e8a567b87a7 100644 --- a/scripts/lib/bundled-plugin-build-entries.mjs +++ b/scripts/lib/bundled-plugin-build-entries.mjs @@ -2,6 +2,8 @@ import fs from "node:fs"; import path from "node:path"; import { shouldBuildBundledCluster } from "./optional-bundled-clusters.mjs"; +const TOP_LEVEL_PUBLIC_SURFACE_EXTENSIONS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]); + function readBundledPluginPackageJson(packageJsonPath) { if (!fs.existsSync(packageJsonPath)) { return null; @@ -30,6 +32,39 @@ function collectPluginSourceEntries(packageJson) { return packageEntries.length > 0 ? packageEntries : ["./index.ts"]; } +function collectTopLevelPublicSurfaceEntries(pluginDir) { + if (!fs.existsSync(pluginDir)) { + return []; + } + + return fs + .readdirSync(pluginDir, { withFileTypes: true }) + .flatMap((dirent) => { + if (!dirent.isFile()) { + return []; + } + + const ext = path.extname(dirent.name); + if (!TOP_LEVEL_PUBLIC_SURFACE_EXTENSIONS.has(ext)) { + return []; + } + + const normalizedName = dirent.name.toLowerCase(); + if ( + normalizedName.endsWith(".d.ts") || + normalizedName.includes(".test.") || + normalizedName.includes(".spec.") || + normalizedName.includes(".fixture.") || + normalizedName.includes(".snap") + ) { + return []; + } + + return [`./${dirent.name}`]; + }) + .toSorted((left, right) => left.localeCompare(right)); +} + export function collectBundledPluginBuildEntries(params = {}) { const cwd = params.cwd ?? process.cwd(); const env = params.env ?? process.env; @@ -57,7 +92,12 @@ export function collectBundledPluginBuildEntries(params = {}) { id: dirent.name, hasPackageJson: packageJson !== null, packageJson, - sourceEntries: collectPluginSourceEntries(packageJson), + sourceEntries: Array.from( + new Set([ + ...collectPluginSourceEntries(packageJson), + ...collectTopLevelPublicSurfaceEntries(pluginDir), + ]), + ), }); } diff --git a/scripts/lib/plugin-npm-release.ts b/scripts/lib/plugin-npm-release.ts index 34f98e86f2f..48067d3e765 100644 --- a/scripts/lib/plugin-npm-release.ts +++ b/scripts/lib/plugin-npm-release.ts @@ -170,7 +170,7 @@ export function collectPublishablePluginPackageErrors( errors.push("package.json version must be non-empty."); } else if (parseReleaseVersion(packageVersion) === null) { errors.push( - `package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "${packageVersion}".`, + `package.json version must match YYYY.M.D, YYYY.M.D-N, or YYYY.M.D-beta.N; found "${packageVersion}".`, ); } if (!Array.isArray(extensions) || extensions.length === 0) { @@ -224,7 +224,7 @@ export function collectPublishablePluginPackages( const parsedVersion = parseReleaseVersion(version); if (parsedVersion === null) { validationErrors.push( - `${dir.name}: package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "${version}".`, + `${dir.name}: package.json version must match YYYY.M.D, YYYY.M.D-N, or YYYY.M.D-beta.N; found "${version}".`, ); continue; } diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts index 96ce8336bc9..f363770c201 100644 --- a/scripts/openclaw-npm-release-check.ts +++ b/scripts/openclaw-npm-release-check.ts @@ -18,17 +18,20 @@ type PackageJson = { export type ParsedReleaseVersion = { version: string; + baseVersion: string; channel: "stable" | "beta"; year: number; month: number; day: number; betaNumber?: number; + correctionNumber?: number; date: Date; }; export type ParsedReleaseTag = { version: string; packageVersion: string; + baseVersion: string; channel: "stable" | "beta"; correctionNumber?: number; date: Date; @@ -37,7 +40,8 @@ export type ParsedReleaseTag = { const STABLE_VERSION_REGEX = /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d?)$/; const BETA_VERSION_REGEX = /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d?)-beta\.(?[1-9]\d*)$/; -const CORRECTION_TAG_REGEX = /^(?\d{4}\.[1-9]\d?\.[1-9]\d?)-(?[1-9]\d*)$/; +const CORRECTION_VERSION_REGEX = + /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d?)-(?[1-9]\d*)$/; const EXPECTED_REPOSITORY_URL = "https://github.com/openclaw/openclaw"; const MAX_CALVER_DISTANCE_DAYS = 2; const REQUIRED_PACKED_PATHS = ["dist/control-ui/index.html"]; @@ -92,6 +96,7 @@ function parseDateParts( return { version, + baseVersion: `${year}.${month}.${day}`, channel, year, month, @@ -117,6 +122,20 @@ export function parseReleaseVersion(version: string): ParsedReleaseVersion | nul return parseDateParts(trimmed, betaMatch.groups, "beta"); } + const correctionMatch = CORRECTION_VERSION_REGEX.exec(trimmed); + if (correctionMatch?.groups) { + const parsedCorrection = parseDateParts(trimmed, correctionMatch.groups, "stable"); + const correctionNumber = Number.parseInt(correctionMatch.groups.correction ?? "", 10); + if (parsedCorrection === null || !Number.isInteger(correctionNumber) || correctionNumber < 1) { + return null; + } + + return { + ...parsedCorrection, + correctionNumber, + }; + } + return null; } @@ -131,36 +150,14 @@ export function parseReleaseTagVersion(version: string): ParsedReleaseTag | null return { version: trimmed, packageVersion: parsedVersion.version, + baseVersion: parsedVersion.baseVersion, channel: parsedVersion.channel, date: parsedVersion.date, - correctionNumber: undefined, + correctionNumber: parsedVersion.correctionNumber, }; } - const correctionMatch = CORRECTION_TAG_REGEX.exec(trimmed); - if (!correctionMatch?.groups) { - return null; - } - - const baseVersion = correctionMatch.groups.base ?? ""; - const parsedBaseVersion = parseReleaseVersion(baseVersion); - const correctionNumber = Number.parseInt(correctionMatch.groups.correction ?? "", 10); - if ( - parsedBaseVersion === null || - parsedBaseVersion.channel !== "stable" || - !Number.isInteger(correctionNumber) || - correctionNumber < 1 - ) { - return null; - } - - return { - version: trimmed, - packageVersion: parsedBaseVersion.version, - channel: "stable", - correctionNumber, - date: parsedBaseVersion.date, - }; + return null; } function startOfUtcDay(date: Date): number { @@ -227,7 +224,7 @@ export function collectReleaseTagErrors(params: { const parsedVersion = parseReleaseVersion(packageVersion); if (parsedVersion === null) { errors.push( - `package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "${packageVersion || ""}".`, + `package.json version must match YYYY.M.D, YYYY.M.D-N, or YYYY.M.D-beta.N; found "${packageVersion || ""}".`, ); } @@ -244,17 +241,24 @@ export function collectReleaseTagErrors(params: { } const expectedTag = packageVersion ? `v${packageVersion}` : ""; - const expectedCorrectionTag = parsedVersion?.channel === "stable" ? `${expectedTag}-N` : null; const matchesExpectedTag = parsedTag !== null && parsedVersion !== null && - parsedTag.packageVersion === parsedVersion.version && - parsedTag.channel === parsedVersion.channel; + parsedTag.channel === parsedVersion.channel && + (parsedTag.packageVersion === parsedVersion.version || + (parsedVersion.channel === "stable" && + parsedVersion.correctionNumber === undefined && + parsedTag.correctionNumber !== undefined && + parsedTag.baseVersion === parsedVersion.baseVersion)); if (!matchesExpectedTag) { errors.push( `Release tag ${releaseTag || ""} does not match package.json version ${ packageVersion || "" - }; expected ${expectedCorrectionTag ? `${expectedTag} or ${expectedCorrectionTag}` : expectedTag}.`, + }; expected ${ + parsedVersion?.channel === "stable" && parsedVersion.correctionNumber === undefined + ? `${expectedTag} or ${expectedTag}-N` + : expectedTag + }.`, ); } diff --git a/src/plugins/stage-bundled-plugin-runtime.test.ts b/src/plugins/stage-bundled-plugin-runtime.test.ts index a0cd5db4dd7..e3aecd629d4 100644 --- a/src/plugins/stage-bundled-plugin-runtime.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime.test.ts @@ -77,6 +77,35 @@ describe("stageBundledPluginRuntime", () => { expect(runtimeModule.value).toBe(1); }); + it("stages root runtime sidecars that bundled plugin boundaries resolve directly", () => { + const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-sidecars-"); + const distPluginDir = path.join(repoRoot, "dist", "extensions", "whatsapp"); + fs.mkdirSync(distPluginDir, { recursive: true }); + fs.writeFileSync(path.join(distPluginDir, "index.js"), "export default {};\n", "utf8"); + fs.writeFileSync( + path.join(distPluginDir, "light-runtime-api.js"), + "export const light = true;\n", + "utf8", + ); + fs.writeFileSync( + path.join(distPluginDir, "runtime-api.js"), + "export const heavy = true;\n", + "utf8", + ); + + stageBundledPluginRuntime({ repoRoot }); + + const runtimePluginDir = path.join(repoRoot, "dist-runtime", "extensions", "whatsapp"); + expect(fs.existsSync(path.join(runtimePluginDir, "light-runtime-api.js"))).toBe(true); + expect(fs.existsSync(path.join(runtimePluginDir, "runtime-api.js"))).toBe(true); + expect(fs.readFileSync(path.join(runtimePluginDir, "light-runtime-api.js"), "utf8")).toContain( + "../../../dist/extensions/whatsapp/light-runtime-api.js", + ); + expect(fs.readFileSync(path.join(runtimePluginDir, "runtime-api.js"), "utf8")).toContain( + "../../../dist/extensions/whatsapp/runtime-api.js", + ); + }); + it("keeps plugin command registration on the canonical dist graph when loaded from dist-runtime", async () => { const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-commands-"); const distPluginDir = path.join(repoRoot, "dist", "extensions", "demo"); diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts index 643b29aa19d..30e6709423a 100644 --- a/test/openclaw-npm-release-check.test.ts +++ b/test/openclaw-npm-release-check.test.ts @@ -14,6 +14,7 @@ describe("parseReleaseVersion", () => { it("parses stable CalVer releases", () => { expect(parseReleaseVersion("2026.3.10")).toMatchObject({ version: "2026.3.10", + baseVersion: "2026.3.10", channel: "stable", year: 2026, month: 3, @@ -24,6 +25,7 @@ describe("parseReleaseVersion", () => { it("parses beta CalVer releases", () => { expect(parseReleaseVersion("2026.3.10-beta.2")).toMatchObject({ version: "2026.3.10-beta.2", + baseVersion: "2026.3.10", channel: "beta", year: 2026, month: 3, @@ -32,20 +34,33 @@ describe("parseReleaseVersion", () => { }); }); + it("parses stable correction releases", () => { + expect(parseReleaseVersion("2026.3.10-1")).toMatchObject({ + version: "2026.3.10-1", + baseVersion: "2026.3.10", + channel: "stable", + year: 2026, + month: 3, + day: 10, + correctionNumber: 1, + }); + }); + it("rejects legacy and malformed release formats", () => { - expect(parseReleaseVersion("2026.3.10-1")).toBeNull(); expect(parseReleaseVersion("2026.03.09")).toBeNull(); expect(parseReleaseVersion("v2026.3.10")).toBeNull(); expect(parseReleaseVersion("2026.2.30")).toBeNull(); + expect(parseReleaseVersion("2026.3.10-0")).toBeNull(); expect(parseReleaseVersion("2.0.0-beta2")).toBeNull(); }); }); describe("parseReleaseTagVersion", () => { - it("accepts fallback correction tags for stable releases", () => { + it("accepts correction release tags", () => { expect(parseReleaseTagVersion("2026.3.10-2")).toMatchObject({ version: "2026.3.10-2", - packageVersion: "2026.3.10", + packageVersion: "2026.3.10-2", + baseVersion: "2026.3.10", channel: "stable", correctionNumber: 2, }); @@ -180,6 +195,16 @@ describe("collectReleaseTagErrors", () => { ).toEqual([]); }); + it("accepts correction package versions paired with matching correction tags", () => { + expect( + collectReleaseTagErrors({ + packageVersion: "2026.3.10-1", + releaseTag: "v2026.3.10-1", + now: new Date("2026-03-10T00:00:00Z"), + }), + ).toEqual([]); + }); + it("rejects beta package versions paired with fallback correction tags", () => { expect( collectReleaseTagErrors({ diff --git a/test/plugin-npm-release.test.ts b/test/plugin-npm-release.test.ts index 383d97b9ab9..af718c53d75 100644 --- a/test/plugin-npm-release.test.ts +++ b/test/plugin-npm-release.test.ts @@ -110,7 +110,7 @@ describe("collectPublishablePluginPackageErrors", () => { ).toEqual([ 'package name must start with "@openclaw/"; found "broken".', "package.json private must not be true.", - 'package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "latest".', + 'package.json version must match YYYY.M.D, YYYY.M.D-N, or YYYY.M.D-beta.N; found "latest".', "openclaw.extensions must contain only non-empty strings.", ]); }); diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 070d00831c8..cb158663f6a 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -137,8 +137,13 @@ describe("collectMissingPackPaths", () => { expect.arrayContaining([ "dist/channel-catalog.json", "dist/control-ui/index.html", + "dist/extensions/matrix/helper-api.js", + "dist/extensions/matrix/runtime-api.js", + "dist/extensions/matrix/thread-bindings-runtime.js", "dist/extensions/matrix/openclaw.plugin.json", "dist/extensions/matrix/package.json", + "dist/extensions/whatsapp/light-runtime-api.js", + "dist/extensions/whatsapp/runtime-api.js", "dist/extensions/whatsapp/openclaw.plugin.json", "dist/extensions/whatsapp/package.json", ]), @@ -159,6 +164,18 @@ describe("collectMissingPackPaths", () => { ]), ).toEqual([]); }); + + it("requires bundled plugin runtime sidecars that dynamic plugin boundaries resolve at runtime", () => { + expect(requiredBundledPluginPackPaths).toEqual( + expect.arrayContaining([ + "dist/extensions/matrix/helper-api.js", + "dist/extensions/matrix/runtime-api.js", + "dist/extensions/matrix/thread-bindings-runtime.js", + "dist/extensions/whatsapp/light-runtime-api.js", + "dist/extensions/whatsapp/runtime-api.js", + ]), + ); + }); }); describe("collectPackUnpackedSizeErrors", () => {