diff --git a/.github/workflows/plugin-npm-release.yml b/.github/workflows/plugin-npm-release.yml index eb4a3ca00b99..ff7eb702b851 100644 --- a/.github/workflows/plugin-npm-release.yml +++ b/.github/workflows/plugin-npm-release.yml @@ -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" diff --git a/scripts/lib/plugin-clawhub-release.ts b/scripts/lib/plugin-clawhub-release.ts index 1df426f66e2f..d9207f9bb17c 100644 --- a/scripts/lib/plugin-clawhub-release.ts +++ b/scripts/lib/plugin-clawhub-release.ts @@ -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}/`), + ), + ) ); } diff --git a/scripts/lib/plugin-npm-release.ts b/scripts/lib/plugin-npm-release.ts index 57ffef1c73f3..b09798efaf76 100644 --- a/scripts/lib/plugin-npm-release.ts +++ b/scripts/lib/plugin-npm-release.ts @@ -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 = diff --git a/scripts/lib/plugin-publication-candidates.ts b/scripts/lib/plugin-publication-candidates.ts index 3d3d1d475d4d..c6851db57c5c 100644 --- a/scripts/lib/plugin-publication-candidates.ts +++ b/scripts/lib/plugin-publication-candidates.ts @@ -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 { diff --git a/scripts/lib/plugin-publication-collector.ts b/scripts/lib/plugin-publication-collector.ts index ec00230b958f..2053cf5b69c4 100644 --- a/scripts/lib/plugin-publication-collector.ts +++ b/scripts/lib/plugin-publication-collector.ts @@ -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>(); + 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)) { diff --git a/scripts/plugin-npm-release-check.ts b/scripts/plugin-npm-release-check.ts index ef9cc39abf64..3c06730616fb 100644 --- a/scripts/plugin-npm-release-check.ts +++ b/scripts/plugin-npm-release-check.ts @@ -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) { diff --git a/scripts/release-plan-producer.mts b/scripts/release-plan-producer.mts index 4b2bef917043..afe3f22d0001 100644 --- a/scripts/release-plan-producer.mts +++ b/scripts/release-plan-producer.mts @@ -332,17 +332,26 @@ function collectPackageInventory( if (typeof version !== "string" || !version) { throw new Error("candidate package.json version is required"); } - const packages = new Map }>(); + const packages = new Map< + string, + { name: string; source: string; version: string; targets: Set } + >(); 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(), }; @@ -398,6 +407,15 @@ function collectPackageInventory( function collectPlatformSources(workflowText: string) { const platforms = new Map(); + 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; @@ -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]}`, ); diff --git a/test/helpers/publishable-plugin-fixture.ts b/test/helpers/publishable-plugin-fixture.ts index 947185ffd190..4bde7e7fc7a3 100644 --- a/test/helpers/publishable-plugin-fixture.ts +++ b/test/helpers/publishable-plugin-fixture.ts @@ -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"; diff --git a/test/plugin-clawhub-release.test.ts b/test/plugin-clawhub-release.test.ts index 29ae90b00323..539346233f07 100644 --- a/test/plugin-clawhub-release.test.ts +++ b/test/plugin-clawhub-release.test.ts @@ -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) => { diff --git a/test/plugin-npm-release.test.ts b/test/plugin-npm-release.test.ts index 3a12162e6d9d..2fa2fdade000 100644 --- a/test/plugin-npm-release.test.ts +++ b/test/plugin-npm-release.test.ts @@ -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[] = [ { diff --git a/test/scripts/plugin-npm-extended-stable-workflow.test.ts b/test/scripts/plugin-npm-extended-stable-workflow.test.ts index 1c679ae0a5c6..776eba6669f7 100644 --- a/test/scripts/plugin-npm-extended-stable-workflow.test.ts +++ b/test/scripts/plugin-npm-extended-stable-workflow.test.ts @@ -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", diff --git a/test/scripts/release-plan-producer.test.ts b/test/scripts/release-plan-producer.test.ts index 223fa5547e63..434e475d28e3 100644 --- a/test/scripts/release-plan-producer.test.ts +++ b/test/scripts/release-plan-producer.test.ts @@ -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"], {