fix(release): preserve shipped channel surfaces in npm tar (#52913)

* fix(channels): ship official channel catalog (#52838)

* fix(release): keep shipped bundles in npm tar (#52838)

* build(release): fix rebased release-check helpers (#52838)
This commit is contained in:
Nimrod Gutman
2026-03-23 17:39:22 +02:00
committed by GitHub
parent 7299b42e2a
commit b84a130788
14 changed files with 483 additions and 23 deletions

View File

@@ -681,7 +681,7 @@
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
"release:check": "pnpm config:docs:check && pnpm plugin-sdk:api:check && node scripts/stage-bundled-plugin-runtime-deps.mjs && node --import tsx scripts/release-check.ts",
"release:check": "pnpm config:docs:check && pnpm plugin-sdk:api:check && node scripts/stage-bundled-plugin-runtime-deps.mjs && pnpm ui:build && node --import tsx scripts/release-check.ts",
"release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts",
"release:plugins:npm:check": "node --import tsx scripts/plugin-npm-release-check.ts",
"release:plugins:npm:plan": "node --import tsx scripts/plugin-npm-release-plan.ts",

View File

@@ -1,3 +1,7 @@
export function rewritePackageExtensions(entries: unknown): string[] | undefined;
export function copyBundledPluginMetadata(params?: { repoRoot?: string }): void;
export function copyBundledPluginMetadata(params?: {
repoRoot?: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
}): void;

View File

@@ -0,0 +1,19 @@
export type BundledPluginBuildEntry = {
id: string;
hasPackageJson: boolean;
packageJson: unknown;
sourceEntries: string[];
};
export type BundledPluginBuildEntryParams = {
cwd?: string;
env?: NodeJS.ProcessEnv;
};
export function collectBundledPluginBuildEntries(
params?: BundledPluginBuildEntryParams,
): BundledPluginBuildEntry[];
export function listBundledPluginBuildEntries(
params?: BundledPluginBuildEntryParams,
): Record<string, string>;
export function listBundledPluginPackArtifacts(params?: BundledPluginBuildEntryParams): string[];

View File

@@ -0,0 +1,19 @@
export type BundledPluginBuildEntry = {
id: string;
hasPackageJson: boolean;
packageJson: unknown;
sourceEntries: string[];
};
export type BundledPluginBuildEntryParams = {
cwd?: string;
env?: NodeJS.ProcessEnv;
};
export function collectBundledPluginBuildEntries(
params?: BundledPluginBuildEntryParams,
): BundledPluginBuildEntry[];
export function listBundledPluginBuildEntries(
params?: BundledPluginBuildEntryParams,
): Record<string, string>;
export function listBundledPluginPackArtifacts(params?: BundledPluginBuildEntryParams): string[];

View File

@@ -23,7 +23,9 @@ export function isOptionalBundledCluster(cluster) {
}
export function shouldIncludeOptionalBundledClusters(env = process.env) {
return env[OPTIONAL_BUNDLED_BUILD_ENV] === "1";
// Release artifacts should preserve the last shipped upgrade surface by
// default. Specific size-sensitive lanes can still opt out explicitly.
return env[OPTIONAL_BUNDLED_BUILD_ENV] !== "0";
}
export function hasReleasedBundledInstall(packageJson) {

View File

@@ -26,13 +26,14 @@ const requiredPathGroups = [
"dist/plugin-sdk/compat.js",
"dist/plugin-sdk/root-alias.cjs",
"dist/build-info.json",
"dist/channel-catalog.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
// startup/doctor OOM reports. Keep enough headroom for the current pack while
// failing fast if duplicate/shim content sneaks back into the release artifact.
const npmPackUnpackedSizeBudgetBytes = 160 * 1024 * 1024;
// startup/doctor OOM reports. Keep enough headroom for the current pack with
// restored bundled upgrade surfaces while still catching regressions quickly.
const npmPackUnpackedSizeBudgetBytes = 176 * 1024 * 1024;
const appcastPath = resolve("appcast.xml");
const laneBuildMin = 1_000_000_000;
const laneFloorAdoptionDateKey = 20260227;
@@ -79,6 +80,18 @@ function runPackDry(): PackResult[] {
return JSON.parse(raw) as PackResult[];
}
export function collectMissingPackPaths(paths: Iterable<string>): string[] {
const available = new Set(paths);
return requiredPathGroups
.flatMap((group) => {
if (Array.isArray(group)) {
return group.some((path) => available.has(path)) ? [] : [group.join(" or ")];
}
return available.has(group) ? [] : [group];
})
.toSorted();
}
export function collectForbiddenPackPaths(paths: Iterable<string>): string[] {
const isAllowedBundledPluginNodeModulesPath = (path: string) =>
/^dist\/extensions\/[^/]+\/node_modules\//.test(path);

View File

@@ -3,10 +3,12 @@ import { copyBundledPluginMetadata } from "./copy-bundled-plugin-metadata.mjs";
import { copyPluginSdkRootAlias } from "./copy-plugin-sdk-root-alias.mjs";
import { stageBundledPluginRuntimeDeps } from "./stage-bundled-plugin-runtime-deps.mjs";
import { stageBundledPluginRuntime } from "./stage-bundled-plugin-runtime.mjs";
import { writeOfficialChannelCatalog } from "./write-official-channel-catalog.mjs";
export function runRuntimePostBuild(params = {}) {
copyPluginSdkRootAlias(params);
copyBundledPluginMetadata(params);
writeOfficialChannelCatalog(params);
stageBundledPluginRuntimeDeps(params);
stageBundledPluginRuntime(params);
}

View File

@@ -0,0 +1,19 @@
export const OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH: "dist/channel-catalog.json";
export function buildOfficialChannelCatalog(params?: { repoRoot?: string; cwd?: string }): {
entries: Array<{
name: string;
version?: string;
description?: string;
openclaw: {
channel: Record<string, unknown>;
install: {
npmSpec: string;
localPath?: string;
defaultChoice?: "npm" | "local";
};
};
}>;
};
export function writeOfficialChannelCatalog(params?: { repoRoot?: string; cwd?: string }): void;

View File

@@ -0,0 +1,104 @@
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs";
export const OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH = "dist/channel-catalog.json";
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function trimString(value) {
return typeof value === "string" ? value.trim() : "";
}
function toCatalogInstall(value, packageName) {
const install = isRecord(value) ? value : {};
const npmSpec = trimString(install.npmSpec) || packageName;
if (!npmSpec) {
return null;
}
const localPath = trimString(install.localPath);
const defaultChoice = trimString(install.defaultChoice);
return {
npmSpec,
...(localPath ? { localPath } : {}),
...(defaultChoice === "npm" || defaultChoice === "local" ? { defaultChoice } : {}),
};
}
function buildCatalogEntry(packageJson) {
if (!isRecord(packageJson)) {
return null;
}
const packageName = trimString(packageJson.name);
const manifest = isRecord(packageJson.openclaw) ? packageJson.openclaw : null;
const release = manifest && isRecord(manifest.release) ? manifest.release : null;
const channel = manifest && isRecord(manifest.channel) ? manifest.channel : null;
if (!packageName || !channel || release?.publishToNpm !== true) {
return null;
}
const install = toCatalogInstall(manifest.install, packageName);
if (!install) {
return null;
}
const version = trimString(packageJson.version);
const description = trimString(packageJson.description);
return {
name: packageName,
...(version ? { version } : {}),
...(description ? { description } : {}),
openclaw: {
channel,
install,
},
};
}
export function buildOfficialChannelCatalog(params = {}) {
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
const extensionsRoot = path.join(repoRoot, "extensions");
const entries = [];
if (!fs.existsSync(extensionsRoot)) {
return { entries };
}
for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) {
if (!dirent.isDirectory()) {
continue;
}
const packageJsonPath = path.join(extensionsRoot, dirent.name, "package.json");
if (!fs.existsSync(packageJsonPath)) {
continue;
}
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
const entry = buildCatalogEntry(packageJson);
if (entry) {
entries.push(entry);
}
} catch {
// Ignore invalid package metadata and keep generating the rest of the catalog.
}
}
entries.sort((left, right) => {
const leftId = trimString(left.openclaw?.channel?.id) || left.name;
const rightId = trimString(right.openclaw?.channel?.id) || right.name;
return leftId.localeCompare(rightId);
});
return { entries };
}
export function writeOfficialChannelCatalog(params = {}) {
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH);
const catalog = buildOfficialChannelCatalog({ repoRoot });
writeTextFileIfChanged(outputPath, `${JSON.stringify(catalog, null, 2)}\n`);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
writeOfficialChannelCatalog();
}

View File

@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { MANIFEST_KEY } from "../../compat/legacy-names.js";
import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js";
import { resolveBundledPluginsDir } from "../../plugins/bundled-dir.js";
import { discoverOpenClawPlugins } from "../../plugins/discovery.js";
import { loadPluginManifest } from "../../plugins/manifest.js";
@@ -40,6 +41,7 @@ export type ChannelPluginCatalogEntry = {
type CatalogOptions = {
workspaceDir?: string;
catalogPaths?: string[];
officialCatalogPaths?: string[];
env?: NodeJS.ProcessEnv;
};
@@ -57,6 +59,7 @@ type ExternalCatalogEntry = {
} & Partial<Record<ManifestKey, OpenClawPackageManifest>>;
const ENV_CATALOG_PATHS = ["OPENCLAW_PLUGIN_CATALOG_PATHS", "OPENCLAW_MPM_CATALOG_PATHS"];
const OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH = path.join("dist", "channel-catalog.json");
type ManifestKey = typeof MANIFEST_KEY;
@@ -110,16 +113,20 @@ function resolveExternalCatalogPaths(options: CatalogOptions): string[] {
}
function loadExternalCatalogEntries(options: CatalogOptions): ExternalCatalogEntry[] {
const paths = resolveExternalCatalogPaths(options);
const env = options.env ?? process.env;
const paths = resolveExternalCatalogPaths(options).map((rawPath) =>
resolveUserPath(rawPath, options.env ?? process.env),
);
return loadCatalogEntriesFromPaths(paths);
}
function loadCatalogEntriesFromPaths(paths: Iterable<string>): ExternalCatalogEntry[] {
const entries: ExternalCatalogEntry[] = [];
for (const rawPath of paths) {
const resolved = resolveUserPath(rawPath, env);
if (!fs.existsSync(resolved)) {
for (const resolvedPath of paths) {
if (!fs.existsSync(resolvedPath)) {
continue;
}
try {
const payload = JSON.parse(fs.readFileSync(resolved, "utf-8")) as unknown;
const payload = JSON.parse(fs.readFileSync(resolvedPath, "utf-8")) as unknown;
entries.push(...parseCatalogEntries(payload));
} catch {
// Ignore invalid catalog files.
@@ -128,6 +135,37 @@ function loadExternalCatalogEntries(options: CatalogOptions): ExternalCatalogEnt
return entries;
}
function resolveOfficialCatalogPaths(options: CatalogOptions): string[] {
if (options.officialCatalogPaths && options.officialCatalogPaths.length > 0) {
return options.officialCatalogPaths.map((entry) => entry.trim()).filter(Boolean);
}
const packageRoots = [
resolveOpenClawPackageRootSync({ cwd: process.cwd() }),
resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url }),
].filter((entry, index, all): entry is string => Boolean(entry) && all.indexOf(entry) === index);
const candidates = packageRoots.map((packageRoot) =>
path.join(packageRoot, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH),
);
try {
const execDir = path.dirname(process.execPath);
candidates.push(path.join(execDir, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH));
candidates.push(path.join(execDir, "channel-catalog.json"));
} catch {
// ignore
}
return candidates.filter((entry, index, all) => entry && all.indexOf(entry) === index);
}
function loadOfficialCatalogEntries(options: CatalogOptions): ChannelPluginCatalogEntry[] {
return loadCatalogEntriesFromPaths(resolveOfficialCatalogPaths(options))
.map((entry) => buildExternalCatalogEntry(entry))
.filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry));
}
function toChannelMeta(params: {
channel: NonNullable<OpenClawPackageManifest["channel"]>;
id: string;
@@ -362,6 +400,14 @@ export function listChannelPluginCatalogEntries(
}
}
for (const entry of loadOfficialCatalogEntries(options)) {
const priority = ORIGIN_PRIORITY.bundled ?? 99;
const existing = resolved.get(entry.id);
if (!existing || priority < existing.priority) {
resolved.set(entry.id, { entry, priority });
}
}
const externalEntries = loadExternalCatalogEntries(options)
.map((entry) => buildExternalCatalogEntry(entry))
.filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry));

View File

@@ -325,6 +325,46 @@ describe("channel plugin catalog", () => {
expect(entry?.install.npmSpec).toBe("@openclaw/whatsapp");
expect(entry?.pluginId).toBe("whatsapp");
});
it("includes shipped official channel catalog entries when bundled metadata is omitted", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-official-catalog-"));
const catalogPath = path.join(dir, "channel-catalog.json");
fs.writeFileSync(
catalogPath,
JSON.stringify({
entries: [
{
name: "@openclaw/whatsapp",
openclaw: {
channel: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp (QR link)",
detailLabel: "WhatsApp Web",
docsPath: "/channels/whatsapp",
blurb: "works with your own number; recommend a separate phone + eSIM.",
},
install: {
npmSpec: "@openclaw/whatsapp",
defaultChoice: "npm",
},
},
},
],
}),
);
const entry = listChannelPluginCatalogEntries({
env: {
...process.env,
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
},
officialCatalogPaths: [catalogPath],
}).find((item) => item.id === "whatsapp");
expect(entry?.install.npmSpec).toBe("@openclaw/whatsapp");
expect(entry?.pluginId).toBeUndefined();
});
});
const emptyRegistry = createTestRegistry([]);

View File

@@ -8,7 +8,7 @@ import {
} from "../../scripts/copy-bundled-plugin-metadata.mjs";
const tempDirs: string[] = [];
const includeOptionalEnv = { OPENCLAW_INCLUDE_OPTIONAL_BUNDLED: "1" } as const;
const excludeOptionalEnv = { OPENCLAW_INCLUDE_OPTIONAL_BUNDLED: "0" } as const;
const copyBundledPluginMetadataWithEnv = copyBundledPluginMetadata as (params?: {
repoRoot?: string;
env?: NodeJS.ProcessEnv;
@@ -60,7 +60,7 @@ describe("copyBundledPluginMetadata", () => {
openclaw: { extensions: ["./index.ts"] },
});
copyBundledPluginMetadataWithEnv({ repoRoot, env: includeOptionalEnv });
copyBundledPluginMetadata({ repoRoot });
expect(
fs.existsSync(path.join(repoRoot, "dist", "extensions", "acpx", "openclaw.plugin.json")),
@@ -131,7 +131,7 @@ describe("copyBundledPluginMetadata", () => {
fs.mkdirSync(staleNodeModulesSkillDir, { recursive: true });
fs.writeFileSync(path.join(staleNodeModulesSkillDir, "stale.txt"), "stale\n", "utf8");
copyBundledPluginMetadataWithEnv({ repoRoot, env: includeOptionalEnv });
copyBundledPluginMetadata({ repoRoot });
const copiedSkillDir = path.join(
repoRoot,
@@ -174,7 +174,7 @@ describe("copyBundledPluginMetadata", () => {
openclaw: { extensions: ["./index.ts"] },
});
copyBundledPluginMetadataWithEnv({ repoRoot, env: includeOptionalEnv });
copyBundledPluginMetadata({ repoRoot });
expect(
fs.readFileSync(
@@ -227,7 +227,7 @@ describe("copyBundledPluginMetadata", () => {
const staleNodeModulesDir = path.join(repoRoot, "dist", "extensions", "tlon", "node_modules");
fs.mkdirSync(staleNodeModulesDir, { recursive: true });
copyBundledPluginMetadataWithEnv({ repoRoot, env: includeOptionalEnv });
copyBundledPluginMetadata({ repoRoot });
const bundledManifest = JSON.parse(
fs.readFileSync(
@@ -269,7 +269,7 @@ describe("copyBundledPluginMetadata", () => {
});
try {
copyBundledPluginMetadataWithEnv({ repoRoot, env: includeOptionalEnv });
copyBundledPluginMetadata({ repoRoot });
} finally {
cpSyncSpy.mockRestore();
}
@@ -319,7 +319,7 @@ describe("copyBundledPluginMetadata", () => {
});
fs.mkdirSync(path.join(repoRoot, "extensions"), { recursive: true });
copyBundledPluginMetadataWithEnv({ repoRoot, env: includeOptionalEnv });
copyBundledPluginMetadata({ repoRoot });
expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "removed-plugin"))).toBe(false);
});
@@ -339,12 +339,12 @@ describe("copyBundledPluginMetadata", () => {
name: "@openclaw/google-gemini-cli-auth",
});
copyBundledPluginMetadataWithEnv({ repoRoot, env: includeOptionalEnv });
copyBundledPluginMetadata({ repoRoot });
expect(fs.existsSync(staleDistDir)).toBe(false);
});
it("skips metadata for optional bundled clusters unless explicitly enabled", () => {
it("skips metadata for optional bundled clusters only when explicitly disabled", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-optional-skip-");
const pluginDir = path.join(repoRoot, "extensions", "acpx");
fs.mkdirSync(pluginDir, { recursive: true });
@@ -357,7 +357,7 @@ describe("copyBundledPluginMetadata", () => {
openclaw: { extensions: ["./index.ts"] },
});
copyBundledPluginMetadataWithEnv({ repoRoot, env: {} });
copyBundledPluginMetadataWithEnv({ repoRoot, env: excludeOptionalEnv });
expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "acpx"))).toBe(false);
});

View File

@@ -0,0 +1,146 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
buildOfficialChannelCatalog,
OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH,
writeOfficialChannelCatalog,
} from "../scripts/write-official-channel-catalog.mjs";
const tempDirs: string[] = [];
function makeRepoRoot(prefix: string): string {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(repoRoot);
return repoRoot;
}
function writeJson(filePath: string, value: unknown): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
afterEach(() => {
for (const dir of tempDirs.splice(0, tempDirs.length)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("buildOfficialChannelCatalog", () => {
it("includes publishable official channel plugins and skips non-publishable entries", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-");
writeJson(path.join(repoRoot, "extensions", "whatsapp", "package.json"), {
name: "@openclaw/whatsapp",
version: "2026.3.23",
description: "OpenClaw WhatsApp channel plugin",
openclaw: {
channel: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp (QR link)",
detailLabel: "WhatsApp Web",
docsPath: "/channels/whatsapp",
blurb: "works with your own number; recommend a separate phone + eSIM.",
},
install: {
npmSpec: "@openclaw/whatsapp",
localPath: "extensions/whatsapp",
defaultChoice: "npm",
},
release: {
publishToNpm: true,
},
},
});
writeJson(path.join(repoRoot, "extensions", "local-only", "package.json"), {
name: "@openclaw/local-only",
openclaw: {
channel: {
id: "local-only",
label: "Local Only",
selectionLabel: "Local Only",
docsPath: "/channels/local-only",
blurb: "dev only",
},
install: {
localPath: "extensions/local-only",
},
release: {
publishToNpm: false,
},
},
});
expect(buildOfficialChannelCatalog({ repoRoot })).toEqual({
entries: [
{
name: "@openclaw/whatsapp",
version: "2026.3.23",
description: "OpenClaw WhatsApp channel plugin",
openclaw: {
channel: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp (QR link)",
detailLabel: "WhatsApp Web",
docsPath: "/channels/whatsapp",
blurb: "works with your own number; recommend a separate phone + eSIM.",
},
install: {
npmSpec: "@openclaw/whatsapp",
localPath: "extensions/whatsapp",
defaultChoice: "npm",
},
},
},
],
});
});
it("writes the official catalog under dist", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-write-");
writeJson(path.join(repoRoot, "extensions", "whatsapp", "package.json"), {
name: "@openclaw/whatsapp",
openclaw: {
channel: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp",
docsPath: "/channels/whatsapp",
blurb: "wa",
},
install: {
npmSpec: "@openclaw/whatsapp",
},
release: {
publishToNpm: true,
},
},
});
writeOfficialChannelCatalog({ repoRoot });
const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH);
expect(fs.existsSync(outputPath)).toBe(true);
expect(JSON.parse(fs.readFileSync(outputPath, "utf8"))).toEqual({
entries: [
{
name: "@openclaw/whatsapp",
openclaw: {
channel: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp",
docsPath: "/channels/whatsapp",
blurb: "wa",
},
install: {
npmSpec: "@openclaw/whatsapp",
},
},
},
],
});
});
});

View File

@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import { listBundledPluginPackArtifacts } from "../scripts/lib/bundled-plugin-build-entries.mjs";
import { listPluginSdkDistArtifacts } from "../scripts/lib/plugin-sdk-entries.mjs";
import {
collectAppcastSparkleVersionErrors,
collectBundledExtensionManifestErrors,
collectForbiddenPackPaths,
collectMissingPackPaths,
collectPackUnpackedSizeErrors,
} from "../scripts/release-check.ts";
@@ -14,6 +17,9 @@ function makePackResult(filename: string, unpackedSize: number) {
return { filename, unpackedSize };
}
const requiredPluginSdkPackPaths = [...listPluginSdkDistArtifacts(), "dist/plugin-sdk/compat.js"];
const requiredBundledPluginPackPaths = listBundledPluginPackArtifacts();
describe("collectAppcastSparkleVersionErrors", () => {
it("accepts legacy 9-digit calver builds before lane-floor cutover", () => {
const xml = `<rss><channel>${makeItem("2026.2.26", "202602260")}</channel></rss>`;
@@ -115,6 +121,46 @@ describe("collectForbiddenPackPaths", () => {
});
});
describe("collectMissingPackPaths", () => {
it("requires the shipped channel catalog, control ui, and optional bundled metadata", () => {
const missing = collectMissingPackPaths([
"dist/index.js",
"dist/entry.js",
"dist/plugin-sdk/compat.js",
"dist/plugin-sdk/index.js",
"dist/plugin-sdk/index.d.ts",
"dist/plugin-sdk/root-alias.cjs",
"dist/build-info.json",
]);
expect(missing).toEqual(
expect.arrayContaining([
"dist/channel-catalog.json",
"dist/control-ui/index.html",
"dist/extensions/matrix/openclaw.plugin.json",
"dist/extensions/matrix/package.json",
"dist/extensions/whatsapp/openclaw.plugin.json",
"dist/extensions/whatsapp/package.json",
]),
);
});
it("accepts the shipped upgrade surface when optional bundled metadata is present", () => {
expect(
collectMissingPackPaths([
"dist/index.js",
"dist/entry.js",
"dist/control-ui/index.html",
...requiredBundledPluginPackPaths,
...requiredPluginSdkPackPaths,
"dist/plugin-sdk/root-alias.cjs",
"dist/build-info.json",
"dist/channel-catalog.json",
]),
).toEqual([]);
});
});
describe("collectPackUnpackedSizeErrors", () => {
it("accepts pack results within the unpacked size budget", () => {
expect(
@@ -126,7 +172,7 @@ describe("collectPackUnpackedSizeErrors", () => {
expect(
collectPackUnpackedSizeErrors([makePackResult("openclaw-2026.3.12.tgz", 224_002_564)]),
).toEqual([
"openclaw-2026.3.12.tgz unpackedSize 224002564 bytes (213.6 MiB) exceeds budget 167772160 bytes (160.0 MiB). Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.",
"openclaw-2026.3.12.tgz unpackedSize 224002564 bytes (213.6 MiB) exceeds budget 184549376 bytes (176.0 MiB). Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.",
]);
});