openclaw/scripts/full-release-child-evidence.mjs
Peter Steinberger ebdab59f91
feat(release): redesign release validation and publication for faster stable releases (#156812)
* test(release): guard parallel validation dispatch

* feat(release): seal independent child workload evidence

Record immutable, attempt-aware child receipts independently of parent completion. Keep receipt collection advisory and preserve publisher-only retry evidence. Partial child-reuse discovery and consumption remain a follow-up.

* feat(release): reuse sealed child evidence independently

* fix(ci): plan frozen release shards with trusted tooling

* ci(release): generate hosted shard timing budgets

* fix(release): make non-proof validation lanes advisory

* feat(release): seal resolved publication inputs

* fix(release): accept candidate tags at the frozen target

* docs(release): record pending first-hop parallel lanes

Item 8: remote main d51f3607b9 does not contain bf7848ef23. The feat/first-hop-compat-parallel-lanes branch still owns that implementation. Verified remote refs read-only and inspected the main scenario source; do not duplicate it here. No source changes; prior item gates remain valid.

* ci(plugins): run release plugin coverage on relevant pull requests

* ci(release): support reserved validation runner groups

* docs(release): reconcile fast-path validation guidance

* test(release): reconcile final guards and record validation

* fix(release): retry transient GitHub API failures during evidence verification

* test(release): reconcile advisory Linux CI lane cases with the fast-path policy

* test(release): copy the sealed-evidence tooling closure into the frozen fixtures

* fix(release): keep package integrity blocking and require live operator authority for sealed SDK and soak inputs

* chore(release): record the landing follow-ups in the PR body

* test(ci): expect the four-hour Testbox lease default from #156614

* test(release): reconcile untouched release suites with the advisory policy and reserved runner groups

* fix(release): reconcile advisory-by-default with full coverage and strict stable defaults

Keep RomneyDa's coverage (nine cross-OS Gateway pairs, Linux Gateway lanes as
required proof, Windows/macOS recorded as advisory) and his strict stable
publication gates (stable-profile, soak, blocking performance) as the default,
while retaining Peter's operator fast path: stable_soak_waiver / lane_waiver
are the only way to publish a stable without soak/performance evidence or with
failed non-proof lanes, must name the target version, apply only while the
repository variable still holds them, and are recorded end to end. The stable
closeout accepts the same waivers so the 2026.9.6 closeout replay can proceed.
Adopt main's affected-consumer planner (#156729) with item 4's hosted-row
split re-applied.

* fix(release): revalidate sealed soak waivers at the plugin npm publish boundary

The stable bootstrap approval records whether its soak waiver was explicit or
sealed; the plugin npm child rereads the repository variable before its
token-backed publish and rejects a sealed waiver the variable no longer holds.
Read-only preflight reports sealed waivers with the same rule. Docs state the
nine-pair all-group cross-OS rule and the strict performance gate; the tracked
planner backup is removed and the fast-core worker keeps its runner-group
routing.

* fix(release): recheck sealed waivers before npm I/O and repair CI-surfaced drift

Assert a still-held sealed soak waiver immediately before the plugin
token-backed npm publish, carry the live variable into preflight's gate
evaluation, and state the strict performance gate in the fast-path guide.
Fold main-side drift the merge surfaced: the seal job waits for the new
baseline-ratchets worker, the wrapper closure lists the CLI root options chain,
the maturity publisher's runner-group route is evaluated rather than compared
literally, and two main-authored session test-support suppressions join the
allowlist.

* fix(release): fetch waiver authority live before the plugin npm publish

Read OPENCLAW_RELEASE_STABLE_SOAK_WAIVER from the repository immediately
before the token-backed npm publish (404 means revoked, other read errors
refuse to publish), keep a waiver-less recorded closeout manifest byte-identical
on replay, and describe release_profile=stable as the stable default with the
beta profile plus stable_soak_waiver as the explicit operator fast path.

* test(release): anchor the publish-boundary recheck to the npm publish command

* fix(release): fall back to the job-start waiver when the token cannot read Variables

Keep the live read before npm publish (404 means revoked) but warn and use
the job-start snapshot instead of refusing every bootstrap publish when the
job token lacks Variables access.

* fix(release): refuse the plugin npm publish when waiver authority cannot be read

Revoked (404) and unreadable variable states both stop the token-backed
publish; a job token without Variables read access fails loudly instead of
publishing on a job-start snapshot.
2026-09-24 05:15:18 -07:00

194 lines
7 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import process from "node:process";
import {
composeReleaseChildAttemptEvidence,
releaseChildSpec,
releaseCompositeJobsSha256,
validateReleaseChildRunProvenance,
} from "./full-release-validation-policy.mjs";
import { canonicalizeJsonValue } from "./lib/canonical-json.mjs";
import {
FULL_RELEASE_CHILD_EVIDENCE_JOB as PUBLISHER_JOB,
serializeReleaseArtifact,
} from "./lib/full-release-evidence.mjs";
import { execGhRead } from "./lib/plain-gh.mjs";
const MAX_INPUT_BYTES = 128 * 1024;
function required(name, pattern) {
const value = process.env[name] ?? "";
if (!pattern.test(value)) {
throw new Error(`Invalid ${name}`);
}
return value;
}
function readJson(path, maxBytes) {
if (statSync(path).size > maxBytes) {
throw new Error("Child evidence input exceeds its byte limit");
}
return JSON.parse(readFileSync(path, "utf8"));
}
function github(repository, endpoint, paginate = false) {
const args = ["api", `repos/${repository}/${endpoint}`];
if (paginate) {
args.push("--paginate", "--slurp");
}
return JSON.parse(
execGhRead(args, {
encoding: "utf8",
timeout: 60_000,
maxBuffer: 4 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
}),
);
}
function readJobs(repository, runId, runAttempt) {
const pages = github(
repository,
`actions/runs/${runId}/attempts/${runAttempt}/jobs?per_page=100`,
true,
);
if (!Array.isArray(pages) || pages.length === 0 || pages.length > 20) {
throw new Error("Child evidence job inventory is incomplete");
}
const jobs = pages.flatMap((page) => {
if (!Array.isArray(page.jobs) || page.total_count !== pages[0].total_count) {
throw new Error("Child evidence job inventory is incomplete");
}
return page.jobs;
});
if (
jobs.length !== pages[0].total_count ||
jobs.some((job) => String(job.run_id) !== runId || job.run_attempt !== runAttempt)
) {
throw new Error("Child evidence job inventory is incomplete or belongs to another attempt");
}
return jobs;
}
function seal() {
const repository = required("GITHUB_REPOSITORY", /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u);
const runId = required("GITHUB_RUN_ID", /^[1-9][0-9]*$/u);
const runAttempt = Number(required("GITHUB_RUN_ATTEMPT", /^[1-9][0-9]*$/u));
const workflowSha = required("GITHUB_SHA", /^[a-f0-9]{40}$/u);
const targetSha = required("FRV_CHILD_TARGET_SHA", /^[a-f0-9]{40}$/u);
const role = required("FRV_CHILD_ROLE", /^[A-Za-z]+$/u);
const spec = releaseChildSpec(role);
const event = readJson(required("GITHUB_EVENT_PATH", /\S/u), MAX_INPUT_BYTES);
const inputs = event.inputs;
if (
!inputs ||
typeof inputs !== "object" ||
Array.isArray(inputs) ||
Object.values(inputs).some((value) => !["string", "boolean", "number"].includes(typeof value))
) {
throw new Error("Child evidence requires scalar workflow dispatch inputs");
}
const dispatchId = String(inputs.dispatch_id ?? "");
const parent = /^full-release-validation-([1-9][0-9]*)-([1-9][0-9]*)(.*)$/u.exec(dispatchId);
if (!parent || parent[3] !== spec.suffix) {
throw new Error("Child evidence dispatch does not match its release role");
}
if (!Number.isSafeInteger(runAttempt) || runAttempt > 32) {
throw new Error("Child evidence attempt inventory exceeds its bound");
}
const run = github(repository, `actions/runs/${runId}`);
const expected = {
key: role,
repository,
runId,
plannedRunAttempt: 1,
displayTitle: `${spec.displayName} ${dispatchId}`,
workflow: spec.workflow,
workflowRef: required("GITHUB_REF_NAME", /\S/u),
workflowSha,
};
validateReleaseChildRunProvenance(run, expected);
if (
run.run_attempt !== runAttempt ||
run.head_repository?.full_name !== repository ||
run.status !== "in_progress" ||
run.conclusion !== null
) {
throw new Error("Child evidence publisher is not in the current active workflow attempt");
}
const lineage = github(repository, `compare/${workflowSha}...main?per_page=1`);
if (
!["ahead", "identical"].includes(lineage.status) ||
lineage.merge_base_commit?.sha !== workflowSha
) {
throw new Error("Child evidence workflow SHA is not a main ancestor");
}
const attempts = [];
let publisher;
for (let attempt = 1; attempt <= runAttempt; attempt += 1) {
const allJobs = readJobs(repository, runId, attempt);
const publishers = allJobs.filter((job) => job.name === PUBLISHER_JOB);
if (attempt === runAttempt) {
if (publishers.length !== 1 || publishers[0].status !== "in_progress") {
throw new Error("Child evidence publisher job identity is ambiguous or inactive");
}
publisher = publishers[0];
}
const jobs = allJobs.filter((job) => job.name !== PUBLISHER_JOB);
if (jobs.some((job) => job.status !== "completed")) {
throw new Error("Child evidence cannot seal while predecessor jobs are active");
}
attempts.push({ runAttempt: attempt, jobs: allJobs });
}
const composite = composeReleaseChildAttemptEvidence({ attempts, expected, run });
composite.jobs = composite.jobs.filter((job) => job.name !== PUBLISHER_JOB);
composite.compositeJobsSha256 = releaseCompositeJobsSha256(composite);
if (composite.jobs.length === 0) {
throw new Error("Child evidence has no executed predecessor jobs");
}
const inputsWithoutDispatch = Object.fromEntries(
Object.entries(inputs)
.filter(([key]) => key !== "dispatch_id")
.map(([key, value]) => [key, String(value)]),
);
// The uploader is still active. Consumers must independently require its success
// and the completed child conclusion before accepting these predecessor facts.
const receipt = canonicalizeJsonValue({
schema: "openclaw.full-release-child-evidence/v1",
repository,
role,
targetSha,
workflowSha,
workflowRef: expected.workflowRef,
workflowPath: `.github/workflows/${spec.workflow}`,
displayTitle: expected.displayTitle,
dispatchId,
sourceParentRunId: parent[1],
sourceParentAttempt: Number(parent[2]),
workloadConclusion: composite.jobs.every((job) =>
["success", "neutral"].includes(job.conclusion),
)
? "success"
: "failure",
inputs: inputsWithoutDispatch,
publisher: { jobId: String(publisher.id), jobName: PUBLISHER_JOB },
...composite,
});
const sha256 = createHash("sha256").update(JSON.stringify(receipt)).digest("hex");
const output = required("FRV_CHILD_EVIDENCE_PATH", /\S/u);
mkdirSync(dirname(output), { recursive: true });
writeFileSync(output, serializeReleaseArtifact({ ...receipt, sha256 }));
appendFileSync(
required("GITHUB_OUTPUT", /\S/u),
`artifact_name=full-release-child-evidence-${targetSha}-${role}-${runId}-${runAttempt}\n`,
);
}
try {
seal();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}