fix(release): block stale Codex runtime pins (#100015)

Prevents opted-in plugin runtime dependencies from being published with stale
registry pins. The same freshness assertion now guards both npm and ClawHub
release paths, including the managed Codex runtime.

Prepared head SHA: d5d0ecba4b
Reviewed-by: @fuller-stack-dev

Closes #99951
This commit is contained in:
Jason (Json)
2026-07-04 15:13:31 -06:00
committed by GitHub
parent ff58d23e8f
commit 0221544190
8 changed files with 485 additions and 15 deletions
+3
View File
@@ -42,6 +42,9 @@
"release": {
"publishToClawHub": true,
"publishToNpm": true,
"requireLatestDependencies": [
"@openai/codex"
],
"bundleRuntimeDependencies": false
}
}
+4
View File
@@ -10,6 +10,9 @@ type CodexPackageManifest = {
install?: {
requiredPlatformPackages?: string[];
};
release?: {
requireLatestDependencies?: string[];
};
};
};
@@ -23,6 +26,7 @@ describe("codex package manifest", () => {
expect(packageJson.dependencies?.["@openai/codex"]).toBe(
MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION,
);
expect(packageJson.openclaw?.release?.requireLatestDependencies).toEqual(["@openai/codex"]);
expect(packageJson.openclaw?.install?.requiredPlatformPackages).toEqual([
"@openai/codex-linux-x64",
"@openai/codex-linux-arm64",
+18 -1
View File
@@ -4,10 +4,12 @@ import { resolve } from "node:path";
import { validateExternalCodePluginPackageJson } from "../../packages/plugin-package-contract/src/index.ts";
import { readBoundedResponseText } from "./bounded-response.ts";
import {
assertPluginReleaseDependencyFreshness,
collectExtensionPackageJsonCandidates,
collectChangedPathsFromGitRange,
collectChangedExtensionIdsFromPaths,
collectPublishablePluginPackageErrors,
collectRequiredLatestDependencies,
assertPluginReleaseVersionFloors,
parsePluginReleaseArgs,
resolvePublishablePluginVersion,
@@ -15,10 +17,16 @@ import {
resolveChangedPublishablePluginPackages,
resolveSelectedPublishablePluginPackages,
type GitRangeSelection,
type NpmLatestVersionResolver,
type PluginReleaseSelectionMode,
type RequiredLatestDependency,
} from "./plugin-npm-release.ts";
export { assertPluginReleaseVersionFloors, parsePluginReleaseArgs };
export {
assertPluginReleaseDependencyFreshness,
assertPluginReleaseVersionFloors,
parsePluginReleaseArgs,
};
type PluginPackageJson = {
name?: string;
@@ -51,6 +59,7 @@ export type PublishablePluginPackage = {
version: string;
channel: "stable" | "alpha" | "beta";
publishTag: "latest" | "alpha" | "beta";
requiredLatestDependencies?: RequiredLatestDependency[];
};
type PluginReleasePlanItem = PublishablePluginPackage & {
@@ -237,6 +246,7 @@ export function collectClawHubPublishablePluginPackages(
continue;
}
const { version, parsedVersion } = resolvedVersion;
const requiredLatestDependencies = collectRequiredLatestDependencies(packageJson).dependencies;
publishable.push({
extensionId,
@@ -250,6 +260,7 @@ export function collectClawHubPublishablePluginPackages(
: parsedVersion.channel === "beta"
? "beta"
: "latest",
...(requiredLatestDependencies.length > 0 ? { requiredLatestDependencies } : {}),
});
}
@@ -588,6 +599,7 @@ export async function collectPluginClawHubReleasePlan(params?: {
registryBaseUrl?: string;
fetchImpl?: typeof fetch;
requestTimeoutMs?: number;
resolveLatestVersion?: NpmLatestVersionResolver;
}): Promise<PluginReleasePlan> {
const rootDir = params?.rootDir;
const selection = params?.selection ?? [];
@@ -618,6 +630,11 @@ export async function collectPluginClawHubReleasePlan(params?: {
if (explicitPublishSelection) {
assertPluginReleaseVersionFloors(selectedPublishable, "Plugin ClawHub release plan");
}
assertPluginReleaseDependencyFreshness(
selectedPublishable,
"Plugin ClawHub release plan",
params?.resolveLatestVersion,
);
const planned: PluginReleasePlanItemWithPackageState[] = [];
for (const plugin of selectedPublishable) {
+140 -11
View File
@@ -13,6 +13,8 @@ export type PluginPackageJson = {
version?: string;
type?: string;
private?: boolean;
dependencies?: Record<string, unknown>;
optionalDependencies?: Record<string, unknown>;
repository?:
| string
| {
@@ -36,10 +38,16 @@ export type PluginPackageJson = {
};
release?: {
publishToNpm?: boolean;
requireLatestDependencies?: unknown;
};
};
};
export type RequiredLatestDependency = {
packageName: string;
version: string;
};
export type PublishablePluginPackage = {
extensionId: string;
packageDir: string;
@@ -48,6 +56,7 @@ export type PublishablePluginPackage = {
channel: "stable" | "alpha" | "beta";
publishTag: "latest" | "alpha" | "beta";
installNpmSpec?: string;
requiredLatestDependencies?: RequiredLatestDependency[];
};
export type PluginReleasePlanItem = PublishablePluginPackage & {
@@ -86,6 +95,58 @@ export type PublishablePluginPackageCandidate<
export const OPENCLAW_PLUGIN_NPM_REPOSITORY_URL = "https://github.com/openclaw/openclaw";
export function collectRequiredLatestDependencies(packageJson: PluginPackageJson): {
dependencies: RequiredLatestDependency[];
errors: string[];
} {
const configured = packageJson.openclaw?.release?.requireLatestDependencies;
if (configured === undefined) {
return { dependencies: [], errors: [] };
}
if (!Array.isArray(configured)) {
return {
dependencies: [],
errors: ["openclaw.release.requireLatestDependencies must be an array of package names."],
};
}
const runtimeDependencies = {
...packageJson.dependencies,
...packageJson.optionalDependencies,
};
const dependencies: RequiredLatestDependency[] = [];
const errors: string[] = [];
const seen = new Set<string>();
for (const value of configured) {
if (typeof value !== "string" || !value.trim()) {
errors.push(
"openclaw.release.requireLatestDependencies must contain only non-empty package names.",
);
continue;
}
const packageName = value.trim();
if (seen.has(packageName)) {
errors.push(
`openclaw.release.requireLatestDependencies must not contain duplicate package names; found "${packageName}".`,
);
continue;
}
seen.add(packageName);
const version = runtimeDependencies[packageName];
if (typeof version !== "string" || !version.trim()) {
errors.push(
`openclaw.release.requireLatestDependencies must reference package.json dependencies or optionalDependencies; "${packageName}" is not a runtime dependency.`,
);
continue;
}
dependencies.push({ packageName, version: version.trim() });
}
return { dependencies, errors };
}
function readPluginPackageJson(path: string): unknown {
return JSON.parse(readFileSync(path, "utf8"));
}
@@ -259,6 +320,7 @@ export function collectPublishablePluginPackageErrors(
? packageJson.repository.trim()
: (packageJson.repository?.url?.trim() ?? "");
const extensions = packageJson.openclaw?.extensions ?? [];
const requiredLatestDependencies = collectRequiredLatestDependencies(packageJson);
if (!packageName.startsWith("@openclaw/")) {
errors.push(
@@ -295,6 +357,7 @@ export function collectPublishablePluginPackageErrors(
if (!installNpmSpec) {
errors.push("openclaw.install.npmSpec must be a non-empty string for publishable plugins.");
}
errors.push(...requiredLatestDependencies.errors);
errors.push(
...validateExternalCodePluginPackageJson(packageJson).issues.map((issue) => issue.message),
);
@@ -346,6 +409,7 @@ export function collectPublishablePluginPackages(
continue;
}
const { version, parsedVersion } = resolvedVersion;
const requiredLatestDependencies = collectRequiredLatestDependencies(packageJson).dependencies;
publishable.push({
extensionId,
@@ -355,6 +419,7 @@ export function collectPublishablePluginPackages(
channel: parsedVersion.channel,
publishTag: resolveNpmPublishPlan(version).publishTag,
installNpmSpec: normalizeOptionalString(packageJson.openclaw?.install?.npmSpec),
...(requiredLatestDependencies.length > 0 ? { requiredLatestDependencies } : {}),
});
}
@@ -522,25 +587,88 @@ export function assertPluginReleaseVersionFloors(
);
}
function isPluginVersionPublished(packageName: string, version: string): boolean {
export type NpmLatestVersionResolver = (packageName: string) => string;
function runNpmView(args: string[]): string {
const tempDir = mkdtempSync(join(tmpdir(), "openclaw-plugin-npm-view-"));
const userconfigPath = join(tempDir, "npmrc");
writeFileSync(userconfigPath, "");
try {
execFileSync(
"npm",
["view", `${packageName}@${version}`, "version", "--userconfig", userconfigPath],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
},
);
return execFileSync("npm", ["view", ...args, "--userconfig", userconfigPath], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}
export function resolveNpmLatestVersion(packageName: string): string {
const raw = runNpmView([packageName, "dist-tags.latest", "--json"]);
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed !== "string" || !parsed.trim()) {
throw new Error(`npm returned an invalid latest dist-tag for ${packageName}.`);
}
return parsed.trim();
}
export function collectPluginReleaseDependencyFreshnessErrors(
plugins: readonly PublishablePluginPackage[],
resolveLatestVersion: NpmLatestVersionResolver = resolveNpmLatestVersion,
): string[] {
// Only plugin-owned opt-ins use this strict gate. It prevents release branches
// from silently carrying old executable pins while leaving normal dependencies alone.
const latestVersions = new Map<string, string>();
const errors: string[] = [];
for (const plugin of plugins) {
for (const dependency of plugin.requiredLatestDependencies ?? []) {
let latestVersion = latestVersions.get(dependency.packageName);
if (!latestVersion) {
try {
latestVersion = resolveLatestVersion(dependency.packageName);
latestVersions.set(dependency.packageName, latestVersion);
} catch (error) {
errors.push(
`${plugin.packageName}@${plugin.version}: could not resolve npm latest for ${dependency.packageName}: ${error instanceof Error ? error.message : String(error)}`,
);
continue;
}
}
if (dependency.version !== latestVersion) {
errors.push(
`${plugin.packageName}@${plugin.version}: ${dependency.packageName} must match npm latest for release; found "${dependency.version}", latest is "${latestVersion}".`,
);
}
}
}
return errors;
}
export function assertPluginReleaseDependencyFreshness(
plugins: readonly PublishablePluginPackage[],
label: string,
resolveLatestVersion: NpmLatestVersionResolver = resolveNpmLatestVersion,
): void {
const errors = collectPluginReleaseDependencyFreshnessErrors(plugins, resolveLatestVersion);
if (errors.length === 0) {
return;
}
throw new Error(
`${label} rejected stale required release dependencies:\n${errors
.map((error) => `- ${error}`)
.join("\n")}`,
);
}
function isPluginVersionPublished(packageName: string, version: string): boolean {
try {
runNpmView([`${packageName}@${version}`, "version"]);
return true;
} catch {
return false;
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}
@@ -583,6 +711,7 @@ export function collectPluginReleasePlan(params?: {
if (explicitPublishSelection) {
assertPluginReleaseVersionFloors(selectedPublishable, "Plugin NPM release plan");
}
assertPluginReleaseDependencyFreshness(selectedPublishable, "Plugin NPM release plan");
const all = selectedPublishable.map((plugin) =>
Object.assign({}, plugin, {
+18 -2
View File
@@ -3,16 +3,25 @@
import { pathToFileURL } from "node:url";
import {
assertPluginReleaseDependencyFreshness,
collectClawHubPublishablePluginPackages,
collectClawHubVersionGateErrors,
assertPluginReleaseVersionFloors,
parsePluginReleaseArgs,
resolveSelectedClawHubPublishablePluginPackages,
} from "./lib/plugin-clawhub-release.ts";
import type { NpmLatestVersionResolver } from "./lib/plugin-npm-release.ts";
export async function runPluginClawHubReleaseCheck(argv: string[]) {
export async function runPluginClawHubReleaseCheck(
argv: string[],
options: {
rootDir?: string;
resolveLatestVersion?: NpmLatestVersionResolver;
} = {},
) {
const { selection, selectionMode, baseRef, headRef } = parsePluginReleaseArgs(argv);
const publishable = collectClawHubPublishablePluginPackages(".", {
const rootDir = options.rootDir ?? ".";
const publishable = collectClawHubPublishablePluginPackages(rootDir, {
packageNames:
selectionMode === "all-publishable" || selection.length === 0 ? undefined : selection,
});
@@ -22,16 +31,23 @@ export async function runPluginClawHubReleaseCheck(argv: string[]) {
selection,
selectionMode,
gitRange,
rootDir,
});
if (selectionMode !== undefined || selection.length > 0) {
assertPluginReleaseVersionFloors(selected, "plugin-clawhub-release-check");
}
assertPluginReleaseDependencyFreshness(
selected,
"plugin-clawhub-release-check",
options.resolveLatestVersion,
);
if (gitRange) {
const errors = collectClawHubVersionGateErrors({
plugins: publishable,
gitRange,
rootDir,
});
if (errors.length > 0) {
throw new Error(
+3 -1
View File
@@ -3,9 +3,10 @@
import { pathToFileURL } from "node:url";
import {
assertPluginReleaseDependencyFreshness,
assertPluginReleaseVersionFloors,
collectChangedExtensionIdsFromGitRange,
collectPublishablePluginPackages,
assertPluginReleaseVersionFloors,
parsePluginReleaseArgs,
resolveChangedPublishablePluginPackages,
resolveSelectedPublishablePluginPackages,
@@ -44,6 +45,7 @@ export function runPluginNpmReleaseCheck(argv: string[]) {
if (selectionMode !== undefined || selection.length > 0) {
assertPluginReleaseVersionFloors(selected, "plugin-npm-release-check");
}
assertPluginReleaseDependencyFreshness(selected, "plugin-npm-release-check");
console.log("plugin-npm-release-check: publishable plugin metadata looks OK.");
if (baseRef && headRef && selected.length === 0) {
+163
View File
@@ -28,6 +28,7 @@ import {
collectPublishablePluginPackages,
OPENCLAW_PLUGIN_NPM_REPOSITORY_URL,
} from "../scripts/lib/plugin-npm-release.ts";
import { runPluginClawHubReleaseCheck } from "../scripts/plugin-clawhub-release-check.ts";
import { cleanupTempDirs, makeTempRepoRoot } from "./helpers/temp-repo.js";
const tempDirs: string[] = [];
@@ -115,6 +116,24 @@ describe("collectClawHubPublishablePluginPackages", () => {
}).map((plugin) => plugin.packageName),
).toEqual(["@openclaw/demo-plugin"]);
});
it("collects exact release dependencies that must match npm latest", () => {
const repoDir = createTempPluginRepo({
requiredLatestDependencyVersion: "1.2.3",
});
expect(collectClawHubPublishablePluginPackages(repoDir)).toEqual([
expect.objectContaining({
packageName: "@openclaw/demo-plugin",
requiredLatestDependencies: [
{
packageName: "demo-runtime",
version: "1.2.3",
},
],
}),
]);
});
});
describe("OpenClaw dual-published plugin metadata", () => {
@@ -354,6 +373,85 @@ describe("resolveSelectedClawHubPublishablePluginPackages", () => {
});
describe("collectPluginClawHubReleasePlan", () => {
it("rejects stale required dependencies before querying ClawHub", async () => {
const repoDir = createTempPluginRepo({
requiredLatestDependencyVersion: "1.2.3",
});
await expect(
collectPluginClawHubReleasePlan({
rootDir: repoDir,
selection: ["@openclaw/demo-plugin"],
resolveLatestVersion: () => "1.2.4",
fetchImpl: async () => {
throw new Error("ClawHub should not be queried for a stale dependency.");
},
}),
).rejects.toThrow(
'@openclaw/demo-plugin@2026.4.1: demo-runtime must match npm latest for release; found "1.2.3", latest is "1.2.4".',
);
});
it("accepts required dependencies matching npm latest", async () => {
const repoDir = createTempPluginRepo({
requiredLatestDependencyVersion: "1.2.3",
});
const { fetchImpl } = createClawHubPlanFetch({
packages: {
"@openclaw/demo-plugin": {
status: 200,
},
},
trustedPublishers: {
"@openclaw/demo-plugin": {
status: 200,
body: {
trustedPublisher: {
repository: "openclaw/openclaw",
workflowFilename: "plugin-clawhub-release.yml",
},
},
},
},
versions: {
"@openclaw/demo-plugin@2026.4.1": 404,
},
});
const plan = await collectPluginClawHubReleasePlan({
rootDir: repoDir,
selection: ["@openclaw/demo-plugin"],
resolveLatestVersion: () => "1.2.3",
fetchImpl,
registryBaseUrl: "https://clawhub.ai",
});
expect(plan.candidates.map((plugin) => plugin.packageName)).toEqual([
"@openclaw/demo-plugin",
]);
});
it("fails closed when npm latest cannot be resolved", async () => {
const repoDir = createTempPluginRepo({
requiredLatestDependencyVersion: "1.2.3",
});
await expect(
collectPluginClawHubReleasePlan({
rootDir: repoDir,
selection: ["@openclaw/demo-plugin"],
resolveLatestVersion: () => {
throw new Error("registry unavailable");
},
fetchImpl: async () => {
throw new Error("ClawHub should not be queried when npm latest is unavailable.");
},
}),
).rejects.toThrow(
"@openclaw/demo-plugin@2026.4.1: could not resolve npm latest for demo-runtime: registry unavailable",
);
});
it("keeps existing trusted packages with missing versions as normal candidates", async () => {
const repoDir = createTempPluginRepo();
const { fetchImpl, requests } = createClawHubPlanFetch({
@@ -1053,6 +1151,58 @@ describe("buildOpenClawReleaseClawHubPlan", () => {
});
});
describe("runPluginClawHubReleaseCheck", () => {
it("rejects stale required dependencies", async () => {
const repoDir = createTempPluginRepo({
requiredLatestDependencyVersion: "1.2.3",
});
await expect(
runPluginClawHubReleaseCheck(["--plugins", "@openclaw/demo-plugin"], {
rootDir: repoDir,
resolveLatestVersion: () => "1.2.4",
}),
).rejects.toThrow(
'@openclaw/demo-plugin@2026.4.1: demo-runtime must match npm latest for release; found "1.2.3", latest is "1.2.4".',
);
});
it("accepts required dependencies matching npm latest", async () => {
const repoDir = createTempPluginRepo({
requiredLatestDependencyVersion: "1.2.3",
});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
try {
await expect(
runPluginClawHubReleaseCheck(["--plugins", "@openclaw/demo-plugin"], {
rootDir: repoDir,
resolveLatestVersion: () => "1.2.3",
}),
).resolves.toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("fails closed when npm latest cannot be resolved", async () => {
const repoDir = createTempPluginRepo({
requiredLatestDependencyVersion: "1.2.3",
});
await expect(
runPluginClawHubReleaseCheck(["--plugins", "@openclaw/demo-plugin"], {
rootDir: repoDir,
resolveLatestVersion: () => {
throw new Error("registry unavailable");
},
}),
).rejects.toThrow(
"@openclaw/demo-plugin@2026.4.1: could not resolve npm latest for demo-runtime: registry unavailable",
);
});
});
describe("buildOpenClawReleaseClawHubRuntimeState", () => {
it("includes the normal ClawHub run in verifier args when the release waits for it", () => {
const state = buildOpenClawReleaseClawHubRuntimeState({
@@ -1407,6 +1557,7 @@ function createTempPluginRepo(
extraExtensionIds?: string[];
publishToClawHub?: boolean;
includeClawHubContract?: boolean;
requiredLatestDependencyVersion?: string;
} = {},
) {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-clawhub-release-");
@@ -1431,6 +1582,13 @@ function createTempPluginRepo(
type: "git",
url: OPENCLAW_PLUGIN_NPM_REPOSITORY_URL,
},
...(options.requiredLatestDependencyVersion
? {
dependencies: {
"demo-runtime": options.requiredLatestDependencyVersion,
},
}
: {}),
openclaw: {
extensions: ["./index.ts"],
...(options.includeClawHubContract === false
@@ -1448,6 +1606,11 @@ function createTempPluginRepo(
},
release: {
publishToClawHub: options.publishToClawHub ?? true,
...(options.requiredLatestDependencyVersion
? {
requireLatestDependencies: ["demo-runtime"],
}
: {}),
},
},
},
+136
View File
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { collectClawHubPublishablePluginPackages } from "../scripts/lib/plugin-clawhub-release.ts";
import {
collectChangedExtensionIdsFromPaths,
collectPluginReleaseDependencyFreshnessErrors,
collectPluginReleaseVersionFloorErrors,
collectPublishablePluginPackages,
collectPublishablePluginPackageErrors,
@@ -299,6 +300,42 @@ describe("collectPublishablePluginPackageErrors", () => {
}),
).toEqual(["README.md must exist and contain package documentation."]);
});
it("requires latest-release dependencies to name exact runtime dependencies", () => {
expect(
collectPublishablePluginPackageErrors({
extensionId: "codex",
packageDir: bundledPluginRoot("codex"),
readmeText: "# Codex\n",
packageJson: {
name: "@openclaw/codex",
version: "2026.6.11",
type: "module",
repository: {
type: "git",
url: OPENCLAW_PLUGIN_NPM_REPOSITORY_URL,
},
dependencies: {
"@openai/codex": "0.142.5",
},
openclaw: {
extensions: ["./index.ts"],
...externalPluginContract("2026.6.11"),
install: {
npmSpec: "@openclaw/codex",
},
release: {
publishToNpm: true,
requireLatestDependencies: ["@openai/codex", "@openai/codex", "missing"],
},
},
},
}),
).toEqual([
'openclaw.release.requireLatestDependencies must not contain duplicate package names; found "@openai/codex".',
'openclaw.release.requireLatestDependencies must reference package.json dependencies or optionalDependencies; "missing" is not a runtime dependency.',
]);
});
});
describe("collectPluginReleaseVersionFloorErrors", () => {
@@ -331,6 +368,58 @@ describe("collectPluginReleaseVersionFloorErrors", () => {
});
});
describe("collectPluginReleaseDependencyFreshnessErrors", () => {
const plugin: PublishablePluginPackage = {
extensionId: "codex",
packageDir: "extensions/codex",
packageName: "@openclaw/codex",
version: "2026.6.11",
channel: "stable",
publishTag: "latest",
requiredLatestDependencies: [
{
packageName: "@openai/codex",
version: "0.139.0",
},
],
};
it("rejects release dependencies older than the npm latest dist-tag", () => {
expect(collectPluginReleaseDependencyFreshnessErrors([plugin], () => "0.142.5")).toEqual([
'@openclaw/codex@2026.6.11: @openai/codex must match npm latest for release; found "0.139.0", latest is "0.142.5".',
]);
});
it("accepts release dependencies matching the npm latest dist-tag", () => {
expect(
collectPluginReleaseDependencyFreshnessErrors(
[
{
...plugin,
requiredLatestDependencies: [
{
packageName: "@openai/codex",
version: "0.142.5",
},
],
},
],
() => "0.142.5",
),
).toEqual([]);
});
it("fails closed when npm latest cannot be resolved", () => {
expect(
collectPluginReleaseDependencyFreshnessErrors([plugin], () => {
throw new Error("registry unavailable");
}),
).toEqual([
"@openclaw/codex@2026.6.11: could not resolve npm latest for @openai/codex: registry unavailable",
]);
});
});
describe("collectPublishablePluginPackages", () => {
it("keeps publishable plugin dist trees out of the core npm package unless bundled", () => {
const corePackageRuntimePluginIds = new Set(["discord"]);
@@ -404,6 +493,53 @@ describe("collectPublishablePluginPackages", () => {
]);
});
it("collects exact release dependencies that must match npm latest", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-release-");
mkdirSync(join(repoDir, "extensions", "demo-plugin"), { recursive: true });
writePluginReadme(repoDir, "demo-plugin");
writeJsonFile(join(repoDir, "extensions", "demo-plugin", "package.json"), {
name: "@openclaw/demo-plugin",
version: "2026.4.10",
type: "module",
repository: {
type: "git",
url: OPENCLAW_PLUGIN_NPM_REPOSITORY_URL,
},
dependencies: {
"demo-runtime": "1.2.3",
},
openclaw: {
extensions: ["./index.ts"],
...externalPluginContract("2026.4.10"),
install: {
npmSpec: "@openclaw/demo-plugin",
},
release: {
publishToNpm: true,
requireLatestDependencies: ["demo-runtime"],
},
},
});
expect(collectPublishablePluginPackages(repoDir)).toEqual([
{
extensionId: "demo-plugin",
packageDir: "extensions/demo-plugin",
packageName: "@openclaw/demo-plugin",
version: "2026.4.10",
channel: "stable",
publishTag: "latest",
installNpmSpec: "@openclaw/demo-plugin",
requiredLatestDependencies: [
{
packageName: "demo-runtime",
version: "1.2.3",
},
],
},
]);
});
it("does not validate unselected publishable plugin manifests", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-release-");
mkdirSync(join(repoDir, "extensions", "demo-plugin"), { recursive: true });