ci: split package acceptance refs

This commit is contained in:
Peter Steinberger
2026-04-27 04:39:19 +01:00
parent 34f81c6a8a
commit 02d266c6c4
12 changed files with 407 additions and 79 deletions

View File

@@ -14,6 +14,7 @@ function parseArgs(argv) {
outputDir: "",
outputName: "",
skipBuild: false,
sourceDir: ROOT_DIR,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
@@ -27,6 +28,10 @@ function parseArgs(argv) {
options.outputName = arg.slice("--output-name=".length);
} else if (arg === "--skip-build") {
options.skipBuild = true;
} else if (arg === "--source-dir") {
options.sourceDir = argv[(index += 1)] ?? "";
} else if (arg?.startsWith("--source-dir=")) {
options.sourceDir = arg.slice("--source-dir=".length);
} else {
throw new Error(`unknown argument: ${arg}`);
}
@@ -34,10 +39,10 @@ function parseArgs(argv) {
return options;
}
function run(command, args) {
function run(command, args, cwd) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: ROOT_DIR,
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.pipe(process.stderr, { end: false });
@@ -53,10 +58,10 @@ function run(command, args) {
});
}
async function runCapture(command, args) {
async function runCapture(command, args, cwd) {
return await new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: ROOT_DIR,
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
@@ -100,6 +105,7 @@ async function newestOpenClawTarball(outputDir, packOutput) {
async function main() {
const options = parseArgs(process.argv.slice(2));
const sourceDir = path.resolve(ROOT_DIR, options.sourceDir || ROOT_DIR);
const outputDir = path.resolve(
ROOT_DIR,
options.outputDir || path.join(".artifacts", "docker-e2e-package"),
@@ -108,26 +114,28 @@ async function main() {
if (!options.skipBuild) {
console.error("==> Building OpenClaw package artifacts");
await run("pnpm", ["build"]);
await run("pnpm", ["build"], sourceDir);
}
console.error("==> Writing OpenClaw package inventory");
await run("node", [
"--import",
"tsx",
"--input-type=module",
"-e",
"const { writePackageDistInventory } = await import('./src/infra/package-dist-inventory.ts'); await writePackageDistInventory(process.cwd());",
]);
await run(
"node",
[
"--import",
"tsx",
"--input-type=module",
"-e",
"const { writePackageDistInventory } = await import('./src/infra/package-dist-inventory.ts'); await writePackageDistInventory(process.cwd());",
],
sourceDir,
);
console.error("==> Packing OpenClaw package");
const packOutput = await runCapture("npm", [
"pack",
"--silent",
"--ignore-scripts",
"--pack-destination",
outputDir,
]);
const packOutput = await runCapture(
"npm",
["pack", "--silent", "--ignore-scripts", "--pack-destination", outputDir],
sourceDir,
);
let tarball = await newestOpenClawTarball(outputDir, packOutput);
if (options.outputName) {
@@ -140,7 +148,11 @@ async function main() {
}
console.error("==> Checking OpenClaw package tarball");
await run("node", ["scripts/check-openclaw-package-tarball.mjs", tarball]);
await run(
"node",
[path.join(ROOT_DIR, "scripts/check-openclaw-package-tarball.mjs"), tarball],
sourceDir,
);
process.stdout.write(`${tarball}\n`);
}

View File

@@ -4,6 +4,7 @@ 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";
@@ -18,6 +19,7 @@ function usage() {
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.
@@ -33,6 +35,7 @@ export function parseArgs(argv) {
metadata: "",
outputDir: "",
outputName: DEFAULT_OUTPUT_NAME,
packageRef: "",
packageSha256: "",
packageSpec: "",
packageUrl: "",
@@ -59,6 +62,8 @@ export function parseArgs(argv) {
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") {
@@ -167,6 +172,104 @@ async function findSingleTarball(dir) {
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 {
@@ -238,39 +341,68 @@ async function resolveCandidate(options) {
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 = "";
if (options.source === "ref") {
await run("node", [
"scripts/package-openclaw-for-docker.mjs",
"--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");
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}`);
}
if (!options.packageSha256) {
throw new Error("source=url requires --package-sha256");
} finally {
if (packageWorktreeDir) {
await run("git", ["worktree", "remove", "--force", packageWorktreeDir]).catch(() => {});
}
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}`);
}
const digest = await assertExpectedSha256(target, options.packageSha256);
@@ -278,7 +410,10 @@ async function resolveCandidate(options) {
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),