fix(release): harden package candidate checks

This commit is contained in:
Peter Steinberger
2026-04-28 16:34:06 +01:00
parent 3afc597287
commit 3a3859b484
5 changed files with 140 additions and 7 deletions

View File

@@ -55,6 +55,8 @@ const DIST_IMPORT_REFERENCE_ENTRYPOINTS = [
const LEGACY_PACKAGE_ACCEPTANCE_COMPAT_MAX = { year: 2026, month: 4, day: 25 };
const LEGACY_LOCAL_BUILD_METADATA_COMPAT_MAX = { year: 2026, month: 4, day: 26 };
const FORBIDDEN_LOCAL_BUILD_METADATA_FILES = new Set(LOCAL_BUILD_METADATA_DIST_PATHS);
const REQUIRED_PACKAGE_ENTRIES = ["dist/control-ui/index.html", "dist/postinstall-inventory.json"];
const REQUIRED_PACKAGE_PREFIXES = ["dist/control-ui/assets/"];
const LEGACY_OMITTED_PRIVATE_QA_INVENTORY_PREFIXES = [
"dist/extensions/qa-channel/",
@@ -223,8 +225,15 @@ for (const forbiddenEntry of FORBIDDEN_LOCAL_BUILD_METADATA_FILES) {
errors.push(`forbidden local build metadata tar entry ${forbiddenEntry}`);
}
}
if (!entrySet.has("dist/postinstall-inventory.json")) {
errors.push("missing dist/postinstall-inventory.json");
for (const requiredEntry of REQUIRED_PACKAGE_ENTRIES) {
if (!entrySet.has(requiredEntry)) {
errors.push(`missing required package tar entry ${requiredEntry}`);
}
}
for (const requiredPrefix of REQUIRED_PACKAGE_PREFIXES) {
if (!normalized.some((entry) => entry.startsWith(requiredPrefix))) {
errors.push(`missing required package tar entries under ${requiredPrefix}`);
}
}
if (entrySet.has("dist/postinstall-inventory.json")) {
try {

View File

@@ -10,6 +10,8 @@ import {
existsSync,
mkdirSync,
readFileSync,
readlinkSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
@@ -1301,8 +1303,62 @@ export function resolveInstalledPrefixDirFromCliPath(cliPath, platform = process
return dirname(dirname(resolvedCliPath));
}
function readInstalledMetadataFromCliPath(cliPath, platform = process.platform) {
return readInstalledMetadata(resolveInstalledPrefixDirFromCliPath(cliPath, platform));
export function resolveInstalledPackageRootFromCliPath(cliPath, platform = process.platform) {
const resolvedCliPath =
platform === "win32" ? normalizeWindowsInstalledCliPath(cliPath) : String(cliPath ?? "");
const packageRoots: string[] = [];
const addPackageRoot = (candidate?: string) => {
if (candidate && !packageRoots.includes(candidate)) {
packageRoots.push(candidate);
}
};
addPackageRoot(
installedPackageRoot(resolveInstalledPrefixDirFromCliPath(resolvedCliPath, platform)),
);
if (platform !== "win32") {
try {
const realpath = realpathSync(resolvedCliPath);
const marker = "/node_modules/openclaw/";
const markerIndex = realpath.indexOf(marker);
if (markerIndex >= 0) {
addPackageRoot(realpath.slice(0, markerIndex + marker.length - 1));
}
} catch {}
try {
const linkTarget = readlinkSync(resolvedCliPath);
const absoluteTarget = linkTarget.startsWith("/")
? linkTarget
: join(dirname(resolvedCliPath), linkTarget);
const marker = "/node_modules/openclaw/";
const markerIndex = absoluteTarget.indexOf(marker);
if (markerIndex >= 0) {
addPackageRoot(absoluteTarget.slice(0, markerIndex + marker.length - 1));
}
} catch {}
try {
const shim = readFileSync(resolvedCliPath, "utf8");
const match = /(?<root>\/[^ "'\n\r]+\/node_modules\/openclaw)(?:\/|["'\s]|$)/u.exec(shim);
addPackageRoot(match?.groups?.root);
} catch {}
if (resolvedCliPath.endsWith("/.local/bin/openclaw")) {
const homeDir = dirname(dirname(dirname(resolvedCliPath)));
addPackageRoot(join(homeDir, ".npm-global", "lib", "node_modules", "openclaw"));
}
}
const found = packageRoots.find((packageRoot) => existsSync(join(packageRoot, "package.json")));
return found ?? packageRoots[0];
}
export function readInstalledMetadataFromCliPath(cliPath, platform = process.platform) {
return readInstalledMetadataFromPackageRoot(
resolveInstalledPackageRootFromCliPath(cliPath, platform),
);
}
function resolveInstalledCliInvocation(cliPath, platform = process.platform) {
@@ -2780,7 +2836,10 @@ async function runOpenClaw(params) {
}
function readInstalledPackageManifest(prefixDir) {
const packageRoot = installedPackageRoot(prefixDir);
return readInstalledPackageManifestFromPackageRoot(installedPackageRoot(prefixDir));
}
function readInstalledPackageManifestFromPackageRoot(packageRoot) {
const packageJsonPath = join(packageRoot, "package.json");
if (!existsSync(packageJsonPath)) {
throw new Error(`Installed package manifest missing: ${packageJsonPath}`);
@@ -2797,7 +2856,11 @@ export function readInstalledVersion(prefixDir) {
}
function readInstalledMetadata(prefixDir) {
const { packageJson, packageRoot } = readInstalledPackageManifest(prefixDir);
return readInstalledMetadataFromPackageRoot(installedPackageRoot(prefixDir));
}
function readInstalledMetadataFromPackageRoot(packageRoot) {
const { packageJson } = readInstalledPackageManifestFromPackageRoot(packageRoot);
const buildInfoPath = join(packageRoot, "dist", "build-info.json");
if (!existsSync(buildInfoPath)) {
throw new Error(`Installed build info missing: ${buildInfoPath}`);

View File

@@ -115,6 +115,8 @@ async function main() {
if (!options.skipBuild) {
console.error("==> Building OpenClaw package artifacts");
await run("pnpm", ["build"], sourceDir);
console.error("==> Building OpenClaw Control UI assets");
await run("pnpm", ["ui:build"], sourceDir);
}
console.error("==> Writing OpenClaw package inventory");

View File

@@ -12,6 +12,7 @@ function withTarball(
files: Record<string, string>,
testBody: (tarball: string) => void,
version = "0.0.0",
includeDefaultUi = true,
) {
const root = mkdtempSync(join(tmpdir(), "openclaw-package-tarball-test-"));
try {
@@ -22,7 +23,16 @@ function withTarball(
join(packageRoot, "dist", "postinstall-inventory.json"),
JSON.stringify(inventory),
);
for (const [relativePath, body] of Object.entries(files)) {
const packageFiles = {
...(includeDefaultUi
? {
"dist/control-ui/assets/index.js": "console.log('openclaw');\n",
"dist/control-ui/index.html": '<!doctype html><div id="root"></div>\n',
}
: {}),
...files,
};
for (const [relativePath, body] of Object.entries(packageFiles)) {
const filePath = join(packageRoot, relativePath);
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, body);
@@ -159,4 +169,24 @@ describe("check-openclaw-package-tarball", () => {
"2026.4.26",
);
});
it("rejects tarballs missing Control UI assets", () => {
withTarball(
["dist/index.js"],
{ "dist/index.js": "export {};\n" },
(tarball) => {
const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(
"missing required package tar entry dist/control-ui/index.html",
);
expect(result.stderr).toContain(
"missing required package tar entries under dist/control-ui/assets/",
);
},
"2026.4.27",
false,
);
});
});

View File

@@ -28,10 +28,12 @@ import {
normalizeWindowsInstalledCliPath,
parseArgs,
packageHasScript,
readInstalledMetadataFromCliPath,
readInstalledVersion,
readRunnerOverrideEnv,
resolveExplicitBaselineVersion,
resolveDevUpdateVerificationRef,
resolveInstalledPackageRootFromCliPath,
resolveInstalledPrefixDirFromCliPath,
resolvePublishedInstallerUrl,
resolveRequestedSuites,
@@ -398,6 +400,33 @@ describe("scripts/openclaw-cross-os-release-checks", () => {
).toBe("/Users/runner/.npm-global");
});
it("resolves npm package metadata behind installer-local POSIX shims", () => {
const dir = mkdtempSync(join(tmpdir(), "openclaw-cross-os-installer-shim-"));
try {
const shimPath = join(dir, ".local", "bin", "openclaw");
const packageRoot = join(dir, ".npm-global", "lib", "node_modules", "openclaw");
mkdirSync(join(packageRoot, "dist"), { recursive: true });
mkdirSync(join(dir, ".local", "bin"), { recursive: true });
writeFileSync(
join(packageRoot, "package.json"),
JSON.stringify({ version: "2026.4.27-beta.1" }),
);
writeFileSync(
join(packageRoot, "dist", "build-info.json"),
JSON.stringify({ commit: "abc123" }),
);
writeFileSync(shimPath, `#!/bin/sh\nexec node "${packageRoot}/dist/entry.js" "$@"\n`);
expect(resolveInstalledPackageRootFromCliPath(shimPath, "linux")).toBe(packageRoot);
expect(readInstalledMetadataFromCliPath(shimPath, "linux")).toEqual({
commit: "abc123",
version: "2026.4.27-beta.1",
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("detects whether a managed gateway listener is still reachable on loopback", async () => {
const server = createNetServer();
await new Promise((resolvePromise) => {