openclaw/scripts/lib/plugin-publication-collector.ts
Peter Steinberger 14cc3a4da8
chore(release): retire the internal Tideclaw alpha release track (#160261)
* chore(release): retire the Tideclaw alpha release track

Tideclaw alpha/nightly publication is retired. Alpha remains readable as
history (existing v*-alpha tags, published versions, changelog and
upgrade-survivor baselines, product version ordering), but it can no longer
authorize a release.

Every active release boundary now rejects an alpha version or -alpha.N tag,
the alpha npm dist-tag, and tideclaw/alpha/* workflow or tooling routes:
preparation, Full Release Validation publication selection, npm preflight,
approval receipts, core/plugin npm and ClawHub publication, native handoffs,
and finalization. Channel mappings throw for alpha instead of falling through
to latest. The Tideclaw branch routes, alpha dist-tag options, the alpha FRV
publication route, and the alpha-only Docker runtime-assets job are removed.
Beta, stable, extended-stable, and correction releases are unchanged.

The release-openclaw-nightly skill is deleted and the release skills and docs
no longer describe the alpha track.

* test(release): drop retired alpha preparation and finalization expectations

* test(release): drop remaining retired alpha references
2026-09-28 10:01:49 -07:00

362 lines
12 KiB
TypeScript

import { normalizeOptionalString } from "../../packages/normalization-core/src/string-coerce.js";
import { validateExternalCodePluginPackageJson } from "../../packages/plugin-package-contract/src/index.ts";
import { resolveNpmPublishPlan } from "./npm-publish-plan.mjs";
import { isPluginPublicationEnabled } from "./plugin-publication-target.mjs";
import { parseReleaseVersion } from "./release-version.mjs";
export type PluginPackageJson = {
name?: string;
version?: string;
type?: string;
private?: boolean;
dependencies?: Record<string, unknown>;
optionalDependencies?: Record<string, unknown>;
repository?:
| string
| {
type?: string;
url?: string;
};
openclaw?: {
extensions?: string[];
install?: {
defaultChoice?: string;
minHostVersion?: string;
npmSpec?: string;
};
compat?: {
pluginApi?: string;
minGatewayVersion?: string;
};
build?: {
bundledDist?: boolean;
openclawVersion?: string;
pluginSdkVersion?: string;
};
release?: {
publishToClawHub?: boolean;
publishToNpm?: boolean;
requireLatestDependencies?: unknown;
};
};
};
export type PublishablePluginPackageCandidate<
TPackageJson extends PluginPackageJson = PluginPackageJson,
> = {
extensionId: string;
packageDir: string;
packageJson: TPackageJson;
readmeText?: string;
};
type RequiredLatestDependency = {
packageName: string;
version: string;
};
export type PublishablePluginPackage = {
extensionId: string;
packageDir: string;
packageName: string;
version: string;
channel: "stable" | "beta";
publishTag: "latest" | "beta" | "extended-stable";
installNpmSpec?: string;
requiredLatestDependencies?: RequiredLatestDependency[];
};
export type PublishablePluginPackageFilters = {
extensionIds?: readonly string[];
packageNames?: readonly string[];
npmDistTag?: "extended-stable";
rootVersion?: string;
};
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._-]*$/;
function collectRequiredLatestDependencies(packageJson: PluginPackageJson): {
dependencies: RequiredLatestDependency[];
errors: string[];
} {
const configured = packageJson.openclaw?.release?.requireLatestDependencies;
if (configured === undefined) {
return { dependencies: [], errors: [] };
}
if (!Array.isArray(configured)) {
return {
dependencies: [],
errors: ["openclaw.release.requireLatestDependencies must be an array of package names."],
};
}
const runtimeDependencies = {
...packageJson.dependencies,
...packageJson.optionalDependencies,
};
const dependencies: RequiredLatestDependency[] = [];
const errors: string[] = [];
const seen = new Set<string>();
for (const value of configured) {
if (typeof value !== "string" || !value.trim()) {
errors.push(
"openclaw.release.requireLatestDependencies must contain only non-empty package names.",
);
continue;
}
const packageName = value.trim();
if (seen.has(packageName)) {
errors.push(
`openclaw.release.requireLatestDependencies must not contain duplicate package names; found "${packageName}".`,
);
continue;
}
seen.add(packageName);
const version = runtimeDependencies[packageName];
if (typeof version !== "string" || !version.trim()) {
errors.push(
`openclaw.release.requireLatestDependencies must reference package.json dependencies or optionalDependencies; "${packageName}" is not a runtime dependency.`,
);
continue;
}
dependencies.push({ packageName, version: version.trim() });
}
return { dependencies, errors };
}
function resolvePublishablePluginVersion(params: {
extensionId: string;
packageJson: Pick<PluginPackageJson, "version">;
validationErrors: string[];
}): { version: string; parsedVersion: NonNullable<ReturnType<typeof parseReleaseVersion>> } | null {
const version = params.packageJson.version?.trim() ?? "";
const parsedVersion = parseReleaseVersion(version);
if (parsedVersion === null) {
params.validationErrors.push(
`${params.extensionId}: package.json version must match YYYY.M.PATCH, YYYY.M.PATCH-N, or YYYY.M.PATCH-beta.N; found "${version}".`,
);
return null;
}
if (parsedVersion.channel === "alpha") {
throw new Error("Alpha releases are retired; use a beta prerelease instead.");
}
return { version, parsedVersion };
}
export function collectPublishablePluginPackageErrors(
candidate: PublishablePluginPackageCandidate,
): string[] {
const { packageJson } = candidate;
const errors: string[] = [];
const packageName = packageJson.name?.trim() ?? "";
const packageVersion = packageJson.version?.trim() ?? "";
const installNpmSpec = normalizeOptionalString(packageJson.openclaw?.install?.npmSpec);
const repositoryUrl =
typeof packageJson.repository === "string"
? packageJson.repository.trim()
: (packageJson.repository?.url?.trim() ?? "");
const extensions = packageJson.openclaw?.extensions ?? [];
const requiredLatestDependencies = collectRequiredLatestDependencies(packageJson);
if (!packageName.startsWith("@openclaw/")) {
errors.push(
`package name must start with "@openclaw/"; found "${packageName || "<missing>"}".`,
);
}
if (packageJson.private === true) {
errors.push("package.json private must not be true.");
}
if (packageJson.type !== "module") {
errors.push('package.json type must be "module" so built .js runtime entries load as ESM.');
}
if (!candidate.readmeText?.trim()) {
errors.push("README.md must exist and contain package documentation.");
}
if (repositoryUrl !== OPENCLAW_PLUGIN_NPM_REPOSITORY_URL) {
errors.push(
`package.json repository.url must be "${OPENCLAW_PLUGIN_NPM_REPOSITORY_URL}" so npm provenance can validate GitHub trusted publishing; found "${repositoryUrl || "<missing>"}".`,
);
}
if (!packageVersion) {
errors.push("package.json version must be non-empty.");
} else if (parseReleaseVersion(packageVersion) === null) {
errors.push(
`package.json version must match YYYY.M.PATCH, YYYY.M.PATCH-N, or YYYY.M.PATCH-beta.N; found "${packageVersion}".`,
);
}
if (parseReleaseVersion(packageVersion)?.channel === "alpha") {
errors.push("Alpha releases are retired; use a beta prerelease instead.");
}
if (!Array.isArray(extensions) || extensions.length === 0) {
errors.push("openclaw.extensions must contain at least one entry.");
}
if (extensions.some((entry) => typeof entry !== "string" || !entry.trim())) {
errors.push("openclaw.extensions must contain only non-empty strings.");
}
if (!installNpmSpec) {
errors.push("openclaw.install.npmSpec must be a non-empty string for publishable plugins.");
}
errors.push(...requiredLatestDependencies.errors);
errors.push(
...validateExternalCodePluginPackageJson(packageJson).issues.map((issue) => issue.message),
);
return errors;
}
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",
filters: PublishablePluginPackageFilters = {},
): PublishablePluginPackage[] {
if (filters.rootVersion?.includes("-alpha.")) {
throw new Error("Alpha releases are retired; use a beta prerelease instead.");
}
const publishable: PublishablePluginPackage[] = [];
const validationErrors: string[] = [];
const selectedExtensionIds = new Set(filters.extensionIds ?? []);
const selectedPackageNames = new Set(filters.packageNames ?? []);
const hasSelectedExtensionIds = Array.isArray(filters.extensionIds);
const hasSelectedPackageNames = Array.isArray(filters.packageNames);
validationErrors.push(
...collectConflictingPluginPackageSourceErrors(
candidates
.filter(
(candidate) =>
isPluginPublicationEnabled(candidate.packageJson, "npm") ||
isPluginPublicationEnabled(candidate.packageJson, "clawhub"),
)
.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)) {
continue;
}
const packageName = packageJson.name?.trim() ?? "";
if (hasSelectedPackageNames && !selectedPackageNames.has(packageName)) {
continue;
}
if (!isPluginPublicationEnabled(packageJson, target)) {
continue;
}
if (target === "clawhub" && !SAFE_CLAWHUB_EXTENSION_ID.test(extensionId)) {
validationErrors.push(
`${extensionId}: extension directory name must match ^[a-z0-9][a-z0-9._-]*$ for ClawHub publish.`,
);
continue;
}
const errors = collectPublishablePluginPackageErrors(candidate);
if (errors.length > 0) {
validationErrors.push(...errors.map((error) => `${extensionId}: ${error}`));
continue;
}
const resolvedVersion = resolvePublishablePluginVersion({
extensionId,
packageJson,
validationErrors,
});
if (!resolvedVersion) {
continue;
}
const { version, parsedVersion } = resolvedVersion;
const requiredLatestDependencies = collectRequiredLatestDependencies(packageJson).dependencies;
const publishTag =
target === "npm"
? resolveNpmPublishPlan(version, undefined, filters.npmDistTag).publishTag
: parsedVersion.channel === "beta"
? "beta"
: "latest";
publishable.push({
extensionId,
packageDir,
packageName,
version,
channel: parsedVersion.channel === "beta" ? "beta" : "stable",
publishTag,
...(target === "npm"
? { installNpmSpec: normalizeOptionalString(packageJson.openclaw?.install?.npmSpec) }
: {}),
...(requiredLatestDependencies.length > 0 ? { requiredLatestDependencies } : {}),
});
}
if (target === "npm" && filters.npmDistTag === "extended-stable") {
const rootVersion = filters.rootVersion?.trim() ?? "";
for (const plugin of publishable) {
if (plugin.version !== rootVersion) {
validationErrors.push(
`${plugin.extensionId}: package version ${plugin.version} must match root package version ${rootVersion || "<missing>"} for extended-stable publication.`,
);
}
}
}
if (validationErrors.length > 0) {
const label =
target === "clawhub"
? "Publishable ClawHub plugin metadata validation failed"
: "Publishable plugin metadata validation failed";
throw new Error(`${label}:\n${validationErrors.map((error) => `- ${error}`).join("\n")}`);
}
return publishable.toSorted((left, right) => left.packageName.localeCompare(right.packageName));
}