mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
2799 lines
104 KiB
JavaScript
2799 lines
104 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import {
|
|
validateFullReleaseCandidateBinding,
|
|
validateFullReleaseCandidateRequest,
|
|
validateRecordedFullReleaseCandidateRequest,
|
|
} from "./full-release-candidate-contract.mjs";
|
|
import {
|
|
validateFlakeClassification,
|
|
validateFlakeGateEntries,
|
|
} from "./full-release-flake-classification.mjs";
|
|
import {
|
|
FULL_RELEASE_PUBLICATION_ADMISSION_CONTRACT,
|
|
FULL_RELEASE_SOURCE_ADMISSION_CONTRACT,
|
|
publicationIntentInputs,
|
|
validatePublicationAdmissionBinding,
|
|
validatePublicationSourceBinding,
|
|
} from "./full-release-publication-contract.mjs";
|
|
import { hasRequiredCrossOsSuites } from "./lib/cross-os-release-checks/suite-filter.mjs";
|
|
import { candidateArtifactJsonFromBinding } from "./lib/full-release-candidate-reuse.mjs";
|
|
import {
|
|
MAX_RELEASE_ARTIFACT_BYTES,
|
|
serializeReleaseArtifact,
|
|
} from "./lib/full-release-evidence.mjs";
|
|
import { changelogEntryPath, isReleaseChangelogPath } from "./lib/release-changelog.mjs";
|
|
import { classifyReleaseTrain, parseReleaseVersion } from "./lib/release-version.mjs";
|
|
|
|
export { MAX_RELEASE_ARTIFACT_BYTES, serializeReleaseArtifact };
|
|
|
|
// CI's dependency-free shell gate is pinned to this class by its workflow tests.
|
|
export const WINDOWS_NODE_CI_ADVISORY = Object.freeze({
|
|
id: "windows-node-ci",
|
|
child: "normalCi",
|
|
jobNamePattern: /^checks-windows-node-.+$/u,
|
|
aggregateJob: "checks-windows",
|
|
});
|
|
|
|
function isWindowsNodeAdvisoryJob(child, job) {
|
|
return (
|
|
child.key === WINDOWS_NODE_CI_ADVISORY.child &&
|
|
typeof job.name === "string" &&
|
|
WINDOWS_NODE_CI_ADVISORY.jobNamePattern.test(job.name) &&
|
|
job.status === "completed" &&
|
|
["failure", "timed_out"].includes(job.conclusion)
|
|
);
|
|
}
|
|
|
|
function recordedFlakeReceipt(child, job) {
|
|
return child.flakeClassifications?.find((receipt) => {
|
|
if (receipt.jobName !== job.name || receipt.jobUrl !== (job.html_url ?? job.url)) {
|
|
return false;
|
|
}
|
|
try {
|
|
validateFlakeClassification(receipt, { child: { ...child, jobs: [job] } });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function isAdvisoryJob(child, job) {
|
|
return isWindowsNodeAdvisoryJob(child, job) || Boolean(recordedFlakeReceipt(child, job));
|
|
}
|
|
|
|
export function releaseAdvisoryJobs(children) {
|
|
return children.flatMap((child) =>
|
|
child.jobs.flatMap((job) => {
|
|
const windows = isWindowsNodeAdvisoryJob(child, job);
|
|
const receipt = windows ? undefined : recordedFlakeReceipt(child, job);
|
|
return windows || receipt
|
|
? [
|
|
{
|
|
class: windows ? WINDOWS_NODE_CI_ADVISORY.id : "recorded-flake",
|
|
child: child.key,
|
|
job: job.name,
|
|
conclusion: job.conclusion,
|
|
runId: child.runId,
|
|
url: job.html_url ?? job.url ?? "",
|
|
...(receipt
|
|
? {
|
|
jobId: receipt.jobId,
|
|
trackingUrl: receipt.trackingUrl,
|
|
reason: receipt.reason,
|
|
receiptRunId: receipt.receiptRunId,
|
|
}
|
|
: {}),
|
|
},
|
|
]
|
|
: [];
|
|
}),
|
|
);
|
|
}
|
|
|
|
export function releaseChildClassificationEvidence(child) {
|
|
return child.flakeClassifications === undefined && child.gateEntries === undefined
|
|
? {}
|
|
: {
|
|
flakeClassifications: child.flakeClassifications ?? [],
|
|
gateEntries: child.gateEntries ?? [],
|
|
status: child.status,
|
|
conclusion: child.conclusion,
|
|
};
|
|
}
|
|
|
|
function validateChildClassificationEvidence(child, binding) {
|
|
if (child.flakeClassifications !== undefined) {
|
|
if (!Array.isArray(child.flakeClassifications)) {
|
|
throw new Error("Release flake classifications are invalid");
|
|
}
|
|
if (
|
|
child.flakeClassifications.length &&
|
|
(!/^[1-9][0-9]*$/u.test(String(binding.parentRunId ?? "")) ||
|
|
positiveInteger(binding.parentRunAttempt) === undefined ||
|
|
!/^[a-f0-9]{40}$/u.test(String(binding.targetSha ?? "")))
|
|
) {
|
|
throw new Error("Release flake classification parent binding is invalid");
|
|
}
|
|
const jobs = new Set();
|
|
for (const receipt of child.flakeClassifications) {
|
|
validateFlakeClassification(receipt, { ...binding, child });
|
|
if (jobs.has(receipt.jobId)) {
|
|
throw new Error("Release flake classifications repeat a job");
|
|
}
|
|
jobs.add(receipt.jobId);
|
|
}
|
|
}
|
|
if (child.gateEntries !== undefined) {
|
|
if (!Array.isArray(child.gateEntries)) {
|
|
throw new Error("Release CI gate entries are invalid");
|
|
}
|
|
if (child.gateEntries.length) {
|
|
validateFlakeGateEntries(child.gateEntries);
|
|
}
|
|
}
|
|
return releaseChildClassificationEvidence(child);
|
|
}
|
|
|
|
function validateReleaseAdvisoryJobs(value, children) {
|
|
const expected = releaseAdvisoryJobs(children);
|
|
const recorded = value === undefined ? [] : value;
|
|
if (!Array.isArray(recorded) || jsonSha256(recorded) !== jsonSha256(expected)) {
|
|
throw new Error("Release advisory jobs differ from the release policy evidence");
|
|
}
|
|
return expected;
|
|
}
|
|
|
|
export function validateReleaseManifestAdvisoryJobs(manifest) {
|
|
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
throw new Error("Release advisory manifest is invalid");
|
|
}
|
|
if (
|
|
manifest.childEvidence !== undefined &&
|
|
(!manifest.childEvidence ||
|
|
typeof manifest.childEvidence !== "object" ||
|
|
Array.isArray(manifest.childEvidence))
|
|
) {
|
|
throw new Error("Release advisory child evidence is invalid");
|
|
}
|
|
const children = Object.entries(manifest.childEvidence ?? {}).map(([key, child]) => {
|
|
if (!child || !Array.isArray(child.jobs)) {
|
|
throw new Error("Release advisory child evidence is invalid");
|
|
}
|
|
if (
|
|
key === WINDOWS_NODE_CI_ADVISORY.child &&
|
|
(typeof child.runId !== "string" ||
|
|
!/^[1-9][0-9]*$/u.test(child.runId) ||
|
|
child.runId !== manifest.childRuns?.normalCi)
|
|
) {
|
|
throw new Error("Release advisory child run differs from the manifest");
|
|
}
|
|
const snapshot = Object.assign({}, child, { key });
|
|
validateChildClassificationEvidence(snapshot, {
|
|
parentRunId: manifest.runId,
|
|
parentRunAttempt: manifest.sourceParentRunAttempt,
|
|
targetSha: manifest.targetSha,
|
|
});
|
|
if (child.flakeClassifications?.length && !terminalPolicyPass(snapshot)) {
|
|
throw new Error("Release recorded flake evidence does not pass terminal policy");
|
|
}
|
|
return snapshot;
|
|
});
|
|
return validateReleaseAdvisoryJobs(manifest.advisoryJobs, children);
|
|
}
|
|
|
|
export function buildReleaseValidationManifest({ plan, drain, context }) {
|
|
const childEvidence = Object.fromEntries(
|
|
Object.entries(drain?.children ?? {}).map(([key, child]) => [
|
|
key,
|
|
{
|
|
...releaseChildClassificationEvidence(child),
|
|
runId: child.runId,
|
|
plannedRunAttempt: child.plannedRunAttempt,
|
|
effectiveRunAttempt: child.runAttempt,
|
|
observedRunAttempts: child.observedRunAttempts,
|
|
compositeJobsSha256: child.compositeJobsSha256,
|
|
dispatchActor: child.dispatchActor,
|
|
triggeringActor: child.triggeringActor,
|
|
repository: child.repository,
|
|
jobs: child.timing.jobs.map(
|
|
({ name, status, conclusion, acceptedRunAttempt, startedAt, completedAt, url }) => ({
|
|
name,
|
|
status,
|
|
conclusion,
|
|
acceptedRunAttempt,
|
|
startedAt,
|
|
completedAt,
|
|
url,
|
|
}),
|
|
),
|
|
},
|
|
]),
|
|
);
|
|
const current = {
|
|
version: 4,
|
|
runId: context.runId,
|
|
runAttempt: context.runAttempt,
|
|
workflowRef: context.workflowRef,
|
|
workflowSha: context.workflowSha,
|
|
workflowFullRef: context.workflowFullRef,
|
|
workflowRefType: context.workflowRefType,
|
|
targetRef: context.targetRef,
|
|
targetSha: plan.targetSha,
|
|
candidateBinding: plan.candidate,
|
|
publicationArtifacts: context.publicationArtifacts ?? { npmPreflight: null, docker: null },
|
|
publishInputs: context.publishInputs,
|
|
advisoryJobs: releaseAdvisoryJobs(
|
|
Object.entries(childEvidence).map(([key, child]) => Object.assign({}, child, { key })),
|
|
),
|
|
childEvidence,
|
|
executionPlanSha256: plan.sha256,
|
|
sourceParentRunAttempt: Number(plan.parentRunAttempt),
|
|
...(plan.sourceAdmissionContract
|
|
? {
|
|
sourceAdmissionContract: plan.sourceAdmissionContract,
|
|
sourceAdmission: plan.sourceAdmission,
|
|
trustedWorkflow: plan.trustedWorkflow,
|
|
}
|
|
: {}),
|
|
...(plan.publicationAdmissionContract
|
|
? {
|
|
publicationAdmissionContract: plan.publicationAdmissionContract,
|
|
publicationAdmission: plan.publicationAdmission,
|
|
}
|
|
: {}),
|
|
};
|
|
const runs = Object.fromEntries(plan.children.map((child) => [child.key, child.runId]));
|
|
const root = plan.evidenceReuse.sourceManifest;
|
|
let rootPublication;
|
|
if (plan.evidenceReuse.requested && root?.publicationAdmissionContract !== undefined) {
|
|
rootPublication = {
|
|
sourceAdmissionContract: root.sourceAdmissionContract,
|
|
sourceAdmission: root.sourceAdmission,
|
|
publicationAdmissionContract: root.publicationAdmissionContract,
|
|
publicationAdmission: root.publicationAdmission,
|
|
};
|
|
validatePublicationAdmissionBinding(rootPublication);
|
|
if (
|
|
root.sourceAdmission.runId !== plan.evidenceReuse.rootRunId ||
|
|
root.sourceAdmission.candidateSha !== plan.evidenceReuse.evidenceSha
|
|
) {
|
|
throw new Error("reused publication admission differs from the retained root identity");
|
|
}
|
|
}
|
|
const manifest = plan.evidenceReuse.requested
|
|
? {
|
|
...plan.evidenceReuse.sourceManifest,
|
|
...current,
|
|
evidenceReuse: {
|
|
policy: plan.evidenceReuse.policy,
|
|
runId: plan.evidenceReuse.rootRunId,
|
|
selectedRunId: plan.evidenceReuse.selectedRunId,
|
|
evidenceSha: plan.evidenceReuse.evidenceSha,
|
|
changedPaths: plan.evidenceReuse.changedPaths ?? [],
|
|
...(rootPublication ? { publication: rootPublication } : {}),
|
|
},
|
|
controls: {
|
|
...plan.evidenceReuse.sourceManifest.controls,
|
|
performanceReportPublication: "artifact-only",
|
|
},
|
|
}
|
|
: {
|
|
...current,
|
|
workflowName: "Full Release Validation",
|
|
releaseProfile: context.releaseProfile,
|
|
rerunGroup: context.rerunGroup,
|
|
runReleaseSoak: context.runReleaseSoak,
|
|
validationInputs: {
|
|
...context.validationInputs,
|
|
...(plan.sourceAdmissionContract
|
|
? {
|
|
...publicationIntentInputs(plan.sourceAdmission),
|
|
}
|
|
: {}),
|
|
...(plan.coveragePolicy ? { coveragePolicy: plan.coveragePolicy } : {}),
|
|
},
|
|
controls: {
|
|
stableSoakRequired: ["stable", "full"].includes(context.releaseProfile),
|
|
performanceBlocking: context.releaseProfile !== "beta",
|
|
performanceReportPublication: "artifact-only",
|
|
},
|
|
childRuns: {
|
|
normalCi: runs.normalCi ?? "",
|
|
pluginPrereleaseIndependent: runs.pluginPrereleaseIndependent ?? "",
|
|
pluginPrereleaseCandidate: runs.pluginPrereleaseCandidate ?? "",
|
|
releaseChecksIndependent: runs.releaseChecksIndependent ?? "",
|
|
releaseChecksCandidate: runs.releaseChecksCandidate ?? "",
|
|
npmTelegram: runs.npmTelegram ?? "",
|
|
productPerformance: {
|
|
runId: runs.productPerformance ?? "",
|
|
conclusion: drain?.children?.productPerformance?.conclusion ?? "",
|
|
blocking: context.releaseProfile !== "beta",
|
|
},
|
|
},
|
|
};
|
|
serializeReleaseArtifact(manifest);
|
|
return manifest;
|
|
}
|
|
|
|
const SUCCESSFUL_JOB_CONCLUSIONS = new Set(["neutral", "skipped", "success"]);
|
|
const MAX_REPORTED_ISSUES = 25;
|
|
const MAX_SUMMARY_ISSUES = 5;
|
|
const MAX_LABEL_LENGTH = 200;
|
|
const MAX_MESSAGE_LENGTH = 500;
|
|
const MAX_URL_LENGTH = 1024;
|
|
const EXACT_TARGET_EVIDENCE_REUSE_POLICY = "exact-target-full-validation-v1";
|
|
const CHANGELOG_ONLY_EVIDENCE_REUSE_POLICY = "changelog-only-release-v1";
|
|
export const SPLIT_CHANGELOG_EVIDENCE_REUSE_POLICY = "split-changelog-release-v1";
|
|
|
|
export function assertReleasePublicationKnownBudget(plan, context) {
|
|
serializeReleaseArtifact(plan);
|
|
// Only these future fields have finite bounds in the existing plan normalizers.
|
|
// Complete future jobs/attempts and candidate receipts are not covered here.
|
|
const issue = normalizeIssue(
|
|
Object.fromEntries(
|
|
["child", "conclusion", "job", "kind", "message", "runId", "url"].map((key) => [
|
|
key,
|
|
"\0".repeat(
|
|
key === "url"
|
|
? MAX_URL_LENGTH
|
|
: key === "message"
|
|
? MAX_MESSAGE_LENGTH
|
|
: MAX_LABEL_LENGTH,
|
|
),
|
|
]),
|
|
),
|
|
"orchestration_error",
|
|
);
|
|
const reserved = {
|
|
...plan,
|
|
children: plan.children.map((child) =>
|
|
child.selected
|
|
? {
|
|
...child,
|
|
result: "\0".repeat(MAX_LABEL_LENGTH),
|
|
runId: "9".repeat(MAX_LABEL_LENGTH),
|
|
runAttempt: Number.MAX_SAFE_INTEGER,
|
|
sourceParentAttempt: Number.MAX_SAFE_INTEGER,
|
|
url: "\0".repeat(MAX_URL_LENGTH),
|
|
}
|
|
: child,
|
|
),
|
|
gates: plan.gates.map((gate) => ({ ...gate, result: "\0".repeat(MAX_LABEL_LENGTH) })),
|
|
blockers: Array.from({ length: MAX_REPORTED_ISSUES }, () => issue),
|
|
errors: Array.from({ length: MAX_REPORTED_ISSUES }, () => issue),
|
|
};
|
|
serializeReleaseArtifact(reserved);
|
|
buildReleaseValidationManifest({ plan: reserved, context });
|
|
}
|
|
|
|
// A split-layout receipt is bound to one selected release, never the directory.
|
|
// Historical root-file receipts retain their original exact-path contract.
|
|
export function isSplitChangelogEvidenceDelta(paths, version) {
|
|
if (typeof version !== "string" || !version || version === "Unreleased") {
|
|
return false;
|
|
}
|
|
try {
|
|
const targetVersion = version.replace(/^v/u, "");
|
|
const parsedVersion = parseReleaseVersion(targetVersion);
|
|
// Beta release notes and contribution records use the stable base section.
|
|
const sectionVersion =
|
|
parsedVersion?.channel === "beta" && parsedVersion.version === targetVersion
|
|
? parsedVersion.baseVersion
|
|
: version;
|
|
return (
|
|
Array.isArray(paths) &&
|
|
paths.length > 0 &&
|
|
new Set(paths).size === paths.length &&
|
|
paths.includes(changelogEntryPath(sectionVersion)) &&
|
|
paths.every((name) => isReleaseChangelogPath(name, { version: sectionVersion }))
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function classifyReleaseChangelogEvidenceComparison(comparison, { baseSha, version }) {
|
|
const files = Array.isArray(comparison?.files) ? comparison.files : [];
|
|
const changedPaths = files.map((file) => file.filename);
|
|
const legacy = changedPaths.length === 1 && changedPaths[0] === "CHANGELOG.md";
|
|
const split = isSplitChangelogEvidenceDelta(changedPaths, version);
|
|
if (
|
|
comparison?.status !== "ahead" ||
|
|
comparison?.merge_base_commit?.sha !== baseSha ||
|
|
(!legacy && !split) ||
|
|
new Set(changedPaths).size !== changedPaths.length ||
|
|
files.some(
|
|
(file) =>
|
|
file.previous_filename ||
|
|
!(
|
|
split && file.filename !== "CHANGELOG.md" ? ["added", "modified"] : ["modified"]
|
|
).includes(file.status),
|
|
)
|
|
) {
|
|
throw new Error("changelog-only release evidence reuse failed commit comparison");
|
|
}
|
|
return {
|
|
changedPaths,
|
|
policy: split ? SPLIT_CHANGELOG_EVIDENCE_REUSE_POLICY : CHANGELOG_ONLY_EVIDENCE_REUSE_POLICY,
|
|
};
|
|
}
|
|
const REVIEWED_TELEGRAM_WAIVERS = new Map([
|
|
["2026.8.1-owner-approved", ["telegram"]],
|
|
["2026.9.1-owner-approved", ["telegram"]],
|
|
["2026.9.5-owner-approved", ["telegram", "matrix"]],
|
|
// Peter approved waiving minor live-channel QA for 2026.9.7 (2026-09-29 00:40 PT) when
|
|
// repair is not possible before release: QA Live Matrix 7/24 deterministic on
|
|
// 01d71319 (FRV 36534008742, jobs 109299525565 and rerun 109316259401) and Telegram
|
|
// QA 4/25 on 56fb8872 and e61efb6c (job 109279074202).
|
|
["2026.9.7-owner-approved", ["telegram", "matrix"]],
|
|
]);
|
|
const HARD_GH_TRANSPORT_PATTERN =
|
|
/HTTP (?:400|401|403|404|410|422)\b|Bad credentials|authentication required|not authenticated|gh auth login|unknown (?:command|flag)|Usage: gh\b|ENOENT|EACCES/iu;
|
|
const RATE_LIMITED_403_PATTERN =
|
|
/HTTP 403\b[\s\S]*(?:rate limit|abuse detection)|(?:rate limit|abuse detection)[\s\S]*HTTP 403\b/iu;
|
|
const TRANSIENT_GH_TRANSPORT_PATTERN =
|
|
/HTTP 429\b|HTTP 5[0-9][0-9]\b|Server Error|secondary rate limit|API rate limit|abuse detection|error connecting to|context deadline exceeded|connection reset by peer|connection refused|TLS handshake timeout|i\/o timeout|timed out|\btimeout\b|network is unreachable|unexpected EOF|ETIMEDOUT|ECONNRESET|EAI_AGAIN/iu;
|
|
const RELEASE_GH_ARTIFACT_MISSING_LINE_PATTERN =
|
|
/^(?:no valid artifacts found(?: to download)?|no artifact matches any of the names(?: or patterns)? provided|could not find any artifacts|artifact .+ not found)$/iu;
|
|
const RELEASE_GH_ARTIFACT_CONTENT_ERROR_PATTERN =
|
|
/unexpected end of JSON|\b(?:artifact|archive|JSON)\b.{0,80}\b(?:malformed|invalid|oversized|too large|exceeds? (?:the )?size limit)\b|\b(?:malformed|invalid|oversized)\b.{0,80}\b(?:artifact|archive|JSON)\b/iu;
|
|
|
|
const RELEASE_DECISION_STATES = Object.freeze([
|
|
"qualifying",
|
|
"blocked_diagnostics_running",
|
|
"passed",
|
|
"blocked_complete",
|
|
"orchestration_error",
|
|
"cancelled_with_children",
|
|
]);
|
|
|
|
const RELEASE_DECISION_STATE_SET = new Set(RELEASE_DECISION_STATES);
|
|
const LEGACY_CHILD_SPECS = Object.freeze([
|
|
{
|
|
dispatchName: "Dispatch plugin prerelease",
|
|
displayName: "Plugin Prerelease",
|
|
key: "pluginPrerelease",
|
|
parentJobName: "Run plugin prerelease validation",
|
|
rerunGroups: ["all", "plugin-prerelease"],
|
|
suffix: "-plugin-prerelease",
|
|
workflow: "plugin-prerelease.yml",
|
|
},
|
|
{
|
|
dispatchName: "Dispatch release checks",
|
|
displayName: "OpenClaw Release Checks",
|
|
key: "releaseChecks",
|
|
parentJobName: "Run release/live/Docker/QA validation",
|
|
rerunGroups: [
|
|
"all",
|
|
"install-smoke",
|
|
"cross-os",
|
|
"live-e2e",
|
|
"package",
|
|
"qa-parity",
|
|
"qa-live",
|
|
],
|
|
suffix: "-release-checks",
|
|
workflow: "openclaw-release-checks.yml",
|
|
},
|
|
]);
|
|
const CHILD_SPECS = Object.freeze([
|
|
{
|
|
dispatchName: "Dispatch CI",
|
|
displayName: "CI",
|
|
key: "normalCi",
|
|
parentJobName: "Run normal full CI",
|
|
rerunGroups: ["all", "ci"],
|
|
suffix: "-ci",
|
|
workflow: "ci.yml",
|
|
},
|
|
{
|
|
dispatchName: "Dispatch plugin prerelease independent phase",
|
|
displayName: "Plugin Prerelease",
|
|
key: "pluginPrereleaseIndependent",
|
|
parentJobName: "Run plugin prerelease independent validation",
|
|
rerunGroups: ["all", "plugin-prerelease"],
|
|
suffix: "-plugin-prerelease-independent",
|
|
workflow: "plugin-prerelease.yml",
|
|
},
|
|
{
|
|
dispatchName: "Dispatch plugin prerelease candidate phase",
|
|
displayName: "Plugin Prerelease",
|
|
key: "pluginPrereleaseCandidate",
|
|
parentJobName: "Run plugin prerelease candidate validation",
|
|
rerunGroups: ["all", "plugin-prerelease"],
|
|
suffix: "-plugin-prerelease-candidate",
|
|
workflow: "plugin-prerelease.yml",
|
|
},
|
|
{
|
|
dispatchName: "Dispatch release checks independent phase",
|
|
displayName: "OpenClaw Release Checks",
|
|
key: "releaseChecksIndependent",
|
|
parentJobName: "Run release checks independent validation",
|
|
rerunGroups: ["all", "install-smoke", "live-e2e", "qa-parity", "qa-live"],
|
|
suffix: "-release-checks-independent",
|
|
workflow: "openclaw-release-checks.yml",
|
|
},
|
|
{
|
|
dispatchName: "Dispatch release checks candidate phase",
|
|
displayName: "OpenClaw Release Checks",
|
|
key: "releaseChecksCandidate",
|
|
parentJobName: "Run release checks candidate validation",
|
|
rerunGroups: ["all", "cross-os", "live-e2e", "package"],
|
|
suffix: "-release-checks-candidate",
|
|
workflow: "openclaw-release-checks.yml",
|
|
},
|
|
{
|
|
dispatchName: "Dispatch npm Telegram E2E",
|
|
displayName: "NPM Telegram Beta E2E",
|
|
key: "npmTelegram",
|
|
parentJobName: "Run package Telegram E2E",
|
|
rerunGroups: ["npm-telegram"],
|
|
suffix: "-npm-telegram",
|
|
workflow: "npm-telegram-beta-e2e.yml",
|
|
},
|
|
{
|
|
dispatchName: "Dispatch OpenClaw Performance",
|
|
displayName: "OpenClaw Performance",
|
|
key: "productPerformance",
|
|
parentJobName: "Run product performance evidence",
|
|
rerunGroups: ["all", "performance"],
|
|
suffix: "",
|
|
workflow: "openclaw-performance.yml",
|
|
},
|
|
]);
|
|
const UNPHASED_CHILD_SPECS = Object.freeze([
|
|
CHILD_SPECS.find((spec) => spec.key === "normalCi"),
|
|
...LEGACY_CHILD_SPECS,
|
|
CHILD_SPECS.find((spec) => spec.key === "npmTelegram"),
|
|
CHILD_SPECS.find((spec) => spec.key === "productPerformance"),
|
|
]);
|
|
const HISTORICAL_EXECUTION_PLAN_KEYS = Object.freeze(
|
|
[
|
|
"blockers",
|
|
"children",
|
|
"errors",
|
|
"evidenceReuse",
|
|
"gates",
|
|
"kind",
|
|
"parentRunAttempt",
|
|
"parentRunId",
|
|
"releaseProfile",
|
|
"rerunGroup",
|
|
"sha256",
|
|
"targetSha",
|
|
"trustedWorkflow",
|
|
"version",
|
|
"workflowRef",
|
|
"workflowSha",
|
|
].toSorted(),
|
|
);
|
|
const ATTEMPT_AWARE_V2_EXECUTION_PLAN_KEYS = Object.freeze(
|
|
[
|
|
...HISTORICAL_EXECUTION_PLAN_KEYS,
|
|
"attemptEvidenceVersion",
|
|
"candidate",
|
|
"candidateRequest",
|
|
"repository",
|
|
].toSorted((left, right) => left.localeCompare(right)),
|
|
);
|
|
const HISTORICAL_EXECUTION_PLAN_CHILD_KEYS = Object.freeze(
|
|
[
|
|
"dispatchName",
|
|
"displayTitle",
|
|
"key",
|
|
"required",
|
|
"result",
|
|
"runAttempt",
|
|
"runId",
|
|
"selected",
|
|
"source",
|
|
"url",
|
|
"workflow",
|
|
"workflowRef",
|
|
"workflowSha",
|
|
].toSorted(),
|
|
);
|
|
const ATTEMPT_AWARE_EXECUTION_PLAN_CHILD_KEYS = Object.freeze(
|
|
[...HISTORICAL_EXECUTION_PLAN_CHILD_KEYS, "sourceParentAttempt"].toSorted((left, right) =>
|
|
left.localeCompare(right),
|
|
),
|
|
);
|
|
|
|
function releaseGhTransportErrorText(error) {
|
|
const values = [error];
|
|
const seen = new Set();
|
|
const parts = [];
|
|
while (values.length > 0) {
|
|
const value = values.shift();
|
|
if (value && typeof value === "object") {
|
|
if (seen.has(value)) {
|
|
continue;
|
|
}
|
|
seen.add(value);
|
|
if (value instanceof Error) {
|
|
parts.push(value.name, value.message);
|
|
}
|
|
for (const key of ["stderr", "stdout", "code", "signal", "cause"]) {
|
|
if (key in value && value[key] !== undefined) {
|
|
values.push(value[key]);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
if (value !== undefined && value !== null) {
|
|
parts.push(String(value));
|
|
}
|
|
}
|
|
return parts.join("\n");
|
|
}
|
|
|
|
export function classifyReleaseGhTransportError(error) {
|
|
const text = releaseGhTransportErrorText(error);
|
|
if (RATE_LIMITED_403_PATTERN.test(text)) {
|
|
return "transient";
|
|
}
|
|
if (HARD_GH_TRANSPORT_PATTERN.test(text)) {
|
|
return "hard";
|
|
}
|
|
return TRANSIENT_GH_TRANSPORT_PATTERN.test(text) ? "transient" : "ambiguous";
|
|
}
|
|
|
|
export function isReleaseGhArtifactMissingError(error) {
|
|
if (classifyReleaseGhTransportError(error) !== "ambiguous") {
|
|
return false;
|
|
}
|
|
const text = releaseGhTransportErrorText(error);
|
|
if (RELEASE_GH_ARTIFACT_CONTENT_ERROR_PATTERN.test(text)) {
|
|
return false;
|
|
}
|
|
return text
|
|
.split(/\r?\n/u)
|
|
.some((line) => RELEASE_GH_ARTIFACT_MISSING_LINE_PATTERN.test(line.trim()));
|
|
}
|
|
|
|
function stringValue(value, fallback = "") {
|
|
return typeof value === "string" ? value : fallback;
|
|
}
|
|
|
|
function boundedString(value, maxLength) {
|
|
return stringValue(value)
|
|
.replaceAll(/[\r\n\t]+/gu, " ")
|
|
.trim()
|
|
.slice(0, maxLength);
|
|
}
|
|
|
|
function positiveInteger(value) {
|
|
const normalized = Number(value);
|
|
return Number.isSafeInteger(normalized) && normalized > 0 ? normalized : undefined;
|
|
}
|
|
|
|
function canonicalValue(value) {
|
|
if (Array.isArray(value)) {
|
|
return value.map(canonicalValue);
|
|
}
|
|
if (value && typeof value === "object") {
|
|
return Object.fromEntries(
|
|
Object.entries(value)
|
|
.filter(([, entry]) => entry !== undefined)
|
|
.toSorted(([left], [right]) => left.localeCompare(right))
|
|
.map(([key, entry]) => [key, canonicalValue(entry)]),
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function booleanValue(value) {
|
|
return value === true || value === "true";
|
|
}
|
|
|
|
// Omission retains the historical full inventory. Reduced coverage is an
|
|
// explicit release decision, bound to the exact version and sealed plan.
|
|
export function normalizeReleaseCoveragePolicy({
|
|
coveragePolicy,
|
|
releaseProfile,
|
|
rerunGroup,
|
|
runReleaseSoak,
|
|
targetVersion,
|
|
candidateVersion,
|
|
crossOsSuiteFilter = "",
|
|
}) {
|
|
// Full qualification requires install and upgrade proof on every supported OS.
|
|
if (rerunGroup === "all" && !hasRequiredCrossOsSuites(crossOsSuiteFilter)) {
|
|
throw new Error(
|
|
"release coverage policy requires all Linux, Windows, and macOS cross-OS suites",
|
|
);
|
|
}
|
|
if (coveragePolicy === undefined) {
|
|
return undefined;
|
|
}
|
|
const target = parseReleaseVersion(stringValue(targetVersion));
|
|
const beta =
|
|
coveragePolicy === "npm-beta-v1" &&
|
|
releaseProfile === "beta" &&
|
|
(runReleaseSoak === false || runReleaseSoak === "false") &&
|
|
target?.channel === "beta";
|
|
const stable =
|
|
coveragePolicy === "npm-stable-v1" &&
|
|
releaseProfile === "stable" &&
|
|
(runReleaseSoak === true || runReleaseSoak === "true") &&
|
|
target !== null &&
|
|
classifyReleaseTrain(target) === "stable";
|
|
if (
|
|
(!beta && !stable) ||
|
|
rerunGroup !== "all" ||
|
|
target?.version !== targetVersion ||
|
|
(candidateVersion !== undefined && candidateVersion !== targetVersion)
|
|
) {
|
|
throw new Error(
|
|
"release coverage policy requires an exact beta without soak or regular stable with soak, the matching profile, and all group",
|
|
);
|
|
}
|
|
return coveragePolicy;
|
|
}
|
|
|
|
export function validateReleaseCoveragePolicyBinding(plan, validationInputs = {}) {
|
|
const coveragePolicy = normalizeReleaseCoveragePolicy({
|
|
...validationInputs,
|
|
releaseProfile: plan?.releaseProfile,
|
|
rerunGroup: plan?.rerunGroup,
|
|
runReleaseSoak: plan?.candidateRequest?.releaseSoak,
|
|
});
|
|
if (
|
|
coveragePolicy !== plan?.coveragePolicy ||
|
|
(coveragePolicy && validationInputs.targetVersion !== plan?.targetVersion)
|
|
) {
|
|
throw new Error("release coverage policy differs from the immutable execution plan");
|
|
}
|
|
}
|
|
|
|
// Each omission requires a reviewed code change; waived or unrun never means passed.
|
|
export function normalizeReleaseTelegramWaiver({
|
|
telegramWaiver,
|
|
targetVersion,
|
|
candidateVersion,
|
|
releaseProfile,
|
|
rerunGroup,
|
|
liveSuiteFilter = "",
|
|
releasePackageSpec = "",
|
|
packageAcceptancePackageSpec = "",
|
|
npmTelegramPackageSpec = "",
|
|
}) {
|
|
if (telegramWaiver === undefined || telegramWaiver === "") {
|
|
return "";
|
|
}
|
|
if (
|
|
!REVIEWED_TELEGRAM_WAIVERS.has(telegramWaiver) ||
|
|
telegramWaiver !== `${targetVersion}-owner-approved` ||
|
|
parseReleaseVersion(stringValue(targetVersion))?.baseVersion !== stringValue(targetVersion) ||
|
|
!["stable", "full"].includes(releaseProfile)
|
|
) {
|
|
throw new Error("Telegram waiver requires an exact stable/full owner declaration");
|
|
}
|
|
if (candidateVersion !== undefined && candidateVersion !== targetVersion) {
|
|
throw new Error("Telegram waiver target version differs from the release candidate");
|
|
}
|
|
if (
|
|
rerunGroup === "npm-telegram" ||
|
|
liveSuiteFilter
|
|
.toLowerCase()
|
|
.split(",")
|
|
.some((lane) =>
|
|
[
|
|
"qa-live",
|
|
"qa-live-all",
|
|
"qa-all",
|
|
"qa-live-non-slack",
|
|
"qa-non-slack",
|
|
"non-slack",
|
|
"no-slack",
|
|
"without-slack",
|
|
...REVIEWED_TELEGRAM_WAIVERS.get(telegramWaiver).flatMap((channel) => [
|
|
`qa-live-${channel}`,
|
|
`qa-${channel}`,
|
|
channel,
|
|
]),
|
|
].includes(lane.trim()),
|
|
)
|
|
) {
|
|
throw new Error(
|
|
"Telegram waiver conflicts with explicitly requested waived-channel validation",
|
|
);
|
|
}
|
|
// Blank specs select the sealed SHA candidate. Registry overrides must name
|
|
// the waived release exactly; a moving dist-tag does not establish version.
|
|
if (
|
|
[releasePackageSpec, packageAcceptancePackageSpec, npmTelegramPackageSpec].some(
|
|
(spec) => spec !== "" && spec !== `openclaw@${targetVersion}`,
|
|
)
|
|
) {
|
|
throw new Error(`Telegram waiver package overrides must be openclaw@${targetVersion}`);
|
|
}
|
|
return telegramWaiver;
|
|
}
|
|
|
|
export function releaseWaivedIntegrationChannels(input) {
|
|
return [...(REVIEWED_TELEGRAM_WAIVERS.get(normalizeReleaseTelegramWaiver(input)) ?? [])];
|
|
}
|
|
|
|
export function validateReleaseTelegramWaiverBinding(plan, validationInputs = {}) {
|
|
const telegramWaiver = normalizeReleaseTelegramWaiver({
|
|
...validationInputs,
|
|
releaseProfile: plan?.releaseProfile,
|
|
rerunGroup: plan?.rerunGroup,
|
|
});
|
|
if (
|
|
telegramWaiver !== (plan?.telegramWaiver ?? "") ||
|
|
(telegramWaiver && validationInputs.targetVersion !== plan?.targetVersion)
|
|
) {
|
|
throw new Error("Telegram waiver differs from the immutable execution plan");
|
|
}
|
|
}
|
|
|
|
export function releaseChildSpecs() {
|
|
return [...CHILD_SPECS, ...LEGACY_CHILD_SPECS];
|
|
}
|
|
|
|
export function releaseChildSpec(key) {
|
|
const spec = releaseChildSpecs().find((entry) => entry.key === key);
|
|
if (!spec) {
|
|
throw new Error(`release child key is invalid: `);
|
|
}
|
|
return spec;
|
|
}
|
|
|
|
function normalizedAttemptJob(job, runAttempt) {
|
|
const name = boundedString(job?.name, MAX_LABEL_LENGTH);
|
|
if (!name) {
|
|
throw new Error(`release child attempt ${runAttempt} contains an unnamed job`);
|
|
}
|
|
return {
|
|
acceptedRunAttempt: runAttempt,
|
|
completedAt: stringValue(job?.completed_at ?? job?.completedAt),
|
|
conclusion: boundedString(job?.conclusion, MAX_LABEL_LENGTH),
|
|
name,
|
|
startedAt: stringValue(job?.started_at ?? job?.startedAt),
|
|
status: boundedString(job?.status, MAX_LABEL_LENGTH),
|
|
url: boundedString(job?.html_url ?? job?.url, MAX_URL_LENGTH),
|
|
};
|
|
}
|
|
|
|
export function compareReleaseJobsByName(left, right) {
|
|
// Composite evidence is hashed and revalidated across runners, so its order
|
|
// must not depend on the host's locale collation.
|
|
return left.name === right.name ? 0 : left.name < right.name ? -1 : 1;
|
|
}
|
|
|
|
export function validateReleaseChildRunProvenance(run, expected = {}) {
|
|
const plannedRunAttempt = positiveInteger(expected.plannedRunAttempt);
|
|
const effectiveRunAttempt = positiveInteger(run?.run_attempt);
|
|
const path = stringValue(run?.path).split("@", 1)[0];
|
|
const dispatchActor = boundedString(run?.actor?.login, MAX_LABEL_LENGTH);
|
|
const triggeringActor = boundedString(run?.triggering_actor?.login, MAX_LABEL_LENGTH);
|
|
if (
|
|
plannedRunAttempt === undefined ||
|
|
effectiveRunAttempt === undefined ||
|
|
effectiveRunAttempt < plannedRunAttempt ||
|
|
String(run?.id ?? "") !== String(expected.runId ?? "") ||
|
|
run?.event !== "workflow_dispatch" ||
|
|
path !== `.github/workflows/${expected.workflow}` ||
|
|
run?.display_title !== expected.displayTitle ||
|
|
run?.head_branch !== expected.workflowRef ||
|
|
run?.head_sha !== expected.workflowSha ||
|
|
(expected.repository !== undefined &&
|
|
run?.repository?.full_name !== String(expected.repository)) ||
|
|
dispatchActor !== "github-actions[bot]" ||
|
|
!triggeringActor ||
|
|
(effectiveRunAttempt === plannedRunAttempt && triggeringActor !== "github-actions[bot]")
|
|
) {
|
|
throw new Error(`release child provenance changed: ${expected.key ?? expected.runId}`);
|
|
}
|
|
return {
|
|
dispatchActor,
|
|
effectiveRunAttempt,
|
|
repository: stringValue(run?.repository?.full_name, stringValue(expected.repository)),
|
|
triggeringActor,
|
|
};
|
|
}
|
|
|
|
function jsonSha256(value) {
|
|
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
}
|
|
|
|
export function releaseCompositeJobsSha256(value) {
|
|
const { effectiveRunAttempt, jobs, plannedRunAttempt } = value;
|
|
return jsonSha256(canonicalValue({ effectiveRunAttempt, jobs, plannedRunAttempt }));
|
|
}
|
|
|
|
export function composeReleaseAttemptJobs(attempts, expected = {}) {
|
|
const plannedRunAttempt = positiveInteger(expected.plannedRunAttempt);
|
|
const effectiveRunAttempt = positiveInteger(expected.effectiveRunAttempt);
|
|
if (
|
|
plannedRunAttempt === undefined ||
|
|
effectiveRunAttempt === undefined ||
|
|
effectiveRunAttempt < plannedRunAttempt
|
|
) {
|
|
throw new Error("release child attempt range is invalid");
|
|
}
|
|
if (!Array.isArray(attempts)) {
|
|
throw new Error("release child attempt evidence is invalid");
|
|
}
|
|
const normalizedAttempts = attempts.map((attempt) => ({
|
|
jobs: Array.isArray(attempt?.jobs) ? attempt.jobs : [],
|
|
runAttempt: positiveInteger(attempt?.runAttempt),
|
|
}));
|
|
const expectedCount = effectiveRunAttempt - plannedRunAttempt + 1;
|
|
if (normalizedAttempts.length !== expectedCount) {
|
|
throw new Error("release child attempt evidence is gapped");
|
|
}
|
|
|
|
const accepted = new Map();
|
|
for (let index = 0; index < normalizedAttempts.length; index += 1) {
|
|
const attempt = normalizedAttempts[index];
|
|
const expectedAttempt = plannedRunAttempt + index;
|
|
if (attempt.runAttempt !== expectedAttempt || attempt.jobs.length === 0) {
|
|
throw new Error("release child attempt evidence is gapped");
|
|
}
|
|
const names = new Set();
|
|
const completedNames = new Set(
|
|
expectedAttempt < effectiveRunAttempt
|
|
? attempt.jobs.filter((job) => job?.status === "completed").map((job) => job.name)
|
|
: [],
|
|
);
|
|
for (const rawJob of attempt.jobs) {
|
|
const job = normalizedAttemptJob(rawJob, expectedAttempt);
|
|
// Skipped jobs and GitHub's runnerless, stepless queued rerun copies (beside a
|
|
// completed sibling in a superseded attempt) never executed, so they carry no
|
|
// evidence. Drop them before identity checks because such copies may collide.
|
|
const ghost = job.status === "queued" && !rawJob.runner_name && rawJob.steps?.length === 0;
|
|
const skipped = job.status === "completed" && job.conclusion === "skipped";
|
|
if (skipped || (ghost && completedNames.has(job.name))) {
|
|
continue;
|
|
}
|
|
if (names.has(job.name)) {
|
|
throw Object.assign(
|
|
new Error(
|
|
`release child attempt ${expectedAttempt} contains duplicate job identity: ${job.name}`,
|
|
),
|
|
{ code: "FRV_DUPLICATE_JOB_IDENTITY", runAttempt: expectedAttempt },
|
|
);
|
|
}
|
|
names.add(job.name);
|
|
accepted.set(job.name, job);
|
|
}
|
|
}
|
|
|
|
const composite = {
|
|
effectiveRunAttempt,
|
|
jobs: [...accepted.values()].toSorted(compareReleaseJobsByName),
|
|
plannedRunAttempt,
|
|
};
|
|
return { ...composite, sha256: releaseCompositeJobsSha256(composite) };
|
|
}
|
|
|
|
export function composeReleaseChildAttemptEvidence({ attempts, expected, run }) {
|
|
const provenance = validateReleaseChildRunProvenance(run, expected);
|
|
const composite = composeReleaseAttemptJobs(attempts, {
|
|
effectiveRunAttempt: provenance.effectiveRunAttempt,
|
|
plannedRunAttempt: expected.plannedRunAttempt,
|
|
});
|
|
return {
|
|
compositeJobsSha256: composite.sha256,
|
|
dispatchActor: provenance.dispatchActor,
|
|
effectiveRunAttempt: composite.effectiveRunAttempt,
|
|
jobs: composite.jobs,
|
|
observedRunAttempts: attempts.map((attempt) => attempt.runAttempt),
|
|
plannedRunAttempt: composite.plannedRunAttempt,
|
|
repository: provenance.repository,
|
|
runId: String(run.id),
|
|
triggeringActor: provenance.triggeringActor,
|
|
};
|
|
}
|
|
|
|
export function validateReleaseChildDispatchBinding({
|
|
child,
|
|
coveragePolicy,
|
|
log,
|
|
plannedRunAttempt,
|
|
repository,
|
|
targetSha,
|
|
}) {
|
|
const escapedRepo = String(repository).replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
const exactPattern = new RegExp(
|
|
`https://github\\.com/${escapedRepo}/actions/runs/([1-9][0-9]*) \\(attempt ([1-9][0-9]*)\\)`,
|
|
"gu",
|
|
);
|
|
const urlPattern = new RegExp(
|
|
`https://github\\.com/${escapedRepo}/actions/runs/([1-9][0-9]*)`,
|
|
"gu",
|
|
);
|
|
const exact = Array.from(String(log).matchAll(exactPattern), (match) => ({
|
|
runAttempt: Number(match[2]),
|
|
runId: match[1],
|
|
}));
|
|
const runIds = [...new Set(Array.from(String(log).matchAll(urlPattern), (match) => match[1]))];
|
|
const exactBound =
|
|
exact.length === 1 &&
|
|
exact[0].runId === String(child.runId) &&
|
|
exact[0].runAttempt === Number(plannedRunAttempt);
|
|
const historicalUrlBound =
|
|
exact.length === 0 && runIds.length === 1 && runIds[0] === String(child.runId);
|
|
if (!exactBound && !historicalUrlBound) {
|
|
throw new Error(`release child is not uniquely emitted by its parent job: ${child.key}`);
|
|
}
|
|
if (child.key !== "npmTelegram" && !String(log).includes(`TARGET_SHA: ${targetSha}`)) {
|
|
throw new Error(`release child parent target SHA changed: ${child.key}`);
|
|
}
|
|
if (child.key === "productPerformance" && !String(log).includes("-f publish_reports=false")) {
|
|
throw new Error("release performance child is not dispatched in artifact-only mode");
|
|
}
|
|
if (child.key === "normalCi") {
|
|
const scopes = [...String(log).matchAll(/\bCI_RELEASE_SCOPE: ([^\s]+)/gu)].map(
|
|
(match) => match[1],
|
|
);
|
|
const expectedScope =
|
|
coveragePolicy === "npm-beta-v1"
|
|
? "npm-beta"
|
|
: coveragePolicy === "npm-stable-v1"
|
|
? "npm-stable"
|
|
: "full";
|
|
if (
|
|
scopes.some((scope) => scope !== expectedScope) ||
|
|
(coveragePolicy !== undefined && scopes.length === 0)
|
|
) {
|
|
throw new Error("release normal CI dispatch scope differs from its coverage policy");
|
|
}
|
|
}
|
|
}
|
|
|
|
function candidatePreparationRequired(input) {
|
|
if (
|
|
booleanValue(input.evidenceReuse) ||
|
|
stringValue(input.releasePackageSpec).trim() ||
|
|
stringValue(input.packageAcceptancePackageSpec).trim()
|
|
) {
|
|
return false;
|
|
}
|
|
if (["all", "plugin-prerelease", "cross-os", "package"].includes(input.rerunGroup)) {
|
|
return true;
|
|
}
|
|
return input.rerunGroup === "live-e2e" && !stringValue(input.liveSuiteFilter).trim();
|
|
}
|
|
|
|
function releaseExecutionChildRequired(spec, input, npmTelegramForAll) {
|
|
if (
|
|
input.coveragePolicy === "npm-beta-v1" &&
|
|
["productPerformance", "npmTelegram"].includes(spec.key)
|
|
) {
|
|
return false;
|
|
}
|
|
switch (spec.key) {
|
|
case "npmTelegram":
|
|
return input.rerunGroup === "npm-telegram" || npmTelegramForAll;
|
|
case "pluginPrereleaseCandidate":
|
|
return spec.rerunGroups.includes(input.rerunGroup);
|
|
case "releaseChecksCandidate":
|
|
return (
|
|
["all", "cross-os", "package"].includes(input.rerunGroup) ||
|
|
(input.rerunGroup === "live-e2e" && !stringValue(input.liveSuiteFilter).trim())
|
|
);
|
|
default:
|
|
return spec.rerunGroups.includes(input.rerunGroup);
|
|
}
|
|
}
|
|
|
|
function canonicalSkippedPlanChild(spec, { evidenceReuse, expected }) {
|
|
return {
|
|
dispatchName: spec.dispatchName,
|
|
displayTitle: `${spec.displayName} full-release-validation-${expected.parentRunId}-${expected.parentRunAttempt}${spec.suffix}`,
|
|
key: spec.key,
|
|
required: false,
|
|
result: "skipped",
|
|
runAttempt: null,
|
|
runId: "",
|
|
selected: false,
|
|
source: evidenceReuse.requested ? "reused" : "fresh",
|
|
sourceParentAttempt: null,
|
|
url: "",
|
|
workflow: spec.workflow,
|
|
workflowRef: stringValue(expected.workflowRef).trim(),
|
|
workflowSha: stringValue(expected.workflowSha).trim(),
|
|
};
|
|
}
|
|
|
|
function executionPlanChildRequired(spec, rerunGroup) {
|
|
if (spec.key !== "npmTelegram") {
|
|
return spec.rerunGroups.includes(rerunGroup);
|
|
}
|
|
return rerunGroup === "all" || rerunGroup === "npm-telegram";
|
|
}
|
|
|
|
export function buildReleaseExecutionPlan(input) {
|
|
const telegramWaiver = normalizeReleaseTelegramWaiver(input);
|
|
normalizeReleaseCoveragePolicy({
|
|
...input,
|
|
runReleaseSoak: input.runReleaseSoak ?? input.candidateRequestInput?.releaseSoak,
|
|
});
|
|
const parentRunId = stringValue(input.parentRunId).trim();
|
|
const parentRunAttempt = positiveInteger(input.parentRunAttempt);
|
|
const rerunGroup = stringValue(input.rerunGroup).trim();
|
|
if (!parentRunId || parentRunAttempt === undefined || !rerunGroup) {
|
|
throw new Error("release execution plan identity is invalid");
|
|
}
|
|
const reused = booleanValue(input.evidenceReuse);
|
|
const childInputs =
|
|
input.children && typeof input.children === "object" && !Array.isArray(input.children)
|
|
? input.children
|
|
: {};
|
|
const npmTelegramForAll =
|
|
!telegramWaiver &&
|
|
rerunGroup === "all" &&
|
|
Boolean(
|
|
stringValue(input.npmTelegramPackageSpec).trim() ||
|
|
stringValue(input.releasePackageSpec).trim(),
|
|
);
|
|
const phasedChildren = Number(input.childPhaseVersion) === 3;
|
|
const childSpecs = phasedChildren ? CHILD_SPECS : UNPHASED_CHILD_SPECS;
|
|
const children = childSpecs.map((spec) => {
|
|
const raw = childInputs[spec.key] ?? {};
|
|
const required = releaseExecutionChildRequired(spec, input, npmTelegramForAll);
|
|
const dispatchId = `full-release-validation-${parentRunId}-${parentRunAttempt}${spec.suffix}`;
|
|
return {
|
|
dispatchName: spec.dispatchName,
|
|
displayTitle: `${spec.displayName} ${dispatchId}`,
|
|
key: spec.key,
|
|
required,
|
|
result: stringValue(raw.result, "skipped"),
|
|
runAttempt: positiveInteger(raw.runAttempt) ?? null,
|
|
runId: stringValue(raw.runId).trim(),
|
|
selected: required,
|
|
source: reused ? "reused" : "fresh",
|
|
sourceParentAttempt: null,
|
|
url: stringValue(raw.url).trim(),
|
|
workflow: spec.workflow,
|
|
workflowRef: stringValue(input.workflowRef).trim(),
|
|
workflowSha: stringValue(input.workflowSha).trim(),
|
|
};
|
|
});
|
|
const gates = [
|
|
{
|
|
name: "Resolve target ref",
|
|
required: true,
|
|
result: stringValue(input.resolveTargetResult, "missing"),
|
|
},
|
|
{
|
|
name: phasedChildren ? "Acquire full release candidate" : "Prepare shared release candidate",
|
|
required: phasedChildren
|
|
? !reused && booleanValue(input.candidateRequired)
|
|
: candidatePreparationRequired(input),
|
|
result: stringValue(
|
|
phasedChildren ? input.candidateAcquisitionResult : input.candidateBindingResult,
|
|
"skipped",
|
|
),
|
|
},
|
|
];
|
|
return { children, gates };
|
|
}
|
|
|
|
function normalizedGate(gate) {
|
|
return {
|
|
name: boundedString(gate?.name, MAX_LABEL_LENGTH),
|
|
required: gate?.required === true,
|
|
result: boundedString(gate?.result, MAX_LABEL_LENGTH),
|
|
};
|
|
}
|
|
|
|
function normalizedEvidenceReuse(evidenceReuse) {
|
|
if (!evidenceReuse || evidenceReuse.requested !== true) {
|
|
return { requested: false };
|
|
}
|
|
return {
|
|
changedPaths: Array.isArray(evidenceReuse.changedPaths)
|
|
? evidenceReuse.changedPaths
|
|
.map((value) => boundedString(value, MAX_LABEL_LENGTH))
|
|
.filter(Boolean)
|
|
: [],
|
|
evidenceSha: boundedString(evidenceReuse.evidenceSha, MAX_LABEL_LENGTH),
|
|
policy: boundedString(evidenceReuse.policy, MAX_LABEL_LENGTH),
|
|
requested: true,
|
|
rootRunId: boundedString(evidenceReuse.rootRunId, MAX_LABEL_LENGTH),
|
|
runUrl: boundedString(evidenceReuse.runUrl, MAX_URL_LENGTH),
|
|
selectedRunId: boundedString(evidenceReuse.selectedRunId, MAX_LABEL_LENGTH),
|
|
sourceManifest:
|
|
evidenceReuse.sourceManifest &&
|
|
typeof evidenceReuse.sourceManifest === "object" &&
|
|
!Array.isArray(evidenceReuse.sourceManifest)
|
|
? structuredClone(evidenceReuse.sourceManifest)
|
|
: null,
|
|
};
|
|
}
|
|
|
|
function validEvidenceReuseIdentity(evidenceReuse) {
|
|
if (!evidenceReuse.requested) {
|
|
return true;
|
|
}
|
|
const validChangedPaths =
|
|
(evidenceReuse.policy === EXACT_TARGET_EVIDENCE_REUSE_POLICY &&
|
|
evidenceReuse.changedPaths.length === 0) ||
|
|
(evidenceReuse.policy === CHANGELOG_ONLY_EVIDENCE_REUSE_POLICY &&
|
|
evidenceReuse.changedPaths.length === 1 &&
|
|
evidenceReuse.changedPaths[0] === "CHANGELOG.md") ||
|
|
(evidenceReuse.policy === SPLIT_CHANGELOG_EVIDENCE_REUSE_POLICY &&
|
|
isSplitChangelogEvidenceDelta(
|
|
evidenceReuse.changedPaths,
|
|
evidenceReuse.sourceManifest?.candidateBinding?.package?.version ??
|
|
evidenceReuse.sourceManifest?.validationInputs?.targetVersion,
|
|
));
|
|
return (
|
|
/^[a-f0-9]{40}$/u.test(evidenceReuse.evidenceSha) &&
|
|
/^[1-9][0-9]*$/u.test(evidenceReuse.rootRunId) &&
|
|
/^[1-9][0-9]*$/u.test(evidenceReuse.selectedRunId) &&
|
|
evidenceReuse.sourceManifest !== null &&
|
|
validChangedPaths
|
|
);
|
|
}
|
|
|
|
function normalizedTrustedWorkflow(identity) {
|
|
const ref = boundedString(identity?.ref, MAX_LABEL_LENGTH);
|
|
const fullRef = boundedString(identity?.fullRef, MAX_LABEL_LENGTH);
|
|
const sha = boundedString(identity?.sha, MAX_LABEL_LENGTH);
|
|
if (
|
|
!ref ||
|
|
!/^[a-f0-9]{40}$/u.test(sha) ||
|
|
(fullRef !== `refs/heads/${ref}` && fullRef !== `refs/tags/${ref}`)
|
|
) {
|
|
throw new Error("release execution plan trusted workflow identity is invalid");
|
|
}
|
|
return { fullRef, ref, sha };
|
|
}
|
|
|
|
function hasExactKeys(value, expectedKeys) {
|
|
return (
|
|
value &&
|
|
typeof value === "object" &&
|
|
!Array.isArray(value) &&
|
|
JSON.stringify(Object.keys(value).toSorted()) === JSON.stringify(expectedKeys)
|
|
);
|
|
}
|
|
|
|
// Published validation artifacts contain empty retired retry metadata. Preserve
|
|
// those bytes for digest verification without accepting a retry allowance.
|
|
export function validateRetiredReleaseRetryFields(value) {
|
|
for (const key of ["knownFlakyJobs", "automaticRetries"]) {
|
|
if (Object.hasOwn(value, key) && (!Array.isArray(value[key]) || value[key].length !== 0)) {
|
|
throw new Error(`release artifact ${key} must be empty; automatic retries are disabled`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function releaseExecutionPlanShape(payload) {
|
|
validateRetiredReleaseRetryFields(payload);
|
|
const hasAttemptEvidence = Object.hasOwn(payload, "attemptEvidenceVersion");
|
|
const attemptEvidenceVersion = hasAttemptEvidence ? payload.attemptEvidenceVersion : undefined;
|
|
const basePlanKeys = hasAttemptEvidence
|
|
? ATTEMPT_AWARE_V2_EXECUTION_PLAN_KEYS
|
|
: HISTORICAL_EXECUTION_PLAN_KEYS;
|
|
const expectedPlanKeys = [
|
|
...new Set([
|
|
...basePlanKeys,
|
|
...(Object.hasOwn(payload, "knownFlakyJobs") ? ["knownFlakyJobs"] : []),
|
|
...(Object.hasOwn(payload, "childReuse") ? ["childReuse"] : []),
|
|
...(Object.hasOwn(payload, "telegramWaiver") ? ["targetVersion", "telegramWaiver"] : []),
|
|
...(Object.hasOwn(payload, "coveragePolicy") ? ["targetVersion", "coveragePolicy"] : []),
|
|
...(Object.hasOwn(payload, "sourceAdmissionContract")
|
|
? ["sourceAdmissionContract", "sourceAdmission"]
|
|
: []),
|
|
...(Object.hasOwn(payload, "publicationAdmissionContract")
|
|
? ["publicationAdmissionContract", "publicationAdmission"]
|
|
: []),
|
|
]),
|
|
].toSorted((left, right) => (left === right ? 0 : left < right ? -1 : 1));
|
|
const expectedChildKeys = hasAttemptEvidence
|
|
? ATTEMPT_AWARE_EXECUTION_PLAN_CHILD_KEYS
|
|
: HISTORICAL_EXECUTION_PLAN_CHILD_KEYS;
|
|
if (
|
|
(hasAttemptEvidence && ![2, 3].includes(attemptEvidenceVersion)) ||
|
|
(Object.hasOwn(payload, "knownFlakyJobs") && !hasAttemptEvidence) ||
|
|
(Object.hasOwn(payload, "coveragePolicy") && attemptEvidenceVersion !== 3) ||
|
|
!hasExactKeys(payload, expectedPlanKeys) ||
|
|
!Array.isArray(payload.children) ||
|
|
payload.children.some((child) => !hasExactKeys(child, expectedChildKeys))
|
|
) {
|
|
throw new Error("release execution plan artifact schema is invalid");
|
|
}
|
|
return hasAttemptEvidence ? `attempt-aware-v${attemptEvidenceVersion}` : "historical";
|
|
}
|
|
|
|
function executionPlanDigestPayload(plan) {
|
|
const source = Object.hasOwn(plan, "sourceAdmissionContract")
|
|
? {
|
|
sourceAdmissionContract: plan.sourceAdmissionContract,
|
|
sourceAdmission: plan.sourceAdmission,
|
|
}
|
|
: {};
|
|
const publication = Object.hasOwn(plan, "publicationAdmissionContract")
|
|
? {
|
|
publicationAdmissionContract: plan.publicationAdmissionContract,
|
|
publicationAdmission: plan.publicationAdmission,
|
|
}
|
|
: {};
|
|
const waiver = Object.hasOwn(plan, "telegramWaiver")
|
|
? { targetVersion: plan.targetVersion, telegramWaiver: plan.telegramWaiver }
|
|
: {};
|
|
const coverage = Object.hasOwn(plan, "coveragePolicy")
|
|
? { targetVersion: plan.targetVersion, coveragePolicy: plan.coveragePolicy }
|
|
: {};
|
|
if (!Object.hasOwn(plan, "attemptEvidenceVersion")) {
|
|
return {
|
|
...source,
|
|
...publication,
|
|
...waiver,
|
|
blockers: plan.blockers,
|
|
children: plan.children,
|
|
errors: plan.errors,
|
|
evidenceReuse: plan.evidenceReuse,
|
|
gates: plan.gates,
|
|
kind: plan.kind,
|
|
parentRunAttempt: plan.parentRunAttempt,
|
|
parentRunId: plan.parentRunId,
|
|
releaseProfile: plan.releaseProfile,
|
|
rerunGroup: plan.rerunGroup,
|
|
targetSha: plan.targetSha,
|
|
trustedWorkflow: plan.trustedWorkflow,
|
|
version: plan.version,
|
|
workflowRef: plan.workflowRef,
|
|
workflowSha: plan.workflowSha,
|
|
};
|
|
}
|
|
return {
|
|
...source,
|
|
...publication,
|
|
...waiver,
|
|
...coverage,
|
|
...(plan.childReuse !== undefined ? { childReuse: plan.childReuse } : {}),
|
|
...(Object.hasOwn(plan, "knownFlakyJobs") ? { knownFlakyJobs: plan.knownFlakyJobs } : {}),
|
|
attemptEvidenceVersion: plan.attemptEvidenceVersion,
|
|
blockers: plan.blockers,
|
|
candidate: plan.candidate,
|
|
candidateRequest: plan.candidateRequest,
|
|
children: plan.children,
|
|
errors: plan.errors,
|
|
evidenceReuse: plan.evidenceReuse,
|
|
gates: plan.gates,
|
|
kind: plan.kind,
|
|
parentRunAttempt: plan.parentRunAttempt,
|
|
parentRunId: plan.parentRunId,
|
|
releaseProfile: plan.releaseProfile,
|
|
repository: plan.repository,
|
|
rerunGroup: plan.rerunGroup,
|
|
targetSha: plan.targetSha,
|
|
trustedWorkflow: plan.trustedWorkflow,
|
|
version: plan.version,
|
|
workflowRef: plan.workflowRef,
|
|
workflowSha: plan.workflowSha,
|
|
};
|
|
}
|
|
|
|
export function releaseExecutionPlanSha256(plan) {
|
|
return jsonSha256(executionPlanDigestPayload(plan));
|
|
}
|
|
|
|
export function buildReleaseExecutionPlanArtifact({
|
|
attemptEvidenceVersion,
|
|
blockers = [],
|
|
candidate = null,
|
|
coveragePolicy,
|
|
children,
|
|
childReuse,
|
|
errors = [],
|
|
evidenceReuse,
|
|
expected,
|
|
gates,
|
|
releaseProfile,
|
|
rerunGroup,
|
|
sourceAdmissionContract,
|
|
sourceAdmission,
|
|
publicationAdmissionContract,
|
|
publicationAdmission,
|
|
targetVersion,
|
|
telegramWaiver,
|
|
trustedWorkflow,
|
|
}) {
|
|
const waiver = normalizeReleaseTelegramWaiver({
|
|
telegramWaiver,
|
|
targetVersion,
|
|
releaseProfile,
|
|
rerunGroup,
|
|
});
|
|
const attemptAware = attemptEvidenceVersion !== undefined;
|
|
const normalizedAttemptEvidenceVersion = attemptAware
|
|
? Number(attemptEvidenceVersion)
|
|
: undefined;
|
|
if (attemptAware && ![2, 3].includes(normalizedAttemptEvidenceVersion)) {
|
|
throw new Error("release execution plan attempt evidence version is invalid");
|
|
}
|
|
const normalizedReuse = normalizedEvidenceReuse(evidenceReuse);
|
|
if (!validEvidenceReuseIdentity(normalizedReuse)) {
|
|
throw new Error("release execution plan evidence reuse binding is invalid");
|
|
}
|
|
const normalizedChildren = children.map((child) => {
|
|
const spec = releaseChildSpec(child.key);
|
|
return normalizedPlanChild(
|
|
{ ...child, dispatchName: spec.dispatchName },
|
|
{ sourceParentAttempt: attemptAware },
|
|
);
|
|
});
|
|
const artifactSpecs = normalizedAttemptEvidenceVersion === 3 ? CHILD_SPECS : UNPHASED_CHILD_SPECS;
|
|
for (const spec of artifactSpecs) {
|
|
if (
|
|
!normalizedChildren.some((child) => child.key === spec.key) &&
|
|
!executionPlanChildRequired(spec, rerunGroup)
|
|
) {
|
|
normalizedChildren.push(
|
|
normalizedPlanChild(
|
|
canonicalSkippedPlanChild(spec, {
|
|
evidenceReuse: normalizedReuse,
|
|
expected,
|
|
}),
|
|
{ sourceParentAttempt: attemptAware },
|
|
),
|
|
);
|
|
}
|
|
}
|
|
const basePlan = {
|
|
...(sourceAdmissionContract !== undefined ? { sourceAdmissionContract, sourceAdmission } : {}),
|
|
...(publicationAdmissionContract !== undefined
|
|
? { publicationAdmissionContract, publicationAdmission }
|
|
: {}),
|
|
...(waiver ? { telegramWaiver: waiver, targetVersion } : {}),
|
|
...(coveragePolicy !== undefined ? { coveragePolicy, targetVersion } : {}),
|
|
...(childReuse !== undefined ? { childReuse: structuredClone(childReuse) } : {}),
|
|
version: 1,
|
|
kind: "openclaw.full-release-execution-plan",
|
|
parentRunId: String(expected.parentRunId),
|
|
parentRunAttempt: positiveInteger(expected.parentRunAttempt),
|
|
workflowRef: boundedString(expected.workflowRef, MAX_LABEL_LENGTH),
|
|
workflowSha: boundedString(expected.workflowSha, MAX_LABEL_LENGTH),
|
|
targetSha: boundedString(expected.targetSha, MAX_LABEL_LENGTH),
|
|
trustedWorkflow: normalizedTrustedWorkflow(trustedWorkflow),
|
|
releaseProfile: boundedString(releaseProfile, MAX_LABEL_LENGTH),
|
|
rerunGroup: boundedString(rerunGroup, MAX_LABEL_LENGTH),
|
|
evidenceReuse: normalizedReuse,
|
|
gates: gates.map(normalizedGate),
|
|
children: normalizedChildren,
|
|
blockers: normalizeIssues(blockers, "release_blocker"),
|
|
errors: normalizeIssues(errors, "orchestration_error"),
|
|
};
|
|
validatePlanSourceAdmission(basePlan, expected);
|
|
validatePlanPublicationAdmission(basePlan, expected);
|
|
if (!attemptAware) {
|
|
if (coveragePolicy !== undefined || childReuse !== undefined) {
|
|
throw new Error(
|
|
"release coverage policy and child reuse require an attempt-aware execution plan",
|
|
);
|
|
}
|
|
const result = { ...basePlan, sha256: releaseExecutionPlanSha256(basePlan) };
|
|
serializeReleaseArtifact(result);
|
|
return result;
|
|
}
|
|
const repository = boundedString(expected.repository, MAX_LABEL_LENGTH);
|
|
const normalizedCandidateRequest = validateFullReleaseCandidateRequest(expected.candidateRequest);
|
|
const plan = {
|
|
...basePlan,
|
|
attemptEvidenceVersion: normalizedAttemptEvidenceVersion,
|
|
repository,
|
|
candidateRequest: normalizedCandidateRequest,
|
|
candidate: candidate === null ? null : validateFullReleaseCandidateBinding(candidate),
|
|
};
|
|
validateCandidatePlanBinding(plan, expected.candidateRequest);
|
|
validateChildReuseBindings(plan);
|
|
const result = { ...plan, sha256: releaseExecutionPlanSha256(plan) };
|
|
serializeReleaseArtifact(result);
|
|
return result;
|
|
}
|
|
|
|
function validatePlanPublicationAdmission(plan, expected) {
|
|
if (
|
|
plan.publicationAdmissionContract === FULL_RELEASE_PUBLICATION_ADMISSION_CONTRACT &&
|
|
plan.publicationAdmission === null &&
|
|
plan.gates.some(
|
|
(gate) => gate.name === "Resolve target ref" && gate.required && gate.result !== "success",
|
|
)
|
|
) {
|
|
if (
|
|
expected.publicationAdmissionContract !== undefined &&
|
|
expected.publicationAdmissionContract !== plan.publicationAdmissionContract
|
|
) {
|
|
throw new Error("publication admission contract mismatch");
|
|
}
|
|
return;
|
|
}
|
|
validatePublicationAdmissionBinding(plan, expected);
|
|
}
|
|
|
|
function validatePlanSourceAdmission(plan, expected) {
|
|
// Failed resolution still seals an honest failure plan, never successful source evidence.
|
|
if (
|
|
plan.sourceAdmissionContract === FULL_RELEASE_SOURCE_ADMISSION_CONTRACT &&
|
|
plan.sourceAdmission === null &&
|
|
plan.gates.some(
|
|
(gate) => gate.name === "Resolve target ref" && gate.required && gate.result !== "success",
|
|
)
|
|
) {
|
|
if (
|
|
expected.sourceAdmissionContract !== undefined &&
|
|
expected.sourceAdmissionContract !== plan.sourceAdmissionContract
|
|
) {
|
|
throw new Error("source admission contract mismatch");
|
|
}
|
|
return;
|
|
}
|
|
validatePublicationSourceBinding(plan, expected);
|
|
}
|
|
|
|
function validateCandidatePlanBinding(plan, expectedCandidateRequest) {
|
|
const request = validateRecordedFullReleaseCandidateRequest(plan.candidateRequest);
|
|
if (plan.coveragePolicy !== undefined && plan.attemptEvidenceVersion !== 3) {
|
|
throw new Error("release coverage policy requires a phase-three execution plan");
|
|
}
|
|
normalizeReleaseCoveragePolicy({
|
|
...plan,
|
|
runReleaseSoak: request.releaseSoak,
|
|
candidateVersion: plan.candidate?.package.version,
|
|
});
|
|
if (
|
|
request.repository !== plan.repository ||
|
|
request.targetSha !== plan.targetSha ||
|
|
request.toolingSha !== plan.workflowSha ||
|
|
request.toolingSha !== plan.trustedWorkflow.sha ||
|
|
request.releaseProfile !== plan.releaseProfile
|
|
) {
|
|
throw new Error("release candidate request differs from the execution plan identity");
|
|
}
|
|
if (
|
|
expectedCandidateRequest !== undefined &&
|
|
JSON.stringify(request) !==
|
|
JSON.stringify(validateRecordedFullReleaseCandidateRequest(expectedCandidateRequest))
|
|
) {
|
|
throw new Error("release candidate request differs from the expected plan inputs");
|
|
}
|
|
if (plan.candidate !== null) {
|
|
const candidate = validateFullReleaseCandidateBinding(plan.candidate);
|
|
if (JSON.stringify(candidate.request) !== JSON.stringify(request)) {
|
|
throw new Error("release candidate binding request differs from the execution plan");
|
|
}
|
|
normalizeReleaseTelegramWaiver({ ...plan, candidateVersion: candidate.package.version });
|
|
}
|
|
}
|
|
|
|
export function validateReleaseExecutionPlanArtifact(payload, expected = {}) {
|
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
throw new Error("release execution plan artifact is invalid");
|
|
}
|
|
const shape = releaseExecutionPlanShape(payload);
|
|
serializeReleaseArtifact(payload);
|
|
validatePlanSourceAdmission(payload, expected);
|
|
validatePlanPublicationAdmission(payload, expected);
|
|
const sha256 = releaseExecutionPlanSha256(payload);
|
|
if (payload.sha256 !== sha256) {
|
|
throw new Error("release execution plan artifact digest is invalid");
|
|
}
|
|
if (
|
|
Object.hasOwn(expected, "coveragePolicy") &&
|
|
payload.coveragePolicy !== expected.coveragePolicy
|
|
) {
|
|
throw new Error("release coverage policy differs from the expected execution plan");
|
|
}
|
|
const telegramWaiver = normalizeReleaseTelegramWaiver(payload);
|
|
if (
|
|
(Object.hasOwn(payload, "telegramWaiver") && !telegramWaiver) ||
|
|
(expected.telegramWaiver !== undefined && telegramWaiver !== expected.telegramWaiver) ||
|
|
(telegramWaiver &&
|
|
expected.targetVersion !== undefined &&
|
|
payload.targetVersion !== expected.targetVersion)
|
|
) {
|
|
throw new Error("Telegram waiver differs from the expected execution plan");
|
|
}
|
|
if (
|
|
payload.version !== 1 ||
|
|
payload.kind !== "openclaw.full-release-execution-plan" ||
|
|
!/^[1-9][0-9]*$/u.test(String(payload.parentRunId ?? "")) ||
|
|
positiveInteger(payload.parentRunAttempt) === undefined ||
|
|
!/^[a-f0-9]{40}$/u.test(String(payload.workflowSha ?? "")) ||
|
|
(payload.targetSha !== "" && !/^[a-f0-9]{40}$/u.test(String(payload.targetSha ?? ""))) ||
|
|
(expected.parentRunId !== undefined &&
|
|
String(payload.parentRunId) !== String(expected.parentRunId)) ||
|
|
(expected.sourceParentRunAttempt !== undefined &&
|
|
Number(payload.parentRunAttempt) !== Number(expected.sourceParentRunAttempt)) ||
|
|
(expected.maxParentRunAttempt !== undefined &&
|
|
Number(payload.parentRunAttempt) > Number(expected.maxParentRunAttempt)) ||
|
|
(expected.workflowRef !== undefined && payload.workflowRef !== expected.workflowRef) ||
|
|
(expected.workflowSha !== undefined && payload.workflowSha !== expected.workflowSha) ||
|
|
(expected.releaseProfile !== undefined && payload.releaseProfile !== expected.releaseProfile) ||
|
|
(expected.rerunGroup !== undefined && payload.rerunGroup !== expected.rerunGroup) ||
|
|
(expected.targetSha !== undefined && payload.targetSha !== expected.targetSha) ||
|
|
(shape !== "historical" &&
|
|
expected.repository !== undefined &&
|
|
payload.repository !== expected.repository)
|
|
) {
|
|
throw new Error("release execution plan artifact binding is invalid");
|
|
}
|
|
const evidenceReuse = normalizedEvidenceReuse(payload.evidenceReuse);
|
|
if (!validEvidenceReuseIdentity(evidenceReuse)) {
|
|
throw new Error("release execution plan evidence reuse binding is invalid");
|
|
}
|
|
const trustedWorkflow = normalizedTrustedWorkflow(payload.trustedWorkflow);
|
|
const children = validatePlan(payload.children, {
|
|
sourceParentAttempt: shape !== "historical",
|
|
});
|
|
validateExecutionPlanChildBindings(children, payload);
|
|
validateChildReuseBindings(payload);
|
|
const plan = {
|
|
...payload,
|
|
parentRunAttempt: positiveInteger(payload.parentRunAttempt),
|
|
parentRunId: String(payload.parentRunId),
|
|
children,
|
|
blockers: normalizeIssues(payload.blockers, "release_blocker"),
|
|
errors: normalizeIssues(payload.errors, "orchestration_error"),
|
|
evidenceReuse,
|
|
gates: Array.isArray(payload.gates) ? payload.gates.map(normalizedGate) : [],
|
|
trustedWorkflow,
|
|
...(shape !== "historical"
|
|
? {
|
|
attemptEvidenceVersion: payload.attemptEvidenceVersion,
|
|
repository: String(payload.repository),
|
|
candidateRequest: validateRecordedFullReleaseCandidateRequest(payload.candidateRequest),
|
|
candidate:
|
|
payload.candidate === null
|
|
? null
|
|
: validateFullReleaseCandidateBinding(payload.candidate),
|
|
}
|
|
: {}),
|
|
};
|
|
if (shape !== "historical") {
|
|
validateCandidatePlanBinding(plan, expected.candidateRequest);
|
|
}
|
|
return { ...plan, sha256 };
|
|
}
|
|
|
|
function normalizeIssue(issue, fallbackKind) {
|
|
return {
|
|
child: boundedString(issue?.child, MAX_LABEL_LENGTH),
|
|
conclusion: boundedString(issue?.conclusion, MAX_LABEL_LENGTH),
|
|
job: boundedString(issue?.job, MAX_LABEL_LENGTH),
|
|
kind: boundedString(issue?.kind, MAX_LABEL_LENGTH) || fallbackKind,
|
|
message: boundedString(issue?.message, MAX_MESSAGE_LENGTH),
|
|
runId: boundedString(issue?.runId, MAX_LABEL_LENGTH),
|
|
url: boundedString(issue?.url, MAX_URL_LENGTH),
|
|
};
|
|
}
|
|
|
|
function normalizeIssues(issues, fallbackKind) {
|
|
return (Array.isArray(issues) ? issues : [])
|
|
.slice(0, MAX_REPORTED_ISSUES)
|
|
.map((issue) => normalizeIssue(issue, fallbackKind));
|
|
}
|
|
|
|
function blockerEvidence(issue) {
|
|
const { message: _message, ...compact } = normalizeIssue(issue, "release_blocker");
|
|
return Object.fromEntries(Object.entries(compact).filter(([, value]) => value));
|
|
}
|
|
|
|
function blockerIndex(issues) {
|
|
return issues.map((issue) => jsonSha256(blockerEvidence(issue))).toSorted();
|
|
}
|
|
|
|
function isFailedJob(job) {
|
|
return (
|
|
job.status === "completed" && !SUCCESSFUL_JOB_CONCLUSIONS.has(String(job.conclusion ?? ""))
|
|
);
|
|
}
|
|
|
|
function recordedFlakeGatePass(child) {
|
|
const gates = child.jobs.filter((job) => job.name === "openclaw/ci-gate");
|
|
const advisoryJobs = child.jobs.filter((job) => isAdvisoryJob(child, job));
|
|
const entries = child.gateEntries;
|
|
if (
|
|
gates.length !== 1 ||
|
|
child.jobs.some((job) => job.status !== "completed") ||
|
|
gates[0].conclusion !== "failure" ||
|
|
!advisoryJobs.some((job) => recordedFlakeReceipt(child, job)) ||
|
|
child.jobs.some((job) => isFailedJob(job) && job !== gates[0] && !isAdvisoryJob(child, job)) ||
|
|
!Array.isArray(entries)
|
|
) {
|
|
return false;
|
|
}
|
|
try {
|
|
validateFlakeGateEntries(entries);
|
|
} catch {
|
|
return false;
|
|
}
|
|
const nonPassing = entries.filter(
|
|
(entry) =>
|
|
!((entry.selected === true || entry.selected === false) && entry.result === "success") &&
|
|
!(entry.selected === false && entry.result === "skipped"),
|
|
);
|
|
// Every failed job is advisory, so failure entries can only come from those jobs.
|
|
// Any other non-passing entry means lost coverage; matrix display names may differ.
|
|
return (
|
|
nonPassing.length > 0 &&
|
|
nonPassing.every((entry) => entry.selected === true && entry.result === "failure")
|
|
);
|
|
}
|
|
|
|
export function terminalPolicyPass(child) {
|
|
const failures = child.jobs.filter(isFailedJob);
|
|
const gatePass = recordedFlakeGatePass(child);
|
|
const advisoryOnly =
|
|
failures.length > 0 &&
|
|
failures.every(
|
|
(job) => isAdvisoryJob(child, job) || (gatePass && job.name === "openclaw/ci-gate"),
|
|
);
|
|
const gates = child.jobs.filter((job) => job.name === "openclaw/ci-gate");
|
|
return (
|
|
child.status === "completed" &&
|
|
(child.conclusion === "success" || (child.conclusion === "failure" && advisoryOnly)) &&
|
|
(failures.length === 0 || advisoryOnly) &&
|
|
// Selected-vs-skipped coverage comes from the successful gate or its exact log.
|
|
(!advisoryOnly ||
|
|
gatePass ||
|
|
(gates.length === 1 && gates[0].status === "completed" && gates[0].conclusion === "success"))
|
|
);
|
|
}
|
|
|
|
// These consumers bind their producer's artifact to the current run attempt, so a
|
|
// failed-jobs rerun that leaves the green producer behind stays red (#161317).
|
|
const ATTEMPT_BOUND_RELEASE_PRODUCERS = Object.freeze([
|
|
Object.freeze({
|
|
producer: "install_smoke_release_checks / installer_smoke_candidate_payload",
|
|
consumers: Object.freeze([
|
|
"install_smoke_release_checks / installer_smoke_nonroot_image",
|
|
"install_smoke_release_checks / installer_smoke_nonroot",
|
|
]),
|
|
}),
|
|
]);
|
|
|
|
/** Choose the single GitHub rerun request that can repair a terminal child's blocking jobs. */
|
|
export function planReleaseChildRerun({ childKey, jobs }) {
|
|
const failed = jobs
|
|
.filter((job) => isFailedJob(job) && !isAdvisoryJob({ key: childKey }, job))
|
|
.map((job) => job.name)
|
|
.toSorted();
|
|
if (failed.length === 0) {
|
|
throw new Error(`${childKey} has no blocking failed job to rerun`);
|
|
}
|
|
for (const { producer, consumers } of ATTEMPT_BOUND_RELEASE_PRODUCERS) {
|
|
const producerJob = jobs.find((job) => job.name === producer);
|
|
if (
|
|
producerJob?.status === "completed" &&
|
|
producerJob.conclusion === "success" &&
|
|
consumers.some((name) => failed.includes(name))
|
|
) {
|
|
return { failed, mode: "producer", producer };
|
|
}
|
|
}
|
|
return { failed, mode: "failed-jobs" };
|
|
}
|
|
|
|
function dispatchBlockers(children) {
|
|
return children.flatMap((child) => {
|
|
if (!child.required || !child.selected) {
|
|
return [];
|
|
}
|
|
const missing =
|
|
!/^[1-9][0-9]*$/u.test(String(child.runId ?? "")) ||
|
|
positiveInteger(child.runAttempt) === undefined;
|
|
if (!missing && (child.source !== "fresh" || child.result === "success")) {
|
|
return [];
|
|
}
|
|
const kind = missing ? "dispatch_missing" : "dispatch_failed";
|
|
return [
|
|
{
|
|
child: child.key,
|
|
conclusion: stringValue(child.result, "missing"),
|
|
job: child.dispatchName || `Dispatch ${child.key}`,
|
|
kind,
|
|
message: missing
|
|
? `${child.key} required dispatch did not record an exact run ID and attempt`
|
|
: `${child.key} required dispatch ended with ${stringValue(child.result, "missing")}`,
|
|
runId: stringValue(child.runId),
|
|
url: stringValue(child.url),
|
|
},
|
|
];
|
|
});
|
|
}
|
|
|
|
function releaseState(cancelled, activeRunIds, blockers, errors) {
|
|
const active = activeRunIds.length > 0;
|
|
return (
|
|
(cancelled && active && "cancelled_with_children") ||
|
|
(errors.length > 0 && "orchestration_error") ||
|
|
(blockers.length > 0 && (active ? "blocked_diagnostics_running" : "blocked_complete")) ||
|
|
(active ? "qualifying" : "passed")
|
|
);
|
|
}
|
|
|
|
export function classifyReleaseSnapshot({
|
|
cancelled = false,
|
|
children,
|
|
extraBlockers = [],
|
|
extraErrors = [],
|
|
localFailures = [],
|
|
}) {
|
|
const selected = children.filter((child) => child.selected);
|
|
const active = selected.filter(
|
|
(child) => child.runId && child.runAttempt && child.status !== "completed",
|
|
);
|
|
const childErrors = selected.flatMap((child) =>
|
|
(child.errors ?? []).filter((error) => error.kind !== "dispatch_missing"),
|
|
);
|
|
const childJobBlockers = selected.flatMap((child) =>
|
|
child.jobs
|
|
.filter(
|
|
(job) =>
|
|
isFailedJob(job) &&
|
|
!isAdvisoryJob(child, job) &&
|
|
!(job.name === "openclaw/ci-gate" && recordedFlakeGatePass(child)),
|
|
)
|
|
.map((job) => ({
|
|
child: child.key,
|
|
conclusion: job.conclusion,
|
|
job: job.name,
|
|
kind: "job_failure",
|
|
message: `${child.key} job failed policy`,
|
|
primaryAt: stringValue(
|
|
job.completed_at ?? job.completedAt ?? job.started_at ?? job.startedAt,
|
|
),
|
|
runId: child.runId,
|
|
url: job.html_url ?? job.url ?? child.url,
|
|
})),
|
|
);
|
|
const childJobBlockerKeys = new Set(
|
|
childJobBlockers.map((blocker) => `${blocker.child}:${blocker.runId}`),
|
|
);
|
|
const terminalBlockers = selected
|
|
.filter(
|
|
(child) =>
|
|
child.runId &&
|
|
child.runAttempt &&
|
|
child.status === "completed" &&
|
|
!terminalPolicyPass(child) &&
|
|
!childJobBlockerKeys.has(`${child.key}:${child.runId}`),
|
|
)
|
|
.map((child) => ({
|
|
child: child.key,
|
|
conclusion: child.conclusion,
|
|
job: "<workflow>",
|
|
kind: "workflow_failure",
|
|
message: `${child.key} workflow failed release policy`,
|
|
primaryAt: stringValue(child.updatedAt ?? child.createdAt),
|
|
runId: child.runId,
|
|
url: child.url,
|
|
}));
|
|
const rawBlockers = [
|
|
...localFailures,
|
|
...extraBlockers,
|
|
...dispatchBlockers(selected),
|
|
...childJobBlockers,
|
|
...terminalBlockers,
|
|
];
|
|
const blockers = normalizeIssues(rawBlockers, "release_blocker");
|
|
const errors = normalizeIssues([...extraErrors, ...childErrors], "orchestration_error");
|
|
|
|
const activeRunIds = active.map((child) => String(child.runId)).toSorted();
|
|
const primary = [...childJobBlockers, ...terminalBlockers]
|
|
.filter((issue) => issue.primaryAt)
|
|
.toSorted((left, right) => String(left.primaryAt).localeCompare(String(right.primaryAt), "en"));
|
|
return {
|
|
activeRunIds,
|
|
advisoryJobs: releaseAdvisoryJobs(selected),
|
|
blockerCount: rawBlockers.length,
|
|
blockerIndex: blockerIndex(rawBlockers),
|
|
blockers,
|
|
errors,
|
|
firstPrimaryFailure: primary[0] ? blockerEvidence(primary[0]) : null,
|
|
state: releaseState(cancelled, activeRunIds, blockers, errors),
|
|
};
|
|
}
|
|
|
|
function childTiming(child) {
|
|
const started = Date.parse(child.createdAt);
|
|
const updated = Date.parse(child.updatedAt);
|
|
return {
|
|
durationMinutes:
|
|
Number.isFinite(started) && Number.isFinite(updated)
|
|
? Math.round(((updated - started) / 60_000) * 10) / 10
|
|
: null,
|
|
jobs: child.jobs.map((job) => {
|
|
const startedAt = stringValue(job.started_at ?? job.startedAt);
|
|
const completedAt = stringValue(job.completed_at ?? job.completedAt);
|
|
const jobStarted = Date.parse(startedAt);
|
|
const jobCompleted = Date.parse(completedAt);
|
|
return {
|
|
acceptedRunAttempt: positiveInteger(job.acceptedRunAttempt),
|
|
completedAt,
|
|
conclusion: stringValue(job.conclusion),
|
|
durationMinutes:
|
|
Number.isFinite(jobStarted) && Number.isFinite(jobCompleted)
|
|
? Math.round(((jobCompleted - jobStarted) / 60_000) * 10) / 10
|
|
: null,
|
|
name: boundedString(job.name, MAX_LABEL_LENGTH),
|
|
startedAt,
|
|
status: stringValue(job.status),
|
|
url: boundedString(job.html_url ?? job.url, MAX_URL_LENGTH),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
function normalizedPlanChild(child, options = {}) {
|
|
const normalized = {
|
|
dispatchName: boundedString(child.dispatchName, MAX_LABEL_LENGTH),
|
|
displayTitle: boundedString(child.displayTitle, MAX_LABEL_LENGTH),
|
|
key: boundedString(child.key, MAX_LABEL_LENGTH),
|
|
required: child.required === true,
|
|
result: boundedString(child.result, MAX_LABEL_LENGTH),
|
|
runAttempt: positiveInteger(child.runAttempt) ?? null,
|
|
runId: boundedString(child.runId, MAX_LABEL_LENGTH),
|
|
selected: child.selected === true,
|
|
source: child.source === "reused" ? "reused" : "fresh",
|
|
url: boundedString(child.url, MAX_URL_LENGTH),
|
|
workflow: boundedString(child.workflow, MAX_LABEL_LENGTH),
|
|
workflowRef: boundedString(child.workflowRef, MAX_LABEL_LENGTH),
|
|
workflowSha: boundedString(child.workflowSha, MAX_LABEL_LENGTH),
|
|
};
|
|
return options.sourceParentAttempt === false
|
|
? normalized
|
|
: {
|
|
...normalized,
|
|
sourceParentAttempt: positiveInteger(child.sourceParentAttempt) ?? null,
|
|
};
|
|
}
|
|
|
|
export function buildReleaseStateArtifact({
|
|
cancellation = {},
|
|
children,
|
|
decision,
|
|
executionPlan,
|
|
expected,
|
|
mode,
|
|
releaseProfile,
|
|
rerunGroup,
|
|
transport = { status: "certain" },
|
|
}) {
|
|
const activeRunIds = (decision.activeRunIds ?? []).map(String);
|
|
if (
|
|
activeRunIds.some((runId) => !/^[1-9][0-9]*$/u.test(runId)) ||
|
|
new Set(activeRunIds).size !== activeRunIds.length ||
|
|
JSON.stringify(activeRunIds) !== JSON.stringify(activeRunIds.toSorted())
|
|
) {
|
|
throw new Error("release state active run IDs are malformed, duplicated, or unordered");
|
|
}
|
|
const completeBlockerIndex = decision.blockerIndex ?? blockerIndex(decision.blockers ?? []);
|
|
const { deadlineMonotonicMs: _deadline, error: _error, ...transportEvidence } = transport;
|
|
return {
|
|
version: 2,
|
|
kind:
|
|
mode === "decision"
|
|
? "openclaw.full-release-decision"
|
|
: "openclaw.full-release-diagnostic-drain",
|
|
mode,
|
|
parentRunId: expected.parentRunId,
|
|
parentRunAttempt: expected.parentRunAttempt,
|
|
sourceParentRunAttempt: executionPlan.parentRunAttempt,
|
|
workflowRef: expected.workflowRef,
|
|
workflowSha: expected.workflowSha,
|
|
targetSha: expected.targetSha,
|
|
releaseProfile,
|
|
rerunGroup,
|
|
executionPlanSha256: executionPlan.sha256,
|
|
state: decision.state,
|
|
advisoryJobs: decision.advisoryJobs ?? [],
|
|
activeRunIds,
|
|
blockerCount: decision.blockerCount ?? completeBlockerIndex.length,
|
|
blockerIndex: completeBlockerIndex,
|
|
blockers: decision.blockers,
|
|
errors: decision.errors,
|
|
firstPrimaryFailure: decision.firstPrimaryFailure ?? null,
|
|
transport: transportEvidence,
|
|
cancellation: {
|
|
cancelledRunIds: [...(cancellation.cancelledRunIds ?? [])].map(String),
|
|
requested: cancellation.requested === true,
|
|
},
|
|
children: Object.fromEntries(
|
|
children
|
|
.filter((child) => child.selected && child.runId && child.runAttempt)
|
|
.map((child) => [
|
|
child.key,
|
|
{
|
|
...releaseChildClassificationEvidence(child),
|
|
compositeJobsSha256: boundedString(child.compositeJobsSha256, MAX_LABEL_LENGTH),
|
|
conclusion: stringValue(child.conclusion),
|
|
dispatchActor: boundedString(child.dispatchActor, MAX_LABEL_LENGTH),
|
|
displayTitle: boundedString(child.displayTitle, MAX_LABEL_LENGTH),
|
|
errors: normalizeIssues(child.errors, "orchestration_error"),
|
|
observedRunAttempts: Array.isArray(child.observedRunAttempts)
|
|
? child.observedRunAttempts.map((value) => {
|
|
const attempt = positiveInteger(value);
|
|
if (attempt === undefined) {
|
|
throw new Error(`release state child attempt is invalid: ${child.key}`);
|
|
}
|
|
return attempt;
|
|
})
|
|
: [],
|
|
plannedRunAttempt: positiveInteger(child.plannedRunAttempt ?? child.runAttempt),
|
|
runAttempt: positiveInteger(child.runAttempt),
|
|
runId: String(child.runId),
|
|
repository: boundedString(child.repository, MAX_LABEL_LENGTH),
|
|
status: stringValue(child.status),
|
|
timing: childTiming(child),
|
|
url: boundedString(child.url, MAX_URL_LENGTH),
|
|
workflow: boundedString(child.workflow, MAX_LABEL_LENGTH),
|
|
workflowRef: boundedString(child.workflowRef, MAX_LABEL_LENGTH),
|
|
workflowSha: boundedString(child.workflowSha, MAX_LABEL_LENGTH),
|
|
triggeringActor: boundedString(child.triggeringActor, MAX_LABEL_LENGTH),
|
|
},
|
|
]),
|
|
),
|
|
};
|
|
}
|
|
|
|
function validatePlan(value, options = {}) {
|
|
if (!Array.isArray(value)) {
|
|
throw new Error("release state plan is invalid");
|
|
}
|
|
const keys = new Set();
|
|
return value.map((child) => {
|
|
const normalized = normalizedPlanChild(child, options);
|
|
if (
|
|
!normalized.key ||
|
|
!normalized.workflow ||
|
|
!normalized.displayTitle ||
|
|
!normalized.dispatchName ||
|
|
keys.has(normalized.key) ||
|
|
(normalized.required && !normalized.selected)
|
|
) {
|
|
throw new Error("release state child plan is invalid");
|
|
}
|
|
keys.add(normalized.key);
|
|
return normalized;
|
|
});
|
|
}
|
|
|
|
function validateExecutionPlanChildBindings(children, payload) {
|
|
const expectedKeys = (payload.attemptEvidenceVersion === 3 ? CHILD_SPECS : UNPHASED_CHILD_SPECS)
|
|
.map((spec) => spec.key)
|
|
.toSorted();
|
|
if (
|
|
JSON.stringify(children.map((child) => child.key).toSorted()) !== JSON.stringify(expectedKeys)
|
|
) {
|
|
throw new Error("release execution plan child inventory is invalid");
|
|
}
|
|
for (const child of children) {
|
|
const spec = releaseChildSpec(child.key);
|
|
if (child.dispatchName !== spec.dispatchName || child.workflow !== spec.workflow) {
|
|
throw new Error(`release execution plan child identity is invalid: ${child.key}`);
|
|
}
|
|
if (
|
|
payload.coveragePolicy === "npm-beta-v1" &&
|
|
["productPerformance", "npmTelegram"].includes(child.key) &&
|
|
(child.required ||
|
|
child.selected ||
|
|
child.runId ||
|
|
child.runAttempt ||
|
|
child.url ||
|
|
child.result !== "skipped")
|
|
) {
|
|
throw new Error("release coverage policy requires unrun confidence children");
|
|
}
|
|
if (
|
|
payload.telegramWaiver &&
|
|
child.key === "npmTelegram" &&
|
|
(child.required ||
|
|
child.selected ||
|
|
child.runId ||
|
|
child.runAttempt ||
|
|
child.result !== "skipped")
|
|
) {
|
|
throw new Error("Telegram waiver requires an unrun Telegram child");
|
|
}
|
|
if (
|
|
child.source === "fresh" &&
|
|
(child.displayTitle !==
|
|
`${spec.displayName} full-release-validation-${payload.parentRunId}-${payload.parentRunAttempt}${spec.suffix}` ||
|
|
child.workflowRef !== payload.workflowRef ||
|
|
child.workflowSha !== payload.workflowSha)
|
|
) {
|
|
throw new Error(`release execution plan child identity is invalid: ${child.key}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateChildReuseBindings(plan) {
|
|
if (plan.childReuse === undefined) {
|
|
return;
|
|
}
|
|
if (
|
|
plan.attemptEvidenceVersion !== 3 ||
|
|
plan.evidenceReuse.requested ||
|
|
!plan.childReuse ||
|
|
typeof plan.childReuse !== "object" ||
|
|
Array.isArray(plan.childReuse)
|
|
) {
|
|
throw new Error("release child reuse requires an independent phase-three plan");
|
|
}
|
|
for (const [role, selection] of Object.entries(plan.childReuse)) {
|
|
const child = plan.children.find((entry) => entry.key === role);
|
|
const spec = releaseChildSpec(role);
|
|
if (
|
|
!child?.selected ||
|
|
child.source !== "reused" ||
|
|
child.result !== "success" ||
|
|
selection?.role !== role ||
|
|
selection.repository !== plan.repository ||
|
|
selection.targetSha !== plan.targetSha ||
|
|
child.runId !== selection.runId ||
|
|
child.runAttempt !== 1 ||
|
|
child.workflowSha !== selection.workflowSha ||
|
|
child.workflowRef !== selection.workflowRef ||
|
|
child.displayTitle !== selection.displayTitle ||
|
|
child.url !== selection.url ||
|
|
child.sourceParentAttempt !== selection.sourceParentAttempt ||
|
|
!/^[1-9][0-9]*$/u.test(selection.sourceParentRunId) ||
|
|
!Number.isSafeInteger(selection.sourceParentAttempt) ||
|
|
selection.sourceParentAttempt < 1 ||
|
|
child.displayTitle !==
|
|
`${spec.displayName} full-release-validation-${selection.sourceParentRunId}-${selection.sourceParentAttempt}${spec.suffix}` ||
|
|
!Number.isSafeInteger(selection.runAttempt) ||
|
|
selection.runAttempt < 1 ||
|
|
!/^[a-f0-9]{40}$/u.test(selection.workflowSha) ||
|
|
!/^[a-f0-9]{64}$/u.test(selection.receiptSha256) ||
|
|
!selection.inputs ||
|
|
typeof selection.inputs !== "object" ||
|
|
Array.isArray(selection.inputs) ||
|
|
Object.values(selection.inputs).some((value) => typeof value !== "string") ||
|
|
Object.hasOwn(selection.inputs, "dispatch_id")
|
|
) {
|
|
throw new Error(`release child reuse differs from its immutable plan: ${role}`);
|
|
}
|
|
const targetKey =
|
|
role === "npmTelegram"
|
|
? "harness_ref"
|
|
: role.startsWith("releaseChecks")
|
|
? "ref"
|
|
: "target_ref";
|
|
if (
|
|
selection.inputs[targetKey] !== plan.targetSha ||
|
|
(role.endsWith("Candidate") && selection.inputs.phase !== "candidate") ||
|
|
(role.endsWith("Independent") && selection.inputs.phase !== "independent") ||
|
|
(selection.inputs.candidate_artifact_json &&
|
|
(!plan.candidate ||
|
|
selection.inputs.candidate_artifact_json !==
|
|
candidateArtifactJsonFromBinding(plan.candidate)))
|
|
) {
|
|
throw new Error(`release child reuse target or candidate changed: ${role}`);
|
|
}
|
|
}
|
|
if (
|
|
plan.children.some(
|
|
(child) =>
|
|
child.selected && child.source === "reused" && !Object.hasOwn(plan.childReuse, child.key),
|
|
)
|
|
) {
|
|
throw new Error("release child reuse omitted a selected receipt");
|
|
}
|
|
}
|
|
|
|
function validateTransport(value) {
|
|
const affected = value?.affected;
|
|
if (
|
|
(value?.status === "certain" && Object.keys(value).length !== 1) ||
|
|
(value?.status !== "certain" &&
|
|
(!["uncertain", "expired"].includes(value?.status) ||
|
|
!Array.isArray(affected) ||
|
|
affected.length === 0 ||
|
|
affected.some(
|
|
(entry) =>
|
|
!entry?.child ||
|
|
entry.errorClass !== "transient" ||
|
|
!positiveInteger(entry.runAttempt) ||
|
|
!/^[1-9][0-9]*$/u.test(String(entry.runId)),
|
|
) ||
|
|
!Number.isFinite(Date.parse(value.startedAt)) ||
|
|
!Number.isFinite(Date.parse(value.deadlineAt))))
|
|
) {
|
|
throw new Error("release state transport is invalid");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function validateReleaseStateArtifact(payload, expected, expectedMode) {
|
|
const expectedValues = expected ?? {};
|
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
throw new Error("release state artifact is invalid");
|
|
}
|
|
validateRetiredReleaseRetryFields(payload);
|
|
const mode = expectedMode ?? payload.mode;
|
|
const expectedKind =
|
|
mode === "decision"
|
|
? "openclaw.full-release-decision"
|
|
: "openclaw.full-release-diagnostic-drain";
|
|
if (
|
|
payload.version !== 2 ||
|
|
payload.mode !== mode ||
|
|
payload.kind !== expectedKind ||
|
|
!RELEASE_DECISION_STATE_SET.has(stringValue(payload.state)) ||
|
|
typeof payload.cancellation?.requested !== "boolean" ||
|
|
!Array.isArray(payload.cancellation?.cancelledRunIds) ||
|
|
!/^[a-f0-9]{64}$/u.test(String(payload.executionPlanSha256 ?? "")) ||
|
|
positiveInteger(payload.parentRunAttempt) === undefined ||
|
|
positiveInteger(payload.sourceParentRunAttempt) === undefined ||
|
|
(expectedValues.parentRunId !== undefined &&
|
|
String(payload.parentRunId) !== String(expectedValues.parentRunId)) ||
|
|
(expectedValues.parentRunAttempt !== undefined &&
|
|
Number(payload.parentRunAttempt) !== Number(expectedValues.parentRunAttempt)) ||
|
|
(expectedValues.maxParentRunAttempt !== undefined &&
|
|
Number(payload.parentRunAttempt) > Number(expectedValues.maxParentRunAttempt)) ||
|
|
(expectedValues.workflowRef !== undefined &&
|
|
payload.workflowRef !== expectedValues.workflowRef) ||
|
|
(expectedValues.workflowSha !== undefined &&
|
|
payload.workflowSha !== expectedValues.workflowSha) ||
|
|
(expectedValues.targetSha !== undefined && payload.targetSha !== expectedValues.targetSha) ||
|
|
(expectedValues.releaseProfile !== undefined &&
|
|
payload.releaseProfile !== expectedValues.releaseProfile) ||
|
|
(expectedValues.rerunGroup !== undefined && payload.rerunGroup !== expectedValues.rerunGroup)
|
|
) {
|
|
throw new Error("release state artifact binding is invalid");
|
|
}
|
|
const blockers = normalizeIssues(payload.blockers, "release_blocker");
|
|
const errors = normalizeIssues(payload.errors, "orchestration_error");
|
|
const machineFields = ["blockerCount", "blockerIndex", "firstPrimaryFailure", "transport"];
|
|
const machineEvidence = Object.hasOwn(payload, "transport");
|
|
if (machineFields.some((key) => Object.hasOwn(payload, key) !== machineEvidence)) {
|
|
throw new Error("release state machine evidence is incomplete");
|
|
}
|
|
const completeBlockerIndex =
|
|
machineEvidence && Array.isArray(payload.blockerIndex)
|
|
? payload.blockerIndex.map(String).toSorted()
|
|
: blockerIndex(blockers);
|
|
const firstFailure = machineEvidence ? payload.firstPrimaryFailure : null;
|
|
const transport = machineEvidence
|
|
? validateTransport(payload.transport)
|
|
: payload.state === "passed"
|
|
? { status: "certain" }
|
|
: null;
|
|
if (
|
|
(machineEvidence &&
|
|
(!Number.isSafeInteger(payload.blockerCount) ||
|
|
payload.blockerCount < 0 ||
|
|
payload.blockerCount !== completeBlockerIndex.length ||
|
|
completeBlockerIndex.some((value) => !/^[a-f0-9]{64}$/u.test(value)) ||
|
|
JSON.stringify(payload.blockerIndex) !== JSON.stringify(completeBlockerIndex) ||
|
|
(firstFailure !== null &&
|
|
(JSON.stringify(firstFailure) !== JSON.stringify(blockerEvidence(firstFailure)) ||
|
|
!completeBlockerIndex.includes(blockerIndex([firstFailure])[0]))))) ||
|
|
(payload.state === "passed" && transport?.status !== "certain") ||
|
|
(transport?.status === "expired" &&
|
|
!errors.some(({ kind }) => kind === "transport_deadline_exceeded"))
|
|
) {
|
|
throw new Error("release state machine evidence is invalid");
|
|
}
|
|
if (!Array.isArray(payload.activeRunIds)) {
|
|
throw new Error("release state active run IDs are invalid");
|
|
}
|
|
const activeRunIds = payload.activeRunIds.map(String);
|
|
if (
|
|
activeRunIds.some((runId) => !/^[1-9][0-9]*$/u.test(runId)) ||
|
|
new Set(activeRunIds).size !== activeRunIds.length ||
|
|
JSON.stringify(activeRunIds) !== JSON.stringify(activeRunIds.toSorted())
|
|
) {
|
|
throw new Error("release state active run IDs are malformed, duplicated, or unordered");
|
|
}
|
|
const children =
|
|
payload.children && typeof payload.children === "object" && !Array.isArray(payload.children)
|
|
? Object.fromEntries(
|
|
Object.entries(payload.children).map(([key, child]) => {
|
|
if (!child || typeof child !== "object" || Array.isArray(child)) {
|
|
throw new Error(`release state child snapshot is invalid: ${key}`);
|
|
}
|
|
if (!Array.isArray(child.timing?.jobs)) {
|
|
throw new Error(`release state child jobs are invalid: ${key}`);
|
|
}
|
|
const plannedRunAttempt = positiveInteger(child.plannedRunAttempt);
|
|
const runAttempt = positiveInteger(child.runAttempt);
|
|
const observedRunAttempts = Array.isArray(child.observedRunAttempts)
|
|
? child.observedRunAttempts.map((value) => positiveInteger(value))
|
|
: undefined;
|
|
const expectedRunAttempts =
|
|
plannedRunAttempt === undefined || runAttempt === undefined
|
|
? []
|
|
: Array.from(
|
|
{ length: runAttempt - plannedRunAttempt + 1 },
|
|
(_, index) => plannedRunAttempt + index,
|
|
);
|
|
if (
|
|
(child.compositeJobsSha256 || (observedRunAttempts?.length ?? 0) > 0) &&
|
|
(plannedRunAttempt === undefined ||
|
|
runAttempt === undefined ||
|
|
!observedRunAttempts ||
|
|
observedRunAttempts.some((value) => value === undefined) ||
|
|
JSON.stringify(observedRunAttempts) !== JSON.stringify(expectedRunAttempts))
|
|
) {
|
|
throw new Error(`release state child attempt evidence is invalid: ${key}`);
|
|
}
|
|
const timingJobs = child.timing.jobs.map((job) => ({
|
|
acceptedRunAttempt: positiveInteger(job?.acceptedRunAttempt),
|
|
completedAt: stringValue(job?.completedAt),
|
|
conclusion: stringValue(job?.conclusion),
|
|
durationMinutes:
|
|
typeof job?.durationMinutes === "number" ? job.durationMinutes : null,
|
|
name: boundedString(job?.name, MAX_LABEL_LENGTH),
|
|
startedAt: stringValue(job?.startedAt),
|
|
status: stringValue(job?.status),
|
|
url: boundedString(job?.url, MAX_URL_LENGTH),
|
|
}));
|
|
const jobNames = timingJobs.map((job) => job.name);
|
|
const canonicalJobNames = timingJobs
|
|
.toSorted(compareReleaseJobsByName)
|
|
.map((job) => job.name);
|
|
if (
|
|
child.compositeJobsSha256 &&
|
|
(timingJobs.length === 0 ||
|
|
timingJobs.some(
|
|
(job) =>
|
|
!job.name ||
|
|
job.acceptedRunAttempt === undefined ||
|
|
job.acceptedRunAttempt < plannedRunAttempt ||
|
|
job.acceptedRunAttempt > runAttempt,
|
|
) ||
|
|
new Set(jobNames).size !== jobNames.length ||
|
|
JSON.stringify(jobNames) !== JSON.stringify(canonicalJobNames))
|
|
) {
|
|
throw new Error(`release state child composite jobs are invalid: ${key}`);
|
|
}
|
|
return [
|
|
key,
|
|
{
|
|
...validateChildClassificationEvidence(
|
|
{ ...child, key, jobs: timingJobs },
|
|
{
|
|
parentRunId: payload.parentRunId,
|
|
parentRunAttempt: payload.sourceParentRunAttempt,
|
|
targetSha: payload.targetSha,
|
|
},
|
|
),
|
|
compositeJobsSha256: boundedString(child.compositeJobsSha256, MAX_LABEL_LENGTH),
|
|
conclusion: stringValue(child.conclusion),
|
|
dispatchActor: boundedString(child.dispatchActor, MAX_LABEL_LENGTH),
|
|
displayTitle: boundedString(child.displayTitle, MAX_LABEL_LENGTH),
|
|
errors: normalizeIssues(child.errors, "orchestration_error"),
|
|
observedRunAttempts: observedRunAttempts ?? [],
|
|
plannedRunAttempt,
|
|
runAttempt,
|
|
runId: String(child.runId ?? ""),
|
|
repository: boundedString(child.repository, MAX_LABEL_LENGTH),
|
|
status: stringValue(child.status),
|
|
timing: {
|
|
durationMinutes:
|
|
typeof child.timing?.durationMinutes === "number"
|
|
? child.timing.durationMinutes
|
|
: null,
|
|
jobs: timingJobs,
|
|
},
|
|
url: boundedString(child.url, MAX_URL_LENGTH),
|
|
workflow: boundedString(child.workflow, MAX_LABEL_LENGTH),
|
|
workflowRef: boundedString(child.workflowRef, MAX_LABEL_LENGTH),
|
|
workflowSha: boundedString(child.workflowSha, MAX_LABEL_LENGTH),
|
|
triggeringActor: boundedString(child.triggeringActor, MAX_LABEL_LENGTH),
|
|
},
|
|
];
|
|
}),
|
|
)
|
|
: {};
|
|
return {
|
|
...payload,
|
|
activeRunIds,
|
|
advisoryJobs: validateReleaseAdvisoryJobs(
|
|
payload.advisoryJobs,
|
|
Object.entries(children).map(([key, child]) =>
|
|
Object.assign({}, child, {
|
|
key,
|
|
jobs: child.timing.jobs,
|
|
}),
|
|
),
|
|
),
|
|
blockerCount: machineEvidence ? payload.blockerCount : null,
|
|
blockerIndex: completeBlockerIndex,
|
|
blockers,
|
|
children,
|
|
errors,
|
|
firstPrimaryFailure: firstFailure,
|
|
parentRunAttempt: positiveInteger(payload.parentRunAttempt),
|
|
sourceParentRunAttempt: positiveInteger(payload.sourceParentRunAttempt),
|
|
transport,
|
|
};
|
|
}
|
|
|
|
export function releasePlanGateFailures(gates) {
|
|
return gates
|
|
.filter((gate) => gate.required && gate.result !== "success")
|
|
.map((gate) => ({
|
|
child: "<parent>",
|
|
conclusion: stringValue(gate.result, "missing"),
|
|
job: stringValue(gate.name, "parent gate"),
|
|
kind: "parent_gate_failure",
|
|
message: `${stringValue(gate.name, "parent gate")} did not succeed`,
|
|
}));
|
|
}
|
|
|
|
export function releaseStateChildEvidence(child) {
|
|
return canonicalValue({
|
|
...releaseChildClassificationEvidence(child),
|
|
compositeJobsSha256: child.compositeJobsSha256,
|
|
conclusion: child.conclusion,
|
|
dispatchActor: child.dispatchActor,
|
|
effectiveRunAttempt: child.runAttempt,
|
|
jobs: child.timing.jobs.map((job) => ({
|
|
acceptedRunAttempt: job.acceptedRunAttempt,
|
|
completedAt: job.completedAt,
|
|
conclusion: job.conclusion,
|
|
name: job.name,
|
|
startedAt: job.startedAt,
|
|
status: job.status,
|
|
url: job.url,
|
|
})),
|
|
observedRunAttempts: child.observedRunAttempts,
|
|
plannedRunAttempt: child.plannedRunAttempt,
|
|
repository: child.repository,
|
|
runId: child.runId,
|
|
status: child.status,
|
|
triggeringActor: child.triggeringActor,
|
|
workflow: child.workflow,
|
|
workflowRef: child.workflowRef,
|
|
workflowSha: child.workflowSha,
|
|
});
|
|
}
|
|
|
|
function verifyStateStructure(state, executionPlan, label) {
|
|
const selected = executionPlan.children.filter((entry) => entry.selected);
|
|
const expectedKeys = selected.map((child) => child.key).toSorted();
|
|
if (JSON.stringify(Object.keys(state.children).toSorted()) !== JSON.stringify(expectedKeys)) {
|
|
throw new Error(`${label} child set differs from the immutable execution plan`);
|
|
}
|
|
const snapshots = selected.map((child) => {
|
|
if (!child.runId || !child.runAttempt) {
|
|
throw new Error(`selected release child omitted exact identity: ${child.key}`);
|
|
}
|
|
const snapshot = state.children[child.key];
|
|
if (
|
|
!snapshot ||
|
|
snapshot.runId !== child.runId ||
|
|
snapshot.plannedRunAttempt !== child.runAttempt ||
|
|
snapshot.runAttempt < child.runAttempt ||
|
|
snapshot.displayTitle !== child.displayTitle ||
|
|
snapshot.workflow !== child.workflow ||
|
|
snapshot.workflowRef !== child.workflowRef ||
|
|
snapshot.workflowSha !== child.workflowSha
|
|
) {
|
|
throw new Error(`${label} child provenance differs from the immutable plan: ${child.key}`);
|
|
}
|
|
if (executionPlan.attemptEvidenceVersion !== undefined) {
|
|
if (
|
|
snapshot.dispatchActor !== "github-actions[bot]" ||
|
|
!snapshot.triggeringActor ||
|
|
!snapshot.repository
|
|
) {
|
|
throw new Error(`${label} child rerun provenance is invalid: ${child.key}`);
|
|
}
|
|
const expectedAttempts = Array.from(
|
|
{ length: snapshot.runAttempt - child.runAttempt + 1 },
|
|
(_, index) => child.runAttempt + index,
|
|
);
|
|
if (JSON.stringify(snapshot.observedRunAttempts) !== JSON.stringify(expectedAttempts)) {
|
|
throw new Error(`${label} child attempt evidence is gapped: ${child.key}`);
|
|
}
|
|
const composite = {
|
|
effectiveRunAttempt: snapshot.runAttempt,
|
|
jobs: snapshot.timing.jobs.map((job) => {
|
|
const acceptedRunAttempt = positiveInteger(job.acceptedRunAttempt);
|
|
if (
|
|
acceptedRunAttempt === undefined ||
|
|
acceptedRunAttempt < child.runAttempt ||
|
|
acceptedRunAttempt > snapshot.runAttempt
|
|
) {
|
|
throw new Error(`${label} child job attempt is invalid: ${child.key}`);
|
|
}
|
|
return {
|
|
acceptedRunAttempt,
|
|
completedAt: job.completedAt,
|
|
conclusion: job.conclusion,
|
|
name: job.name,
|
|
startedAt: job.startedAt,
|
|
status: job.status,
|
|
url: job.url,
|
|
};
|
|
}),
|
|
plannedRunAttempt: child.runAttempt,
|
|
};
|
|
if (
|
|
snapshot.compositeJobsSha256 !== releaseCompositeJobsSha256(composite) ||
|
|
new Set(composite.jobs.map((job) => job.name)).size !== composite.jobs.length
|
|
) {
|
|
throw new Error(`${label} child composite job evidence is invalid: ${child.key}`);
|
|
}
|
|
}
|
|
return Object.assign({}, child, snapshot, {
|
|
jobs: snapshot.timing.jobs.map((job) => ({
|
|
acceptedRunAttempt: job.acceptedRunAttempt,
|
|
conclusion: job.conclusion,
|
|
html_url: job.url,
|
|
name: job.name,
|
|
status: job.status,
|
|
url: job.url,
|
|
})),
|
|
});
|
|
});
|
|
const { cancelledRunIds, requested } = state.cancellation;
|
|
const affectedRunIds = new Set(affectedActiveRunIds(snapshots, state.blockers));
|
|
if (
|
|
(requested &&
|
|
!state.errors.some(
|
|
({ child, kind }) => child === "<collector>" && kind === "collector_cancelled",
|
|
)) ||
|
|
new Set(cancelledRunIds).size !== cancelledRunIds.length ||
|
|
cancelledRunIds.some((runId) => !/^[1-9][0-9]*$/u.test(runId) || !affectedRunIds.has(runId))
|
|
) {
|
|
throw new Error(`${label} cancellation differs from exact child state`);
|
|
}
|
|
const baseline = classifyReleaseSnapshot({
|
|
children: snapshots,
|
|
extraBlockers: executionPlan.blockers,
|
|
extraErrors: executionPlan.errors,
|
|
localFailures: releasePlanGateFailures(executionPlan.gates),
|
|
releaseProfile: executionPlan.releaseProfile,
|
|
workflowRef: executionPlan.workflowRef,
|
|
});
|
|
if (JSON.stringify(state.activeRunIds) !== JSON.stringify(baseline.activeRunIds)) {
|
|
throw new Error(`${label} activeRunIds differs from canonical release policy`);
|
|
}
|
|
const snapshotsByKey = new Map(snapshots.map((snapshot) => [snapshot.key, snapshot]));
|
|
if (
|
|
state.transport?.affected?.some(({ child, runAttempt, runId }) => {
|
|
const snapshot = snapshotsByKey.get(child);
|
|
return snapshot?.runId !== runId || snapshot.runAttempt !== runAttempt;
|
|
})
|
|
) {
|
|
throw new Error(`${label} transport provenance differs from exact child state`);
|
|
}
|
|
for (const key of ["blockers", "errors"]) {
|
|
const indexed = key === "blockers" && state.blockerCount !== null;
|
|
const claimed = new Set(
|
|
(indexed ? state.blockerIndex : state[key]).map((issue) => JSON.stringify(issue)),
|
|
);
|
|
const required = indexed ? baseline.blockerIndex : baseline[key];
|
|
if (required.some((issue) => !claimed.has(JSON.stringify(issue)))) {
|
|
throw new Error(`${label} omits baseline ${key}`);
|
|
}
|
|
}
|
|
if (releaseState(requested, state.activeRunIds, state.blockers, state.errors) !== state.state) {
|
|
throw new Error(`${label} state differs from canonical release policy`);
|
|
}
|
|
}
|
|
|
|
function acceptedJobBlockerAttempt(state, blocker) {
|
|
const child = state.children[blocker.child];
|
|
if (blocker.kind !== "job_failure" || !child || child.runId !== blocker.runId) {
|
|
return undefined;
|
|
}
|
|
return child.timing.jobs.find(
|
|
(job) =>
|
|
job.name === blocker.job &&
|
|
job.url === blocker.url &&
|
|
job.conclusion === blocker.conclusion &&
|
|
job.status === "completed",
|
|
)?.acceptedRunAttempt;
|
|
}
|
|
|
|
function verifyStateTransition(decision, drain, executionPlan) {
|
|
const reuseRecovery =
|
|
["blocked_complete", "blocked_diagnostics_running"].includes(decision.state) &&
|
|
drain.state === "passed" &&
|
|
decision.errors.length === 0 &&
|
|
decision.blockers.length > 0 &&
|
|
decision.blockers.every(
|
|
({ child, kind }) =>
|
|
child === "<evidence>" && ["reused_evidence_invalid", "provenance_mismatch"].includes(kind),
|
|
);
|
|
if (
|
|
drain.transport?.status === "uncertain" ||
|
|
["qualifying", "blocked_diagnostics_running"].includes(drain.state) ||
|
|
decision.state === "qualifying" ||
|
|
(decision.state === "passed" && drain.state === "blocked_complete") ||
|
|
(decision.state.startsWith("blocked_") && drain.state === "passed" && !reuseRecovery)
|
|
) {
|
|
throw new Error(
|
|
"release decision and diagnostic drain transition is invalid: " +
|
|
`decision(parentRunAttempt=${decision.parentRunAttempt}, state=${decision.state}), ` +
|
|
`drain(parentRunAttempt=${drain.parentRunAttempt}, state=${drain.state}); ` +
|
|
`executionPlan(originalParentRunAttempt=${executionPlan.parentRunAttempt}, sha256=${executionPlan.sha256}); ` +
|
|
"compatible collector evidence for the same execution plan is required",
|
|
);
|
|
}
|
|
if (
|
|
decision.state.startsWith("blocked_") &&
|
|
drain.state === "blocked_complete" &&
|
|
!decision.blockers.every((blocker) =>
|
|
drain.blockers.some(
|
|
(candidate) =>
|
|
JSON.stringify(candidate) === JSON.stringify(blocker) ||
|
|
// A verified newer attempt can replace a job URL while retaining the
|
|
// same blocker. Both URLs must belong to their accepted job evidence.
|
|
(executionPlan.attemptEvidenceVersion !== undefined &&
|
|
JSON.stringify({ ...candidate, url: blocker.url }) === JSON.stringify(blocker) &&
|
|
acceptedJobBlockerAttempt(drain, candidate) >
|
|
acceptedJobBlockerAttempt(decision, blocker)) ||
|
|
(blocker.kind === "workflow_failure" &&
|
|
candidate.kind === "job_failure" &&
|
|
["child", "runId"].every((key) => candidate[key] === blocker[key])),
|
|
),
|
|
)
|
|
) {
|
|
throw new Error("diagnostic drain changed or removed a release decision blocker");
|
|
}
|
|
}
|
|
|
|
function verifyReleaseStatePair(planPayload, decisionPayload, drainPayload, expected = {}) {
|
|
const executionPlan = validateReleaseExecutionPlanArtifact(planPayload, expected);
|
|
const decision = validateReleaseStateArtifact(decisionPayload, expected, "decision");
|
|
const drain = validateReleaseStateArtifact(drainPayload, expected, "drain");
|
|
if (
|
|
decision.executionPlanSha256 !== executionPlan.sha256 ||
|
|
drain.executionPlanSha256 !== executionPlan.sha256 ||
|
|
decision.sourceParentRunAttempt !== executionPlan.parentRunAttempt ||
|
|
drain.sourceParentRunAttempt !== executionPlan.parentRunAttempt
|
|
) {
|
|
throw new Error("release decision and diagnostic drain execution plans differ");
|
|
}
|
|
verifyStateStructure(decision, executionPlan, "release decision");
|
|
verifyStateStructure(drain, executionPlan, "diagnostic drain");
|
|
verifyStateTransition(decision, drain, executionPlan);
|
|
return {
|
|
decision,
|
|
drain,
|
|
executionPlan,
|
|
sourceAttempts: {
|
|
decision: decision.parentRunAttempt,
|
|
drain: drain.parentRunAttempt,
|
|
executionPlan: executionPlan.parentRunAttempt,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function verifyReleaseStateArtifacts(plan, decision, drain, expected = {}) {
|
|
const verified = verifyReleaseStatePair(plan, decision, drain, expected);
|
|
if (verified.decision.state !== "passed" || verified.drain.state !== "passed") {
|
|
const outcome = verified.drain.state === "passed" ? verified.decision : verified.drain;
|
|
throw new Error(formatReleaseStateOutcome(outcome));
|
|
}
|
|
for (const child of verified.executionPlan.children.filter((entry) => entry.selected)) {
|
|
if (
|
|
JSON.stringify(releaseStateChildEvidence(verified.decision.children[child.key])) !==
|
|
JSON.stringify(releaseStateChildEvidence(verified.drain.children[child.key]))
|
|
) {
|
|
throw new Error(`release decision and diagnostic drain child evidence differ: ${child.key}`);
|
|
}
|
|
}
|
|
return verified;
|
|
}
|
|
|
|
function newestStateCandidate(candidates, mode, runId, expected) {
|
|
const prefix = mode === "decision" ? "full-release-decision" : "full-release-diagnostics";
|
|
const pattern = new RegExp(`^${prefix}-${runId}-([1-9][0-9]*)$`, "u");
|
|
const maxParentRunAttempt = Number(expected.maxParentRunAttempt ?? Number.POSITIVE_INFINITY);
|
|
const sorted = candidates
|
|
.map((candidate) => {
|
|
const match = pattern.exec(String(candidate.name ?? ""));
|
|
return match ? { ...candidate, attempt: Number(match[1]) } : undefined;
|
|
})
|
|
.filter(Boolean)
|
|
.filter((candidate) => candidate.attempt <= maxParentRunAttempt)
|
|
.toSorted((left, right) => right.attempt - left.attempt);
|
|
const newest = sorted[0];
|
|
if (!newest) {
|
|
throw new Error(`no ${mode} artifact exists at or before the current parent attempt`);
|
|
}
|
|
const payload = validateReleaseStateArtifact(newest.payload, expected, mode);
|
|
if (payload.parentRunAttempt !== newest.attempt) {
|
|
throw new Error(`${mode} artifact name and source attempt differ`);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
export function selectReleaseStateArtifacts(
|
|
executionPlanPayload,
|
|
decisionCandidates,
|
|
drainCandidates,
|
|
expected = {},
|
|
) {
|
|
const executionPlan = validateReleaseExecutionPlanArtifact(executionPlanPayload, expected);
|
|
const selectionExpected = {
|
|
...expected,
|
|
parentRunAttempt: undefined,
|
|
};
|
|
const decision = newestStateCandidate(
|
|
decisionCandidates,
|
|
"decision",
|
|
executionPlan.parentRunId,
|
|
selectionExpected,
|
|
);
|
|
const drain = newestStateCandidate(
|
|
drainCandidates,
|
|
"drain",
|
|
executionPlan.parentRunId,
|
|
selectionExpected,
|
|
);
|
|
return verifyReleaseStatePair(executionPlan, decision, drain, selectionExpected);
|
|
}
|
|
|
|
function issueSummary(prefix, issue) {
|
|
const label =
|
|
issue.job || issue.message || issue.child || issue.kind || `${prefix.toLowerCase()} detail`;
|
|
const result = issue.conclusion ? ` (${issue.conclusion})` : "";
|
|
const url = issue.url ? ` ${issue.url}` : "";
|
|
return `- ${prefix}: ${label}${result}${url}`;
|
|
}
|
|
|
|
function releaseStateDetailLines(payload, maxItems = MAX_SUMMARY_ISSUES) {
|
|
const normalizedMax = Math.max(1, Math.min(maxItems || MAX_SUMMARY_ISSUES, 10));
|
|
const lines = [];
|
|
for (const blocker of payload.blockers.slice(0, normalizedMax)) {
|
|
lines.push(issueSummary("Blocker", blocker));
|
|
}
|
|
for (const error of payload.errors.slice(0, normalizedMax)) {
|
|
lines.push(issueSummary("Collector error", error));
|
|
}
|
|
for (const advisory of payload.advisoryJobs ?? []) {
|
|
lines.push(
|
|
advisory.class === "recorded-flake"
|
|
? `${issueSummary("Advisory [recorded-flake]", { ...advisory, job: `${advisory.child}/${advisory.job}` })} — ${advisory.reason} ${advisory.trackingUrl}`
|
|
: issueSummary(`Advisory [${advisory.class}]`, advisory),
|
|
);
|
|
}
|
|
const omitted =
|
|
Math.max(0, payload.blockers.length - normalizedMax) +
|
|
Math.max(0, payload.errors.length - normalizedMax);
|
|
if (omitted > 0) {
|
|
lines.push(`- ${omitted} additional blocker/error item(s) omitted`);
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
export function formatReleaseStateOutcome(payload) {
|
|
const lines = [`Full Release Validation state: ${payload.state}`];
|
|
lines.push(...releaseStateDetailLines(payload));
|
|
if (payload.state === "blocked_diagnostics_running") {
|
|
lines.push(
|
|
"Diagnostic Drain is still collecting terminal evidence; diagnose now, retry later.",
|
|
);
|
|
} else if (payload.state === "orchestration_error") {
|
|
lines.push("Recover the collector against the same exact child runs; do not redispatch tests.");
|
|
} else if (payload.state === "cancelled_with_children") {
|
|
lines.push("The collector stopped while exact child runs remained active.");
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
export function affectedActiveRunIds(children, blockers, cancelledRunIds = new Set()) {
|
|
const affected = new Set(
|
|
blockers.map((blocker) => String(blocker.runId ?? "")).filter((runId) => runId),
|
|
);
|
|
return children
|
|
.filter(
|
|
(child) =>
|
|
child.source !== "reused" &&
|
|
child.status !== "completed" &&
|
|
affected.has(String(child.runId)) &&
|
|
!cancelledRunIds.has(String(child.runId)),
|
|
)
|
|
.map((child) => String(child.runId));
|
|
}
|