From 58a7d0d446c339d5bd90120d4100bc6e55a07fb6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 20 Aug 2026 22:24:52 -0700 Subject: [PATCH] fix(release): harden immutable plan authority --- scripts/release-ci-summary.mjs | 19 +- scripts/release-plan-contract.d.mts | 30 +- scripts/release-plan-contract.mjs | 211 ++++++++++-- scripts/release-plan-inventory.json | 17 - scripts/release-plan-producer.mts | 322 +++++++++++++----- ...idate-full-release-validation-evidence.mjs | 4 +- .../release-plan-lock-v1.compatibility.json | 53 +-- test/fixtures/release-plan-v1.source.json | 49 +-- test/scripts/release-ci-summary.test.ts | 75 ++-- test/scripts/release-plan-contract.test.ts | 172 ++++++---- test/scripts/release-plan-producer.test.ts | 267 +++++++++------ ...e-full-release-validation-evidence.test.ts | 10 +- 12 files changed, 779 insertions(+), 450 deletions(-) delete mode 100644 scripts/release-plan-inventory.json diff --git a/scripts/release-ci-summary.mjs b/scripts/release-ci-summary.mjs index 0d7e7bfd4a22..7a32bb9e16fa 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, 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) { diff --git a/scripts/release-plan-contract.d.mts b/scripts/release-plan-contract.d.mts index afd0b26e4b38..2cbc24a9082f 100644 --- a/scripts/release-plan-contract.d.mts +++ b/scripts/release-plan-contract.d.mts @@ -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; + 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; diff --git a/scripts/release-plan-contract.mjs b/scripts/release-plan-contract.mjs index a54d869cb4c0..8f7a6c8f0c26 100644 --- a/scripts/release-plan-contract.mjs +++ b/scripts/release-plan-contract.mjs @@ -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`); - } - 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"), + }; } diff --git a/scripts/release-plan-inventory.json b/scripts/release-plan-inventory.json deleted file mode 100644 index 51565742a809..000000000000 --- a/scripts/release-plan-inventory.json +++ /dev/null @@ -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" - } - ] -} diff --git a/scripts/release-plan-producer.mts b/scripts/release-plan-producer.mts index 1c0e04505f23..f4c1f46983ff 100644 --- a/scripts/release-plan-producer.mts +++ b/scripts/release-plan-producer.mts @@ -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( + 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 }>(); 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(); + 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; }; - 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; - 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))); diff --git a/scripts/validate-full-release-validation-evidence.mjs b/scripts/validate-full-release-validation-evidence.mjs index db41bc27f8cd..64ca33bf4020 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 (![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 = [ diff --git a/test/fixtures/release-plan-lock-v1.compatibility.json b/test/fixtures/release-plan-lock-v1.compatibility.json index f578726f0f35..dcca8934eb90 100644 --- a/test/fixtures/release-plan-lock-v1.compatibility.json +++ b/test/fixtures/release-plan-lock-v1.compatibility.json @@ -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"} diff --git a/test/fixtures/release-plan-v1.source.json b/test/fixtures/release-plan-v1.source.json index 546d13982fa4..52f355c08409 100644 --- a/test/fixtures/release-plan-v1.source.json +++ b/test/fixtures/release-plan-v1.source.json @@ -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"} diff --git a/test/scripts/release-ci-summary.test.ts b/test/scripts/release-ci-summary.test.ts index 8051c513fcdf..170181ea6674 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 | 4; + version?: 2 | 3; workflowFullRef?: string; workflowRefType?: "branch" | "tag"; workflowSha?: string; @@ -436,7 +436,7 @@ function rawManifest({ targetRef?: string; targetSha: string; validationInputs: Record; - 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); diff --git a/test/scripts/release-plan-contract.test.ts b/test/scripts/release-plan-contract.test.ts index 69d9b5470b23..1061cc4f18b7 100644 --- a/test/scripts/release-plan-contract.test.ts +++ b/test/scripts/release-plan-contract.test.ts @@ -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; -const lockFixture = JSON.parse( - readFileSync(resolve(fixtureDir, "release-plan-lock-v1.compatibility.json"), "utf8"), -) as Record; +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; +const lockFixture = JSON.parse(lockText) as Record; 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), 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" }); }); }); diff --git a/test/scripts/release-plan-producer.test.ts b/test/scripts/release-plan-producer.test.ts index f6a6f81506a0..7b7bb3d64327 100644 --- a/test/scripts/release-plan-producer.test.ts +++ b/test/scripts/release-plan-producer.test.ts @@ -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, + 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", + ); + } }); }); diff --git a/test/scripts/validate-full-release-validation-evidence.test.ts b/test/scripts/validate-full-release-validation-evidence.test.ts index a7c6e39edad9..5ad2ea3ebf59 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.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", ); });