Files
openclaw/scripts/resolve-openclaw-package-candidate.mjs
2026-04-28 12:53:05 -07:00

467 lines
14 KiB
JavaScript

#!/usr/bin/env node
// Normalizes package-acceptance inputs into the tarball shape consumed by Docker E2E.
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { createWriteStream } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import { fileURLToPath } from "node:url";
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const DEFAULT_OUTPUT_NAME = "openclaw-current.tgz";
export const OPENCLAW_PACKAGE_SPEC_RE =
/^openclaw@(beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-beta\.[1-9][0-9]*)?)$/u;
function usage() {
return `Usage: node scripts/resolve-openclaw-package-candidate.mjs --source <ref|npm|url|artifact> --output-dir <dir> [options]
Options:
--package-spec <spec> Published npm spec for source=npm.
--package-ref <ref> Trusted repo ref for source=ref.
--package-url <url> HTTPS tarball URL for source=url.
--package-sha256 <sha256> Expected tarball SHA-256 for source=url or source=artifact.
--artifact-dir <dir> Directory containing exactly one .tgz for source=artifact.
--output-name <name> Output tarball filename. Default: ${DEFAULT_OUTPUT_NAME}
--metadata <file> Write package metadata JSON.
--github-output <file> Append tarball, sha256, package name/version outputs.`;
}
export function parseArgs(argv) {
const options = {
artifactDir: "",
githubOutput: "",
metadata: "",
outputDir: "",
outputName: DEFAULT_OUTPUT_NAME,
packageRef: "",
packageSha256: "",
packageSpec: "",
packageUrl: "",
source: "",
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const readValue = (name) => {
const value = argv[(index += 1)];
if (value === undefined) {
throw new Error(`${name} requires a value`);
}
return value;
};
if (arg === "--artifact-dir") {
options.artifactDir = readValue(arg);
} else if (arg === "--github-output") {
options.githubOutput = readValue(arg);
} else if (arg === "--metadata") {
options.metadata = readValue(arg);
} else if (arg === "--output-dir") {
options.outputDir = readValue(arg);
} else if (arg === "--output-name") {
options.outputName = readValue(arg);
} else if (arg === "--package-sha256") {
options.packageSha256 = readValue(arg).toLowerCase();
} else if (arg === "--package-ref") {
options.packageRef = readValue(arg);
} else if (arg === "--package-spec") {
options.packageSpec = readValue(arg);
} else if (arg === "--package-url") {
options.packageUrl = readValue(arg);
} else if (arg === "--source") {
options.source = readValue(arg);
} else if (arg === "--help" || arg === "-h") {
options.help = true;
} else {
throw new Error(`unknown argument: ${arg}`);
}
}
return options;
}
export function validateOpenClawPackageSpec(spec) {
if (!OPENCLAW_PACKAGE_SPEC_RE.test(spec)) {
throw new Error(
`package_spec must be openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: ${spec}`,
);
}
}
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: options.cwd ?? ROOT_DIR,
stdio: options.capture ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"],
});
let stdout = "";
let stderr = "";
if (options.capture) {
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
}
child.on("error", reject);
child.on("close", (status, signal) => {
if (status === 0) {
resolve(stdout);
return;
}
const detail = stderr.trim() ? `\n${stderr.trim()}` : "";
reject(new Error(`${command} ${args.join(" ")} failed with ${status ?? signal}${detail}`));
});
});
}
async function walkFiles(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const absolute = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await walkFiles(absolute)));
} else if (entry.isFile()) {
files.push(absolute);
}
}
return files;
}
async function sha256(file) {
const hash = createHash("sha256");
const handle = await fs.open(file, "r");
try {
for await (const chunk of handle.createReadStream()) {
hash.update(chunk);
}
} finally {
await handle.close();
}
return hash.digest("hex");
}
function assertSha256(value) {
if (!/^[a-f0-9]{64}$/u.test(value)) {
throw new Error(`package_sha256 must be a lowercase or uppercase 64-character SHA-256 digest`);
}
}
async function assertExpectedSha256(file, expected) {
if (!expected) {
return await sha256(file);
}
assertSha256(expected);
const actual = await sha256(file);
if (actual !== expected.toLowerCase()) {
throw new Error(`package SHA-256 mismatch: expected ${expected}, got ${actual}`);
}
return actual;
}
async function findSingleTarball(dir) {
const files = (await walkFiles(path.resolve(ROOT_DIR, dir)))
.filter((file) => /\.t(?:ar\.)?gz$/u.test(path.basename(file)))
.toSorted((a, b) => a.localeCompare(b));
if (files.length !== 1) {
throw new Error(
`source=artifact requires exactly one .tgz under ${dir}; found ${files.length}: ${files.join(", ")}`,
);
}
return files[0];
}
async function revParseTrustedInputRef(ref) {
const candidates = [ref, `refs/remotes/origin/${ref}`, `refs/tags/${ref}`];
for (const candidate of candidates) {
const resolved = await run("git", ["rev-parse", "--verify", `${candidate}^{commit}`], {
capture: true,
}).then(
(value) => value.trim(),
() => "",
);
if (resolved) {
return resolved;
}
}
throw new Error(`package_ref does not resolve to a commit: ${ref}`);
}
async function resolveTrustedRepoRef(ref) {
if (!ref || ref.trim() === "" || ref.startsWith("-")) {
throw new Error(
`package_ref must be a branch, tag, or full commit SHA; got: ${ref || "<empty>"}`,
);
}
await run("git", ["fetch", "--no-tags", "origin", "+refs/heads/*:refs/remotes/origin/*"]);
await run("git", ["fetch", "--tags", "origin", "+refs/tags/*:refs/tags/*"]);
const selectedSha = await revParseTrustedInputRef(ref);
const isMainAncestor = await run("git", [
"merge-base",
"--is-ancestor",
selectedSha,
"refs/remotes/origin/main",
]).then(
() => true,
() => false,
);
if (isMainAncestor) {
return { selectedSha, trustedReason: "main-ancestor" };
}
const releaseTags = (await run("git", ["tag", "--points-at", selectedSha], { capture: true }))
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean);
if (releaseTags.some((tag) => tag.startsWith("v"))) {
return { selectedSha, trustedReason: "release-tag" };
}
const containingBranches = (
await run(
"git",
[
"for-each-ref",
"--format=%(refname:short)",
"--contains",
selectedSha,
"refs/remotes/origin",
],
{ capture: true },
)
)
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean);
if (containingBranches.some((branch) => branch.startsWith("origin/"))) {
return { selectedSha, trustedReason: "repository-branch-history" };
}
throw new Error(
`package_ref ${ref} resolved to ${selectedSha}, which is not reachable from an OpenClaw branch or release tag`,
);
}
async function preparePackageSourceWorktree(ref) {
const { selectedSha, trustedReason } = await resolveTrustedRepoRef(ref);
const sourceDir = path.join(
process.env.RUNNER_TEMP || os.tmpdir(),
`openclaw-package-source-${process.pid}`,
);
await fs.rm(sourceDir, { recursive: true, force: true });
await run("git", ["worktree", "add", "--detach", sourceDir, selectedSha]);
return { selectedSha, sourceDir, trustedReason };
}
async function installPackageSourceDeps(sourceDir) {
await run(
"pnpm",
[
"install",
"--frozen-lockfile",
"--ignore-scripts=false",
"--config.engine-strict=false",
"--config.enable-pre-post-scripts=true",
],
{ cwd: sourceDir },
);
}
async function moveNewestPackedTarball(outputDir, packOutput, outputName) {
let filename = "";
try {
const parsed = JSON.parse(packOutput);
if (Array.isArray(parsed)) {
filename = parsed.find((entry) => typeof entry?.filename === "string")?.filename ?? "";
}
} catch {}
if (!filename) {
for (const line of packOutput.split(/\r?\n/u)) {
const trimmed = line.trim();
if (/^openclaw-.*\.tgz$/u.test(trimmed)) {
filename = trimmed;
}
}
}
if (!filename) {
const entries = await fs.readdir(outputDir);
filename = entries
.filter((entry) => /^openclaw-.*\.tgz$/u.test(entry))
.toSorted((a, b) => a.localeCompare(b))
.at(-1);
}
if (!filename) {
throw new Error(`npm pack produced no OpenClaw tarball in ${outputDir}`);
}
const packed = path.join(outputDir, filename);
const target = path.join(outputDir, outputName);
if (packed !== target) {
await fs.rm(target, { force: true });
await fs.rename(packed, target);
}
return target;
}
async function downloadUrl(url, target) {
const parsed = new URL(url);
if (parsed.protocol !== "https:") {
throw new Error(`package_url must use https: ${url}`);
}
const response = await fetch(parsed);
if (!response.ok || !response.body) {
throw new Error(`failed to download package_url: HTTP ${response.status}`);
}
await pipeline(response.body, createWriteStream(target));
}
async function readPackageJson(tarball) {
const raw = await run("tar", ["-xOf", tarball, "package/package.json"], { capture: true });
const pkg = JSON.parse(raw);
return {
name: typeof pkg.name === "string" ? pkg.name : "",
version: typeof pkg.version === "string" ? pkg.version : "",
};
}
async function appendGithubOutputs(file, outputs) {
if (!file) {
return;
}
const body = Object.entries(outputs)
.map(([key, value]) => `${key}=${String(value).replace(/\n/gu, " ")}`)
.join("\n");
await fs.appendFile(file, `${body}\n`);
}
async function resolveCandidate(options) {
const outputDir = path.resolve(ROOT_DIR, options.outputDir);
const target = path.join(outputDir, options.outputName || DEFAULT_OUTPUT_NAME);
await fs.mkdir(outputDir, { recursive: true });
await fs.rm(target, { force: true });
let packageRef = "";
let packageSourceSha = "";
let packageTrustedReason = "";
let packageWorktreeDir = "";
try {
if (options.source === "ref") {
packageRef = options.packageRef || "main";
const packageSource = await preparePackageSourceWorktree(packageRef);
packageWorktreeDir = packageSource.sourceDir;
packageSourceSha = packageSource.selectedSha;
packageTrustedReason = packageSource.trustedReason;
await installPackageSourceDeps(packageSource.sourceDir);
await run("node", [
"scripts/package-openclaw-for-docker.mjs",
"--source-dir",
packageSource.sourceDir,
"--output-dir",
outputDir,
"--output-name",
options.outputName || DEFAULT_OUTPUT_NAME,
]);
} else if (options.source === "npm") {
validateOpenClawPackageSpec(options.packageSpec);
const packOutput = await run(
"npm",
[
"pack",
options.packageSpec,
"--ignore-scripts",
"--json",
"--pack-destination",
outputDir,
],
{ capture: true },
);
await moveNewestPackedTarball(
outputDir,
packOutput,
options.outputName || DEFAULT_OUTPUT_NAME,
);
} else if (options.source === "url") {
if (!options.packageUrl) {
throw new Error("source=url requires --package-url");
}
if (!options.packageSha256) {
throw new Error("source=url requires --package-sha256");
}
await downloadUrl(options.packageUrl, target);
} else if (options.source === "artifact") {
if (!options.artifactDir) {
throw new Error("source=artifact requires --artifact-dir");
}
const input = await findSingleTarball(options.artifactDir);
await fs.copyFile(input, target);
} else {
throw new Error(`source must be one of: ref, npm, url, artifact. Got: ${options.source}`);
}
} finally {
if (packageWorktreeDir) {
await run("git", ["worktree", "remove", "--force", packageWorktreeDir]).catch(() => {});
}
}
const digest = await assertExpectedSha256(target, options.packageSha256);
await run("node", ["scripts/check-openclaw-package-tarball.mjs", target]);
const pkg = await readPackageJson(target);
const metadata = {
name: pkg.name,
packageRef,
packageSpec: options.packageSpec || "",
packageSourceSha,
packageTrustedReason,
sha256: digest,
source: options.source,
tarball: path.relative(ROOT_DIR, target),
version: pkg.version,
};
if (pkg.name !== "openclaw") {
throw new Error(`package candidate must be named "openclaw"; got: ${pkg.name || "<missing>"}`);
}
if (!pkg.version) {
throw new Error("package candidate package.json has no version");
}
if (options.metadata) {
await fs.mkdir(path.dirname(path.resolve(ROOT_DIR, options.metadata)), { recursive: true });
await fs.writeFile(
path.resolve(ROOT_DIR, options.metadata),
`${JSON.stringify(metadata, null, 2)}\n`,
);
}
await appendGithubOutputs(options.githubOutput, {
package_name: pkg.name,
package_source_sha: packageSourceSha,
package_version: pkg.version,
sha256: digest,
tarball: metadata.tarball,
});
return metadata;
}
export async function main(argv = process.argv.slice(2)) {
const options = parseArgs(argv);
if (options.help) {
console.log(usage());
return;
}
if (!options.outputDir) {
throw new Error("--output-dir is required");
}
const metadata = await resolveCandidate(options);
console.log(JSON.stringify(metadata, null, 2));
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
console.error(usage());
process.exit(1);
});
}