From 74c2a4607ac0eaddcafdda8b8aeef93c9a22c3c5 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:42:04 -0700 Subject: [PATCH] fix(release): lock packed AI runtime dependency --- .github/workflows/openclaw-npm-release.yml | 7 + scripts/openclaw-npm-prepublish-verify.ts | 14 +- scripts/prepare-openclaw-npm-shrinkwrap.ts | 194 ++++++++++++++++++ .../prepare-openclaw-npm-shrinkwrap.test.ts | 133 ++++++++++++ 4 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 scripts/prepare-openclaw-npm-shrinkwrap.ts create mode 100644 test/scripts/prepare-openclaw-npm-shrinkwrap.test.ts diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index 32bffc7049a8..817e4a03b28e 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -309,6 +309,13 @@ jobs: AI_RUNTIME_TARBALL_DIR: ${{ steps.ai_runtime_tarballs.outputs.dir }} run: | set -euo pipefail + mapfile -t AI_TARBALLS < <(find "$AI_RUNTIME_TARBALL_DIR" -maxdepth 1 -type f -name 'openclaw-ai-*.tgz' -print | sort) + if [[ "${#AI_TARBALLS[@]}" -ne 1 ]]; then + echo "Expected exactly one prepared @openclaw/ai tarball, found ${#AI_TARBALLS[@]}." >&2 + exit 1 + fi + AI_TARBALL_PATH="${AI_TARBALLS[0]}" + node --import tsx scripts/prepare-openclaw-npm-shrinkwrap.ts "$AI_TARBALL_PATH" PACK_OUTPUT="$RUNNER_TEMP/npm-pack-output.txt" pnpm pack --json 2>&1 | tee "$PACK_OUTPUT" PACK_NAME="$(node - "$PACK_OUTPUT" <<'NODE' diff --git a/scripts/openclaw-npm-prepublish-verify.ts b/scripts/openclaw-npm-prepublish-verify.ts index 09d05d0ffcde..812dd345f1e3 100644 --- a/scripts/openclaw-npm-prepublish-verify.ts +++ b/scripts/openclaw-npm-prepublish-verify.ts @@ -14,6 +14,11 @@ import { resolveInstalledBinaryCommandInvocation, } from "./openclaw-npm-postpublish-verify.ts"; import { resolveNpmCommandInvocation } from "./openclaw-npm-release-check.ts"; +import { + assertPreparedOpenClawNpmShrinkwrap, + npmTarballIntegrity, + readTarballJson, +} from "./prepare-openclaw-npm-shrinkwrap.ts"; import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "./windows-cmd-helpers.mjs"; type InstalledPackageJson = { @@ -98,6 +103,13 @@ function main(argv = process.argv.slice(2)): void { let binaryInvocation: NpmVerifyCommandInvocation; let packageRoot: string; if (usesPreparedLocalDependencyInstall(args.dependencyTarballPaths.length)) { + const aiTarballPath = realpathSync(args.dependencyTarballPaths[0]); + assertPreparedOpenClawNpmShrinkwrap({ + aiIntegrity: npmTarballIntegrity(aiTarballPath), + aiManifest: readTarballJson(aiTarballPath, "package/package.json"), + rootManifest: readTarballJson(args.tarballPath, "package/package.json"), + shrinkwrap: readTarballJson(args.tarballPath, "package/npm-shrinkwrap.json"), + }); mkdirSync(prefixDir, { recursive: true }); writeFileSync( join(prefixDir, "package.json"), @@ -105,7 +117,7 @@ function main(argv = process.argv.slice(2)): void { { private: true, dependencies: { - "@openclaw/ai": pathToFileURL(realpathSync(args.dependencyTarballPaths[0])).href, + "@openclaw/ai": pathToFileURL(aiTarballPath).href, openclaw: pathToFileURL(realpathSync(args.tarballPath)).href, }, }, diff --git a/scripts/prepare-openclaw-npm-shrinkwrap.ts b/scripts/prepare-openclaw-npm-shrinkwrap.ts new file mode 100644 index 000000000000..a6fd17a67416 --- /dev/null +++ b/scripts/prepare-openclaw-npm-shrinkwrap.ts @@ -0,0 +1,194 @@ +#!/usr/bin/env -S node --import tsx + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { basename, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const AI_PACKAGE_NAME = "@openclaw/ai"; +const AI_LOCK_PATH = "node_modules/@openclaw/ai"; + +type JsonObject = Record; + +type PackageManifest = JsonObject & { + dependencies?: Record; + engines?: Record; + license?: string; + name?: string; + version?: string; +}; + +type NpmShrinkwrap = JsonObject & { + lockfileVersion?: number; + packages?: Record; +}; + +function requireObject(value: unknown, label: string): JsonObject { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as JsonObject; +} + +function requireString(value: unknown, label: string): string { + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`${label} must be a nonempty string`); + } + return value.trim(); +} + +function registryTarballUrl(packageName: string, version: string): string { + return `https://registry.npmjs.org/${packageName}/-/${basename(packageName)}-${version}.tgz`; +} + +function expectedAiLockEntry(params: { + aiIntegrity: string; + aiManifest: PackageManifest; + aiVersion: string; +}): JsonObject { + const aiDependencies = params.aiManifest.dependencies ?? {}; + return { + version: params.aiVersion, + resolved: registryTarballUrl(AI_PACKAGE_NAME, params.aiVersion), + integrity: params.aiIntegrity, + ...(params.aiManifest.license ? { license: params.aiManifest.license } : {}), + ...(Object.keys(aiDependencies).length > 0 ? { dependencies: aiDependencies } : {}), + ...(params.aiManifest.engines ? { engines: params.aiManifest.engines } : {}), + }; +} + +export function prepareOpenClawNpmShrinkwrap(params: { + aiIntegrity: string; + aiManifest: PackageManifest; + rootManifest: PackageManifest; + shrinkwrap: NpmShrinkwrap; +}): NpmShrinkwrap { + const rootVersion = requireString(params.rootManifest.version, "root package version"); + const aiName = requireString(params.aiManifest.name, "AI package name"); + const aiVersion = requireString(params.aiManifest.version, "AI package version"); + if (aiName !== AI_PACKAGE_NAME) { + throw new Error(`AI package name must be ${AI_PACKAGE_NAME}, found ${aiName}`); + } + if (aiVersion !== rootVersion) { + throw new Error(`AI package version ${aiVersion} does not match OpenClaw ${rootVersion}`); + } + if (!params.aiIntegrity.startsWith("sha512-")) { + throw new Error("AI package integrity must use sha512"); + } + if (params.shrinkwrap.lockfileVersion !== 3) { + throw new Error(`npm shrinkwrap lockfileVersion must be 3`); + } + + const packages = requireObject(params.shrinkwrap.packages, "npm shrinkwrap packages") as Record< + string, + JsonObject + >; + const rootPackage = requireObject(packages[""], "npm shrinkwrap root package"); + const rootLockVersion = requireString(rootPackage.version, "npm shrinkwrap root version"); + if (rootLockVersion !== rootVersion) { + throw new Error( + `npm shrinkwrap root version ${rootLockVersion} does not match OpenClaw ${rootVersion}`, + ); + } + + const rootDependencies = requireObject( + rootPackage.dependencies, + "npm shrinkwrap root dependencies", + ) as Record; + const aiDependencies = params.aiManifest.dependencies ?? {}; + for (const dependencyName of Object.keys(aiDependencies)) { + if (!packages[`node_modules/${dependencyName}`]) { + throw new Error(`npm shrinkwrap is missing AI runtime dependency ${dependencyName}`); + } + } + + rootDependencies[AI_PACKAGE_NAME] = aiVersion; + packages[AI_LOCK_PATH] = expectedAiLockEntry({ + aiIntegrity: params.aiIntegrity, + aiManifest: params.aiManifest, + aiVersion, + }); + return params.shrinkwrap; +} + +export function assertPreparedOpenClawNpmShrinkwrap(params: { + aiIntegrity: string; + aiManifest: PackageManifest; + rootManifest: PackageManifest; + shrinkwrap: NpmShrinkwrap; +}): void { + const expected = prepareOpenClawNpmShrinkwrap({ + ...params, + shrinkwrap: structuredClone(params.shrinkwrap), + }); + const actualPackages = requireObject( + params.shrinkwrap.packages, + "npm shrinkwrap packages", + ) as Record; + const expectedPackages = requireObject( + expected.packages, + "expected npm shrinkwrap packages", + ) as Record; + const actualRoot = requireObject(actualPackages[""], "npm shrinkwrap root package"); + const expectedRoot = requireObject(expectedPackages[""], "expected npm shrinkwrap root package"); + const actualRootDependencies = requireObject( + actualRoot.dependencies, + "npm shrinkwrap root dependencies", + ); + const expectedRootDependencies = requireObject( + expectedRoot.dependencies, + "expected npm shrinkwrap root dependencies", + ); + if ( + actualRootDependencies[AI_PACKAGE_NAME] !== expectedRootDependencies[AI_PACKAGE_NAME] || + JSON.stringify(actualPackages[AI_LOCK_PATH]) !== JSON.stringify(expectedPackages[AI_LOCK_PATH]) + ) { + throw new Error( + `prepared OpenClaw npm shrinkwrap does not lock the exact ${AI_PACKAGE_NAME} tarball`, + ); + } +} + +export function readTarballJson(tarballPath: string, entry: string): JsonObject { + const raw = execFileSync("tar", ["-xOf", tarballPath, entry], { + encoding: "utf8", + maxBuffer: 1024 * 1024, + }); + return requireObject(JSON.parse(raw), `${entry} in ${tarballPath}`); +} + +export function npmTarballIntegrity(tarballPath: string): string { + return `sha512-${createHash("sha512").update(readFileSync(tarballPath)).digest("base64")}`; +} + +function main(argv = process.argv.slice(2)): void { + const aiTarballPath = argv[0]?.trim(); + const shrinkwrapPath = resolve(argv[1]?.trim() || "npm-shrinkwrap.json"); + const rootManifestPath = resolve(argv[2]?.trim() || "package.json"); + if (!aiTarballPath || argv.length > 3) { + throw new Error( + "Usage: node --import tsx scripts/prepare-openclaw-npm-shrinkwrap.ts [npm-shrinkwrap.json] [package.json]", + ); + } + + const prepared = prepareOpenClawNpmShrinkwrap({ + aiIntegrity: npmTarballIntegrity(aiTarballPath), + aiManifest: readTarballJson(aiTarballPath, "package/package.json") as PackageManifest, + rootManifest: requireObject( + JSON.parse(readFileSync(rootManifestPath, "utf8")), + "root package manifest", + ) as PackageManifest, + shrinkwrap: requireObject( + JSON.parse(readFileSync(shrinkwrapPath, "utf8")), + "npm shrinkwrap", + ) as NpmShrinkwrap, + }); + writeFileSync(shrinkwrapPath, `${JSON.stringify(prepared, null, 2)}\n`); + console.log(`Prepared ${shrinkwrapPath} for ${AI_PACKAGE_NAME}.`); +} + +const entrypoint = process.argv[1] ? pathToFileURL(process.argv[1]).href : null; +if (entrypoint !== null && import.meta.url === entrypoint) { + main(); +} diff --git a/test/scripts/prepare-openclaw-npm-shrinkwrap.test.ts b/test/scripts/prepare-openclaw-npm-shrinkwrap.test.ts new file mode 100644 index 000000000000..b20b656afb20 --- /dev/null +++ b/test/scripts/prepare-openclaw-npm-shrinkwrap.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { + assertPreparedOpenClawNpmShrinkwrap, + prepareOpenClawNpmShrinkwrap, +} from "../../scripts/prepare-openclaw-npm-shrinkwrap.ts"; + +const AI_DEPENDENCIES = { + "@anthropic-ai/sdk": "0.109.1", + openai: "6.45.0", +}; + +function createShrinkwrap() { + return { + lockfileVersion: 3, + packages: { + "": { + name: "openclaw", + version: "2026.7.1-beta.5", + dependencies: { + openai: "6.45.0", + }, + }, + "node_modules/@anthropic-ai/sdk": { + version: "0.109.1", + }, + "node_modules/openai": { + version: "6.45.0", + }, + }, + }; +} + +describe("prepareOpenClawNpmShrinkwrap", () => { + it("adds the exact registry AI runtime dependency to the root shrinkwrap", () => { + const prepared = prepareOpenClawNpmShrinkwrap({ + aiIntegrity: "sha512-test", + aiManifest: { + name: "@openclaw/ai", + version: "2026.7.1-beta.5", + license: "MIT", + engines: { node: ">=22.19.0" }, + dependencies: AI_DEPENDENCIES, + }, + rootManifest: { + name: "openclaw", + version: "2026.7.1-beta.5", + }, + shrinkwrap: createShrinkwrap(), + }); + + expect(prepared.packages?.[""]?.dependencies).toEqual({ + "@openclaw/ai": "2026.7.1-beta.5", + openai: "6.45.0", + }); + expect(prepared.packages?.["node_modules/@openclaw/ai"]).toEqual({ + version: "2026.7.1-beta.5", + resolved: "https://registry.npmjs.org/@openclaw/ai/-/ai-2026.7.1-beta.5.tgz", + integrity: "sha512-test", + license: "MIT", + dependencies: AI_DEPENDENCIES, + engines: { node: ">=22.19.0" }, + }); + expect(() => + assertPreparedOpenClawNpmShrinkwrap({ + aiIntegrity: "sha512-test", + aiManifest: { + name: "@openclaw/ai", + version: "2026.7.1-beta.5", + license: "MIT", + engines: { node: ">=22.19.0" }, + dependencies: AI_DEPENDENCIES, + }, + rootManifest: { + name: "openclaw", + version: "2026.7.1-beta.5", + }, + shrinkwrap: prepared, + }), + ).not.toThrow(); + }); + + it("rejects mismatched versions and incomplete dependency graphs", () => { + expect(() => + prepareOpenClawNpmShrinkwrap({ + aiIntegrity: "sha512-test", + aiManifest: { + name: "@openclaw/ai", + version: "2026.7.1-beta.4", + dependencies: AI_DEPENDENCIES, + }, + rootManifest: { + name: "openclaw", + version: "2026.7.1-beta.5", + }, + shrinkwrap: createShrinkwrap(), + }), + ).toThrow("does not match OpenClaw"); + + const incomplete = createShrinkwrap(); + delete incomplete.packages["node_modules/openai"]; + expect(() => + prepareOpenClawNpmShrinkwrap({ + aiIntegrity: "sha512-test", + aiManifest: { + name: "@openclaw/ai", + version: "2026.7.1-beta.5", + dependencies: AI_DEPENDENCIES, + }, + rootManifest: { + name: "openclaw", + version: "2026.7.1-beta.5", + }, + shrinkwrap: incomplete, + }), + ).toThrow("missing AI runtime dependency openai"); + + expect(() => + assertPreparedOpenClawNpmShrinkwrap({ + aiIntegrity: "sha512-test", + aiManifest: { + name: "@openclaw/ai", + version: "2026.7.1-beta.5", + dependencies: AI_DEPENDENCIES, + }, + rootManifest: { + name: "openclaw", + version: "2026.7.1-beta.5", + }, + shrinkwrap: createShrinkwrap(), + }), + ).toThrow("does not lock the exact @openclaw/ai tarball"); + }); +});