mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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:
@@ -42,6 +42,9 @@
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true,
|
||||
"requireLatestDependencies": [
|
||||
"@openai/codex"
|
||||
],
|
||||
"bundleRuntimeDependencies": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user