diff --git a/extensions/codex/package.json b/extensions/codex/package.json index 9444c11123e5..052212963fc0 100644 --- a/extensions/codex/package.json +++ b/extensions/codex/package.json @@ -42,6 +42,9 @@ "release": { "publishToClawHub": true, "publishToNpm": true, + "requireLatestDependencies": [ + "@openai/codex" + ], "bundleRuntimeDependencies": false } } diff --git a/extensions/codex/src/manifest.test.ts b/extensions/codex/src/manifest.test.ts index 77afd689467f..aec3ccfd7727 100644 --- a/extensions/codex/src/manifest.test.ts +++ b/extensions/codex/src/manifest.test.ts @@ -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", diff --git a/scripts/lib/plugin-clawhub-release.ts b/scripts/lib/plugin-clawhub-release.ts index b719a058ef46..8ce33e3139d7 100644 --- a/scripts/lib/plugin-clawhub-release.ts +++ b/scripts/lib/plugin-clawhub-release.ts @@ -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 { 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) { diff --git a/scripts/lib/plugin-npm-release.ts b/scripts/lib/plugin-npm-release.ts index 82d213805df4..25c1f13aa276 100644 --- a/scripts/lib/plugin-npm-release.ts +++ b/scripts/lib/plugin-npm-release.ts @@ -13,6 +13,8 @@ export type PluginPackageJson = { version?: string; type?: string; private?: boolean; + dependencies?: Record; + optionalDependencies?: Record; 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(); + + 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(); + 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, { diff --git a/scripts/plugin-clawhub-release-check.ts b/scripts/plugin-clawhub-release-check.ts index bf1d91d387bf..d103b8dbed79 100644 --- a/scripts/plugin-clawhub-release-check.ts +++ b/scripts/plugin-clawhub-release-check.ts @@ -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( diff --git a/scripts/plugin-npm-release-check.ts b/scripts/plugin-npm-release-check.ts index 14258fd40402..4d9482344460 100644 --- a/scripts/plugin-npm-release-check.ts +++ b/scripts/plugin-npm-release-check.ts @@ -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) { diff --git a/test/plugin-clawhub-release.test.ts b/test/plugin-clawhub-release.test.ts index 2b12e2052e29..9d6deb9979c1 100644 --- a/test/plugin-clawhub-release.test.ts +++ b/test/plugin-clawhub-release.test.ts @@ -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"], + } + : {}), }, }, }, diff --git a/test/plugin-npm-release.test.ts b/test/plugin-npm-release.test.ts index cddae23ab86a..9b242128b407 100644 --- a/test/plugin-npm-release.test.ts +++ b/test/plugin-npm-release.test.ts @@ -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 });