diff --git a/scripts/release-plan-contract.d.mts b/scripts/release-plan-contract.d.mts index ecd25f16cf4d..a0566562f929 100644 --- a/scripts/release-plan-contract.d.mts +++ b/scripts/release-plan-contract.d.mts @@ -42,6 +42,16 @@ 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 canonicalReleaseJson(value: unknown): string; +export function releaseCanonicalDigest(value: unknown): string; +export function parseCanonicalReleaseJson( + text: string, + options?: { + label?: string; + maxBytes?: number; + validate?: (value: unknown) => T; + }, +): T; export function validateReleasePlan(value: unknown): ReleasePlan; export function canonicalReleasePlanJson(value: unknown): string; export function releasePlanDigest(value: unknown): string; diff --git a/scripts/release-plan-contract.mjs b/scripts/release-plan-contract.mjs index 0703c541e2ed..aca7890f2aa6 100644 --- a/scripts/release-plan-contract.mjs +++ b/scripts/release-plan-contract.mjs @@ -140,7 +140,7 @@ function canonicalize(value, path = "$", ancestors = new Set()) { } } -function canonicalAsciiJson(value) { +export function canonicalReleaseJson(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"); @@ -148,6 +148,44 @@ function canonicalAsciiJson(value) { return json; } +export function releaseCanonicalDigest(value) { + return `sha256:${createHash("sha256").update(canonicalReleaseJson(value), "ascii").digest("hex")}`; +} + +export function parseCanonicalReleaseJson( + text, + { label = "release JSON", maxBytes = RELEASE_PLAN_MAX_BYTES, validate = (value) => value } = {}, +) { + if (typeof text !== "string" || Buffer.byteLength(text, "utf8") > maxBytes) { + fail(`${label} is missing or too large`); + } + if (!/^[\x20-\x7e]+\n$/u.test(text)) { + fail(`${label} must be compact printable ASCII with exactly one trailing LF`); + } + 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 + ? `${label} contains a duplicate key` + : `${label} is invalid: ${document.errors[0].message}`, + ); + } + let value; + try { + value = JSON.parse(text); + } catch (error) { + throw new Error(`${label} is invalid JSON`, { cause: error }); + } + const validated = validate(value); + if (text !== canonicalReleaseJson(validated)) { + fail(`${label} does not use canonical bytes`); + } + return validated; +} + function validatePackages(value) { if (!Array.isArray(value) || value.length === 0) { fail("release plan packages must be a non-empty array"); @@ -338,18 +376,18 @@ export function validateReleasePlan(value) { fail(`release plan tooling workflow_path must be ${WORKFLOW_PATH}`); } validateToolingRoute(plan.purpose, plan.tooling.ref, plan.tooling.sha); - if (Buffer.byteLength(canonicalAsciiJson(plan), "ascii") > RELEASE_PLAN_MAX_BYTES) { + if (Buffer.byteLength(canonicalReleaseJson(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)); + return canonicalReleaseJson(validateReleasePlan(value)); } export function releasePlanDigest(value) { - return `sha256:${createHash("sha256").update(canonicalReleasePlanJson(value), "ascii").digest("hex")}`; + return releaseCanonicalDigest(validateReleasePlan(value)); } export function createReleasePlanLock(value) { @@ -379,36 +417,13 @@ export function validateReleasePlanLock(value) { } export function canonicalReleasePlanLockJson(value) { - return canonicalAsciiJson(validateReleasePlanLock(value)); + return canonicalReleaseJson(validateReleasePlanLock(value)); } export function parseReleasePlanLockJson(text) { - if (typeof text !== "string" || Buffer.byteLength(text, "utf8") > RELEASE_PLAN_MAX_BYTES + 4096) { - fail("release plan lock JSON is missing or too large"); - } - if (!/^[\x20-\x7e]+\n$/u.test(text)) { - fail("release plan lock JSON must be compact printable ASCII with exactly one trailing LF"); - } - 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}`, - ); - } - 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; + return parseCanonicalReleaseJson(text, { + label: "release plan lock JSON", + maxBytes: RELEASE_PLAN_MAX_BYTES + 4096, + validate: validateReleasePlanLock, + }); } diff --git a/scripts/release-validation-receipt-contract.d.mts b/scripts/release-validation-receipt-contract.d.mts new file mode 100644 index 000000000000..c4176ea8a68b --- /dev/null +++ b/scripts/release-validation-receipt-contract.d.mts @@ -0,0 +1,165 @@ +import type { + ReleaseValidationIntent, + ReleaseValidationProfile, + ReleaseValidationPurpose, +} from "./release-validation-intent.mjs"; + +export type ReleaseValidationReceiptDigest = `sha256:${string}`; +export type ReleaseValidationReceiptRunConclusion = + | "action_required" + | "cancelled" + | "failure" + | "neutral" + | "skipped" + | "stale" + | "startup_failure" + | "success" + | "timed_out"; + +export type ReleaseValidationAttempt = { + workflow_path: ".github/workflows/full-release-validation.yml"; + workflow_name: "Full Release Validation"; + workflow_ref: string; + workflow_sha: string; + run_id: string; + run_attempt: number; + url: string; +}; + +export type ReleaseValidationSourceAttempt = { + schema: string; + digest: ReleaseValidationReceiptDigest; + parent_run_attempt: number; + source_parent_run_attempt?: number; +}; + +export type ReleaseValidationReceipt = { + schema: "openclaw.release-validation-receipt.v1"; + canonicalization: "ascii-sorted-compact-json-trailing-newline-v1"; + target: { + repository: "openclaw/openclaw"; + ref: string; + sha: string; + }; + tooling: { + repository: "openclaw/openclaw"; + ref: string; + sha: string; + }; + attempt: ReleaseValidationAttempt; + release_plan: { + schema: "openclaw.release-plan.v1"; + purpose: ReleaseValidationPurpose; + plan_digest: ReleaseValidationReceiptDigest; + lock_digest: ReleaseValidationReceiptDigest; + }; + validation: { + intent: ReleaseValidationIntent; + profile: ReleaseValidationProfile; + soak: boolean; + policy: { + id: "openclaw.release-validation-policy.v1"; + fail_fast: boolean; + outcome: "blocked" | "orchestration-error" | "passed"; + }; + }; + source_attempts: { + execution_plan: ReleaseValidationSourceAttempt; + decision: Required; + diagnostic_drain: Required; + }; + groups: Array<{ + id: string; + mode: "blocking" | "diagnostic"; + policy: string; + }>; + child_runs: Array<{ + group: string; + workflow_path: string; + run_id: string; + run_attempt: number; + workflow_sha: string; + conclusion: ReleaseValidationReceiptRunConclusion; + url: string; + }>; + observed_jobs: Array<{ + group: string; + name: string; + policy: "advisory" | "blocking"; + status: "completed"; + conclusion: ReleaseValidationReceiptRunConclusion; + started_at: string | null; + completed_at: string | null; + url: string; + }>; + source_artifacts: Array<{ + kind: + | "candidate" + | "child-evidence" + | "decision" + | "diagnostic-drain" + | "execution-plan" + | "release-plan-lock" + | "validation-manifest"; + artifact_id: string; + artifact_name: string; + entry_name: string; + run_id: string; + run_attempt: number; + archive_digest: ReleaseValidationReceiptDigest; + content_digest: ReleaseValidationReceiptDigest; + created_at: string; + }>; + timestamps: { + started_at: string; + decision_at: string; + drain_completed_at: string; + sealed_at: string; + }; + lineage: { + generation: number; + root_receipt_digest: ReleaseValidationReceiptDigest | null; + parent_receipt_digest: ReleaseValidationReceiptDigest | null; + }; +}; + +export type ReleaseValidationReceiptLocator = { + schema: "openclaw.release-validation-receipt-locator.v1"; + canonicalization: "ascii-sorted-compact-json-trailing-newline-v1"; + receipt_digest: ReleaseValidationReceiptDigest; + locator: { + repository: "openclaw/openclaw"; + run_id: string; + run_attempt: number; + artifact_id: string; + artifact_name: string; + entry_name: "release-validation-receipt.json"; + archive_digest: ReleaseValidationReceiptDigest; + }; + sealed_at: string; +}; + +export const RELEASE_VALIDATION_RECEIPT_SCHEMA: "openclaw.release-validation-receipt.v1"; +export const RELEASE_VALIDATION_RECEIPT_LOCATOR_SCHEMA: "openclaw.release-validation-receipt-locator.v1"; +export const RELEASE_VALIDATION_POLICY_ID: "openclaw.release-validation-policy.v1"; +export const RELEASE_VALIDATION_RECEIPT_MAX_BYTES: number; +export const RELEASE_VALIDATION_RECEIPT_LOCATOR_MAX_BYTES: number; +export function validateReleaseValidationReceipt(value: unknown): ReleaseValidationReceipt; +export function canonicalReleaseValidationReceiptJson(value: unknown): string; +export function releaseValidationReceiptDigest(value: unknown): ReleaseValidationReceiptDigest; +export function parseReleaseValidationReceiptJson(text: string): ReleaseValidationReceipt; +export function validateReleaseValidationReceiptLocator( + value: unknown, +): ReleaseValidationReceiptLocator; +export function createReleaseValidationReceiptLocator( + receiptValue: unknown, + locatorValue: unknown, +): ReleaseValidationReceiptLocator; +export function validateReleaseValidationReceiptLocatorForReceipt( + locatorValue: unknown, + receiptValue: unknown, +): ReleaseValidationReceiptLocator; +export function canonicalReleaseValidationReceiptLocatorJson(value: unknown): string; +export function parseReleaseValidationReceiptLocatorJson( + text: string, +): ReleaseValidationReceiptLocator; diff --git a/scripts/release-validation-receipt-contract.mjs b/scripts/release-validation-receipt-contract.mjs new file mode 100644 index 000000000000..b70a5e8ecd76 --- /dev/null +++ b/scripts/release-validation-receipt-contract.mjs @@ -0,0 +1,930 @@ +import { isRecord } from "./lib/record-shared.mjs"; +import { + canonicalReleaseJson, + parseCanonicalReleaseJson, + releaseCanonicalDigest, + RELEASE_PLAN_CANONICALIZATION, +} from "./release-plan-contract.mjs"; +import { + releaseValidationIntentForPurpose, + resolveReleaseValidationIntent, +} from "./release-validation-intent.mjs"; + +export const RELEASE_VALIDATION_RECEIPT_SCHEMA = "openclaw.release-validation-receipt.v1"; +export const RELEASE_VALIDATION_RECEIPT_LOCATOR_SCHEMA = + "openclaw.release-validation-receipt-locator.v1"; +export const RELEASE_VALIDATION_POLICY_ID = "openclaw.release-validation-policy.v1"; +export const RELEASE_VALIDATION_RECEIPT_MAX_BYTES = 256 * 1024; +export const RELEASE_VALIDATION_RECEIPT_LOCATOR_MAX_BYTES = 16 * 1024; + +const REPOSITORY = "openclaw/openclaw"; +const WORKFLOW_PATH = ".github/workflows/full-release-validation.yml"; +const WORKFLOW_NAME = "Full Release Validation"; +const SHA_PATTERN = /^[a-f0-9]{40}$/u; +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u; +const ASCII_PATTERN = /^[\x20-\x7e]+$/u; +const RUN_ID_PATTERN = /^[1-9][0-9]*$/u; +const REF_PATTERN = /^refs\/(?:heads|tags)\/[A-Za-z0-9._/-]+$/u; +const TIMESTAMP_PATTERN = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/u; +const URL_PATTERN = + /^https:\/\/github\.com\/openclaw\/openclaw\/actions\/runs\/[1-9][0-9]*(?:\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?$/u; +const GROUP_MODES = new Set(["blocking", "diagnostic"]); +const POLICY_OUTCOMES = new Set(["blocked", "orchestration-error", "passed"]); +const RUN_CONCLUSIONS = new Set([ + "action_required", + "cancelled", + "failure", + "neutral", + "skipped", + "stale", + "startup_failure", + "success", + "timed_out", +]); +const JOB_POLICIES = new Set(["advisory", "blocking"]); +const SOURCE_ARTIFACT_KINDS = new Set([ + "candidate", + "child-evidence", + "decision", + "diagnostic-drain", + "execution-plan", + "release-plan-lock", + "validation-manifest", +]); +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 object(value, label) { + if (!isRecord(value)) { + fail(`${label} must be an object`); + } + return value; +} + +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 enumString(value, allowed, label) { + const result = asciiString(value, label); + if (!allowed.has(result)) { + fail(`${label} contains unsupported value: ${result}`); + } + return result; +} + +function sha(value, label) { + const result = asciiString(value, label); + if (!SHA_PATTERN.test(result)) { + fail(`${label} must be a lowercase 40-character commit SHA`); + } + return result; +} + +function digest(value, label) { + if (typeof value !== "string" || !DIGEST_PATTERN.test(value)) { + fail(`${label} must be sha256:<64 lowercase hex characters>`); + } + return value; +} + +function runId(value, label) { + const result = asciiString(value, label); + if (!RUN_ID_PATTERN.test(result)) { + fail(`${label} must be a positive integer string`); + } + return result; +} + +function positiveInteger(value, label) { + if (!Number.isSafeInteger(value) || value < 1) { + fail(`${label} must be a positive safe integer`); + } + return value; +} + +function nonNegativeInteger(value, label) { + if (!Number.isSafeInteger(value) || value < 0) { + fail(`${label} must be a non-negative safe integer`); + } + return value; +} + +function timestamp(value, label) { + const result = asciiString(value, label); + if (!TIMESTAMP_PATTERN.test(result)) { + fail(`${label} must use canonical UTC seconds`); + } + const milliseconds = Date.parse(result); + if ( + !Number.isFinite(milliseconds) || + new Date(milliseconds).toISOString().replace(".000Z", "Z") !== result + ) { + fail(`${label} must be a valid canonical UTC timestamp`); + } + return result; +} + +function nullableTimestamp(value, label) { + return value === null ? null : timestamp(value, label); +} + +function actionUrl(value, label) { + const result = asciiString(value, label); + if (!URL_PATTERN.test(result)) { + fail(`${label} must be an OpenClaw GitHub Actions URL`); + } + return result; +} + +function qualifiedRef(value, label) { + const result = asciiString(value, label); + if (!REF_PATTERN.test(result)) { + fail(`${label} must be a qualified branch or tag ref`); + } + return result; +} + +function targetRef(value, targetSha, label) { + const result = asciiString(value, label); + if (result !== targetSha && !REF_PATTERN.test(result)) { + fail(`${label} must be the target SHA or a qualified branch or tag ref`); + } + return result; +} + +function boolean(value, label) { + if (typeof value !== "boolean") { + fail(`${label} must be boolean`); + } + return value; +} + +function nullableDigest(value, label) { + return value === null ? null : digest(value, label); +} + +function validateTarget(value) { + const target = object(value, "release validation receipt target"); + exactKeys(target, ["repository", "ref", "sha"], "release validation receipt target"); + const targetSha = sha(target.sha, "release validation receipt target SHA"); + const result = { + repository: asciiString(target.repository, "release validation receipt target repository"), + ref: targetRef(target.ref, targetSha, "release validation receipt target ref"), + sha: targetSha, + }; + if (result.repository !== REPOSITORY) { + fail(`release validation receipt target repository must be ${REPOSITORY}`); + } + return result; +} + +function validateTooling(value) { + const tooling = object(value, "release validation receipt tooling"); + exactKeys(tooling, ["repository", "ref", "sha"], "release validation receipt tooling"); + const result = { + repository: asciiString(tooling.repository, "release validation receipt tooling repository"), + ref: qualifiedRef(tooling.ref, "release validation receipt tooling ref"), + sha: sha(tooling.sha, "release validation receipt tooling SHA"), + }; + if (result.repository !== REPOSITORY) { + fail(`release validation receipt tooling repository must be ${REPOSITORY}`); + } + return result; +} + +function validateAttempt(value, tooling) { + const attempt = object(value, "release validation receipt attempt"); + exactKeys( + attempt, + [ + "workflow_path", + "workflow_name", + "workflow_ref", + "workflow_sha", + "run_id", + "run_attempt", + "url", + ], + "release validation receipt attempt", + ); + const result = { + workflow_path: asciiString( + attempt.workflow_path, + "release validation receipt attempt workflow_path", + ), + workflow_name: asciiString( + attempt.workflow_name, + "release validation receipt attempt workflow_name", + ), + workflow_ref: qualifiedRef( + attempt.workflow_ref, + "release validation receipt attempt workflow_ref", + ), + workflow_sha: sha(attempt.workflow_sha, "release validation receipt attempt workflow_sha"), + run_id: runId(attempt.run_id, "release validation receipt attempt run_id"), + run_attempt: positiveInteger( + attempt.run_attempt, + "release validation receipt attempt run_attempt", + ), + url: actionUrl(attempt.url, "release validation receipt attempt URL"), + }; + if (result.workflow_path !== WORKFLOW_PATH || result.workflow_name !== WORKFLOW_NAME) { + fail("release validation receipt attempt must identify Full Release Validation"); + } + if (!result.url.includes(`/actions/runs/${result.run_id}`)) { + fail("release validation receipt attempt URL must bind its run_id"); + } + if (result.workflow_ref !== tooling.ref || result.workflow_sha !== tooling.sha) { + fail("release validation receipt attempt workflow identity differs from tooling"); + } + return result; +} + +function validateReleasePlanBinding(value) { + const releasePlan = object(value, "release validation receipt release_plan"); + exactKeys( + releasePlan, + ["schema", "purpose", "plan_digest", "lock_digest"], + "release validation receipt release_plan", + ); + if (releasePlan.schema !== "openclaw.release-plan.v1") { + fail("release validation receipt release_plan schema is unsupported"); + } + return { + schema: "openclaw.release-plan.v1", + purpose: asciiString(releasePlan.purpose, "release validation receipt release_plan purpose"), + plan_digest: digest( + releasePlan.plan_digest, + "release validation receipt release_plan plan_digest", + ), + lock_digest: digest( + releasePlan.lock_digest, + "release validation receipt release_plan lock_digest", + ), + }; +} + +function validateValidation(value, purpose) { + const validation = object(value, "release validation receipt validation"); + exactKeys( + validation, + ["intent", "profile", "soak", "policy"], + "release validation receipt validation", + ); + const policy = object(validation.policy, "release validation receipt validation policy"); + exactKeys(policy, ["id", "fail_fast", "outcome"], "release validation receipt validation policy"); + const intent = asciiString(validation.intent, "release validation receipt validation intent"); + releaseValidationIntentForPurpose(purpose, intent); + const resolved = resolveReleaseValidationIntent(intent, { + profile: asciiString(validation.profile, "release validation receipt validation profile"), + soak: boolean(validation.soak, "release validation receipt validation soak"), + }); + const policyId = asciiString(policy.id, "release validation receipt validation policy id"); + if (policyId !== RELEASE_VALIDATION_POLICY_ID) { + fail(`release validation receipt validation policy id must be ${RELEASE_VALIDATION_POLICY_ID}`); + } + return { + intent: resolved.intent, + profile: resolved.profile, + soak: resolved.soak, + policy: { + id: policyId, + fail_fast: boolean( + policy.fail_fast, + "release validation receipt validation policy fail_fast", + ), + outcome: enumString( + policy.outcome, + POLICY_OUTCOMES, + "release validation receipt validation policy outcome", + ), + }, + }; +} + +function validateSourceAttempt(value, label, withSourceAttempt) { + const source = object(value, label); + exactKeys( + source, + withSourceAttempt + ? ["schema", "digest", "parent_run_attempt", "source_parent_run_attempt"] + : ["schema", "digest", "parent_run_attempt"], + label, + ); + return { + schema: asciiString(source.schema, `${label} schema`), + digest: digest(source.digest, `${label} digest`), + parent_run_attempt: positiveInteger(source.parent_run_attempt, `${label} parent_run_attempt`), + ...(withSourceAttempt + ? { + source_parent_run_attempt: positiveInteger( + source.source_parent_run_attempt, + `${label} source_parent_run_attempt`, + ), + } + : {}), + }; +} + +function validateSourceAttempts(value, attempt) { + const sources = object(value, "release validation receipt source_attempts"); + exactKeys( + sources, + ["execution_plan", "decision", "diagnostic_drain"], + "release validation receipt source_attempts", + ); + const executionPlan = validateSourceAttempt( + sources.execution_plan, + "release validation receipt source_attempts execution_plan", + false, + ); + const decision = validateSourceAttempt( + sources.decision, + "release validation receipt source_attempts decision", + true, + ); + const diagnosticDrain = validateSourceAttempt( + sources.diagnostic_drain, + "release validation receipt source_attempts diagnostic_drain", + true, + ); + if ( + executionPlan.schema !== "openclaw.full-release-execution-plan.v1" || + decision.schema !== "openclaw.full-release-decision.v2" || + diagnosticDrain.schema !== "openclaw.full-release-diagnostic-drain.v2" + ) { + fail("release validation receipt source_attempts schema is unsupported"); + } + if ( + decision.source_parent_run_attempt !== executionPlan.parent_run_attempt || + diagnosticDrain.source_parent_run_attempt !== executionPlan.parent_run_attempt + ) { + fail("release validation receipt state attempts must bind the execution plan attempt"); + } + if ( + executionPlan.parent_run_attempt > attempt.run_attempt || + decision.parent_run_attempt > attempt.run_attempt || + diagnosticDrain.parent_run_attempt > attempt.run_attempt + ) { + fail("release validation receipt source attempt cannot exceed the receipt attempt"); + } + return { + execution_plan: executionPlan, + decision, + diagnostic_drain: diagnosticDrain, + }; +} + +function validateGroups(value) { + if (!Array.isArray(value) || value.length === 0) { + fail("release validation receipt groups must be a non-empty array"); + } + const groups = value.map((entry, index) => { + const group = object(entry, `release validation receipt groups[${index}]`); + exactKeys(group, ["id", "mode", "policy"], `release validation receipt groups[${index}]`); + return { + id: asciiString(group.id, `release validation receipt groups[${index}].id`), + mode: enumString(group.mode, GROUP_MODES, `release validation receipt groups[${index}].mode`), + policy: asciiString(group.policy, `release validation receipt groups[${index}].policy`), + }; + }); + const ids = groups.map((group) => group.id); + if ( + new Set(ids).size !== ids.length || + ids.some((id, index) => index > 0 && compareAscii(ids[index - 1], id) >= 0) + ) { + fail("release validation receipt groups must have unique ids in ascending ASCII order"); + } + return groups; +} + +function validateChildRuns(value, groups, tooling) { + if (!Array.isArray(value) || value.length === 0) { + fail("release validation receipt child_runs must be a non-empty array"); + } + const groupIds = new Set(groups.map((group) => group.id)); + const children = value.map((entry, index) => { + const child = object(entry, `release validation receipt child_runs[${index}]`); + exactKeys( + child, + ["group", "workflow_path", "run_id", "run_attempt", "workflow_sha", "conclusion", "url"], + `release validation receipt child_runs[${index}]`, + ); + const result = { + group: asciiString(child.group, `release validation receipt child_runs[${index}].group`), + workflow_path: asciiString( + child.workflow_path, + `release validation receipt child_runs[${index}].workflow_path`, + ), + run_id: runId(child.run_id, `release validation receipt child_runs[${index}].run_id`), + run_attempt: positiveInteger( + child.run_attempt, + `release validation receipt child_runs[${index}].run_attempt`, + ), + workflow_sha: sha( + child.workflow_sha, + `release validation receipt child_runs[${index}].workflow_sha`, + ), + conclusion: enumString( + child.conclusion, + RUN_CONCLUSIONS, + `release validation receipt child_runs[${index}].conclusion`, + ), + url: actionUrl(child.url, `release validation receipt child_runs[${index}].url`), + }; + if (!groupIds.has(result.group)) { + fail(`release validation receipt child_runs[${index}] references an unknown group`); + } + if (result.workflow_sha !== tooling.sha) { + fail(`release validation receipt child_runs[${index}] workflow SHA differs from tooling`); + } + if (!result.url.includes(`/actions/runs/${result.run_id}`)) { + fail(`release validation receipt child_runs[${index}] URL must bind its run_id`); + } + return result; + }); + const groupNames = children.map((child) => child.group); + if ( + new Set(groupNames).size !== groupNames.length || + groupNames.some((group, index) => index > 0 && compareAscii(groupNames[index - 1], group) >= 0) + ) { + fail("release validation receipt child_runs must have one child per group in ASCII order"); + } + if (groupNames.length !== groups.length || groupNames.some((group) => !groupIds.has(group))) { + fail("release validation receipt child_runs must cover every declared group"); + } + return children; +} + +function validateObservedJobs(value, children) { + if (!Array.isArray(value) || value.length === 0) { + fail("release validation receipt observed_jobs must be a non-empty array"); + } + const childByGroup = new Map(children.map((child) => [child.group, child])); + const jobs = value.map((entry, index) => { + const job = object(entry, `release validation receipt observed_jobs[${index}]`); + exactKeys( + job, + ["group", "name", "policy", "status", "conclusion", "started_at", "completed_at", "url"], + `release validation receipt observed_jobs[${index}]`, + ); + const result = { + group: asciiString(job.group, `release validation receipt observed_jobs[${index}].group`), + name: asciiString(job.name, `release validation receipt observed_jobs[${index}].name`), + policy: enumString( + job.policy, + JOB_POLICIES, + `release validation receipt observed_jobs[${index}].policy`, + ), + status: asciiString(job.status, `release validation receipt observed_jobs[${index}].status`), + conclusion: enumString( + job.conclusion, + RUN_CONCLUSIONS, + `release validation receipt observed_jobs[${index}].conclusion`, + ), + started_at: nullableTimestamp( + job.started_at, + `release validation receipt observed_jobs[${index}].started_at`, + ), + completed_at: nullableTimestamp( + job.completed_at, + `release validation receipt observed_jobs[${index}].completed_at`, + ), + url: actionUrl(job.url, `release validation receipt observed_jobs[${index}].url`), + }; + const child = childByGroup.get(result.group); + if (!child) { + fail(`release validation receipt observed_jobs[${index}] references an unknown group`); + } + if (result.status !== "completed") { + fail(`release validation receipt observed_jobs[${index}] must be terminal`); + } + if ( + result.started_at !== null && + result.completed_at !== null && + Date.parse(result.started_at) > Date.parse(result.completed_at) + ) { + fail(`release validation receipt observed_jobs[${index}] timestamps are reversed`); + } + if (!result.url.includes(`/actions/runs/${child.run_id}`)) { + fail(`release validation receipt observed_jobs[${index}] URL must bind its child run`); + } + return result; + }); + const identities = jobs.map((job) => `${job.group}\0${job.name}`); + if ( + new Set(identities).size !== identities.length || + identities.some( + (identity, index) => index > 0 && compareAscii(identities[index - 1], identity) >= 0, + ) + ) { + fail("release validation receipt observed_jobs must be unique in group/name ASCII order"); + } + for (const group of childByGroup.keys()) { + if (!jobs.some((job) => job.group === group)) { + fail(`release validation receipt observed_jobs omitted group: ${group}`); + } + } + return jobs; +} + +function validateSourceArtifacts(value, attempt, releasePlan, sourceAttempts) { + if (!Array.isArray(value) || value.length < 4) { + fail("release validation receipt source_artifacts must contain required source artifacts"); + } + const artifacts = value.map((entry, index) => { + const artifact = object(entry, `release validation receipt source_artifacts[${index}]`); + exactKeys( + artifact, + [ + "kind", + "artifact_id", + "artifact_name", + "entry_name", + "run_id", + "run_attempt", + "archive_digest", + "content_digest", + "created_at", + ], + `release validation receipt source_artifacts[${index}]`, + ); + return { + kind: enumString( + artifact.kind, + SOURCE_ARTIFACT_KINDS, + `release validation receipt source_artifacts[${index}].kind`, + ), + artifact_id: runId( + artifact.artifact_id, + `release validation receipt source_artifacts[${index}].artifact_id`, + ), + artifact_name: asciiString( + artifact.artifact_name, + `release validation receipt source_artifacts[${index}].artifact_name`, + ), + entry_name: asciiString( + artifact.entry_name, + `release validation receipt source_artifacts[${index}].entry_name`, + ), + run_id: runId( + artifact.run_id, + `release validation receipt source_artifacts[${index}].run_id`, + ), + run_attempt: positiveInteger( + artifact.run_attempt, + `release validation receipt source_artifacts[${index}].run_attempt`, + ), + archive_digest: digest( + artifact.archive_digest, + `release validation receipt source_artifacts[${index}].archive_digest`, + ), + content_digest: digest( + artifact.content_digest, + `release validation receipt source_artifacts[${index}].content_digest`, + ), + created_at: timestamp( + artifact.created_at, + `release validation receipt source_artifacts[${index}].created_at`, + ), + }; + }); + const identities = artifacts.map((artifact) => `${artifact.kind}\0${artifact.artifact_name}`); + if ( + new Set(identities).size !== identities.length || + identities.some( + (identity, index) => index > 0 && compareAscii(identities[index - 1], identity) >= 0, + ) + ) { + fail("release validation receipt source_artifacts must be unique in kind/name ASCII order"); + } + const required = [ + ["execution-plan", sourceAttempts.execution_plan], + ["decision", sourceAttempts.decision], + ["diagnostic-drain", sourceAttempts.diagnostic_drain], + ]; + for (const [kind, source] of required) { + const matches = artifacts.filter((artifact) => artifact.kind === kind); + if ( + matches.length !== 1 || + matches[0].run_id !== attempt.run_id || + matches[0].run_attempt !== source.parent_run_attempt || + matches[0].content_digest !== source.digest + ) { + fail(`release validation receipt source_artifacts ${kind} binding is invalid`); + } + } + const planLocks = artifacts.filter((artifact) => artifact.kind === "release-plan-lock"); + if (planLocks.length !== 1 || planLocks[0].content_digest !== releasePlan.lock_digest) { + fail("release validation receipt release-plan-lock artifact binding is invalid"); + } + return artifacts; +} + +function validateTimestamps(value, attempt, sourceArtifacts) { + const timestamps = object(value, "release validation receipt timestamps"); + exactKeys( + timestamps, + ["started_at", "decision_at", "drain_completed_at", "sealed_at"], + "release validation receipt timestamps", + ); + const result = { + started_at: timestamp( + timestamps.started_at, + "release validation receipt timestamps started_at", + ), + decision_at: timestamp( + timestamps.decision_at, + "release validation receipt timestamps decision_at", + ), + drain_completed_at: timestamp( + timestamps.drain_completed_at, + "release validation receipt timestamps drain_completed_at", + ), + sealed_at: timestamp(timestamps.sealed_at, "release validation receipt timestamps sealed_at"), + }; + const ordered = [ + result.started_at, + result.decision_at, + result.drain_completed_at, + result.sealed_at, + ].map(Date.parse); + if (ordered.some((entry, index) => index > 0 && ordered[index - 1] > entry)) { + fail("release validation receipt timestamps must be chronological"); + } + if ( + sourceArtifacts.some( + (artifact) => Date.parse(artifact.created_at) > Date.parse(result.sealed_at), + ) + ) { + fail("release validation receipt cannot precede a source artifact"); + } + if (!attempt.url.includes(`/actions/runs/${attempt.run_id}`)) { + fail("release validation receipt timestamp attempt binding is invalid"); + } + return result; +} + +function validateLineage(value) { + const lineage = object(value, "release validation receipt lineage"); + exactKeys( + lineage, + ["generation", "root_receipt_digest", "parent_receipt_digest"], + "release validation receipt lineage", + ); + const result = { + generation: nonNegativeInteger( + lineage.generation, + "release validation receipt lineage generation", + ), + root_receipt_digest: nullableDigest( + lineage.root_receipt_digest, + "release validation receipt lineage root_receipt_digest", + ), + parent_receipt_digest: nullableDigest( + lineage.parent_receipt_digest, + "release validation receipt lineage parent_receipt_digest", + ), + }; + if ( + (result.generation === 0 && + (result.root_receipt_digest !== null || result.parent_receipt_digest !== null)) || + (result.generation > 0 && + (result.root_receipt_digest === null || result.parent_receipt_digest === null)) + ) { + fail("release validation receipt lineage generation and digests disagree"); + } + return result; +} + +export function validateReleaseValidationReceipt(value) { + const receipt = object(value, "release validation receipt"); + exactKeys( + receipt, + [ + "schema", + "canonicalization", + "target", + "tooling", + "attempt", + "release_plan", + "validation", + "source_attempts", + "groups", + "child_runs", + "observed_jobs", + "source_artifacts", + "timestamps", + "lineage", + ], + "release validation receipt", + ); + if (receipt.schema !== RELEASE_VALIDATION_RECEIPT_SCHEMA) { + fail(`release validation receipt schema must be ${RELEASE_VALIDATION_RECEIPT_SCHEMA}`); + } + if (receipt.canonicalization !== RELEASE_PLAN_CANONICALIZATION) { + fail(`release validation receipt canonicalization must be ${RELEASE_PLAN_CANONICALIZATION}`); + } + const target = validateTarget(receipt.target); + const tooling = validateTooling(receipt.tooling); + const attempt = validateAttempt(receipt.attempt, tooling); + const releasePlan = validateReleasePlanBinding(receipt.release_plan); + const validation = validateValidation(receipt.validation, releasePlan.purpose); + const sourceAttempts = validateSourceAttempts(receipt.source_attempts, attempt); + const groups = validateGroups(receipt.groups); + const childRuns = validateChildRuns(receipt.child_runs, groups, tooling); + const observedJobs = validateObservedJobs(receipt.observed_jobs, childRuns); + const sourceArtifacts = validateSourceArtifacts( + receipt.source_artifacts, + attempt, + releasePlan, + sourceAttempts, + ); + const timestamps = validateTimestamps(receipt.timestamps, attempt, sourceArtifacts); + const lineage = validateLineage(receipt.lineage); + const result = { + schema: RELEASE_VALIDATION_RECEIPT_SCHEMA, + canonicalization: RELEASE_PLAN_CANONICALIZATION, + target, + tooling, + attempt, + release_plan: releasePlan, + validation, + source_attempts: sourceAttempts, + groups, + child_runs: childRuns, + observed_jobs: observedJobs, + source_artifacts: sourceArtifacts, + timestamps, + lineage, + }; + if ( + validation.policy.outcome === "passed" && + observedJobs.some((job) => job.policy === "blocking" && job.conclusion !== "success") + ) { + fail("release validation receipt passed outcome contains failed blocking evidence"); + } + if ( + Buffer.byteLength(canonicalReleaseJson(result), "ascii") > RELEASE_VALIDATION_RECEIPT_MAX_BYTES + ) { + fail(`release validation receipt exceeds ${RELEASE_VALIDATION_RECEIPT_MAX_BYTES} bytes`); + } + return result; +} + +export function canonicalReleaseValidationReceiptJson(value) { + return canonicalReleaseJson(validateReleaseValidationReceipt(value)); +} + +export function releaseValidationReceiptDigest(value) { + return releaseCanonicalDigest(validateReleaseValidationReceipt(value)); +} + +export function parseReleaseValidationReceiptJson(text) { + return parseCanonicalReleaseJson(text, { + label: "release validation receipt JSON", + maxBytes: RELEASE_VALIDATION_RECEIPT_MAX_BYTES, + validate: validateReleaseValidationReceipt, + }); +} + +export function validateReleaseValidationReceiptLocator(value) { + const envelope = object(value, "release validation receipt locator"); + exactKeys( + envelope, + ["schema", "canonicalization", "receipt_digest", "locator", "sealed_at"], + "release validation receipt locator", + ); + if (envelope.schema !== RELEASE_VALIDATION_RECEIPT_LOCATOR_SCHEMA) { + fail( + `release validation receipt locator schema must be ${RELEASE_VALIDATION_RECEIPT_LOCATOR_SCHEMA}`, + ); + } + if (envelope.canonicalization !== RELEASE_PLAN_CANONICALIZATION) { + fail( + `release validation receipt locator canonicalization must be ${RELEASE_PLAN_CANONICALIZATION}`, + ); + } + const locator = object(envelope.locator, "release validation receipt locator coordinates"); + exactKeys( + locator, + [ + "repository", + "run_id", + "run_attempt", + "artifact_id", + "artifact_name", + "entry_name", + "archive_digest", + ], + "release validation receipt locator coordinates", + ); + const result = { + schema: RELEASE_VALIDATION_RECEIPT_LOCATOR_SCHEMA, + canonicalization: RELEASE_PLAN_CANONICALIZATION, + receipt_digest: digest( + envelope.receipt_digest, + "release validation receipt locator receipt_digest", + ), + locator: { + repository: asciiString(locator.repository, "release validation receipt locator repository"), + run_id: runId(locator.run_id, "release validation receipt locator run_id"), + run_attempt: positiveInteger( + locator.run_attempt, + "release validation receipt locator run_attempt", + ), + artifact_id: runId(locator.artifact_id, "release validation receipt locator artifact_id"), + artifact_name: asciiString( + locator.artifact_name, + "release validation receipt locator artifact_name", + ), + entry_name: asciiString(locator.entry_name, "release validation receipt locator entry_name"), + archive_digest: digest( + locator.archive_digest, + "release validation receipt locator archive_digest", + ), + }, + sealed_at: timestamp(envelope.sealed_at, "release validation receipt locator sealed_at"), + }; + if (result.locator.repository !== REPOSITORY) { + fail(`release validation receipt locator repository must be ${REPOSITORY}`); + } + if (result.locator.entry_name !== "release-validation-receipt.json") { + fail("release validation receipt locator entry_name is unsupported"); + } + if ( + result.locator.artifact_name !== + `release-validation-receipt-${result.locator.run_id}-${result.locator.run_attempt}` + ) { + fail("release validation receipt locator artifact_name must bind its run and attempt"); + } + if ( + Buffer.byteLength(canonicalReleaseJson(result), "ascii") > + RELEASE_VALIDATION_RECEIPT_LOCATOR_MAX_BYTES + ) { + fail( + `release validation receipt locator exceeds ${RELEASE_VALIDATION_RECEIPT_LOCATOR_MAX_BYTES} bytes`, + ); + } + return result; +} + +export function createReleaseValidationReceiptLocator(receiptValue, locatorValue) { + const receipt = validateReleaseValidationReceipt(receiptValue); + const locator = object(locatorValue, "release validation receipt locator coordinates"); + const envelope = validateReleaseValidationReceiptLocator({ + schema: RELEASE_VALIDATION_RECEIPT_LOCATOR_SCHEMA, + canonicalization: RELEASE_PLAN_CANONICALIZATION, + receipt_digest: releaseValidationReceiptDigest(receipt), + locator, + sealed_at: receipt.timestamps.sealed_at, + }); + if ( + envelope.locator.run_id !== receipt.attempt.run_id || + envelope.locator.run_attempt !== receipt.attempt.run_attempt + ) { + fail("release validation receipt locator attempt differs from its receipt"); + } + return envelope; +} + +export function validateReleaseValidationReceiptLocatorForReceipt(locatorValue, receiptValue) { + const locator = validateReleaseValidationReceiptLocator(locatorValue); + const receipt = validateReleaseValidationReceipt(receiptValue); + if ( + locator.receipt_digest !== releaseValidationReceiptDigest(receipt) || + locator.locator.run_id !== receipt.attempt.run_id || + locator.locator.run_attempt !== receipt.attempt.run_attempt || + locator.sealed_at !== receipt.timestamps.sealed_at + ) { + fail("release validation receipt locator differs from its receipt"); + } + return locator; +} + +export function canonicalReleaseValidationReceiptLocatorJson(value) { + return canonicalReleaseJson(validateReleaseValidationReceiptLocator(value)); +} + +export function parseReleaseValidationReceiptLocatorJson(text) { + return parseCanonicalReleaseJson(text, { + label: "release validation receipt locator JSON", + maxBytes: RELEASE_VALIDATION_RECEIPT_LOCATOR_MAX_BYTES, + validate: validateReleaseValidationReceiptLocator, + }); +} diff --git a/test/scripts/release-validation-receipt-contract.test.ts b/test/scripts/release-validation-receipt-contract.test.ts new file mode 100644 index 000000000000..b51d7a8b3523 --- /dev/null +++ b/test/scripts/release-validation-receipt-contract.test.ts @@ -0,0 +1,509 @@ +import { describe, expect, it } from "vitest"; +import { + canonicalReleaseValidationReceiptJson, + canonicalReleaseValidationReceiptLocatorJson, + createReleaseValidationReceiptLocator, + parseReleaseValidationReceiptJson, + parseReleaseValidationReceiptLocatorJson, + RELEASE_VALIDATION_RECEIPT_LOCATOR_SCHEMA, + RELEASE_VALIDATION_RECEIPT_SCHEMA, + releaseValidationReceiptDigest, + validateReleaseValidationReceiptLocatorForReceipt, + validateReleaseValidationReceipt, +} from "../../scripts/release-validation-receipt-contract.mjs"; + +const TARGET_SHA = "a".repeat(40); +const TOOLING_SHA = "b".repeat(40); +const PLAN_DIGEST = `sha256:${"1".repeat(64)}`; +const LOCK_DIGEST = `sha256:${"2".repeat(64)}`; +const EXECUTION_PLAN_DIGEST = `sha256:${"3".repeat(64)}`; +const DECISION_DIGEST = `sha256:${"4".repeat(64)}`; +const DRAIN_DIGEST = `sha256:${"5".repeat(64)}`; + +function sourceArtifact( + kind: string, + id: string, + name: string, + entryName: string, + runAttempt: number, + archiveCharacter: string, + contentDigest: string, +) { + return { + kind, + artifact_id: id, + artifact_name: name, + entry_name: entryName, + run_id: "9001", + run_attempt: runAttempt, + archive_digest: `sha256:${archiveCharacter.repeat(64)}`, + content_digest: contentDigest, + created_at: "2026-08-21T10:40:00Z", + }; +} + +function receiptFixture() { + return { + schema: RELEASE_VALIDATION_RECEIPT_SCHEMA, + canonicalization: "ascii-sorted-compact-json-trailing-newline-v1", + target: { + repository: "openclaw/openclaw", + ref: "refs/tags/v2026.8.1-beta.3", + sha: TARGET_SHA, + }, + tooling: { + repository: "openclaw/openclaw", + ref: "refs/tags/release-publish/bbbbbbbbbbbb-123", + sha: TOOLING_SHA, + }, + attempt: { + workflow_path: ".github/workflows/full-release-validation.yml", + workflow_name: "Full Release Validation", + workflow_ref: "refs/tags/release-publish/bbbbbbbbbbbb-123", + workflow_sha: TOOLING_SHA, + run_id: "9001", + run_attempt: 2, + url: "https://github.com/openclaw/openclaw/actions/runs/9001/attempts/2", + }, + release_plan: { + schema: "openclaw.release-plan.v1", + purpose: "beta-publish", + plan_digest: PLAN_DIGEST, + lock_digest: LOCK_DIGEST, + }, + validation: { + intent: "release-beta", + profile: "beta", + soak: false, + policy: { + id: "openclaw.release-validation-policy.v1", + fail_fast: false, + outcome: "passed", + }, + }, + source_attempts: { + execution_plan: { + schema: "openclaw.full-release-execution-plan.v1", + digest: EXECUTION_PLAN_DIGEST, + parent_run_attempt: 1, + }, + decision: { + schema: "openclaw.full-release-decision.v2", + digest: DECISION_DIGEST, + parent_run_attempt: 2, + source_parent_run_attempt: 1, + }, + diagnostic_drain: { + schema: "openclaw.full-release-diagnostic-drain.v2", + digest: DRAIN_DIGEST, + parent_run_attempt: 2, + source_parent_run_attempt: 1, + }, + }, + groups: [ + { id: "normal-ci", mode: "blocking", policy: "required-success" }, + { id: "performance", mode: "diagnostic", policy: "advisory-beta" }, + { id: "release-checks", mode: "blocking", policy: "required-success" }, + ], + child_runs: [ + { + group: "normal-ci", + workflow_path: ".github/workflows/ci.yml", + run_id: "9101", + run_attempt: 1, + workflow_sha: TOOLING_SHA, + conclusion: "success", + url: "https://github.com/openclaw/openclaw/actions/runs/9101", + }, + { + group: "performance", + workflow_path: ".github/workflows/openclaw-performance.yml", + run_id: "9102", + run_attempt: 1, + workflow_sha: TOOLING_SHA, + conclusion: "failure", + url: "https://github.com/openclaw/openclaw/actions/runs/9102", + }, + { + group: "release-checks", + workflow_path: ".github/workflows/openclaw-release-checks.yml", + run_id: "9103", + run_attempt: 1, + workflow_sha: TOOLING_SHA, + conclusion: "success", + url: "https://github.com/openclaw/openclaw/actions/runs/9103", + }, + ], + observed_jobs: [ + { + group: "normal-ci", + name: "test", + policy: "blocking", + status: "completed", + conclusion: "success", + started_at: "2026-08-21T09:01:00Z", + completed_at: "2026-08-21T09:21:00Z", + url: "https://github.com/openclaw/openclaw/actions/runs/9101/job/1", + }, + { + group: "performance", + name: "bench", + policy: "advisory", + status: "completed", + conclusion: "failure", + started_at: "2026-08-21T09:02:00Z", + completed_at: "2026-08-21T09:12:00Z", + url: "https://github.com/openclaw/openclaw/actions/runs/9102/job/2", + }, + { + group: "release-checks", + name: "package", + policy: "blocking", + status: "completed", + conclusion: "success", + started_at: "2026-08-21T09:03:00Z", + completed_at: "2026-08-21T10:30:00Z", + url: "https://github.com/openclaw/openclaw/actions/runs/9103/job/3", + }, + ], + source_artifacts: [ + sourceArtifact( + "decision", + "9201", + "full-release-decision-9001-2", + "full-release-decision.json", + 2, + "6", + DECISION_DIGEST, + ), + sourceArtifact( + "diagnostic-drain", + "9202", + "full-release-diagnostics-9001-2", + "full-release-diagnostic-manifest.json", + 2, + "7", + DRAIN_DIGEST, + ), + sourceArtifact( + "execution-plan", + "9203", + "full-release-execution-plan-9001", + "full-release-execution-plan.json", + 1, + "8", + EXECUTION_PLAN_DIGEST, + ), + sourceArtifact( + "release-plan-lock", + "9204", + "release-plan-lock-9001", + "release-plan-lock.json", + 2, + "9", + LOCK_DIGEST, + ), + ], + timestamps: { + started_at: "2026-08-21T09:00:00Z", + decision_at: "2026-08-21T09:22:00Z", + drain_completed_at: "2026-08-21T10:35:00Z", + sealed_at: "2026-08-21T10:45:00Z", + }, + lineage: { + generation: 0, + root_receipt_digest: null, + parent_receipt_digest: null, + }, + }; +} + +function locatorCoordinates() { + return { + repository: "openclaw/openclaw", + run_id: "9001", + run_attempt: 2, + artifact_id: "9301", + artifact_name: "release-validation-receipt-9001-2", + entry_name: "release-validation-receipt.json", + archive_digest: `sha256:${"a".repeat(64)}`, + }; +} + +describe("release validation receipt contract", () => { + it("canonicalizes, hashes, and parses one immutable receipt", () => { + const fixture = receiptFixture(); + const canonical = canonicalReleaseValidationReceiptJson(fixture); + expect(canonical.endsWith("\n")).toBe(true); + expect(canonical.slice(0, -1)).toMatch(/^[\x20-\x7e]+$/u); + expect(parseReleaseValidationReceiptJson(canonical)).toEqual( + validateReleaseValidationReceipt(fixture), + ); + expect(releaseValidationReceiptDigest(fixture)).toMatch(/^sha256:[a-f0-9]{64}$/u); + expect( + releaseValidationReceiptDigest({ + ...fixture, + target: { sha: TARGET_SHA, ref: fixture.target.ref, repository: fixture.target.repository }, + }), + ).toBe(releaseValidationReceiptDigest(fixture)); + expect( + releaseValidationReceiptDigest({ + ...fixture, + target: { ...fixture.target, sha: "c".repeat(40) }, + }), + ).not.toBe(releaseValidationReceiptDigest(fixture)); + }); + + it("rejects duplicate, reordered, pretty, CRLF, and non-ASCII bytes", () => { + const canonical = canonicalReleaseValidationReceiptJson(receiptFixture()); + const parsed = JSON.parse(canonical) as Record; + const duplicate = canonical.replace('{"attempt":', `{"attempt":{},"attempt":`); + expect(() => parseReleaseValidationReceiptJson(duplicate)).toThrow("duplicate key"); + expect(() => + parseReleaseValidationReceiptJson( + `${JSON.stringify({ + schema: parsed.schema, + target: parsed.target, + ...parsed, + })}\n`, + ), + ).toThrow("canonical bytes"); + expect(() => parseReleaseValidationReceiptJson(`${JSON.stringify(parsed, null, 2)}\n`)).toThrow( + "compact printable ASCII", + ); + expect(() => parseReleaseValidationReceiptJson(canonical.replace(/\n$/u, "\r\n"))).toThrow( + "exactly one trailing LF", + ); + expect(() => + parseReleaseValidationReceiptJson(canonical.replace("normal-ci", "normal-cí")), + ).toThrow("printable ASCII"); + }); + + it("rejects unknown fields at every authority boundary", () => { + const fixture = receiptFixture(); + expect(() => validateReleaseValidationReceipt({ ...fixture, latest: true })).toThrow( + "receipt keys must be exactly", + ); + expect(() => + validateReleaseValidationReceipt({ + ...fixture, + attempt: { ...fixture.attempt, head_branch: "main" }, + }), + ).toThrow("attempt keys must be exactly"); + expect(() => + validateReleaseValidationReceipt({ + ...fixture, + validation: { + ...fixture.validation, + policy: { ...fixture.validation.policy, retry: true }, + }, + }), + ).toThrow("policy keys must be exactly"); + }); + + it("binds purpose, intent, profile, soak, target, and tooling", () => { + const fixture = receiptFixture(); + expect(() => + validateReleaseValidationReceipt({ + ...fixture, + validation: { ...fixture.validation, intent: "release-stable" }, + }), + ).toThrow("does not allow validation intent"); + expect(() => + validateReleaseValidationReceipt({ + ...fixture, + validation: { ...fixture.validation, profile: "full" }, + }), + ).toThrow("profile assertion conflicts"); + expect(() => + validateReleaseValidationReceipt({ + ...fixture, + target: { ...fixture.target, ref: "main" }, + }), + ).toThrow("qualified branch or tag ref"); + expect(() => + validateReleaseValidationReceipt({ + ...fixture, + child_runs: fixture.child_runs.map((child, index) => + index === 0 ? { ...child, workflow_sha: "c".repeat(40) } : child, + ), + }), + ).toThrow("workflow SHA differs from tooling"); + expect(() => + validateReleaseValidationReceipt({ + ...fixture, + attempt: { ...fixture.attempt, workflow_sha: "c".repeat(40) }, + }), + ).toThrow("workflow identity differs from tooling"); + }); + + it("binds execution plan, Decision, and Drain source attempts and artifacts", () => { + const fixture = receiptFixture(); + expect(() => + validateReleaseValidationReceipt({ + ...fixture, + source_attempts: { + ...fixture.source_attempts, + decision: { + ...fixture.source_attempts.decision, + source_parent_run_attempt: 2, + }, + }, + }), + ).toThrow("bind the execution plan attempt"); + expect(() => + validateReleaseValidationReceipt({ + ...fixture, + source_attempts: { + ...fixture.source_attempts, + diagnostic_drain: { + ...fixture.source_attempts.diagnostic_drain, + parent_run_attempt: 3, + }, + }, + }), + ).toThrow("cannot exceed the receipt attempt"); + expect(() => + validateReleaseValidationReceipt({ + ...fixture, + source_artifacts: fixture.source_artifacts.map((artifact) => + artifact.kind === "decision" + ? { ...artifact, content_digest: `sha256:${"f".repeat(64)}` } + : artifact, + ), + }), + ).toThrow("decision binding is invalid"); + expect(() => + validateReleaseValidationReceipt({ + ...fixture, + release_plan: { ...fixture.release_plan, lock_digest: `sha256:${"e".repeat(64)}` }, + }), + ).toThrow("release-plan-lock artifact binding is invalid"); + }); + + it("requires complete, sorted, unique group and job evidence", () => { + const fixture = receiptFixture(); + expect(() => + validateReleaseValidationReceipt({ + ...fixture, + groups: [...fixture.groups].reverse(), + }), + ).toThrow("ascending ASCII order"); + expect(() => + validateReleaseValidationReceipt({ + ...fixture, + child_runs: fixture.child_runs.slice(1), + }), + ).toThrow("cover every declared group"); + expect(() => + validateReleaseValidationReceipt({ + ...fixture, + observed_jobs: fixture.observed_jobs.map((job, index) => + index === 0 ? { ...job, status: "in_progress" } : job, + ), + }), + ).toThrow("must be terminal"); + expect(() => + validateReleaseValidationReceipt({ + ...fixture, + observed_jobs: [...fixture.observed_jobs, fixture.observed_jobs[0]], + }), + ).toThrow("unique in group/name ASCII order"); + }); + + it("allows diagnostic failures but rejects a passed blocking failure", () => { + const fixture = receiptFixture(); + expect(validateReleaseValidationReceipt(fixture).validation.policy.outcome).toBe("passed"); + expect( + validateReleaseValidationReceipt({ + ...fixture, + child_runs: fixture.child_runs.map((child) => + child.group === "release-checks" ? { ...child, conclusion: "failure" } : child, + ), + }).validation.policy.outcome, + ).toBe("passed"); + expect(() => + validateReleaseValidationReceipt({ + ...fixture, + observed_jobs: fixture.observed_jobs.map((job, index) => + index === 0 ? { ...job, conclusion: "failure" } : job, + ), + }), + ).toThrow("failed blocking evidence"); + }); + + it("requires chronological timestamps and coherent lineage", () => { + const fixture = receiptFixture(); + expect(() => + validateReleaseValidationReceipt({ + ...fixture, + timestamps: { ...fixture.timestamps, decision_at: "2026-08-21T08:59:59Z" }, + }), + ).toThrow("timestamps must be chronological"); + expect(() => + validateReleaseValidationReceipt({ + ...fixture, + lineage: { + generation: 0, + root_receipt_digest: `sha256:${"d".repeat(64)}`, + parent_receipt_digest: null, + }, + }), + ).toThrow("generation and digests disagree"); + expect( + validateReleaseValidationReceipt({ + ...fixture, + lineage: { + generation: 1, + root_receipt_digest: `sha256:${"d".repeat(64)}`, + parent_receipt_digest: `sha256:${"d".repeat(64)}`, + }, + }).lineage.generation, + ).toBe(1); + }); +}); + +describe("release validation receipt locator contract", () => { + it("creates and parses a digest-bound locator envelope", () => { + const receipt = receiptFixture(); + const locator = createReleaseValidationReceiptLocator(receipt, locatorCoordinates()); + expect(locator.schema).toBe(RELEASE_VALIDATION_RECEIPT_LOCATOR_SCHEMA); + expect(locator.receipt_digest).toBe(releaseValidationReceiptDigest(receipt)); + const canonical = canonicalReleaseValidationReceiptLocatorJson(locator); + expect(parseReleaseValidationReceiptLocatorJson(canonical)).toEqual(locator); + expect(validateReleaseValidationReceiptLocatorForReceipt(locator, receipt)).toEqual(locator); + }); + + it("rejects locator attempt drift, unknown keys, and receipt digest tampering", () => { + const receipt = receiptFixture(); + expect(() => + createReleaseValidationReceiptLocator(receipt, { + ...locatorCoordinates(), + run_attempt: 1, + artifact_name: "release-validation-receipt-9001-1", + }), + ).toThrow("attempt differs from its receipt"); + expect(() => + createReleaseValidationReceiptLocator(receipt, { + ...locatorCoordinates(), + artifact_name: "release-validation-receipt-latest", + }), + ).toThrow("artifact_name must bind its run and attempt"); + const locator = createReleaseValidationReceiptLocator(receipt, locatorCoordinates()); + expect(() => + parseReleaseValidationReceiptLocatorJson( + canonicalReleaseValidationReceiptLocatorJson({ + ...locator, + locator: { ...locator.locator, mutable_latest: true }, + }), + ), + ).toThrow("coordinates keys must be exactly"); + const canonical = canonicalReleaseValidationReceiptLocatorJson(locator); + const tampered = parseReleaseValidationReceiptLocatorJson( + canonical.replace(locator.receipt_digest, `sha256:${"f".repeat(64)}`), + ); + expect(() => validateReleaseValidationReceiptLocatorForReceipt(tampered, receipt)).toThrow( + "differs from its receipt", + ); + }); +});