From 3a3859b484754b2c49eb8bce070d6bce8bc73205 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 16:34:06 +0100 Subject: [PATCH] fix(release): harden package candidate checks --- scripts/check-openclaw-package-tarball.mjs | 13 +++- scripts/openclaw-cross-os-release-checks.ts | 71 +++++++++++++++++-- scripts/package-openclaw-for-docker.mjs | 2 + .../check-openclaw-package-tarball.test.ts | 32 ++++++++- .../openclaw-cross-os-release-checks.test.ts | 29 ++++++++ 5 files changed, 140 insertions(+), 7 deletions(-) diff --git a/scripts/check-openclaw-package-tarball.mjs b/scripts/check-openclaw-package-tarball.mjs index bb1fc38e0f7..ae817fc2d0b 100644 --- a/scripts/check-openclaw-package-tarball.mjs +++ b/scripts/check-openclaw-package-tarball.mjs @@ -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 { diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts index f5d42a9b8d6..6fabc2b59e0 100644 --- a/scripts/openclaw-cross-os-release-checks.ts +++ b/scripts/openclaw-cross-os-release-checks.ts @@ -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 = /(?\/[^ "'\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}`); diff --git a/scripts/package-openclaw-for-docker.mjs b/scripts/package-openclaw-for-docker.mjs index 69226853f97..aad5a4cd7d4 100644 --- a/scripts/package-openclaw-for-docker.mjs +++ b/scripts/package-openclaw-for-docker.mjs @@ -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"); diff --git a/test/scripts/check-openclaw-package-tarball.test.ts b/test/scripts/check-openclaw-package-tarball.test.ts index 4aac37c70eb..ac017fb357a 100644 --- a/test/scripts/check-openclaw-package-tarball.test.ts +++ b/test/scripts/check-openclaw-package-tarball.test.ts @@ -12,6 +12,7 @@ function withTarball( files: Record, 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": '
\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, + ); + }); }); diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index b44442bc871..2a576e4afc3 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -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) => {