Files
openclaw/scripts/release-plan-producer-core.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

634 lines
23 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { collectExtensionPackageJsonCandidates } from "./lib/plugin-publication-candidates.ts";
import {
collectPublishablePluginPackagesFromCandidates,
type PluginPackageJson,
} from "./lib/plugin-publication-collector.ts";
import { parseReleaseVersion } from "./lib/release-version.mjs";
import {
canonicalReleasePlanJson,
canonicalReleasePlanLockJson,
createReleasePlanLock,
parseReleasePlanLockJson,
RELEASE_PLAN_SCHEMA,
validateReleasePlan,
type ReleasePlan,
type ReleasePlanLock,
type ReleasePlanPurpose,
} from "./release-plan-contract.mjs";
import { verifyReleaseToolingIdentity } from "./release-tooling-identity.mjs";
import {
releaseValidationIntentForPurpose,
resolveReleaseValidationIntent,
type ReleaseValidationIntent,
} from "./release-validation-intent.mjs";
type ReleasePlanIntent = "publish" | "diagnostic" | "postpublish-confidence" | "main-qualification";
type MainQualificationValidationIntent = Extract<
ReleaseValidationIntent,
"main-daily" | "main-weekly"
>;
type ReleasePlanSource = {
repoRoot?: string;
candidateSha: string;
candidateRef: string;
toolingSha: string;
toolingFullRef: string;
runGh?: (args: string[]) => string;
intent: ReleasePlanIntent;
validationIntent?: MainQualificationValidationIntent;
};
type CorePackagePolicy = {
path: string;
dependency?: string;
};
type ReleasePlanRuntime = {
parseYamlDocuments: (sources: [string, string, string]) => [unknown, unknown, unknown];
runGh: (args: string[]) => string;
};
type ReleasePlanProducerRequest =
| { operation: "produce" | "produce-lock"; params: ReleasePlanSource }
| { operation: "verify-lock"; lockJson: string; params: ReleasePlanSource };
const REPOSITORY = "openclaw/openclaw";
const VALIDATION_WORKFLOW_PATH = ".github/workflows/full-release-validation.yml";
const PUBLICATION_WORKFLOW_PATH = ".github/workflows/openclaw-release-publish.yml";
const NPM_PUBLICATION_WORKFLOW_PATH = ".github/workflows/openclaw-npm-release.yml";
const YAML_PACKAGE_VERSION = "2.9.0";
const YAML_PACKAGE_INTEGRITY =
"sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==";
const compareAscii = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0);
function git(repoRoot: string, args: string[]): string {
return execFileSync("git", args, {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}
function resolveCommit(repoRoot: string, revision: string, label: string): string {
let resolved: string;
try {
resolved = git(repoRoot, ["rev-parse", "--verify", `${revision}^{commit}`]);
} catch {
throw new Error(`${label} does not resolve to a commit: ${revision}`);
}
if (!/^[a-f0-9]{40}$/u.test(resolved)) {
throw new Error(`${label} did not resolve to an exact lowercase commit SHA`);
}
return resolved;
}
function requireExactSha(value: string, label: string): 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 requireQualifiedRef(value: string, label: string): string {
if (!/^refs\/(?:heads|tags)\/[A-Za-z0-9._/-]+$/u.test(value)) {
throw new Error(`${label} must be a qualified branch or tag ref`);
}
return value;
}
function readGitBytes(repoRoot: string, commit: string, path: string): Buffer {
try {
return execFileSync("git", ["show", `${commit}:${path}`], {
cwd: repoRoot,
maxBuffer: 16 * 1024 * 1024,
});
} catch {
throw new Error(`${path} is missing from ${commit}`);
}
}
function readGitText(repoRoot: string, commit: string, path: string): string {
return readGitBytes(repoRoot, commit, path).toString("utf8");
}
function gitPathExists(repoRoot: string, commit: string, path: string): boolean {
try {
execFileSync("git", ["cat-file", "-e", `${commit}:${path}`], {
cwd: repoRoot,
stdio: "ignore",
});
return true;
} catch {
return false;
}
}
type LineRange = { start: number; end: number };
function findLockfileMapping(
lines: string[],
key: string,
indent: number,
scope: LineRange = { start: 0, end: lines.length },
): LineRange {
const declaration = `${" ".repeat(indent)}${key}:`;
const matches = lines
.slice(scope.start, scope.end)
.flatMap((line, index) => (line === declaration ? [scope.start + index] : []));
if (matches.length !== 1) {
throw new Error(`pnpm lockfile must declare exactly one ${key} mapping`);
}
const start = matches[0]!;
let end = scope.end;
for (let index = start + 1; index < scope.end; index += 1) {
const line = lines[index] ?? "";
if (line && line.length - line.trimStart().length <= indent) {
end = index;
break;
}
}
return { start, end };
}
function verifyYamlLockfile(lockfileText: string) {
const lines = lockfileText.split("\n");
const body = (range: LineRange) => lines.slice(range.start + 1, range.end);
if (lines.filter((line) => line === "lockfileVersion: '9.0'").length !== 1) {
throw new Error("pnpm lockfile must use lockfileVersion 9.0");
}
const importers = findLockfileMapping(lines, "importers", 0);
const rootImporter = findLockfileMapping(lines, ".", 2, importers);
const dependencies = findLockfileMapping(lines, "dependencies", 4, rootImporter);
const yamlImporter = findLockfileMapping(lines, "yaml", 6, dependencies);
const importerEntries = body(yamlImporter).filter(Boolean);
if (
importerEntries.length !== 2 ||
importerEntries[0] !== ` specifier: ${YAML_PACKAGE_VERSION}` ||
importerEntries[1] !== ` version: ${YAML_PACKAGE_VERSION}`
) {
throw new Error(`pnpm root importer must pin yaml exactly to ${YAML_PACKAGE_VERSION}`);
}
const packages = findLockfileMapping(lines, "packages", 0);
const yamlPackage = findLockfileMapping(lines, `yaml@${YAML_PACKAGE_VERSION}`, 2, packages);
const resolution = ` resolution: {integrity: ${YAML_PACKAGE_INTEGRITY}}`;
if (
body(yamlPackage)
.filter((line) => line.trimStart().startsWith("resolution:"))
.join("\n") !== resolution
) {
throw new Error(`pnpm lockfile must bind yaml@${YAML_PACKAGE_VERSION} to its exact integrity`);
}
const snapshots = findLockfileMapping(lines, "snapshots", 0);
if (
body(snapshots).filter((line) => line === ` yaml@${YAML_PACKAGE_VERSION}: {}`).length !== 1
) {
throw new Error(
`pnpm lockfile yaml@${YAML_PACKAGE_VERSION} snapshot must have no dependencies`,
);
}
}
function parseVerifiedYamlDocuments(
repoRoot: string,
toolingSha: string,
sources: [string, string, string],
runtime: ReleasePlanRuntime,
): [unknown, unknown, unknown] {
const packageJsonBytes = readGitBytes(repoRoot, toolingSha, "package.json");
const lockfileBytes = readGitBytes(repoRoot, toolingSha, "pnpm-lock.yaml");
let packageJson: { dependencies?: Record<string, unknown> };
try {
packageJson = JSON.parse(packageJsonBytes.toString("utf8")) as typeof packageJson;
} catch (error) {
throw new Error("tooling package.json is invalid JSON", { cause: error });
}
if (packageJson.dependencies?.yaml !== YAML_PACKAGE_VERSION) {
throw new Error(`tooling package.json must pin yaml exactly to ${YAML_PACKAGE_VERSION}`);
}
verifyYamlLockfile(lockfileBytes.toString("utf8"));
return runtime.parseYamlDocuments(sources);
}
function withCandidateSnapshot<T>(
repoRoot: string,
candidateSha: string,
callback: (snapshotRoot: string) => T,
): T {
const snapshotRoot = mkdtempSync(join(tmpdir(), "openclaw-release-candidate-"));
try {
const tree = execFileSync(
"git",
["ls-tree", "-r", "-z", candidateSha, "--", "package.json", "extensions", "packages"],
{ cwd: repoRoot },
).toString("utf8");
const inventoryPaths: string[] = [];
for (const entry of tree.split("\0").filter(Boolean)) {
const [metadata, path] = entry.split("\t");
if (
!path ||
(path !== "package.json" &&
!/^extensions\/[^/]+\/(?:package\.json|README\.md)$/u.test(path) &&
!/^packages\/[^/]+\/package\.json$/u.test(path))
) {
continue;
}
if (metadata?.startsWith("120000 ")) {
throw new Error("candidate package inventory must not contain symbolic links");
}
inventoryPaths.push(path);
}
if (!inventoryPaths.includes("package.json")) {
throw new Error("candidate package.json is missing");
}
const archivePath = join(snapshotRoot, "candidate.tar");
execFileSync(
"git",
["archive", "--format=tar", `--output=${archivePath}`, candidateSha, "--", ...inventoryPaths],
{ cwd: repoRoot },
);
execFileSync("tar", ["-xf", archivePath, "-C", snapshotRoot]);
mkdirSync(join(snapshotRoot, "extensions"), { recursive: true });
return callback(snapshotRoot);
} finally {
rmSync(snapshotRoot, { force: true, recursive: true });
}
}
export function deriveReleasePlanPolicy(
intent: ReleasePlanIntent,
version: string,
validationIntent?: MainQualificationValidationIntent,
) {
const parsed = parseReleaseVersion(version);
if (parsed === null || parsed.version !== version) {
throw new Error(`unsupported release version: ${version}`);
}
if (intent !== "main-qualification" && validationIntent !== undefined) {
throw new Error("validation intent is only valid for main-qualification");
}
const purpose: ReleasePlanPurpose | undefined =
intent === "main-qualification" ||
intent === "diagnostic" ||
intent === "postpublish-confidence"
? intent
: intent === "publish"
? parsed.channel === "stable"
? "stable-publish"
: "beta-publish"
: undefined;
if (!purpose) {
throw new Error("unsupported release plan intent");
}
const tag = intent === "main-qualification" || intent === "diagnostic" ? null : `v${version}`;
return {
...resolveReleaseValidationIntent(releaseValidationIntentForPurpose(purpose, validationIntent)),
purpose,
tag,
};
}
function collectAllowedGroups(workflowDocument: unknown): string[] {
const workflow = workflowDocument as {
on?: { workflow_dispatch?: { inputs?: { rerun_group?: { options?: unknown } } } };
};
const options = workflow.on?.workflow_dispatch?.inputs?.rerun_group?.options;
if (
!Array.isArray(options) ||
options.length === 0 ||
options.some((entry) => typeof entry !== "string" || !entry)
) {
throw new Error(`${VALIDATION_WORKFLOW_PATH} must declare rerun_group choice options`);
}
const groups = [...new Set(options)];
if (groups.length !== options.length) {
throw new Error(`${VALIDATION_WORKFLOW_PATH} rerun_group options must be unique`);
}
return groups.toSorted(compareAscii);
}
function readPackageManifest(path: string): PluginPackageJson {
return JSON.parse(readFileSync(path, "utf8")) as PluginPackageJson;
}
function collectCorePackagePolicy(
workflowText: string,
workflowDocument: unknown,
): CorePackagePolicy[] {
const workflow = workflowDocument as {
jobs?: Record<string, { steps?: Array<{ env?: { CORE_PACKAGE_DIRS?: unknown } }> }>;
};
const declarations = Object.values(workflow.jobs ?? {}).flatMap((job) =>
(job.steps ?? [])
.map((step) => step.env?.CORE_PACKAGE_DIRS)
.filter((value): value is string => typeof value === "string"),
);
const [declaration] = declarations;
if (declarations.length !== 1 || !declaration) {
throw new Error(`${NPM_PUBLICATION_WORKFLOW_PATH} must declare one CORE_PACKAGE_DIRS owner`);
}
const paths = declaration.trim().split(/\s+/u).filter(Boolean);
if (
paths.length === 0 ||
new Set(paths).size !== paths.length ||
paths.some((path) => !/^packages\/[a-z0-9-]+$/u.test(path))
) {
throw new Error(`${NPM_PUBLICATION_WORKFLOW_PATH} has invalid CORE_PACKAGE_DIRS`);
}
const dependencyGates = new Map<string, string>();
for (const match of workflowText.matchAll(
/\[\[ "\$package_dir" == "(packages\/[a-z0-9-]+)" \]\][^\n]*dependencies\?\.\["([^"]+)"\]/gu,
)) {
if (match[1] && match[2]) {
dependencyGates.set(match[1], match[2]);
}
}
for (const path of dependencyGates.keys()) {
if (!paths.includes(path)) {
throw new Error(`${NPM_PUBLICATION_WORKFLOW_PATH} gates an undeclared core package: ${path}`);
}
}
return paths
.map((path) => {
const dependency = dependencyGates.get(path);
return dependency ? { path, dependency } : { path };
})
.toSorted((left, right) => compareAscii(left.path, right.path));
}
function collectPackageInventory(
snapshotRoot: string,
rootManifest: PluginPackageJson,
corePackages: CorePackagePolicy[],
) {
const version = rootManifest.version;
if (typeof version !== "string" || !version) {
throw new Error("candidate package.json version is required");
}
const packages = new Map<
string,
{ name: string; source: string; version: string; targets: Set<string> }
>();
const addPackage = (manifest: PluginPackageJson, targets: string[], source: string) => {
if (typeof manifest.name !== "string" || typeof manifest.version !== "string") {
throw new Error(`${source} must declare package name and version`);
}
const existing = packages.get(manifest.name);
if (existing && existing.source !== source) {
throw new Error(
`package inventory source mismatch for ${manifest.name}: ${existing.source} and ${source}`,
);
}
if (existing && existing.version !== manifest.version) {
throw new Error(`package inventory version mismatch for ${manifest.name}`);
}
const entry = existing ?? {
name: manifest.name,
source,
version: manifest.version,
targets: new Set<string>(),
};
for (const target of targets) {
entry.targets.add(target);
}
packages.set(manifest.name, entry);
};
addPackage({ name: "openclaw", version }, ["npm"], "package.json");
const pluginCandidates = collectExtensionPackageJsonCandidates(snapshotRoot);
for (const [target, plugins] of [
["clawhub", collectPublishablePluginPackagesFromCandidates(pluginCandidates, "clawhub")],
["npm", collectPublishablePluginPackagesFromCandidates(pluginCandidates, "npm")],
] as const) {
for (const plugin of plugins) {
addPackage(
{ name: plugin.packageName, version: plugin.version },
[target],
`${plugin.packageDir}/package.json`,
);
}
}
for (const policy of corePackages) {
const manifestPath = join(snapshotRoot, policy.path, "package.json");
if (!existsSync(manifestPath)) {
if (policy.dependency && typeof rootManifest.dependencies?.[policy.dependency] === "string") {
throw new Error(
`publishable core package manifest is missing: ${policy.path}/package.json`,
);
}
continue;
}
const manifest = readPackageManifest(manifestPath);
if (policy.dependency && typeof rootManifest.dependencies?.[policy.dependency] !== "string") {
continue;
}
if (!policy.dependency && manifest.openclaw?.release?.publishToNpm !== true) {
continue;
}
if (manifest.version !== version) {
throw new Error(`${policy.path} version must match openclaw ${version}`);
}
addPackage(manifest, ["npm"], `${policy.path}/package.json`);
}
return [...packages.values()]
.map((entry) => ({
name: entry.name,
version: entry.version,
targets: [...entry.targets].toSorted(compareAscii),
}))
.toSorted((left, right) => compareAscii(left.name, right.name));
}
function collectPlatformSources(workflowText: string, workflowDocument: unknown) {
const platforms = new Map<string, string>();
const addPlatform = (id: string, source: string) => {
const existing = platforms.get(id);
if (existing && existing !== source) {
throw new Error(
`${PUBLICATION_WORKFLOW_PATH} declares conflicting platform ${id}: ${existing} and ${source}`,
);
}
platforms.set(id, source);
};
const promotionPattern = /promote_([a-z0-9_]+)_release_assets?\(\)\s*\{([\s\S]*?)^\s*\}/gmu;
const dispatchPattern =
/dispatch_workflow(?:_at_ref)?\s+(?:(?:"[^"]+"|'[^']+')\s+){0,2}([a-z0-9][a-z0-9-]+\.yml)/u;
for (const match of workflowText.matchAll(promotionPattern)) {
const id = match[1]?.replaceAll("_", "-");
const workflowName = dispatchPattern.exec(match[2] ?? "")?.[1];
if (!id || !workflowName) {
throw new Error(`${PUBLICATION_WORKFLOW_PATH} has an invalid platform promotion function`);
}
addPlatform(id, `.github/workflows/${workflowName}`);
}
const workflow = workflowDocument as {
jobs?: Record<string, { uses?: unknown }>;
};
for (const [jobId, job] of Object.entries(workflow.jobs ?? {})) {
if (!jobId.startsWith("publish_") || typeof job.uses !== "string") {
continue;
}
const match = /^\.\/\.github\/workflows\/([a-z0-9][a-z0-9-]+\.yml)$/u.exec(job.uses);
if (!match?.[1]) {
throw new Error(`${PUBLICATION_WORKFLOW_PATH} has an invalid reusable publication workflow`);
}
addPlatform(
jobId.slice("publish_".length).replaceAll("_", "-"),
`.github/workflows/${match[1]}`,
);
}
if (platforms.size === 0) {
throw new Error(`${PUBLICATION_WORKFLOW_PATH} does not declare platform publication workflows`);
}
return [...platforms.entries()].toSorted(([left], [right]) => compareAscii(left, right));
}
function collectPlatformInventory(
repoRoot: string,
toolingSha: string,
workflowText: string,
workflowDocument: unknown,
) {
return collectPlatformSources(workflowText, workflowDocument).map(([id, source]) => {
if (!gitPathExists(repoRoot, toolingSha, source)) {
throw new Error(`release platform workflow does not exist at tooling SHA: ${source}`);
}
return { id, source };
});
}
function readCandidateInventory(
repoRoot: string,
candidateSha: string,
corePackages: CorePackagePolicy[],
) {
return withCandidateSnapshot(repoRoot, candidateSha, (snapshotRoot) => {
const rootPackage = readPackageManifest(join(snapshotRoot, "package.json"));
if (typeof rootPackage.version !== "string" || !rootPackage.version) {
throw new Error("candidate package.json version is required");
}
return {
version: rootPackage.version,
packages: collectPackageInventory(snapshotRoot, rootPackage, corePackages),
};
});
}
function resolveSource(params: ReleasePlanSource) {
const repoRoot = resolve(params.repoRoot ?? ".");
const candidateSha = requireExactSha(params.candidateSha, "candidate SHA");
const toolingSha = requireExactSha(params.toolingSha, "tooling SHA");
const toolingFullRef = requireQualifiedRef(params.toolingFullRef, "tooling full ref");
if (resolveCommit(repoRoot, candidateSha, "candidate SHA") !== candidateSha) {
throw new Error("candidate SHA does not resolve to itself");
}
const toolingRef = toolingFullRef.replace(/^refs\/(?:heads|tags)\//u, "");
const verifiedTooling = verifyReleaseToolingIdentity({
repository: REPOSITORY,
workflowFullRef: toolingFullRef,
workflowRef: toolingRef,
workflowSha: toolingSha,
...(params.runGh ? { runGh: params.runGh } : {}),
});
if (
params.intent !== "diagnostic" &&
params.intent !== "main-qualification" &&
verifiedTooling.route !== "protected-tag"
) {
throw new Error(`${params.intent} tooling must use a release-publish tag bound to its SHA`);
}
return { candidateSha, repoRoot, toolingFullRef, toolingSha };
}
function produceReleasePlan(params: ReleasePlanSource, runtime: ReleasePlanRuntime): ReleasePlan {
const { candidateSha, repoRoot, toolingFullRef, toolingSha } = resolveSource(params);
const validationWorkflow = readGitText(repoRoot, toolingSha, VALIDATION_WORKFLOW_PATH);
const publicationWorkflow = readGitText(repoRoot, toolingSha, PUBLICATION_WORKFLOW_PATH);
const npmPublicationWorkflow = readGitText(repoRoot, toolingSha, NPM_PUBLICATION_WORKFLOW_PATH);
const [validationDocument, publicationDocument, npmPublicationDocument] =
parseVerifiedYamlDocuments(
repoRoot,
toolingSha,
[validationWorkflow, publicationWorkflow, npmPublicationWorkflow],
runtime,
);
const candidate = readCandidateInventory(
repoRoot,
candidateSha,
collectCorePackagePolicy(npmPublicationWorkflow, npmPublicationDocument),
);
const policy = deriveReleasePlanPolicy(params.intent, candidate.version, params.validationIntent);
// ReleasePlan binds the candidate bytes. A branch used only to make the FRV
// workflow reachable is dispatch state and must not become plan authority.
const expectedCandidateRef =
params.intent === "diagnostic" || params.intent === "main-qualification"
? candidateSha
: `refs/tags/v${candidate.version}`;
if (params.candidateRef !== expectedCandidateRef) {
throw new Error(`${params.intent} candidate ref must be ${expectedCandidateRef}`);
}
if (
params.intent === "postpublish-confidence" &&
resolveCommit(repoRoot, params.candidateRef, "published candidate tag") !== candidateSha
) {
throw new Error("published candidate tag does not resolve to the candidate SHA");
}
return validateReleasePlan({
schema: RELEASE_PLAN_SCHEMA,
release_id: candidate.version,
version: candidate.version,
tag: policy.tag,
candidate_sha: candidateSha,
target_context_ref: expectedCandidateRef,
purpose: policy.purpose,
tooling: {
repository: REPOSITORY,
workflow_path: VALIDATION_WORKFLOW_PATH,
ref: toolingFullRef,
sha: toolingSha,
},
validation: {
intent: policy.intent,
profile: policy.profile,
soak: policy.soak,
allowed_groups: collectAllowedGroups(validationDocument),
},
inventory: {
packages: candidate.packages,
platforms: collectPlatformInventory(
repoRoot,
toolingSha,
publicationWorkflow,
publicationDocument,
),
},
});
}
function verifyReleasePlanLock(
lockJson: string,
params: ReleasePlanSource,
runtime: ReleasePlanRuntime,
): ReleasePlanLock {
const expectedPlan = produceReleasePlan(params, runtime);
const lock = parseReleasePlanLockJson(lockJson);
if (canonicalReleasePlanJson(lock.plan) !== canonicalReleasePlanJson(expectedPlan)) {
throw new Error("release plan does not match repository-derived authority");
}
return lock;
}
export function runReleasePlanProducerOperation(
request: ReleasePlanProducerRequest,
runtime: ReleasePlanRuntime,
): ReleasePlan | ReleasePlanLock | string {
const params = { ...request.params, runGh: runtime.runGh };
if (request.operation === "produce") {
return produceReleasePlan(params, runtime);
}
if (request.operation === "verify-lock") {
return verifyReleasePlanLock(request.lockJson, params, runtime);
}
return canonicalReleasePlanLockJson(createReleasePlanLock(produceReleasePlan(params, runtime)));
}