mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-22 18:35:21 -06:00
a2f0b84e8a
* fix(release): separate decisions from diagnostic drain * test(release): align decision drain fixtures * fix(release): pin artifact downloads to valid v8 SHA * fix(release): default empty evidence paths in plans * fix(release): bind complete evidence reuse selection * fix(release): harden retry artifact integrity * fix(release): canonicalize reused validation evidence * fix(release): bind manifest children to execution plan * test(release): fix validation fixture types * fix(release): retry transient decision artifact reads
929 lines
31 KiB
JavaScript
929 lines
31 KiB
JavaScript
#!/usr/bin/env node
|
|
import { execFile } from "node:child_process";
|
|
import {
|
|
appendFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import process from "node:process";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promisify } from "node:util";
|
|
import {
|
|
affectedActiveRunIds,
|
|
buildReleaseExecutionPlan,
|
|
buildReleaseExecutionPlanArtifact,
|
|
buildReleaseStateArtifact,
|
|
classifyReleaseGhTransportError,
|
|
classifyReleaseSnapshot,
|
|
formatReleaseStateOutcome,
|
|
releasePlanGateFailures,
|
|
selectReleaseStateArtifacts,
|
|
validateReleaseExecutionPlanArtifact,
|
|
verifyReleaseStateArtifacts,
|
|
} from "./full-release-validation-policy.mjs";
|
|
|
|
export * from "./full-release-validation-policy.mjs";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const RELEASE_SUMMARY_PATH =
|
|
process.env.OPENCLAW_RELEASE_CI_SUMMARY_VALIDATOR ??
|
|
fileURLToPath(new URL("./release-ci-summary.mjs", import.meta.url));
|
|
const API_ERROR_PATTERN =
|
|
/HTTP [45][0-9][0-9]|API|Bad credentials|rate limit|network|connection|timeout|ETIMEDOUT|ECONNRESET|EAI_AGAIN/u;
|
|
const DEFAULT_POLL_INTERVAL_MS = 60_000;
|
|
const DEFAULT_HEARTBEAT_INTERVAL_MS = 5 * 60_000;
|
|
const GH_TIMEOUT_MS = 60_000;
|
|
|
|
function stringValue(value, fallback = "") {
|
|
return typeof value === "string" ? value : fallback;
|
|
}
|
|
|
|
function requiredString(value, label) {
|
|
const normalized = stringValue(value).trim();
|
|
if (!normalized) {
|
|
throw new Error(`${label} is required`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function positiveInteger(value, label) {
|
|
const normalized = Number(value);
|
|
if (!Number.isSafeInteger(normalized) || normalized < 1) {
|
|
throw new Error(`${label} must be a positive integer`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
async function abortableSleep(milliseconds, signal) {
|
|
let abortError;
|
|
await new Promise((resolve) => {
|
|
const timer = setTimeout(resolve, milliseconds);
|
|
const abort = () => {
|
|
clearTimeout(timer);
|
|
abortError = signal?.reason instanceof Error ? signal.reason : new Error("operation aborted");
|
|
resolve();
|
|
};
|
|
signal?.addEventListener("abort", abort, { once: true });
|
|
});
|
|
if (abortError instanceof Error) {
|
|
throw new Error(abortError.message, { cause: abortError });
|
|
}
|
|
}
|
|
|
|
async function runGh(args, options = {}) {
|
|
const attempts = options.attempts ?? 6;
|
|
let lastError;
|
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
try {
|
|
const result = await execFileAsync("gh", args, {
|
|
encoding: "utf8",
|
|
env: process.env,
|
|
killSignal: "SIGKILL",
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
signal: options.signal,
|
|
timeout: GH_TIMEOUT_MS,
|
|
});
|
|
return result.stdout;
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (options.signal?.aborted) {
|
|
throw options.signal.reason ?? error;
|
|
}
|
|
if (attempt === attempts || classifyReleaseGhTransportError(error) !== "transient") {
|
|
throw error;
|
|
}
|
|
await abortableSleep(Math.min(attempt * 10_000, 60_000), options.signal);
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|
|
|
|
async function githubJson(path, signal) {
|
|
return JSON.parse(
|
|
await runGh(["api", `repos/${process.env.GITHUB_REPOSITORY}/${path}`], { signal }),
|
|
);
|
|
}
|
|
|
|
async function githubJobs(runId, signal) {
|
|
return (
|
|
await runGh(
|
|
[
|
|
"api",
|
|
"--paginate",
|
|
`repos/${process.env.GITHUB_REPOSITORY}/actions/runs/${runId}/jobs?per_page=100`,
|
|
"--jq",
|
|
".jobs[] | @json",
|
|
],
|
|
{ signal },
|
|
)
|
|
)
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line));
|
|
}
|
|
|
|
function issue(kind, child, message, extra = {}) {
|
|
return {
|
|
child: child.key,
|
|
kind,
|
|
message,
|
|
runId: stringValue(child.runId),
|
|
url: stringValue(child.url),
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
export function validateChildBinding(child, run, jobs) {
|
|
const errors = [];
|
|
const path = stringValue(run.path).split("@", 1)[0];
|
|
if (String(run.id) !== String(child.runId)) {
|
|
errors.push(issue("provenance_mismatch", child, `${child.key} run ID changed`));
|
|
}
|
|
if (run.event !== "workflow_dispatch") {
|
|
errors.push(issue("provenance_mismatch", child, `${child.key} event changed`));
|
|
}
|
|
if (path !== `.github/workflows/${child.workflow}`) {
|
|
errors.push(issue("provenance_mismatch", child, `${child.key} workflow path changed`));
|
|
}
|
|
if (run.display_title !== child.displayTitle) {
|
|
errors.push(issue("provenance_mismatch", child, `${child.key} display title changed`));
|
|
}
|
|
if (run.head_branch !== child.workflowRef) {
|
|
errors.push(issue("provenance_mismatch", child, `${child.key} workflow ref changed`));
|
|
}
|
|
if (run.head_sha !== child.workflowSha) {
|
|
errors.push(issue("provenance_mismatch", child, `${child.key} tooling SHA changed`));
|
|
}
|
|
if (Number(run.run_attempt) !== Number(child.runAttempt)) {
|
|
errors.push(issue("provenance_mismatch", child, `${child.key} run attempt changed`));
|
|
}
|
|
return {
|
|
...child,
|
|
conclusion: stringValue(run.conclusion),
|
|
createdAt: stringValue(run.created_at),
|
|
errors,
|
|
jobs,
|
|
runAttempt: Number(run.run_attempt),
|
|
runId: String(run.id),
|
|
status: stringValue(run.status),
|
|
updatedAt: stringValue(run.updated_at),
|
|
url: stringValue(run.html_url, child.url),
|
|
workflowRef: stringValue(run.head_branch),
|
|
workflowSha: stringValue(run.head_sha),
|
|
};
|
|
}
|
|
|
|
async function readChild(child, previous, signal) {
|
|
if (!child.selected) {
|
|
return { ...child, errors: [], jobs: [], status: "skipped" };
|
|
}
|
|
if (!child.runId || !child.runAttempt) {
|
|
return {
|
|
...child,
|
|
errors: [issue("dispatch_missing", child, `${child.key} omitted its exact run identity`)],
|
|
jobs: [],
|
|
status: "missing",
|
|
};
|
|
}
|
|
try {
|
|
const run = await githubJson(`actions/runs/${child.runId}`, signal);
|
|
return validateChildBinding(child, run, await githubJobs(child.runId, signal));
|
|
} catch (error) {
|
|
return {
|
|
...child,
|
|
conclusion: stringValue(previous?.conclusion),
|
|
createdAt: stringValue(previous?.createdAt),
|
|
errors: [
|
|
...(previous?.errors ?? []).filter((entry) => entry.kind === "provenance_mismatch"),
|
|
issue(
|
|
"api_error",
|
|
child,
|
|
`${child.key} GitHub read failed: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
),
|
|
],
|
|
jobs: previous?.jobs ?? [],
|
|
status: stringValue(previous?.status, "unknown"),
|
|
updatedAt: stringValue(previous?.updatedAt),
|
|
};
|
|
}
|
|
}
|
|
|
|
export function parsePlanInputs(value) {
|
|
const parsed = JSON.parse(requiredString(value, "plan inputs JSON"));
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
throw new Error("plan inputs JSON must be an object");
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
export function hydrateReusedPlan(plan, evidence) {
|
|
const byRole = new Map((evidence.children ?? []).map((child) => [child.role, child]));
|
|
return plan.map((child) => {
|
|
if (!child.selected) {
|
|
return child;
|
|
}
|
|
const reused = byRole.get(child.key);
|
|
if (!reused) {
|
|
return child;
|
|
}
|
|
return {
|
|
...child,
|
|
displayTitle: reused.displayTitle,
|
|
result: "success",
|
|
runAttempt: reused.runAttempt,
|
|
runId: reused.runId,
|
|
url: reused.url,
|
|
workflowRef: reused.headBranch,
|
|
workflowSha: reused.workflowSha,
|
|
};
|
|
});
|
|
}
|
|
|
|
function changedPathsValue(value) {
|
|
if (Array.isArray(value)) {
|
|
return value;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(stringValue(value, "[]"));
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function canonicalJson(value) {
|
|
if (Array.isArray(value)) {
|
|
return value.map(canonicalJson);
|
|
}
|
|
if (value && typeof value === "object") {
|
|
return Object.fromEntries(
|
|
Object.entries(value)
|
|
.toSorted(([left], [right]) => left.localeCompare(right))
|
|
.map(([key, entry]) => [key, canonicalJson(entry)]),
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function validateReuse(plan, planInputs, signal) {
|
|
if (!(planInputs.evidenceReuse === true || planInputs.evidenceReuse === "true")) {
|
|
return { blockers: [], children: plan, errors: [] };
|
|
}
|
|
try {
|
|
const args = [
|
|
RELEASE_SUMMARY_PATH,
|
|
"--validate-run",
|
|
requiredString(planInputs.evidenceRunId, "evidence selected run ID"),
|
|
"--repo",
|
|
requiredString(process.env.GITHUB_REPOSITORY, "GitHub repository"),
|
|
"--trusted-workflow-ref",
|
|
requiredString(planInputs.trustedWorkflow?.ref, "trusted workflow ref"),
|
|
"--trusted-workflow-full-ref",
|
|
requiredString(planInputs.trustedWorkflow?.fullRef, "trusted workflow full ref"),
|
|
"--trusted-workflow-sha",
|
|
requiredString(planInputs.trustedWorkflow?.sha, "trusted workflow SHA"),
|
|
"--verifier-source-sha",
|
|
requiredString(planInputs.workflowSha, "workflow SHA"),
|
|
"--verifier-source-file",
|
|
RELEASE_SUMMARY_PATH,
|
|
"--expected-target-sha",
|
|
requiredString(process.env.TARGET_SHA, "target SHA"),
|
|
"--expected-evidence-policy",
|
|
requiredString(planInputs.evidencePolicy, "evidence policy"),
|
|
"--expected-evidence-sha",
|
|
requiredString(planInputs.evidenceSha, "evidence SHA"),
|
|
"--expected-root-run-id",
|
|
requiredString(planInputs.evidenceRootRunId, "evidence root run ID"),
|
|
"--expected-selected-run-id",
|
|
requiredString(planInputs.evidenceRunId, "evidence selected run ID"),
|
|
"--expected-changed-paths-json",
|
|
JSON.stringify(changedPathsValue(planInputs.evidenceChangedPaths)),
|
|
"--json",
|
|
];
|
|
const result = await execFileAsync(process.execPath, args, {
|
|
encoding: "utf8",
|
|
env: process.env,
|
|
killSignal: "SIGKILL",
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
signal,
|
|
timeout: GH_TIMEOUT_MS * 6,
|
|
});
|
|
const evidence = JSON.parse(result.stdout);
|
|
if (
|
|
evidence.releaseProfile !== process.env.RELEASE_PROFILE ||
|
|
evidence.rerunGroup !== process.env.RERUN_GROUP ||
|
|
!evidence.manifest ||
|
|
typeof evidence.manifest !== "object" ||
|
|
Array.isArray(evidence.manifest)
|
|
) {
|
|
throw new Error("reused release evidence no longer matches the requested validation");
|
|
}
|
|
return {
|
|
blockers: [],
|
|
children: hydrateReusedPlan(plan, evidence),
|
|
errors: [],
|
|
sourceManifest: canonicalJson(evidence.manifest),
|
|
};
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const entry = {
|
|
child: "<evidence>",
|
|
kind: API_ERROR_PATTERN.test(message) ? "api_error" : "reused_evidence_invalid",
|
|
message,
|
|
runId: stringValue(planInputs.evidenceRunId),
|
|
url: stringValue(planInputs.evidenceRunUrl),
|
|
};
|
|
return API_ERROR_PATTERN.test(message)
|
|
? { blockers: [], children: plan, errors: [entry] }
|
|
: { blockers: [entry], children: plan, errors: [] };
|
|
}
|
|
}
|
|
|
|
function writeArtifact(path, payload) {
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`);
|
|
}
|
|
|
|
function writeResult(path, payload) {
|
|
writeArtifact(path, payload);
|
|
if (process.env.GITHUB_OUTPUT) {
|
|
appendFileSync(process.env.GITHUB_OUTPUT, `state=${payload.state}\n`);
|
|
for (const [key, child] of Object.entries(payload.children ?? {})) {
|
|
appendFileSync(process.env.GITHUB_OUTPUT, `${key}_conclusion=${child.conclusion ?? ""}\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function writeExecutionPlan(path, payload) {
|
|
writeArtifact(path, payload);
|
|
if (process.env.GITHUB_OUTPUT) {
|
|
appendFileSync(process.env.GITHUB_OUTPUT, `sha256=${payload.sha256}\n`);
|
|
appendFileSync(
|
|
process.env.GITHUB_OUTPUT,
|
|
`source_parent_attempt=${payload.parentRunAttempt}\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function appendSummary(mode, payload) {
|
|
if (!process.env.GITHUB_STEP_SUMMARY) {
|
|
return;
|
|
}
|
|
appendFileSync(
|
|
process.env.GITHUB_STEP_SUMMARY,
|
|
`## ${mode === "decision" ? "Release Decision" : "Diagnostic Drain"}\n\n${formatReleaseStateOutcome(payload)}\n`,
|
|
);
|
|
}
|
|
|
|
export function formatReleaseStateHeartbeat(mode, decision) {
|
|
return `${mode} heartbeat: state=${decision.state} active=${decision.activeRunIds.length} blockers=${decision.blockers.length} errors=${decision.errors.length}`;
|
|
}
|
|
|
|
async function cancelAffectedChildren(children, blockers, cancelledRunIds, signal) {
|
|
const errors = [];
|
|
for (const runId of affectedActiveRunIds(children, blockers, cancelledRunIds)) {
|
|
const child = children.find((entry) => String(entry.runId) === runId);
|
|
try {
|
|
await runGh(["run", "cancel", runId], { attempts: 1, signal });
|
|
cancelledRunIds.add(runId);
|
|
} catch (error) {
|
|
errors.push(
|
|
issue(
|
|
"api_error",
|
|
child ?? { key: "<child>", runId },
|
|
`exact child cancellation failed: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function readArtifact(path, label) {
|
|
try {
|
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
} catch (error) {
|
|
throw new Error(
|
|
`${label} artifact is unreadable: ${error instanceof Error ? error.message : String(error)}`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
}
|
|
|
|
function verifyMode() {
|
|
const expected = {
|
|
maxParentRunAttempt: positiveInteger(process.env.GITHUB_RUN_ATTEMPT, "parent run attempt"),
|
|
parentRunId: requiredString(process.env.GITHUB_RUN_ID, "parent run ID"),
|
|
releaseProfile: requiredString(process.env.RELEASE_PROFILE, "release profile"),
|
|
rerunGroup: requiredString(process.env.RERUN_GROUP, "rerun group"),
|
|
targetSha: requiredString(process.env.TARGET_SHA, "target SHA"),
|
|
workflowRef: requiredString(process.env.GITHUB_REF_NAME, "workflow ref"),
|
|
workflowSha: requiredString(process.env.GITHUB_SHA, "workflow SHA"),
|
|
};
|
|
const verified = verifyReleaseStateArtifacts(
|
|
readArtifact(
|
|
requiredString(process.env.RELEASE_EXECUTION_PLAN_PATH, "execution plan path"),
|
|
"execution plan",
|
|
),
|
|
readArtifact(requiredString(process.env.RELEASE_DECISION_PATH, "decision path"), "decision"),
|
|
readArtifact(requiredString(process.env.DIAGNOSTIC_DRAIN_PATH, "drain path"), "drain"),
|
|
expected,
|
|
);
|
|
appendSummary("decision", verified.decision);
|
|
appendSummary("drain", verified.drain);
|
|
}
|
|
|
|
function planExpected() {
|
|
return {
|
|
parentRunId: requiredString(process.env.GITHUB_RUN_ID, "parent run ID"),
|
|
releaseProfile: requiredString(process.env.RELEASE_PROFILE, "release profile"),
|
|
rerunGroup: requiredString(process.env.RERUN_GROUP, "rerun group"),
|
|
targetSha: stringValue(process.env.TARGET_SHA),
|
|
workflowSha: requiredString(process.env.GITHUB_SHA, "workflow SHA"),
|
|
workflowRef: requiredString(process.env.GITHUB_REF_NAME, "workflow ref"),
|
|
};
|
|
}
|
|
|
|
function evidenceReuseFromInputs(planInputs, sourceManifest = {}) {
|
|
return {
|
|
changedPaths: changedPathsValue(planInputs.evidenceChangedPaths),
|
|
evidenceSha: stringValue(planInputs.evidenceSha),
|
|
policy: stringValue(planInputs.evidencePolicy),
|
|
requested: planInputs.evidenceReuse === true || planInputs.evidenceReuse === "true",
|
|
rootRunId: stringValue(planInputs.evidenceRootRunId),
|
|
runUrl: stringValue(planInputs.evidenceRunUrl),
|
|
selectedRunId: stringValue(planInputs.evidenceRunId),
|
|
sourceManifest,
|
|
};
|
|
}
|
|
|
|
function trustedWorkflowFromInputs(planInputs) {
|
|
return planInputs.trustedWorkflow;
|
|
}
|
|
|
|
async function planMode() {
|
|
const outputPath = requiredString(
|
|
process.env.FULL_RELEASE_EXECUTION_PLAN_PATH,
|
|
"execution plan output path",
|
|
);
|
|
const expected = planExpected();
|
|
const currentAttempt = positiveInteger(process.env.GITHUB_RUN_ATTEMPT, "parent run attempt");
|
|
|
|
if (process.env.FULL_RELEASE_RESTORE_PLAN === "true") {
|
|
const restored = validateReleaseExecutionPlanArtifact(
|
|
readArtifact(outputPath, "execution plan"),
|
|
expected,
|
|
);
|
|
writeExecutionPlan(outputPath, restored);
|
|
return;
|
|
}
|
|
if (currentAttempt !== 1) {
|
|
throw new Error("collector retry omitted the immutable attempt-one execution plan");
|
|
}
|
|
|
|
const planInputs = parsePlanInputs(process.env.FULL_RELEASE_PLAN_INPUTS_JSON);
|
|
const built = buildReleaseExecutionPlan(planInputs);
|
|
const abortController = new AbortController();
|
|
let finished = false;
|
|
let plan = buildReleaseExecutionPlanArtifact({
|
|
children: built.children,
|
|
evidenceReuse: evidenceReuseFromInputs(planInputs),
|
|
expected: { ...expected, parentRunAttempt: currentAttempt },
|
|
gates: built.gates,
|
|
releaseProfile: expected.releaseProfile,
|
|
rerunGroup: expected.rerunGroup,
|
|
trustedWorkflow: trustedWorkflowFromInputs(planInputs),
|
|
});
|
|
const stop = () => {
|
|
if (finished) {
|
|
return;
|
|
}
|
|
abortController.abort(new Error("execution plan collection cancelled"));
|
|
plan = buildReleaseExecutionPlanArtifact({
|
|
blockers: plan.blockers,
|
|
children: plan.children,
|
|
errors: [
|
|
...plan.errors,
|
|
{
|
|
child: "<collector>",
|
|
kind: "collector_cancelled",
|
|
message: "execution plan collector received a termination signal",
|
|
},
|
|
],
|
|
evidenceReuse: plan.evidenceReuse,
|
|
expected: { ...expected, parentRunAttempt: currentAttempt },
|
|
gates: plan.gates,
|
|
releaseProfile: expected.releaseProfile,
|
|
rerunGroup: expected.rerunGroup,
|
|
trustedWorkflow: plan.trustedWorkflow,
|
|
});
|
|
writeExecutionPlan(outputPath, plan);
|
|
finished = true;
|
|
process.exit(1);
|
|
};
|
|
process.once("SIGINT", stop);
|
|
process.once("SIGTERM", stop);
|
|
|
|
const reuse = await validateReuse(built.children, planInputs, abortController.signal);
|
|
if (finished) {
|
|
return;
|
|
}
|
|
plan = buildReleaseExecutionPlanArtifact({
|
|
blockers: reuse.blockers,
|
|
children: reuse.children,
|
|
errors: reuse.errors,
|
|
evidenceReuse: evidenceReuseFromInputs(planInputs, reuse.sourceManifest),
|
|
expected: { ...expected, parentRunAttempt: currentAttempt },
|
|
gates: built.gates,
|
|
releaseProfile: expected.releaseProfile,
|
|
rerunGroup: expected.rerunGroup,
|
|
trustedWorkflow: trustedWorkflowFromInputs(planInputs),
|
|
});
|
|
writeExecutionPlan(outputPath, plan);
|
|
finished = true;
|
|
if ((reuse.blockers?.length ?? 0) > 0 || (reuse.errors?.length ?? 0) > 0) {
|
|
throw new Error("release execution plan could not bind reusable evidence");
|
|
}
|
|
}
|
|
|
|
async function collectMode(mode) {
|
|
const expected = {
|
|
parentRunAttempt: positiveInteger(process.env.GITHUB_RUN_ATTEMPT, "parent run attempt"),
|
|
parentRunId: requiredString(process.env.GITHUB_RUN_ID, "parent run ID"),
|
|
targetSha: stringValue(process.env.TARGET_SHA),
|
|
workflowRef: requiredString(process.env.GITHUB_REF_NAME, "workflow ref"),
|
|
workflowSha: requiredString(process.env.GITHUB_SHA, "workflow SHA"),
|
|
};
|
|
const releaseProfile = requiredString(process.env.RELEASE_PROFILE, "release profile");
|
|
const rerunGroup = requiredString(process.env.RERUN_GROUP, "rerun group");
|
|
const outputPath = requiredString(process.env.FULL_RELEASE_STATE_PATH, "state output path");
|
|
const executionPlan = validateReleaseExecutionPlanArtifact(
|
|
readArtifact(
|
|
requiredString(process.env.FULL_RELEASE_EXECUTION_PLAN_PATH, "execution plan path"),
|
|
"execution plan",
|
|
),
|
|
{
|
|
parentRunId: expected.parentRunId,
|
|
releaseProfile,
|
|
rerunGroup,
|
|
targetSha: expected.targetSha,
|
|
workflowSha: expected.workflowSha,
|
|
},
|
|
);
|
|
const plan = executionPlan.children;
|
|
const gateFailures = releasePlanGateFailures(executionPlan.gates);
|
|
const failFast = mode === "decision" && process.env.FAIL_FAST === "true";
|
|
const pollIntervalMs =
|
|
Number(process.env.FULL_RELEASE_POLL_INTERVAL_MS) || DEFAULT_POLL_INTERVAL_MS;
|
|
const heartbeatIntervalMs =
|
|
Number(process.env.FULL_RELEASE_HEARTBEAT_INTERVAL_MS) || DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
const cancelledRunIds = new Set();
|
|
let snapshots = plan.map((child) => ({
|
|
...child,
|
|
errors: [],
|
|
jobs: [],
|
|
status: child.selected && child.runId ? "queued" : child.selected ? "missing" : "skipped",
|
|
}));
|
|
let finished = false;
|
|
const abortController = new AbortController();
|
|
let decisionReuse = { blockers: [], children: plan, errors: [] };
|
|
|
|
const writePayload = (decision, cancellation = {}) => {
|
|
const payload = buildReleaseStateArtifact({
|
|
cancellation,
|
|
children: snapshots,
|
|
decision,
|
|
executionPlan,
|
|
expected,
|
|
mode,
|
|
releaseProfile,
|
|
rerunGroup,
|
|
});
|
|
writeResult(outputPath, payload);
|
|
appendSummary(mode, payload);
|
|
return payload;
|
|
};
|
|
const stop = () => {
|
|
if (finished) {
|
|
return;
|
|
}
|
|
abortController.abort(new Error(`${mode} collector cancelled`));
|
|
const decision = classifyReleaseSnapshot({
|
|
cancelled: true,
|
|
children: snapshots,
|
|
extraBlockers: executionPlan.blockers,
|
|
extraErrors: [
|
|
...executionPlan.errors,
|
|
{
|
|
child: "<collector>",
|
|
kind: "collector_cancelled",
|
|
message: `${mode} collector received a termination signal`,
|
|
},
|
|
],
|
|
localFailures: gateFailures,
|
|
releaseProfile,
|
|
workflowRef: expected.workflowRef,
|
|
});
|
|
writePayload(decision, { cancelledRunIds, requested: true });
|
|
finished = true;
|
|
process.exit(1);
|
|
};
|
|
process.once("SIGINT", stop);
|
|
process.once("SIGTERM", stop);
|
|
|
|
if (mode === "decision" && executionPlan.evidenceReuse.requested) {
|
|
decisionReuse = await validateReuse(
|
|
plan,
|
|
{
|
|
evidenceChangedPaths: executionPlan.evidenceReuse.changedPaths,
|
|
evidencePolicy: executionPlan.evidenceReuse.policy,
|
|
evidenceReuse: true,
|
|
evidenceRunId: executionPlan.evidenceReuse.selectedRunId,
|
|
evidenceRootRunId: executionPlan.evidenceReuse.rootRunId,
|
|
evidenceRunUrl: executionPlan.evidenceReuse.runUrl,
|
|
evidenceSha: executionPlan.evidenceReuse.evidenceSha,
|
|
trustedWorkflow: executionPlan.trustedWorkflow,
|
|
workflowSha: executionPlan.workflowSha,
|
|
},
|
|
abortController.signal,
|
|
);
|
|
const exactPlan = JSON.stringify(
|
|
plan.map(({ key, runAttempt, runId }) => ({ key, runAttempt, runId })),
|
|
);
|
|
const revalidatedPlan = JSON.stringify(
|
|
decisionReuse.children.map(({ key, runAttempt, runId }) => ({ key, runAttempt, runId })),
|
|
);
|
|
if (exactPlan !== revalidatedPlan) {
|
|
decisionReuse = {
|
|
...decisionReuse,
|
|
blockers: [
|
|
...decisionReuse.blockers,
|
|
{
|
|
child: "<evidence>",
|
|
kind: "provenance_mismatch",
|
|
message: "revalidated evidence child identities differ from the immutable plan",
|
|
runId: executionPlan.evidenceReuse.rootRunId,
|
|
url: executionPlan.evidenceReuse.runUrl,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
if (
|
|
JSON.stringify(canonicalJson(decisionReuse.sourceManifest)) !==
|
|
JSON.stringify(canonicalJson(executionPlan.evidenceReuse.sourceManifest))
|
|
) {
|
|
decisionReuse = {
|
|
...decisionReuse,
|
|
blockers: [
|
|
...decisionReuse.blockers,
|
|
{
|
|
child: "<evidence>",
|
|
kind: "provenance_mismatch",
|
|
message: "revalidated evidence source manifest differs from the immutable plan",
|
|
runId: executionPlan.evidenceReuse.rootRunId,
|
|
url: executionPlan.evidenceReuse.runUrl,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
}
|
|
|
|
let nextHeartbeat = 0;
|
|
while (!finished) {
|
|
snapshots = await Promise.all(
|
|
plan.map((child, index) => readChild(child, snapshots[index], abortController.signal)),
|
|
);
|
|
let decision = classifyReleaseSnapshot({
|
|
children: snapshots,
|
|
extraBlockers: [...executionPlan.blockers, ...decisionReuse.blockers],
|
|
extraErrors: [...executionPlan.errors, ...decisionReuse.errors],
|
|
localFailures: gateFailures,
|
|
releaseProfile,
|
|
workflowRef: expected.workflowRef,
|
|
});
|
|
if (Date.now() >= nextHeartbeat) {
|
|
console.log(formatReleaseStateHeartbeat(mode, decision));
|
|
nextHeartbeat = Date.now() + heartbeatIntervalMs;
|
|
}
|
|
if (failFast && decision.blockers.length > 0) {
|
|
const cancellationErrors = await cancelAffectedChildren(
|
|
snapshots,
|
|
decision.blockers,
|
|
cancelledRunIds,
|
|
abortController.signal,
|
|
);
|
|
if (cancellationErrors.length > 0) {
|
|
decision = classifyReleaseSnapshot({
|
|
children: snapshots,
|
|
extraBlockers: [
|
|
...executionPlan.blockers,
|
|
...decisionReuse.blockers,
|
|
...decision.blockers,
|
|
],
|
|
extraErrors: [...executionPlan.errors, ...decisionReuse.errors, ...cancellationErrors],
|
|
localFailures: gateFailures,
|
|
releaseProfile,
|
|
workflowRef: expected.workflowRef,
|
|
});
|
|
}
|
|
}
|
|
const done =
|
|
mode === "decision"
|
|
? decision.state !== "qualifying"
|
|
: decision.state === "orchestration_error" ||
|
|
(decision.state !== "qualifying" && decision.activeRunIds.length === 0);
|
|
if (done) {
|
|
const payload = writePayload(decision, { cancelledRunIds, requested: false });
|
|
finished = true;
|
|
process.exitCode =
|
|
payload.state === "passed" ? 0 : payload.state === "orchestration_error" ? 2 : 1;
|
|
return;
|
|
}
|
|
await abortableSleep(pollIntervalMs, abortController.signal);
|
|
}
|
|
}
|
|
|
|
function readStateCandidates(root, prefix, runId, maxParentRunAttempt, filename) {
|
|
const pattern = new RegExp(`^${prefix}-${runId}-([1-9][0-9]*)$`, "u");
|
|
const candidates = readdirSync(root, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => {
|
|
const match = pattern.exec(entry.name);
|
|
return match ? { attempt: Number(match[1]), name: entry.name } : undefined;
|
|
})
|
|
.filter(Boolean)
|
|
.filter((entry) => entry.attempt <= maxParentRunAttempt)
|
|
.map((entry) => {
|
|
const payload = readArtifact(join(root, entry.name, filename), entry.name);
|
|
if (Number(payload.parentRunAttempt) !== entry.attempt) {
|
|
throw new Error(`${entry.name} payload attempt differs from its artifact name`);
|
|
}
|
|
return { name: entry.name, payload };
|
|
});
|
|
const directPath = join(root, filename);
|
|
if (existsSync(directPath)) {
|
|
const payload = readArtifact(directPath, filename);
|
|
const attempt = positiveInteger(payload.parentRunAttempt, `${filename} parent run attempt`);
|
|
if (attempt <= maxParentRunAttempt) {
|
|
candidates.push({ name: `${prefix}-${runId}-${attempt}`, payload });
|
|
}
|
|
}
|
|
return candidates;
|
|
}
|
|
|
|
async function validateManifestMode() {
|
|
const manifestPath = requiredString(
|
|
process.env.RELEASE_VALIDATION_MANIFEST_PATH,
|
|
"release validation manifest path",
|
|
);
|
|
const executionPlan = validateReleaseExecutionPlanArtifact(
|
|
readArtifact(
|
|
requiredString(process.env.RELEASE_EXECUTION_PLAN_PATH, "execution plan path"),
|
|
"execution plan",
|
|
),
|
|
{
|
|
parentRunId: requiredString(process.env.GITHUB_RUN_ID, "parent run ID"),
|
|
releaseProfile: requiredString(process.env.RELEASE_PROFILE, "release profile"),
|
|
rerunGroup: requiredString(process.env.RERUN_GROUP, "rerun group"),
|
|
targetSha: requiredString(process.env.TARGET_SHA, "target SHA"),
|
|
workflowRef: requiredString(process.env.GITHUB_REF_NAME, "workflow ref"),
|
|
workflowSha: requiredString(process.env.GITHUB_SHA, "workflow SHA"),
|
|
},
|
|
);
|
|
const rawManifest = readArtifact(manifestPath, "release validation manifest");
|
|
const { validateParentManifest } = await import("./release-ci-summary.mjs");
|
|
const manifest = validateParentManifest(rawManifest, {
|
|
runAttempt: positiveInteger(process.env.GITHUB_RUN_ATTEMPT, "parent run attempt"),
|
|
runId: executionPlan.parentRunId,
|
|
workflowRef: executionPlan.workflowRef,
|
|
workflowSha: executionPlan.workflowSha,
|
|
});
|
|
const expectedChildRunIds = Object.fromEntries(
|
|
["normalCi", "npmTelegram", "pluginPrerelease", "productPerformance", "releaseChecks"].map(
|
|
(key) => {
|
|
const child = executionPlan.children.find((entry) => entry.key === key);
|
|
return [key, child?.selected ? stringValue(child.runId) : ""];
|
|
},
|
|
),
|
|
);
|
|
const expectedEvidenceReuse = executionPlan.evidenceReuse.requested
|
|
? {
|
|
changedPaths: executionPlan.evidenceReuse.changedPaths,
|
|
evidenceSha: executionPlan.evidenceReuse.evidenceSha,
|
|
policy: executionPlan.evidenceReuse.policy,
|
|
runId: executionPlan.evidenceReuse.rootRunId,
|
|
selectedRunId: executionPlan.evidenceReuse.selectedRunId,
|
|
}
|
|
: undefined;
|
|
if (
|
|
manifest.targetSha !== executionPlan.targetSha ||
|
|
manifest.releaseProfile !== executionPlan.releaseProfile ||
|
|
manifest.rerunGroup !== executionPlan.rerunGroup ||
|
|
JSON.stringify(canonicalJson(manifest.childRunIds)) !==
|
|
JSON.stringify(canonicalJson(expectedChildRunIds)) ||
|
|
JSON.stringify(canonicalJson(manifest.evidenceReuse)) !==
|
|
JSON.stringify(canonicalJson(expectedEvidenceReuse)) ||
|
|
rawManifest.executionPlanSha256 !== executionPlan.sha256 ||
|
|
Number(rawManifest.sourceParentRunAttempt) !== executionPlan.parentRunAttempt
|
|
) {
|
|
throw new Error("release validation manifest differs from the immutable execution plan");
|
|
}
|
|
}
|
|
|
|
function selectMode() {
|
|
const expected = {
|
|
maxParentRunAttempt: positiveInteger(
|
|
process.env.GITHUB_RUN_ATTEMPT,
|
|
"current parent run attempt",
|
|
),
|
|
parentRunId: requiredString(process.env.GITHUB_RUN_ID, "parent run ID"),
|
|
releaseProfile: requiredString(process.env.RELEASE_PROFILE, "release profile"),
|
|
rerunGroup: requiredString(process.env.RERUN_GROUP, "rerun group"),
|
|
targetSha: requiredString(process.env.TARGET_SHA, "target SHA"),
|
|
workflowRef: requiredString(process.env.GITHUB_REF_NAME, "workflow ref"),
|
|
workflowSha: requiredString(process.env.GITHUB_SHA, "workflow SHA"),
|
|
};
|
|
const selected = selectReleaseStateArtifacts(
|
|
readArtifact(
|
|
requiredString(process.env.RELEASE_EXECUTION_PLAN_PATH, "execution plan path"),
|
|
"execution plan",
|
|
),
|
|
readStateCandidates(
|
|
requiredString(process.env.RELEASE_DECISION_ATTEMPTS_PATH, "decision attempts path"),
|
|
"full-release-decision",
|
|
expected.parentRunId,
|
|
expected.maxParentRunAttempt,
|
|
"full-release-decision.json",
|
|
),
|
|
readStateCandidates(
|
|
requiredString(process.env.DIAGNOSTIC_DRAIN_ATTEMPTS_PATH, "drain attempts path"),
|
|
"full-release-diagnostics",
|
|
expected.parentRunId,
|
|
expected.maxParentRunAttempt,
|
|
"full-release-diagnostic-manifest.json",
|
|
),
|
|
expected,
|
|
);
|
|
writeArtifact(
|
|
requiredString(process.env.RELEASE_DECISION_PATH, "selected decision path"),
|
|
selected.decision,
|
|
);
|
|
writeArtifact(
|
|
requiredString(process.env.DIAGNOSTIC_DRAIN_PATH, "selected drain path"),
|
|
selected.drain,
|
|
);
|
|
if (process.env.GITHUB_OUTPUT) {
|
|
appendFileSync(
|
|
process.env.GITHUB_OUTPUT,
|
|
`decision_source_attempt=${selected.sourceAttempts.decision}\n`,
|
|
);
|
|
appendFileSync(
|
|
process.env.GITHUB_OUTPUT,
|
|
`drain_source_attempt=${selected.sourceAttempts.drain}\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const mode = process.argv[2];
|
|
if (mode === "plan") {
|
|
await planMode();
|
|
return;
|
|
}
|
|
if (mode === "verify") {
|
|
verifyMode();
|
|
return;
|
|
}
|
|
if (mode === "select") {
|
|
selectMode();
|
|
return;
|
|
}
|
|
if (mode === "validate-manifest") {
|
|
await validateManifestMode();
|
|
return;
|
|
}
|
|
if (!["decision", "drain"].includes(mode)) {
|
|
throw new Error(
|
|
"usage: full-release-validation-state.mjs <plan|decision|drain|select|validate-manifest|verify>",
|
|
);
|
|
}
|
|
await collectMode(mode);
|
|
}
|
|
|
|
if (process.argv[1]?.endsWith("full-release-validation-state.mjs")) {
|
|
try {
|
|
await main();
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exit(2);
|
|
}
|
|
}
|