fix(release): harden immutable plan authority

This commit is contained in:
Vincent Koc
2026-08-20 22:24:52 -07:00
parent 2d40311753
commit 58a7d0d446
12 changed files with 779 additions and 450 deletions
+9 -10
View File
@@ -428,7 +428,7 @@ export function validateParentManifest(value, expected) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("release validation manifest must be an object");
}
if (![2, 3, 4].includes(value.version) || value.workflowName !== "Full Release Validation") {
if (![2, 3].includes(value.version) || value.workflowName !== "Full Release Validation") {
throw new Error("release validation manifest schema is unsupported");
}
if (String(value.runId) !== String(expected.runId)) {
@@ -450,7 +450,7 @@ export function validateParentManifest(value, expected) {
let workflowSha;
let workflowFullRef;
let workflowRefType;
if (value.version >= 3) {
if (value.version === 3) {
workflowSha = normalizeSha(value.workflowSha, "release validation manifest workflow SHA");
if (expected.workflowSha !== undefined && workflowSha !== expected.workflowSha) {
throw new Error("release validation manifest workflow SHA mismatch");
@@ -478,7 +478,7 @@ export function validateParentManifest(value, expected) {
throw new Error("release validation manifest release soak value is invalid");
}
const controls = normalizeJsonObject(value.controls, "release validation manifest controls");
if (value.version >= 3 && controls.performanceReportPublication !== "artifact-only") {
if (value.version === 3 && controls.performanceReportPublication !== "artifact-only") {
throw new Error("release validation manifest performance report publication mode is invalid");
}
const validationInputs =
@@ -1210,8 +1210,8 @@ export function validateTrustedProducerIdentity(
);
}
if (shaPinned) {
if (manifest.version < 3) {
throw new Error("SHA-pinned release evidence requires a v3 or v4 manifest");
if (manifest.version !== 3) {
throw new Error("SHA-pinned release evidence requires a v3 manifest");
}
if (!manifest.workflowRef.startsWith(`release-ci/${manifest.workflowSha.slice(0, 12)}-`)) {
throw new Error("SHA-pinned release evidence branch does not match its workflow SHA");
@@ -1231,16 +1231,15 @@ export function validateTrustedProducerIdentity(
}
let workflowRefProof = "legacy-v2-main-ancestry";
if (manifest.version >= 3) {
if (manifest.version === 3) {
if (manifest.workflowRefType !== "branch" || manifest.workflowFullRef !== expectedFullRef) {
throw new Error("release evidence producer workflow full ref is not trusted");
}
const manifestName = `manifest-v${manifest.version}`;
workflowRefProof = protectedTagRoute
? `${manifestName}-protected-tag-exact-sha`
? "manifest-v3-protected-tag-exact-sha"
: shaPinned
? `${manifestName}-sha-pinned-main-ancestry`
: `${manifestName}-branch`;
? "manifest-v3-sha-pinned-main-ancestry"
: "manifest-v3-branch";
}
if (!protectedTagRoute) {
+28 -2
View File
@@ -22,9 +22,10 @@ export type ReleasePlan = {
profile: "beta" | "stable" | "full";
soak: boolean;
allowed_groups: string[];
exceptions: Array<{ code: string; reason: string }>;
};
inventory: {
packages: Array<{ name: string; version: string; targets: string[] }>;
packages: Array<{ name: string; version: string; targets: Array<"clawhub" | "npm"> }>;
platforms: Array<{ id: string; source: string }>;
};
};
@@ -35,15 +36,40 @@ export type ReleasePlanLock = {
plan: ReleasePlan;
};
export type ValidationAttemptRequest = {
schema: "openclaw.validation-attempt-request.v1";
plan_digest: string;
rerun_group: string;
filters: Record<string, string>;
fail_fast: boolean;
reuse_evidence: boolean;
};
export type ValidationAttemptReceipt = {
schema: "openclaw.validation-attempt-receipt.v1";
plan_digest: string;
request_digest: string;
run_id: string;
run_attempt: string;
workflow_ref: string;
workflow_full_ref: string;
workflow_sha: string;
target_sha: string;
};
export const RELEASE_PLAN_SCHEMA: "openclaw.release-plan.v1";
export const RELEASE_PLAN_LOCK_SCHEMA: "openclaw.release-plan-lock.v1";
export const VALIDATION_ATTEMPT_REQUEST_SCHEMA: "openclaw.validation-attempt-request.v1";
export const VALIDATION_ATTEMPT_RECEIPT_SCHEMA: "openclaw.validation-attempt-receipt.v1";
export const RELEASE_PLAN_CANONICALIZATION: "ascii-sorted-compact-json-trailing-newline-v1";
export const RELEASE_PLAN_MAX_BYTES: number;
export const VALIDATION_ATTEMPT_REQUEST_MAX_BYTES: number;
export function validateReleasePlan(value: unknown): ReleasePlan;
export function canonicalReleasePlanJson(value: unknown): string;
export function releasePlanDigest(value: unknown): string;
export function createReleasePlanLock(value: unknown): ReleasePlanLock;
export function validateReleasePlanDigest(value: unknown): string;
export function validateReleasePlanLock(value: unknown): ReleasePlanLock;
export function canonicalReleasePlanLockJson(value: unknown): string;
export function parseReleasePlanLockJson(text: string): ReleasePlanLock;
export function validateValidationAttemptRequest(value: unknown): ValidationAttemptRequest;
export function validateValidationAttemptReceipt(value: unknown): ValidationAttemptReceipt;
+175 -36
View File
@@ -1,14 +1,19 @@
import { createHash } from "node:crypto";
import { parseDocument } from "yaml";
import { isRecord } from "./lib/record-shared.mjs";
import { parseReleaseVersion } from "./lib/release-version.mjs";
export const RELEASE_PLAN_SCHEMA = "openclaw.release-plan.v1";
export const RELEASE_PLAN_LOCK_SCHEMA = "openclaw.release-plan-lock.v1";
export const VALIDATION_ATTEMPT_REQUEST_SCHEMA = "openclaw.validation-attempt-request.v1";
export const VALIDATION_ATTEMPT_RECEIPT_SCHEMA = "openclaw.validation-attempt-receipt.v1";
export const RELEASE_PLAN_CANONICALIZATION = "ascii-sorted-compact-json-trailing-newline-v1";
export const RELEASE_PLAN_MAX_BYTES = 32 * 1024;
export const VALIDATION_ATTEMPT_REQUEST_MAX_BYTES = 8 * 1024;
const SHA_PATTERN = /^[a-f0-9]{40}$/u;
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
const POSITIVE_INTEGER_PATTERN = /^[1-9][0-9]*$/u;
const ASCII_PATTERN = /^[\x20-\x7e]+$/u;
const REPOSITORY = "openclaw/openclaw";
const WORKFLOW_PATH = ".github/workflows/full-release-validation.yml";
@@ -49,6 +54,21 @@ function sha(value, label) {
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 positiveIntegerString(value, label) {
const normalized = asciiString(value, label);
if (!POSITIVE_INTEGER_PATTERN.test(normalized)) {
fail(`${label} must be a positive integer string`);
}
return normalized;
}
function sortedUniqueStrings(value, label) {
if (!Array.isArray(value) || value.length === 0) {
fail(`${label} must be a non-empty array`);
@@ -56,7 +76,7 @@ function sortedUniqueStrings(value, label) {
const result = value.map((entry, index) => asciiString(entry, `${label}[${index}]`));
if (
new Set(result).size !== result.length ||
result.some((entry, index) => index > 0 && result[index - 1] >= entry)
result.some((entry, index) => index > 0 && compareAscii(result[index - 1], entry) >= 0)
) {
fail(`${label} must contain unique strings in ascending ASCII order`);
}
@@ -116,7 +136,7 @@ function validatePackages(value) {
const names = packages.map((entry) => entry.name);
if (
new Set(names).size !== names.length ||
names.some((entry, index) => index > 0 && names[index - 1] >= entry)
names.some((entry, index) => index > 0 && compareAscii(names[index - 1], entry) >= 0)
) {
fail("release plan packages must have unique names in ascending ASCII order");
}
@@ -140,13 +160,60 @@ function validatePlatforms(value) {
const ids = platforms.map((entry) => entry.id);
if (
new Set(ids).size !== ids.length ||
ids.some((entry, index) => index > 0 && ids[index - 1] >= entry)
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 validateExceptions(value) {
if (!Array.isArray(value)) {
fail("release plan validation exceptions must be an array");
}
const exceptions = value.map((entry, index) => {
if (!isRecord(entry)) {
fail(`release plan validation exceptions[${index}] must be an object`);
}
exactKeys(entry, ["code", "reason"], `release plan validation exceptions[${index}]`);
return {
code: asciiString(entry.code, `release plan validation exceptions[${index}].code`),
reason: asciiString(entry.reason, `release plan validation exceptions[${index}].reason`),
};
});
const codes = exceptions.map((entry) => entry.code);
if (
new Set(codes).size !== codes.length ||
codes.some((entry, index) => index > 0 && compareAscii(codes[index - 1], entry) >= 0)
) {
fail("release plan validation exceptions must have unique codes in ascending ASCII order");
}
return exceptions;
}
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 === "main-qualification") {
if (tag !== null || targetContextRef !== candidateSha) {
fail("main-qualification 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`);
}
}
export function validateReleasePlan(value) {
if (!isRecord(value)) {
fail("release plan must be an object");
@@ -180,22 +247,10 @@ export function validateReleasePlan(value) {
fail("release plan release_id must equal version");
}
const tag = value.tag === null ? null : asciiString(value.tag, "release plan tag");
if (
(purpose === "beta-publish" ||
purpose === "stable-publish" ||
purpose === "postpublish-confidence") &&
tag !== `v${version}`
) {
fail(`${purpose} release plan tag must equal v<version>`);
}
if (purpose === "main-qualification" && tag !== null) {
fail("main-qualification release plans must not carry a tag");
}
const candidateSha = sha(value.candidate_sha, "release plan candidate SHA");
const targetContextRef = asciiString(value.target_context_ref, "release plan target_context_ref");
if (targetContextRef !== candidateSha && targetContextRef !== `refs/tags/${tag}`) {
fail("release plan target_context_ref must bind the candidate SHA or release tag");
}
validatePurposeMatrix({ candidateSha, purpose, tag, targetContextRef, version });
const expectedPolicy = {
"beta-publish": { profile: "beta", soak: false },
"stable-publish": { profile: "stable", soak: true },
@@ -207,13 +262,17 @@ export function validateReleasePlan(value) {
}
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)\/.+/u.test(toolingRef)) {
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, ["profile", "soak", "allowed_groups"], "release plan validation");
exactKeys(
value.validation,
["profile", "soak", "allowed_groups", "exceptions"],
"release plan validation",
);
const profile = asciiString(value.validation.profile, "release plan validation profile");
if (!PROFILES.has(profile)) {
fail(`unsupported release plan validation profile: ${profile}`);
@@ -249,6 +308,7 @@ export function validateReleasePlan(value) {
value.validation.allowed_groups,
"release plan validation allowed_groups",
),
exceptions: validateExceptions(value.validation.exceptions),
},
inventory: {
packages: validatePackages(value.inventory.packages),
@@ -284,13 +344,6 @@ export function createReleasePlanLock(value) {
};
}
export function validateReleasePlanDigest(value) {
if (typeof value !== "string" || !DIGEST_PATTERN.test(value)) {
fail("release plan digest must be sha256:<64 lowercase hex characters>");
}
return value;
}
export function validateReleasePlanLock(value) {
if (!isRecord(value)) {
fail("release plan lock must be an object");
@@ -300,11 +353,11 @@ export function validateReleasePlanLock(value) {
fail(`release plan lock schema must be ${RELEASE_PLAN_LOCK_SCHEMA}`);
}
const plan = validateReleasePlan(value.plan);
const digest = validateReleasePlanDigest(value.digest);
if (digest !== releasePlanDigest(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, plan };
return { schema: RELEASE_PLAN_LOCK_SCHEMA, digest: planDigest, plan };
}
export function canonicalReleasePlanLockJson(value) {
@@ -315,12 +368,8 @@ 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");
}
const hasNonAscii = [...text].some((character) => {
const code = character.charCodeAt(0);
return code !== 9 && code !== 10 && code !== 13 && (code < 32 || code > 126);
});
if (hasNonAscii) {
fail("release plan lock JSON must contain only ASCII");
if (!/^[\x20-\x7e]+\n$/u.test(text)) {
fail("release plan lock JSON must be compact printable ASCII with exactly one trailing LF");
}
const document = parseDocument(text, { strict: true, uniqueKeys: true });
if (document.errors.length > 0) {
@@ -333,5 +382,95 @@ export function parseReleasePlanLockJson(text) {
: `release plan lock JSON is invalid: ${document.errors[0].message}`,
);
}
return validateReleasePlanLock(JSON.parse(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;
}
export function validateValidationAttemptRequest(value) {
if (!isRecord(value)) {
fail("validation attempt request must be an object");
}
exactKeys(
value,
["schema", "plan_digest", "rerun_group", "filters", "fail_fast", "reuse_evidence"],
"validation attempt request",
);
if (value.schema !== VALIDATION_ATTEMPT_REQUEST_SCHEMA) {
fail(`validation attempt request schema must be ${VALIDATION_ATTEMPT_REQUEST_SCHEMA}`);
}
if (!isRecord(value.filters)) {
fail("validation attempt request filters must be an object");
}
const filters = Object.fromEntries(
Object.entries(value.filters)
.map(([key, entry]) => [
asciiString(key, "validation attempt request filter key"),
asciiString(entry, `validation attempt request filter ${key}`),
])
.toSorted(([left], [right]) => compareAscii(left, right)),
);
if (typeof value.fail_fast !== "boolean" || typeof value.reuse_evidence !== "boolean") {
fail("validation attempt request fail_fast and reuse_evidence must be booleans");
}
const request = {
schema: VALIDATION_ATTEMPT_REQUEST_SCHEMA,
plan_digest: digest(value.plan_digest, "validation attempt request plan_digest"),
rerun_group: asciiString(value.rerun_group, "validation attempt request rerun_group"),
filters,
fail_fast: value.fail_fast,
reuse_evidence: value.reuse_evidence,
};
if (
Buffer.byteLength(canonicalAsciiJson(request), "ascii") > VALIDATION_ATTEMPT_REQUEST_MAX_BYTES
) {
fail(`validation attempt request exceeds ${VALIDATION_ATTEMPT_REQUEST_MAX_BYTES} bytes`);
}
return request;
}
export function validateValidationAttemptReceipt(value) {
if (!isRecord(value)) {
fail("validation attempt receipt must be an object");
}
exactKeys(
value,
[
"schema",
"plan_digest",
"request_digest",
"run_id",
"run_attempt",
"workflow_ref",
"workflow_full_ref",
"workflow_sha",
"target_sha",
],
"validation attempt receipt",
);
if (value.schema !== VALIDATION_ATTEMPT_RECEIPT_SCHEMA) {
fail(`validation attempt receipt schema must be ${VALIDATION_ATTEMPT_RECEIPT_SCHEMA}`);
}
return {
schema: VALIDATION_ATTEMPT_RECEIPT_SCHEMA,
plan_digest: digest(value.plan_digest, "validation attempt receipt plan_digest"),
request_digest: digest(value.request_digest, "validation attempt receipt request_digest"),
run_id: positiveIntegerString(value.run_id, "validation attempt receipt run_id"),
run_attempt: positiveIntegerString(value.run_attempt, "validation attempt receipt run_attempt"),
workflow_ref: asciiString(value.workflow_ref, "validation attempt receipt workflow_ref"),
workflow_full_ref: asciiString(
value.workflow_full_ref,
"validation attempt receipt workflow_full_ref",
),
workflow_sha: sha(value.workflow_sha, "validation attempt receipt workflow_sha"),
target_sha: sha(value.target_sha, "validation attempt receipt target_sha"),
};
}
-17
View File
@@ -1,17 +0,0 @@
{
"schema": "openclaw.release-plan-inventory.v1",
"platforms": [
{
"id": "android",
"source": ".github/workflows/android-release.yml"
},
{
"id": "docker",
"source": ".github/workflows/docker-release.yml"
},
{
"id": "windows",
"source": ".github/workflows/windows-node-release.yml"
}
]
}
+242 -80
View File
@@ -1,28 +1,140 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { parse as parseYaml } from "yaml";
import { collectClawHubPublishablePluginPackages } from "./lib/plugin-clawhub-release.ts";
import { collectPublishablePluginPackages } from "./lib/plugin-npm-release.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";
type ReleasePlanIntent = "publish" | "postpublish-confidence" | "main-qualification";
export type ReleasePlanIntent = "publish" | "postpublish-confidence" | "main-qualification";
type ReleasePlanSource = {
repoRoot?: string;
intent: ReleasePlanIntent;
candidateSha: string;
candidateRef: string;
toolingSha: string;
toolingFullRef: string;
};
const REPOSITORY = "openclaw/openclaw";
const WORKFLOW_PATH = ".github/workflows/full-release-validation.yml";
const INVENTORY_PATH = "scripts/release-plan-inventory.json";
const VALIDATION_WORKFLOW_PATH = ".github/workflows/full-release-validation.yml";
const PUBLICATION_WORKFLOW_PATH = ".github/workflows/openclaw-release-publish.yml";
const PRODUCER_PATH = "scripts/release-plan-producer.mts";
const SHA_PATTERN = /^[a-f0-9]{40}$/u;
const compareAscii = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0);
function readJson(path: string): unknown {
return JSON.parse(readFileSync(path, "utf8"));
function git(repoRoot: string, args: string[]): string {
return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8" }).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 (!SHA_PATTERN.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 (!SHA_PATTERN.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 readGitText(repoRoot: string, commit: string, path: string): string {
try {
return execFileSync("git", ["show", `${commit}:${path}`], {
cwd: repoRoot,
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
});
} catch {
throw new Error(`${path} is missing from ${commit}`);
}
}
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;
}
}
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"],
{ 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))
) {
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(
@@ -34,7 +146,10 @@ export function deriveReleasePlanPolicy(
soak: boolean;
tag: string | null;
} {
const prerelease = /-(?:alpha|beta)\.[1-9][0-9]*$/u.test(version);
const parsed = parseReleaseVersion(version);
if (parsed === null || parsed.version !== version) {
throw new Error(`unsupported release version: ${version}`);
}
if (intent === "main-qualification") {
return { profile: "full", purpose: "main-qualification", soak: true, tag: null };
}
@@ -46,13 +161,13 @@ export function deriveReleasePlanPolicy(
tag: `v${version}`,
};
}
return prerelease
? { profile: "beta", purpose: "beta-publish", soak: false, tag: `v${version}` }
: { profile: "stable", purpose: "stable-publish", soak: true, tag: `v${version}` };
return parsed.channel === "stable"
? { profile: "stable", purpose: "stable-publish", soak: true, tag: `v${version}` }
: { profile: "beta", purpose: "beta-publish", soak: false, tag: `v${version}` };
}
function collectAllowedGroups(repoRoot: string): string[] {
const workflow = parseYaml(readFileSync(resolve(repoRoot, WORKFLOW_PATH), "utf8")) as {
function collectAllowedGroups(workflowText: string): string[] {
const workflow = parseYaml(workflowText) as {
on?: { workflow_dispatch?: { inputs?: { rerun_group?: { options?: unknown } } } };
};
const options = workflow.on?.workflow_dispatch?.inputs?.rerun_group?.options;
@@ -61,24 +176,26 @@ function collectAllowedGroups(repoRoot: string): string[] {
options.length === 0 ||
options.some((entry) => typeof entry !== "string" || !entry)
) {
throw new Error(`${WORKFLOW_PATH} must declare rerun_group choice options`);
throw new Error(`${VALIDATION_WORKFLOW_PATH} must declare rerun_group choice options`);
}
return [...new Set(options)].toSorted((left, right) =>
left < right ? -1 : left > right ? 1 : 0,
);
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 collectPackageInventory(repoRoot: string, version: string) {
function collectPackageInventory(snapshotRoot: string, version: string) {
const packages = new Map<string, { name: string; version: string; targets: Set<string> }>();
packages.set("openclaw", { name: "openclaw", version, targets: new Set(["npm"]) });
for (const plugin of collectPublishablePluginPackages(repoRoot)) {
for (const plugin of collectPublishablePluginPackages(snapshotRoot)) {
packages.set(plugin.packageName, {
name: plugin.packageName,
version: plugin.version,
targets: new Set(["npm"]),
});
}
for (const plugin of collectClawHubPublishablePluginPackages(repoRoot)) {
for (const plugin of collectClawHubPublishablePluginPackages(snapshotRoot)) {
const existing = packages.get(plugin.packageName);
if (existing && existing.version !== plugin.version) {
throw new Error(`plugin inventory version mismatch for ${plugin.packageName}`);
@@ -95,95 +212,137 @@ function collectPackageInventory(repoRoot: string, version: string) {
.map((entry) => ({
name: entry.name,
version: entry.version,
targets: [...entry.targets].toSorted(),
targets: [...entry.targets].toSorted(compareAscii),
}))
.toSorted((left, right) => left.name.localeCompare(right.name));
.toSorted((left, right) => compareAscii(left.name, right.name));
}
function collectPlatformInventory(repoRoot: string) {
const inventory = readJson(resolve(repoRoot, INVENTORY_PATH)) as {
schema?: unknown;
platforms?: unknown;
function collectPlatformSources(workflowText: string) {
const platforms = new Map<string, string>();
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`);
}
platforms.set(id, `.github/workflows/${workflowName}`);
}
const workflow = parseYaml(workflowText) as {
jobs?: Record<string, { uses?: unknown }>;
};
if (
inventory.schema !== "openclaw.release-plan-inventory.v1" ||
!Array.isArray(inventory.platforms)
) {
throw new Error(`${INVENTORY_PATH} must use openclaw.release-plan-inventory.v1`);
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`);
}
platforms.set(
jobId.slice("publish_".length).replaceAll("_", "-"),
`.github/workflows/${match[1]}`,
);
}
const platforms = inventory.platforms.map((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
throw new Error(`${INVENTORY_PATH} contains an invalid platform entry`);
}
const platform = entry as Record<string, unknown>;
if (typeof platform.id !== "string" || typeof platform.source !== "string") {
throw new Error(`${INVENTORY_PATH} contains an invalid platform entry`);
}
if (!existsSync(resolve(repoRoot, platform.source))) {
throw new Error(`release platform source does not exist: ${platform.source}`);
}
return { id: platform.id, source: platform.source };
});
return platforms.toSorted((left, right) => left.id.localeCompare(right.id));
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));
}
export function produceReleasePlan(params: {
repoRoot?: string;
intent: ReleasePlanIntent;
toolingFullRef: string;
}): ReleasePlan {
const repoRoot = resolve(params.repoRoot ?? ".");
const candidateSha = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: repoRoot,
encoding: "utf8",
}).trim();
const worktreeStatus = execFileSync("git", ["status", "--porcelain", "--untracked-files=all"], {
cwd: repoRoot,
encoding: "utf8",
function collectPlatformInventory(repoRoot: string, toolingSha: string, workflowText: string) {
return collectPlatformSources(workflowText).map(([id, source]) => {
if (!gitPathExists(repoRoot, toolingSha, source)) {
throw new Error(`release platform workflow does not exist at tooling SHA: ${source}`);
}
return { id, source };
});
if (worktreeStatus) {
throw new Error("release plan source checkout must be clean");
}
function readCandidateInventory(repoRoot: string, candidateSha: string) {
return withCandidateSnapshot(repoRoot, candidateSha, (snapshotRoot) => {
const rootPackage = JSON.parse(readFileSync(join(snapshotRoot, "package.json"), "utf8")) as {
version?: unknown;
};
if (typeof rootPackage.version !== "string" || !rootPackage.version) {
throw new Error("candidate package.json version is required");
}
return {
version: rootPackage.version,
packages: collectPackageInventory(snapshotRoot, rootPackage.version),
};
});
}
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, params.candidateRef, "candidate ref") !== candidateSha) {
throw new Error("candidate ref does not resolve to the requested candidate SHA");
}
const toolingMatch = /^refs\/(heads|tags)\/(.+)$/u.exec(params.toolingFullRef);
if (!toolingMatch) {
throw new Error("tooling full ref must be a qualified branch or tag ref");
if (resolveCommit(repoRoot, toolingFullRef, "tooling full ref") !== toolingSha) {
throw new Error("tooling full ref does not resolve to the requested tooling SHA");
}
const toolingSha = execFileSync(
"git",
["rev-parse", "--verify", `${params.toolingFullRef}^{commit}`],
{ cwd: repoRoot, encoding: "utf8" },
).trim();
const rootPackage = readJson(resolve(repoRoot, "package.json")) as { version?: unknown };
if (typeof rootPackage.version !== "string" || !rootPackage.version) {
throw new Error("package.json version is required");
return { candidateSha, repoRoot, toolingFullRef, toolingSha };
}
export function produceReleasePlan(params: ReleasePlanSource): ReleasePlan {
const { candidateSha, repoRoot, toolingFullRef, toolingSha } = resolveSource(params);
const candidate = readCandidateInventory(repoRoot, candidateSha);
const policy = deriveReleasePlanPolicy(params.intent, candidate.version);
const expectedCandidateRef =
params.intent === "main-qualification" ? candidateSha : `refs/tags/v${candidate.version}`;
if (params.candidateRef !== expectedCandidateRef) {
throw new Error(`${params.intent} candidate ref must be ${expectedCandidateRef}`);
}
const policy = deriveReleasePlanPolicy(params.intent, rootPackage.version);
if (!gitPathExists(repoRoot, toolingSha, PRODUCER_PATH)) {
throw new Error(`${PRODUCER_PATH} is missing from tooling SHA ${toolingSha}`);
}
const validationWorkflow = readGitText(repoRoot, toolingSha, VALIDATION_WORKFLOW_PATH);
const publicationWorkflow = readGitText(repoRoot, toolingSha, PUBLICATION_WORKFLOW_PATH);
return validateReleasePlan({
schema: RELEASE_PLAN_SCHEMA,
release_id: rootPackage.version,
version: rootPackage.version,
release_id: candidate.version,
version: candidate.version,
tag: policy.tag,
candidate_sha: candidateSha,
target_context_ref: policy.tag ? `refs/tags/${policy.tag}` : candidateSha,
target_context_ref: expectedCandidateRef,
purpose: policy.purpose,
tooling: {
repository: REPOSITORY,
workflow_path: WORKFLOW_PATH,
ref: params.toolingFullRef,
workflow_path: VALIDATION_WORKFLOW_PATH,
ref: toolingFullRef,
sha: toolingSha,
},
validation: {
profile: policy.profile,
soak: policy.soak,
allowed_groups: collectAllowedGroups(repoRoot),
allowed_groups: collectAllowedGroups(validationWorkflow),
exceptions: [],
},
inventory: {
packages: collectPackageInventory(repoRoot, rootPackage.version),
platforms: collectPlatformInventory(repoRoot),
packages: candidate.packages,
platforms: collectPlatformInventory(repoRoot, toolingSha, publicationWorkflow),
},
});
}
export function verifyReleasePlanLock(
lockJson: string,
params: ReleasePlanSource,
): ReleasePlanLock {
const lock = parseReleasePlanLockJson(lockJson);
const expectedPlan = produceReleasePlan(params);
if (canonicalReleasePlanJson(lock.plan) !== canonicalReleasePlanJson(expectedPlan)) {
throw new Error("release plan does not match repository-derived authority");
}
return lock;
}
function requiredOption(args: string[], name: string): string {
const index = args.indexOf(name);
const value = index >= 0 ? args[index + 1] : undefined;
@@ -201,6 +360,9 @@ function main() {
}
const plan = produceReleasePlan({
intent,
candidateSha: requiredOption(args, "--candidate-sha"),
candidateRef: requiredOption(args, "--candidate-ref"),
toolingSha: requiredOption(args, "--tooling-sha"),
toolingFullRef: requiredOption(args, "--tooling-full-ref"),
});
process.stdout.write(canonicalReleasePlanLockJson(createReleasePlanLock(plan)));
@@ -172,9 +172,9 @@ export function validateFullReleaseValidationEvidence({
);
}
if (![3, 4].includes(manifest.version)) {
if (manifest.version !== 3) {
throw new Error(
`Full release validation manifest must use version 3 or 4, got ${displayValue(manifest.version)}.`,
`Full release validation manifest must use version 3, got ${displayValue(manifest.version)}.`,
);
}
const manifestChecks = [
+1 -52
View File
@@ -1,52 +1 @@
{
"schema": "openclaw.release-plan-lock.v1",
"digest": "sha256:f48b6de82045491d086c8fafb8217ea565a99a87a01e96bd01a30c9690f89462",
"plan": {
"schema": "openclaw.release-plan.v1",
"release_id": "2026.8.1-beta.2",
"version": "2026.8.1-beta.2",
"tag": "v2026.8.1-beta.2",
"candidate_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"target_context_ref": "refs/tags/v2026.8.1-beta.2",
"purpose": "beta-publish",
"tooling": {
"repository": "openclaw/openclaw",
"workflow_path": ".github/workflows/full-release-validation.yml",
"ref": "refs/tags/release-publish/bbbbbbbbbbbb-123",
"sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
},
"validation": {
"profile": "beta",
"soak": false,
"allowed_groups": ["all", "ci", "package"]
},
"inventory": {
"packages": [
{
"name": "@openclaw/example",
"version": "2026.8.1-beta.2",
"targets": ["clawhub", "npm"]
},
{
"name": "openclaw",
"version": "2026.8.1-beta.2",
"targets": ["npm"]
}
],
"platforms": [
{
"id": "android",
"source": ".github/workflows/android-release.yml"
},
{
"id": "docker",
"source": ".github/workflows/docker-release.yml"
},
{
"id": "windows",
"source": ".github/workflows/windows-node-release.yml"
}
]
}
}
}
{"digest":"sha256:e621017c5bb1f9d70974d33e098f2be653f28b1804d925d8a7f8dabe8bc49205","plan":{"candidate_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","inventory":{"packages":[{"name":"@openclaw/example","targets":["clawhub","npm"],"version":"2026.8.1-beta.2"},{"name":"openclaw","targets":["npm"],"version":"2026.8.1-beta.2"}],"platforms":[{"id":"android","source":".github/workflows/android-release.yml"},{"id":"docker","source":".github/workflows/docker-release.yml"},{"id":"vcr","source":".github/workflows/vercel-container-registry-publish.yml"},{"id":"windows","source":".github/workflows/windows-node-release.yml"}]},"purpose":"beta-publish","release_id":"2026.8.1-beta.2","schema":"openclaw.release-plan.v1","tag":"v2026.8.1-beta.2","target_context_ref":"refs/tags/v2026.8.1-beta.2","tooling":{"ref":"refs/tags/release-publish/bbbbbbbbbbbb-123","repository":"openclaw/openclaw","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","workflow_path":".github/workflows/full-release-validation.yml"},"validation":{"allowed_groups":["all","ci","package"],"exceptions":[],"profile":"beta","soak":false},"version":"2026.8.1-beta.2"},"schema":"openclaw.release-plan-lock.v1"}
+1 -48
View File
@@ -1,48 +1 @@
{
"schema": "openclaw.release-plan.v1",
"release_id": "2026.8.1-beta.2",
"version": "2026.8.1-beta.2",
"tag": "v2026.8.1-beta.2",
"candidate_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"target_context_ref": "refs/tags/v2026.8.1-beta.2",
"purpose": "beta-publish",
"tooling": {
"repository": "openclaw/openclaw",
"workflow_path": ".github/workflows/full-release-validation.yml",
"ref": "refs/tags/release-publish/bbbbbbbbbbbb-123",
"sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
},
"validation": {
"profile": "beta",
"soak": false,
"allowed_groups": ["all", "ci", "package"]
},
"inventory": {
"packages": [
{
"name": "@openclaw/example",
"version": "2026.8.1-beta.2",
"targets": ["clawhub", "npm"]
},
{
"name": "openclaw",
"version": "2026.8.1-beta.2",
"targets": ["npm"]
}
],
"platforms": [
{
"id": "android",
"source": ".github/workflows/android-release.yml"
},
{
"id": "docker",
"source": ".github/workflows/docker-release.yml"
},
{
"id": "windows",
"source": ".github/workflows/windows-node-release.yml"
}
]
}
}
{"candidate_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","inventory":{"packages":[{"name":"@openclaw/example","targets":["clawhub","npm"],"version":"2026.8.1-beta.2"},{"name":"openclaw","targets":["npm"],"version":"2026.8.1-beta.2"}],"platforms":[{"id":"android","source":".github/workflows/android-release.yml"},{"id":"docker","source":".github/workflows/docker-release.yml"},{"id":"vcr","source":".github/workflows/vercel-container-registry-publish.yml"},{"id":"windows","source":".github/workflows/windows-node-release.yml"}]},"purpose":"beta-publish","release_id":"2026.8.1-beta.2","schema":"openclaw.release-plan.v1","tag":"v2026.8.1-beta.2","target_context_ref":"refs/tags/v2026.8.1-beta.2","tooling":{"ref":"refs/tags/release-publish/bbbbbbbbbbbb-123","repository":"openclaw/openclaw","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","workflow_path":".github/workflows/full-release-validation.yml"},"validation":{"allowed_groups":["all","ci","package"],"exceptions":[],"profile":"beta","soak":false},"version":"2026.8.1-beta.2"}
+36 -39
View File
@@ -420,7 +420,7 @@ function rawManifest({
rerunGroup?: string;
runId?: string;
targetSha?: string;
version?: 2 | 3 | 4;
version?: 2 | 3;
workflowFullRef?: string;
workflowRefType?: "branch" | "tag";
workflowSha?: string;
@@ -436,7 +436,7 @@ function rawManifest({
targetRef?: string;
targetSha: string;
validationInputs: Record<string, string>;
version: 2 | 3 | 4;
version: 2 | 3;
workflowFullRef?: string;
workflowName: string;
workflowRef: string;
@@ -482,7 +482,7 @@ function rawManifest({
workflowName: "Full Release Validation",
workflowRef: "main",
...(workflowSha ? { workflowSha } : {}),
...(version >= 3
...(version === 3
? {
workflowFullRef: workflowFullRef ?? "refs/heads/main",
workflowRefType: workflowRefType ?? "branch",
@@ -500,7 +500,7 @@ function trustedMainPackageFixture({
workflowRefType,
workflowSha = "0".repeat(40),
}: {
manifestVersion?: 2 | 3 | 4;
manifestVersion?: 2 | 3;
parentPath?: string;
targetSha?: string;
workflowFullRef?: string;
@@ -1327,42 +1327,39 @@ describe("release CI summary child correlation", () => {
},
);
it.each([3, 4] as const)(
"accepts v%s SHA-pinned producer identity with exact-target evidence reuse",
(manifestVersion) => {
const workflowSha = "7".repeat(40);
const workflowRef = `release-ci/${workflowSha.slice(0, 12)}-1783705000000`;
const fixture = trustedMainPackageFixture({
manifestVersion,
workflowFullRef: `refs/heads/${workflowRef}`,
workflowRef,
workflowSha,
});
fixture.manifest.targetRef = fixture.targetSha;
fixture.manifest.evidenceReuse = {
changedPaths: [],
evidenceSha: fixture.targetSha,
policy: "exact-target-full-validation-v1",
runId: "29071366024",
selectedRunId: "29071366024",
};
it("accepts SHA-pinned producer identity with exact-target evidence reuse", () => {
const workflowSha = "7".repeat(40);
const workflowRef = `release-ci/${workflowSha.slice(0, 12)}-1783705000000`;
const fixture = trustedMainPackageFixture({
manifestVersion: 3,
workflowFullRef: `refs/heads/${workflowRef}`,
workflowRef,
workflowSha,
});
fixture.manifest.targetRef = fixture.targetSha;
fixture.manifest.evidenceReuse = {
changedPaths: [],
evidenceSha: fixture.targetSha,
policy: "exact-target-full-validation-v1",
runId: "29071366024",
selectedRunId: "29071366024",
};
expect(
validateTrustedProducerIdentity(
{
manifest: fixture.manifest,
parentRun: fixture.parentRun,
},
fixture.client,
{ sourceSha: "c".repeat(40) },
"main",
),
).toMatchObject({
producerOnTrustedMainLineage: true,
workflowRefProof: `manifest-v${manifestVersion}-sha-pinned-main-ancestry`,
});
},
);
expect(
validateTrustedProducerIdentity(
{
manifest: fixture.manifest,
parentRun: fixture.parentRun,
},
fixture.client,
{ sourceSha: "c".repeat(40) },
"main",
),
).toMatchObject({
producerOnTrustedMainLineage: true,
workflowRefProof: "manifest-v3-sha-pinned-main-ancestry",
});
});
it("rejects a SHA-pinned evidenceReuse field even when false", () => {
const workflowSha = "7".repeat(40);
+112 -60
View File
@@ -9,97 +9,149 @@ import {
RELEASE_PLAN_CANONICALIZATION,
releasePlanDigest,
validateReleasePlan,
validateReleasePlanLock,
validateValidationAttemptReceipt,
validateValidationAttemptRequest,
} from "../../scripts/release-plan-contract.mjs";
const fixtureDir = resolve("test/fixtures");
const sourceFixture = JSON.parse(
readFileSync(resolve(fixtureDir, "release-plan-v1.source.json"), "utf8"),
) as Record<string, unknown>;
const lockFixture = JSON.parse(
readFileSync(resolve(fixtureDir, "release-plan-lock-v1.compatibility.json"), "utf8"),
) as Record<string, unknown>;
const sourceText = readFileSync(resolve(fixtureDir, "release-plan-v1.source.json"), "utf8");
const lockText = readFileSync(
resolve(fixtureDir, "release-plan-lock-v1.compatibility.json"),
"utf8",
);
const sourceFixture = JSON.parse(sourceText) as Record<string, unknown>;
const lockFixture = JSON.parse(lockText) as Record<string, unknown>;
describe("release plan contract", () => {
it("matches the exact public and private-consumer compatibility fixture", () => {
const canonical = canonicalReleasePlanJson(sourceFixture);
it("pins exact canonical source and lock bytes as the cross-repo golden fixture", () => {
expect(RELEASE_PLAN_CANONICALIZATION).toBe("ascii-sorted-compact-json-trailing-newline-v1");
expect(sourceText).toBe(canonicalReleasePlanJson(sourceFixture));
expect(lockText).toBe(canonicalReleasePlanLockJson(lockFixture));
expect(createReleasePlanLock(sourceFixture)).toEqual(lockFixture);
expect(validateReleasePlanLock(lockFixture).plan).toEqual(sourceFixture);
expect(Buffer.byteLength(canonical, "ascii")).toBe(943);
expect(releasePlanDigest(sourceFixture)).toBe(
"sha256:f48b6de82045491d086c8fafb8217ea565a99a87a01e96bd01a30c9690f89462",
expect(parseReleasePlanLockJson(lockText)).toEqual(lockFixture);
expect(lockText.endsWith("\n")).toBe(true);
expect(lockText.slice(0, -1)).toMatch(/^[\x20-\x7e]+$/u);
});
it("rejects duplicate, reordered, pretty, CRLF, and non-ASCII lock bytes", () => {
const duplicate = lockText.replace(
'{"digest":',
`{"digest":"${String(lockFixture.digest)}","digest":`,
);
expect(canonical.endsWith("\n")).toBe(true);
expect(canonical.slice(0, -1)).not.toMatch(/[\r\n]/u);
expect(canonical.slice(0, -1)).toMatch(/^[\x20-\x7e]+$/u);
});
it("round-trips the exact outer lock envelope", () => {
const canonicalLock = canonicalReleasePlanLockJson(lockFixture);
expect(parseReleasePlanLockJson(canonicalLock)).toEqual(lockFixture);
expect(Object.keys(lockFixture).toSorted()).toEqual(["digest", "plan", "schema"]);
});
it("rejects duplicate keys, unknown authority, and non-ASCII data", () => {
const canonicalLock = canonicalReleasePlanLockJson(lockFixture);
expect(() => parseReleasePlanLockJson(duplicate)).toThrow("duplicate key");
expect(() =>
parseReleasePlanLockJson(
canonicalLock.replace('{"digest":', `{"digest":"${String(lockFixture.digest)}","digest":`),
`${JSON.stringify({
schema: lockFixture.schema,
plan: lockFixture.plan,
digest: lockFixture.digest,
})}\n`,
),
).toThrow("duplicate key");
).toThrow("canonical bytes");
expect(() => parseReleasePlanLockJson(`${JSON.stringify(lockFixture, null, 2)}\n`)).toThrow(
"compact printable ASCII",
);
expect(() => parseReleasePlanLockJson(lockText.replace(/\n$/u, "\r\n"))).toThrow(
"exactly one trailing LF",
);
expect(() =>
parseReleasePlanLockJson(lockText.replace("openclaw/openclaw", "opénclaw")),
).toThrow("printable ASCII");
});
it("enforces the purpose, version, tag, and target context matrix", () => {
expect(() =>
validateReleasePlan({
...sourceFixture,
purpose: "stable-publish",
validation: {
allowed_groups: ["all", "ci", "package"],
exceptions: [],
profile: "stable",
soak: true,
},
}),
).toThrow("stable-publish release plan version must be stable");
expect(() =>
validateReleasePlan({
...sourceFixture,
purpose: "main-qualification",
tag: null,
target_context_ref: "refs/tags/null",
validation: {
allowed_groups: ["all", "ci", "package"],
exceptions: [],
profile: "full",
soak: true,
},
}),
).toThrow("candidate SHA context");
expect(() =>
validateReleasePlan({
...sourceFixture,
tag: "v2026.8.1-beta.3",
}),
).toThrow("exact version tag context");
});
it("rejects unknown authority, invalid ordering, and unsupported versions", () => {
expect(() => validateReleasePlan({ ...sourceFixture, run_id: "123" })).toThrow(
"release plan keys must be exactly",
);
expect(() =>
validateReleasePlan({
...sourceFixture,
release_id: "2026.8.1-béta.2",
version: "2026.08.1-beta.2",
release_id: "2026.08.1-beta.2",
tag: "v2026.08.1-beta.2",
target_context_ref: "refs/tags/v2026.08.1-beta.2",
}),
).toThrow("printable ASCII");
});
it("keeps ValidationAttempt state outside ReleasePlan", () => {
const plan = validateReleasePlan(sourceFixture);
expect(plan).not.toHaveProperty("attempt");
expect(plan).not.toHaveProperty("run_id");
expect(plan).not.toHaveProperty("timestamp");
expect(plan).not.toHaveProperty("rerun_group");
expect(plan).not.toHaveProperty("filters");
expect(plan).not.toHaveProperty("local_path");
});
it("rejects unsorted inventory and purpose-policy drift", () => {
).toThrow("supported release version");
expect(() =>
validateReleasePlan({
...sourceFixture,
inventory: {
...(sourceFixture.inventory as Record<string, unknown>),
packages: [
{
name: "openclaw",
version: "2026.8.1-beta.2",
targets: ["npm"],
},
{ name: "openclaw", targets: ["npm"], version: "2026.8.1-beta.2" },
{
name: "@openclaw/example",
version: "2026.8.1-beta.2",
targets: ["clawhub", "npm"],
version: "2026.8.1-beta.2",
},
],
},
}),
).toThrow("packages must have unique names in ascending ASCII order");
expect(() =>
validateReleasePlan({
...sourceFixture,
validation: {
allowed_groups: ["all"],
profile: "full",
soak: true,
},
).toThrow("ascending ASCII order");
});
it("keeps ValidationAttempt request and receipt state outside ReleasePlan", () => {
const plan = validateReleasePlan(sourceFixture);
const planDigest = releasePlanDigest(plan);
expect(plan).not.toHaveProperty("run_id");
expect(plan).not.toHaveProperty("rerun_group");
expect(
validateValidationAttemptRequest({
schema: "openclaw.validation-attempt-request.v1",
plan_digest: planDigest,
rerun_group: "package",
filters: { platform: "linux", package: "openclaw" },
fail_fast: false,
reuse_evidence: true,
}),
).toThrow("beta-publish validation policy is invalid");
).toMatchObject({ plan_digest: planDigest, rerun_group: "package" });
expect(
validateValidationAttemptReceipt({
schema: "openclaw.validation-attempt-receipt.v1",
plan_digest: planDigest,
request_digest: `sha256:${"b".repeat(64)}`,
run_id: "123",
run_attempt: "2",
workflow_ref: "release-ci/example",
workflow_full_ref: "refs/heads/release-ci/example",
workflow_sha: "c".repeat(40),
target_sha: "a".repeat(40),
}),
).toMatchObject({ run_attempt: "2", run_id: "123" });
});
});
+168 -99
View File
@@ -1,27 +1,58 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
canonicalReleasePlanLockJson,
createReleasePlanLock,
} from "../../scripts/release-plan-contract.mjs";
import {
deriveReleasePlanPolicy,
produceReleasePlan,
verifyReleasePlanLock,
type ReleasePlanIntent,
} from "../../scripts/release-plan-producer.mts";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs: string[] = [];
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function createFixtureRepo() {
const root = mkdtempSync(join(tmpdir(), "openclaw-release-plan-"));
tempDirs.push(root);
mkdirSync(join(root, "extensions"), { recursive: true });
mkdirSync(join(root, ".github/workflows"), { recursive: true });
mkdirSync(join(root, "scripts"), { recursive: true });
writeFileSync(
join(root, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.8.1" }),
function writeFixture(root: string, path: string, content: string) {
const target = join(root, path);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, content);
}
function commit(root: string, message: string): string {
execFileSync("git", ["add", "."], { cwd: root });
execFileSync(
"git",
[
"-c",
"user.name=OpenClaw Test",
"-c",
"user.email=test@example.invalid",
"commit",
"-q",
"-m",
message,
],
{ cwd: root },
);
writeFileSync(
join(root, ".github/workflows/full-release-validation.yml"),
return execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim();
}
function createFixtureRepo(version = "2026.8.1-beta.2") {
const root = tempDirs.make("openclaw-release-plan-");
execFileSync("git", ["init", "-q", "-b", "tooling"], { cwd: root });
writeFixture(root, "package.json", JSON.stringify({ name: "openclaw", version }));
const candidateSha = commit(root, "candidate");
const candidateRef = `refs/tags/v${version}`;
execFileSync("git", ["tag", `v${version}`, candidateSha], { cwd: root });
writeFixture(
root,
".github/workflows/full-release-validation.yml",
[
"on:",
" workflow_dispatch:",
@@ -34,48 +65,61 @@ function createFixtureRepo() {
"",
].join("\n"),
);
const platforms = [
["android", ".github/workflows/android-release.yml"],
["docker", ".github/workflows/docker-release.yml"],
["windows", ".github/workflows/windows-node-release.yml"],
] as const;
for (const [, source] of platforms) {
writeFileSync(join(root, source), "name: fixture\n");
}
writeFileSync(
join(root, "scripts/release-plan-inventory.json"),
JSON.stringify({
schema: "openclaw.release-plan-inventory.v1",
platforms: platforms.map(([id, source]) => ({ id, source })),
}),
);
execFileSync("git", ["init", "-q", "-b", "tooling"], { cwd: root });
execFileSync("git", ["add", "."], { cwd: root });
execFileSync(
"git",
writeFixture(
root,
".github/workflows/openclaw-release-publish.yml",
[
"-c",
"user.name=OpenClaw Test",
"-c",
"user.email=test@example.invalid",
"commit",
"-q",
"-m",
"fixture",
],
{ cwd: root },
"name: Release Publish",
"jobs:",
" publish:",
" runs-on: ubuntu-latest",
" steps:",
" - run: |",
" promote_windows_release_assets() {",
" dispatch_workflow windows-node-release.yml",
" }",
" promote_android_release_asset() {",
' dispatch_workflow_at_ref "${RELEASE_TAG}" "${TARGET_SHA}" android-release.yml',
" }",
" dispatch_workflow plugin-npm-release.yml",
" publish_docker:",
" uses: ./.github/workflows/docker-release.yml",
" publish_vcr:",
" uses: ./.github/workflows/vercel-container-registry-publish.yml",
"",
].join("\n"),
);
return root;
for (const name of [
"android-release.yml",
"docker-release.yml",
"plugin-npm-release.yml",
"vercel-container-registry-publish.yml",
"windows-node-release.yml",
]) {
writeFixture(root, `.github/workflows/${name}`, `name: ${name}\n`);
}
writeFixture(root, "scripts/release-plan-producer.mts", "// tooling-owned fixture\n");
writeFixture(root, "package.json", JSON.stringify({ name: "openclaw", version: "2099.1.1" }));
const toolingSha = commit(root, "tooling");
return { candidateRef, candidateSha, root, toolingSha };
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { force: true, recursive: true });
}
});
function sourceParams(
fixture: ReturnType<typeof createFixtureRepo>,
intent: ReleasePlanIntent = "publish",
) {
return {
repoRoot: fixture.root,
intent,
candidateSha: fixture.candidateSha,
candidateRef: intent === "main-qualification" ? fixture.candidateSha : fixture.candidateRef,
toolingSha: fixture.toolingSha,
toolingFullRef: "refs/heads/tooling",
} as const;
}
describe("release plan producer", () => {
it("derives purpose, profile, tag, and soak from intent and version", () => {
it("derives purpose, profile, tag, and soak from the canonical version parser", () => {
expect(deriveReleasePlanPolicy("publish", "2026.8.1-beta.2")).toEqual({
profile: "beta",
purpose: "beta-publish",
@@ -88,67 +132,92 @@ describe("release plan producer", () => {
soak: true,
tag: "v2026.8.1",
});
expect(deriveReleasePlanPolicy("postpublish-confidence", "2026.8.1")).toEqual({
profile: "full",
purpose: "postpublish-confidence",
soak: true,
tag: "v2026.8.1",
});
expect(deriveReleasePlanPolicy("main-qualification", "2026.8.1")).toEqual({
profile: "full",
purpose: "main-qualification",
soak: true,
tag: null,
});
expect(() => deriveReleasePlanPolicy("publish", "2026.08.1")).toThrow(
"unsupported release version",
);
});
it("derives identity, groups, and inventory from an exact clean checkout", () => {
const repoRoot = createFixtureRepo();
const sha = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: repoRoot,
encoding: "utf8",
}).trim();
const plan = produceReleasePlan({
repoRoot,
intent: "main-qualification",
toolingFullRef: "refs/heads/tooling",
});
it("reads candidate inventory and tooling policy from genuinely different commits", () => {
const fixture = createFixtureRepo();
expect(fixture.candidateSha).not.toBe(fixture.toolingSha);
expect(() =>
execFileSync(
"git",
["cat-file", "-e", `${fixture.candidateSha}:scripts/release-plan-producer.mts`],
{ cwd: fixture.root, stdio: "ignore" },
),
).toThrow();
expect(
execFileSync(
"git",
["cat-file", "-e", `${fixture.toolingSha}:scripts/release-plan-producer.mts`],
{ cwd: fixture.root, stdio: "ignore" },
),
).toBeNull();
const plan = produceReleasePlan(sourceParams(fixture));
expect(plan).toMatchObject({
candidate_sha: sha,
purpose: "main-qualification",
release_id: "2026.8.1",
tag: null,
target_context_ref: sha,
version: "2026.8.1",
candidate_sha: fixture.candidateSha,
purpose: "beta-publish",
release_id: "2026.8.1-beta.2",
tag: "v2026.8.1-beta.2",
target_context_ref: fixture.candidateRef,
version: "2026.8.1-beta.2",
});
expect(plan.tooling).toEqual({
expect(plan.tooling).toMatchObject({
ref: "refs/heads/tooling",
repository: "openclaw/openclaw",
sha,
workflow_path: ".github/workflows/full-release-validation.yml",
sha: fixture.toolingSha,
});
expect(plan.validation.allowed_groups).toEqual(["all", "ci", "package"]);
expect(plan.inventory.packages).toEqual([
{ name: "openclaw", targets: ["npm"], version: "2026.8.1" },
{ name: "openclaw", targets: ["npm"], version: "2026.8.1-beta.2" },
]);
expect(plan.inventory.platforms.map((platform) => platform.id)).toEqual([
"android",
"docker",
"windows",
expect(plan.inventory.platforms).toEqual([
{ id: "android", source: ".github/workflows/android-release.yml" },
{ id: "docker", source: ".github/workflows/docker-release.yml" },
{
id: "vcr",
source: ".github/workflows/vercel-container-registry-publish.yml",
},
{ id: "windows", source: ".github/workflows/windows-node-release.yml" },
]);
});
it("rejects a checkout whose bytes are not represented by candidate HEAD", () => {
const repoRoot = createFixtureRepo();
writeFileSync(join(repoRoot, "package.json"), '{"name":"openclaw","version":"2026.8.2"}');
it("requires exact candidate and tooling identity instead of checkout HEAD", () => {
const fixture = createFixtureRepo();
expect(() =>
produceReleasePlan({
repoRoot,
intent: "publish",
toolingFullRef: "refs/heads/tooling",
}),
).toThrow("source checkout must be clean");
produceReleasePlan({ ...sourceParams(fixture), candidateSha: fixture.toolingSha }),
).toThrow("candidate ref does not resolve");
expect(() =>
produceReleasePlan({ ...sourceParams(fixture), toolingSha: fixture.candidateSha }),
).toThrow("tooling full ref does not resolve");
expect(() =>
produceReleasePlan({ ...sourceParams(fixture), candidateRef: "refs/heads/tooling" }),
).toThrow("candidate ref does not resolve");
});
it("rejects recomputed locks with partial groups or bogus inventory", () => {
const fixture = createFixtureRepo();
const params = sourceParams(fixture);
const plan = produceReleasePlan(params);
const validLock = canonicalReleasePlanLockJson(createReleasePlanLock(plan));
expect(verifyReleasePlanLock(validLock, params).plan).toEqual(plan);
const partialGroups = structuredClone(plan);
partialGroups.validation.allowed_groups = ["all", "ci"];
const partialPlatforms = structuredClone(plan);
partialPlatforms.inventory.platforms = partialPlatforms.inventory.platforms.slice(0, -1);
const bogusPackages = structuredClone(plan);
bogusPackages.inventory.packages.push({
name: "zz-not-published",
targets: ["npm"],
version: plan.version,
});
for (const changed of [partialGroups, partialPlatforms, bogusPackages]) {
const redigested = canonicalReleasePlanLockJson(createReleasePlanLock(changed));
expect(() => verifyReleasePlanLock(redigested, params)).toThrow(
"repository-derived authority",
);
}
});
});
@@ -117,8 +117,8 @@ describe("full release validation evidence", () => {
});
});
it.each([3, 4])("accepts canonical v%s SHA-pinned evidence bound to current main", (version) => {
const { isTrustedMainAncestor, result } = validate({}, { version });
it("accepts canonical SHA-pinned evidence bound to current main", () => {
const { isTrustedMainAncestor, result } = validate();
expect(result.source).toBe("sha-pinned-main");
expect(isTrustedMainAncestor).toHaveBeenCalledWith(workflowSha);
@@ -257,7 +257,7 @@ describe("full release validation evidence", () => {
["workflow full ref", {}, { workflowFullRef: "refs/heads/main" }, "workflowFullRef"],
["target SHA", {}, { targetSha: "c".repeat(40) }, "targetSha"],
["target ref", {}, { targetRef: "v2026.7.1-beta.3" }, "target ref"],
["manifest version", {}, { version: 2 }, "version 3 or 4"],
["manifest version", {}, { version: 2 }, "version 3"],
])("rejects mismatched %s", (_name, runOverrides, manifestOverrides, message) => {
expect(() => validate(runOverrides, manifestOverrides)).toThrow(message);
});
@@ -276,8 +276,8 @@ describe("full release validation evidence", () => {
expect(() => validate({}, {}, false)).toThrow("not reachable from current main");
});
it.each([3, 4])("accepts exact-target evidence reuse with a v%s manifest", (version) => {
expect(validate({}, { version, evidenceReuse: exactTargetEvidenceReuse() }).result.source).toBe(
it("accepts exact-target evidence reuse on the SHA-pinned path", () => {
expect(validate({}, { evidenceReuse: exactTargetEvidenceReuse() }).result.source).toBe(
"sha-pinned-main",
);
});