mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
1611 lines
58 KiB
JavaScript
1611 lines
58 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { isRecord } from "./lib/record-shared.mjs";
|
|
import {
|
|
canonicalReleaseJson,
|
|
parseCanonicalReleaseJson,
|
|
releaseCanonicalDigest,
|
|
releasePlanDigest,
|
|
RELEASE_PLAN_CANONICALIZATION,
|
|
validateReleasePlanLock,
|
|
} 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;
|
|
export const RELEASE_VALIDATION_REUSE_POLICIES = Object.freeze({
|
|
"release-beta": Object.freeze({
|
|
max_age_ms: 6 * 60 * 60 * 1000,
|
|
cadence_ms: 6 * 60 * 60 * 1000,
|
|
}),
|
|
"release-stable": Object.freeze({
|
|
max_age_ms: 6 * 60 * 60 * 1000,
|
|
cadence_ms: 6 * 60 * 60 * 1000,
|
|
}),
|
|
"main-daily": Object.freeze({
|
|
max_age_ms: 24 * 60 * 60 * 1000,
|
|
cadence_ms: 24 * 60 * 60 * 1000,
|
|
}),
|
|
"main-weekly": Object.freeze({
|
|
max_age_ms: 7 * 24 * 60 * 60 * 1000,
|
|
cadence_ms: 7 * 24 * 60 * 60 * 1000,
|
|
}),
|
|
"diagnostic-full": Object.freeze({
|
|
max_age_ms: 7 * 24 * 60 * 60 * 1000,
|
|
cadence_ms: 7 * 24 * 60 * 60 * 1000,
|
|
}),
|
|
});
|
|
|
|
const REPOSITORY = "openclaw/openclaw";
|
|
const WORKFLOW_PATH = ".github/workflows/full-release-validation.yml";
|
|
const WORKFLOW_NAME = "Full Release Validation";
|
|
const EXECUTION_PLAN_SCHEMA = "openclaw.full-release-execution-plan.v1";
|
|
const DECISION_SCHEMA = "openclaw.full-release-decision.v2";
|
|
const DRAIN_SCHEMA = "openclaw.full-release-diagnostic-drain.v2";
|
|
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 GROUP_MODES = new Set(["blocking", "diagnostic"]);
|
|
const JOB_POLICIES = new Set(["advisory", "blocking"]);
|
|
const RUN_CONCLUSIONS = new Set([
|
|
"action_required",
|
|
"cancelled",
|
|
"failure",
|
|
"neutral",
|
|
"skipped",
|
|
"stale",
|
|
"startup_failure",
|
|
"success",
|
|
"timed_out",
|
|
]);
|
|
const JOB_STATUSES = new Set(["completed", "in_progress", "queued"]);
|
|
const REQUIRED_ARTIFACTS = Object.freeze({
|
|
decision: { entry: "full-release-decision.json" },
|
|
"diagnostic-drain": { entry: "full-release-diagnostic-manifest.json" },
|
|
"execution-plan": { entry: "full-release-execution-plan.json" },
|
|
"release-plan-lock": { entry: "release-plan-lock.json" },
|
|
});
|
|
const verifiedArtifactEvidence = new WeakSet();
|
|
const verifiedReceipts = new WeakMap();
|
|
const compareAscii = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
|
|
|
|
function fail(message) {
|
|
throw new Error(message);
|
|
}
|
|
|
|
function object(value, label) {
|
|
if (!isRecord(value)) {
|
|
fail(`${label} must be an object`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
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 ascii(value, label) {
|
|
if (typeof value !== "string" || !ASCII_PATTERN.test(value)) {
|
|
fail(`${label} must be a non-empty printable ASCII string`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function enumValue(value, allowed, label) {
|
|
const result = ascii(value, label);
|
|
if (!allowed.has(result)) {
|
|
fail(`${label} contains unsupported value: ${result}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function sha(value, label) {
|
|
const result = ascii(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 exactBytesDigest(value) {
|
|
return `sha256:${createHash("sha256").update(value, "ascii").digest("hex")}`;
|
|
}
|
|
|
|
function runId(value, label) {
|
|
const result = ascii(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 booleanValue(value, label) {
|
|
if (typeof value !== "boolean") {
|
|
fail(`${label} must be boolean`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function timestamp(value, label) {
|
|
const result = ascii(value, label);
|
|
const milliseconds = Date.parse(result);
|
|
if (
|
|
!TIMESTAMP_PATTERN.test(result) ||
|
|
!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 qualifiedRef(value, label) {
|
|
const result = ascii(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 = ascii(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 exactRunUrl(value, id, label) {
|
|
const expected = `https://github.com/${REPOSITORY}/actions/runs/${id}`;
|
|
if (value !== expected) {
|
|
fail(`${label} must equal ${expected}`);
|
|
}
|
|
return expected;
|
|
}
|
|
|
|
function exactJobUrl(value, parentRunId, label) {
|
|
const pattern = new RegExp(
|
|
`^https://github\\.com/${REPOSITORY}/actions/runs/${parentRunId}/job/([1-9][0-9]*)$`,
|
|
"u",
|
|
);
|
|
if (typeof value !== "string" || !pattern.test(value)) {
|
|
fail(`${label} must be an exact job URL for run ${parentRunId}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function exactArtifactUrl(value, parentRunId, artifactId, label) {
|
|
const expected = `https://github.com/${REPOSITORY}/actions/runs/${parentRunId}/artifacts/${artifactId}`;
|
|
if (value !== expected) {
|
|
fail(`${label} must equal ${expected}`);
|
|
}
|
|
return expected;
|
|
}
|
|
|
|
function sortedUnique(values, label, identity) {
|
|
const identities = values.map(identity);
|
|
if (
|
|
new Set(identities).size !== identities.length ||
|
|
identities.some((entry, index) => index > 0 && compareAscii(identities[index - 1], entry) >= 0)
|
|
) {
|
|
fail(`${label} must be unique in ascending ASCII order`);
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function validateExecutionGroup(value, index, toolingSha) {
|
|
const label = `release validation execution plan groups[${index}]`;
|
|
const group = object(value, label);
|
|
exactKeys(
|
|
group,
|
|
["id", "mode", "policy", "workflow_path", "run_id", "run_attempt", "workflow_sha", "url"],
|
|
label,
|
|
);
|
|
const result = {
|
|
id: ascii(group.id, `${label}.id`),
|
|
mode: enumValue(group.mode, GROUP_MODES, `${label}.mode`),
|
|
policy: ascii(group.policy, `${label}.policy`),
|
|
workflow_path: ascii(group.workflow_path, `${label}.workflow_path`),
|
|
run_id: runId(group.run_id, `${label}.run_id`),
|
|
run_attempt: positiveInteger(group.run_attempt, `${label}.run_attempt`),
|
|
workflow_sha: sha(group.workflow_sha, `${label}.workflow_sha`),
|
|
url: "",
|
|
};
|
|
result.url = exactRunUrl(group.url, result.run_id, `${label}.url`);
|
|
if (result.workflow_sha !== toolingSha) {
|
|
fail(`${label}.workflow_sha differs from the execution plan tooling`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function validateReleaseValidationExecutionPlanSource(value) {
|
|
const label = "release validation execution plan source";
|
|
const source = object(value, label);
|
|
exactKeys(
|
|
source,
|
|
[
|
|
"schema",
|
|
"parent_run_id",
|
|
"parent_run_attempt",
|
|
"workflow_ref",
|
|
"workflow_sha",
|
|
"target_sha",
|
|
"release_profile",
|
|
"rerun_group",
|
|
"fail_fast",
|
|
"started_at",
|
|
"groups",
|
|
],
|
|
label,
|
|
);
|
|
if (source.schema !== EXECUTION_PLAN_SCHEMA) {
|
|
fail(`release validation execution plan source schema must be ${EXECUTION_PLAN_SCHEMA}`);
|
|
}
|
|
const workflowSha = sha(source.workflow_sha, `${label}.workflow_sha`);
|
|
if (!Array.isArray(source.groups) || source.groups.length === 0) {
|
|
fail(`${label}.groups must be a non-empty array`);
|
|
}
|
|
const groups = sortedUnique(
|
|
source.groups.map((group, index) => validateExecutionGroup(group, index, workflowSha)),
|
|
`${label}.groups`,
|
|
(group) => group.id,
|
|
);
|
|
if (!groups.some((group) => group.mode === "blocking")) {
|
|
fail(`${label}.groups must contain a blocking group`);
|
|
}
|
|
return {
|
|
schema: EXECUTION_PLAN_SCHEMA,
|
|
parent_run_id: runId(source.parent_run_id, `${label}.parent_run_id`),
|
|
parent_run_attempt: positiveInteger(source.parent_run_attempt, `${label}.parent_run_attempt`),
|
|
workflow_ref: qualifiedRef(source.workflow_ref, `${label}.workflow_ref`),
|
|
workflow_sha: workflowSha,
|
|
target_sha: sha(source.target_sha, `${label}.target_sha`),
|
|
release_profile: ascii(source.release_profile, `${label}.release_profile`),
|
|
rerun_group: ascii(source.rerun_group, `${label}.rerun_group`),
|
|
fail_fast: booleanValue(source.fail_fast, `${label}.fail_fast`),
|
|
started_at: timestamp(source.started_at, `${label}.started_at`),
|
|
groups,
|
|
};
|
|
}
|
|
|
|
function validateStateJob(value, stateLabel, groupId, parentRunId, index) {
|
|
const label = `${stateLabel} groups.${groupId}.jobs[${index}]`;
|
|
const job = object(value, label);
|
|
exactKeys(
|
|
job,
|
|
["name", "policy", "status", "conclusion", "started_at", "completed_at", "url"],
|
|
label,
|
|
);
|
|
const result = {
|
|
name: ascii(job.name, `${label}.name`),
|
|
policy: enumValue(job.policy, JOB_POLICIES, `${label}.policy`),
|
|
status: enumValue(job.status, JOB_STATUSES, `${label}.status`),
|
|
conclusion:
|
|
job.conclusion === null
|
|
? null
|
|
: enumValue(job.conclusion, RUN_CONCLUSIONS, `${label}.conclusion`),
|
|
started_at: nullableTimestamp(job.started_at, `${label}.started_at`),
|
|
completed_at: nullableTimestamp(job.completed_at, `${label}.completed_at`),
|
|
url: exactJobUrl(job.url, parentRunId, `${label}.url`),
|
|
};
|
|
if (
|
|
(result.status === "completed" &&
|
|
(result.conclusion === null || result.completed_at === null)) ||
|
|
(result.status !== "completed" &&
|
|
(result.conclusion !== null || result.completed_at !== null)) ||
|
|
(result.started_at !== null &&
|
|
result.completed_at !== null &&
|
|
Date.parse(result.started_at) > Date.parse(result.completed_at))
|
|
) {
|
|
fail(`${label} status, conclusion, or timestamps are inconsistent`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function validateStateGroup(value, stateLabel, index) {
|
|
const label = `${stateLabel} groups[${index}]`;
|
|
const group = object(value, label);
|
|
exactKeys(
|
|
group,
|
|
["id", "run_id", "run_attempt", "status", "conclusion", "completed_at", "url", "jobs"],
|
|
label,
|
|
);
|
|
const id = ascii(group.id, `${label}.id`);
|
|
const groupRunId = runId(group.run_id, `${label}.run_id`);
|
|
if (!Array.isArray(group.jobs) || group.jobs.length === 0) {
|
|
fail(`${label}.jobs must be a non-empty array`);
|
|
}
|
|
const jobs = sortedUnique(
|
|
group.jobs.map((job, jobIndex) => validateStateJob(job, stateLabel, id, groupRunId, jobIndex)),
|
|
`${label}.jobs`,
|
|
(job) => job.name,
|
|
);
|
|
const result = {
|
|
id,
|
|
run_id: groupRunId,
|
|
run_attempt: positiveInteger(group.run_attempt, `${label}.run_attempt`),
|
|
status: enumValue(group.status, JOB_STATUSES, `${label}.status`),
|
|
conclusion:
|
|
group.conclusion === null
|
|
? null
|
|
: enumValue(group.conclusion, RUN_CONCLUSIONS, `${label}.conclusion`),
|
|
completed_at: nullableTimestamp(group.completed_at, `${label}.completed_at`),
|
|
url: exactRunUrl(group.url, groupRunId, `${label}.url`),
|
|
jobs,
|
|
};
|
|
if (
|
|
(result.status === "completed" &&
|
|
(result.conclusion === null ||
|
|
result.completed_at === null ||
|
|
jobs.some((job) => job.status !== "completed"))) ||
|
|
(result.status !== "completed" &&
|
|
(result.conclusion !== null || result.completed_at !== null)) ||
|
|
(result.completed_at !== null &&
|
|
jobs.some(
|
|
(job) =>
|
|
job.completed_at !== null &&
|
|
Date.parse(job.completed_at) > Date.parse(result.completed_at),
|
|
))
|
|
) {
|
|
fail(`${label} status, conclusion, jobs, or timestamps are inconsistent`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function validateReleaseValidationStateSource(value, mode) {
|
|
if (mode !== "decision" && mode !== "diagnostic-drain") {
|
|
fail("release validation state source mode is unsupported");
|
|
}
|
|
const label = `release validation ${mode} source`;
|
|
const source = object(value, label);
|
|
exactKeys(
|
|
source,
|
|
[
|
|
"schema",
|
|
"parent_run_id",
|
|
"parent_run_attempt",
|
|
"source_parent_run_attempt",
|
|
"workflow_ref",
|
|
"workflow_sha",
|
|
"target_sha",
|
|
"execution_plan_digest",
|
|
"observed_at",
|
|
"groups",
|
|
],
|
|
label,
|
|
);
|
|
const expectedSchema = mode === "decision" ? DECISION_SCHEMA : DRAIN_SCHEMA;
|
|
if (source.schema !== expectedSchema) {
|
|
fail(`${label} schema must be ${expectedSchema}`);
|
|
}
|
|
if (!Array.isArray(source.groups) || source.groups.length === 0) {
|
|
fail(`${label}.groups must be a non-empty array`);
|
|
}
|
|
return {
|
|
schema: expectedSchema,
|
|
parent_run_id: runId(source.parent_run_id, `${label}.parent_run_id`),
|
|
parent_run_attempt: positiveInteger(source.parent_run_attempt, `${label}.parent_run_attempt`),
|
|
source_parent_run_attempt: positiveInteger(
|
|
source.source_parent_run_attempt,
|
|
`${label}.source_parent_run_attempt`,
|
|
),
|
|
workflow_ref: qualifiedRef(source.workflow_ref, `${label}.workflow_ref`),
|
|
workflow_sha: sha(source.workflow_sha, `${label}.workflow_sha`),
|
|
target_sha: sha(source.target_sha, `${label}.target_sha`),
|
|
execution_plan_digest: digest(source.execution_plan_digest, `${label}.execution_plan_digest`),
|
|
observed_at: timestamp(source.observed_at, `${label}.observed_at`),
|
|
groups: sortedUnique(
|
|
source.groups.map((group, index) => validateStateGroup(group, label, index)),
|
|
`${label}.groups`,
|
|
(group) => group.id,
|
|
),
|
|
};
|
|
}
|
|
|
|
function verifyPlanBinding(lock, executionPlan) {
|
|
const plan = lock.plan;
|
|
if (
|
|
plan.candidate_sha !== executionPlan.target_sha ||
|
|
plan.tooling.repository !== REPOSITORY ||
|
|
plan.tooling.workflow_path !== WORKFLOW_PATH ||
|
|
plan.tooling.ref !== executionPlan.workflow_ref ||
|
|
plan.tooling.sha !== executionPlan.workflow_sha ||
|
|
plan.validation.profile !== executionPlan.release_profile ||
|
|
!plan.validation.allowed_groups.includes(executionPlan.rerun_group)
|
|
) {
|
|
fail("release validation sources differ from the validated ReleasePlan");
|
|
}
|
|
}
|
|
|
|
function verifyStateBinding(state, executionPlan, label) {
|
|
const executionDigest = releaseCanonicalDigest(executionPlan);
|
|
if (
|
|
state.parent_run_id !== executionPlan.parent_run_id ||
|
|
state.source_parent_run_attempt !== executionPlan.parent_run_attempt ||
|
|
state.workflow_ref !== executionPlan.workflow_ref ||
|
|
state.workflow_sha !== executionPlan.workflow_sha ||
|
|
state.target_sha !== executionPlan.target_sha ||
|
|
state.execution_plan_digest !== executionDigest
|
|
) {
|
|
fail(`${label} differs from the execution plan`);
|
|
}
|
|
const plannedIds = executionPlan.groups.map((group) => group.id);
|
|
if (
|
|
state.groups.length !== plannedIds.length ||
|
|
state.groups.some((group, index) => group.id !== plannedIds[index])
|
|
) {
|
|
fail(`${label} group set differs from the execution plan`);
|
|
}
|
|
for (const group of state.groups) {
|
|
const planned = executionPlan.groups.find((entry) => entry.id === group.id);
|
|
if (
|
|
group.run_id !== planned.run_id ||
|
|
group.run_attempt !== planned.run_attempt ||
|
|
group.url !== planned.url
|
|
) {
|
|
fail(`${label} group tuple differs from the execution plan: ${group.id}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function requireTerminalJob(job, deadline, label) {
|
|
if (
|
|
job.status !== "completed" ||
|
|
job.conclusion === null ||
|
|
job.completed_at === null ||
|
|
Date.parse(job.completed_at) > Date.parse(deadline)
|
|
) {
|
|
fail(`${label} was not complete before ${deadline}`);
|
|
}
|
|
}
|
|
|
|
function verifyDecisionAndDrain(executionPlan, decision, drain) {
|
|
verifyStateBinding(decision, executionPlan, "release validation decision");
|
|
verifyStateBinding(drain, executionPlan, "release validation diagnostic drain");
|
|
if (
|
|
executionPlan.parent_run_attempt > decision.parent_run_attempt ||
|
|
decision.parent_run_attempt > drain.parent_run_attempt ||
|
|
Date.parse(executionPlan.started_at) > Date.parse(decision.observed_at) ||
|
|
Date.parse(decision.observed_at) > Date.parse(drain.observed_at)
|
|
) {
|
|
fail("release validation source attempt or timestamp order is invalid");
|
|
}
|
|
for (const planned of executionPlan.groups) {
|
|
const decisionGroup = decision.groups.find((group) => group.id === planned.id);
|
|
const drainGroup = drain.groups.find((group) => group.id === planned.id);
|
|
if (planned.mode === "blocking") {
|
|
if (
|
|
decisionGroup.status !== "completed" ||
|
|
decisionGroup.conclusion !== "success" ||
|
|
decisionGroup.completed_at === null ||
|
|
Date.parse(decisionGroup.completed_at) > Date.parse(decision.observed_at)
|
|
) {
|
|
fail(`blocking group was not complete before Decision: ${planned.id}`);
|
|
}
|
|
const blockingJobs = decisionGroup.jobs.filter((job) => job.policy === "blocking");
|
|
if (blockingJobs.length === 0) {
|
|
fail(`blocking group omitted blocking jobs: ${planned.id}`);
|
|
}
|
|
for (const job of blockingJobs) {
|
|
requireTerminalJob(job, decision.observed_at, `blocking job ${planned.id}/${job.name}`);
|
|
if (job.conclusion !== "success") {
|
|
fail(`blocking job failed release policy: ${planned.id}/${job.name}`);
|
|
}
|
|
}
|
|
} else if (
|
|
decisionGroup.jobs.some((job) => job.policy !== "advisory") ||
|
|
drainGroup.jobs.some((job) => job.policy !== "advisory")
|
|
) {
|
|
fail(`diagnostic group contains a blocking job: ${planned.id}`);
|
|
}
|
|
if (
|
|
drainGroup.status !== "completed" ||
|
|
drainGroup.conclusion === null ||
|
|
drainGroup.completed_at === null ||
|
|
Date.parse(drainGroup.completed_at) > Date.parse(drain.observed_at)
|
|
) {
|
|
fail(`group was not complete before Diagnostic Drain: ${planned.id}`);
|
|
}
|
|
for (const job of drainGroup.jobs) {
|
|
requireTerminalJob(job, drain.observed_at, `drained job ${planned.id}/${job.name}`);
|
|
}
|
|
if (planned.mode === "blocking") {
|
|
if (
|
|
drainGroup.conclusion !== "success" ||
|
|
drainGroup.conclusion !== decisionGroup.conclusion
|
|
) {
|
|
fail(`blocking group changed after Decision: ${planned.id}`);
|
|
}
|
|
const decisionJobs = decisionGroup.jobs.filter((job) => job.policy === "blocking");
|
|
const drainJobs = drainGroup.jobs.filter((job) => job.policy === "blocking");
|
|
if (
|
|
decisionJobs.length !== drainJobs.length ||
|
|
decisionJobs.some(
|
|
(job, index) => canonicalReleaseJson(job) !== canonicalReleaseJson(drainJobs[index]),
|
|
)
|
|
) {
|
|
const changedJob =
|
|
decisionJobs.find(
|
|
(job, index) => canonicalReleaseJson(job) !== canonicalReleaseJson(drainJobs[index]),
|
|
)?.name ?? "job-set";
|
|
fail(`blocking job changed after Decision: ${planned.id}/${changedJob}`);
|
|
}
|
|
for (const job of drainJobs) {
|
|
if (job.conclusion !== "success") {
|
|
fail(`blocking job changed after Decision: ${planned.id}/${job.name}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function artifactName(kind, run, executionPlan, decision, drain) {
|
|
if (kind === "execution-plan") {
|
|
return `full-release-execution-plan-${run}`;
|
|
}
|
|
if (kind === "decision") {
|
|
return `full-release-decision-${run}-${decision.parent_run_attempt}`;
|
|
}
|
|
if (kind === "diagnostic-drain") {
|
|
return `full-release-diagnostics-${run}-${drain.parent_run_attempt}`;
|
|
}
|
|
return `release-plan-lock-${run}-${executionPlan.parent_run_attempt}`;
|
|
}
|
|
|
|
function artifactAttempt(kind, executionPlan, decision, drain) {
|
|
if (kind === "decision") {
|
|
return decision.parent_run_attempt;
|
|
}
|
|
if (kind === "diagnostic-drain") {
|
|
return drain.parent_run_attempt;
|
|
}
|
|
return executionPlan.parent_run_attempt;
|
|
}
|
|
|
|
export function verifyReleaseValidationArtifactEvidence(value, authenticate) {
|
|
const label = "release validation artifact evidence";
|
|
const artifact = object(value, label);
|
|
exactKeys(
|
|
artifact,
|
|
[
|
|
"kind",
|
|
"artifact_id",
|
|
"artifact_name",
|
|
"entry_name",
|
|
"run_id",
|
|
"run_attempt",
|
|
"archive_digest",
|
|
"content_digest",
|
|
"created_at",
|
|
"expires_at",
|
|
"url",
|
|
"entry_bytes",
|
|
],
|
|
label,
|
|
);
|
|
const artifactId = runId(artifact.artifact_id, `${label}.artifact_id`);
|
|
const parentRunId = runId(artifact.run_id, `${label}.run_id`);
|
|
if (typeof artifact.entry_bytes !== "string") {
|
|
fail(`${label}.entry_bytes must be a canonical JSON string`);
|
|
}
|
|
parseCanonicalReleaseJson(artifact.entry_bytes, {
|
|
label: `${label}.entry_bytes`,
|
|
maxBytes: RELEASE_VALIDATION_RECEIPT_MAX_BYTES,
|
|
});
|
|
const result = Object.freeze({
|
|
kind: ascii(artifact.kind, `${label}.kind`),
|
|
artifact_id: artifactId,
|
|
artifact_name: ascii(artifact.artifact_name, `${label}.artifact_name`),
|
|
entry_name: ascii(artifact.entry_name, `${label}.entry_name`),
|
|
run_id: parentRunId,
|
|
run_attempt: positiveInteger(artifact.run_attempt, `${label}.run_attempt`),
|
|
archive_digest: digest(artifact.archive_digest, `${label}.archive_digest`),
|
|
content_digest: digest(artifact.content_digest, `${label}.content_digest`),
|
|
created_at: timestamp(artifact.created_at, `${label}.created_at`),
|
|
expires_at: timestamp(artifact.expires_at, `${label}.expires_at`),
|
|
url: exactArtifactUrl(artifact.url, parentRunId, artifactId, `${label}.url`),
|
|
entry_bytes: artifact.entry_bytes,
|
|
});
|
|
if (result.content_digest !== exactBytesDigest(result.entry_bytes)) {
|
|
fail(`${label}.content_digest differs from the exact canonical entry bytes`);
|
|
}
|
|
if (typeof authenticate !== "function" || authenticate(result) !== true) {
|
|
fail(`${label} was not authenticated by its evidence verifier`);
|
|
}
|
|
verifiedArtifactEvidence.add(result);
|
|
return result;
|
|
}
|
|
|
|
function validateSourceArtifacts(value, sources) {
|
|
if (!Array.isArray(value) || value.length !== 4) {
|
|
fail("release validation source_artifacts must contain exactly four source artifacts");
|
|
}
|
|
const expectedBytes = {
|
|
decision: canonicalReleaseJson(sources.decision),
|
|
"diagnostic-drain": canonicalReleaseJson(sources.diagnosticDrain),
|
|
"execution-plan": canonicalReleaseJson(sources.executionPlan),
|
|
"release-plan-lock": canonicalReleaseJson(sources.releasePlanLock),
|
|
};
|
|
const artifacts = sortedUnique(
|
|
value.map((entry, index) => {
|
|
const label = `release validation source_artifacts[${index}]`;
|
|
if (!isRecord(entry) || !verifiedArtifactEvidence.has(entry)) {
|
|
fail(`${label} must be authenticated artifact evidence`);
|
|
}
|
|
const artifact = object(entry, label);
|
|
exactKeys(
|
|
artifact,
|
|
[
|
|
"kind",
|
|
"artifact_id",
|
|
"artifact_name",
|
|
"entry_name",
|
|
"run_id",
|
|
"run_attempt",
|
|
"archive_digest",
|
|
"content_digest",
|
|
"created_at",
|
|
"expires_at",
|
|
"url",
|
|
"entry_bytes",
|
|
],
|
|
label,
|
|
);
|
|
const kind = ascii(artifact.kind, `${label}.kind`);
|
|
if (!Object.hasOwn(REQUIRED_ARTIFACTS, kind)) {
|
|
fail(`${label}.kind is unsupported`);
|
|
}
|
|
const artifactId = runId(artifact.artifact_id, `${label}.artifact_id`);
|
|
const parentRunId = runId(artifact.run_id, `${label}.run_id`);
|
|
const result = {
|
|
kind,
|
|
artifact_id: artifactId,
|
|
artifact_name: ascii(artifact.artifact_name, `${label}.artifact_name`),
|
|
entry_name: ascii(artifact.entry_name, `${label}.entry_name`),
|
|
run_id: parentRunId,
|
|
run_attempt: positiveInteger(artifact.run_attempt, `${label}.run_attempt`),
|
|
archive_digest: digest(artifact.archive_digest, `${label}.archive_digest`),
|
|
content_digest: digest(artifact.content_digest, `${label}.content_digest`),
|
|
created_at: timestamp(artifact.created_at, `${label}.created_at`),
|
|
expires_at: timestamp(artifact.expires_at, `${label}.expires_at`),
|
|
url: exactArtifactUrl(artifact.url, parentRunId, artifactId, `${label}.url`),
|
|
};
|
|
if (
|
|
result.run_id !== sources.executionPlan.parent_run_id ||
|
|
result.run_attempt !==
|
|
artifactAttempt(kind, sources.executionPlan, sources.decision, sources.diagnosticDrain) ||
|
|
result.artifact_name !==
|
|
artifactName(
|
|
kind,
|
|
result.run_id,
|
|
sources.executionPlan,
|
|
sources.decision,
|
|
sources.diagnosticDrain,
|
|
) ||
|
|
result.entry_name !== REQUIRED_ARTIFACTS[kind].entry ||
|
|
result.content_digest !== exactBytesDigest(expectedBytes[kind]) ||
|
|
Date.parse(result.expires_at) <= Date.parse(result.created_at) ||
|
|
artifact.entry_bytes !== expectedBytes[kind]
|
|
) {
|
|
fail(`${label} coordinates differ from its source object`);
|
|
}
|
|
return result;
|
|
}),
|
|
"release validation source_artifacts",
|
|
(artifact) => `${artifact.kind}\0${artifact.artifact_name}`,
|
|
);
|
|
const ids = artifacts.map((artifact) => artifact.artifact_id);
|
|
const coordinates = artifacts.map(
|
|
(artifact) =>
|
|
`${artifact.run_id}\0${artifact.run_attempt}\0${artifact.artifact_name}\0${artifact.entry_name}`,
|
|
);
|
|
if (new Set(ids).size !== ids.length || new Set(coordinates).size !== coordinates.length) {
|
|
fail("release validation source_artifacts must have unique IDs and coordinates");
|
|
}
|
|
const byKind = Object.fromEntries(artifacts.map((artifact) => [artifact.kind, artifact]));
|
|
const decisionAt = Date.parse(sources.decision.observed_at);
|
|
const drainAt = Date.parse(sources.diagnosticDrain.observed_at);
|
|
const executionCreatedAt = Date.parse(byKind["execution-plan"].created_at);
|
|
if (
|
|
Date.parse(byKind.decision.created_at) < decisionAt ||
|
|
Date.parse(byKind["diagnostic-drain"].created_at) < drainAt ||
|
|
executionCreatedAt < Date.parse(sources.executionPlan.started_at) ||
|
|
executionCreatedAt > decisionAt ||
|
|
Date.parse(byKind["release-plan-lock"].created_at) > decisionAt
|
|
) {
|
|
fail("release validation source artifact timestamps differ from source observations");
|
|
}
|
|
return artifacts;
|
|
}
|
|
|
|
function validateLineageShape(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:
|
|
lineage.root_receipt_digest === null
|
|
? null
|
|
: digest(
|
|
lineage.root_receipt_digest,
|
|
"release validation receipt lineage root_receipt_digest",
|
|
),
|
|
parent_receipt_digest:
|
|
lineage.parent_receipt_digest === null
|
|
? null
|
|
: digest(
|
|
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;
|
|
}
|
|
|
|
function sameLineagePolicy(left, right) {
|
|
return (
|
|
left.validation.intent === right.validation.intent &&
|
|
left.validation.profile === right.validation.profile &&
|
|
left.validation.soak === right.validation.soak &&
|
|
left.validation.policy.id === right.validation.policy.id
|
|
);
|
|
}
|
|
|
|
function authenticatedReceipt(value, label) {
|
|
if (!isRecord(value) || !verifiedReceipts.has(value)) {
|
|
fail(`${label} must be an authenticated release validation receipt`);
|
|
}
|
|
const receipt = validateReleaseValidationReceipt(value);
|
|
if (verifiedReceipts.get(value) !== releaseValidationReceiptDigest(receipt)) {
|
|
fail(`${label} changed after it was authenticated`);
|
|
}
|
|
return receipt;
|
|
}
|
|
|
|
function buildLineage({ parentReceipt, rootReceipt, validation, startedAt }) {
|
|
if (parentReceipt === undefined) {
|
|
if (rootReceipt !== undefined) {
|
|
fail("release validation root receipt requires a parent receipt");
|
|
}
|
|
return { generation: 0, root_receipt_digest: null, parent_receipt_digest: null };
|
|
}
|
|
const parent = authenticatedReceipt(parentReceipt, "release validation parent receipt");
|
|
if (!sameLineagePolicy({ validation }, parent)) {
|
|
fail("release validation parent receipt uses a different intent policy");
|
|
}
|
|
if (Date.parse(parent.timestamps.sealed_at) > Date.parse(startedAt)) {
|
|
fail("release validation parent receipt was sealed after its child started");
|
|
}
|
|
const parentDigest = releaseValidationReceiptDigest(parent);
|
|
let root;
|
|
if (parent.lineage.generation === 0) {
|
|
root = parent;
|
|
if (rootReceipt !== undefined) {
|
|
const suppliedRoot = authenticatedReceipt(
|
|
rootReceipt,
|
|
"release validation supplied root receipt",
|
|
);
|
|
if (releaseValidationReceiptDigest(suppliedRoot) !== parentDigest) {
|
|
fail("release validation supplied root differs from the generation-zero parent");
|
|
}
|
|
}
|
|
} else {
|
|
if (rootReceipt === undefined) {
|
|
fail("release validation non-root parent requires the actual root receipt");
|
|
}
|
|
root = authenticatedReceipt(rootReceipt, "release validation root receipt");
|
|
if (
|
|
root.lineage.generation !== 0 ||
|
|
releaseValidationReceiptDigest(root) !== parent.lineage.root_receipt_digest
|
|
) {
|
|
fail("release validation root receipt does not continue the parent lineage");
|
|
}
|
|
}
|
|
if (!sameLineagePolicy({ validation }, root)) {
|
|
fail("release validation root receipt uses a different intent policy");
|
|
}
|
|
return {
|
|
generation: parent.lineage.generation + 1,
|
|
root_receipt_digest: releaseValidationReceiptDigest(root),
|
|
parent_receipt_digest: parentDigest,
|
|
};
|
|
}
|
|
|
|
function normalizeFinalGroups(executionPlan, drain) {
|
|
return executionPlan.groups.map((planned) => {
|
|
const observed = drain.groups.find((group) => group.id === planned.id);
|
|
return {
|
|
id: planned.id,
|
|
mode: planned.mode,
|
|
policy: planned.policy,
|
|
workflow_path: planned.workflow_path,
|
|
run_id: planned.run_id,
|
|
run_attempt: planned.run_attempt,
|
|
workflow_sha: planned.workflow_sha,
|
|
url: planned.url,
|
|
conclusion: observed.conclusion,
|
|
completed_at: observed.completed_at,
|
|
jobs: observed.jobs,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function sealReleaseValidationReceipt(input) {
|
|
const releasePlanLock = validateReleasePlanLock(input.releasePlanLock);
|
|
const executionPlan = validateReleaseValidationExecutionPlanSource(input.executionPlan);
|
|
const decision = validateReleaseValidationStateSource(input.decision, "decision");
|
|
const diagnosticDrain = validateReleaseValidationStateSource(
|
|
input.diagnosticDrain,
|
|
"diagnostic-drain",
|
|
);
|
|
verifyPlanBinding(releasePlanLock, executionPlan);
|
|
verifyDecisionAndDrain(executionPlan, decision, diagnosticDrain);
|
|
const sourceArtifacts = validateSourceArtifacts(input.sourceArtifacts, {
|
|
releasePlanLock,
|
|
executionPlan,
|
|
decision,
|
|
diagnosticDrain,
|
|
});
|
|
const sealedAt = timestamp(input.sealedAt, "release validation receipt sealed_at");
|
|
if (
|
|
sourceArtifacts.some(
|
|
(artifact) =>
|
|
Date.parse(artifact.created_at) > Date.parse(sealedAt) ||
|
|
Date.parse(artifact.expires_at) <= Date.parse(sealedAt),
|
|
) ||
|
|
Date.parse(diagnosticDrain.observed_at) > Date.parse(sealedAt)
|
|
) {
|
|
fail("release validation receipt was sealed before its sources completed");
|
|
}
|
|
const plan = releasePlanLock.plan;
|
|
const validation = {
|
|
intent: plan.validation.intent,
|
|
profile: plan.validation.profile,
|
|
soak: plan.validation.soak,
|
|
allowed_groups: plan.validation.allowed_groups,
|
|
rerun_group: executionPlan.rerun_group,
|
|
policy: {
|
|
id: RELEASE_VALIDATION_POLICY_ID,
|
|
fail_fast: executionPlan.fail_fast,
|
|
},
|
|
};
|
|
const receipt = {
|
|
schema: RELEASE_VALIDATION_RECEIPT_SCHEMA,
|
|
canonicalization: RELEASE_PLAN_CANONICALIZATION,
|
|
target: {
|
|
repository: REPOSITORY,
|
|
ref: plan.target_context_ref,
|
|
sha: plan.candidate_sha,
|
|
},
|
|
tooling: {
|
|
repository: plan.tooling.repository,
|
|
workflow_path: plan.tooling.workflow_path,
|
|
ref: plan.tooling.ref,
|
|
sha: plan.tooling.sha,
|
|
},
|
|
attempt: {
|
|
workflow_name: WORKFLOW_NAME,
|
|
run_id: executionPlan.parent_run_id,
|
|
run_attempt: Math.max(decision.parent_run_attempt, diagnosticDrain.parent_run_attempt),
|
|
url: `https://github.com/${REPOSITORY}/actions/runs/${executionPlan.parent_run_id}`,
|
|
},
|
|
release_plan: {
|
|
schema: plan.schema,
|
|
purpose: plan.purpose,
|
|
plan_digest: releasePlanDigest(plan),
|
|
lock_digest: releaseCanonicalDigest(releasePlanLock),
|
|
},
|
|
validation,
|
|
source_attempts: {
|
|
execution_plan: {
|
|
schema: executionPlan.schema,
|
|
digest: releaseCanonicalDigest(executionPlan),
|
|
parent_run_attempt: executionPlan.parent_run_attempt,
|
|
},
|
|
decision: {
|
|
schema: decision.schema,
|
|
digest: releaseCanonicalDigest(decision),
|
|
parent_run_attempt: decision.parent_run_attempt,
|
|
source_parent_run_attempt: decision.source_parent_run_attempt,
|
|
},
|
|
diagnostic_drain: {
|
|
schema: diagnosticDrain.schema,
|
|
digest: releaseCanonicalDigest(diagnosticDrain),
|
|
parent_run_attempt: diagnosticDrain.parent_run_attempt,
|
|
source_parent_run_attempt: diagnosticDrain.source_parent_run_attempt,
|
|
},
|
|
},
|
|
groups: normalizeFinalGroups(executionPlan, diagnosticDrain),
|
|
source_artifacts: sourceArtifacts,
|
|
timestamps: {
|
|
started_at: executionPlan.started_at,
|
|
decision_at: decision.observed_at,
|
|
drain_completed_at: diagnosticDrain.observed_at,
|
|
sealed_at: sealedAt,
|
|
},
|
|
lineage: buildLineage({
|
|
parentReceipt: input.parentReceipt,
|
|
rootReceipt: input.rootReceipt,
|
|
validation,
|
|
startedAt: executionPlan.started_at,
|
|
}),
|
|
};
|
|
const result = validateReleaseValidationReceipt(receipt);
|
|
verifiedReceipts.set(result, releaseValidationReceiptDigest(result));
|
|
return result;
|
|
}
|
|
|
|
function validateReceiptGroup(value, index, toolingSha) {
|
|
const label = `release validation receipt groups[${index}]`;
|
|
const group = object(value, label);
|
|
exactKeys(
|
|
group,
|
|
[
|
|
"id",
|
|
"mode",
|
|
"policy",
|
|
"workflow_path",
|
|
"run_id",
|
|
"run_attempt",
|
|
"workflow_sha",
|
|
"url",
|
|
"conclusion",
|
|
"completed_at",
|
|
"jobs",
|
|
],
|
|
label,
|
|
);
|
|
const id = ascii(group.id, `${label}.id`);
|
|
const groupRunId = runId(group.run_id, `${label}.run_id`);
|
|
if (!Array.isArray(group.jobs) || group.jobs.length === 0) {
|
|
fail(`${label}.jobs must be a non-empty array`);
|
|
}
|
|
const jobs = sortedUnique(
|
|
group.jobs.map((job, jobIndex) =>
|
|
validateStateJob(job, "release validation receipt", id, groupRunId, jobIndex),
|
|
),
|
|
`${label}.jobs`,
|
|
(job) => job.name,
|
|
);
|
|
const result = {
|
|
id,
|
|
mode: enumValue(group.mode, GROUP_MODES, `${label}.mode`),
|
|
policy: ascii(group.policy, `${label}.policy`),
|
|
workflow_path: ascii(group.workflow_path, `${label}.workflow_path`),
|
|
run_id: groupRunId,
|
|
run_attempt: positiveInteger(group.run_attempt, `${label}.run_attempt`),
|
|
workflow_sha: sha(group.workflow_sha, `${label}.workflow_sha`),
|
|
url: exactRunUrl(group.url, groupRunId, `${label}.url`),
|
|
conclusion: enumValue(group.conclusion, RUN_CONCLUSIONS, `${label}.conclusion`),
|
|
completed_at: timestamp(group.completed_at, `${label}.completed_at`),
|
|
jobs,
|
|
};
|
|
if (
|
|
result.workflow_sha !== toolingSha ||
|
|
jobs.some(
|
|
(job) => job.status !== "completed" || job.conclusion === null || job.completed_at === null,
|
|
)
|
|
) {
|
|
fail(`${label} is not terminal or differs from tooling`);
|
|
}
|
|
const blockingJobs = jobs.filter((job) => job.policy === "blocking");
|
|
if (
|
|
(result.mode === "blocking" &&
|
|
(result.conclusion !== "success" ||
|
|
blockingJobs.length === 0 ||
|
|
blockingJobs.some((job) => job.conclusion !== "success"))) ||
|
|
(result.mode === "diagnostic" && blockingJobs.length > 0)
|
|
) {
|
|
fail(`${label} violates blocking or diagnostic policy`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function validateReceiptSourceAttempt(value, label, state) {
|
|
const source = object(value, label);
|
|
exactKeys(
|
|
source,
|
|
state
|
|
? ["schema", "digest", "parent_run_attempt", "source_parent_run_attempt"]
|
|
: ["schema", "digest", "parent_run_attempt"],
|
|
label,
|
|
);
|
|
return {
|
|
schema: ascii(source.schema, `${label}.schema`),
|
|
digest: digest(source.digest, `${label}.digest`),
|
|
parent_run_attempt: positiveInteger(source.parent_run_attempt, `${label}.parent_run_attempt`),
|
|
...(state
|
|
? {
|
|
source_parent_run_attempt: positiveInteger(
|
|
source.source_parent_run_attempt,
|
|
`${label}.source_parent_run_attempt`,
|
|
),
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
function validateReceiptArtifacts(value, context) {
|
|
if (!Array.isArray(value) || value.length !== 4) {
|
|
fail("release validation receipt source_artifacts must contain exactly four artifacts");
|
|
}
|
|
const expected = {
|
|
decision: {
|
|
name: `full-release-decision-${context.attempt.run_id}-${context.sourceAttempts.decision.parent_run_attempt}`,
|
|
entry: REQUIRED_ARTIFACTS.decision.entry,
|
|
attempt: context.sourceAttempts.decision.parent_run_attempt,
|
|
digest: context.sourceAttempts.decision.digest,
|
|
},
|
|
"diagnostic-drain": {
|
|
name: `full-release-diagnostics-${context.attempt.run_id}-${context.sourceAttempts.diagnostic_drain.parent_run_attempt}`,
|
|
entry: REQUIRED_ARTIFACTS["diagnostic-drain"].entry,
|
|
attempt: context.sourceAttempts.diagnostic_drain.parent_run_attempt,
|
|
digest: context.sourceAttempts.diagnostic_drain.digest,
|
|
},
|
|
"execution-plan": {
|
|
name: `full-release-execution-plan-${context.attempt.run_id}`,
|
|
entry: REQUIRED_ARTIFACTS["execution-plan"].entry,
|
|
attempt: context.sourceAttempts.execution_plan.parent_run_attempt,
|
|
digest: context.sourceAttempts.execution_plan.digest,
|
|
},
|
|
"release-plan-lock": {
|
|
name: `release-plan-lock-${context.attempt.run_id}-${context.sourceAttempts.execution_plan.parent_run_attempt}`,
|
|
entry: REQUIRED_ARTIFACTS["release-plan-lock"].entry,
|
|
attempt: context.sourceAttempts.execution_plan.parent_run_attempt,
|
|
digest: context.releasePlan.lock_digest,
|
|
},
|
|
};
|
|
const artifacts = sortedUnique(
|
|
value.map((entry, index) => {
|
|
const label = `release validation receipt source_artifacts[${index}]`;
|
|
const artifact = object(entry, label);
|
|
exactKeys(
|
|
artifact,
|
|
[
|
|
"kind",
|
|
"artifact_id",
|
|
"artifact_name",
|
|
"entry_name",
|
|
"run_id",
|
|
"run_attempt",
|
|
"archive_digest",
|
|
"content_digest",
|
|
"created_at",
|
|
"expires_at",
|
|
"url",
|
|
],
|
|
label,
|
|
);
|
|
const kind = ascii(artifact.kind, `${label}.kind`);
|
|
if (!Object.hasOwn(expected, kind)) {
|
|
fail(`${label}.kind is unsupported`);
|
|
}
|
|
const artifactId = runId(artifact.artifact_id, `${label}.artifact_id`);
|
|
const artifactRunId = runId(artifact.run_id, `${label}.run_id`);
|
|
const result = {
|
|
kind,
|
|
artifact_id: artifactId,
|
|
artifact_name: ascii(artifact.artifact_name, `${label}.artifact_name`),
|
|
entry_name: ascii(artifact.entry_name, `${label}.entry_name`),
|
|
run_id: artifactRunId,
|
|
run_attempt: positiveInteger(artifact.run_attempt, `${label}.run_attempt`),
|
|
archive_digest: digest(artifact.archive_digest, `${label}.archive_digest`),
|
|
content_digest: digest(artifact.content_digest, `${label}.content_digest`),
|
|
created_at: timestamp(artifact.created_at, `${label}.created_at`),
|
|
expires_at: timestamp(artifact.expires_at, `${label}.expires_at`),
|
|
url: exactArtifactUrl(artifact.url, artifactRunId, artifactId, `${label}.url`),
|
|
};
|
|
const required = expected[kind];
|
|
if (
|
|
result.run_id !== context.attempt.run_id ||
|
|
result.run_attempt !== required.attempt ||
|
|
result.artifact_name !== required.name ||
|
|
result.entry_name !== required.entry ||
|
|
result.content_digest !== required.digest ||
|
|
Date.parse(result.expires_at) <= Date.parse(result.created_at)
|
|
) {
|
|
fail(`${label} coordinates differ from its receipt sources`);
|
|
}
|
|
return result;
|
|
}),
|
|
"release validation receipt source_artifacts",
|
|
(artifact) => `${artifact.kind}\0${artifact.artifact_name}`,
|
|
);
|
|
const ids = artifacts.map((artifact) => artifact.artifact_id);
|
|
const coordinates = artifacts.map(
|
|
(artifact) =>
|
|
`${artifact.run_id}\0${artifact.run_attempt}\0${artifact.artifact_name}\0${artifact.entry_name}`,
|
|
);
|
|
if (new Set(ids).size !== ids.length || new Set(coordinates).size !== coordinates.length) {
|
|
fail("release validation receipt source_artifacts IDs or coordinates are duplicated");
|
|
}
|
|
const byKind = Object.fromEntries(artifacts.map((artifact) => [artifact.kind, artifact]));
|
|
const startedAt = Date.parse(context.timestamps.started_at);
|
|
const decisionAt = Date.parse(context.timestamps.decision_at);
|
|
const drainAt = Date.parse(context.timestamps.drain_completed_at);
|
|
const sealedAt = Date.parse(context.timestamps.sealed_at);
|
|
if (
|
|
Date.parse(byKind.decision.created_at) < decisionAt ||
|
|
Date.parse(byKind["diagnostic-drain"].created_at) < drainAt ||
|
|
Date.parse(byKind["execution-plan"].created_at) < startedAt ||
|
|
Date.parse(byKind["execution-plan"].created_at) > decisionAt ||
|
|
Date.parse(byKind["release-plan-lock"].created_at) > decisionAt ||
|
|
artifacts.some(
|
|
(artifact) =>
|
|
Date.parse(artifact.created_at) > sealedAt || Date.parse(artifact.expires_at) <= sealedAt,
|
|
)
|
|
) {
|
|
fail("release validation receipt source artifact timestamps are invalid");
|
|
}
|
|
return artifacts;
|
|
}
|
|
|
|
export function validateReleaseValidationReceipt(value) {
|
|
const label = "release validation receipt";
|
|
const receipt = object(value, label);
|
|
exactKeys(
|
|
receipt,
|
|
[
|
|
"schema",
|
|
"canonicalization",
|
|
"target",
|
|
"tooling",
|
|
"attempt",
|
|
"release_plan",
|
|
"validation",
|
|
"source_attempts",
|
|
"groups",
|
|
"source_artifacts",
|
|
"timestamps",
|
|
"lineage",
|
|
],
|
|
label,
|
|
);
|
|
if (
|
|
receipt.schema !== RELEASE_VALIDATION_RECEIPT_SCHEMA ||
|
|
receipt.canonicalization !== RELEASE_PLAN_CANONICALIZATION
|
|
) {
|
|
fail("release validation receipt schema or canonicalization is unsupported");
|
|
}
|
|
const target = object(receipt.target, `${label} target`);
|
|
exactKeys(target, ["repository", "ref", "sha"], `${label} target`);
|
|
const targetSha = sha(target.sha, `${label} target.sha`);
|
|
const normalizedTarget = {
|
|
repository: ascii(target.repository, `${label} target.repository`),
|
|
ref: targetRef(target.ref, targetSha, `${label} target.ref`),
|
|
sha: targetSha,
|
|
};
|
|
if (normalizedTarget.repository !== REPOSITORY) {
|
|
fail(`release validation receipt target.repository must be ${REPOSITORY}`);
|
|
}
|
|
const tooling = object(receipt.tooling, `${label} tooling`);
|
|
exactKeys(tooling, ["repository", "workflow_path", "ref", "sha"], `${label} tooling`);
|
|
const normalizedTooling = {
|
|
repository: ascii(tooling.repository, `${label} tooling.repository`),
|
|
workflow_path: ascii(tooling.workflow_path, `${label} tooling.workflow_path`),
|
|
ref: qualifiedRef(tooling.ref, `${label} tooling.ref`),
|
|
sha: sha(tooling.sha, `${label} tooling.sha`),
|
|
};
|
|
if (
|
|
normalizedTooling.repository !== REPOSITORY ||
|
|
normalizedTooling.workflow_path !== WORKFLOW_PATH
|
|
) {
|
|
fail("release validation receipt tooling authority is unsupported");
|
|
}
|
|
const attempt = object(receipt.attempt, `${label} attempt`);
|
|
exactKeys(attempt, ["workflow_name", "run_id", "run_attempt", "url"], `${label} attempt`);
|
|
const attemptRunId = runId(attempt.run_id, `${label} attempt.run_id`);
|
|
const normalizedAttempt = {
|
|
workflow_name: ascii(attempt.workflow_name, `${label} attempt.workflow_name`),
|
|
run_id: attemptRunId,
|
|
run_attempt: positiveInteger(attempt.run_attempt, `${label} attempt.run_attempt`),
|
|
url: exactRunUrl(attempt.url, attemptRunId, `${label} attempt.url`),
|
|
};
|
|
if (normalizedAttempt.workflow_name !== WORKFLOW_NAME) {
|
|
fail(`release validation receipt attempt.workflow_name must be ${WORKFLOW_NAME}`);
|
|
}
|
|
const releasePlan = object(receipt.release_plan, `${label} release_plan`);
|
|
exactKeys(
|
|
releasePlan,
|
|
["schema", "purpose", "plan_digest", "lock_digest"],
|
|
`${label} release_plan`,
|
|
);
|
|
const normalizedReleasePlan = {
|
|
schema: ascii(releasePlan.schema, `${label} release_plan.schema`),
|
|
purpose: ascii(releasePlan.purpose, `${label} release_plan.purpose`),
|
|
plan_digest: digest(releasePlan.plan_digest, `${label} release_plan.plan_digest`),
|
|
lock_digest: digest(releasePlan.lock_digest, `${label} release_plan.lock_digest`),
|
|
};
|
|
if (normalizedReleasePlan.schema !== "openclaw.release-plan.v1") {
|
|
fail("release validation receipt release_plan.schema is unsupported");
|
|
}
|
|
const validation = object(receipt.validation, `${label} validation`);
|
|
exactKeys(
|
|
validation,
|
|
["intent", "profile", "soak", "allowed_groups", "rerun_group", "policy"],
|
|
`${label} validation`,
|
|
);
|
|
if (!Array.isArray(validation.allowed_groups) || validation.allowed_groups.length === 0) {
|
|
fail(`${label} validation.allowed_groups must be a non-empty array`);
|
|
}
|
|
const allowedGroups = sortedUnique(
|
|
validation.allowed_groups.map((group, index) =>
|
|
ascii(group, `${label} validation.allowed_groups[${index}]`),
|
|
),
|
|
`${label} validation.allowed_groups`,
|
|
(group) => group,
|
|
);
|
|
const policy = object(validation.policy, `${label} validation.policy`);
|
|
exactKeys(policy, ["id", "fail_fast"], `${label} validation.policy`);
|
|
const normalizedValidation = {
|
|
intent: ascii(validation.intent, `${label} validation.intent`),
|
|
profile: ascii(validation.profile, `${label} validation.profile`),
|
|
soak: booleanValue(validation.soak, `${label} validation.soak`),
|
|
allowed_groups: allowedGroups,
|
|
rerun_group: ascii(validation.rerun_group, `${label} validation.rerun_group`),
|
|
policy: {
|
|
id: ascii(policy.id, `${label} validation.policy.id`),
|
|
fail_fast: booleanValue(policy.fail_fast, `${label} validation.policy.fail_fast`),
|
|
},
|
|
};
|
|
if (
|
|
normalizedValidation.policy.id !== RELEASE_VALIDATION_POLICY_ID ||
|
|
!allowedGroups.includes(normalizedValidation.rerun_group)
|
|
) {
|
|
fail("release validation receipt validation policy or rerun group is unsupported");
|
|
}
|
|
releaseValidationIntentForPurpose(normalizedReleasePlan.purpose, normalizedValidation.intent);
|
|
resolveReleaseValidationIntent(normalizedValidation.intent, {
|
|
profile: normalizedValidation.profile,
|
|
soak: normalizedValidation.soak,
|
|
});
|
|
const sourceAttempts = object(receipt.source_attempts, `${label} source_attempts`);
|
|
exactKeys(
|
|
sourceAttempts,
|
|
["execution_plan", "decision", "diagnostic_drain"],
|
|
`${label} source_attempts`,
|
|
);
|
|
const normalizedSourceAttempts = {
|
|
execution_plan: validateReceiptSourceAttempt(
|
|
sourceAttempts.execution_plan,
|
|
`${label} source_attempts.execution_plan`,
|
|
false,
|
|
),
|
|
decision: validateReceiptSourceAttempt(
|
|
sourceAttempts.decision,
|
|
`${label} source_attempts.decision`,
|
|
true,
|
|
),
|
|
diagnostic_drain: validateReceiptSourceAttempt(
|
|
sourceAttempts.diagnostic_drain,
|
|
`${label} source_attempts.diagnostic_drain`,
|
|
true,
|
|
),
|
|
};
|
|
if (
|
|
normalizedSourceAttempts.execution_plan.schema !== EXECUTION_PLAN_SCHEMA ||
|
|
normalizedSourceAttempts.decision.schema !== DECISION_SCHEMA ||
|
|
normalizedSourceAttempts.diagnostic_drain.schema !== DRAIN_SCHEMA ||
|
|
normalizedSourceAttempts.execution_plan.parent_run_attempt >
|
|
normalizedSourceAttempts.decision.parent_run_attempt ||
|
|
normalizedSourceAttempts.decision.parent_run_attempt >
|
|
normalizedSourceAttempts.diagnostic_drain.parent_run_attempt ||
|
|
normalizedSourceAttempts.decision.source_parent_run_attempt !==
|
|
normalizedSourceAttempts.execution_plan.parent_run_attempt ||
|
|
normalizedSourceAttempts.diagnostic_drain.source_parent_run_attempt !==
|
|
normalizedSourceAttempts.execution_plan.parent_run_attempt
|
|
) {
|
|
fail("release validation receipt source attempt binding is invalid");
|
|
}
|
|
if (
|
|
normalizedAttempt.run_attempt !==
|
|
Math.max(
|
|
normalizedSourceAttempts.decision.parent_run_attempt,
|
|
normalizedSourceAttempts.diagnostic_drain.parent_run_attempt,
|
|
)
|
|
) {
|
|
fail("release validation receipt attempt differs from its terminal source attempts");
|
|
}
|
|
if (!Array.isArray(receipt.groups) || receipt.groups.length === 0) {
|
|
fail(`${label} groups must be a non-empty array`);
|
|
}
|
|
const groups = sortedUnique(
|
|
receipt.groups.map((group, index) => validateReceiptGroup(group, index, normalizedTooling.sha)),
|
|
`${label} groups`,
|
|
(group) => group.id,
|
|
);
|
|
const timestamps = object(receipt.timestamps, `${label} timestamps`);
|
|
exactKeys(
|
|
timestamps,
|
|
["started_at", "decision_at", "drain_completed_at", "sealed_at"],
|
|
`${label} timestamps`,
|
|
);
|
|
const normalizedTimestamps = {
|
|
started_at: timestamp(timestamps.started_at, `${label} timestamps.started_at`),
|
|
decision_at: timestamp(timestamps.decision_at, `${label} timestamps.decision_at`),
|
|
drain_completed_at: timestamp(
|
|
timestamps.drain_completed_at,
|
|
`${label} timestamps.drain_completed_at`,
|
|
),
|
|
sealed_at: timestamp(timestamps.sealed_at, `${label} timestamps.sealed_at`),
|
|
};
|
|
const orderedTimes = [
|
|
normalizedTimestamps.started_at,
|
|
normalizedTimestamps.decision_at,
|
|
normalizedTimestamps.drain_completed_at,
|
|
normalizedTimestamps.sealed_at,
|
|
].map(Date.parse);
|
|
if (orderedTimes.some((time, index) => index > 0 && orderedTimes[index - 1] > time)) {
|
|
fail("release validation receipt timestamps are not chronological");
|
|
}
|
|
const result = {
|
|
schema: RELEASE_VALIDATION_RECEIPT_SCHEMA,
|
|
canonicalization: RELEASE_PLAN_CANONICALIZATION,
|
|
target: normalizedTarget,
|
|
tooling: normalizedTooling,
|
|
attempt: normalizedAttempt,
|
|
release_plan: normalizedReleasePlan,
|
|
validation: normalizedValidation,
|
|
source_attempts: normalizedSourceAttempts,
|
|
groups,
|
|
source_artifacts: validateReceiptArtifacts(receipt.source_artifacts, {
|
|
attempt: normalizedAttempt,
|
|
releasePlan: normalizedReleasePlan,
|
|
sourceAttempts: normalizedSourceAttempts,
|
|
timestamps: normalizedTimestamps,
|
|
}),
|
|
timestamps: normalizedTimestamps,
|
|
lineage: validateLineageShape(receipt.lineage),
|
|
};
|
|
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 verifyReleaseValidationReceiptLineage(
|
|
receiptValue,
|
|
{ parentReceipt, rootReceipt } = {},
|
|
) {
|
|
const receipt = validateReleaseValidationReceipt(receiptValue);
|
|
const expected = buildLineage({
|
|
parentReceipt,
|
|
rootReceipt,
|
|
validation: receipt.validation,
|
|
startedAt: receipt.timestamps.started_at,
|
|
});
|
|
if (canonicalReleaseJson(receipt.lineage) !== canonicalReleaseJson(expected)) {
|
|
fail("release validation receipt lineage differs from the supplied parent/root continuity");
|
|
}
|
|
return receipt.lineage;
|
|
}
|
|
|
|
export function verifyReleaseValidationReceipt(receiptValue, input) {
|
|
const receipt = validateReleaseValidationReceipt(receiptValue);
|
|
const expected = sealReleaseValidationReceipt(input);
|
|
if (canonicalReleaseJson(receipt) !== canonicalReleaseJson(expected)) {
|
|
fail("release validation receipt differs from its validated source objects");
|
|
}
|
|
verifiedReceipts.set(receipt, releaseValidationReceiptDigest(receipt));
|
|
return receipt;
|
|
}
|
|
|
|
export function validateReleaseValidationReceiptReuseFreshness(receiptValue, optionsValue) {
|
|
const receipt = authenticatedReceipt(receiptValue, "release validation receipt reuse candidate");
|
|
const options = object(optionsValue, "release validation receipt reuse options");
|
|
exactKeys(options, ["now_ms", "max_future_skew_ms"], "release validation receipt reuse options");
|
|
const nowMs = nonNegativeInteger(options.now_ms, "release validation receipt reuse now_ms");
|
|
const futureSkewMs = nonNegativeInteger(
|
|
options.max_future_skew_ms,
|
|
"release validation receipt reuse max_future_skew_ms",
|
|
);
|
|
const policy = RELEASE_VALIDATION_REUSE_POLICIES[receipt.validation.intent];
|
|
const maxAgeMs = policy.max_age_ms;
|
|
const cadenceMs = policy.cadence_ms;
|
|
const sealedAtMs = Date.parse(receipt.timestamps.sealed_at);
|
|
const sourceTimes = [
|
|
receipt.timestamps.started_at,
|
|
receipt.timestamps.decision_at,
|
|
receipt.timestamps.drain_completed_at,
|
|
receipt.timestamps.sealed_at,
|
|
...receipt.source_artifacts.map((artifact) => artifact.created_at),
|
|
].map(Date.parse);
|
|
const futureBoundaryMs = nowMs + futureSkewMs;
|
|
if (
|
|
!Number.isSafeInteger(futureBoundaryMs) ||
|
|
sourceTimes.some((value) => value > futureBoundaryMs)
|
|
) {
|
|
fail("release validation receipt reuse evidence is newer than the allowed future skew");
|
|
}
|
|
const policyExpiryMs = sealedAtMs + Math.min(maxAgeMs, cadenceMs);
|
|
if (!Number.isSafeInteger(policyExpiryMs)) {
|
|
fail("release validation receipt reuse policy expiry exceeds safe integer range");
|
|
}
|
|
const artifactExpiryMs = Math.min(
|
|
...receipt.source_artifacts.map((artifact) => Date.parse(artifact.expires_at)),
|
|
);
|
|
const expiresAtMs = Math.min(policyExpiryMs, artifactExpiryMs);
|
|
if (nowMs >= expiresAtMs) {
|
|
fail("release validation receipt reuse evidence is expired");
|
|
}
|
|
return {
|
|
intent: receipt.validation.intent,
|
|
age_ms: Math.max(0, nowMs - sealedAtMs),
|
|
max_age_ms: maxAgeMs,
|
|
cadence_ms: cadenceMs,
|
|
expires_at_ms: expiresAtMs,
|
|
};
|
|
}
|
|
|
|
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 label = "release validation receipt locator";
|
|
const envelope = object(value, label);
|
|
exactKeys(
|
|
envelope,
|
|
["schema", "canonicalization", "receipt_digest", "locator", "sealed_at"],
|
|
label,
|
|
);
|
|
if (
|
|
envelope.schema !== RELEASE_VALIDATION_RECEIPT_LOCATOR_SCHEMA ||
|
|
envelope.canonicalization !== RELEASE_PLAN_CANONICALIZATION
|
|
) {
|
|
fail("release validation receipt locator schema or canonicalization is unsupported");
|
|
}
|
|
const locator = object(envelope.locator, `${label} coordinates`);
|
|
exactKeys(
|
|
locator,
|
|
[
|
|
"repository",
|
|
"run_id",
|
|
"run_attempt",
|
|
"artifact_id",
|
|
"artifact_name",
|
|
"entry_name",
|
|
"archive_digest",
|
|
"url",
|
|
],
|
|
`${label} coordinates`,
|
|
);
|
|
const locatorRunId = runId(locator.run_id, `${label} run_id`);
|
|
const artifactId = runId(locator.artifact_id, `${label} artifact_id`);
|
|
const normalizedLocator = {
|
|
repository: ascii(locator.repository, `${label} repository`),
|
|
run_id: locatorRunId,
|
|
run_attempt: positiveInteger(locator.run_attempt, `${label} run_attempt`),
|
|
artifact_id: artifactId,
|
|
artifact_name: ascii(locator.artifact_name, `${label} artifact_name`),
|
|
entry_name: ascii(locator.entry_name, `${label} entry_name`),
|
|
archive_digest: digest(locator.archive_digest, `${label} archive_digest`),
|
|
url: exactArtifactUrl(locator.url, locatorRunId, artifactId, `${label} url`),
|
|
};
|
|
if (
|
|
normalizedLocator.repository !== REPOSITORY ||
|
|
normalizedLocator.entry_name !== "release-validation-receipt.json" ||
|
|
normalizedLocator.artifact_name !==
|
|
`release-validation-receipt-${locatorRunId}-${normalizedLocator.run_attempt}`
|
|
) {
|
|
fail("release validation receipt locator coordinates are unsupported");
|
|
}
|
|
return {
|
|
schema: RELEASE_VALIDATION_RECEIPT_LOCATOR_SCHEMA,
|
|
canonicalization: RELEASE_PLAN_CANONICALIZATION,
|
|
receipt_digest: digest(envelope.receipt_digest, `${label} receipt_digest`),
|
|
locator: normalizedLocator,
|
|
sealed_at: timestamp(envelope.sealed_at, `${label} sealed_at`),
|
|
};
|
|
}
|
|
|
|
export function createReleaseValidationReceiptLocator(receiptValue, locatorValue) {
|
|
const receipt = validateReleaseValidationReceipt(receiptValue);
|
|
const result = validateReleaseValidationReceiptLocator({
|
|
schema: RELEASE_VALIDATION_RECEIPT_LOCATOR_SCHEMA,
|
|
canonicalization: RELEASE_PLAN_CANONICALIZATION,
|
|
receipt_digest: releaseValidationReceiptDigest(receipt),
|
|
locator: locatorValue,
|
|
sealed_at: receipt.timestamps.sealed_at,
|
|
});
|
|
if (
|
|
result.locator.run_id !== receipt.attempt.run_id ||
|
|
result.locator.run_attempt !== receipt.attempt.run_attempt
|
|
) {
|
|
fail("release validation receipt locator attempt differs from its receipt");
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|