fix(release): ship bundled plugins in pack artifacts

This commit is contained in:
Vincent Koc
2026-03-23 08:21:50 -07:00
parent 31675d65d4
commit 70b235f312
10 changed files with 157 additions and 64 deletions

View File

@@ -187,6 +187,9 @@ jobs:
- name: Build dist
run: pnpm build
- name: Build Control UI
run: pnpm ui:build
- name: Upload dist artifact
uses: actions/upload-artifact@v7
with:
@@ -261,6 +264,7 @@ jobs:
cache_key_suffix: "node22"
command: |
pnpm build
pnpm ui:build
node openclaw.mjs --help
node openclaw.mjs status --json --timeout 1
pnpm test:build:singleton

View File

@@ -189,7 +189,11 @@ export function copyBundledPluginMetadata(params = {}) {
const pluginDir = path.join(extensionsRoot, dirent.name);
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
const distPluginDir = path.join(distExtensionsRoot, dirent.name);
if (!shouldBuildBundledCluster(dirent.name, env)) {
const packageJsonPath = path.join(pluginDir, "package.json");
const packageJson = fs.existsSync(packageJsonPath)
? JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
: undefined;
if (!shouldBuildBundledCluster(dirent.name, env, { packageJson })) {
removePathIfExists(distPluginDir);
continue;
}
@@ -219,13 +223,10 @@ export function copyBundledPluginMetadata(params = {}) {
: manifest;
writeTextFileIfChanged(distManifestPath, `${JSON.stringify(bundledManifest, null, 2)}\n`);
const packageJsonPath = path.join(pluginDir, "package.json");
if (!fs.existsSync(packageJsonPath)) {
removeFileIfExists(distPackageJsonPath);
continue;
}
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
if (packageJson.openclaw && "extensions" in packageJson.openclaw) {
packageJson.openclaw = {
...packageJson.openclaw,

View File

@@ -0,0 +1,95 @@
import fs from "node:fs";
import path from "node:path";
import { shouldBuildBundledCluster } from "./optional-bundled-clusters.mjs";
function readBundledPluginPackageJson(packageJsonPath) {
if (!fs.existsSync(packageJsonPath)) {
return null;
}
try {
return JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
} catch {
return null;
}
}
function collectPluginSourceEntries(packageJson) {
let packageEntries = Array.isArray(packageJson?.openclaw?.extensions)
? packageJson.openclaw.extensions.filter(
(entry) => typeof entry === "string" && entry.trim().length > 0,
)
: [];
const setupEntry =
typeof packageJson?.openclaw?.setupEntry === "string" &&
packageJson.openclaw.setupEntry.trim().length > 0
? packageJson.openclaw.setupEntry
: undefined;
if (setupEntry) {
packageEntries = Array.from(new Set([...packageEntries, setupEntry]));
}
return packageEntries.length > 0 ? packageEntries : ["./index.ts"];
}
export function collectBundledPluginBuildEntries(params = {}) {
const cwd = params.cwd ?? process.cwd();
const env = params.env ?? process.env;
const extensionsRoot = path.join(cwd, "extensions");
const entries = [];
for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) {
if (!dirent.isDirectory()) {
continue;
}
const pluginDir = path.join(extensionsRoot, dirent.name);
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
if (!fs.existsSync(manifestPath)) {
continue;
}
const packageJsonPath = path.join(pluginDir, "package.json");
const packageJson = readBundledPluginPackageJson(packageJsonPath);
if (!shouldBuildBundledCluster(dirent.name, env, { packageJson })) {
continue;
}
entries.push({
id: dirent.name,
hasPackageJson: packageJson !== null,
packageJson,
sourceEntries: collectPluginSourceEntries(packageJson),
});
}
return entries;
}
export function listBundledPluginBuildEntries(params = {}) {
return Object.fromEntries(
collectBundledPluginBuildEntries(params).flatMap(({ id, sourceEntries }) =>
sourceEntries.map((entry) => {
const normalizedEntry = entry.replace(/^\.\//, "");
const entryKey = `extensions/${id}/${normalizedEntry.replace(/\.[^.]+$/u, "")}`;
return [entryKey, path.join("extensions", id, normalizedEntry)];
}),
),
);
}
export function listBundledPluginPackArtifacts(params = {}) {
const entries = collectBundledPluginBuildEntries(params);
const artifacts = new Set();
for (const { id, hasPackageJson, sourceEntries } of entries) {
artifacts.add(`dist/extensions/${id}/openclaw.plugin.json`);
if (hasPackageJson) {
artifacts.add(`dist/extensions/${id}/package.json`);
}
for (const entry of sourceEntries) {
const normalizedEntry = entry.replace(/^\.\//, "").replace(/\.[^.]+$/u, "");
artifacts.add(`dist/extensions/${id}/${normalizedEntry}.js`);
}
}
return [...artifacts].toSorted((left, right) => left.localeCompare(right));
}

View File

@@ -3,4 +3,9 @@ export const optionalBundledClusterSet: Set<string>;
export const OPTIONAL_BUNDLED_BUILD_ENV: "OPENCLAW_INCLUDE_OPTIONAL_BUNDLED";
export function isOptionalBundledCluster(cluster: string): boolean;
export function shouldIncludeOptionalBundledClusters(env?: NodeJS.ProcessEnv): boolean;
export function shouldBuildBundledCluster(cluster: string, env?: NodeJS.ProcessEnv): boolean;
export function hasReleasedBundledInstall(packageJson: unknown): boolean;
export function shouldBuildBundledCluster(
cluster: string,
env?: NodeJS.ProcessEnv,
options?: { packageJson?: unknown },
): boolean;

View File

@@ -3,4 +3,9 @@ export const optionalBundledClusterSet: Set<string>;
export const OPTIONAL_BUNDLED_BUILD_ENV: "OPENCLAW_INCLUDE_OPTIONAL_BUNDLED";
export function isOptionalBundledCluster(cluster: string): boolean;
export function shouldIncludeOptionalBundledClusters(env?: NodeJS.ProcessEnv): boolean;
export function shouldBuildBundledCluster(cluster: string, env?: NodeJS.ProcessEnv): boolean;
export function hasReleasedBundledInstall(packageJson: unknown): boolean;
export function shouldBuildBundledCluster(
cluster: string,
env?: NodeJS.ProcessEnv,
options?: { packageJson?: unknown },
): boolean;

View File

@@ -26,6 +26,16 @@ export function shouldIncludeOptionalBundledClusters(env = process.env) {
return env[OPTIONAL_BUNDLED_BUILD_ENV] === "1";
}
export function shouldBuildBundledCluster(cluster, env = process.env) {
export function hasReleasedBundledInstall(packageJson) {
return (
typeof packageJson?.openclaw?.install?.npmSpec === "string" &&
packageJson.openclaw.install.npmSpec.trim().length > 0
);
}
export function shouldBuildBundledCluster(cluster, env = process.env, options = {}) {
if (hasReleasedBundledInstall(options.packageJson)) {
return true;
}
return shouldIncludeOptionalBundledClusters(env) || !isOptionalBundledCluster(cluster);
}

View File

@@ -9,6 +9,7 @@ import {
type BundledExtension,
type ExtensionPackageJson as PackageJson,
} from "./lib/bundled-extension-manifest.ts";
import { listBundledPluginPackArtifacts } from "./lib/bundled-plugin-build-entries.mjs";
import { listPluginSdkDistArtifacts } from "./lib/plugin-sdk-entries.mjs";
import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts";
@@ -21,9 +22,11 @@ const requiredPathGroups = [
["dist/index.js", "dist/index.mjs"],
["dist/entry.js", "dist/entry.mjs"],
...listPluginSdkDistArtifacts(),
...listBundledPluginPackArtifacts(),
"dist/plugin-sdk/compat.js",
"dist/plugin-sdk/root-alias.cjs",
"dist/build-info.json",
"dist/control-ui/index.html",
];
const forbiddenPrefixes = ["dist-runtime/", "dist/OpenClaw.app/"];
// 2026.3.12 ballooned to ~213.6 MiB unpacked and correlated with low-memory
@@ -85,7 +88,7 @@ export function collectForbiddenPackPaths(paths: Iterable<string>): string[] {
forbiddenPrefixes.some((prefix) => path.startsWith(prefix)) ||
(/node_modules\//.test(path) && !isAllowedBundledPluginNodeModulesPath(path)),
)
.toSorted();
.toSorted((left, right) => left.localeCompare(right));
}
function formatMiB(bytes: number): string {
@@ -303,7 +306,7 @@ async function main() {
}
return paths.has(group) ? [] : [group];
})
.toSorted();
.toSorted((left, right) => left.localeCompare(right));
const forbidden = collectForbiddenPackPaths(paths);
const sizeErrors = collectPackUnpackedSizeErrors(results);

View File

@@ -40,6 +40,9 @@ describe("tsdown config", () => {
"plugin-sdk/compat",
"plugin-sdk/index",
"extensions/openai/index",
"extensions/matrix/index",
"extensions/msteams/index",
"extensions/whatsapp/index",
"bundled/boot-md/handler",
]),
);

View File

@@ -361,4 +361,25 @@ describe("copyBundledPluginMetadata", () => {
expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "acpx"))).toBe(false);
});
it("still bundles previously released optional plugins without the opt-in env", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-released-optional-");
const pluginDir = path.join(repoRoot, "extensions", "whatsapp");
fs.mkdirSync(pluginDir, { recursive: true });
writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
id: "whatsapp",
configSchema: { type: "object" },
});
writeJson(path.join(pluginDir, "package.json"), {
name: "@openclaw/whatsapp",
openclaw: {
extensions: ["./index.ts"],
install: { npmSpec: "@openclaw/whatsapp" },
},
});
copyBundledPluginMetadataWithEnv({ repoRoot, env: {} });
expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "whatsapp"))).toBe(true);
});
});

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { defineConfig, type UserConfig } from "tsdown";
import { shouldBuildBundledCluster } from "./scripts/lib/optional-bundled-clusters.mjs";
import { listBundledPluginBuildEntries } from "./scripts/lib/bundled-plugin-build-entries.mjs";
import { buildPluginSdkEntrySources } from "./scripts/lib/plugin-sdk-entries.mjs";
type InputOptionsFactory = Extract<NonNullable<UserConfig["inputOptions"]>, Function>;
@@ -80,60 +80,6 @@ function nodeBuildConfig(config: UserConfig): UserConfig {
};
}
function listBundledPluginBuildEntries(): Record<string, string> {
const extensionsRoot = path.join(process.cwd(), "extensions");
const entries: Record<string, string> = {};
for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) {
if (!dirent.isDirectory()) {
continue;
}
if (!shouldBuildBundledCluster(dirent.name, process.env)) {
continue;
}
const pluginDir = path.join(extensionsRoot, dirent.name);
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
if (!fs.existsSync(manifestPath)) {
continue;
}
const packageJsonPath = path.join(pluginDir, "package.json");
let packageEntries: string[] = [];
if (fs.existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
openclaw?: { extensions?: unknown; setupEntry?: unknown };
};
packageEntries = Array.isArray(packageJson.openclaw?.extensions)
? packageJson.openclaw.extensions.filter(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
)
: [];
const setupEntry =
typeof packageJson.openclaw?.setupEntry === "string" &&
packageJson.openclaw.setupEntry.trim().length > 0
? packageJson.openclaw.setupEntry
: undefined;
if (setupEntry) {
packageEntries = Array.from(new Set([...packageEntries, setupEntry]));
}
} catch {
packageEntries = [];
}
}
const sourceEntries = packageEntries.length > 0 ? packageEntries : ["./index.ts"];
for (const entry of sourceEntries) {
const normalizedEntry = entry.replace(/^\.\//, "");
const entryKey = `extensions/${dirent.name}/${normalizedEntry.replace(/\.[^.]+$/u, "")}`;
entries[entryKey] = path.join("extensions", dirent.name, normalizedEntry);
}
}
return entries;
}
const bundledPluginBuildEntries = listBundledPluginBuildEntries();
function buildBundledHookEntries(): Record<string, string> {