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

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