mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
fix(release): close plugin publication authority
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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}/`),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]}`,
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"], {
|
||||
|
||||
Reference in New Issue
Block a user