fix(release): verify npm tarball before publish

This commit is contained in:
Peter Steinberger
2026-05-15 12:42:56 +01:00
parent e79e5dbbdf
commit 6330fe607d
4 changed files with 218 additions and 0 deletions

View File

@@ -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:

View File

@@ -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 <tarball.tgz> [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 || "<missing>"}.`,
);
}
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;
}
}

View File

@@ -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 || "<missing>"}.`,
);
}
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 });

View File

@@ -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"),