fix(release): define immutable release plan contract

This commit is contained in:
Vincent Koc
2026-08-20 22:02:19 -07:00
parent 5edc2a7f21
commit 88ec05f87c
12 changed files with 1035 additions and 52 deletions
+10 -9
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].includes(value.version) || value.workflowName !== "Full Release Validation") {
if (![2, 3, 4].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 manifest");
if (manifest.version < 3) {
throw new Error("SHA-pinned release evidence requires a v3 or v4 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,15 +1231,16 @@ 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
? "manifest-v3-protected-tag-exact-sha"
? `${manifestName}-protected-tag-exact-sha`
: shaPinned
? "manifest-v3-sha-pinned-main-ancestry"
: "manifest-v3-branch";
? `${manifestName}-sha-pinned-main-ancestry`
: `${manifestName}-branch`;
}
if (!protectedTagRoute) {
+49
View File
@@ -0,0 +1,49 @@
export type ReleasePlanPurpose =
| "beta-publish"
| "stable-publish"
| "postpublish-confidence"
| "main-qualification";
export type ReleasePlan = {
schema: "openclaw.release-plan.v1";
release_id: string;
version: string;
tag: string | null;
candidate_sha: string;
target_context_ref: string;
purpose: ReleasePlanPurpose;
tooling: {
repository: "openclaw/openclaw";
workflow_path: ".github/workflows/full-release-validation.yml";
ref: string;
sha: string;
};
validation: {
profile: "beta" | "stable" | "full";
soak: boolean;
allowed_groups: string[];
};
inventory: {
packages: Array<{ name: string; version: string; targets: string[] }>;
platforms: Array<{ id: string; source: string }>;
};
};
export type ReleasePlanLock = {
schema: "openclaw.release-plan-lock.v1";
digest: string;
plan: ReleasePlan;
};
export const RELEASE_PLAN_SCHEMA: "openclaw.release-plan.v1";
export const RELEASE_PLAN_LOCK_SCHEMA: "openclaw.release-plan-lock.v1";
export const RELEASE_PLAN_CANONICALIZATION: "ascii-sorted-compact-json-trailing-newline-v1";
export const RELEASE_PLAN_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;
+337
View File
@@ -0,0 +1,337 @@
import { createHash } from "node:crypto";
import { parseDocument } from "yaml";
import { isRecord } from "./lib/record-shared.mjs";
export const RELEASE_PLAN_SCHEMA = "openclaw.release-plan.v1";
export const RELEASE_PLAN_LOCK_SCHEMA = "openclaw.release-plan-lock.v1";
export const RELEASE_PLAN_CANONICALIZATION = "ascii-sorted-compact-json-trailing-newline-v1";
export 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 PURPOSES = new Set([
"beta-publish",
"stable-publish",
"postpublish-confidence",
"main-qualification",
]);
const PROFILES = new Set(["beta", "stable", "full"]);
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 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 && result[index - 1] >= entry)
) {
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 canonicalize(value) {
if (Array.isArray(value)) {
return value.map(canonicalize);
}
if (isRecord(value)) {
return Object.fromEntries(
Object.keys(value)
.toSorted(compareAscii)
.map((key) => [key, canonicalize(value[key])]),
);
}
return 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 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 && names[index - 1] >= entry)
) {
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 && ids[index - 1] >= entry)
) {
fail("release plan platforms must have unique ids in ascending ASCII order");
}
return platforms;
}
export function validateReleasePlan(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");
if (!PURPOSES.has(purpose)) {
fail(`unsupported release plan purpose: ${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");
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");
}
const expectedPolicy = {
"beta-publish": { profile: "beta", soak: false },
"stable-publish": { profile: "stable", soak: true },
"postpublish-confidence": { profile: "full", soak: true },
"main-qualification": { profile: "full", soak: true },
}[purpose];
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)\/.+/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");
const profile = asciiString(value.validation.profile, "release plan validation profile");
if (!PROFILES.has(profile)) {
fail(`unsupported release plan validation profile: ${profile}`);
}
if (typeof value.validation.soak !== "boolean") {
fail("release plan validation soak must be boolean");
}
if (profile !== expectedPolicy.profile || value.validation.soak !== expectedPolicy.soak) {
fail(`release plan ${purpose} validation policy is invalid`);
}
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: {
profile,
soak: value.validation.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}`);
}
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));
}
export 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,
};
}
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");
}
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 digest = validateReleasePlanDigest(value.digest);
if (digest !== releasePlanDigest(plan)) {
fail("release plan lock digest does not match its canonical plan");
}
return { schema: RELEASE_PLAN_LOCK_SCHEMA, digest, 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");
}
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");
}
const document = parseDocument(text, { strict: true, uniqueKeys: true });
if (document.errors.length > 0) {
const duplicate = document.errors.find((error) =>
error.message.includes("keys must be unique"),
);
fail(
duplicate
? "release plan JSON contains a duplicate key"
: `release plan lock JSON is invalid: ${document.errors[0].message}`,
);
}
return validateReleasePlanLock(JSON.parse(text));
}
+17
View File
@@ -0,0 +1,17 @@
{
"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"
}
]
}
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { 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 {
canonicalReleasePlanLockJson,
createReleasePlanLock,
RELEASE_PLAN_SCHEMA,
validateReleasePlan,
type ReleasePlan,
type ReleasePlanPurpose,
} from "./release-plan-contract.mjs";
type ReleasePlanIntent = "publish" | "postpublish-confidence" | "main-qualification";
const REPOSITORY = "openclaw/openclaw";
const WORKFLOW_PATH = ".github/workflows/full-release-validation.yml";
const INVENTORY_PATH = "scripts/release-plan-inventory.json";
function readJson(path: string): unknown {
return JSON.parse(readFileSync(path, "utf8"));
}
export function deriveReleasePlanPolicy(
intent: ReleasePlanIntent,
version: string,
): {
profile: "beta" | "stable" | "full";
purpose: ReleasePlanPurpose;
soak: boolean;
tag: string | null;
} {
const prerelease = /-(?:alpha|beta)\.[1-9][0-9]*$/u.test(version);
if (intent === "main-qualification") {
return { profile: "full", purpose: "main-qualification", soak: true, tag: null };
}
if (intent === "postpublish-confidence") {
return {
profile: "full",
purpose: "postpublish-confidence",
soak: true,
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}` };
}
function collectAllowedGroups(repoRoot: string): string[] {
const workflow = parseYaml(readFileSync(resolve(repoRoot, WORKFLOW_PATH), "utf8")) as {
on?: { workflow_dispatch?: { inputs?: { rerun_group?: { options?: unknown } } } };
};
const options = workflow.on?.workflow_dispatch?.inputs?.rerun_group?.options;
if (
!Array.isArray(options) ||
options.length === 0 ||
options.some((entry) => typeof entry !== "string" || !entry)
) {
throw new Error(`${WORKFLOW_PATH} must declare rerun_group choice options`);
}
return [...new Set(options)].toSorted((left, right) =>
left < right ? -1 : left > right ? 1 : 0,
);
}
function collectPackageInventory(repoRoot: 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)) {
packages.set(plugin.packageName, {
name: plugin.packageName,
version: plugin.version,
targets: new Set(["npm"]),
});
}
for (const plugin of collectClawHubPublishablePluginPackages(repoRoot)) {
const existing = packages.get(plugin.packageName);
if (existing && existing.version !== plugin.version) {
throw new Error(`plugin inventory version mismatch for ${plugin.packageName}`);
}
const entry = existing ?? {
name: plugin.packageName,
version: plugin.version,
targets: new Set<string>(),
};
entry.targets.add("clawhub");
packages.set(plugin.packageName, entry);
}
return [...packages.values()]
.map((entry) => ({
name: entry.name,
version: entry.version,
targets: [...entry.targets].toSorted(),
}))
.toSorted((left, right) => left.name.localeCompare(right.name));
}
function collectPlatformInventory(repoRoot: string) {
const inventory = readJson(resolve(repoRoot, INVENTORY_PATH)) as {
schema?: unknown;
platforms?: 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`);
}
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));
}
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",
});
if (worktreeStatus) {
throw new Error("release plan source checkout must be clean");
}
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");
}
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");
}
const policy = deriveReleasePlanPolicy(params.intent, rootPackage.version);
return validateReleasePlan({
schema: RELEASE_PLAN_SCHEMA,
release_id: rootPackage.version,
version: rootPackage.version,
tag: policy.tag,
candidate_sha: candidateSha,
target_context_ref: policy.tag ? `refs/tags/${policy.tag}` : candidateSha,
purpose: policy.purpose,
tooling: {
repository: REPOSITORY,
workflow_path: WORKFLOW_PATH,
ref: params.toolingFullRef,
sha: toolingSha,
},
validation: {
profile: policy.profile,
soak: policy.soak,
allowed_groups: collectAllowedGroups(repoRoot),
},
inventory: {
packages: collectPackageInventory(repoRoot, rootPackage.version),
platforms: collectPlatformInventory(repoRoot),
},
});
}
function requiredOption(args: string[], name: string): string {
const index = args.indexOf(name);
const value = index >= 0 ? args[index + 1] : undefined;
if (!value || value.startsWith("-")) {
throw new Error(`${name} is required`);
}
return value;
}
function main() {
const args = process.argv.slice(2);
const intent = requiredOption(args, "--intent") as ReleasePlanIntent;
if (!["publish", "postpublish-confidence", "main-qualification"].includes(intent)) {
throw new Error("--intent must be publish, postpublish-confidence, or main-qualification");
}
const plan = produceReleasePlan({
intent,
toolingFullRef: requiredOption(args, "--tooling-full-ref"),
});
process.stdout.write(canonicalReleasePlanLockJson(createReleasePlanLock(plan)));
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
console.error("[release-plan-producer] FAILED (exit 1)");
process.exitCode = 1;
}
}
@@ -172,9 +172,9 @@ export function validateFullReleaseValidationEvidence({
);
}
if (manifest.version !== 3) {
if (![3, 4].includes(manifest.version)) {
throw new Error(
`Full release validation manifest must use version 3, got ${displayValue(manifest.version)}.`,
`Full release validation manifest must use version 3 or 4, got ${displayValue(manifest.version)}.`,
);
}
const manifestChecks = [
+52
View File
@@ -0,0 +1,52 @@
{
"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"
}
]
}
}
}
+48
View File
@@ -0,0 +1,48 @@
{
"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"
}
]
}
}
+39 -36
View File
@@ -420,7 +420,7 @@ function rawManifest({
rerunGroup?: string;
runId?: string;
targetSha?: string;
version?: 2 | 3;
version?: 2 | 3 | 4;
workflowFullRef?: string;
workflowRefType?: "branch" | "tag";
workflowSha?: string;
@@ -436,7 +436,7 @@ function rawManifest({
targetRef?: string;
targetSha: string;
validationInputs: Record<string, string>;
version: 2 | 3;
version: 2 | 3 | 4;
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;
manifestVersion?: 2 | 3 | 4;
parentPath?: string;
targetSha?: string;
workflowFullRef?: string;
@@ -1327,39 +1327,42 @@ describe("release CI summary child correlation", () => {
},
);
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",
};
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",
};
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",
});
});
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`,
});
},
);
it("rejects a SHA-pinned evidenceReuse field even when false", () => {
const workflowSha = "7".repeat(40);
+105
View File
@@ -0,0 +1,105 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import {
canonicalReleasePlanJson,
canonicalReleasePlanLockJson,
createReleasePlanLock,
parseReleasePlanLockJson,
RELEASE_PLAN_CANONICALIZATION,
releasePlanDigest,
validateReleasePlan,
validateReleasePlanLock,
} 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>;
describe("release plan contract", () => {
it("matches the exact public and private-consumer compatibility fixture", () => {
const canonical = canonicalReleasePlanJson(sourceFixture);
expect(RELEASE_PLAN_CANONICALIZATION).toBe("ascii-sorted-compact-json-trailing-newline-v1");
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(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(
canonicalLock.replace('{"digest":', `{"digest":"${String(lockFixture.digest)}","digest":`),
),
).toThrow("duplicate key");
expect(() => validateReleasePlan({ ...sourceFixture, run_id: "123" })).toThrow(
"release plan keys must be exactly",
);
expect(() =>
validateReleasePlan({
...sourceFixture,
release_id: "2026.8.1-béta.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", () => {
expect(() =>
validateReleasePlan({
...sourceFixture,
inventory: {
...(sourceFixture.inventory as Record<string, unknown>),
packages: [
{
name: "openclaw",
version: "2026.8.1-beta.2",
targets: ["npm"],
},
{
name: "@openclaw/example",
version: "2026.8.1-beta.2",
targets: ["clawhub", "npm"],
},
],
},
}),
).toThrow("packages must have unique names in ascending ASCII order");
expect(() =>
validateReleasePlan({
...sourceFixture,
validation: {
allowed_groups: ["all"],
profile: "full",
soak: true,
},
}),
).toThrow("beta-publish validation policy is invalid");
});
});
+154
View File
@@ -0,0 +1,154 @@
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 { afterEach, describe, expect, it } from "vitest";
import {
deriveReleasePlanPolicy,
produceReleasePlan,
} from "../../scripts/release-plan-producer.mts";
const tempDirs: string[] = [];
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" }),
);
writeFileSync(
join(root, ".github/workflows/full-release-validation.yml"),
[
"on:",
" workflow_dispatch:",
" inputs:",
" rerun_group:",
" options:",
" - package",
" - all",
" - ci",
"",
].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",
[
"-c",
"user.name=OpenClaw Test",
"-c",
"user.email=test@example.invalid",
"commit",
"-q",
"-m",
"fixture",
],
{ cwd: root },
);
return root;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { force: true, recursive: true });
}
});
describe("release plan producer", () => {
it("derives purpose, profile, tag, and soak from intent and version", () => {
expect(deriveReleasePlanPolicy("publish", "2026.8.1-beta.2")).toEqual({
profile: "beta",
purpose: "beta-publish",
soak: false,
tag: "v2026.8.1-beta.2",
});
expect(deriveReleasePlanPolicy("publish", "2026.8.1")).toEqual({
profile: "stable",
purpose: "stable-publish",
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,
});
});
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",
});
expect(plan).toMatchObject({
candidate_sha: sha,
purpose: "main-qualification",
release_id: "2026.8.1",
tag: null,
target_context_ref: sha,
version: "2026.8.1",
});
expect(plan.tooling).toEqual({
ref: "refs/heads/tooling",
repository: "openclaw/openclaw",
sha,
workflow_path: ".github/workflows/full-release-validation.yml",
});
expect(plan.validation.allowed_groups).toEqual(["all", "ci", "package"]);
expect(plan.inventory.packages).toEqual([
{ name: "openclaw", targets: ["npm"], version: "2026.8.1" },
]);
expect(plan.inventory.platforms.map((platform) => platform.id)).toEqual([
"android",
"docker",
"windows",
]);
});
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"}');
expect(() =>
produceReleasePlan({
repoRoot,
intent: "publish",
toolingFullRef: "refs/heads/tooling",
}),
).toThrow("source checkout must be clean");
});
});
@@ -117,8 +117,8 @@ describe("full release validation evidence", () => {
});
});
it("accepts canonical SHA-pinned evidence bound to current main", () => {
const { isTrustedMainAncestor, result } = validate();
it.each([3, 4])("accepts canonical v%s SHA-pinned evidence bound to current main", (version) => {
const { isTrustedMainAncestor, result } = validate({}, { version });
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"],
["manifest version", {}, { version: 2 }, "version 3 or 4"],
])("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("accepts exact-target evidence reuse on the SHA-pinned path", () => {
expect(validate({}, { evidenceReuse: exactTargetEvidenceReuse() }).result.source).toBe(
it.each([3, 4])("accepts exact-target evidence reuse with a v%s manifest", (version) => {
expect(validate({}, { version, evidenceReuse: exactTargetEvidenceReuse() }).result.source).toBe(
"sha-pinned-main",
);
});