fix(release): close plugin publication authority

This commit is contained in:
Vincent Koc
2026-08-21 02:12:21 -07:00
parent ddf592bf8a
commit a4957ab16f
12 changed files with 372 additions and 54 deletions
+4
View File
@@ -9,7 +9,9 @@ on:
- ".github/workflows/plugin-npm-release.yml"
- "extensions/**"
- "package.json"
- "pnpm-lock.yaml"
- "packages/normalization-core/**"
- "packages/plugin-package-contract/src/index.ts"
- "scripts/generate-npm-package-lock.mjs"
- "scripts/generate-npm-package-lock.mts"
- "scripts/lib/npm-publish-plan.mjs"
@@ -19,6 +21,8 @@ on:
- "scripts/lib/plugin-npm-package-manifest.mts"
- "scripts/lib/tsx-cli-shim.mjs"
- "scripts/lib/plugin-npm-release.ts"
- "scripts/lib/plugin-publication-candidates.ts"
- "scripts/lib/plugin-publication-collector.ts"
- "scripts/lib/actions-artifact-archive.mjs"
- "scripts/plugin-npm-publish.sh"
- "scripts/plugin-publication-artifact.mjs"
+15 -14
View File
@@ -18,7 +18,11 @@ import {
type NpmLatestVersionResolver,
type PluginReleaseSelectionMode,
} from "./plugin-npm-release.ts";
import { collectExtensionPackageJsonCandidates } from "./plugin-publication-candidates.ts";
import {
collectExtensionPackageJsonCandidates,
hasPluginPublicationSharedAuthorityChanges,
PLUGIN_PUBLICATION_SHARED_AUTHORITY_PATHS,
} from "./plugin-publication-candidates.ts";
import {
collectPublishablePluginPackagesFromCandidates,
type PluginPackageJson,
@@ -75,17 +79,10 @@ const CLAWHUB_ERROR_BODY_MAX_CHARS = 400;
const CLAWHUB_RELEASE_PLAN_CONCURRENCY = 8;
const OPENCLAW_PLUGIN_CLAWHUB_REPOSITORY = "openclaw/openclaw";
const OPENCLAW_PLUGIN_CLAWHUB_WORKFLOW_FILENAME = "plugin-clawhub-release.yml";
const CLAWHUB_SHARED_RELEASE_INPUT_PATHS = [
const CLAWHUB_RELEASE_AUTHORITY_PATHS = [
".github/workflows/plugin-clawhub-release.yml",
".github/actions/setup-node-env",
"package.json",
"pnpm-lock.yaml",
"packages/plugin-package-contract/src/index.ts",
"scripts/lib/bounded-response.mjs",
"scripts/lib/npm-publish-plan.mjs",
"scripts/lib/plugin-publication-candidates.ts",
"scripts/lib/plugin-publication-collector.ts",
"scripts/lib/release-version.mjs",
"scripts/lib/plugin-npm-release.ts",
"scripts/lib/plugin-clawhub-release.ts",
"scripts/openclaw-npm-release-check.ts",
@@ -254,7 +251,8 @@ function collectPluginClawHubRelevantPathsFromGitRange(params: {
}): string[] {
return collectPluginClawHubReleasePathsFromGitRangeForPathspecs(params, [
"extensions",
...CLAWHUB_SHARED_RELEASE_INPUT_PATHS,
...PLUGIN_PUBLICATION_SHARED_AUTHORITY_PATHS,
...CLAWHUB_RELEASE_AUTHORITY_PATHS,
]);
}
@@ -273,10 +271,13 @@ function collectPluginClawHubReleasePathsFromGitRangeForPathspecs(
}
function hasSharedClawHubReleaseInputChanges(changedPaths: readonly string[]) {
return changedPaths.some((path) =>
CLAWHUB_SHARED_RELEASE_INPUT_PATHS.some(
(sharedPath) => path === sharedPath || path.startsWith(`${sharedPath}/`),
),
return (
hasPluginPublicationSharedAuthorityChanges(changedPaths) ||
changedPaths.some((path) =>
CLAWHUB_RELEASE_AUTHORITY_PATHS.some(
(authorityPath) => path === authorityPath || path.startsWith(`${authorityPath}/`),
),
)
);
}
+37 -20
View File
@@ -4,8 +4,13 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { expectDefined } from "../../packages/normalization-core/src/expect.js";
import { collectExtensionPackageJsonCandidates } from "./plugin-publication-candidates.ts";
import {
collectExtensionPackageJsonCandidates,
hasPluginPublicationSharedAuthorityChanges,
PLUGIN_PUBLICATION_SHARED_AUTHORITY_PATHS,
} from "./plugin-publication-candidates.ts";
import {
assertUniquePublishablePluginPackageSources,
collectPublishablePluginPackagesFromCandidates,
type PluginPackageJson,
type PublishablePluginPackage,
@@ -43,6 +48,11 @@ export type GitRangeSelection = {
headRef: string;
};
export type PluginNpmGitRangeSelection = {
changedExtensionIds: string[];
sharedAuthorityChanged: boolean;
};
type ParsedPluginReleaseArgs = {
selection: string[];
selectionMode?: PluginReleaseSelectionMode;
@@ -219,6 +229,7 @@ export function resolveSelectedPublishablePluginPackages(params: {
plugins: PublishablePluginPackage[];
selection: string[];
}): PublishablePluginPackage[] {
assertUniquePublishablePluginPackageSources(params.plugins, "Plugin selection");
if (params.selection.length === 0) {
return params.plugins;
}
@@ -320,17 +331,19 @@ export function collectChangedPathsFromGitRange(params: {
.map((path) => normalizeGitDiffPath(path));
}
export function collectChangedExtensionIdsFromGitRange(params: {
export function collectPluginNpmGitRangeSelection(params: {
rootDir?: string;
gitRange: GitRangeSelection;
}): string[] {
return collectChangedExtensionIdsFromPaths(
collectChangedPathsFromGitRange({
rootDir: params.rootDir,
gitRange: params.gitRange,
pathspecs: ["extensions"],
}),
);
}): PluginNpmGitRangeSelection {
const changedPaths = collectChangedPathsFromGitRange({
rootDir: params.rootDir,
gitRange: params.gitRange,
pathspecs: ["extensions", ...PLUGIN_PUBLICATION_SHARED_AUTHORITY_PATHS],
});
return {
changedExtensionIds: collectChangedExtensionIdsFromPaths(changedPaths),
sharedAuthorityChanged: hasPluginPublicationSharedAuthorityChanges(changedPaths),
};
}
export function resolveChangedPublishablePluginPackages(params: {
@@ -483,17 +496,19 @@ export function collectPluginReleasePlan(params?: {
gitRange?: GitRangeSelection;
npmDistTag?: "extended-stable";
}): PluginReleasePlan {
const changedExtensionIds = params?.gitRange
? collectChangedExtensionIdsFromGitRange({
const gitRangeSelection = params?.gitRange
? collectPluginNpmGitRangeSelection({
rootDir: params.rootDir,
gitRange: params.gitRange,
})
: [];
: undefined;
const allPublishable = collectPublishablePluginPackages(params?.rootDir, {
extensionIds:
params?.selectionMode === "all-publishable" || !params?.gitRange
params?.selectionMode === "all-publishable" ||
!gitRangeSelection ||
gitRangeSelection.sharedAuthorityChanged
? undefined
: changedExtensionIds,
: gitRangeSelection.changedExtensionIds,
packageNames: params?.selection && params.selection.length > 0 ? params.selection : undefined,
npmDistTag: params?.npmDistTag,
});
@@ -505,11 +520,13 @@ export function collectPluginReleasePlan(params?: {
plugins: allPublishable,
selection: params.selection,
})
: params?.gitRange
? resolveChangedPublishablePluginPackages({
plugins: allPublishable,
changedExtensionIds,
})
: gitRangeSelection
? gitRangeSelection.sharedAuthorityChanged
? allPublishable
: resolveChangedPublishablePluginPackages({
plugins: allPublishable,
changedExtensionIds: gitRangeSelection.changedExtensionIds,
})
: allPublishable;
const explicitPublishSelection =
@@ -5,6 +5,27 @@ import type {
PublishablePluginPackageCandidate,
} from "./plugin-publication-collector.ts";
// Any change here can alter the package inventory for both registries. Range
// selectors and workflow triggers must keep this closure in sync.
export const PLUGIN_PUBLICATION_SHARED_AUTHORITY_PATHS = [
"package.json",
"pnpm-lock.yaml",
"packages/normalization-core/src/string-coerce.ts",
"packages/plugin-package-contract/src/index.ts",
"scripts/lib/npm-publish-plan.mjs",
"scripts/lib/plugin-publication-candidates.ts",
"scripts/lib/plugin-publication-collector.ts",
"scripts/lib/release-version.mjs",
] as const;
export function hasPluginPublicationSharedAuthorityChanges(paths: readonly string[]): boolean {
return paths.some((path) =>
PLUGIN_PUBLICATION_SHARED_AUTHORITY_PATHS.some(
(authorityPath) => path === authorityPath || path.startsWith(`${authorityPath}/`),
),
);
}
function readPluginPackageJson(absolutePath: string, repoPath: string): PluginPackageJson {
let raw: string;
try {
@@ -72,6 +72,11 @@ export type PublishablePluginPackageFilters = {
rootVersion?: string;
};
export type PublishablePluginPackageSource = Pick<
PublishablePluginPackage,
"extensionId" | "packageDir" | "packageName"
>;
export const OPENCLAW_PLUGIN_NPM_REPOSITORY_URL = "https://github.com/openclaw/openclaw";
const SAFE_CLAWHUB_EXTENSION_ID = /^[a-z0-9][a-z0-9._-]*$/;
@@ -208,6 +213,46 @@ export function collectPublishablePluginPackageErrors(
return errors;
}
export function collectConflictingPluginPackageSourceErrors(
sources: readonly PublishablePluginPackageSource[],
): string[] {
const sourcesByPackageName = new Map<string, Map<string, PublishablePluginPackageSource>>();
for (const source of sources) {
const packageName = source.packageName.trim();
if (!packageName) {
continue;
}
const packageSources = sourcesByPackageName.get(packageName) ?? new Map();
packageSources.set(`${source.extensionId}\0${source.packageDir}`, source);
sourcesByPackageName.set(packageName, packageSources);
}
return [...sourcesByPackageName.entries()]
.flatMap(([packageName, packageSources]) => {
if (packageSources.size < 2) {
return [];
}
const descriptions = [...packageSources.values()]
.map((source) => `${source.extensionId} (${source.packageDir})`)
.toSorted();
return [
`package ${packageName} is declared by multiple plugin sources: ${descriptions.join(", ")}.`,
];
})
.toSorted();
}
export function assertUniquePublishablePluginPackageSources(
sources: readonly PublishablePluginPackageSource[],
label: string,
): void {
const errors = collectConflictingPluginPackageSourceErrors(sources);
if (errors.length === 0) {
return;
}
throw new Error(`${label} has conflicting plugin package provenance:\n${errors.join("\n")}`);
}
export function collectPublishablePluginPackagesFromCandidates(
candidates: readonly PublishablePluginPackageCandidate[],
target: "npm" | "clawhub",
@@ -220,6 +265,23 @@ export function collectPublishablePluginPackagesFromCandidates(
const hasSelectedExtensionIds = Array.isArray(filters.extensionIds);
const hasSelectedPackageNames = Array.isArray(filters.packageNames);
validationErrors.push(
...collectConflictingPluginPackageSourceErrors(
candidates
.filter(
(candidate) =>
!isPluginExternalPublicationDeferred(candidate.packageJson) &&
(candidate.packageJson.openclaw?.release?.publishToNpm === true ||
candidate.packageJson.openclaw?.release?.publishToClawHub === true),
)
.map((candidate) => ({
extensionId: candidate.extensionId,
packageDir: candidate.packageDir,
packageName: candidate.packageJson.name?.trim() ?? "",
})),
),
);
for (const candidate of candidates) {
const { extensionId, packageDir, packageJson } = candidate;
if (hasSelectedExtensionIds && !selectedExtensionIds.has(extensionId)) {
+15 -11
View File
@@ -5,7 +5,7 @@ import { pathToFileURL } from "node:url";
import {
assertPluginReleaseDependencyFreshness,
assertPluginReleaseVersionFloors,
collectChangedExtensionIdsFromGitRange,
collectPluginNpmGitRangeSelection,
collectPublishablePluginPackages,
parsePluginNpmReleaseArgs,
resolveChangedPublishablePluginPackages,
@@ -15,17 +15,19 @@ import {
function runPluginNpmReleaseCheck(argv: string[]) {
const { selection, selectionMode, npmDistTag, baseRef, headRef } =
parsePluginNpmReleaseArgs(argv);
const changedExtensionIds =
const gitRangeSelection =
baseRef && headRef
? collectChangedExtensionIdsFromGitRange({
? collectPluginNpmGitRangeSelection({
gitRange: { baseRef, headRef },
})
: [];
: undefined;
const publishable = collectPublishablePluginPackages(".", {
extensionIds:
selectionMode === "all-publishable" || !(baseRef && headRef)
selectionMode === "all-publishable" ||
!gitRangeSelection ||
gitRangeSelection.sharedAuthorityChanged
? undefined
: changedExtensionIds,
: gitRangeSelection.changedExtensionIds,
packageNames: selection.length > 0 ? selection : undefined,
npmDistTag,
});
@@ -37,11 +39,13 @@ function runPluginNpmReleaseCheck(argv: string[]) {
plugins: publishable,
selection,
})
: baseRef && headRef
? resolveChangedPublishablePluginPackages({
plugins: publishable,
changedExtensionIds,
})
: gitRangeSelection
? gitRangeSelection.sharedAuthorityChanged
? publishable
: resolveChangedPublishablePluginPackages({
plugins: publishable,
changedExtensionIds: gitRangeSelection.changedExtensionIds,
})
: publishable;
if (selectionMode !== undefined || selection.length > 0) {
+21 -3
View File
@@ -332,17 +332,26 @@ function collectPackageInventory(
if (typeof version !== "string" || !version) {
throw new Error("candidate package.json version is required");
}
const packages = new Map<string, { name: string; version: string; targets: Set<string> }>();
const packages = new Map<
string,
{ name: string; source: string; version: string; targets: Set<string> }
>();
const addPackage = (manifest: PackageManifest, targets: string[], source: string) => {
if (typeof manifest.name !== "string" || typeof manifest.version !== "string") {
throw new Error(`${source} must declare package name and version`);
}
const existing = packages.get(manifest.name);
if (existing && existing.source !== source) {
throw new Error(
`package inventory source mismatch for ${manifest.name}: ${existing.source} and ${source}`,
);
}
if (existing && existing.version !== manifest.version) {
throw new Error(`package inventory version mismatch for ${manifest.name}`);
}
const entry = existing ?? {
name: manifest.name,
source,
version: manifest.version,
targets: new Set<string>(),
};
@@ -398,6 +407,15 @@ function collectPackageInventory(
function collectPlatformSources(workflowText: string) {
const platforms = new Map<string, string>();
const addPlatform = (id: string, source: string) => {
const existing = platforms.get(id);
if (existing && existing !== source) {
throw new Error(
`${PUBLICATION_WORKFLOW_PATH} declares conflicting platform ${id}: ${existing} and ${source}`,
);
}
platforms.set(id, source);
};
const promotionPattern = /promote_([a-z0-9_]+)_release_assets?\(\)\s*\{([\s\S]*?)^\s*\}/gmu;
const dispatchPattern =
/dispatch_workflow(?:_at_ref)?\s+(?:(?:"[^"]+"|'[^']+')\s+){0,2}([a-z0-9][a-z0-9-]+\.yml)/u;
@@ -407,7 +425,7 @@ function collectPlatformSources(workflowText: string) {
if (!id || !workflowName) {
throw new Error(`${PUBLICATION_WORKFLOW_PATH} has an invalid platform promotion function`);
}
platforms.set(id, `.github/workflows/${workflowName}`);
addPlatform(id, `.github/workflows/${workflowName}`);
}
const workflow = parseYaml(workflowText) as {
jobs?: Record<string, { uses?: unknown }>;
@@ -420,7 +438,7 @@ function collectPlatformSources(workflowText: string) {
if (!match?.[1]) {
throw new Error(`${PUBLICATION_WORKFLOW_PATH} has an invalid reusable publication workflow`);
}
platforms.set(
addPlatform(
jobId.slice("publish_".length).replaceAll("_", "-"),
`.github/workflows/${match[1]}`,
);
+2 -1
View File
@@ -6,6 +6,7 @@ type PublishablePluginSurface = "npm" | "clawhub" | "both" | "clawhub-disabled";
type PublishablePluginFixtureOptions = {
extensionId?: string;
packageName?: string;
version: string;
publishTo: PublishablePluginSurface;
bundledDist?: boolean;
@@ -21,7 +22,7 @@ export function writePublishablePluginFixture(
options: PublishablePluginFixtureOptions,
) {
const extensionId = options.extensionId ?? "demo-plugin";
const packageName = `@openclaw/${extensionId}`;
const packageName = options.packageName ?? `@openclaw/${extensionId}`;
const packageDir = join(repoDir, "extensions", extensionId);
const publishToNpm = options.publishTo === "npm" || options.publishTo === "both";
const publishToClawHub = options.publishTo === "clawhub" || options.publishTo === "both";
+15
View File
@@ -128,6 +128,20 @@ describe("resolveChangedClawHubPublishablePluginPackages", () => {
});
describe("collectClawHubPublishablePluginPackages", () => {
it("rejects duplicate ClawHub package names from different plugin directories", () => {
const repoDir = createTempPluginRepo();
writePublishablePluginFixture(repoDir, {
extensionId: "demo-shadow",
packageName: "@openclaw/demo-plugin",
version: "2026.4.1",
publishTo: "clawhub",
});
expect(() => collectClawHubPublishablePluginPackages(repoDir)).toThrow(
"package @openclaw/demo-plugin is declared by multiple plugin sources: demo-plugin (extensions/demo-plugin), demo-shadow (extensions/demo-shadow).",
);
});
it("requires the ClawHub external plugin contract", () => {
const repoDir = createTempPluginRepo({
includeClawHubContract: false,
@@ -410,6 +424,7 @@ describe("resolveSelectedClawHubPublishablePluginPackages", () => {
});
it.each([
"packages/normalization-core/src/string-coerce.ts",
"scripts/lib/plugin-publication-candidates.ts",
"scripts/lib/plugin-publication-collector.ts",
])("selects all publishable plugins when %s changes", (changedPath) => {
+99 -3
View File
@@ -1,12 +1,14 @@
// Plugin npm release tests validate plugin npm release artifacts.
import { mkdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { bundledPluginFile, bundledPluginRoot } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it, vi } from "vitest";
import { collectClawHubPublishablePluginPackages } from "../scripts/lib/plugin-clawhub-release.ts";
import {
collectChangedExtensionIdsFromPaths,
collectPluginReleaseDependencyFreshnessErrors,
collectPluginNpmGitRangeSelection,
collectPluginReleasePlan,
collectPluginReleaseVersionFloorErrors,
collectPublishablePluginPackages,
@@ -24,7 +26,7 @@ import { writePublishablePluginFixture } from "./helpers/publishable-plugin-fixt
import { cleanupTempDirs, makeTempDir as makeTempRepoRoot } from "./helpers/temp-dir.js";
import { writeJsonFile } from "./helpers/temp-repo.js";
type ExecFileSync = typeof import("node:child_process").execFileSync;
type ExecFileSync = typeof execFileSync;
const childProcessMock = vi.hoisted(() => ({
execFileSyncOverride: undefined as ExecFileSync | undefined,
@@ -519,6 +521,22 @@ describe("collectPluginReleasePlan", () => {
});
describe("collectPublishablePluginPackages", () => {
it("rejects duplicate npm package names from different plugin directories", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-release-");
for (const extensionId of ["demo-one", "demo-two"]) {
writePublishablePluginFixture(repoDir, {
extensionId,
packageName: "@openclaw/shared-plugin",
version: "2026.4.10",
publishTo: "npm",
});
}
expect(() => collectPublishablePluginPackages(repoDir)).toThrow(
"package @openclaw/shared-plugin is declared by multiple plugin sources: demo-one (extensions/demo-one), demo-two (extensions/demo-two).",
);
});
it("defers explicitly bundled plugins from npm and ClawHub release plans", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-release-");
writePublishablePluginFixture(repoDir, {
@@ -773,6 +791,22 @@ describe("resolveSelectedPublishablePluginPackages", () => {
}),
).toThrowError("Unknown or non-publishable plugin package selection: @openclaw/missing.");
});
it("rejects duplicate selected package provenance instead of choosing the last entry", () => {
expect(() =>
resolveSelectedPublishablePluginPackages({
plugins: [
publishablePlugins[0],
{
...publishablePlugins[0],
extensionId: "feishu-shadow",
packageDir: "extensions/feishu-shadow",
},
],
selection: ["@openclaw/feishu"],
}),
).toThrow("Plugin selection has conflicting plugin package provenance");
});
});
describe("collectChangedExtensionIdsFromPaths", () => {
@@ -788,6 +822,68 @@ describe("collectChangedExtensionIdsFromPaths", () => {
});
});
describe("collectPluginNpmGitRangeSelection", () => {
it.each([
"packages/normalization-core/src/string-coerce.ts",
"scripts/lib/plugin-publication-candidates.ts",
"scripts/lib/plugin-publication-collector.ts",
])("selects all publishable plugins for a shared-only %s change", (changedPath) => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-range-");
const absolutePath = join(repoDir, changedPath);
mkdirSync(dirname(absolutePath), { recursive: true });
writeFileSync(absolutePath, "// before\n");
execFileSync("git", ["init", "-q", "-b", "main"], { cwd: repoDir });
execFileSync("git", ["add", "."], { cwd: repoDir });
execFileSync(
"git",
[
"-c",
"user.name=OpenClaw Tests",
"-c",
"user.email=tests@openclaw.invalid",
"commit",
"-qm",
"base",
],
{ cwd: repoDir },
);
const baseRef = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: repoDir,
encoding: "utf8",
}).trim();
writeFileSync(absolutePath, "// after\n");
execFileSync("git", ["add", "."], { cwd: repoDir });
execFileSync(
"git",
[
"-c",
"user.name=OpenClaw Tests",
"-c",
"user.email=tests@openclaw.invalid",
"commit",
"-qm",
"change",
],
{ cwd: repoDir },
);
const headRef = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: repoDir,
encoding: "utf8",
}).trim();
expect(
collectPluginNpmGitRangeSelection({
rootDir: repoDir,
gitRange: { baseRef, headRef },
}),
).toEqual({
changedExtensionIds: [],
sharedAuthorityChanged: true,
});
});
});
describe("resolveChangedPublishablePluginPackages", () => {
const publishablePlugins: PublishablePluginPackage[] = [
{
@@ -1,6 +1,7 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
import { PLUGIN_PUBLICATION_SHARED_AUTHORITY_PATHS } from "../../scripts/lib/plugin-publication-candidates.ts";
const workflowPath = ".github/workflows/plugin-npm-release.yml";
const metaPackagePath = "extensions/meta/package.json";
@@ -54,7 +55,25 @@ function step(job: Job | undefined, name: string): Step {
return found;
}
function workflowPathPatternCovers(pattern: string, path: string): boolean {
if (pattern.endsWith("/**")) {
const directory = pattern.slice(0, -3);
return path === directory || path.startsWith(`${directory}/`);
}
return path === pattern;
}
describe("plugin npm extended-stable workflow", () => {
it("triggers for every shared plugin publication authority", () => {
const triggerPaths = workflow().on?.push?.paths ?? [];
for (const authorityPath of PLUGIN_PUBLICATION_SHARED_AUTHORITY_PATHS) {
expect(
triggerPaths.some((pattern) => workflowPathPatternCovers(pattern, authorityPath)),
authorityPath,
).toBe(true);
}
});
it("exposes only the default behavior and closed extended-stable override", () => {
expect(readFileSync(workflowPath, "utf8")).toContain(
"Plugin NPM Release [{0}] {1}', inputs.npm_dist_tag, inputs.ref",
+62 -2
View File
@@ -14,6 +14,7 @@ import {
verifyReleasePlanLock,
type ReleasePlanIntent,
} from "../../scripts/release-plan-producer.mts";
import { writePublishablePluginFixture } from "../helpers/publishable-plugin-fixture.js";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
@@ -65,7 +66,13 @@ function copyToolingClosure(root: string) {
function createFixtureRepo(
version = "2026.8.1-beta.2",
options: { malformedPlugin?: boolean; malformedPluginJson?: boolean } = {},
options: {
conflictingPlatformId?: boolean;
corePackageNameCollision?: boolean;
duplicateCrossTargetPackageName?: boolean;
malformedPlugin?: boolean;
malformedPluginJson?: boolean;
} = {},
) {
const root = tempDirs.make("openclaw-release-plan-");
execFileSync("git", ["init", "-q", "-b", "tooling"], { cwd: root });
@@ -94,7 +101,27 @@ function createFixtureRepo(
}),
);
}
if (options.malformedPluginJson) {
if (options.corePackageNameCollision) {
writePublishablePluginFixture(root, {
extensionId: "shadow-ai",
packageName: "@openclaw/ai",
version,
publishTo: "both",
});
} else if (options.duplicateCrossTargetPackageName) {
writePublishablePluginFixture(root, {
extensionId: "duplicate-npm",
packageName: "@openclaw/duplicate",
version,
publishTo: "npm",
});
writePublishablePluginFixture(root, {
extensionId: "duplicate-clawhub",
packageName: "@openclaw/duplicate",
version,
publishTo: "clawhub",
});
} else if (options.malformedPluginJson) {
writeFixture(root, "extensions/broken/package.json", "{ not-json\n");
} else if (options.malformedPlugin) {
writeFixture(
@@ -155,6 +182,9 @@ function createFixtureRepo(
" uses: ./.github/workflows/docker-release.yml",
" publish_vcr:",
" uses: ./.github/workflows/vercel-container-registry-publish.yml",
...(options.conflictingPlatformId
? [" publish_windows:", " uses: ./.github/workflows/docker-release.yml"]
: []),
"",
].join("\n"),
);
@@ -393,6 +423,36 @@ describe("release plan producer", () => {
expect(() => produceReleasePlan(sourceParams(fixture))).toThrow(error);
});
it("rejects duplicate package names split across npm and ClawHub plugin sources", () => {
const fixture = createFixtureRepo("2026.8.1-beta.2", {
duplicateCrossTargetPackageName: true,
});
expect(() => produceReleasePlan(sourceParams(fixture))).toThrow(
"package @openclaw/duplicate is declared by multiple plugin sources",
);
});
it("rejects a plugin package name that collides with a core package source", () => {
const fixture = createFixtureRepo("2026.8.1-beta.2", {
corePackageNameCollision: true,
});
expect(() => produceReleasePlan(sourceParams(fixture))).toThrow(
"package inventory source mismatch for @openclaw/ai: extensions/shadow-ai/package.json and packages/ai/package.json",
);
});
it("rejects conflicting platform publication sources with the same id", () => {
const fixture = createFixtureRepo("2026.8.1-beta.2", {
conflictingPlatformId: true,
});
expect(() => produceReleasePlan(sourceParams(fixture))).toThrow(
"declares conflicting platform windows: .github/workflows/windows-node-release.yml and .github/workflows/docker-release.yml",
);
});
it("matches the exact current publisher inventory: 93 npm and 89 ClawHub packages", () => {
const root = tempDirs.make("openclaw-release-plan-current-");
const candidateSha = execFileSync("git", ["rev-parse", "HEAD"], {