Skip to content

Commit 6330fe6

Browse files
committed
fix(release): verify npm tarball before publish
1 parent e79e5db commit 6330fe6

4 files changed

Lines changed: 218 additions & 0 deletions

File tree

.github/workflows/openclaw-npm-release.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,20 @@ jobs:
287287
NODE
288288
echo "dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT"
289289
290+
- name: Verify prepared npm tarball install
291+
env:
292+
PREFLIGHT_ARTIFACT_DIR: ${{ steps.packed_tarball.outputs.dir }}
293+
run: |
294+
set -euo pipefail
295+
TARBALL_PATH="$(find "$PREFLIGHT_ARTIFACT_DIR" -maxdepth 1 -type f -name '*.tgz' -print | sort | tail -n 1)"
296+
if [[ -z "$TARBALL_PATH" ]]; then
297+
echo "Prepared preflight tarball not found." >&2
298+
ls -la "$PREFLIGHT_ARTIFACT_DIR" >&2 || true
299+
exit 1
300+
fi
301+
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
302+
node --import tsx scripts/openclaw-npm-prepublish-verify.ts "$TARBALL_PATH" "$PACKAGE_VERSION"
303+
290304
- name: Upload dependency release evidence
291305
uses: actions/upload-artifact@v7
292306
with:
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
#!/usr/bin/env -S node --import tsx
2+
3+
import { execFileSync } from "node:child_process";
4+
import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
5+
import { tmpdir } from "node:os";
6+
import { join } from "node:path";
7+
import { pathToFileURL } from "node:url";
8+
import { formatErrorMessage } from "../src/infra/errors.ts";
9+
import { runInstalledWorkspaceBootstrapSmoke } from "./lib/workspace-bootstrap-smoke.mjs";
10+
import {
11+
collectInstalledPackageErrors,
12+
normalizeInstalledBinaryVersion,
13+
resolveInstalledBinaryPath,
14+
} from "./openclaw-npm-postpublish-verify.ts";
15+
import { resolveNpmCommandInvocation } from "./openclaw-npm-release-check.ts";
16+
17+
type InstalledPackageJson = {
18+
version?: string;
19+
};
20+
21+
function npmExec(args: string[], cwd: string): string {
22+
const invocation = resolveNpmCommandInvocation({
23+
npmExecPath: process.env.npm_execpath,
24+
nodeExecPath: process.execPath,
25+
platform: process.platform,
26+
});
27+
28+
return execFileSync(invocation.command, [...invocation.args, ...args], {
29+
cwd,
30+
encoding: "utf8",
31+
stdio: ["ignore", "pipe", "pipe"],
32+
}).trim();
33+
}
34+
35+
function main(): void {
36+
const tarballPath = process.argv[2]?.trim();
37+
const expectedVersion = process.argv[3]?.trim();
38+
if (!tarballPath) {
39+
throw new Error(
40+
"Usage: node --import tsx scripts/openclaw-npm-prepublish-verify.ts <tarball.tgz> [expected-version]",
41+
);
42+
}
43+
44+
const workingDir = mkdtempSync(join(tmpdir(), "openclaw-prepublish-"));
45+
const prefixDir = join(workingDir, "prefix");
46+
try {
47+
npmExec(
48+
[
49+
"install",
50+
"-g",
51+
"--prefix",
52+
prefixDir,
53+
realpathSync(tarballPath),
54+
"--no-fund",
55+
"--no-audit",
56+
],
57+
workingDir,
58+
);
59+
const globalRoot = npmExec(["root", "-g", "--prefix", prefixDir], workingDir);
60+
const packageRoot = join(globalRoot, "openclaw");
61+
const pkg = JSON.parse(
62+
readFileSync(join(packageRoot, "package.json"), "utf8"),
63+
) as InstalledPackageJson;
64+
const resolvedExpectedVersion = expectedVersion || pkg.version?.trim() || "";
65+
const errors = collectInstalledPackageErrors({
66+
expectedVersion: resolvedExpectedVersion,
67+
installedVersion: pkg.version?.trim() ?? "",
68+
packageRoot,
69+
});
70+
const installedBinaryVersion = execFileSync(
71+
resolveInstalledBinaryPath(prefixDir),
72+
["--version"],
73+
{
74+
cwd: workingDir,
75+
encoding: "utf8",
76+
shell: process.platform === "win32",
77+
stdio: ["ignore", "pipe", "pipe"],
78+
},
79+
).trim();
80+
if (normalizeInstalledBinaryVersion(installedBinaryVersion) !== resolvedExpectedVersion) {
81+
errors.push(
82+
`installed openclaw binary version mismatch: expected ${resolvedExpectedVersion}, found ${installedBinaryVersion || "<missing>"}.`,
83+
);
84+
}
85+
if (errors.length === 0) {
86+
runInstalledWorkspaceBootstrapSmoke({ packageRoot });
87+
}
88+
if (errors.length > 0) {
89+
throw new Error(`prepared tarball install failed:\n- ${errors.join("\n- ")}`);
90+
}
91+
console.log(
92+
`openclaw-npm-prepublish-verify: prepared tarball install OK (${resolvedExpectedVersion}).`,
93+
);
94+
} finally {
95+
rmSync(workingDir, { force: true, recursive: true });
96+
}
97+
}
98+
99+
const entrypoint = process.argv[1] ? pathToFileURL(process.argv[1]).href : null;
100+
if (entrypoint !== null && import.meta.url === entrypoint) {
101+
try {
102+
main();
103+
} catch (error) {
104+
console.error(`openclaw-npm-prepublish-verify: ${formatErrorMessage(error)}`);
105+
process.exitCode = 1;
106+
}
107+
}

scripts/release-check.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ import {
3838
runInstalledWorkspaceBootstrapSmoke,
3939
WORKSPACE_TEMPLATE_PACK_PATHS,
4040
} from "./lib/workspace-bootstrap-smoke.mjs";
41+
import {
42+
collectInstalledPackageErrors,
43+
normalizeInstalledBinaryVersion,
44+
} from "./openclaw-npm-postpublish-verify.ts";
4145
import { listStaticExtensionAssetOutputs } from "./runtime-postbuild.mjs";
4246
import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts";
4347
import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs";
@@ -330,6 +334,58 @@ function runPackedBundledPluginPostinstall(packageRoot: string): void {
330334
});
331335
}
332336

337+
export function collectPackedInstalledPackageVerificationErrors(params: {
338+
expectedVersion: string;
339+
installedBinaryVersion?: string;
340+
packageRoot: string;
341+
}): string[] {
342+
const packageJson = JSON.parse(
343+
readFileSync(join(params.packageRoot, "package.json"), "utf8"),
344+
) as { version?: string };
345+
const errors = collectInstalledPackageErrors({
346+
expectedVersion: params.expectedVersion,
347+
installedVersion: packageJson.version?.trim() ?? "",
348+
packageRoot: params.packageRoot,
349+
});
350+
if (
351+
params.installedBinaryVersion !== undefined &&
352+
normalizeInstalledBinaryVersion(params.installedBinaryVersion) !== params.expectedVersion
353+
) {
354+
errors.push(
355+
`installed openclaw binary version mismatch: expected ${params.expectedVersion}, found ${params.installedBinaryVersion || "<missing>"}.`,
356+
);
357+
}
358+
return errors;
359+
}
360+
361+
function verifyPackedInstalledPackage(params: {
362+
expectedVersion: string;
363+
packageRoot: string;
364+
prefixDir: string;
365+
tmpRoot: string;
366+
}): void {
367+
const installedBinaryVersion = execFileSync(
368+
resolveInstalledBinaryPath(params.prefixDir),
369+
["--version"],
370+
{
371+
cwd: params.tmpRoot,
372+
encoding: "utf8",
373+
shell: process.platform === "win32",
374+
stdio: ["ignore", "pipe", "pipe"],
375+
},
376+
).trim();
377+
const errors = collectPackedInstalledPackageVerificationErrors({
378+
expectedVersion: params.expectedVersion,
379+
installedBinaryVersion,
380+
packageRoot: params.packageRoot,
381+
});
382+
if (errors.length > 0) {
383+
throw new Error(
384+
`release-check: packed installed package verification failed:\n- ${errors.join("\n- ")}`,
385+
);
386+
}
387+
}
388+
333389
export function writePackedBundledPluginActivationConfig(homeDir: string): void {
334390
const configPath = join(homeDir, ".openclaw", "openclaw.json");
335391
mkdirSync(join(homeDir, ".openclaw"), { recursive: true });
@@ -464,6 +520,14 @@ function runPackedCliSmoke(params: {
464520
function runPackedBundledChannelEntrySmoke(): void {
465521
const tmpRoot = mkdtempSync(join(tmpdir(), "openclaw-release-pack-smoke-"));
466522
try {
523+
const expectedVersion = (
524+
JSON.parse(readFileSync(resolve("package.json"), "utf8")) as {
525+
version?: string;
526+
}
527+
).version;
528+
if (!expectedVersion) {
529+
throw new Error("release-check: root package.json is missing version.");
530+
}
467531
const packDir = join(tmpRoot, "pack");
468532
mkdirSync(packDir);
469533

@@ -473,6 +537,12 @@ function runPackedBundledChannelEntrySmoke(): void {
473537
installPackedTarball(prefixDir, tarballPath, tmpRoot);
474538

475539
const packageRoot = join(resolveGlobalRoot(prefixDir, tmpRoot), "openclaw");
540+
verifyPackedInstalledPackage({
541+
expectedVersion,
542+
packageRoot,
543+
prefixDir,
544+
tmpRoot,
545+
});
476546
const homeDir = join(tmpRoot, "home");
477547
const stateDir = join(tmpRoot, "state");
478548
mkdirSync(homeDir, { recursive: true });

test/release-check.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
collectForbiddenPackPaths,
1919
collectMissingPackPaths,
2020
collectPackUnpackedSizeErrors,
21+
collectPackedInstalledPackageVerificationErrors,
2122
createPackedCompletionSmokeEnv,
2223
createPackedCliSmokeEnv,
2324
createPackedBundledPluginPostinstallEnv,
@@ -533,6 +534,32 @@ describe("collectMissingPackPaths", () => {
533534
).toStrictEqual([]);
534535
});
535536

537+
it("runs postpublish package integrity checks against the packed install before publish", () => {
538+
const root = mkdtempSync(join(tmpdir(), "release-check-packed-install-"));
539+
try {
540+
const packageRoot = join(root, "openclaw");
541+
const distDir = join(packageRoot, "dist");
542+
mkdirSync(distDir, { recursive: true });
543+
writeFileSync(
544+
join(packageRoot, "package.json"),
545+
`${JSON.stringify({ name: "openclaw", version: "2026.5.14-beta.3", dependencies: {} })}\n`,
546+
);
547+
writeFileSync(join(distDir, "typescript-compiler.js"), "x".repeat(6 * 1024 * 1024 + 1));
548+
549+
expect(
550+
collectPackedInstalledPackageVerificationErrors({
551+
expectedVersion: "2026.5.14-beta.3",
552+
installedBinaryVersion: "openclaw 2026.5.14-beta.3",
553+
packageRoot,
554+
}),
555+
).toEqual([
556+
"installed package root dist file 'typescript-compiler.js' is invalid or exceeds 6291456 bytes.",
557+
]);
558+
} finally {
559+
rmSync(root, { recursive: true, force: true });
560+
}
561+
});
562+
536563
it("requires bundled plugin runtime sidecars that dynamic plugin boundaries resolve at runtime", () => {
537564
expect(requiredBundledPluginPackPaths).not.toContain(
538565
bundledDistPluginFile("slack", "runtime-api.js"),

0 commit comments

Comments
 (0)