fix: ship bundled plugin runtime sidecars

This commit is contained in:
Peter Steinberger 2026-03-23 17:37:30 -07:00
parent ffd722bc2c
commit 9334015262
No known key found for this signature in database
8 changed files with 156 additions and 38 deletions

View File

@ -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

View File

@ -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),
]),
),
});
}

View File

@ -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;
}

View File

@ -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 = /^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)$/;
const BETA_VERSION_REGEX =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)-beta\.(?<beta>[1-9]\d*)$/;
const CORRECTION_TAG_REGEX = /^(?<base>\d{4}\.[1-9]\d?\.[1-9]\d?)-(?<correction>[1-9]\d*)$/;
const CORRECTION_VERSION_REGEX =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)-(?<correction>[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 || "<missing>"}".`,
`package.json version must match YYYY.M.D, YYYY.M.D-N, or YYYY.M.D-beta.N; found "${packageVersion || "<missing>"}".`,
);
}
@ -244,17 +241,24 @@ export function collectReleaseTagErrors(params: {
}
const expectedTag = packageVersion ? `v${packageVersion}` : "<missing>";
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 || "<missing>"} does not match package.json version ${
packageVersion || "<missing>"
}; expected ${expectedCorrectionTag ? `${expectedTag} or ${expectedCorrectionTag}` : expectedTag}.`,
}; expected ${
parsedVersion?.channel === "stable" && parsedVersion.correctionNumber === undefined
? `${expectedTag} or ${expectedTag}-N`
: expectedTag
}.`,
);
}

View File

@ -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");

View File

@ -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({

View File

@ -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.",
]);
});

View File

@ -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", () => {