refactor: validate bundled extension release metadata

This commit is contained in:
Peter Steinberger
2026-03-08 17:00:47 +00:00
parent e53d840fed
commit f493b03202
2 changed files with 109 additions and 8 deletions

View File

@@ -22,6 +22,11 @@ type PackageJson = {
}; };
}; };
}; };
type BundledExtension = { id: string; packageJson: PackageJson };
type BundledExtensionMetadata = BundledExtension & {
npmSpec?: string;
rootDependencyMirrorAllowlist: string[];
};
const requiredPathGroups = [ const requiredPathGroups = [
["dist/index.js", "dist/index.mjs"], ["dist/index.js", "dist/index.mjs"],
@@ -133,7 +138,7 @@ function normalizePluginSyncVersion(version: string): string {
export function collectBundledExtensionRootDependencyGapErrors(params: { export function collectBundledExtensionRootDependencyGapErrors(params: {
rootPackage: PackageJson; rootPackage: PackageJson;
extensions: Array<{ id: string; packageJson: PackageJson }>; extensions: BundledExtension[];
}): string[] { }): string[] {
const rootDeps = { const rootDeps = {
...params.rootPackage.dependencies, ...params.rootPackage.dependencies,
@@ -141,17 +146,15 @@ export function collectBundledExtensionRootDependencyGapErrors(params: {
}; };
const errors: string[] = []; const errors: string[] = [];
for (const extension of params.extensions) { for (const extension of normalizeBundledExtensionMetadata(params.extensions)) {
if (!extension.packageJson.openclaw?.install?.npmSpec) { if (!extension.npmSpec) {
continue; continue;
} }
const missing = Object.keys(extension.packageJson.dependencies ?? {}) const missing = Object.keys(extension.packageJson.dependencies ?? {})
.filter((dep) => dep !== "openclaw" && !rootDeps[dep]) .filter((dep) => dep !== "openclaw" && !rootDeps[dep])
.toSorted(); .toSorted();
const allowlisted = [ const allowlisted = extension.rootDependencyMirrorAllowlist.toSorted();
...(extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist ?? []),
].toSorted();
if (missing.join("\n") !== allowlisted.join("\n")) { if (missing.join("\n") !== allowlisted.join("\n")) {
const unexpected = missing.filter((dep) => !allowlisted.includes(dep)); const unexpected = missing.filter((dep) => !allowlisted.includes(dep));
const resolved = allowlisted.filter((dep) => !missing.includes(dep)); const resolved = allowlisted.filter((dep) => !missing.includes(dep));
@@ -172,7 +175,56 @@ export function collectBundledExtensionRootDependencyGapErrors(params: {
return errors; 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 extensionsDir = resolve("extensions");
const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) =>
entry.isDirectory(), entry.isDirectory(),
@@ -195,9 +247,18 @@ function collectBundledExtensions(): Array<{ id: string; packageJson: PackageJso
function checkBundledExtensionRootDependencyMirrors() { function checkBundledExtensionRootDependencyMirrors() {
const rootPackage = JSON.parse(readFileSync(resolve("package.json"), "utf8")) as PackageJson; 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({ const errors = collectBundledExtensionRootDependencyGapErrors({
rootPackage, rootPackage,
extensions: collectBundledExtensions(), extensions,
}); });
if (errors.length > 0) { if (errors.length > 0) {
console.error("release-check: bundled extension root dependency mirror validation failed:"); console.error("release-check: bundled extension root dependency mirror validation failed:");

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
collectAppcastSparkleVersionErrors, collectAppcastSparkleVersionErrors,
collectBundledExtensionManifestErrors,
collectBundledExtensionRootDependencyGapErrors, collectBundledExtensionRootDependencyGapErrors,
} from "../scripts/release-check.ts"; } 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",
]);
});
});