Files
openclaw/scripts/release-plan-contract.mjs
T
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

443 lines
16 KiB
JavaScript

import { createHash } from "node:crypto";
import { isRecord } from "./lib/record-shared.mjs";
import { parseReleaseVersion } from "./lib/release-version.mjs";
import {
releaseValidationIntentForPurpose,
resolveReleaseValidationIntent,
} from "./release-validation-intent.mjs";
export const RELEASE_PLAN_SCHEMA = "openclaw.release-plan.v1";
const RELEASE_PLAN_LOCK_SCHEMA = "openclaw.release-plan-lock.v1";
export const RELEASE_PLAN_CANONICALIZATION = "ascii-sorted-compact-json-trailing-newline-v1";
const RELEASE_PLAN_MAX_BYTES = 32 * 1024;
const SHA_PATTERN = /^[a-f0-9]{40}$/u;
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
const ASCII_PATTERN = /^[\x20-\x7e]+$/u;
const REPOSITORY = "openclaw/openclaw";
const WORKFLOW_PATH = ".github/workflows/full-release-validation.yml";
const PACKAGE_TARGETS = new Set(["clawhub", "npm"]);
const compareAscii = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
function fail(message) {
throw new Error(message);
}
function exactKeys(value, keys, label) {
const actual = Object.keys(value).toSorted(compareAscii);
const expected = [...keys].toSorted(compareAscii);
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
fail(`${label} keys must be exactly: ${expected.join(", ")}`);
}
}
function asciiString(value, label) {
if (typeof value !== "string" || !ASCII_PATTERN.test(value)) {
fail(`${label} must be a non-empty printable ASCII string`);
}
return value;
}
function sha(value, label) {
const normalized = asciiString(value, label);
if (!SHA_PATTERN.test(normalized)) {
fail(`${label} must be a lowercase 40-character commit SHA`);
}
return normalized;
}
function digest(value, label) {
if (typeof value !== "string" || !DIGEST_PATTERN.test(value)) {
fail(`${label} must be sha256:<64 lowercase hex characters>`);
}
return value;
}
function sortedUniqueStrings(value, label) {
if (!Array.isArray(value) || value.length === 0) {
fail(`${label} must be a non-empty array`);
}
const result = value.map((entry, index) => asciiString(entry, `${label}[${index}]`));
if (
new Set(result).size !== result.length ||
result.some((entry, index) => index > 0 && compareAscii(result[index - 1], entry) >= 0)
) {
fail(`${label} must contain unique strings in ascending ASCII order`);
}
return result;
}
function sortedUniqueEnumStrings(value, allowed, label) {
const result = sortedUniqueStrings(value, label);
const unsupported = result.find((entry) => !allowed.has(entry));
if (unsupported) {
fail(`${label} contains unsupported value: ${unsupported}`);
}
return result;
}
function canonicalPath(parent, key) {
return `${parent}[${JSON.stringify(key)}]`;
}
function canonicalize(value, path = "$", ancestors = new Set()) {
if (value === null || typeof value === "string" || typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) {
fail(`canonical JSON number at ${path} must be finite`);
}
if (Object.is(value, -0)) {
fail(`canonical JSON number at ${path} must not be negative zero`);
}
return value;
}
if (typeof value !== "object") {
fail(`canonical JSON contains unsupported ${typeof value} at ${path}`);
}
if (ancestors.has(value)) {
fail(`canonical JSON must not contain cycles at ${path}`);
}
ancestors.add(value);
try {
if (Array.isArray(value)) {
const keys = Reflect.ownKeys(value).filter((key) => key !== "length");
if (
keys.length !== value.length ||
keys.some((key, index) => typeof key !== "string" || key !== String(index))
) {
fail(`canonical JSON array at ${path} must be dense and contain no extra properties`);
}
return keys.map((key) => {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor?.enumerable || !("value" in descriptor)) {
fail(`canonical JSON array at ${path} must contain enumerable data properties only`);
}
return canonicalize(descriptor.value, canonicalPath(path, key), ancestors);
});
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
fail(`canonical JSON object at ${path} must be plain`);
}
const keys = Reflect.ownKeys(value);
if (keys.some((key) => typeof key !== "string")) {
fail(`canonical JSON object at ${path} must use string keys only`);
}
return Object.fromEntries(
keys.toSorted(compareAscii).map((key) => {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor?.enumerable || !("value" in descriptor)) {
fail(`canonical JSON object at ${path} must contain enumerable data properties only`);
}
return [key, canonicalize(descriptor.value, canonicalPath(path, key), ancestors)];
}),
);
} finally {
ancestors.delete(value);
}
}
function canonicalAsciiJson(value) {
const json = `${JSON.stringify(canonicalize(value))}\n`;
if (!/^[\x20-\x7e]+\n$/u.test(json)) {
fail("canonical JSON must be printable ASCII with exactly one trailing newline");
}
return json;
}
function assertNoDuplicateJsonKeys(text) {
const tokenPattern = new RegExp(
String.raw`"(?:\\(?:["\\/bfnrt]|u[a-fA-F0-9]{4})|[^"\\\u0000-\u001f])*"|[{}\[\],:]|true|false|null|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?|\s+`,
"guy",
);
const stack = [];
for (let index = 0; index < text.length;) {
tokenPattern.lastIndex = index;
const match = tokenPattern.exec(text);
if (!match) {
fail("release plan lock JSON is invalid JSON");
}
index = tokenPattern.lastIndex;
const token = match[0];
if (/^\s+$/u.test(token)) {
continue;
}
const current = stack.at(-1);
if (token === "{") {
stack.push({ keys: new Set(), expectingKey: true });
} else if (token === "[") {
stack.push(null);
} else if (token === "}" || token === "]") {
stack.pop();
} else if (token === "," && current !== null && current !== undefined) {
current.expectingKey = true;
} else if (token === ":" && current !== null && current !== undefined) {
current.expectingKey = false;
} else if (token.startsWith('"') && current?.expectingKey) {
const key = JSON.parse(token);
if (current.keys.has(key)) {
fail("release plan JSON contains a duplicate key");
}
current.keys.add(key);
current.expectingKey = false;
}
}
}
function validatePackages(value) {
if (!Array.isArray(value) || value.length === 0) {
fail("release plan packages must be a non-empty array");
}
const packages = value.map((entry, index) => {
if (!isRecord(entry)) {
fail(`release plan packages[${index}] must be an object`);
}
exactKeys(entry, ["name", "version", "targets"], `release plan packages[${index}]`);
return {
name: asciiString(entry.name, `release plan packages[${index}].name`),
version: asciiString(entry.version, `release plan packages[${index}].version`),
targets: sortedUniqueEnumStrings(
entry.targets,
PACKAGE_TARGETS,
`release plan packages[${index}].targets`,
),
};
});
const names = packages.map((entry) => entry.name);
if (
new Set(names).size !== names.length ||
names.some((entry, index) => index > 0 && compareAscii(names[index - 1], entry) >= 0)
) {
fail("release plan packages must have unique names in ascending ASCII order");
}
return packages;
}
function validatePlatforms(value) {
if (!Array.isArray(value) || value.length === 0) {
fail("release plan platforms must be a non-empty array");
}
const platforms = value.map((entry, index) => {
if (!isRecord(entry)) {
fail(`release plan platforms[${index}] must be an object`);
}
exactKeys(entry, ["id", "source"], `release plan platforms[${index}]`);
return {
id: asciiString(entry.id, `release plan platforms[${index}].id`),
source: asciiString(entry.source, `release plan platforms[${index}].source`),
};
});
const ids = platforms.map((entry) => entry.id);
if (
new Set(ids).size !== ids.length ||
ids.some((entry, index) => index > 0 && compareAscii(ids[index - 1], entry) >= 0)
) {
fail("release plan platforms must have unique ids in ascending ASCII order");
}
return platforms;
}
function validatePurposeMatrix({ candidateSha, purpose, tag, targetContextRef, version }) {
const parsedVersion = parseReleaseVersion(version);
if (parsedVersion === null || parsedVersion.version !== version) {
fail("release plan version must use a supported release version");
}
if (purpose === "beta-publish" && parsedVersion.channel === "stable") {
fail("beta-publish release plan version must be alpha or beta");
}
if (purpose === "stable-publish" && parsedVersion.channel !== "stable") {
fail("stable-publish release plan version must be stable");
}
if (purpose === "diagnostic" || purpose === "main-qualification") {
if (tag !== null || targetContextRef !== candidateSha) {
fail(`${purpose} release plans require a null tag and candidate SHA context`);
}
return;
}
const expectedTag = `v${version}`;
if (tag !== expectedTag || targetContextRef !== `refs/tags/${expectedTag}`) {
fail(`${purpose} release plans require the exact version tag context`);
}
}
function validateToolingRoute(purpose, ref, toolingSha) {
const protectedMatch = /^refs\/tags\/release-publish\/([a-f0-9]{12})-[1-9][0-9]*$/u.exec(ref);
const protectedRoute = protectedMatch?.[1] === toolingSha.slice(0, 12);
if (purpose === "diagnostic" || purpose === "main-qualification") {
if (ref !== "refs/heads/main" && !protectedRoute) {
fail(`${purpose} tooling must use trusted main or a protected release-publish tag`);
}
return;
}
if (!protectedRoute) {
fail(`${purpose} tooling must use a protected release-publish tag bound to its SHA`);
}
}
export function validateReleasePlan(value) {
canonicalize(value);
if (!isRecord(value)) {
fail("release plan must be an object");
}
exactKeys(
value,
[
"schema",
"release_id",
"version",
"tag",
"candidate_sha",
"target_context_ref",
"purpose",
"tooling",
"validation",
"inventory",
],
"release plan",
);
if (value.schema !== RELEASE_PLAN_SCHEMA) {
fail(`release plan schema must be ${RELEASE_PLAN_SCHEMA}`);
}
const purpose = asciiString(value.purpose, "release plan purpose");
const version = asciiString(value.version, "release plan version");
const releaseId = asciiString(value.release_id, "release plan release_id");
if (releaseId !== version) {
fail("release plan release_id must equal version");
}
const tag = value.tag === null ? null : asciiString(value.tag, "release plan tag");
const candidateSha = sha(value.candidate_sha, "release plan candidate SHA");
const targetContextRef = asciiString(value.target_context_ref, "release plan target_context_ref");
validatePurposeMatrix({ candidateSha, purpose, tag, targetContextRef, version });
if (!isRecord(value.tooling)) {
fail("release plan tooling must be an object");
}
exactKeys(value.tooling, ["repository", "workflow_path", "ref", "sha"], "release plan tooling");
const toolingRef = asciiString(value.tooling.ref, "release plan tooling ref");
if (!/^refs\/(?:heads|tags)\/[A-Za-z0-9._/-]+$/u.test(toolingRef)) {
fail("release plan tooling ref must be a qualified branch or tag ref");
}
if (!isRecord(value.validation)) {
fail("release plan validation must be an object");
}
exactKeys(
value.validation,
["intent", "profile", "soak", "allowed_groups"],
"release plan validation",
);
const intent = asciiString(value.validation.intent, "release plan validation intent");
releaseValidationIntentForPurpose(purpose, intent);
const profile = asciiString(value.validation.profile, "release plan validation profile");
if (typeof value.validation.soak !== "boolean") {
fail("release plan validation soak must be boolean");
}
const validationPolicy = resolveReleaseValidationIntent(intent, {
profile,
soak: value.validation.soak,
});
if (!isRecord(value.inventory)) {
fail("release plan inventory must be an object");
}
exactKeys(value.inventory, ["packages", "platforms"], "release plan inventory");
const plan = {
schema: RELEASE_PLAN_SCHEMA,
release_id: releaseId,
version,
tag,
candidate_sha: candidateSha,
target_context_ref: targetContextRef,
purpose,
tooling: {
repository: asciiString(value.tooling.repository, "release plan tooling repository"),
workflow_path: asciiString(value.tooling.workflow_path, "release plan tooling workflow_path"),
ref: toolingRef,
sha: sha(value.tooling.sha, "release plan tooling SHA"),
},
validation: {
intent: validationPolicy.intent,
profile: validationPolicy.profile,
soak: validationPolicy.soak,
allowed_groups: sortedUniqueStrings(
value.validation.allowed_groups,
"release plan validation allowed_groups",
),
},
inventory: {
packages: validatePackages(value.inventory.packages),
platforms: validatePlatforms(value.inventory.platforms),
},
};
if (plan.tooling.repository !== REPOSITORY) {
fail(`release plan tooling repository must be ${REPOSITORY}`);
}
if (plan.tooling.workflow_path !== WORKFLOW_PATH) {
fail(`release plan tooling workflow_path must be ${WORKFLOW_PATH}`);
}
validateToolingRoute(plan.purpose, plan.tooling.ref, plan.tooling.sha);
if (Buffer.byteLength(canonicalAsciiJson(plan), "ascii") > RELEASE_PLAN_MAX_BYTES) {
fail(`release plan exceeds ${RELEASE_PLAN_MAX_BYTES} bytes`);
}
return plan;
}
export function canonicalReleasePlanJson(value) {
return canonicalAsciiJson(validateReleasePlan(value));
}
function releasePlanDigest(value) {
return `sha256:${createHash("sha256").update(canonicalReleasePlanJson(value), "ascii").digest("hex")}`;
}
export function createReleasePlanLock(value) {
const plan = validateReleasePlan(value);
return {
schema: RELEASE_PLAN_LOCK_SCHEMA,
digest: releasePlanDigest(plan),
plan,
};
}
function validateReleasePlanLock(value) {
canonicalize(value);
if (!isRecord(value)) {
fail("release plan lock must be an object");
}
exactKeys(value, ["schema", "digest", "plan"], "release plan lock");
if (value.schema !== RELEASE_PLAN_LOCK_SCHEMA) {
fail(`release plan lock schema must be ${RELEASE_PLAN_LOCK_SCHEMA}`);
}
const plan = validateReleasePlan(value.plan);
const planDigest = digest(value.digest, "release plan lock digest");
if (planDigest !== releasePlanDigest(plan)) {
fail("release plan lock digest does not match its canonical plan");
}
return { schema: RELEASE_PLAN_LOCK_SCHEMA, digest: planDigest, plan };
}
export function canonicalReleasePlanLockJson(value) {
return canonicalAsciiJson(validateReleasePlanLock(value));
}
export function parseReleasePlanLockJson(text) {
if (typeof text !== "string" || Buffer.byteLength(text, "utf8") > RELEASE_PLAN_MAX_BYTES + 4096) {
fail("release plan lock JSON is missing or too large");
}
if (!/^[\x20-\x7e]+\n$/u.test(text)) {
fail("release plan lock JSON must be compact printable ASCII with exactly one trailing LF");
}
assertNoDuplicateJsonKeys(text);
let value;
try {
value = JSON.parse(text);
} catch (error) {
throw new Error("release plan lock JSON is invalid JSON", { cause: error });
}
const lock = validateReleasePlanLock(value);
if (text !== canonicalReleasePlanLockJson(lock)) {
fail("release plan lock JSON does not use canonical bytes");
}
return lock;
}