Files
openclaw/scripts/release-plan-producer.mts
Vincent Koc 1da74794b6 fix(release): establish immutable release plan contract (#127008)
* fix(release): define immutable release plan contract

* fix(release): harden immutable plan authority

* fix(release): tighten release plan authority

* fix(release): share plugin publication authority

* fix(release): verify plan authority remotely

* fix(release): track ClawHub publication authorities

* fix(release): trust remote tooling tag identity

* fix(release): close plugin publication authority

* fix(release): align npm authority selection

* fix(release): bind plans to validation intent

* fix(release): require qualification cadence

* fix(release): reject lossy canonical values

* fix(release): narrow qualification cadence

* fix(release): bind plan parser dependency

* fix(release): add tagless diagnostic plans

* fix(release): attest release plan parser tree

* fix(release): isolate verified plan parser snapshot

* fix(release): verify plan tooling before execution
2026-08-21 11:24:22 -07:00

545 lines
22 KiB
TypeScript

#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import { isBuiltin } from "node:module";
import { join, posix, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { deserialize, serialize } from "node:v8";
export type ReleasePlanIntent =
| "publish"
| "diagnostic"
| "postpublish-confidence"
| "main-qualification";
export type MainQualificationValidationIntent = "main-daily" | "main-weekly";
type RunGh = (args: string[]) => string;
type ReleasePlanSourceBase = {
repoRoot?: string;
candidateSha: string;
candidateRef: string;
toolingSha: string;
toolingFullRef: string;
runGh?: RunGh;
};
export type ReleasePlanSource =
| (ReleasePlanSourceBase & {
intent: "main-qualification";
validationIntent: MainQualificationValidationIntent;
})
| (ReleasePlanSourceBase & {
intent: Exclude<ReleasePlanIntent, "main-qualification">;
validationIntent?: never;
});
type ReleasePlan = {
schema: string;
release_id: string;
version: string;
tag: string | null;
candidate_sha: string;
target_context_ref: string;
purpose: string;
tooling: { repository: string; workflow_path: string; ref: string; sha: string };
validation: {
intent: string;
profile: string;
soak: boolean;
allowed_groups: string[];
};
inventory: {
packages: Array<{ name: string; version: string; targets: string[] }>;
platforms: Array<{ id: string; source: string }>;
};
};
type ReleasePlanLock = Record<"schema" | "digest", string> & { plan: ReleasePlan };
const REPOSITORY = "openclaw/openclaw";
const EXECUTION_ROOT = fileURLToPath(new URL("..", import.meta.url));
const BOOTSTRAP_PATH = "scripts/release-plan-producer.mts",
CORE_PATH = "scripts/release-plan-producer-core.mts";
const TOOLING_MODULE_PATHS = [
"packages/normalization-core/src/record-coerce.ts",
"packages/normalization-core/src/string-coerce.ts",
"packages/plugin-package-contract/src/index.ts",
"scripts/lib/npm-publish-plan.mjs",
"scripts/lib/plugin-publication-candidates.ts",
"scripts/lib/plugin-publication-collector.ts",
"scripts/lib/record-shared.mjs",
"scripts/lib/release-version.mjs",
CORE_PATH,
"scripts/release-plan-contract.mjs",
"scripts/release-tooling-identity.mjs",
"scripts/release-validation-intent.mjs",
] as const;
const PROTECTED_TAG_PATTERN = /^release-publish\/([a-f0-9]{12})-([1-9][0-9]*)$/u;
const MAX_TOOLING_FILE_BYTES = 512 * 1024,
MAX_TOOLING_BYTES = 2 * 1024 * 1024;
const YAML_PACKAGE_TREE_SHA256 = "610ccacfe592d226ac1eb04842d1f591c5381f2a68b9f785643101d10db52c27";
const YAML_PACKAGE_MAX_FILES = 512;
const YAML_PACKAGE_MAX_ENTRIES = 1024;
const YAML_PACKAGE_MAX_BYTES = 4 * 1024 * 1024;
const compareAscii = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0);
type ToolingModule = { path: string; bytes: Buffer; imports: Array<[string, string]> };
type YamlEntry =
| { kind: "directory"; path: string }
| { kind: "file"; path: string; bytes: Buffer };
type SerializableSource = Omit<ReleasePlanSourceBase, "runGh"> & Record<string, unknown>;
type ProducerRequest =
| { operation: "produce" | "produce-lock"; params: SerializableSource }
| { operation: "verify-lock"; lockJson: string; params: SerializableSource };
const CHILD_RUNNER = String.raw`
import { createHash } from "node:crypto"; import { readFileSync } from "node:fs";
import { createRequire, isBuiltin, registerHooks } from "node:module";
import { deserialize, serialize } from "node:v8";
const TOOLING_ROOT = "file:///__openclaw_verified_tooling__/", YAML_ROOT = "file:///__openclaw_verified_yaml__/";
const YAML_ABSOLUTE_ROOT = "/__openclaw_verified_yaml__", CORE_PATH = ${JSON.stringify(CORE_PATH)};
const EXPECTED_TOOLING_PATHS = ${JSON.stringify(TOOLING_MODULE_PATHS)};
const compareAscii = (left, right) => left < right ? -1 : left > right ? 1 : 0;
const fail = message => { throw new Error(message); };
const toolingUrl = path => new URL(path, TOOLING_ROOT).href, yamlUrl = path => new URL(path, YAML_ROOT).href;
const safePath = path => typeof path === "string" && path.length > 0 &&
/^[\x20-\x7e]+$/.test(path) && !path.includes("\\") && !path.startsWith("/") &&
!path.split("/").some(part => part === "." || part === "..");
try {
const payload = deserialize(readFileSync(0)), expectedPaths = [...EXPECTED_TOOLING_PATHS].sort(compareAscii);
if (!Array.isArray(payload.toolingModules) || payload.toolingModules.length !== expectedPaths.length) fail("verified tooling module set is incomplete");
const toolingModules = new Map();
for (const record of payload.toolingModules) {
if (!record || !expectedPaths.includes(record.path) || toolingModules.has(record.path) || !Array.isArray(record.imports)) fail("verified tooling module record is invalid");
toolingModules.set(record.path, { bytes: Buffer.from(record.bytes),
format: record.path.endsWith(".mjs") ? "module" : "module-typescript",
imports: new Map(record.imports) });
}
if ([...toolingModules.keys()].sort(compareAscii).some((path, index) => path !== expectedPaths[index])) fail("verified tooling module paths do not match the allowlist");
if (!Array.isArray(payload.yamlEntries) || payload.yamlEntries.length > ${YAML_PACKAGE_MAX_ENTRIES}) fail("verified yaml retained tree has too many entries");
const yamlModules = new Map(), yamlRecords = []; let yamlFiles = 0, yamlBytes = 0;
for (const entry of payload.yamlEntries) {
if (!entry || !safePath(entry.path)) fail("verified yaml retained tree contains an unsafe path");
if (entry.kind === "directory") {
yamlRecords.push(JSON.stringify(["directory", entry.path])); continue;
}
if (entry.kind !== "file" || yamlModules.has(entry.path)) fail("verified yaml retained tree contains an invalid entry");
const bytes = Buffer.from(entry.bytes); yamlFiles += 1; yamlBytes += bytes.byteLength;
if (yamlFiles > ${YAML_PACKAGE_MAX_FILES} || yamlBytes > ${YAML_PACKAGE_MAX_BYTES}) fail("verified yaml retained tree exceeds its bounds");
yamlModules.set(entry.path, bytes);
yamlRecords.push(JSON.stringify(["file", entry.path, bytes.byteLength, createHash("sha256").update(bytes).digest("hex")]));
}
const yamlManifest = yamlRecords.sort(compareAscii).join("\n") + "\n";
if (createHash("sha256").update(yamlManifest, "ascii").digest("hex") !== ${JSON.stringify(YAML_PACKAGE_TREE_SHA256)}) fail("verified yaml retained tree digest mismatch");
const packageBytes = yamlModules.get("package.json");
if (!packageBytes) fail("verified yaml retained package.json is missing");
const yamlPackage = JSON.parse(packageBytes.toString("utf8"));
if (yamlPackage.name !== "yaml" || yamlPackage.version !== "2.9.0") fail("verified yaml retained package identity mismatch");
registerHooks({
resolve(specifier, context, nextResolve) {
if (isBuiltin(specifier)) return nextResolve(specifier, context);
if (context.parentURL?.startsWith(TOOLING_ROOT)) {
const parentPath = context.parentURL.slice(TOOLING_ROOT.length);
const targetPath = toolingModules.get(parentPath)?.imports.get(specifier);
if (!targetPath) fail("verified tooling import is not allowlisted");
return { url: toolingUrl(targetPath), format: toolingModules.get(targetPath).format, shortCircuit: true };
}
if (specifier === toolingUrl(CORE_PATH)) return { url: specifier, format: toolingModules.get(CORE_PATH).format, shortCircuit: true };
const targetUrl = specifier.startsWith(YAML_ABSOLUTE_ROOT + "/") ?
yamlUrl(specifier.slice(YAML_ABSOLUTE_ROOT.length + 1)) :
context.parentURL?.startsWith(YAML_ROOT) && specifier.startsWith(".") ?
new URL(specifier, context.parentURL).href : undefined;
if (targetUrl) {
const targetPath = targetUrl.slice(YAML_ROOT.length);
if (!yamlModules.has(targetPath)) fail("verified yaml import is not retained");
return { url: targetUrl, format: "commonjs", shortCircuit: true };
}
fail("verified child rejected an external module import");
},
load(url, context, nextLoad) {
if (url.startsWith(TOOLING_ROOT)) {
const record = toolingModules.get(url.slice(TOOLING_ROOT.length));
if (!record) fail("verified tooling module is not retained");
return { format: record.format, source: record.bytes, shortCircuit: true };
}
if (url.startsWith(YAML_ROOT)) {
const bytes = yamlModules.get(url.slice(YAML_ROOT.length));
if (!bytes) fail("verified yaml module is not retained");
return { format: "commonjs", source: bytes, shortCircuit: true };
}
if (url.startsWith("node:")) return nextLoad(url, context);
fail("verified child rejected an external module load");
},
});
const identityResponses = new Map(payload.identityResponses);
if (identityResponses.size !== 1) fail("verified identity response cache must contain exactly one request");
const runGh = args => {
const key = JSON.stringify(args);
if (!identityResponses.has(key)) fail("verified child rejected an uncached GitHub request");
return identityResponses.get(key);
};
let parseYaml;
const parseYamlDocuments = sources => {
if (!Array.isArray(sources) || sources.length !== 3 || sources.some(value => typeof value !== "string")) fail("verified yaml parser input must contain three workflow strings");
if (!parseYaml) {
const yaml = createRequire(import.meta.url)(YAML_ABSOLUTE_ROOT + "/dist/index.js");
if (typeof yaml.parse !== "function") fail("verified yaml parser must export parse"); parseYaml = yaml.parse;
}
return sources.map(parseYaml);
};
const core = await import(toolingUrl(CORE_PATH)), value = core.runReleasePlanProducerOperation(payload.request, { runGh, parseYamlDocuments });
process.stdout.write(serialize({ ok: true, value }));
} catch (error) {
process.stdout.write(serialize({ ok: false, message: error instanceof Error ? error.message : String(error) }));
}
`;
const gitBytes = (repoRoot: string, args: string[]) =>
execFileSync("git", args, {
cwd: repoRoot,
encoding: null,
maxBuffer: 16 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
function requireSha(value: string, label: string) {
if (!/^[a-f0-9]{40}$/u.test(value)) {
throw new Error(`${label} must be an exact lowercase 40-character commit SHA`);
}
return value;
}
function defaultRunGh(args: string[]) {
return execFileSync("gh", args, {
encoding: "utf8",
killSignal: "SIGKILL",
maxBuffer: 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
timeout: 60_000,
});
}
function verifyRemoteTooling(params: ReleasePlanSource, runGh: RunGh) {
const sha = requireSha(params.toolingSha, "tooling SHA");
const tagRef = params.toolingFullRef.replace(/^refs\/tags\//u, "");
const protectedMatch = PROTECTED_TAG_PATTERN.exec(tagRef);
let args: string[], failure: string;
if (protectedMatch) {
if (protectedMatch[1] !== sha.slice(0, 12)) {
throw new Error("protected release tooling tag SHA prefix does not match the workflow SHA");
}
args = ["api", `repos/${REPOSITORY}/git/ref/tags/${tagRef}`, "--method", "GET"];
failure = "protected release tooling tag is missing or unreadable";
} else if (params.toolingFullRef === "refs/heads/main") {
args = ["api", `repos/${REPOSITORY}/compare/${sha}...main`, "--method", "GET"];
failure = "main release tooling ancestry could not be verified";
} else {
throw new Error("release tooling identity must be trusted main or an exact protected tag");
}
let raw: string;
try {
raw = runGh(args);
} catch (error) {
throw new Error(failure, { cause: error });
}
const response = JSON.parse(raw) as {
ref?: unknown;
status?: unknown;
object?: { type?: unknown; sha?: unknown };
};
if (
protectedMatch &&
(response.ref !== params.toolingFullRef ||
response.object?.type !== "commit" ||
response.object.sha !== sha)
) {
throw new Error(
"protected release tooling tag is missing, moved, annotated, or bound to the wrong SHA",
);
}
if (!protectedMatch && response.status !== "ahead" && response.status !== "identical") {
throw new Error("main release tooling SHA is not reachable from current main");
}
if (params.intent !== "diagnostic" && params.intent !== "main-qualification" && !protectedMatch) {
throw new Error(`${params.intent} tooling must use a release-publish tag bound to its SHA`);
}
return [[JSON.stringify(args), raw]] as Array<[string, string]>;
}
function readGitFile(repoRoot: string, sha: string, path: string) {
const entry = gitBytes(repoRoot, ["ls-tree", sha, "--", path]).toString("utf8").trim();
if (!/^100(?:644|755) blob [a-f0-9]{40}\t/u.test(entry)) {
throw new Error(`tooling closure path must be a regular Git blob: ${path}`);
}
return gitBytes(repoRoot, ["show", `${sha}:${path}`]);
}
function gitPathExists(repoRoot: string, sha: string, path: string) {
try {
execFileSync("git", ["cat-file", "-e", `${sha}:${path}`], { cwd: repoRoot, stdio: "ignore" });
return true;
} catch {
return false;
}
}
function collectLiteralImports(source: string) {
return [
...new Set(
[...source.matchAll(/\b(?:from\s+|import\s*(?:\(\s*)?)["']([^"']+)["']/gu)].map(
(match) => match[1]!,
),
),
].toSorted(compareAscii);
}
function resolveImport(repoRoot: string, sha: string, sourcePath: string, specifier: string) {
if (!specifier.startsWith(".") || specifier.startsWith("file:")) {
throw new Error(`tooling closure contains an unowned import: ${sourcePath} -> ${specifier}`);
}
const importedPath = posix.normalize(posix.join(posix.dirname(sourcePath), specifier));
if (importedPath === ".." || importedPath.startsWith("../")) {
throw new Error(`tooling import escapes repository root: ${sourcePath} -> ${specifier}`);
}
const candidates = new Set([importedPath]);
if (importedPath.endsWith(".js")) {
candidates.add(`${importedPath.slice(0, -3)}.ts`);
} else if (importedPath.endsWith(".mjs")) {
candidates.add(`${importedPath.slice(0, -4)}.mts`);
} else if (!posix.extname(importedPath)) {
for (const suffix of [".ts", ".mts", ".mjs", "/index.ts"]) {
candidates.add(`${importedPath}${suffix}`);
}
}
const existing = [...candidates].filter((path) => gitPathExists(repoRoot, sha, path));
if (existing.length !== 1) {
throw new Error(
`tooling import must resolve to one unambiguous file: ${sourcePath} -> ${specifier}`,
);
}
const [target] = existing;
if (!target || !TOOLING_MODULE_PATHS.includes(target as (typeof TOOLING_MODULE_PATHS)[number])) {
throw new Error(`tooling import is outside the fixed closure: ${sourcePath} -> ${specifier}`);
}
return target;
}
function retainToolingClosure(repoRoot: string, sha: string) {
const pending = [CORE_PATH];
const modules = new Map<string, ToolingModule>();
let totalBytes = 0;
while (pending.length > 0) {
const path = pending.pop();
if (!path || modules.has(path)) {
continue;
}
const bytes = readGitFile(repoRoot, sha, path);
totalBytes += bytes.byteLength;
if (bytes.byteLength > MAX_TOOLING_FILE_BYTES || totalBytes > MAX_TOOLING_BYTES) {
throw new Error("tooling closure exceeds its retained-byte bounds");
}
const imports: Array<[string, string]> = [];
for (const specifier of collectLiteralImports(bytes.toString("utf8"))) {
if (isBuiltin(specifier)) {
continue;
}
const target = resolveImport(repoRoot, sha, path, specifier);
imports.push([specifier, target]);
pending.push(target);
}
modules.set(path, { path, bytes, imports });
}
const retained = [...modules.values()].toSorted((left, right) =>
compareAscii(left.path, right.path),
);
if (
retained.length !== TOOLING_MODULE_PATHS.length ||
retained.some(
(record, index) => record.path !== [...TOOLING_MODULE_PATHS].toSorted(compareAscii)[index],
)
) {
throw new Error("tooling closure does not match the fixed allowlist");
}
return retained;
}
function assertSafeYamlPath(path: string) {
const components = new Set(path.split("/"));
if (
!path ||
!/^[\x20-\x7e]+$/u.test(path) ||
path.includes("\\") ||
posix.isAbsolute(path) ||
components.has(".") ||
components.has("..")
) {
throw new Error(`installed yaml package contains an unsafe path: ${JSON.stringify(path)}`);
}
}
function retainYamlPackage() {
if (typeof fs.constants.O_NOFOLLOW !== "number") {
throw new Error("installed yaml package verification requires O_NOFOLLOW support");
}
const packageRoot = fs.realpathSync(join(EXECUTION_ROOT, "node_modules", "yaml"));
if (!fs.lstatSync(packageRoot).isDirectory()) {
throw new Error("installed yaml package root must be a directory");
}
const entries: YamlEntry[] = [];
let fileCount = 0;
let totalBytes = 0;
const walk = (directory: string, relativeDirectory = "") => {
for (const name of fs.readdirSync(directory).toSorted(compareAscii)) {
const path = relativeDirectory ? `${relativeDirectory}/${name}` : name;
assertSafeYamlPath(path);
if (entries.length >= YAML_PACKAGE_MAX_ENTRIES) {
throw new Error(`installed yaml package exceeds ${YAML_PACKAGE_MAX_ENTRIES} entries`);
}
const absolutePath = join(directory, name);
const lstat = fs.lstatSync(absolutePath);
if (lstat.isSymbolicLink()) {
throw new Error(`installed yaml package must not contain symbolic links: ${path}`);
}
if (lstat.isDirectory()) {
entries.push({ kind: "directory", path });
walk(absolutePath, path);
continue;
}
if (!lstat.isFile()) {
throw new Error("installed yaml package must contain only directories and files");
}
fileCount += 1;
if (fileCount > YAML_PACKAGE_MAX_FILES) {
throw new Error(`installed yaml package exceeds ${YAML_PACKAGE_MAX_FILES} files`);
}
const descriptor = fs.openSync(absolutePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
let bytes: Buffer;
try {
const stat = fs.fstatSync(descriptor);
if (!stat.isFile()) {
throw new Error(`installed yaml package file changed type: ${path}`);
}
totalBytes += stat.size;
if (totalBytes > YAML_PACKAGE_MAX_BYTES) {
throw new Error(`installed yaml package exceeds ${YAML_PACKAGE_MAX_BYTES} bytes`);
}
bytes = fs.readFileSync(descriptor);
if (bytes.byteLength !== stat.size) {
throw new Error(`installed yaml package file changed while being read: ${path}`);
}
} finally {
fs.closeSync(descriptor);
}
entries.push({ kind: "file", path, bytes });
}
};
walk(packageRoot);
return entries;
}
const serializableParams = ({ runGh: _runGh, ...source }: ReleasePlanSource) =>
source as SerializableSource;
function runOperation(request: ProducerRequest, params: ReleasePlanSource) {
const repoRoot = resolve(params.repoRoot ?? ".");
const runGh = params.runGh ?? defaultRunGh;
const identityResponses = verifyRemoteTooling(params, runGh);
const toolingSha = requireSha(params.toolingSha, "tooling SHA");
const executionHead = gitBytes(EXECUTION_ROOT, ["rev-parse", "HEAD"]).toString("utf8").trim();
if (executionHead !== toolingSha) {
throw new Error("tooling bootstrap checkout HEAD must equal tooling SHA");
}
const bootstrapBytes = readGitFile(repoRoot, toolingSha, BOOTSTRAP_PATH);
if (!fs.readFileSync(fileURLToPath(import.meta.url)).equals(bootstrapBytes)) {
throw new Error(`tooling bootstrap differs from tooling SHA: ${BOOTSTRAP_PATH}`);
}
let stdout: Buffer;
try {
stdout = execFileSync(process.execPath, ["--input-type=module", "-e", CHILD_RUNNER], {
cwd: repoRoot,
encoding: null,
env: {},
input: serialize({
identityResponses,
request,
toolingModules: retainToolingClosure(repoRoot, toolingSha),
yamlEntries: retainYamlPackage(),
}),
maxBuffer: 16 * 1024 * 1024,
stdio: ["pipe", "pipe", "pipe"],
});
} catch (error) {
throw new Error("verified release plan child failed", { cause: error });
}
const envelope = deserialize(stdout) as { ok?: unknown; value?: unknown; message?: unknown };
if (envelope.ok !== true) {
throw new Error(
typeof envelope.message === "string" ? envelope.message : "verified child failed",
);
}
return envelope.value;
}
export function produceReleasePlan(params: ReleasePlanSource): ReleasePlan {
return runOperation(
{ operation: "produce", params: serializableParams(params) },
params,
) as ReleasePlan;
}
export function verifyReleasePlanLock(lockJson: string, params: ReleasePlanSource) {
return runOperation(
{ operation: "verify-lock", lockJson, params: serializableParams(params) },
params,
) as ReleasePlanLock;
}
function requiredOption(args: string[], name: string) {
const index = args.indexOf(name);
const value = index >= 0 ? args[index + 1] : undefined;
if (!value || value.startsWith("-")) {
throw new Error(`${name} is required`);
}
return value;
}
function main() {
const args = process.argv.slice(2);
const intent = requiredOption(args, "--intent") as ReleasePlanIntent;
if (!["publish", "diagnostic", "postpublish-confidence", "main-qualification"].includes(intent)) {
throw new Error(
"--intent must be publish, diagnostic, postpublish-confidence, or main-qualification",
);
}
const source = {
candidateSha: requiredOption(args, "--candidate-sha"),
candidateRef: requiredOption(args, "--candidate-ref"),
toolingSha: requiredOption(args, "--tooling-sha"),
toolingFullRef: requiredOption(args, "--tooling-full-ref"),
};
const params = {
...source,
intent,
...(intent === "main-qualification"
? {
validationIntent: requiredOption(
args,
"--validation-intent",
) as MainQualificationValidationIntent,
}
: {}),
} as ReleasePlanSource;
process.stdout.write(
runOperation(
{ operation: "produce-lock", params: serializableParams(params) },
params,
) as string,
);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
console.error("[release-plan-producer] FAILED (exit 1)");
process.exitCode = 1;
}
}