mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(release): define validation receipt contract
This commit is contained in:
@@ -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<T = unknown>(
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<ReleaseValidationSourceAttempt>;
|
||||
diagnostic_drain: Required<ReleaseValidationSourceAttempt>;
|
||||
};
|
||||
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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user