From b84a13078830504a130a35b59482cf5acc9afcfe Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Mon, 23 Mar 2026 17:39:22 +0200 Subject: [PATCH] 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) --- package.json | 2 +- scripts/copy-bundled-plugin-metadata.d.mts | 6 +- .../lib/bundled-plugin-build-entries.d.mts | 19 +++ scripts/lib/bundled-plugin-build-entries.d.ts | 19 +++ scripts/lib/optional-bundled-clusters.mjs | 4 +- scripts/release-check.ts | 19 ++- scripts/runtime-postbuild.mjs | 2 + scripts/write-official-channel-catalog.d.mts | 19 +++ scripts/write-official-channel-catalog.mjs | 104 +++++++++++++ src/channels/plugins/catalog.ts | 58 ++++++- src/channels/plugins/plugins-core.test.ts | 40 +++++ .../copy-bundled-plugin-metadata.test.ts | 20 +-- test/official-channel-catalog.test.ts | 146 ++++++++++++++++++ test/release-check.test.ts | 48 +++++- 14 files changed, 483 insertions(+), 23 deletions(-) create mode 100644 scripts/lib/bundled-plugin-build-entries.d.mts create mode 100644 scripts/lib/bundled-plugin-build-entries.d.ts create mode 100644 scripts/write-official-channel-catalog.d.mts create mode 100644 scripts/write-official-channel-catalog.mjs create mode 100644 test/official-channel-catalog.test.ts diff --git a/package.json b/package.json index 506b541cb4e..825b4764608 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/copy-bundled-plugin-metadata.d.mts b/scripts/copy-bundled-plugin-metadata.d.mts index 1b2d0e4836d..057f4f6c019 100644 --- a/scripts/copy-bundled-plugin-metadata.d.mts +++ b/scripts/copy-bundled-plugin-metadata.d.mts @@ -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; diff --git a/scripts/lib/bundled-plugin-build-entries.d.mts b/scripts/lib/bundled-plugin-build-entries.d.mts new file mode 100644 index 00000000000..3455f3d84e4 --- /dev/null +++ b/scripts/lib/bundled-plugin-build-entries.d.mts @@ -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; +export function listBundledPluginPackArtifacts(params?: BundledPluginBuildEntryParams): string[]; diff --git a/scripts/lib/bundled-plugin-build-entries.d.ts b/scripts/lib/bundled-plugin-build-entries.d.ts new file mode 100644 index 00000000000..3455f3d84e4 --- /dev/null +++ b/scripts/lib/bundled-plugin-build-entries.d.ts @@ -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; +export function listBundledPluginPackArtifacts(params?: BundledPluginBuildEntryParams): string[]; diff --git a/scripts/lib/optional-bundled-clusters.mjs b/scripts/lib/optional-bundled-clusters.mjs index bc73d6b4529..a3aa7f2c609 100644 --- a/scripts/lib/optional-bundled-clusters.mjs +++ b/scripts/lib/optional-bundled-clusters.mjs @@ -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) { diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 45f244a64bf..6a2f7324743 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -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[] { + 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[] { const isAllowedBundledPluginNodeModulesPath = (path: string) => /^dist\/extensions\/[^/]+\/node_modules\//.test(path); diff --git a/scripts/runtime-postbuild.mjs b/scripts/runtime-postbuild.mjs index 6b044252267..ef78dd0d030 100644 --- a/scripts/runtime-postbuild.mjs +++ b/scripts/runtime-postbuild.mjs @@ -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); } diff --git a/scripts/write-official-channel-catalog.d.mts b/scripts/write-official-channel-catalog.d.mts new file mode 100644 index 00000000000..67d260cf09f --- /dev/null +++ b/scripts/write-official-channel-catalog.d.mts @@ -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; + install: { + npmSpec: string; + localPath?: string; + defaultChoice?: "npm" | "local"; + }; + }; + }>; +}; + +export function writeOfficialChannelCatalog(params?: { repoRoot?: string; cwd?: string }): void; diff --git a/scripts/write-official-channel-catalog.mjs b/scripts/write-official-channel-catalog.mjs new file mode 100644 index 00000000000..a913a86b212 --- /dev/null +++ b/scripts/write-official-channel-catalog.mjs @@ -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(); +} diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index ef55372946f..70a29d37311 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -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>; 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): 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; 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)); diff --git a/src/channels/plugins/plugins-core.test.ts b/src/channels/plugins/plugins-core.test.ts index 66ac77ada40..8d8b0b40c9d 100644 --- a/src/channels/plugins/plugins-core.test.ts +++ b/src/channels/plugins/plugins-core.test.ts @@ -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([]); diff --git a/src/plugins/copy-bundled-plugin-metadata.test.ts b/src/plugins/copy-bundled-plugin-metadata.test.ts index f85254e2e98..b08d0658360 100644 --- a/src/plugins/copy-bundled-plugin-metadata.test.ts +++ b/src/plugins/copy-bundled-plugin-metadata.test.ts @@ -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); }); diff --git a/test/official-channel-catalog.test.ts b/test/official-channel-catalog.test.ts new file mode 100644 index 00000000000..6a91d72f1ab --- /dev/null +++ b/test/official-channel-catalog.test.ts @@ -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", + }, + }, + }, + ], + }); + }); +}); diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 09f19d2babb..6d9d4ecd61e 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -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 = `${makeItem("2026.2.26", "202602260")}`; @@ -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.", ]); });