Release: fix npm release preflight under pnpm (#52985)

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
TheRipper 2026-03-24 00:51:09 +07:00 committed by GitHub
parent f9a7427e8e
commit 36d6ba55e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 126 additions and 20 deletions

View File

@ -2,6 +2,7 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { basename } from "node:path";
import { pathToFileURL } from "node:url";
type PackageJson = {
@ -40,7 +41,6 @@ const CORRECTION_TAG_REGEX = /^(?<base>\d{4}\.[1-9]\d?\.[1-9]\d?)-(?<correction>
const EXPECTED_REPOSITORY_URL = "https://github.com/openclaw/openclaw";
const MAX_CALVER_DISTANCE_DAYS = 2;
const REQUIRED_PACKED_PATHS = ["dist/control-ui/index.html"];
const NPM_COMMAND = process.platform === "win32" ? "npm.cmd" : "npm";
function normalizeRepoUrl(value: unknown): string {
if (typeof value !== "string") {
@ -288,15 +288,31 @@ function loadPackageJson(): PackageJson {
return JSON.parse(readFileSync("package.json", "utf8")) as PackageJson;
}
function runNpmCommand(args: string[]): string {
const npmExecPath = process.env.npm_execpath;
if (typeof npmExecPath === "string" && npmExecPath.length > 0) {
return execFileSync(process.execPath, [npmExecPath, ...args], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
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 execFileSync(NPM_COMMAND, args, {
return { command: npmCommand, args: [] };
}
function runNpmCommand(args: string[]): string {
const invocation = resolveNpmCommandInvocation();
return execFileSync(invocation.command, [...invocation.args, ...args], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
@ -312,16 +328,16 @@ type NpmPackResult = {
};
type ExecFailure = Error & {
stderr?: string | Buffer;
stdout?: string | Buffer;
stderr?: string | Uint8Array;
stdout?: string | Uint8Array;
};
function toTrimmedUtf8(value: string | Buffer | undefined): string {
function toTrimmedUtf8(value: string | Uint8Array | undefined): string {
if (typeof value === "string") {
return value.trim();
}
if (Buffer.isBuffer(value)) {
return value.toString("utf8").trim();
if (value instanceof Uint8Array) {
return new TextDecoder().decode(value).trim();
}
return "";
}
@ -343,6 +359,32 @@ function describeExecFailure(error: unknown): string {
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;
}
function collectPackedTarballErrors(): string[] {
const errors: string[] = [];
let stdout = "";
@ -356,15 +398,11 @@ function collectPackedTarballErrors(): string[] {
return errors;
}
let parsed: unknown;
try {
parsed = JSON.parse(stdout);
} catch {
const packResults = parseNpmPackJsonOutput(stdout);
if (!packResults) {
errors.push("Failed to parse JSON output from `npm pack --json --dry-run`.");
return errors;
}
const packResults = Array.isArray(parsed) ? (parsed as NpmPackResult[]) : [];
const firstResult = packResults[0];
if (!firstResult || !Array.isArray(firstResult.files)) {
errors.push("`npm pack --json --dry-run` did not return a files list to validate.");

View File

@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
import {
collectReleasePackageMetadataErrors,
collectReleaseTagErrors,
parseNpmPackJsonOutput,
parseReleaseTagVersion,
parseReleaseVersion,
resolveNpmCommandInvocation,
utcCalendarDayDistance,
} from "../scripts/openclaw-npm-release-check.ts";
@ -62,6 +64,72 @@ describe("utcCalendarDayDistance", () => {
});
});
describe("resolveNpmCommandInvocation", () => {
it("uses npm_execpath when it points to npm", () => {
expect(
resolveNpmCommandInvocation({
npmExecPath: "/usr/local/lib/node_modules/npm/bin/npm-cli.js",
nodeExecPath: "/usr/local/bin/node",
platform: "linux",
}),
).toEqual({
command: "/usr/local/bin/node",
args: ["/usr/local/lib/node_modules/npm/bin/npm-cli.js"],
});
});
it("falls back to the npm command when npm_execpath points to pnpm", () => {
expect(
resolveNpmCommandInvocation({
npmExecPath: "/home/test/.cache/node/corepack/v1/pnpm/10.23.0/bin/pnpm.cjs",
nodeExecPath: "/usr/local/bin/node",
platform: "linux",
}),
).toEqual({
command: "npm",
args: [],
});
});
it("uses the platform npm command when npm_execpath is missing", () => {
expect(resolveNpmCommandInvocation({ platform: "win32" })).toEqual({
command: "npm.cmd",
args: [],
});
});
});
describe("parseNpmPackJsonOutput", () => {
it("parses a plain npm pack JSON array", () => {
expect(parseNpmPackJsonOutput('[{"filename":"openclaw.tgz","files":[]}]')).toEqual([
{ filename: "openclaw.tgz", files: [] },
]);
});
it("parses the trailing JSON payload after npm lifecycle logs", () => {
const stdout = [
'npm warn Unknown project config "node-linker".',
"",
"> openclaw@2026.3.23 prepack",
"> pnpm build && pnpm ui:build",
"",
"[copy-hook-metadata] Copied 4 hook metadata files.",
'[{"filename":"openclaw.tgz","files":[{"path":"dist/control-ui/index.html"}]}]',
].join("\n");
expect(parseNpmPackJsonOutput(stdout)).toEqual([
{
filename: "openclaw.tgz",
files: [{ path: "dist/control-ui/index.html" }],
},
]);
});
it("returns null when no JSON payload is present", () => {
expect(parseNpmPackJsonOutput("> openclaw@2026.3.23 prepack")).toBeNull();
});
});
describe("collectReleaseTagErrors", () => {
it("accepts versions within the two-day CalVer window", () => {
expect(