From f493b03202114696cdc82b2122ffe06c3c70968b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 17:00:47 +0000 Subject: [PATCH] refactor: validate bundled extension release metadata --- scripts/release-check.ts | 77 ++++++++++++++++++++++++++++++++++---- test/release-check.test.ts | 40 ++++++++++++++++++++ 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/scripts/release-check.ts b/scripts/release-check.ts index db396672ff7..df79b69f25c 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -22,6 +22,11 @@ type PackageJson = { }; }; }; +type BundledExtension = { id: string; packageJson: PackageJson }; +type BundledExtensionMetadata = BundledExtension & { + npmSpec?: string; + rootDependencyMirrorAllowlist: string[]; +}; const requiredPathGroups = [ ["dist/index.js", "dist/index.mjs"], @@ -133,7 +138,7 @@ function normalizePluginSyncVersion(version: string): string { export function collectBundledExtensionRootDependencyGapErrors(params: { rootPackage: PackageJson; - extensions: Array<{ id: string; packageJson: PackageJson }>; + extensions: BundledExtension[]; }): string[] { const rootDeps = { ...params.rootPackage.dependencies, @@ -141,17 +146,15 @@ export function collectBundledExtensionRootDependencyGapErrors(params: { }; const errors: string[] = []; - for (const extension of params.extensions) { - if (!extension.packageJson.openclaw?.install?.npmSpec) { + for (const extension of normalizeBundledExtensionMetadata(params.extensions)) { + if (!extension.npmSpec) { continue; } const missing = Object.keys(extension.packageJson.dependencies ?? {}) .filter((dep) => dep !== "openclaw" && !rootDeps[dep]) .toSorted(); - const allowlisted = [ - ...(extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist ?? []), - ].toSorted(); + const allowlisted = extension.rootDependencyMirrorAllowlist.toSorted(); if (missing.join("\n") !== allowlisted.join("\n")) { const unexpected = missing.filter((dep) => !allowlisted.includes(dep)); const resolved = allowlisted.filter((dep) => !missing.includes(dep)); @@ -172,7 +175,56 @@ export function collectBundledExtensionRootDependencyGapErrors(params: { return errors; } -function collectBundledExtensions(): Array<{ id: string; packageJson: PackageJson }> { +function normalizeBundledExtensionMetadata( + extensions: BundledExtension[], +): BundledExtensionMetadata[] { + return extensions.map((extension) => ({ + ...extension, + npmSpec: + typeof extension.packageJson.openclaw?.install?.npmSpec === "string" + ? extension.packageJson.openclaw.install.npmSpec.trim() + : undefined, + rootDependencyMirrorAllowlist: + extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist?.filter( + (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, + ) ?? [], + })); +} + +export function collectBundledExtensionManifestErrors(extensions: BundledExtension[]): string[] { + const errors: string[] = []; + for (const extension of extensions) { + const install = extension.packageJson.openclaw?.install; + if ( + install && + (!install.npmSpec || typeof install.npmSpec !== "string" || !install.npmSpec.trim()) + ) { + errors.push( + `bundled extension '${extension.id}' manifest invalid | openclaw.install.npmSpec must be a non-empty string`, + ); + } + + const allowlist = extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist; + if (allowlist === undefined) { + continue; + } + if (!Array.isArray(allowlist)) { + errors.push( + `bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must be an array of non-empty strings`, + ); + continue; + } + const invalidEntries = allowlist.filter((entry) => typeof entry !== "string" || !entry.trim()); + if (invalidEntries.length > 0) { + errors.push( + `bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must contain only non-empty strings`, + ); + } + } + return errors; +} + +function collectBundledExtensions(): BundledExtension[] { const extensionsDir = resolve("extensions"); const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory(), @@ -195,9 +247,18 @@ function collectBundledExtensions(): Array<{ id: string; packageJson: PackageJso function checkBundledExtensionRootDependencyMirrors() { const rootPackage = JSON.parse(readFileSync(resolve("package.json"), "utf8")) as PackageJson; + const extensions = collectBundledExtensions(); + const manifestErrors = collectBundledExtensionManifestErrors(extensions); + if (manifestErrors.length > 0) { + console.error("release-check: bundled extension manifest validation failed:"); + for (const error of manifestErrors) { + console.error(` - ${error}`); + } + process.exit(1); + } const errors = collectBundledExtensionRootDependencyGapErrors({ rootPackage, - extensions: collectBundledExtensions(), + extensions, }); if (errors.length > 0) { console.error("release-check: bundled extension root dependency mirror validation failed:"); diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 8a9ec8fea0e..636cc9bb39a 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { collectAppcastSparkleVersionErrors, + collectBundledExtensionManifestErrors, collectBundledExtensionRootDependencyGapErrors, } from "../scripts/release-check.ts"; @@ -110,3 +111,42 @@ describe("collectBundledExtensionRootDependencyGapErrors", () => { ]); }); }); + +describe("collectBundledExtensionManifestErrors", () => { + it("flags invalid bundled extension install metadata", () => { + expect( + collectBundledExtensionManifestErrors([ + { + id: "broken", + packageJson: { + openclaw: { + install: { npmSpec: " " }, + }, + }, + }, + ]), + ).toEqual([ + "bundled extension 'broken' manifest invalid | openclaw.install.npmSpec must be a non-empty string", + ]); + }); + + it("flags invalid release-check allowlist metadata", () => { + expect( + collectBundledExtensionManifestErrors([ + { + id: "broken", + packageJson: { + openclaw: { + install: { npmSpec: "@openclaw/broken" }, + releaseChecks: { + rootDependencyMirrorAllowlist: ["ok", ""], + }, + }, + }, + }, + ]), + ).toEqual([ + "bundled extension 'broken' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must contain only non-empty strings", + ]); + }); +});