mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
fix(release): lock packed AI runtime dependency
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user