mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-14 06:06:08 +00:00
1834 lines
62 KiB
JavaScript
Executable File
1834 lines
62 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Release CI summary helper that prints parent and child workflow status for a
|
|
* full release run.
|
|
*/
|
|
import { execFileSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import process from "node:process";
|
|
import { fileURLToPath } from "node:url";
|
|
import { plainGhEnv, resolvePlainGhBin } from "./lib/plain-gh.mjs";
|
|
|
|
const DEFAULT_REPO = process.env.OPENCLAW_RELEASE_REPO || "openclaw/openclaw";
|
|
const RELEASE_EVIDENCE_SCHEMA = "openclaw.release-validation-evidence/v3";
|
|
const SHA_PINNED_BRANCH_PATTERN = /^release-ci\/[a-f0-9]{12}-[1-9][0-9]*$/u;
|
|
const RELEASE_EVIDENCE_SCRIPT = "scripts/release-ci-summary.mjs";
|
|
const RELEASE_EVIDENCE_FILE = fileURLToPath(import.meta.url);
|
|
const RELEASE_EVIDENCE_REPO_ROOT = resolve(dirname(RELEASE_EVIDENCE_FILE), "..");
|
|
const MANIFEST_ARTIFACT_ENTRY = "full-release-validation-manifest.json";
|
|
const MAX_MANIFEST_ARTIFACT_ZIP_BYTES = 256 * 1024;
|
|
const MAX_MANIFEST_JSON_BYTES = 128 * 1024;
|
|
const MAX_MANIFEST_ENTRY_LIST_BYTES = 8 * 1024;
|
|
|
|
const CHILD_DISPATCHES = [
|
|
{
|
|
manifestKey: "normalCi",
|
|
name: "CI",
|
|
parentJobName: "Run normal full CI",
|
|
suffix: "-ci",
|
|
trustedRef: "parent",
|
|
workflow: "ci.yml",
|
|
},
|
|
{
|
|
manifestKey: "releaseChecks",
|
|
name: "OpenClaw Release Checks",
|
|
parentJobName: "Run release/live/Docker/QA validation",
|
|
suffix: "-release-checks",
|
|
trustedRef: "parent",
|
|
workflow: "openclaw-release-checks.yml",
|
|
},
|
|
{
|
|
manifestKey: "pluginPrerelease",
|
|
name: "Plugin Prerelease",
|
|
parentJobName: "Run plugin prerelease validation",
|
|
suffix: "-plugin-prerelease",
|
|
trustedRef: "parent",
|
|
workflow: "plugin-prerelease.yml",
|
|
},
|
|
{
|
|
manifestKey: "npmTelegram",
|
|
name: "NPM Telegram Beta E2E",
|
|
parentJobName: "Run package Telegram E2E",
|
|
suffix: "-npm-telegram",
|
|
trustedRef: "parent",
|
|
workflow: "npm-telegram-beta-e2e.yml",
|
|
},
|
|
{
|
|
manifestKey: "productPerformance",
|
|
name: "OpenClaw Performance",
|
|
parentJobName: "Run product performance evidence",
|
|
suffix: "",
|
|
trustedRef: "parent",
|
|
workflow: "openclaw-performance.yml",
|
|
},
|
|
];
|
|
|
|
const EXACT_TARGET_EVIDENCE_REUSE_POLICY = "exact-target-full-validation-v1";
|
|
const CHANGELOG_ONLY_EVIDENCE_REUSE_POLICY = "changelog-only-release-v1";
|
|
const EVIDENCE_REUSE_POLICIES = new Set([
|
|
EXACT_TARGET_EVIDENCE_REUSE_POLICY,
|
|
CHANGELOG_ONLY_EVIDENCE_REUSE_POLICY,
|
|
]);
|
|
|
|
const RERUN_GROUP_CHILD_KEYS = new Map([
|
|
["all", ["normalCi", "releaseChecks", "pluginPrerelease", "productPerformance"]],
|
|
["ci", ["normalCi"]],
|
|
["plugin-prerelease", ["pluginPrerelease"]],
|
|
["release-checks", ["releaseChecks"]],
|
|
["install-smoke", ["releaseChecks"]],
|
|
["cross-os", ["releaseChecks"]],
|
|
["live-e2e", ["releaseChecks"]],
|
|
["package", ["releaseChecks"]],
|
|
["qa", ["releaseChecks"]],
|
|
["qa-parity", ["releaseChecks"]],
|
|
["qa-live", ["releaseChecks"]],
|
|
["npm-telegram", ["npmTelegram"]],
|
|
["performance", ["productPerformance"]],
|
|
]);
|
|
|
|
function gh(args) {
|
|
return execFileSync(resolvePlainGhBin(), args, {
|
|
encoding: "utf8",
|
|
env: plainGhEnv(),
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
}
|
|
|
|
function jsonGh(args) {
|
|
return JSON.parse(gh(args));
|
|
}
|
|
|
|
function githubRestJson(pathSuffix, repository = DEFAULT_REPO) {
|
|
const result = execFileSync(
|
|
"bash",
|
|
[
|
|
"-lc",
|
|
[
|
|
"set -euo pipefail",
|
|
'token="$("$OPENCLAW_PLAIN_GH_BIN" auth token)"',
|
|
'curl -fsS -H "Authorization: Bearer ${token}" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "${OPENCLAW_GITHUB_REST_URL}"',
|
|
].join("\n"),
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env: {
|
|
...plainGhEnv(),
|
|
OPENCLAW_PLAIN_GH_BIN: resolvePlainGhBin(),
|
|
OPENCLAW_GITHUB_REST_URL: `https://api.github.com/repos/${repository}/${pathSuffix}`,
|
|
},
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
);
|
|
return JSON.parse(result);
|
|
}
|
|
|
|
function downloadArtifactZip(artifactId, destination, repository = DEFAULT_REPO) {
|
|
execFileSync(
|
|
"bash",
|
|
[
|
|
"-lc",
|
|
[
|
|
"set -euo pipefail",
|
|
'token="$("$OPENCLAW_PLAIN_GH_BIN" auth token)"',
|
|
'curl -fsSL -H "Authorization: Bearer ${token}" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" --output "$OPENCLAW_GITHUB_ARTIFACT_DESTINATION" "$OPENCLAW_GITHUB_ARTIFACT_URL"',
|
|
].join("\n"),
|
|
],
|
|
{
|
|
env: {
|
|
...plainGhEnv(),
|
|
OPENCLAW_GITHUB_ARTIFACT_DESTINATION: destination,
|
|
OPENCLAW_GITHUB_ARTIFACT_URL: `https://api.github.com/repos/${repository}/actions/artifacts/${artifactId}/zip`,
|
|
OPENCLAW_PLAIN_GH_BIN: resolvePlainGhBin(),
|
|
},
|
|
stdio: ["ignore", "ignore", "pipe"],
|
|
},
|
|
);
|
|
}
|
|
|
|
function rate() {
|
|
try {
|
|
return jsonGh(["api", "rate_limit"]).resources.core;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function validateParentRunBinding(parentView, parentRest, expectedRunId) {
|
|
const boundWorkflowPath = String(parentRest.path ?? "").split("@", 1)[0];
|
|
if (
|
|
String(parentRest.id) !== String(expectedRunId) ||
|
|
parentRest.event !== "workflow_dispatch" ||
|
|
boundWorkflowPath !== ".github/workflows/full-release-validation.yml" ||
|
|
Number(parentRest.run_attempt) !== Number(parentView.attempt) ||
|
|
parentRest.head_branch !== parentView.headBranch ||
|
|
parentRest.head_sha !== parentView.headSha
|
|
) {
|
|
throw new Error(`full release parent run binding mismatch: ${expectedRunId}`);
|
|
}
|
|
return parentRest;
|
|
}
|
|
|
|
export function expectedChildDispatches(parentRunId, parentRunAttempt, parentWorkflowRef) {
|
|
if (!/^[1-9][0-9]*$/u.test(String(parentRunId))) {
|
|
throw new Error("parent run ID must be a positive decimal");
|
|
}
|
|
if (!Number.isSafeInteger(parentRunAttempt) || parentRunAttempt < 1) {
|
|
throw new Error("parent run attempt must be a positive integer");
|
|
}
|
|
if (typeof parentWorkflowRef !== "string" || parentWorkflowRef.length === 0) {
|
|
throw new Error("parent workflow ref is required");
|
|
}
|
|
const dispatchPrefix = `full-release-validation-${parentRunId}-${parentRunAttempt}`;
|
|
return CHILD_DISPATCHES.map((child) => ({
|
|
...child,
|
|
displayTitle: `${child.name} ${dispatchPrefix}${child.suffix}`,
|
|
headBranch: child.trustedRef === "main" ? "main" : parentWorkflowRef,
|
|
}));
|
|
}
|
|
|
|
export function requiredChildKeysForRerunGroup(rerunGroup) {
|
|
const childKeys = RERUN_GROUP_CHILD_KEYS.get(rerunGroup);
|
|
if (!childKeys) {
|
|
throw new Error(`release validation manifest rerun group is invalid: ${rerunGroup}`);
|
|
}
|
|
return new Set(childKeys);
|
|
}
|
|
|
|
export function expectedSelectedChildDispatches(
|
|
parentRunId,
|
|
parentRunAttempt,
|
|
parentWorkflowRef,
|
|
selectedKeys,
|
|
) {
|
|
return expectedChildDispatches(parentRunId, parentRunAttempt, parentWorkflowRef).filter((child) =>
|
|
selectedKeys.has(child.manifestKey),
|
|
);
|
|
}
|
|
|
|
export function selectExactChildRun(runs, expectedDisplayTitle, expectedHeadBranch) {
|
|
const matches = runs.filter(
|
|
(run) =>
|
|
run.event === "workflow_dispatch" &&
|
|
run.display_title === expectedDisplayTitle &&
|
|
run.head_branch === expectedHeadBranch,
|
|
);
|
|
if (matches.length > 1) {
|
|
throw new Error(
|
|
`multiple child runs have exact dispatch title and branch: ${expectedDisplayTitle} (${expectedHeadBranch})`,
|
|
);
|
|
}
|
|
return matches[0];
|
|
}
|
|
|
|
export function selectExactChildRunFromPages(runPages, expectedDisplayTitle, expectedHeadBranch) {
|
|
let exactMatch;
|
|
for (const runs of runPages) {
|
|
const match = selectExactChildRun(runs, expectedDisplayTitle, expectedHeadBranch);
|
|
if (match) {
|
|
if (exactMatch) {
|
|
throw new Error(
|
|
`multiple child runs have exact dispatch title and branch: ${expectedDisplayTitle} (${expectedHeadBranch})`,
|
|
);
|
|
}
|
|
exactMatch = match;
|
|
}
|
|
if (runs.length < 100) {
|
|
break;
|
|
}
|
|
}
|
|
return exactMatch;
|
|
}
|
|
|
|
function findExactChildRun(child, repository = DEFAULT_REPO) {
|
|
const runPages = [];
|
|
for (let page = 1; page <= 10; page += 1) {
|
|
const query = new URLSearchParams({
|
|
event: "workflow_dispatch",
|
|
branch: child.headBranch,
|
|
page: String(page),
|
|
per_page: "100",
|
|
});
|
|
const runs =
|
|
githubRestJson(`actions/workflows/${child.workflow}/runs?${query.toString()}`, repository)
|
|
.workflow_runs ?? [];
|
|
runPages.push(runs);
|
|
if (runs.length < 100) {
|
|
break;
|
|
}
|
|
}
|
|
return selectExactChildRunFromPages(runPages, child.displayTitle, child.headBranch);
|
|
}
|
|
|
|
function findParentJobsAll(parentRunId, repository = DEFAULT_REPO) {
|
|
const jobs = [];
|
|
for (let page = 1; page <= 10; page += 1) {
|
|
const query = new URLSearchParams({
|
|
filter: "all",
|
|
page: String(page),
|
|
per_page: "100",
|
|
});
|
|
const pageJobs =
|
|
githubRestJson(`actions/runs/${parentRunId}/jobs?${query.toString()}`, repository).jobs ?? [];
|
|
jobs.push(...pageJobs);
|
|
if (pageJobs.length < 100) {
|
|
break;
|
|
}
|
|
}
|
|
return jobs;
|
|
}
|
|
|
|
function parentJobLog(jobId, repository = DEFAULT_REPO) {
|
|
return gh(["api", `repos/${repository}/actions/jobs/${jobId}/logs`]);
|
|
}
|
|
|
|
function normalizeOptionalRunId(value, label) {
|
|
if (value === "") {
|
|
return "";
|
|
}
|
|
if (!/^[1-9][0-9]*$/u.test(String(value))) {
|
|
throw new Error(`${label} must be empty or a positive decimal run ID`);
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
function normalizeRequiredRunId(value, label) {
|
|
const runId = normalizeOptionalRunId(value, label);
|
|
if (!runId) {
|
|
throw new Error(`${label} is required`);
|
|
}
|
|
return runId;
|
|
}
|
|
|
|
function normalizeRepository(value) {
|
|
const repository = String(value ?? "");
|
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)) {
|
|
throw new Error("repository must use the owner/name form");
|
|
}
|
|
return repository;
|
|
}
|
|
|
|
function normalizeWorkflowRef(value, label) {
|
|
const workflowRef = String(value ?? "");
|
|
const hasForbiddenCharacter = Array.from(workflowRef).some((character) => {
|
|
const codePoint = character.codePointAt(0) ?? 0;
|
|
return (
|
|
codePoint <= 0x1f ||
|
|
codePoint === 0x7f ||
|
|
character.trim() === "" ||
|
|
"~^:?*[\\".includes(character)
|
|
);
|
|
});
|
|
if (workflowRef.length === 0 || workflowRef.length > 255 || hasForbiddenCharacter) {
|
|
throw new Error(`${label} is invalid`);
|
|
}
|
|
return workflowRef;
|
|
}
|
|
|
|
function normalizeSha(value, label) {
|
|
const sha = String(value ?? "");
|
|
if (!/^[a-f0-9]{40}$/u.test(sha)) {
|
|
throw new Error(`${label} is invalid`);
|
|
}
|
|
return sha;
|
|
}
|
|
|
|
function normalizePositiveInteger(value, label) {
|
|
const number = Number(value);
|
|
if (!Number.isSafeInteger(number) || number < 1) {
|
|
throw new Error(`${label} must be a positive integer`);
|
|
}
|
|
return number;
|
|
}
|
|
|
|
function normalizeJsonObject(value, label) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new Error(`${label} is invalid`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function manifestEvidenceIdentity(manifest) {
|
|
return canonicalJson({
|
|
childRunIds: manifest.childRunIds,
|
|
controls: manifest.controls,
|
|
releaseProfile: manifest.releaseProfile,
|
|
rerunGroup: manifest.rerunGroup,
|
|
runReleaseSoak: manifest.runReleaseSoak,
|
|
validationInputs: manifest.validationInputs,
|
|
});
|
|
}
|
|
|
|
export function validateParentManifest(value, expected) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new Error("release validation manifest must be an object");
|
|
}
|
|
if (![2, 3].includes(value.version) || value.workflowName !== "Full Release Validation") {
|
|
throw new Error("release validation manifest schema is unsupported");
|
|
}
|
|
if (String(value.runId) !== String(expected.runId)) {
|
|
throw new Error("release validation manifest run ID mismatch");
|
|
}
|
|
if (
|
|
!/^[1-9][0-9]*$/u.test(String(value.runAttempt)) ||
|
|
(expected.runAttempt !== undefined && Number(value.runAttempt) !== Number(expected.runAttempt))
|
|
) {
|
|
throw new Error("release validation manifest run attempt mismatch");
|
|
}
|
|
const targetSha = normalizeSha(value.targetSha, "release validation manifest target SHA");
|
|
if (typeof value.workflowRef !== "string" || value.workflowRef.length === 0) {
|
|
throw new Error("release validation manifest workflow ref is invalid");
|
|
}
|
|
if (expected.workflowRef !== undefined && value.workflowRef !== expected.workflowRef) {
|
|
throw new Error("release validation manifest workflow ref mismatch");
|
|
}
|
|
let workflowSha;
|
|
let workflowFullRef;
|
|
let workflowRefType;
|
|
if (value.version === 3) {
|
|
workflowSha = normalizeSha(value.workflowSha, "release validation manifest workflow SHA");
|
|
if (expected.workflowSha !== undefined && workflowSha !== expected.workflowSha) {
|
|
throw new Error("release validation manifest workflow SHA mismatch");
|
|
}
|
|
workflowFullRef = String(value.workflowFullRef ?? "");
|
|
workflowRefType = String(value.workflowRefType ?? "");
|
|
if (
|
|
!["branch", "tag"].includes(workflowRefType) ||
|
|
workflowFullRef !==
|
|
`refs/${workflowRefType === "branch" ? "heads" : "tags"}/${value.workflowRef}`
|
|
) {
|
|
throw new Error("release validation manifest workflow full ref is invalid");
|
|
}
|
|
} else if (expected.workflowSha !== undefined) {
|
|
workflowSha = normalizeSha(expected.workflowSha, "release validation workflow SHA");
|
|
}
|
|
const rerunGroup = String(value.rerunGroup ?? "");
|
|
requiredChildKeysForRerunGroup(rerunGroup);
|
|
const releaseProfile = String(value.releaseProfile ?? "");
|
|
if (!["beta", "stable", "full"].includes(releaseProfile)) {
|
|
throw new Error("release validation manifest release profile is invalid");
|
|
}
|
|
const runReleaseSoak = String(value.runReleaseSoak ?? "");
|
|
if (!["true", "false"].includes(runReleaseSoak)) {
|
|
throw new Error("release validation manifest release soak value is invalid");
|
|
}
|
|
const controls = normalizeJsonObject(value.controls, "release validation manifest controls");
|
|
if (value.version === 3 && controls.performanceReportPublication !== "artifact-only") {
|
|
throw new Error("release validation manifest performance report publication mode is invalid");
|
|
}
|
|
const validationInputs =
|
|
value.validationInputs === undefined
|
|
? undefined
|
|
: normalizeJsonObject(
|
|
value.validationInputs,
|
|
"release validation manifest validation inputs",
|
|
);
|
|
const childRuns = value.childRuns;
|
|
if (!childRuns || typeof childRuns !== "object" || Array.isArray(childRuns)) {
|
|
throw new Error("release validation manifest childRuns is invalid");
|
|
}
|
|
const childRunIds = {
|
|
normalCi: normalizeOptionalRunId(childRuns.normalCi, "normal CI run ID"),
|
|
npmTelegram: normalizeOptionalRunId(childRuns.npmTelegram, "npm Telegram run ID"),
|
|
pluginPrerelease: normalizeOptionalRunId(
|
|
childRuns.pluginPrerelease,
|
|
"plugin prerelease run ID",
|
|
),
|
|
productPerformance: normalizeOptionalRunId(
|
|
childRuns.productPerformance?.runId ?? "",
|
|
"performance run ID",
|
|
),
|
|
releaseChecks: normalizeOptionalRunId(childRuns.releaseChecks, "release checks run ID"),
|
|
};
|
|
let evidenceReuse;
|
|
if (value.evidenceReuse !== undefined) {
|
|
const reuse = normalizeJsonObject(
|
|
value.evidenceReuse,
|
|
"release validation manifest evidence reuse",
|
|
);
|
|
if (!EVIDENCE_REUSE_POLICIES.has(reuse.policy)) {
|
|
throw new Error("release validation manifest evidence reuse policy is invalid");
|
|
}
|
|
if (!/^[a-f0-9]{40}$/u.test(String(reuse.evidenceSha))) {
|
|
throw new Error("release validation manifest evidence SHA is invalid");
|
|
}
|
|
if (
|
|
!Array.isArray(reuse.changedPaths) ||
|
|
reuse.changedPaths.some(
|
|
(changedPath) => typeof changedPath !== "string" || changedPath.length === 0,
|
|
) ||
|
|
new Set(reuse.changedPaths).size !== reuse.changedPaths.length
|
|
) {
|
|
throw new Error("release validation manifest evidence changed paths are invalid");
|
|
}
|
|
evidenceReuse = {
|
|
changedPaths: reuse.changedPaths,
|
|
evidenceSha: String(reuse.evidenceSha),
|
|
policy: reuse.policy,
|
|
runId: normalizeRequiredRunId(reuse.runId, "evidence reuse root run ID"),
|
|
selectedRunId: normalizeRequiredRunId(reuse.selectedRunId, "evidence reuse selected run ID"),
|
|
};
|
|
}
|
|
return {
|
|
childRunIds,
|
|
controls,
|
|
evidenceReuse,
|
|
releaseProfile,
|
|
rerunGroup,
|
|
runAttempt: Number(value.runAttempt),
|
|
runId: String(value.runId),
|
|
runReleaseSoak,
|
|
targetRef: String(value.targetRef ?? ""),
|
|
targetSha,
|
|
validationInputs,
|
|
version: value.version,
|
|
workflowFullRef,
|
|
workflowSha,
|
|
workflowRef: value.workflowRef,
|
|
workflowRefType,
|
|
};
|
|
}
|
|
|
|
export function validateEvidenceReuseChain(
|
|
currentManifest,
|
|
selectedManifest,
|
|
rootManifest,
|
|
compareCommits,
|
|
) {
|
|
const reuse = currentManifest.evidenceReuse;
|
|
if (!reuse) {
|
|
throw new Error("release validation manifest does not authorize evidence reuse");
|
|
}
|
|
if (rootManifest.evidenceReuse || selectedManifest.evidenceReuse) {
|
|
throw new Error("evidence reuse must select a root execution manifest");
|
|
}
|
|
if (
|
|
!currentManifest.validationInputs ||
|
|
!selectedManifest.validationInputs ||
|
|
!rootManifest.validationInputs
|
|
) {
|
|
throw new Error("evidence reuse manifests must record validation inputs");
|
|
}
|
|
if (rootManifest.runId !== reuse.runId) {
|
|
throw new Error("evidence reuse root manifest run ID mismatch");
|
|
}
|
|
if (selectedManifest.runId !== reuse.selectedRunId) {
|
|
throw new Error("evidence reuse selected manifest run ID mismatch");
|
|
}
|
|
if (selectedManifest.targetSha !== reuse.evidenceSha) {
|
|
throw new Error("evidence reuse selected manifest SHA mismatch");
|
|
}
|
|
if (rootManifest.targetSha !== reuse.evidenceSha) {
|
|
throw new Error("full release evidence reuse root SHA mismatch");
|
|
}
|
|
if (selectedManifest.runId !== rootManifest.runId) {
|
|
throw new Error("evidence reuse selected manifest is not the chain root");
|
|
}
|
|
if (reuse.policy === EXACT_TARGET_EVIDENCE_REUSE_POLICY) {
|
|
if (reuse.changedPaths.length !== 0 || currentManifest.targetSha !== reuse.evidenceSha) {
|
|
throw new Error("exact-target release evidence reuse requires no changed paths");
|
|
}
|
|
} else if (reuse.policy === CHANGELOG_ONLY_EVIDENCE_REUSE_POLICY) {
|
|
if (
|
|
reuse.changedPaths.length !== 1 ||
|
|
reuse.changedPaths[0] !== "CHANGELOG.md" ||
|
|
currentManifest.targetSha === reuse.evidenceSha
|
|
) {
|
|
throw new Error("changelog-only release evidence reuse has an invalid target delta");
|
|
}
|
|
if (typeof compareCommits !== "function") {
|
|
throw new Error("changelog-only release evidence reuse requires commit comparison");
|
|
}
|
|
const comparison = compareCommits(reuse.evidenceSha, currentManifest.targetSha);
|
|
const changedFiles = Array.isArray(comparison?.files) ? comparison.files : [];
|
|
const changelog = changedFiles[0];
|
|
if (
|
|
comparison?.status !== "ahead" ||
|
|
comparison?.merge_base_commit?.sha !== reuse.evidenceSha ||
|
|
changedFiles.length !== 1 ||
|
|
changelog?.filename !== "CHANGELOG.md" ||
|
|
changelog?.status !== "modified" ||
|
|
changelog?.previous_filename
|
|
) {
|
|
throw new Error("changelog-only release evidence reuse failed commit comparison");
|
|
}
|
|
} else {
|
|
throw new Error("release validation manifest evidence reuse policy is invalid");
|
|
}
|
|
|
|
const rootIdentity = JSON.stringify(manifestEvidenceIdentity(rootManifest));
|
|
for (const [label, manifest] of [
|
|
["selected", selectedManifest],
|
|
["current", currentManifest],
|
|
]) {
|
|
if (JSON.stringify(manifestEvidenceIdentity(manifest)) !== rootIdentity) {
|
|
throw new Error(`evidence reuse ${label} manifest policy differs from the chain root`);
|
|
}
|
|
}
|
|
return rootManifest.targetSha;
|
|
}
|
|
|
|
export function selectedChildKeys(parentJobs) {
|
|
return new Set(
|
|
CHILD_DISPATCHES.filter((child) => {
|
|
const parentJob = parentJobs.find((job) => job.name === child.parentJobName);
|
|
return parentJob && parentJob.conclusion !== "skipped";
|
|
}).map((child) => child.manifestKey),
|
|
);
|
|
}
|
|
|
|
export function manifestChildEntries(manifest, children, selectedKeys) {
|
|
return children.flatMap((child) => {
|
|
const runId = manifest.childRunIds[child.manifestKey];
|
|
if (!runId) {
|
|
if (selectedKeys.has(child.manifestKey)) {
|
|
throw new Error(`selected child is missing from manifest: ${child.name}`);
|
|
}
|
|
return [];
|
|
}
|
|
return [{ child, runId }];
|
|
});
|
|
}
|
|
|
|
function childDispatchAttempt(displayTitle, child, parentRunId, parentRunAttempt) {
|
|
const prefix = `${child.name} full-release-validation-${parentRunId}-`;
|
|
if (!displayTitle.startsWith(prefix) || !displayTitle.endsWith(child.suffix)) {
|
|
return undefined;
|
|
}
|
|
const attemptEnd = child.suffix ? -child.suffix.length : undefined;
|
|
const attemptText = displayTitle.slice(prefix.length, attemptEnd);
|
|
if (!/^[1-9][0-9]*$/u.test(attemptText)) {
|
|
return undefined;
|
|
}
|
|
const attempt = Number(attemptText);
|
|
if (!Number.isSafeInteger(attempt) || attempt > parentRunAttempt) {
|
|
return undefined;
|
|
}
|
|
return attempt;
|
|
}
|
|
|
|
function parentJobExecutionFingerprint(job) {
|
|
return canonicalJson({
|
|
completedAt: job.completed_at,
|
|
conclusion: job.conclusion,
|
|
name: job.name,
|
|
startedAt: job.started_at,
|
|
status: job.status,
|
|
steps: (job.steps ?? []).map((step) => ({
|
|
completedAt: step.completed_at,
|
|
conclusion: step.conclusion,
|
|
name: step.name,
|
|
number: step.number,
|
|
startedAt: step.started_at,
|
|
status: step.status,
|
|
})),
|
|
});
|
|
}
|
|
|
|
function selectedAttemptParentJob(parentJobs, child, parentManifest) {
|
|
const slotJobs = parentJobs.filter((job) => job.name === child.parentJobName);
|
|
if (slotJobs.length === 0) {
|
|
throw new Error(`manifest parent job is missing: ${child.name}`);
|
|
}
|
|
const latestAttempt = Math.max(...slotJobs.map((job) => Number(job.run_attempt)));
|
|
if (latestAttempt !== parentManifest.runAttempt) {
|
|
throw new Error(`manifest parent job latest attempt mismatch: ${child.name}`);
|
|
}
|
|
const currentJobs = slotJobs.filter(
|
|
(job) => Number(job.run_attempt) === parentManifest.runAttempt,
|
|
);
|
|
if (currentJobs.length !== 1) {
|
|
throw new Error(`manifest parent job is not unique at the selected attempt: ${child.name}`);
|
|
}
|
|
const currentJob = currentJobs[0];
|
|
if (currentJob.status !== "completed" || currentJob.conclusion !== "success") {
|
|
throw new Error(`manifest parent job is not completed/success: ${child.name}`);
|
|
}
|
|
return { currentJob, slotJobs };
|
|
}
|
|
|
|
export function resolveManifestChildOriginAttempt(run, child, parentManifest, parentJobs) {
|
|
const correlatedAttempt = childDispatchAttempt(
|
|
String(run.display_title ?? ""),
|
|
child,
|
|
parentManifest.runId,
|
|
parentManifest.runAttempt,
|
|
);
|
|
if (correlatedAttempt !== undefined) {
|
|
return correlatedAttempt;
|
|
}
|
|
if (run.display_title !== child.name) {
|
|
return undefined;
|
|
}
|
|
|
|
const { currentJob, slotJobs } = selectedAttemptParentJob(parentJobs, child, parentManifest);
|
|
const currentFingerprint = JSON.stringify(parentJobExecutionFingerprint(currentJob));
|
|
const carriedOriginAttempts = slotJobs
|
|
.filter(
|
|
(job) =>
|
|
Number(job.run_attempt) < parentManifest.runAttempt &&
|
|
job.status === "completed" &&
|
|
job.conclusion === "success" &&
|
|
JSON.stringify(parentJobExecutionFingerprint(job)) === currentFingerprint,
|
|
)
|
|
.map((job) => Number(job.run_attempt));
|
|
return carriedOriginAttempts.length > 0
|
|
? Math.min(...carriedOriginAttempts)
|
|
: parentManifest.runAttempt;
|
|
}
|
|
|
|
export function selectManifestParentJob(parentJobs, child, parentManifest, originAttempt) {
|
|
const { currentJob, slotJobs } = selectedAttemptParentJob(parentJobs, child, parentManifest);
|
|
if (originAttempt === parentManifest.runAttempt) {
|
|
return currentJob;
|
|
}
|
|
|
|
const originJobs = slotJobs.filter((job) => Number(job.run_attempt) === originAttempt);
|
|
if (originJobs.length !== 1) {
|
|
throw new Error(`manifest parent job origin is not unique: ${child.name}`);
|
|
}
|
|
const originJob = originJobs[0];
|
|
if (originJob.status !== "completed" || originJob.conclusion !== "success") {
|
|
throw new Error(`manifest parent job origin is not completed/success: ${child.name}`);
|
|
}
|
|
if (
|
|
JSON.stringify(parentJobExecutionFingerprint(currentJob)) !==
|
|
JSON.stringify(parentJobExecutionFingerprint(originJob))
|
|
) {
|
|
throw new Error(`manifest parent job carry-forward fingerprint mismatch: ${child.name}`);
|
|
}
|
|
return currentJob;
|
|
}
|
|
|
|
function childRunIdsFromParentLog(log, repository = DEFAULT_REPO) {
|
|
const escapedRepo = repository.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
const pattern = new RegExp(
|
|
`https://github\\.com/${escapedRepo}/actions/runs/([1-9][0-9]*)`,
|
|
"gu",
|
|
);
|
|
return new Set(Array.from(log.matchAll(pattern), (match) => match[1]));
|
|
}
|
|
|
|
export function validateManifestChildRun(
|
|
run,
|
|
child,
|
|
runId,
|
|
parentManifest,
|
|
parentJobs,
|
|
selectedParentJobLog,
|
|
repository = DEFAULT_REPO,
|
|
) {
|
|
if (String(run.id) !== String(runId)) {
|
|
throw new Error(`manifest child run ID mismatch: ${child.name}`);
|
|
}
|
|
const originAttempt = resolveManifestChildOriginAttempt(run, child, parentManifest, parentJobs);
|
|
if (
|
|
run.event !== "workflow_dispatch" ||
|
|
run.head_branch !== child.headBranch ||
|
|
(child.trustedRef === "parent" && run.head_sha !== parentManifest.workflowSha) ||
|
|
!/^[a-f0-9]{40}$/u.test(String(run.head_sha)) ||
|
|
run.actor?.login !== "github-actions[bot]" ||
|
|
run.triggering_actor?.login !== "github-actions[bot]" ||
|
|
!Number.isSafeInteger(Number(run.run_attempt)) ||
|
|
Number(run.run_attempt) < 1 ||
|
|
originAttempt === undefined
|
|
) {
|
|
throw new Error(`manifest child dispatch tuple mismatch: ${child.name}`);
|
|
}
|
|
const childWorkflowPath = String(run.path ?? "").split("@", 1)[0];
|
|
if (childWorkflowPath !== `.github/workflows/${child.workflow}`) {
|
|
throw new Error(`manifest child workflow mismatch: ${child.name}`);
|
|
}
|
|
selectManifestParentJob(parentJobs, child, parentManifest, originAttempt);
|
|
const emittedChildRunIds = childRunIdsFromParentLog(selectedParentJobLog, repository);
|
|
if (emittedChildRunIds.size !== 1 || !emittedChildRunIds.has(String(runId))) {
|
|
throw new Error(`manifest child run is not uniquely emitted by its parent job: ${child.name}`);
|
|
}
|
|
if (
|
|
child.manifestKey !== "npmTelegram" &&
|
|
!selectedParentJobLog.includes(`TARGET_SHA: ${parentManifest.targetSha}`)
|
|
) {
|
|
throw new Error(`manifest parent job target SHA mismatch: ${child.name}`);
|
|
}
|
|
if (
|
|
child.manifestKey === "productPerformance" &&
|
|
!selectedParentJobLog.includes("-f publish_reports=false")
|
|
) {
|
|
throw new Error("manifest performance child is not dispatched in artifact-only mode");
|
|
}
|
|
return run;
|
|
}
|
|
|
|
export function validatePerformanceArtifactOnlyJobs(jobs, runAttempt) {
|
|
const normalizedRunAttempt = normalizePositiveInteger(runAttempt, "performance run attempt");
|
|
const currentJobs = jobs.filter((job) => Number(job.run_attempt) === normalizedRunAttempt);
|
|
const guards = currentJobs.filter((job) => job.name === "Verify artifact-only report mode");
|
|
if (
|
|
guards.length !== 1 ||
|
|
guards[0].status !== "completed" ||
|
|
guards[0].conclusion !== "success"
|
|
) {
|
|
throw new Error("performance artifact-only guard is missing or unsuccessful");
|
|
}
|
|
const unsafePublisher = currentJobs.find(
|
|
(job) =>
|
|
String(job.name ?? "").startsWith("Publish ") &&
|
|
String(job.name ?? "").endsWith(" report") &&
|
|
job.conclusion !== "skipped",
|
|
);
|
|
if (unsafePublisher) {
|
|
throw new Error(`performance report publisher was not skipped: ${unsafePublisher.name}`);
|
|
}
|
|
return guards[0];
|
|
}
|
|
|
|
function manifestArtifactName(runId, runAttempt) {
|
|
const normalizedRunId = normalizeRequiredRunId(runId, "full release run ID");
|
|
const normalizedRunAttempt = normalizePositiveInteger(runAttempt, "full release run attempt");
|
|
return `full-release-validation-${normalizedRunId}-${normalizedRunAttempt}`;
|
|
}
|
|
|
|
function legacyManifestArtifactName(runId) {
|
|
return `full-release-validation-${normalizeRequiredRunId(runId, "full release run ID")}`;
|
|
}
|
|
|
|
export function validateManifestArtifactIdentity(
|
|
artifact,
|
|
{ artifactDigest, artifactId, runAttempt, runId },
|
|
) {
|
|
const normalizedArtifactId = normalizeRequiredRunId(artifactId, "manifest artifact ID");
|
|
const normalizedRunId = normalizeRequiredRunId(runId, "full release run ID");
|
|
const normalizedRunAttempt = normalizePositiveInteger(runAttempt, "full release run attempt");
|
|
const normalizedDigest = String(artifactDigest ?? "");
|
|
if (!/^sha256:[a-f0-9]{64}$/u.test(normalizedDigest)) {
|
|
throw new Error(`release validation manifest artifact digest is invalid: ${normalizedRunId}`);
|
|
}
|
|
const canonicalName = manifestArtifactName(normalizedRunId, normalizedRunAttempt);
|
|
const legacyName = legacyManifestArtifactName(normalizedRunId);
|
|
const validName =
|
|
artifact.name === canonicalName || (normalizedRunAttempt === 1 && artifact.name === legacyName);
|
|
if (
|
|
String(artifact.id) !== normalizedArtifactId ||
|
|
!validName ||
|
|
artifact.digest !== normalizedDigest ||
|
|
artifact.expired !== false ||
|
|
String(artifact.workflow_run?.id) !== normalizedRunId ||
|
|
!Number.isSafeInteger(Number(artifact.size_in_bytes)) ||
|
|
Number(artifact.size_in_bytes) < 1
|
|
) {
|
|
throw new Error(`release validation manifest artifact identity mismatch: ${normalizedRunId}`);
|
|
}
|
|
return artifact;
|
|
}
|
|
|
|
export function selectManifestArtifact(artifacts, runId, runAttempt) {
|
|
const expectedName = manifestArtifactName(runId, runAttempt);
|
|
const canonicalMatches = artifacts.filter(
|
|
(artifact) =>
|
|
artifact.name === expectedName &&
|
|
artifact.expired === false &&
|
|
String(artifact.workflow_run?.id) === String(runId),
|
|
);
|
|
if (canonicalMatches.length > 1) {
|
|
throw new Error(`multiple release validation manifest artifacts found: ${runId}`);
|
|
}
|
|
const canonicalArtifact = canonicalMatches[0];
|
|
if (canonicalArtifact) {
|
|
return validateManifestArtifactIdentity(canonicalArtifact, {
|
|
artifactDigest: canonicalArtifact.digest,
|
|
artifactId: canonicalArtifact.id,
|
|
runAttempt,
|
|
runId,
|
|
});
|
|
}
|
|
|
|
const legacyName = legacyManifestArtifactName(runId);
|
|
const legacyMatches = artifacts.filter(
|
|
(artifact) =>
|
|
artifact.name === legacyName &&
|
|
artifact.expired === false &&
|
|
String(artifact.workflow_run?.id) === String(runId),
|
|
);
|
|
if (legacyMatches.length > 1) {
|
|
throw new Error(`multiple legacy release validation manifest artifacts found: ${runId}`);
|
|
}
|
|
const legacyArtifact = legacyMatches[0];
|
|
if (!legacyArtifact) {
|
|
return undefined;
|
|
}
|
|
if (Number(runAttempt) !== 1) {
|
|
throw new Error(`legacy release validation manifest requires run attempt 1: ${runId}`);
|
|
}
|
|
return validateManifestArtifactIdentity(legacyArtifact, {
|
|
artifactDigest: legacyArtifact.digest,
|
|
artifactId: legacyArtifact.id,
|
|
runAttempt,
|
|
runId,
|
|
});
|
|
}
|
|
|
|
export function validateManifestArtifactCompatibility(artifact, manifest, runId, runAttempt) {
|
|
if (artifact.name === manifestArtifactName(runId, runAttempt)) {
|
|
return artifact;
|
|
}
|
|
if (
|
|
Number(runAttempt) === 1 &&
|
|
artifact.name === legacyManifestArtifactName(runId) &&
|
|
manifest?.version === 2
|
|
) {
|
|
return artifact;
|
|
}
|
|
throw new Error(`legacy release validation manifest artifact is not compatible: ${runId}`);
|
|
}
|
|
|
|
export function readManifestArtifactArchive(archivePath, expectedDigest) {
|
|
const archiveSize = statSync(archivePath).size;
|
|
if (
|
|
!Number.isSafeInteger(archiveSize) ||
|
|
archiveSize < 1 ||
|
|
archiveSize > MAX_MANIFEST_ARTIFACT_ZIP_BYTES
|
|
) {
|
|
throw new Error("release validation manifest artifact compressed size is invalid");
|
|
}
|
|
const archiveBytes = readFileSync(archivePath);
|
|
if (archiveBytes.byteLength !== archiveSize) {
|
|
throw new Error("release validation manifest artifact changed while being verified");
|
|
}
|
|
const actualDigest = `sha256:${createHash("sha256").update(archiveBytes).digest("hex")}`;
|
|
if (actualDigest !== expectedDigest) {
|
|
throw new Error("release validation manifest artifact digest mismatch");
|
|
}
|
|
|
|
let entryList;
|
|
try {
|
|
entryList = execFileSync("unzip", ["-Z", "-1", archivePath], {
|
|
encoding: "utf8",
|
|
maxBuffer: MAX_MANIFEST_ENTRY_LIST_BYTES,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
} catch {
|
|
throw new Error("release validation manifest artifact entry list is invalid");
|
|
}
|
|
const entries = entryList.split(/\r?\n/u).filter((entry) => entry.length > 0);
|
|
if (entries.length !== 1 || entries[0] !== MANIFEST_ARTIFACT_ENTRY) {
|
|
throw new Error(
|
|
`release validation manifest artifact must contain only ${MANIFEST_ARTIFACT_ENTRY}`,
|
|
);
|
|
}
|
|
|
|
let manifestBytes;
|
|
try {
|
|
manifestBytes = execFileSync("unzip", ["-p", archivePath, MANIFEST_ARTIFACT_ENTRY], {
|
|
maxBuffer: MAX_MANIFEST_JSON_BYTES + 1,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
} catch {
|
|
throw new Error("release validation manifest artifact entry could not be read safely");
|
|
}
|
|
if (manifestBytes.byteLength < 1 || manifestBytes.byteLength > MAX_MANIFEST_JSON_BYTES) {
|
|
throw new Error("release validation manifest artifact entry size is invalid");
|
|
}
|
|
return JSON.parse(manifestBytes.toString("utf8"));
|
|
}
|
|
|
|
function downloadParentManifestEvidence(runId, runAttempt, repository, manifestPath) {
|
|
const targetRepository = repository ?? DEFAULT_REPO;
|
|
const artifacts = [];
|
|
for (let page = 1; page <= 10; page += 1) {
|
|
const pageArtifacts =
|
|
githubRestJson(`actions/runs/${runId}/artifacts?per_page=100&page=${page}`, targetRepository)
|
|
.artifacts ?? [];
|
|
artifacts.push(...pageArtifacts);
|
|
if (pageArtifacts.length < 100) {
|
|
break;
|
|
}
|
|
}
|
|
const listedArtifact = selectManifestArtifact(artifacts, runId, runAttempt);
|
|
if (!listedArtifact) {
|
|
return undefined;
|
|
}
|
|
const artifact = validateManifestArtifactIdentity(
|
|
githubRestJson(`actions/artifacts/${listedArtifact.id}`, targetRepository),
|
|
{
|
|
artifactDigest: listedArtifact.digest,
|
|
artifactId: listedArtifact.id,
|
|
runAttempt,
|
|
runId,
|
|
},
|
|
);
|
|
const downloadDir = mkdtempSync(join(tmpdir(), "openclaw-release-ci-summary-"));
|
|
try {
|
|
const archivePath = join(downloadDir, "manifest.zip");
|
|
downloadArtifactZip(String(artifact.id), archivePath, targetRepository);
|
|
const manifest = readManifestArtifactArchive(archivePath, artifact.digest);
|
|
validateManifestArtifactCompatibility(artifact, manifest, runId, runAttempt);
|
|
if (manifestPath) {
|
|
const providedManifest = JSON.parse(readFileSync(resolve(manifestPath), "utf8"));
|
|
if (
|
|
JSON.stringify(canonicalJson(providedManifest)) !== JSON.stringify(canonicalJson(manifest))
|
|
) {
|
|
throw new Error("provided release validation manifest differs from the run artifact");
|
|
}
|
|
}
|
|
return { artifact, manifest };
|
|
} finally {
|
|
rmSync(downloadDir, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
function tryDownloadParentManifest(runId, runAttempt, repository = DEFAULT_REPO) {
|
|
return downloadParentManifestEvidence(runId, runAttempt, repository)?.manifest;
|
|
}
|
|
|
|
function workflowPath(run) {
|
|
return String(run.path ?? "").split("@", 1)[0];
|
|
}
|
|
|
|
function normalizedManifestArtifact(artifact, runAttempt) {
|
|
return {
|
|
digest: artifact.digest,
|
|
id: String(artifact.id),
|
|
name: artifact.name,
|
|
runAttempt,
|
|
sizeInBytes: Number(artifact.size_in_bytes),
|
|
};
|
|
}
|
|
|
|
function validateManifestArtifactBinding(artifact, manifest, parentRun, runId) {
|
|
validateManifestArtifactCompatibility(artifact, manifest, runId, parentRun.run_attempt);
|
|
if (
|
|
String(artifact.workflow_run?.id) !== String(runId) ||
|
|
artifact.workflow_run?.head_branch !== parentRun.head_branch ||
|
|
artifact.workflow_run?.head_sha !== parentRun.head_sha
|
|
) {
|
|
throw new Error(`release validation manifest artifact binding mismatch: ${runId}`);
|
|
}
|
|
}
|
|
|
|
function validateCompletedParentRun(parentView, parentRest, repository, runId) {
|
|
validateParentRunBinding(parentView, parentRest, runId);
|
|
if (
|
|
parentView.status !== "completed" ||
|
|
parentView.conclusion !== "success" ||
|
|
parentRest.status !== "completed" ||
|
|
parentRest.conclusion !== "success" ||
|
|
parentRest.repository?.full_name !== repository
|
|
) {
|
|
throw new Error(`full release parent run is not completed/success: ${runId}`);
|
|
}
|
|
}
|
|
|
|
export function createReleaseEvidenceClient(repository = DEFAULT_REPO) {
|
|
const normalizedRepository = normalizeRepository(repository);
|
|
return {
|
|
compareCommits(base, head) {
|
|
return githubRestJson(`compare/${base}...${head}`, normalizedRepository);
|
|
},
|
|
getJobLog(jobId) {
|
|
return parentJobLog(jobId, normalizedRepository);
|
|
},
|
|
getParentJobs(runId) {
|
|
return findParentJobsAll(runId, normalizedRepository);
|
|
},
|
|
getRun(runId) {
|
|
return githubRestJson(`actions/runs/${runId}`, normalizedRepository);
|
|
},
|
|
getRunView(runId) {
|
|
return jsonGh([
|
|
"run",
|
|
"view",
|
|
String(runId),
|
|
"--repo",
|
|
normalizedRepository,
|
|
"--json",
|
|
"status,conclusion,attempt,headBranch,headSha,url,jobs",
|
|
]);
|
|
},
|
|
loadManifest(runId, runAttempt, manifestPath) {
|
|
return downloadParentManifestEvidence(runId, runAttempt, normalizedRepository, manifestPath);
|
|
},
|
|
};
|
|
}
|
|
|
|
function loadValidatedParentEvidence({ client, manifestPath, repository, runId }) {
|
|
const parentView = client.getRunView(runId);
|
|
const parentRun = client.getRun(runId);
|
|
validateCompletedParentRun(parentView, parentRun, repository, runId);
|
|
|
|
const manifestEvidence = client.loadManifest(runId, parentRun.run_attempt, manifestPath);
|
|
if (!manifestEvidence) {
|
|
throw new Error(`successful parent run is missing its release validation manifest: ${runId}`);
|
|
}
|
|
const manifest = validateParentManifest(manifestEvidence.manifest, {
|
|
runAttempt: parentRun.run_attempt,
|
|
runId,
|
|
workflowRef: parentRun.head_branch,
|
|
workflowSha: parentRun.head_sha,
|
|
});
|
|
validateManifestArtifactBinding(manifestEvidence.artifact, manifest, parentRun, runId);
|
|
|
|
return {
|
|
artifact: manifestEvidence.artifact,
|
|
manifest,
|
|
manifestJson: canonicalJson(manifestEvidence.manifest),
|
|
parentRun,
|
|
parentView,
|
|
};
|
|
}
|
|
|
|
function trustedWorkflowFullRef(workflowRef) {
|
|
return `refs/heads/${workflowRef}`;
|
|
}
|
|
|
|
function normalizeWorkflowPathRef(ref) {
|
|
if (!ref || ref.startsWith("refs/")) {
|
|
return ref;
|
|
}
|
|
return `refs/heads/${ref}`;
|
|
}
|
|
|
|
export function validateTrustedProducerIdentity(evidence, client, verifier, trustedWorkflowRef) {
|
|
const { manifest, parentRun } = evidence;
|
|
// Keep this predicate local: verifier source identity covers this file only.
|
|
const shaPinned = SHA_PINNED_BRANCH_PATTERN.test(manifest.workflowRef ?? "");
|
|
if (manifest.workflowRef !== trustedWorkflowRef && !shaPinned) {
|
|
throw new Error(
|
|
`release evidence producer must run from trusted workflow ref: ${trustedWorkflowRef}`,
|
|
);
|
|
}
|
|
if (shaPinned) {
|
|
if (manifest.version !== 3) {
|
|
throw new Error("SHA-pinned release evidence requires a v3 manifest");
|
|
}
|
|
if (!manifest.workflowRef.startsWith(`release-ci/${manifest.workflowSha.slice(0, 12)}-`)) {
|
|
throw new Error("SHA-pinned release evidence branch does not match its workflow SHA");
|
|
}
|
|
if (manifest.targetRef !== manifest.targetSha) {
|
|
throw new Error("SHA-pinned release evidence target ref must equal its target SHA");
|
|
}
|
|
}
|
|
const expectedFullRef = trustedWorkflowFullRef(manifest.workflowRef);
|
|
const runPath = String(parentRun.path ?? "");
|
|
const [runWorkflowPath, runWorkflowFullRef] = runPath.split("@", 2);
|
|
if (runWorkflowPath !== ".github/workflows/full-release-validation.yml") {
|
|
throw new Error("release evidence producer workflow path is not trusted");
|
|
}
|
|
if (runWorkflowFullRef && normalizeWorkflowPathRef(runWorkflowFullRef) !== expectedFullRef) {
|
|
throw new Error("release evidence producer workflow full ref is not trusted");
|
|
}
|
|
|
|
let workflowRefProof = "legacy-v2-main-ancestry";
|
|
if (manifest.version === 3) {
|
|
if (manifest.workflowRefType !== "branch" || manifest.workflowFullRef !== expectedFullRef) {
|
|
throw new Error("release evidence producer workflow full ref is not trusted");
|
|
}
|
|
workflowRefProof = shaPinned ? "manifest-v3-sha-pinned-main-ancestry" : "manifest-v3-branch";
|
|
}
|
|
|
|
const comparison = client.compareCommits(manifest.workflowSha, verifier.sourceSha);
|
|
if (
|
|
!["ahead", "identical"].includes(String(comparison.status)) ||
|
|
comparison.merge_base_commit?.sha !== manifest.workflowSha
|
|
) {
|
|
throw new Error("release evidence producer is not on the trusted main verifier lineage");
|
|
}
|
|
|
|
return {
|
|
producerOnTrustedMainLineage: true,
|
|
workflowFullRef: expectedFullRef,
|
|
workflowQualifiedPath: `${runWorkflowPath}@${expectedFullRef}`,
|
|
workflowRefProof,
|
|
workflowRefType: "branch",
|
|
workflowRunPath: runPath,
|
|
};
|
|
}
|
|
|
|
function normalizedParentTuple(evidence, identity) {
|
|
const { manifest, parentRun } = evidence;
|
|
return {
|
|
artifact: normalizedManifestArtifact(evidence.artifact, manifest.runAttempt),
|
|
conclusion: parentRun.conclusion,
|
|
manifest: evidence.manifestJson,
|
|
manifestVersion: manifest.version,
|
|
runAttempt: manifest.runAttempt,
|
|
runId: manifest.runId,
|
|
status: parentRun.status,
|
|
targetSha: manifest.targetSha,
|
|
url: parentRun.html_url ?? evidence.parentView.url,
|
|
...identity,
|
|
workflowPath: workflowPath(parentRun),
|
|
workflowRef: manifest.workflowRef,
|
|
workflowSha: manifest.workflowSha,
|
|
};
|
|
}
|
|
|
|
export function resolveVerifierIdentity(
|
|
sourceSha,
|
|
verifierSourceContent,
|
|
repositoryRoot = RELEASE_EVIDENCE_REPO_ROOT,
|
|
) {
|
|
let normalizedSourceSha = sourceSha ?? process.env.GITHUB_SHA;
|
|
if (!/^[a-f0-9]{40}$/u.test(String(normalizedSourceSha ?? ""))) {
|
|
try {
|
|
normalizedSourceSha = execFileSync("git", ["-C", repositoryRoot, "rev-parse", "HEAD"], {
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
}).trim();
|
|
} catch {
|
|
normalizedSourceSha = null;
|
|
}
|
|
}
|
|
if (!/^[a-f0-9]{40}$/u.test(String(normalizedSourceSha ?? ""))) {
|
|
throw new Error("release evidence verifier source SHA is unavailable");
|
|
}
|
|
const script = readFileSync(RELEASE_EVIDENCE_FILE);
|
|
const scriptSha256 = createHash("sha256").update(script).digest("hex");
|
|
let sourceScript;
|
|
if (verifierSourceContent !== undefined) {
|
|
sourceScript = Buffer.from(verifierSourceContent);
|
|
} else {
|
|
try {
|
|
sourceScript = execFileSync(
|
|
"git",
|
|
["-C", repositoryRoot, "show", `${normalizedSourceSha}:${RELEASE_EVIDENCE_SCRIPT}`],
|
|
{
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
);
|
|
} catch {
|
|
throw new Error("release evidence verifier source blob is unavailable");
|
|
}
|
|
}
|
|
const sourceScriptSha256 = createHash("sha256").update(sourceScript).digest("hex");
|
|
if (scriptSha256 !== sourceScriptSha256) {
|
|
throw new Error("release evidence verifier script differs from its source SHA");
|
|
}
|
|
return {
|
|
schemaVersion: 3,
|
|
script: RELEASE_EVIDENCE_SCRIPT,
|
|
scriptSha256,
|
|
sourceSha: normalizedSourceSha,
|
|
};
|
|
}
|
|
|
|
function validateStrictChildRun({ child, client, parentEvidence, parentJobs, repository, runId }) {
|
|
const run = client.getRun(runId);
|
|
const originAttempt = resolveManifestChildOriginAttempt(
|
|
run,
|
|
child,
|
|
parentEvidence.manifest,
|
|
parentJobs,
|
|
);
|
|
if (originAttempt === undefined) {
|
|
throw new Error(`manifest child dispatch tuple mismatch: ${child.name}`);
|
|
}
|
|
const parentJob = selectManifestParentJob(
|
|
parentJobs,
|
|
child,
|
|
parentEvidence.manifest,
|
|
originAttempt,
|
|
);
|
|
validateManifestChildRun(
|
|
run,
|
|
child,
|
|
runId,
|
|
parentEvidence.manifest,
|
|
parentJobs,
|
|
client.getJobLog(parentJob.id),
|
|
repository,
|
|
);
|
|
if (
|
|
run.repository?.full_name !== repository ||
|
|
run.status !== "completed" ||
|
|
run.conclusion !== "success" ||
|
|
run.head_sha !== parentEvidence.manifest.workflowSha
|
|
) {
|
|
throw new Error(`manifest child run is not exact completed/success evidence: ${child.name}`);
|
|
}
|
|
if (child.manifestKey === "productPerformance") {
|
|
validatePerformanceArtifactOnlyJobs(client.getParentJobs(runId), run.run_attempt);
|
|
}
|
|
|
|
return {
|
|
conclusion: run.conclusion,
|
|
dispatchNonce: `full-release-validation-${parentEvidence.manifest.runId}-${originAttempt}${child.suffix}`,
|
|
displayTitle: run.display_title,
|
|
event: run.event,
|
|
headBranch: run.head_branch,
|
|
parentJobId: String(parentJob.id),
|
|
path: workflowPath(run),
|
|
role: child.manifestKey,
|
|
runAttempt: normalizePositiveInteger(run.run_attempt, `${child.name} run attempt`),
|
|
runId: String(run.id),
|
|
sourceParentAttempt: originAttempt,
|
|
sourceParentRunId: parentEvidence.manifest.runId,
|
|
status: run.status,
|
|
url: run.html_url,
|
|
workflowSha: run.head_sha,
|
|
...(child.manifestKey === "productPerformance" ? { reportPublication: "artifact-only" } : {}),
|
|
};
|
|
}
|
|
|
|
export function validateReleaseRunEvidence(
|
|
{
|
|
manifestPath,
|
|
repository = DEFAULT_REPO,
|
|
runId,
|
|
trustedWorkflowRef = "main",
|
|
verifierSourceContent,
|
|
verifierSourceSha,
|
|
},
|
|
client,
|
|
) {
|
|
const normalizedRepository = normalizeRepository(repository);
|
|
const normalizedRunId = normalizeRequiredRunId(runId, "full release run ID");
|
|
const normalizedTrustedWorkflowRef = normalizeWorkflowRef(
|
|
trustedWorkflowRef,
|
|
"trusted workflow ref",
|
|
);
|
|
const evidenceClient = client ?? createReleaseEvidenceClient(normalizedRepository);
|
|
const verifier = resolveVerifierIdentity(verifierSourceSha, verifierSourceContent);
|
|
const currentEvidence = loadValidatedParentEvidence({
|
|
client: evidenceClient,
|
|
manifestPath,
|
|
repository: normalizedRepository,
|
|
runId: normalizedRunId,
|
|
});
|
|
const producerIdentities = new Map([
|
|
[
|
|
currentEvidence.manifest.runId,
|
|
validateTrustedProducerIdentity(
|
|
currentEvidence,
|
|
evidenceClient,
|
|
verifier,
|
|
normalizedTrustedWorkflowRef,
|
|
),
|
|
],
|
|
]);
|
|
|
|
let rootEvidence = currentEvidence;
|
|
let selectedEvidence = currentEvidence;
|
|
const reuse = currentEvidence.manifest.evidenceReuse;
|
|
if (reuse) {
|
|
rootEvidence = loadValidatedParentEvidence({
|
|
client: evidenceClient,
|
|
repository: normalizedRepository,
|
|
runId: reuse.runId,
|
|
});
|
|
selectedEvidence =
|
|
reuse.selectedRunId === reuse.runId
|
|
? rootEvidence
|
|
: loadValidatedParentEvidence({
|
|
client: evidenceClient,
|
|
repository: normalizedRepository,
|
|
runId: reuse.selectedRunId,
|
|
});
|
|
validateEvidenceReuseChain(
|
|
currentEvidence.manifest,
|
|
selectedEvidence.manifest,
|
|
rootEvidence.manifest,
|
|
(base, head) => evidenceClient.compareCommits(base, head),
|
|
);
|
|
}
|
|
|
|
for (const evidence of [currentEvidence, selectedEvidence, rootEvidence]) {
|
|
if (!producerIdentities.has(evidence.manifest.runId)) {
|
|
producerIdentities.set(
|
|
evidence.manifest.runId,
|
|
validateTrustedProducerIdentity(
|
|
evidence,
|
|
evidenceClient,
|
|
verifier,
|
|
normalizedTrustedWorkflowRef,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
const selectedKeys = requiredChildKeysForRerunGroup(rootEvidence.manifest.rerunGroup);
|
|
const expectedChildren = expectedSelectedChildDispatches(
|
|
rootEvidence.manifest.runId,
|
|
rootEvidence.manifest.runAttempt,
|
|
rootEvidence.manifest.workflowRef,
|
|
selectedKeys,
|
|
);
|
|
const parentJobs = evidenceClient.getParentJobs(rootEvidence.manifest.runId);
|
|
const children = manifestChildEntries(rootEvidence.manifest, expectedChildren, selectedKeys).map(
|
|
({ child, runId: childRunId }) =>
|
|
validateStrictChildRun({
|
|
child,
|
|
client: evidenceClient,
|
|
parentEvidence: rootEvidence,
|
|
parentJobs,
|
|
repository: normalizedRepository,
|
|
runId: childRunId,
|
|
}),
|
|
);
|
|
|
|
const current = normalizedParentTuple(
|
|
currentEvidence,
|
|
producerIdentities.get(currentEvidence.manifest.runId),
|
|
);
|
|
const root = normalizedParentTuple(
|
|
rootEvidence,
|
|
producerIdentities.get(rootEvidence.manifest.runId),
|
|
);
|
|
const childConclusions = Object.fromEntries(
|
|
children.map((child) => [child.role, child.conclusion]),
|
|
);
|
|
return canonicalJson({
|
|
children,
|
|
conclusions: {
|
|
allRequiredSucceeded: children.every((child) => child.conclusion === "success"),
|
|
children: childConclusions,
|
|
current: current.conclusion,
|
|
root: root.conclusion,
|
|
},
|
|
controls: rootEvidence.manifest.controls,
|
|
current,
|
|
directRoot: !reuse,
|
|
evidenceReuse: reuse
|
|
? {
|
|
changedPaths: reuse.changedPaths,
|
|
evidenceSha: reuse.evidenceSha,
|
|
policy: reuse.policy,
|
|
rootRunId: reuse.runId,
|
|
selectedRunId: reuse.selectedRunId,
|
|
}
|
|
: null,
|
|
manifest: rootEvidence.manifestJson,
|
|
releaseProfile: rootEvidence.manifest.releaseProfile,
|
|
repository: normalizedRepository,
|
|
rerunGroup: rootEvidence.manifest.rerunGroup,
|
|
root,
|
|
runReleaseSoak: rootEvidence.manifest.runReleaseSoak === "true",
|
|
schema: RELEASE_EVIDENCE_SCHEMA,
|
|
producerOnTrustedMainLineage: true,
|
|
trustedWorkflowFullRef: trustedWorkflowFullRef(normalizedTrustedWorkflowRef),
|
|
trustedWorkflowRef: normalizedTrustedWorkflowRef,
|
|
valid: true,
|
|
validationInputs: rootEvidence.manifest.validationInputs ?? null,
|
|
verifier,
|
|
});
|
|
}
|
|
|
|
export function parseReleaseCiSummaryArgs(argv) {
|
|
const options = {
|
|
intervalMs: 30_000,
|
|
json: false,
|
|
manifestPath: undefined,
|
|
repository: DEFAULT_REPO,
|
|
runId: undefined,
|
|
trustedWorkflowRef: "main",
|
|
validate: false,
|
|
verifierSourceFile: undefined,
|
|
verifierSourceSha: undefined,
|
|
watch: false,
|
|
};
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const argument = argv[index];
|
|
if (argument === "--validate-run") {
|
|
options.validate = true;
|
|
options.runId = argv[++index];
|
|
} else if (argument === "--repo") {
|
|
options.repository = argv[++index];
|
|
} else if (argument === "--manifest") {
|
|
options.manifestPath = argv[++index];
|
|
} else if (argument === "--trusted-workflow-ref") {
|
|
options.trustedWorkflowRef = argv[++index];
|
|
} else if (argument === "--verifier-source-sha") {
|
|
options.verifierSourceSha = argv[++index];
|
|
} else if (argument === "--verifier-source-file") {
|
|
options.verifierSourceFile = argv[++index];
|
|
} else if (argument === "--json") {
|
|
options.json = true;
|
|
} else if (argument === "--watch") {
|
|
options.watch = true;
|
|
} else if (argument === "--interval") {
|
|
const seconds = argv[++index];
|
|
if (!/^[1-9][0-9]*$/u.test(seconds ?? "")) {
|
|
throw new Error("--interval requires a positive number of seconds");
|
|
}
|
|
options.intervalMs = Number(seconds) * 1000;
|
|
} else if (!argument.startsWith("-") && !options.runId && !options.validate) {
|
|
options.runId = argument;
|
|
} else {
|
|
throw new Error(`unknown or incomplete argument: ${argument}`);
|
|
}
|
|
}
|
|
if (!options.validate && options.manifestPath) {
|
|
throw new Error("--manifest requires --validate-run");
|
|
}
|
|
if (options.validate && options.watch) {
|
|
throw new Error("--watch cannot be combined with --validate-run");
|
|
}
|
|
if (options.verifierSourceFile && !options.verifierSourceSha) {
|
|
throw new Error("--verifier-source-file requires --verifier-source-sha");
|
|
}
|
|
if (!options.runId) {
|
|
throw new Error("full release run ID is required");
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function printUsage() {
|
|
console.error(
|
|
[
|
|
"usage: release-ci-summary.mjs <full-release-run-id>",
|
|
" release-ci-summary.mjs <full-release-run-id> --watch [--interval seconds]",
|
|
" release-ci-summary.mjs --validate-run <id> [--repo owner/name] [--trusted-workflow-ref main] [--manifest path] [--verifier-source-sha sha --verifier-source-file path] --json",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
|
|
export function releaseCiWatchFingerprint(parent) {
|
|
return JSON.stringify({
|
|
attempt: parent.attempt,
|
|
conclusion: parent.conclusion ?? "",
|
|
jobs: (parent.jobs ?? [])
|
|
.map((job) => ({
|
|
conclusion: job.conclusion ?? "",
|
|
name: job.name,
|
|
status: job.status,
|
|
}))
|
|
.toSorted((left, right) => left.name.localeCompare(right.name)),
|
|
status: parent.status,
|
|
});
|
|
}
|
|
|
|
function summarizeReleaseCiRun(options) {
|
|
execFileSync(
|
|
process.execPath,
|
|
[
|
|
RELEASE_EVIDENCE_FILE,
|
|
options.runId,
|
|
"--repo",
|
|
options.repository,
|
|
"--trusted-workflow-ref",
|
|
options.trustedWorkflowRef,
|
|
],
|
|
{ stdio: "inherit" },
|
|
);
|
|
}
|
|
|
|
export async function watchReleaseCiRun(options, overrides = {}) {
|
|
const fetchParent =
|
|
overrides.fetchParent ??
|
|
(() =>
|
|
jsonGh([
|
|
"run",
|
|
"view",
|
|
options.runId,
|
|
"--repo",
|
|
options.repository,
|
|
"--json",
|
|
"status,conclusion,attempt,jobs",
|
|
]));
|
|
const summarize = overrides.summarize ?? (() => summarizeReleaseCiRun(options));
|
|
const sleep =
|
|
overrides.sleep ??
|
|
((milliseconds) =>
|
|
new Promise((complete) => {
|
|
setTimeout(complete, milliseconds);
|
|
}));
|
|
let previousFingerprint;
|
|
while (true) {
|
|
const parent = fetchParent();
|
|
const fingerprint = releaseCiWatchFingerprint(parent);
|
|
if (fingerprint !== previousFingerprint) {
|
|
summarize();
|
|
previousFingerprint = fingerprint;
|
|
}
|
|
if (parent.status === "completed") {
|
|
if (parent.conclusion !== "success") {
|
|
throw new Error(
|
|
`full release run ${options.runId} completed with ${parent.conclusion || "no conclusion"}`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
await sleep(options.intervalMs);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
let options;
|
|
try {
|
|
options = parseReleaseCiSummaryArgs(process.argv.slice(2));
|
|
} catch (error) {
|
|
printUsage();
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exit(2);
|
|
}
|
|
const { repository, runId } = options;
|
|
|
|
if (options.validate) {
|
|
try {
|
|
const evidence = validateReleaseRunEvidence({
|
|
manifestPath: options.manifestPath,
|
|
repository,
|
|
runId,
|
|
trustedWorkflowRef: options.trustedWorkflowRef,
|
|
verifierSourceContent: options.verifierSourceFile
|
|
? readFileSync(options.verifierSourceFile)
|
|
: undefined,
|
|
verifierSourceSha: options.verifierSourceSha,
|
|
});
|
|
console.log(JSON.stringify(evidence, null, options.json ? 2 : 0));
|
|
} catch (error) {
|
|
const failure = {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
schema: RELEASE_EVIDENCE_SCHEMA,
|
|
valid: false,
|
|
};
|
|
if (options.json) {
|
|
console.log(JSON.stringify(failure, null, 2));
|
|
} else {
|
|
console.error(failure.error);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
return;
|
|
}
|
|
if (options.watch) {
|
|
await watchReleaseCiRun(options);
|
|
return;
|
|
}
|
|
|
|
const core = rate();
|
|
if (core) {
|
|
const reset = new Date(core.reset * 1000).toISOString();
|
|
console.log(`rate: remaining=${core.remaining}/${core.limit} reset=${reset}`);
|
|
if (core.remaining < 20) {
|
|
console.error("rate too low for CI summary; wait for reset before polling");
|
|
process.exit(3);
|
|
}
|
|
}
|
|
|
|
const parent = jsonGh([
|
|
"run",
|
|
"view",
|
|
runId,
|
|
"--repo",
|
|
repository,
|
|
"--json",
|
|
"status,conclusion,attempt,headBranch,headSha,url,jobs",
|
|
]);
|
|
validateParentRunBinding(parent, githubRestJson(`actions/runs/${runId}`, repository), runId);
|
|
|
|
console.log(`parent: ${runId} ${parent.status}/${parent.conclusion || "none"}`);
|
|
console.log(`workflow-ref: ${parent.headBranch}`);
|
|
console.log(`workflow-sha: ${parent.headSha}`);
|
|
console.log(`url: ${parent.url}`);
|
|
|
|
for (const job of parent.jobs ?? []) {
|
|
const marker = job.conclusion || job.status;
|
|
console.log(`parent-job: ${marker} ${job.name}`);
|
|
}
|
|
|
|
const currentManifestRaw = tryDownloadParentManifest(runId, parent.attempt, repository);
|
|
let children;
|
|
if (currentManifestRaw) {
|
|
const currentManifest = validateParentManifest(currentManifestRaw, {
|
|
runAttempt: parent.attempt,
|
|
runId,
|
|
workflowRef: parent.headBranch,
|
|
workflowSha: parent.headSha,
|
|
});
|
|
console.log(`candidate-sha: ${currentManifest.targetSha}`);
|
|
console.log(`manifest-run: ${currentManifest.runId}/${currentManifest.runAttempt}`);
|
|
|
|
let sourceManifest = currentManifest;
|
|
let sourceParent = parent;
|
|
if (currentManifest.evidenceReuse) {
|
|
const rootRunId = currentManifest.evidenceReuse.runId;
|
|
const rootParent = jsonGh([
|
|
"run",
|
|
"view",
|
|
rootRunId,
|
|
"--repo",
|
|
repository,
|
|
"--json",
|
|
"status,conclusion,attempt,headBranch,headSha,url,jobs",
|
|
]);
|
|
validateParentRunBinding(
|
|
rootParent,
|
|
githubRestJson(`actions/runs/${rootRunId}`, repository),
|
|
rootRunId,
|
|
);
|
|
if (rootParent.status !== "completed" || rootParent.conclusion !== "success") {
|
|
throw new Error(`evidence root run is not completed/success: ${rootRunId}`);
|
|
}
|
|
const rootManifestRaw = tryDownloadParentManifest(rootRunId, rootParent.attempt, repository);
|
|
if (!rootManifestRaw) {
|
|
throw new Error(`evidence root manifest is unavailable: ${rootRunId}`);
|
|
}
|
|
const rootManifest = validateParentManifest(rootManifestRaw, {
|
|
runAttempt: rootParent.attempt,
|
|
runId: rootRunId,
|
|
workflowRef: rootParent.headBranch,
|
|
workflowSha: rootParent.headSha,
|
|
});
|
|
|
|
const selectedRunId = currentManifest.evidenceReuse.selectedRunId;
|
|
let selectedManifest = rootManifest;
|
|
if (selectedRunId !== rootRunId) {
|
|
const selectedParent = jsonGh([
|
|
"run",
|
|
"view",
|
|
selectedRunId,
|
|
"--repo",
|
|
repository,
|
|
"--json",
|
|
"status,conclusion,attempt,headBranch,headSha,url,jobs",
|
|
]);
|
|
validateParentRunBinding(
|
|
selectedParent,
|
|
githubRestJson(`actions/runs/${selectedRunId}`, repository),
|
|
selectedRunId,
|
|
);
|
|
if (selectedParent.status !== "completed" || selectedParent.conclusion !== "success") {
|
|
throw new Error(`selected evidence run is not completed/success: ${selectedRunId}`);
|
|
}
|
|
const selectedManifestRaw = tryDownloadParentManifest(
|
|
selectedRunId,
|
|
selectedParent.attempt,
|
|
repository,
|
|
);
|
|
if (!selectedManifestRaw) {
|
|
throw new Error(`selected evidence manifest is unavailable: ${selectedRunId}`);
|
|
}
|
|
selectedManifest = validateParentManifest(selectedManifestRaw, {
|
|
runAttempt: selectedParent.attempt,
|
|
runId: selectedRunId,
|
|
workflowRef: selectedParent.headBranch,
|
|
workflowSha: selectedParent.headSha,
|
|
});
|
|
}
|
|
|
|
const evidenceSha = validateEvidenceReuseChain(
|
|
currentManifest,
|
|
selectedManifest,
|
|
rootManifest,
|
|
(base, head) => githubRestJson(`compare/${base}...${head}`, repository),
|
|
);
|
|
sourceManifest = rootManifest;
|
|
sourceParent = rootParent;
|
|
console.log(`evidence-selected-run: ${selectedRunId}`);
|
|
console.log(`evidence-root-run: ${rootRunId}`);
|
|
console.log(`evidence-sha: ${evidenceSha}`);
|
|
console.log(`evidence-policy: ${currentManifest.evidenceReuse.policy}`);
|
|
console.log(
|
|
`evidence-changed-paths: ${JSON.stringify(currentManifest.evidenceReuse.changedPaths)}`,
|
|
);
|
|
}
|
|
|
|
const expectedChildren = expectedSelectedChildDispatches(
|
|
sourceManifest.runId,
|
|
sourceManifest.runAttempt,
|
|
sourceManifest.workflowRef,
|
|
requiredChildKeysForRerunGroup(sourceManifest.rerunGroup),
|
|
);
|
|
const sourceParentJobs = findParentJobsAll(sourceManifest.runId, repository);
|
|
children = manifestChildEntries(
|
|
sourceManifest,
|
|
expectedChildren,
|
|
requiredChildKeysForRerunGroup(sourceManifest.rerunGroup),
|
|
).map(({ child, runId: childRunId }) => {
|
|
const run = githubRestJson(`actions/runs/${childRunId}`, repository);
|
|
const originAttempt = resolveManifestChildOriginAttempt(
|
|
run,
|
|
child,
|
|
sourceManifest,
|
|
sourceParentJobs,
|
|
);
|
|
if (originAttempt === undefined) {
|
|
throw new Error(`manifest child dispatch tuple mismatch: ${child.name}`);
|
|
}
|
|
const parentJob = selectManifestParentJob(
|
|
sourceParentJobs,
|
|
child,
|
|
sourceManifest,
|
|
originAttempt,
|
|
);
|
|
const validatedRun = validateManifestChildRun(
|
|
run,
|
|
child,
|
|
childRunId,
|
|
{ ...sourceManifest, workflowSha: sourceParent.headSha },
|
|
sourceParentJobs,
|
|
parentJobLog(parentJob.id, repository),
|
|
repository,
|
|
);
|
|
if (child.manifestKey === "productPerformance") {
|
|
validatePerformanceArtifactOnlyJobs(
|
|
findParentJobsAll(childRunId, repository),
|
|
run.run_attempt,
|
|
);
|
|
}
|
|
return { child, run: validatedRun };
|
|
});
|
|
} else {
|
|
console.log("candidate-sha: unavailable (release validation manifest not uploaded)");
|
|
if (parent.status === "completed" && parent.conclusion === "success") {
|
|
throw new Error("successful parent run is missing its release validation manifest");
|
|
}
|
|
const selectedKeys = selectedChildKeys(parent.jobs ?? []);
|
|
children = expectedSelectedChildDispatches(
|
|
runId,
|
|
parent.attempt,
|
|
parent.headBranch,
|
|
selectedKeys,
|
|
)
|
|
.map((child) => {
|
|
const run = findExactChildRun(child, repository);
|
|
if (!run) {
|
|
console.log(
|
|
`child-missing: ${child.name} title=${child.displayTitle} branch=${child.headBranch}`,
|
|
);
|
|
}
|
|
return { child, run };
|
|
})
|
|
.filter((entry) => entry.run);
|
|
}
|
|
if (children.length === 0) {
|
|
console.log("children: none found yet");
|
|
return;
|
|
}
|
|
|
|
console.log("children:");
|
|
for (const { child, run } of children) {
|
|
console.log(
|
|
`child: ${run.id} ${child.name} ${run.status}/${run.conclusion || "none"} branch=${run.head_branch} workflow_sha=${run.head_sha}`,
|
|
);
|
|
console.log(`child-url: ${run.html_url}`);
|
|
}
|
|
}
|
|
|
|
if (process.argv[1]?.endsWith("release-ci-summary.mjs")) {
|
|
await main().catch(
|
|
/** @param {unknown} error */ (error) => {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exit(1);
|
|
},
|
|
);
|
|
}
|