diff --git a/scripts/package-openclaw-for-docker.mjs b/scripts/package-openclaw-for-docker.mjs index 6cd6015bc26..471a83763f2 100644 --- a/scripts/package-openclaw-for-docker.mjs +++ b/scripts/package-openclaw-for-docker.mjs @@ -133,6 +133,7 @@ export function parseArgs(argv) { const options = { outputDir: "", outputName: "", + packJson: "", skipBuild: false, sourceDir: ROOT_DIR, }; @@ -164,6 +165,15 @@ export function parseArgs(argv) { "outputName", readEqualsOptionValue(arg.slice("--output-name=".length), "--output-name"), ); + } else if (arg === "--pack-json") { + setOnce("--pack-json", "packJson", readOptionValue(argv, index, arg)); + index += 1; + } else if (arg?.startsWith("--pack-json=")) { + setOnce( + "--pack-json", + "packJson", + readEqualsOptionValue(arg.slice("--pack-json=".length), "--pack-json"), + ); } else if (arg === "--skip-build") { setOnce(arg, "skipBuild", true); } else if (arg === "--source-dir") { @@ -373,6 +383,20 @@ async function runCapture(command, args, cwd, options = {}) { async function newestOpenClawTarball(outputDir, packOutput) { let fromOutput = ""; + try { + const parsed = JSON.parse(packOutput); + if (Array.isArray(parsed)) { + for (const entry of parsed) { + if (typeof entry?.filename !== "string") { + continue; + } + const filename = resolvePackedOpenClawFileName(entry.filename); + if (filename) { + fromOutput = filename; + } + } + } + } catch {} for (const line of packOutput.split(/\r?\n/u)) { const filename = resolvePackedOpenClawFileName(line); if (filename) { @@ -400,6 +424,30 @@ async function newestOpenClawTarball(outputDir, packOutput) { return path.join(outputDir, packed); } +async function writePackJson(packOutput, tarball, packJsonPath, sourceDir) { + if (!packJsonPath) { + return; + } + let parsed; + try { + parsed = JSON.parse(packOutput); + } catch (error) { + throw new Error("npm pack --json output was not valid JSON", { cause: error }); + } + if (!Array.isArray(parsed)) { + throw new Error("npm pack --json output must be an array"); + } + const filename = path.basename(tarball); + for (const entry of parsed) { + if (entry && typeof entry === "object" && typeof entry.filename === "string") { + entry.filename = filename; + } + } + const target = path.resolve(sourceDir, packJsonPath); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, `${JSON.stringify(parsed, null, 2)}\n`); +} + async function cleanPackedOpenClawTarballs(outputDir) { let entries; try { @@ -597,9 +645,17 @@ export async function packOpenClawPackageForDocker(sourceDir, outputDir, options try { await cleanPackedOpenClawTarballs(outputDir); cleanupBundledAiRuntime = await prepareBundledAiRuntime(sourceDir, outputDir, runCaptureImpl); + const packArgs = [ + "pack", + ...(options.packJsonPath ? ["--json"] : []), + "--silent", + "--ignore-scripts", + "--pack-destination", + outputDir, + ]; packOutput = await runCaptureImpl( "npm", - ["pack", "--silent", "--ignore-scripts", "--pack-destination", outputDir], + packArgs, sourceDir, { deferForwardedSignalExit: true, @@ -616,7 +672,17 @@ export async function packOpenClawPackageForDocker(sourceDir, outputDir, options await restoreChangelog(sourceDir); } } - return await newestOpenClawTarball(outputDir, packOutput); + let tarball = await newestOpenClawTarball(outputDir, packOutput); + if (options.outputName) { + const target = path.join(outputDir, options.outputName); + if (target !== tarball) { + await fs.rm(target, { force: true }); + await fs.rename(tarball, target); + tarball = target; + } + } + await writePackJson(packOutput, tarball, options.packJsonPath, sourceDir); + return tarball; } async function main() { @@ -651,16 +717,10 @@ async function main() { }, ); - let tarball = await packOpenClawPackageForDocker(sourceDir, outputDir); - - if (options.outputName) { - const target = path.join(outputDir, options.outputName); - if (target !== tarball) { - await fs.rm(target, { force: true }); - await fs.rename(tarball, target); - tarball = target; - } - } + const tarball = await packOpenClawPackageForDocker(sourceDir, outputDir, { + outputName: options.outputName, + packJsonPath: options.packJson, + }); console.error("==> Checking OpenClaw package tarball"); const checkStartedAt = Date.now(); diff --git a/scripts/test-install-sh-docker.sh b/scripts/test-install-sh-docker.sh index 1f9e6699620..e8b1d2d942c 100755 --- a/scripts/test-install-sh-docker.sh +++ b/scripts/test-install-sh-docker.sh @@ -299,6 +299,8 @@ prepare_update_tarball() { local baseline_pack_json local pack_json_file local baseline_pack_json_file + local package_args + local package_tgz local packed_update_version pack_json_file="${UPDATE_DIR}/pack.json" baseline_pack_json_file="${UPDATE_DIR}/baseline-pack.json" @@ -316,13 +318,20 @@ prepare_update_tarball() { UPDATE_EXPECT_VERSION="$( node -p 'JSON.parse(require("node:fs").readFileSync("package.json", "utf8")).version' )" - node --import tsx scripts/write-package-dist-inventory.ts - node scripts/check-package-dist-imports.mjs "$ROOT_DIR" - quiet_npm pack --ignore-scripts --json --pack-destination "$UPDATE_DIR" >"$pack_json_file" + package_args=( + --output-dir "$UPDATE_DIR" + --pack-json "$pack_json_file" + --skip-build + ) + package_tgz="$(node scripts/package-openclaw-for-docker.mjs "${package_args[@]}")" + UPDATE_TGZ_FILE="$(basename "$package_tgz")" fi - UPDATE_TGZ_FILE="$(read_pack_tarball_filename "$pack_json_file")" if [[ -z "$UPDATE_PACKAGE_SPEC" ]]; then - node scripts/check-openclaw-package-tarball.mjs "${UPDATE_DIR}/${UPDATE_TGZ_FILE}" + node scripts/check-openclaw-package-tarball.mjs \ + --require-bundled-workspace-deps \ + "${UPDATE_DIR}/${UPDATE_TGZ_FILE}" + else + UPDATE_TGZ_FILE="$(read_pack_tarball_filename "$pack_json_file")" fi print_pack_audit "update" "$pack_json_file" assert_pack_unpacked_size_budget "update" "$pack_json_file" diff --git a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts index 7cdbd59e4ae..b3d26f3b418 100644 --- a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts @@ -86,6 +86,8 @@ describe("package-openclaw-for-docker", () => { "--output-dir", ".artifacts/docker", "--output-name=openclaw-current.tgz", + "--pack-json", + ".artifacts/docker/pack.json", "--source-dir", "/repo", "--skip-build", @@ -93,6 +95,7 @@ describe("package-openclaw-for-docker", () => { ).toEqual({ outputDir: ".artifacts/docker", outputName: "openclaw-current.tgz", + packJson: ".artifacts/docker/pack.json", skipBuild: true, sourceDir: "/repo", }); @@ -112,6 +115,7 @@ describe("package-openclaw-for-docker", () => { const duplicateCases = [ ["--output-dir", ["--output-dir", "one", "--output-dir=two"]], ["--output-name", ["--output-name", "one.tgz", "--output-name=two.tgz"]], + ["--pack-json", ["--pack-json", "one.json", "--pack-json=two.json"]], ["--source-dir", ["--source-dir", "/repo-a", "--source-dir=/repo-b"]], ["--skip-build", ["--skip-build", "--skip-build"]], ] satisfies Array<[string, string[]]>; @@ -383,6 +387,61 @@ describe("package-openclaw-for-docker", () => { ]); }); + it("writes npm pack metadata for renamed package artifacts", async () => { + const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docker-pack-json-")); + const packJsonPath = path.join(outputDir, "pack.json"); + + try { + const tarball = await packOpenClawPackageForDocker("/repo", outputDir, { + outputName: "openclaw-current.tgz", + packJsonPath, + prepareBundledAiRuntime: skipBundledAiRuntime, + prepareChangelog: async () => {}, + restoreChangelog: async () => {}, + runCaptureImpl: async ( + command: string, + args: string[], + _cwd: string, + options: { deferForwardedSignalExit?: boolean }, + ) => { + expect(command).toBe("npm"); + expect(args).toEqual([ + "pack", + "--json", + "--silent", + "--ignore-scripts", + "--pack-destination", + outputDir, + ]); + expect(options.deferForwardedSignalExit).toBe(true); + fs.writeFileSync(path.join(outputDir, "openclaw-2026.5.28.tgz"), "package"); + return JSON.stringify([ + { + entryCount: 1, + filename: "openclaw-2026.5.28.tgz", + size: 7, + unpackedSize: 7, + version: "2026.5.28", + }, + ]); + }, + }); + + expect(tarball).toBe(path.join(outputDir, "openclaw-current.tgz")); + expect(JSON.parse(fs.readFileSync(packJsonPath, "utf8"))).toEqual([ + { + entryCount: 1, + filename: "openclaw-current.tgz", + size: 7, + unpackedSize: 7, + version: "2026.5.28", + }, + ]); + } finally { + fs.rmSync(outputDir, { force: true, recursive: true }); + } + }); + it("rejects path-like npm pack stdout before resolving Docker package tarballs", async () => { for (const filename of [ "../openclaw-2026.6.17.tgz", diff --git a/test/scripts/test-install-sh-docker.test.ts b/test/scripts/test-install-sh-docker.test.ts index d36e21a800a..93f3ce90f46 100644 --- a/test/scripts/test-install-sh-docker.test.ts +++ b/test/scripts/test-install-sh-docker.test.ts @@ -612,6 +612,7 @@ describe("test-install-sh-docker", () => { const script = readFileSync(SCRIPT_PATH, "utf8"); expect(script).toContain("read_pack_tarball_filename()"); + expect(script).toContain('UPDATE_TGZ_FILE="$(basename "$package_tgz")"'); expect(script).toContain('UPDATE_TGZ_FILE="$(read_pack_tarball_filename "$pack_json_file")"'); expect(script).toContain( 'BASELINE_TGZ_FILE="$(read_pack_tarball_filename "$baseline_pack_json_file")"', @@ -644,13 +645,16 @@ describe("test-install-sh-docker", () => { } }); - it("writes the package dist inventory before packing ignore-scripts tarballs", () => { + it("uses the package artifact helper for local update tarballs", () => { const script = readFileSync(SCRIPT_PATH, "utf8"); - expect(script).toContain("node --import tsx scripts/write-package-dist-inventory.ts"); - expect(script).toContain('node scripts/check-package-dist-imports.mjs "$ROOT_DIR"'); - expect(script).toContain("quiet_npm pack --ignore-scripts"); + expect(script).toContain("node scripts/package-openclaw-for-docker.mjs"); + expect(script).toContain('--pack-json "$pack_json_file"'); + expect(script).toContain("--skip-build"); + expect(script).not.toContain("node --import tsx scripts/write-package-dist-inventory.ts"); + expect(script).not.toContain("quiet_npm pack --ignore-scripts --json"); expect(script).toContain("node scripts/check-openclaw-package-tarball.mjs"); + expect(script).toContain("--require-bundled-workspace-deps"); }); it("runs candidate tarballs through the installer script instead of direct npm", () => {