From 6330fe607d03177776167bc982bb42e0d93dbdae Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 15 May 2026 12:42:56 +0100 Subject: [PATCH] fix(release): verify npm tarball before publish --- .github/workflows/openclaw-npm-release.yml | 14 +++ scripts/openclaw-npm-prepublish-verify.ts | 107 +++++++++++++++++++++ scripts/release-check.ts | 70 ++++++++++++++ test/release-check.test.ts | 27 ++++++ 4 files changed, 218 insertions(+) create mode 100644 scripts/openclaw-npm-prepublish-verify.ts diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index e363130a308..0e23f53db10 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -287,6 +287,20 @@ jobs: NODE echo "dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT" + - name: Verify prepared npm tarball install + env: + PREFLIGHT_ARTIFACT_DIR: ${{ steps.packed_tarball.outputs.dir }} + run: | + set -euo pipefail + TARBALL_PATH="$(find "$PREFLIGHT_ARTIFACT_DIR" -maxdepth 1 -type f -name '*.tgz' -print | sort | tail -n 1)" + if [[ -z "$TARBALL_PATH" ]]; then + echo "Prepared preflight tarball not found." >&2 + ls -la "$PREFLIGHT_ARTIFACT_DIR" >&2 || true + exit 1 + fi + PACKAGE_VERSION="$(node -p "require('./package.json').version")" + node --import tsx scripts/openclaw-npm-prepublish-verify.ts "$TARBALL_PATH" "$PACKAGE_VERSION" + - name: Upload dependency release evidence uses: actions/upload-artifact@v7 with: diff --git a/scripts/openclaw-npm-prepublish-verify.ts b/scripts/openclaw-npm-prepublish-verify.ts new file mode 100644 index 00000000000..ac14be1f92d --- /dev/null +++ b/scripts/openclaw-npm-prepublish-verify.ts @@ -0,0 +1,107 @@ +#!/usr/bin/env -S node --import tsx + +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { formatErrorMessage } from "../src/infra/errors.ts"; +import { runInstalledWorkspaceBootstrapSmoke } from "./lib/workspace-bootstrap-smoke.mjs"; +import { + collectInstalledPackageErrors, + normalizeInstalledBinaryVersion, + resolveInstalledBinaryPath, +} from "./openclaw-npm-postpublish-verify.ts"; +import { resolveNpmCommandInvocation } from "./openclaw-npm-release-check.ts"; + +type InstalledPackageJson = { + version?: string; +}; + +function npmExec(args: string[], cwd: string): string { + const invocation = resolveNpmCommandInvocation({ + npmExecPath: process.env.npm_execpath, + nodeExecPath: process.execPath, + platform: process.platform, + }); + + return execFileSync(invocation.command, [...invocation.args, ...args], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function main(): void { + const tarballPath = process.argv[2]?.trim(); + const expectedVersion = process.argv[3]?.trim(); + if (!tarballPath) { + throw new Error( + "Usage: node --import tsx scripts/openclaw-npm-prepublish-verify.ts [expected-version]", + ); + } + + const workingDir = mkdtempSync(join(tmpdir(), "openclaw-prepublish-")); + const prefixDir = join(workingDir, "prefix"); + try { + npmExec( + [ + "install", + "-g", + "--prefix", + prefixDir, + realpathSync(tarballPath), + "--no-fund", + "--no-audit", + ], + workingDir, + ); + const globalRoot = npmExec(["root", "-g", "--prefix", prefixDir], workingDir); + const packageRoot = join(globalRoot, "openclaw"); + const pkg = JSON.parse( + readFileSync(join(packageRoot, "package.json"), "utf8"), + ) as InstalledPackageJson; + const resolvedExpectedVersion = expectedVersion || pkg.version?.trim() || ""; + const errors = collectInstalledPackageErrors({ + expectedVersion: resolvedExpectedVersion, + installedVersion: pkg.version?.trim() ?? "", + packageRoot, + }); + const installedBinaryVersion = execFileSync( + resolveInstalledBinaryPath(prefixDir), + ["--version"], + { + cwd: workingDir, + encoding: "utf8", + shell: process.platform === "win32", + stdio: ["ignore", "pipe", "pipe"], + }, + ).trim(); + if (normalizeInstalledBinaryVersion(installedBinaryVersion) !== resolvedExpectedVersion) { + errors.push( + `installed openclaw binary version mismatch: expected ${resolvedExpectedVersion}, found ${installedBinaryVersion || ""}.`, + ); + } + if (errors.length === 0) { + runInstalledWorkspaceBootstrapSmoke({ packageRoot }); + } + if (errors.length > 0) { + throw new Error(`prepared tarball install failed:\n- ${errors.join("\n- ")}`); + } + console.log( + `openclaw-npm-prepublish-verify: prepared tarball install OK (${resolvedExpectedVersion}).`, + ); + } finally { + rmSync(workingDir, { force: true, recursive: true }); + } +} + +const entrypoint = process.argv[1] ? pathToFileURL(process.argv[1]).href : null; +if (entrypoint !== null && import.meta.url === entrypoint) { + try { + main(); + } catch (error) { + console.error(`openclaw-npm-prepublish-verify: ${formatErrorMessage(error)}`); + process.exitCode = 1; + } +} diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 37211162cec..694504b6d06 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -38,6 +38,10 @@ import { runInstalledWorkspaceBootstrapSmoke, WORKSPACE_TEMPLATE_PACK_PATHS, } from "./lib/workspace-bootstrap-smoke.mjs"; +import { + collectInstalledPackageErrors, + normalizeInstalledBinaryVersion, +} from "./openclaw-npm-postpublish-verify.ts"; import { listStaticExtensionAssetOutputs } from "./runtime-postbuild.mjs"; import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts"; import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs"; @@ -330,6 +334,58 @@ function runPackedBundledPluginPostinstall(packageRoot: string): void { }); } +export function collectPackedInstalledPackageVerificationErrors(params: { + expectedVersion: string; + installedBinaryVersion?: string; + packageRoot: string; +}): string[] { + const packageJson = JSON.parse( + readFileSync(join(params.packageRoot, "package.json"), "utf8"), + ) as { version?: string }; + const errors = collectInstalledPackageErrors({ + expectedVersion: params.expectedVersion, + installedVersion: packageJson.version?.trim() ?? "", + packageRoot: params.packageRoot, + }); + if ( + params.installedBinaryVersion !== undefined && + normalizeInstalledBinaryVersion(params.installedBinaryVersion) !== params.expectedVersion + ) { + errors.push( + `installed openclaw binary version mismatch: expected ${params.expectedVersion}, found ${params.installedBinaryVersion || ""}.`, + ); + } + return errors; +} + +function verifyPackedInstalledPackage(params: { + expectedVersion: string; + packageRoot: string; + prefixDir: string; + tmpRoot: string; +}): void { + const installedBinaryVersion = execFileSync( + resolveInstalledBinaryPath(params.prefixDir), + ["--version"], + { + cwd: params.tmpRoot, + encoding: "utf8", + shell: process.platform === "win32", + stdio: ["ignore", "pipe", "pipe"], + }, + ).trim(); + const errors = collectPackedInstalledPackageVerificationErrors({ + expectedVersion: params.expectedVersion, + installedBinaryVersion, + packageRoot: params.packageRoot, + }); + if (errors.length > 0) { + throw new Error( + `release-check: packed installed package verification failed:\n- ${errors.join("\n- ")}`, + ); + } +} + export function writePackedBundledPluginActivationConfig(homeDir: string): void { const configPath = join(homeDir, ".openclaw", "openclaw.json"); mkdirSync(join(homeDir, ".openclaw"), { recursive: true }); @@ -464,6 +520,14 @@ function runPackedCliSmoke(params: { function runPackedBundledChannelEntrySmoke(): void { const tmpRoot = mkdtempSync(join(tmpdir(), "openclaw-release-pack-smoke-")); try { + const expectedVersion = ( + JSON.parse(readFileSync(resolve("package.json"), "utf8")) as { + version?: string; + } + ).version; + if (!expectedVersion) { + throw new Error("release-check: root package.json is missing version."); + } const packDir = join(tmpRoot, "pack"); mkdirSync(packDir); @@ -473,6 +537,12 @@ function runPackedBundledChannelEntrySmoke(): void { installPackedTarball(prefixDir, tarballPath, tmpRoot); const packageRoot = join(resolveGlobalRoot(prefixDir, tmpRoot), "openclaw"); + verifyPackedInstalledPackage({ + expectedVersion, + packageRoot, + prefixDir, + tmpRoot, + }); const homeDir = join(tmpRoot, "home"); const stateDir = join(tmpRoot, "state"); mkdirSync(homeDir, { recursive: true }); diff --git a/test/release-check.test.ts b/test/release-check.test.ts index cf454e71a95..25888d88391 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -18,6 +18,7 @@ import { collectForbiddenPackPaths, collectMissingPackPaths, collectPackUnpackedSizeErrors, + collectPackedInstalledPackageVerificationErrors, createPackedCompletionSmokeEnv, createPackedCliSmokeEnv, createPackedBundledPluginPostinstallEnv, @@ -533,6 +534,32 @@ describe("collectMissingPackPaths", () => { ).toStrictEqual([]); }); + it("runs postpublish package integrity checks against the packed install before publish", () => { + const root = mkdtempSync(join(tmpdir(), "release-check-packed-install-")); + try { + const packageRoot = join(root, "openclaw"); + const distDir = join(packageRoot, "dist"); + mkdirSync(distDir, { recursive: true }); + writeFileSync( + join(packageRoot, "package.json"), + `${JSON.stringify({ name: "openclaw", version: "2026.5.14-beta.3", dependencies: {} })}\n`, + ); + writeFileSync(join(distDir, "typescript-compiler.js"), "x".repeat(6 * 1024 * 1024 + 1)); + + expect( + collectPackedInstalledPackageVerificationErrors({ + expectedVersion: "2026.5.14-beta.3", + installedBinaryVersion: "openclaw 2026.5.14-beta.3", + packageRoot, + }), + ).toEqual([ + "installed package root dist file 'typescript-compiler.js' is invalid or exceeds 6291456 bytes.", + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it("requires bundled plugin runtime sidecars that dynamic plugin boundaries resolve at runtime", () => { expect(requiredBundledPluginPackPaths).not.toContain( bundledDistPluginFile("slack", "runtime-api.js"),