fix(release): lock packed AI runtime dependency

This commit is contained in:
Vincent Koc
2026-07-11 02:42:04 -07:00
parent 7d2a86c65e
commit 74c2a4607a
4 changed files with 347 additions and 1 deletions
@@ -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'
+13 -1
View File
@@ -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,
},
},
+194
View File
@@ -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<string, unknown>;
type PackageManifest = JsonObject & {
dependencies?: Record<string, string>;
engines?: Record<string, string>;
license?: string;
name?: string;
version?: string;
};
type NpmShrinkwrap = JsonObject & {
lockfileVersion?: number;
packages?: Record<string, JsonObject>;
};
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<string, unknown>;
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<string, JsonObject>;
const expectedPackages = requireObject(
expected.packages,
"expected npm shrinkwrap packages",
) as Record<string, JsonObject>;
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 <openclaw-ai.tgz> [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();
}
@@ -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");
});
});