mirror of https://github.com/openclaw/openclaw.git
Release: fix npm release preflight under pnpm (#52985)
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
parent
f9a7427e8e
commit
36d6ba55e3
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue