From 88ec05f87c229cd4591303cd3c8987c8899aa6b1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 20 Aug 2026 22:02:19 -0700 Subject: [PATCH] fix(release): define immutable release plan contract --- scripts/release-ci-summary.mjs | 19 +- scripts/release-plan-contract.d.mts | 49 +++ scripts/release-plan-contract.mjs | 337 ++++++++++++++++++ scripts/release-plan-inventory.json | 17 + scripts/release-plan-producer.mts | 217 +++++++++++ ...idate-full-release-validation-evidence.mjs | 4 +- .../release-plan-lock-v1.compatibility.json | 52 +++ test/fixtures/release-plan-v1.source.json | 48 +++ test/scripts/release-ci-summary.test.ts | 75 ++-- test/scripts/release-plan-contract.test.ts | 105 ++++++ test/scripts/release-plan-producer.test.ts | 154 ++++++++ ...e-full-release-validation-evidence.test.ts | 10 +- 12 files changed, 1035 insertions(+), 52 deletions(-) create mode 100644 scripts/release-plan-contract.d.mts create mode 100644 scripts/release-plan-contract.mjs create mode 100644 scripts/release-plan-inventory.json create mode 100644 scripts/release-plan-producer.mts create mode 100644 test/fixtures/release-plan-lock-v1.compatibility.json create mode 100644 test/fixtures/release-plan-v1.source.json create mode 100644 test/scripts/release-plan-contract.test.ts create mode 100644 test/scripts/release-plan-producer.test.ts diff --git a/scripts/release-ci-summary.mjs b/scripts/release-ci-summary.mjs index 7a32bb9e16fa..0d7e7bfd4a22 100755 --- a/scripts/release-ci-summary.mjs +++ b/scripts/release-ci-summary.mjs @@ -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) { diff --git a/scripts/release-plan-contract.d.mts b/scripts/release-plan-contract.d.mts new file mode 100644 index 000000000000..afd0b26e4b38 --- /dev/null +++ b/scripts/release-plan-contract.d.mts @@ -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; diff --git a/scripts/release-plan-contract.mjs b/scripts/release-plan-contract.mjs new file mode 100644 index 000000000000..a54d869cb4c0 --- /dev/null +++ b/scripts/release-plan-contract.mjs @@ -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`); + } + 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)); +} diff --git a/scripts/release-plan-inventory.json b/scripts/release-plan-inventory.json new file mode 100644 index 000000000000..51565742a809 --- /dev/null +++ b/scripts/release-plan-inventory.json @@ -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" + } + ] +} diff --git a/scripts/release-plan-producer.mts b/scripts/release-plan-producer.mts new file mode 100644 index 000000000000..1c0e04505f23 --- /dev/null +++ b/scripts/release-plan-producer.mts @@ -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 }>(); + 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(), + }; + 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; + 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; + } +} diff --git a/scripts/validate-full-release-validation-evidence.mjs b/scripts/validate-full-release-validation-evidence.mjs index 64ca33bf4020..db41bc27f8cd 100755 --- a/scripts/validate-full-release-validation-evidence.mjs +++ b/scripts/validate-full-release-validation-evidence.mjs @@ -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 = [ diff --git a/test/fixtures/release-plan-lock-v1.compatibility.json b/test/fixtures/release-plan-lock-v1.compatibility.json new file mode 100644 index 000000000000..f578726f0f35 --- /dev/null +++ b/test/fixtures/release-plan-lock-v1.compatibility.json @@ -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" + } + ] + } + } +} diff --git a/test/fixtures/release-plan-v1.source.json b/test/fixtures/release-plan-v1.source.json new file mode 100644 index 000000000000..546d13982fa4 --- /dev/null +++ b/test/fixtures/release-plan-v1.source.json @@ -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" + } + ] + } +} diff --git a/test/scripts/release-ci-summary.test.ts b/test/scripts/release-ci-summary.test.ts index 170181ea6674..8051c513fcdf 100644 --- a/test/scripts/release-ci-summary.test.ts +++ b/test/scripts/release-ci-summary.test.ts @@ -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; - 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); diff --git a/test/scripts/release-plan-contract.test.ts b/test/scripts/release-plan-contract.test.ts new file mode 100644 index 000000000000..69d9b5470b23 --- /dev/null +++ b/test/scripts/release-plan-contract.test.ts @@ -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; +const lockFixture = JSON.parse( + readFileSync(resolve(fixtureDir, "release-plan-lock-v1.compatibility.json"), "utf8"), +) as Record; + +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), + 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"); + }); +}); diff --git a/test/scripts/release-plan-producer.test.ts b/test/scripts/release-plan-producer.test.ts new file mode 100644 index 000000000000..f6a6f81506a0 --- /dev/null +++ b/test/scripts/release-plan-producer.test.ts @@ -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"); + }); +}); diff --git a/test/scripts/validate-full-release-validation-evidence.test.ts b/test/scripts/validate-full-release-validation-evidence.test.ts index 5ad2ea3ebf59..a7c6e39edad9 100644 --- a/test/scripts/validate-full-release-validation-evidence.test.ts +++ b/test/scripts/validate-full-release-validation-evidence.test.ts @@ -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", ); });