mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:40:43 +00:00
Reduce WebUI/Gateway latency churn by avoiding redundant session reloads, carrying session keys through transcript update events, and deferring explicit media provider discovery. Includes changelog attribution and closes the referenced runtime latency issues.
683 lines
21 KiB
JavaScript
683 lines
21 KiB
JavaScript
#!/usr/bin/env -S node --import tsx
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
import { basename, join } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import {
|
|
LOCAL_BUILD_METADATA_DIST_PATHS,
|
|
PACKAGE_DIST_INVENTORY_RELATIVE_PATH,
|
|
writePackageDistInventory,
|
|
} from "../src/infra/package-dist-inventory.ts";
|
|
import {
|
|
compareReleaseVersions as compareReleaseVersionsBase,
|
|
resolveNpmDistTagMirrorAuth as resolveNpmDistTagMirrorAuthBase,
|
|
parseReleaseVersion as parseReleaseVersionBase,
|
|
} from "./lib/npm-publish-plan.mjs";
|
|
import { WORKSPACE_TEMPLATE_PACK_PATHS } from "./lib/workspace-bootstrap-smoke.mjs";
|
|
|
|
type PackageJson = {
|
|
name?: string;
|
|
version?: string;
|
|
description?: string;
|
|
license?: string;
|
|
repository?: { url?: string } | string;
|
|
bin?: Record<string, string>;
|
|
dependencies?: Record<string, string>;
|
|
optionalDependencies?: Record<string, string>;
|
|
peerDependencies?: Record<string, string>;
|
|
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
|
|
};
|
|
|
|
export type ParsedReleaseVersion = {
|
|
version: string;
|
|
baseVersion: string;
|
|
channel: "stable" | "alpha" | "beta";
|
|
year: number;
|
|
month: number;
|
|
day: number;
|
|
alphaNumber?: number;
|
|
betaNumber?: number;
|
|
correctionNumber?: number;
|
|
date: Date;
|
|
};
|
|
|
|
export type ParsedReleaseTag = {
|
|
version: string;
|
|
packageVersion: string;
|
|
baseVersion: string;
|
|
channel: "stable" | "alpha" | "beta";
|
|
correctionNumber?: number;
|
|
date: Date;
|
|
};
|
|
|
|
export type NpmPublishPlan = {
|
|
channel: "stable" | "alpha" | "beta";
|
|
publishTag: "latest" | "alpha" | "beta";
|
|
mirrorDistTags: ("latest" | "alpha" | "beta")[];
|
|
};
|
|
|
|
export type NpmDistTagMirrorAuth = {
|
|
hasAuth: boolean;
|
|
source: "node-auth-token" | "npm-token" | "none";
|
|
};
|
|
const EXPECTED_REPOSITORY_URL = "https://github.com/openclaw/openclaw";
|
|
const OPTIONAL_LOCAL_EMBEDDING_RUNTIME_PACKAGE = "node-llama-cpp";
|
|
const MAX_CALVER_DISTANCE_DAYS = 2;
|
|
const REQUIRED_PACKED_PATHS = [
|
|
PACKAGE_DIST_INVENTORY_RELATIVE_PATH,
|
|
"dist/control-ui/index.html",
|
|
...WORKSPACE_TEMPLATE_PACK_PATHS,
|
|
];
|
|
const CONTROL_UI_ASSET_PREFIX = "dist/control-ui/assets/";
|
|
const FORBIDDEN_PACKED_PATH_RULES = [
|
|
...LOCAL_BUILD_METADATA_DIST_PATHS.map((prefix) => ({
|
|
prefix,
|
|
describe: (packedPath: string) =>
|
|
`npm package must not include local build metadata "${packedPath}".`,
|
|
})),
|
|
{
|
|
prefix: "docs/.generated/",
|
|
describe: (packedPath: string) =>
|
|
`npm package must not include generated docs artifact "${packedPath}".`,
|
|
},
|
|
{
|
|
prefix: "docs/channels/qa-channel.md",
|
|
describe: (packedPath: string) =>
|
|
`npm package must not include private QA channel docs "${packedPath}".`,
|
|
},
|
|
{
|
|
prefix: "dist/extensions/qa-channel/",
|
|
describe: (packedPath: string) =>
|
|
`npm package must not include private QA channel artifact "${packedPath}".`,
|
|
},
|
|
{
|
|
prefix: "dist/extensions/qa-lab/",
|
|
describe: (packedPath: string) =>
|
|
`npm package must not include private QA lab artifact "${packedPath}".`,
|
|
},
|
|
{
|
|
prefix: "dist/plugin-sdk/extensions/qa-channel/",
|
|
describe: (packedPath: string) =>
|
|
`npm package must not include private QA channel type artifact "${packedPath}".`,
|
|
},
|
|
{
|
|
prefix: "dist/plugin-sdk/extensions/qa-lab/",
|
|
describe: (packedPath: string) =>
|
|
`npm package must not include private QA lab type artifact "${packedPath}".`,
|
|
},
|
|
{
|
|
prefix: "dist/plugin-sdk/qa-channel.",
|
|
describe: (packedPath: string) =>
|
|
`npm package must not include private QA channel SDK artifact "${packedPath}".`,
|
|
},
|
|
{
|
|
prefix: "dist/plugin-sdk/qa-channel-protocol.",
|
|
describe: (packedPath: string) =>
|
|
`npm package must not include private QA channel SDK artifact "${packedPath}".`,
|
|
},
|
|
{
|
|
prefix: "dist/qa-runtime-",
|
|
describe: (packedPath: string) =>
|
|
`npm package must not include private QA runtime chunk "${packedPath}".`,
|
|
},
|
|
{
|
|
prefix: "qa/",
|
|
describe: (packedPath: string) =>
|
|
`npm package must not include private QA suite artifact "${packedPath}".`,
|
|
},
|
|
] as const;
|
|
const FORBIDDEN_PRIVATE_QA_CONTENT_MARKERS = [
|
|
"//#region extensions/qa-lab/",
|
|
"qa-channel/runtime-api.js",
|
|
"qa-channel.js",
|
|
"qa-channel-protocol.js",
|
|
"qa-lab/cli.js",
|
|
"qa-lab/runtime-api.js",
|
|
] as const;
|
|
const FORBIDDEN_PRIVATE_QA_CONTENT_SCAN_PREFIXES = ["dist/"] as const;
|
|
const PACKED_TEST_CARGO_DIRECTORY_SEGMENTS = new Set([
|
|
"__snapshots__",
|
|
"__tests__",
|
|
"test",
|
|
"tests",
|
|
]);
|
|
const PACKED_TEST_CARGO_FILE_RE = /(?:^|\/)[^/]+\.(?:test|spec)\.(?:[cm]?[jt]sx?)$/u;
|
|
const NPM_PACK_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
const skipPackValidationEnv = "OPENCLAW_NPM_RELEASE_SKIP_PACK_CHECK";
|
|
|
|
function normalizePackedPath(packedPath: string): string {
|
|
return packedPath.replace(/\\/g, "/");
|
|
}
|
|
function isNodeModulesPackageRoot(segments: string[], index: number): boolean {
|
|
const parent = segments[index - 1];
|
|
if (parent === "node_modules") {
|
|
return true;
|
|
}
|
|
return parent?.startsWith("@") && segments[index - 2] === "node_modules";
|
|
}
|
|
|
|
function pathContainsPackedTestCargo(packedPath: string): boolean {
|
|
const normalizedPath = normalizePackedPath(packedPath);
|
|
if (PACKED_TEST_CARGO_FILE_RE.test(normalizedPath)) {
|
|
return true;
|
|
}
|
|
const segments = normalizedPath.split("/").filter(Boolean);
|
|
return segments.some(
|
|
(segment, index) =>
|
|
index < segments.length - 1 &&
|
|
PACKED_TEST_CARGO_DIRECTORY_SEGMENTS.has(segment) &&
|
|
!isNodeModulesPackageRoot(segments, index),
|
|
);
|
|
}
|
|
|
|
function normalizeRepoUrl(value: unknown): string {
|
|
if (typeof value !== "string") {
|
|
return "";
|
|
}
|
|
|
|
return value
|
|
.trim()
|
|
.replace(/^git\+/, "")
|
|
.replace(/\.git$/i, "")
|
|
.replace(/\/+$/, "");
|
|
}
|
|
|
|
export function parseReleaseVersion(version: string): ParsedReleaseVersion | null {
|
|
return parseReleaseVersionBase(version) as ParsedReleaseVersion | null;
|
|
}
|
|
|
|
export function compareReleaseVersions(left: string, right: string): number | null {
|
|
return compareReleaseVersionsBase(left, right);
|
|
}
|
|
|
|
export function resolveNpmPublishPlan(
|
|
version: string,
|
|
_currentBetaVersion?: string | null,
|
|
requestedPublishTag?: "latest" | "alpha" | "beta" | null,
|
|
): NpmPublishPlan {
|
|
const parsedVersion = parseReleaseVersion(version);
|
|
if (parsedVersion === null) {
|
|
throw new Error(`Unsupported release version "${version}".`);
|
|
}
|
|
|
|
const publishTag =
|
|
requestedPublishTag?.trim() === "latest"
|
|
? "latest"
|
|
: requestedPublishTag?.trim() === "alpha"
|
|
? "alpha"
|
|
: "beta";
|
|
|
|
if (parsedVersion.channel === "alpha") {
|
|
if (publishTag !== "alpha") {
|
|
throw new Error("Alpha prereleases must publish to the alpha dist-tag.");
|
|
}
|
|
return {
|
|
channel: "alpha",
|
|
publishTag: "alpha",
|
|
mirrorDistTags: [],
|
|
};
|
|
}
|
|
|
|
if (parsedVersion.channel === "beta") {
|
|
if (publishTag !== "beta") {
|
|
throw new Error("Beta prereleases must publish to the beta dist-tag.");
|
|
}
|
|
return {
|
|
channel: "beta",
|
|
publishTag: "beta",
|
|
mirrorDistTags: [],
|
|
};
|
|
}
|
|
|
|
return {
|
|
channel: "stable",
|
|
publishTag,
|
|
mirrorDistTags: [],
|
|
};
|
|
}
|
|
|
|
export function resolveNpmDistTagMirrorAuth(params?: {
|
|
nodeAuthToken?: string | null;
|
|
npmToken?: string | null;
|
|
}): NpmDistTagMirrorAuth {
|
|
const nodeAuthToken =
|
|
params && "nodeAuthToken" in params ? params.nodeAuthToken : process.env.NODE_AUTH_TOKEN;
|
|
const npmToken = params && "npmToken" in params ? params.npmToken : process.env.NPM_TOKEN;
|
|
return resolveNpmDistTagMirrorAuthBase({
|
|
nodeAuthToken,
|
|
npmToken,
|
|
}) as NpmDistTagMirrorAuth;
|
|
}
|
|
|
|
export function shouldSkipPackedTarballValidation(env = process.env): boolean {
|
|
const raw = env[skipPackValidationEnv];
|
|
if (!raw) {
|
|
return false;
|
|
}
|
|
return !/^(0|false)$/i.test(raw);
|
|
}
|
|
|
|
export function parseReleaseTagVersion(version: string): ParsedReleaseTag | null {
|
|
const trimmed = version.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const parsedVersion = parseReleaseVersion(trimmed);
|
|
if (parsedVersion !== null) {
|
|
return {
|
|
version: trimmed,
|
|
packageVersion: parsedVersion.version,
|
|
baseVersion: parsedVersion.baseVersion,
|
|
channel: parsedVersion.channel,
|
|
date: parsedVersion.date,
|
|
correctionNumber: parsedVersion.correctionNumber,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function startOfUtcDay(date: Date): number {
|
|
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
|
}
|
|
|
|
export function utcCalendarDayDistance(left: Date, right: Date): number {
|
|
return Math.round(Math.abs(startOfUtcDay(left) - startOfUtcDay(right)) / 86_400_000);
|
|
}
|
|
|
|
export function collectReleasePackageMetadataErrors(pkg: PackageJson): string[] {
|
|
const actualRepositoryUrl = normalizeRepoUrl(
|
|
typeof pkg.repository === "string" ? pkg.repository : pkg.repository?.url,
|
|
);
|
|
const errors: string[] = [];
|
|
|
|
if (pkg.name !== "openclaw") {
|
|
errors.push(`package.json name must be "openclaw"; found "${pkg.name ?? ""}".`);
|
|
}
|
|
if (!pkg.description?.trim()) {
|
|
errors.push("package.json description must be non-empty.");
|
|
}
|
|
if (pkg.license !== "MIT") {
|
|
errors.push(`package.json license must be "MIT"; found "${pkg.license ?? ""}".`);
|
|
}
|
|
if (actualRepositoryUrl !== EXPECTED_REPOSITORY_URL) {
|
|
errors.push(
|
|
`package.json repository.url must resolve to ${EXPECTED_REPOSITORY_URL}; found ${
|
|
actualRepositoryUrl || "<missing>"
|
|
}.`,
|
|
);
|
|
}
|
|
if (pkg.bin?.openclaw !== "openclaw.mjs") {
|
|
errors.push(
|
|
`package.json bin.openclaw must be "openclaw.mjs"; found "${pkg.bin?.openclaw ?? ""}".`,
|
|
);
|
|
}
|
|
if (pkg.dependencies?.[OPTIONAL_LOCAL_EMBEDDING_RUNTIME_PACKAGE]) {
|
|
errors.push(
|
|
`package.json dependencies["${OPTIONAL_LOCAL_EMBEDDING_RUNTIME_PACKAGE}"] must be omitted; keep it optional.`,
|
|
);
|
|
}
|
|
if (pkg.optionalDependencies?.[OPTIONAL_LOCAL_EMBEDDING_RUNTIME_PACKAGE]) {
|
|
errors.push(
|
|
`package.json optionalDependencies["${OPTIONAL_LOCAL_EMBEDDING_RUNTIME_PACKAGE}"] must be omitted; keep it operator-installed.`,
|
|
);
|
|
}
|
|
if (pkg.peerDependencies?.[OPTIONAL_LOCAL_EMBEDDING_RUNTIME_PACKAGE]) {
|
|
errors.push(
|
|
`package.json peerDependencies["${OPTIONAL_LOCAL_EMBEDDING_RUNTIME_PACKAGE}"] must be omitted; keep it optional.`,
|
|
);
|
|
}
|
|
if (pkg.peerDependenciesMeta?.[OPTIONAL_LOCAL_EMBEDDING_RUNTIME_PACKAGE]) {
|
|
errors.push(
|
|
`package.json peerDependenciesMeta["${OPTIONAL_LOCAL_EMBEDDING_RUNTIME_PACKAGE}"] must be omitted; keep it optional.`,
|
|
);
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
export function collectReleaseTagErrors(params: {
|
|
packageVersion: string;
|
|
releaseTag: string;
|
|
releaseSha?: string;
|
|
releaseMainRef?: string;
|
|
now?: Date;
|
|
}): string[] {
|
|
const errors: string[] = [];
|
|
const releaseTag = params.releaseTag.trim();
|
|
const packageVersion = params.packageVersion.trim();
|
|
const now = params.now ?? new Date();
|
|
|
|
const parsedVersion = parseReleaseVersion(packageVersion);
|
|
if (parsedVersion === null) {
|
|
errors.push(
|
|
`package.json version must match YYYY.M.D, YYYY.M.D-N, YYYY.M.D-alpha.N, or YYYY.M.D-beta.N; found "${packageVersion || "<missing>"}".`,
|
|
);
|
|
}
|
|
|
|
if (!releaseTag.startsWith("v")) {
|
|
errors.push(`Release tag must start with "v"; found "${releaseTag || "<missing>"}".`);
|
|
}
|
|
|
|
const tagVersion = releaseTag.startsWith("v") ? releaseTag.slice(1) : releaseTag;
|
|
const parsedTag = parseReleaseTagVersion(tagVersion);
|
|
if (parsedTag === null) {
|
|
errors.push(
|
|
`Release tag must match vYYYY.M.D, vYYYY.M.D-alpha.N, vYYYY.M.D-beta.N, or fallback correction tag vYYYY.M.D-N; found "${releaseTag || "<missing>"}".`,
|
|
);
|
|
}
|
|
|
|
const expectedTag = packageVersion ? `v${packageVersion}` : "<missing>";
|
|
const matchesExpectedTag =
|
|
parsedTag !== null &&
|
|
parsedVersion !== null &&
|
|
parsedTag.channel === parsedVersion.channel &&
|
|
(parsedTag.packageVersion === parsedVersion.version ||
|
|
(parsedVersion.channel === "stable" &&
|
|
parsedVersion.correctionNumber === undefined &&
|
|
parsedTag.correctionNumber !== undefined &&
|
|
parsedTag.baseVersion === parsedVersion.baseVersion));
|
|
if (!matchesExpectedTag) {
|
|
errors.push(
|
|
`Release tag ${releaseTag || "<missing>"} does not match package.json version ${
|
|
packageVersion || "<missing>"
|
|
}; expected ${
|
|
parsedVersion?.channel === "stable" && parsedVersion.correctionNumber === undefined
|
|
? `${expectedTag} or ${expectedTag}-N`
|
|
: expectedTag
|
|
}.`,
|
|
);
|
|
}
|
|
|
|
if (parsedVersion !== null) {
|
|
const dayDistance = utcCalendarDayDistance(parsedVersion.date, now);
|
|
if (dayDistance > MAX_CALVER_DISTANCE_DAYS) {
|
|
const nowLabel = now.toISOString().slice(0, 10);
|
|
const versionDate = parsedVersion.date.toISOString().slice(0, 10);
|
|
errors.push(
|
|
`Release version ${packageVersion} is ${dayDistance} days away from current UTC date ${nowLabel}; release CalVer date ${versionDate} must be within ${MAX_CALVER_DISTANCE_DAYS} days.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (params.releaseSha?.trim() && params.releaseMainRef?.trim()) {
|
|
try {
|
|
execFileSync(
|
|
"git",
|
|
["merge-base", "--is-ancestor", params.releaseSha, params.releaseMainRef],
|
|
{ stdio: "ignore" },
|
|
);
|
|
} catch {
|
|
errors.push(
|
|
`Tagged commit ${params.releaseSha} is not contained in ${params.releaseMainRef}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function loadPackageJson(): PackageJson {
|
|
return JSON.parse(readFileSync("package.json", "utf8")) as PackageJson;
|
|
}
|
|
|
|
function isNpmExecPath(value: string): boolean {
|
|
return /^npm(?:-cli)?(?:\.(?:c?js|cmd|exe))?$/.test(basename(value).toLowerCase());
|
|
}
|
|
|
|
export function resolveNpmCommandInvocation(
|
|
params: {
|
|
npmExecPath?: string;
|
|
nodeExecPath?: string;
|
|
platform?: NodeJS.Platform;
|
|
} = {},
|
|
): { command: string; args: string[] } {
|
|
const npmExecPath = params.npmExecPath ?? process.env.npm_execpath;
|
|
const nodeExecPath = params.nodeExecPath ?? process.execPath;
|
|
const npmCommand = (params.platform ?? process.platform) === "win32" ? "npm.cmd" : "npm";
|
|
|
|
if (typeof npmExecPath === "string" && npmExecPath.length > 0 && isNpmExecPath(npmExecPath)) {
|
|
return { command: nodeExecPath, args: [npmExecPath] };
|
|
}
|
|
|
|
return { command: npmCommand, args: [] };
|
|
}
|
|
|
|
function runNpmCommand(args: string[]): string {
|
|
const invocation = resolveNpmCommandInvocation();
|
|
return execFileSync(invocation.command, [...invocation.args, ...args], {
|
|
encoding: "utf8",
|
|
maxBuffer: NPM_PACK_MAX_BUFFER_BYTES,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
}
|
|
|
|
type NpmPackFileEntry = {
|
|
path?: string;
|
|
};
|
|
|
|
type NpmPackResult = {
|
|
filename?: string;
|
|
files?: NpmPackFileEntry[];
|
|
};
|
|
|
|
type ExecFailure = Error & {
|
|
stderr?: string | Uint8Array;
|
|
stdout?: string | Uint8Array;
|
|
};
|
|
|
|
function toTrimmedUtf8(value: string | Uint8Array | undefined): string {
|
|
if (typeof value === "string") {
|
|
return value.trim();
|
|
}
|
|
if (value instanceof Uint8Array) {
|
|
return new TextDecoder().decode(value).trim();
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function describeExecFailure(error: unknown): string {
|
|
if (!(error instanceof Error)) {
|
|
return String(error);
|
|
}
|
|
const withStreams = error as ExecFailure;
|
|
const details: string[] = [error.message];
|
|
const stderr = toTrimmedUtf8(withStreams.stderr);
|
|
const stdout = toTrimmedUtf8(withStreams.stdout);
|
|
if (stderr) {
|
|
details.push(`stderr: ${stderr}`);
|
|
}
|
|
if (stdout) {
|
|
details.push(`stdout: ${stdout}`);
|
|
}
|
|
return details.join(" | ");
|
|
}
|
|
|
|
export function parseNpmPackJsonOutput(stdout: string): NpmPackResult[] | null {
|
|
const trimmed = stdout.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const candidates = [trimmed];
|
|
const trailingArrayStart = trimmed.lastIndexOf("\n[");
|
|
if (trailingArrayStart !== -1) {
|
|
candidates.push(trimmed.slice(trailingArrayStart + 1).trim());
|
|
}
|
|
|
|
for (const candidate of candidates) {
|
|
try {
|
|
const parsed = JSON.parse(candidate) as unknown;
|
|
if (Array.isArray(parsed)) {
|
|
return parsed as NpmPackResult[];
|
|
}
|
|
} catch {
|
|
// Try the next candidate. npm lifecycle output can prepend non-JSON logs.
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function collectControlUiPackErrors(paths: Iterable<string>): string[] {
|
|
const packedPaths = new Set(paths);
|
|
const assetPaths = [...packedPaths].filter((path) => path.startsWith(CONTROL_UI_ASSET_PREFIX));
|
|
const errors: string[] = [];
|
|
|
|
for (const requiredPath of REQUIRED_PACKED_PATHS) {
|
|
if (!packedPaths.has(requiredPath)) {
|
|
errors.push(
|
|
`npm package is missing required path "${requiredPath}". Ensure UI assets are built and included before publish.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (assetPaths.length === 0) {
|
|
errors.push(
|
|
`npm package is missing Control UI asset payload under "${CONTROL_UI_ASSET_PREFIX}". Refuse release when the dashboard tarball would be empty.`,
|
|
);
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function collectPackedTarballErrors(): string[] {
|
|
const errors: string[] = [];
|
|
let stdout = "";
|
|
try {
|
|
stdout = runNpmCommand(["pack", "--json", "--dry-run", "--ignore-scripts"]);
|
|
} catch (error) {
|
|
const message = describeExecFailure(error);
|
|
errors.push(
|
|
`Failed to inspect npm tarball contents via \`npm pack --json --dry-run --ignore-scripts\`: ${message}`,
|
|
);
|
|
return errors;
|
|
}
|
|
|
|
const packResults = parseNpmPackJsonOutput(stdout);
|
|
if (!packResults) {
|
|
errors.push("Failed to parse JSON output from `npm pack --json --dry-run --ignore-scripts`.");
|
|
return errors;
|
|
}
|
|
const firstResult = packResults[0];
|
|
if (!firstResult || !Array.isArray(firstResult.files)) {
|
|
errors.push(
|
|
"`npm pack --json --dry-run --ignore-scripts` did not return a files list to validate.",
|
|
);
|
|
return errors;
|
|
}
|
|
|
|
const packedPaths = new Set(
|
|
firstResult.files
|
|
.map((entry) => entry.path)
|
|
.filter((path): path is string => typeof path === "string" && path.length > 0),
|
|
);
|
|
|
|
return [
|
|
...collectControlUiPackErrors(packedPaths),
|
|
...collectForbiddenPackedPathErrors(packedPaths),
|
|
...collectForbiddenPackedContentErrors(packedPaths),
|
|
...collectPackedTestCargoErrors(packedPaths),
|
|
];
|
|
}
|
|
|
|
export function collectForbiddenPackedPathErrors(paths: Iterable<string>): string[] {
|
|
const errors: string[] = [];
|
|
for (const packedPath of paths) {
|
|
const matchedRule = FORBIDDEN_PACKED_PATH_RULES.find((rule) =>
|
|
packedPath.startsWith(rule.prefix),
|
|
);
|
|
if (!matchedRule) {
|
|
continue;
|
|
}
|
|
errors.push(matchedRule.describe(packedPath));
|
|
}
|
|
return errors.toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
export function collectForbiddenPackedContentErrors(
|
|
paths: Iterable<string>,
|
|
rootDir = process.cwd(),
|
|
): string[] {
|
|
const textPathPattern = /\.(?:[cm]?js|d\.ts|json|md|mjs|cjs)$/u;
|
|
const errors: string[] = [];
|
|
for (const packedPath of paths) {
|
|
if (
|
|
!FORBIDDEN_PRIVATE_QA_CONTENT_SCAN_PREFIXES.some((prefix) => packedPath.startsWith(prefix))
|
|
) {
|
|
continue;
|
|
}
|
|
if (!textPathPattern.test(packedPath)) {
|
|
continue;
|
|
}
|
|
let content: string;
|
|
try {
|
|
content = readFileSync(pathToFileURL(join(rootDir, packedPath)), "utf8");
|
|
} catch {
|
|
continue;
|
|
}
|
|
const matchedMarker = FORBIDDEN_PRIVATE_QA_CONTENT_MARKERS.find((marker) =>
|
|
content.includes(marker),
|
|
);
|
|
if (!matchedMarker) {
|
|
continue;
|
|
}
|
|
errors.push(
|
|
`npm package must not include private QA lab marker "${matchedMarker}" in "${packedPath}".`,
|
|
);
|
|
}
|
|
return errors.toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
export function collectPackedTestCargoErrors(paths: Iterable<string>): string[] {
|
|
const errors: string[] = [];
|
|
for (const packedPath of paths) {
|
|
if (!pathContainsPackedTestCargo(packedPath)) {
|
|
continue;
|
|
}
|
|
errors.push(`npm package must not include test cargo "${packedPath}".`);
|
|
}
|
|
return errors.toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
async function main(): Promise<number> {
|
|
const pkg = loadPackageJson();
|
|
const now = new Date();
|
|
const skipPackValidation = shouldSkipPackedTarballValidation();
|
|
const metadataErrors = collectReleasePackageMetadataErrors(pkg);
|
|
const tagErrors = collectReleaseTagErrors({
|
|
packageVersion: pkg.version ?? "",
|
|
releaseTag: process.env.RELEASE_TAG ?? "",
|
|
releaseSha: process.env.RELEASE_SHA,
|
|
releaseMainRef: process.env.RELEASE_MAIN_REF,
|
|
now,
|
|
});
|
|
if (!skipPackValidation) {
|
|
await writePackageDistInventory(process.cwd());
|
|
}
|
|
const tarballErrors = skipPackValidation ? [] : collectPackedTarballErrors();
|
|
const errors = [...metadataErrors, ...tagErrors, ...tarballErrors];
|
|
|
|
if (errors.length > 0) {
|
|
for (const error of errors) {
|
|
console.error(`openclaw-npm-release-check: ${error}`);
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
const parsedVersion = parseReleaseVersion(pkg.version ?? "");
|
|
const channel = parsedVersion?.channel ?? "unknown";
|
|
const dayDistance =
|
|
parsedVersion === null ? "unknown" : String(utcCalendarDayDistance(parsedVersion.date, now));
|
|
console.log(
|
|
`openclaw-npm-release-check: validated ${channel} release ${pkg.version} (${dayDistance} day UTC delta${skipPackValidation ? "; metadata-only" : ""}).`,
|
|
);
|
|
return 0;
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
process.exit(await main());
|
|
}
|