mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-22 10:25:20 -06:00
1da74794b6
* 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
443 lines
16 KiB
JavaScript
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;
|
|
}
|