diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index b4fda4fb0a50..1bb645e64056 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -195,6 +195,16 @@ jobs: with: ref: ${{ github.sha }} path: workflow + sparse-checkout: | + scripts/release-tooling-identity.mjs + scripts/github/resolve-openclaw-ref.sh + scripts/github/validate-release-suite-filters.sh + scripts/full-release-candidate-contract.mjs + scripts/full-release-validation-policy.mjs + scripts/lib/record-shared.mjs + scripts/lib/canonical-json.mjs + scripts/lib/upgrade-survivor-policy.mjs + sparse-checkout-cone-mode: false fetch-depth: 1 persist-credentials: false submodules: false @@ -554,6 +564,26 @@ jobs: with: ref: ${{ github.sha }} path: workflow + sparse-checkout: | + scripts/github/find-reusable-release-validation.sh + scripts/release-ci-summary.mjs + scripts/full-release-validation-policy.mjs + scripts/full-release-candidate-contract.mjs + scripts/release-preflight.mjs + scripts/release-preflight.mts + scripts/windows-cmd-helpers.mjs + scripts/lib/canonical-json.mjs + scripts/lib/record-shared.mjs + scripts/lib/upgrade-survivor-policy.mjs + scripts/lib/plain-gh.mjs + scripts/lib/tsx-cli-shim.mjs + scripts/lib/local-check-runtime.mts + scripts/lib/failed-trailer.mts + scripts/lib/error-format.mts + scripts/lib/managed-child-process.mts + scripts/lib/windows-taskkill.mjs + scripts/lib/release-version.mjs + sparse-checkout-cone-mode: false fetch-depth: 1 persist-credentials: false submodules: false @@ -563,6 +593,10 @@ jobs: with: ref: ${{ needs.resolve_target.outputs.sha }} path: target + sparse-checkout: | + package.json + apps/macos/Sources/OpenClaw/Resources/Info.plist + sparse-checkout-cone-mode: false fetch-depth: 1 persist-credentials: false submodules: false diff --git a/docs/reference/full-release-validation.md b/docs/reference/full-release-validation.md index caa9831d395e..ac7fa9603cf8 100644 --- a/docs/reference/full-release-validation.md +++ b/docs/reference/full-release-validation.md @@ -271,6 +271,16 @@ run. Evidence reuse runs only from `main` or a canonical SHA-pinned `release-ci/*` ref whose workflow commit remains on trusted `main` lineage; other workflow refs run the selected lanes fresh. +The reuse search checks each bound parent manifest for eligibility before +loading its child runs, job logs, and execution plan. Incompatible profiles, +inputs, targets, and non-root runs are rejected early. Eligible candidates +still undergo complete provenance and attempt verification before reuse. +The verifier reads independent children concurrently (at most seven), retains +each attempt's job data for its policy checks, and waits for all reads before +reporting success or failure. Attempts and pagination within each child remain +sequential. Target resolution and reuse checkouts include only their tooling +and release metadata; neither needs the complete source tree. + Fresh package-facing validation calls the `Full Release Candidate` reusable workflow once. Plugin Prerelease and OpenClaw Release Checks each dispatch an independent phase immediately, while their candidate phases wait for acquisition. diff --git a/scripts/github/find-reusable-release-validation.sh b/scripts/github/find-reusable-release-validation.sh index 6f2e7d86d9af..5477418505a6 100755 --- a/scripts/github/find-reusable-release-validation.sh +++ b/scripts/github/find-reusable-release-validation.sh @@ -237,6 +237,13 @@ if [[ "$run_count" == "0" ]]; then no_reuse "no prior successful validation runs" fi +reuse_request="$(jq -nc \ + --arg targetSha "$TARGET_SHA" \ + --arg releaseProfile "$RELEASE_PROFILE" \ + --arg runReleaseSoak "$RUN_RELEASE_SOAK" \ + --argjson validationInputs "$expected_inputs" \ + '{targetSha: $targetSha, releaseProfile: $releaseProfile, runReleaseSoak: $runReleaseSoak, validationInputs: $validationInputs}')" + for ((index = 0; index < run_count; index += 1)); do run_id="$(jq -r ".[${index}].id" <<< "$runs_json")" validation_record="" @@ -244,6 +251,7 @@ for ((index = 0; index < run_count; index += 1)); do node "$VALIDATOR" \ --validate-run "$run_id" \ --repo "$REPO" \ + --reuse-request-json "$reuse_request" \ --trusted-workflow-ref "$TRUSTED_WORKFLOW_REF" \ --trusted-workflow-full-ref "$TRUSTED_WORKFLOW_FULL_REF" \ --trusted-workflow-sha "$TRUSTED_WORKFLOW_SHA" \ diff --git a/scripts/lib/plain-gh.mjs b/scripts/lib/plain-gh.mjs index 3d304298435a..74b5b3e9d3bb 100644 --- a/scripts/lib/plain-gh.mjs +++ b/scripts/lib/plain-gh.mjs @@ -1,5 +1,6 @@ -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import fs from "node:fs"; +import { promisify } from "node:util"; /** @typedef {import("node:child_process").ExecFileSyncOptions} ExecFileSyncOptions */ /** @typedef {import("node:child_process").ExecFileSyncOptionsWithBufferEncoding} ExecFileSyncOptionsWithBufferEncoding */ @@ -13,6 +14,7 @@ import fs from "node:fs"; */ const PLAIN_GH_MAX_BUFFER_BYTES = 32 * 1024 * 1024; +const execFileAsync = promisify(execFile); /** * @param {string} filePath @@ -167,17 +169,37 @@ export function execPlainGh(args, options = {}) { * @returns {string | Uint8Array} */ export function execGhRead(args, options = {}, params = {}) { - const env = plainGhEnv(options.env ?? process.env); - // Cache-aware reads stay on PATH even when another caller selects an explicit binary. - delete env.OPENCLAW_GH_BIN; const execFileSyncImpl = params.execFileSyncImpl ?? execFileSync; return execFileSyncImpl("gh", args, { ...options, - env, + env: ghReadEnv(options.env), maxBuffer: options.maxBuffer ?? PLAIN_GH_MAX_BUFFER_BYTES, }); } +/** @param {NodeJS.ProcessEnv} [env] */ +function ghReadEnv(env) { + const next = plainGhEnv(env); + // Cache-aware reads stay on PATH even when another caller selects an explicit binary. + delete next.OPENCLAW_GH_BIN; + return next; +} + +/** + * @param {readonly string[]} args + * @param {import("node:child_process").ExecFileOptions} [options] + * @returns {Promise} + */ +export async function execGhReadAsync(args, options = {}) { + const { stdout } = await execFileAsync("gh", args, { + ...options, + encoding: "utf8", + env: ghReadEnv(options.env), + maxBuffer: options.maxBuffer ?? PLAIN_GH_MAX_BUFFER_BYTES, + }); + return stdout; +} + /** * @param {readonly string[]} args * @param {ExecFileSyncOptions} [options] diff --git a/scripts/release-ci-summary.mjs b/scripts/release-ci-summary.mjs index d7821f1b22ce..3db7dc2db253 100755 --- a/scripts/release-ci-summary.mjs +++ b/scripts/release-ci-summary.mjs @@ -27,7 +27,12 @@ import { validateReleaseStateArtifact, validateReleaseTelegramWaiverBinding, } from "./full-release-validation-policy.mjs"; -import { execGhRead, plainGhAuthenticatedEnv, resolvePlainGhBin } from "./lib/plain-gh.mjs"; +import { + execGhRead, + execGhReadAsync, + plainGhAuthenticatedEnv, + 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"; @@ -214,6 +219,14 @@ function gh(args) { return runReleaseCiGh(args); } +function ghAsync(args) { + return execGhReadAsync(args, { + killSignal: "SIGKILL", + maxBuffer: 64 * 1024 * 1024, + timeout: GH_COMMAND_TIMEOUT_MS, + }); +} + function jsonGh(args) { return JSON.parse(gh(args)); } @@ -226,6 +239,10 @@ function githubRestJson(pathSuffix, repository = DEFAULT_REPO) { return jsonGh(githubRestArgs(pathSuffix, repository)); } +async function githubRestJsonAsync(pathSuffix, repository = DEFAULT_REPO) { + return JSON.parse(await ghAsync(githubRestArgs(pathSuffix, repository))); +} + export function artifactDownloadArgs(artifactId, repository = DEFAULT_REPO) { return ["api", `repos/${repository}/actions/artifacts/${artifactId}/zip`]; } @@ -479,7 +496,7 @@ function findExactChildRun(child, repository = DEFAULT_REPO) { return selectExactChildRunFromPages(runPages, child.displayTitle, child.headBranch); } -function findParentJobsAll(parentRunId, repository = DEFAULT_REPO) { +async function findParentJobsAll(parentRunId, repository = DEFAULT_REPO) { const jobs = []; for (let page = 1; page <= 10; page += 1) { const query = new URLSearchParams({ @@ -488,7 +505,12 @@ function findParentJobsAll(parentRunId, repository = DEFAULT_REPO) { per_page: "100", }); const pageJobs = - githubRestJson(`actions/runs/${parentRunId}/jobs?${query.toString()}`, repository).jobs ?? []; + ( + await githubRestJsonAsync( + `actions/runs/${parentRunId}/jobs?${query.toString()}`, + repository, + ) + ).jobs ?? []; jobs.push(...pageJobs); if (pageJobs.length < 100) { break; @@ -497,7 +519,7 @@ function findParentJobsAll(parentRunId, repository = DEFAULT_REPO) { return jobs; } -function findRunAttemptJobsAll(runId, runAttempt, repository = DEFAULT_REPO) { +async function findRunAttemptJobsAll(runId, runAttempt, repository = DEFAULT_REPO) { const jobs = []; for (let page = 1; page <= 10; page += 1) { const query = new URLSearchParams({ @@ -505,9 +527,11 @@ function findRunAttemptJobsAll(runId, runAttempt, repository = DEFAULT_REPO) { per_page: "100", }); const pageJobs = - githubRestJson( - `actions/runs/${runId}/attempts/${runAttempt}/jobs?${query.toString()}`, - repository, + ( + await githubRestJsonAsync( + `actions/runs/${runId}/attempts/${runAttempt}/jobs?${query.toString()}`, + repository, + ) ).jobs ?? []; jobs.push(...pageJobs); if (pageJobs.length < 100) { @@ -536,14 +560,14 @@ function isUnknownAllowEscapeSequencesFlag(error) { ); } -function parentJobLog(jobId, repository = DEFAULT_REPO) { +async function parentJobLog(jobId, repository = DEFAULT_REPO) { try { - return gh(parentJobLogArgs(jobId, repository)); + return await ghAsync(parentJobLogArgs(jobId, repository)); } catch (error) { if (!isUnknownAllowEscapeSequencesFlag(error)) { throw error; } - return gh(parentJobLogArgs(jobId, repository, false)); + return ghAsync(parentJobLogArgs(jobId, repository, false)); } } @@ -1637,7 +1661,7 @@ export function createReleaseEvidenceClient(repository = DEFAULT_REPO) { return githubRestJson(`git/ref/${refPath}`, normalizedRepository); }, getRun(runId) { - return githubRestJson(`actions/runs/${runId}`, normalizedRepository); + return githubRestJsonAsync(`actions/runs/${runId}`, normalizedRepository); }, getRunView(runId) { return jsonGh([ @@ -1647,7 +1671,7 @@ export function createReleaseEvidenceClient(repository = DEFAULT_REPO) { "--repo", normalizedRepository, "--json", - "status,conclusion,attempt,headBranch,headSha,url,jobs", + "status,conclusion,attempt,headBranch,headSha,url", ]); }, loadManifest(runId, runAttempt, manifestPath) { @@ -1659,7 +1683,7 @@ export function createReleaseEvidenceClient(repository = DEFAULT_REPO) { }; } -function loadValidatedParentEvidence({ +async function loadValidatedParentEvidence({ client, expectedRunAttempts, manifestPath, @@ -1667,7 +1691,7 @@ function loadValidatedParentEvidence({ runId, }) { const parentView = client.getRunView(runId); - const parentRun = client.getRun(runId); + const parentRun = await client.getRun(runId); const parentRunAttempt = normalizePositiveInteger( parentRun.run_attempt, `full release parent ${runId} run attempt`, @@ -1905,7 +1929,7 @@ export function resolveVerifierIdentity( }; } -function validateStrictChildRun({ +async function validateStrictChildRun({ child, childEvidence, client, @@ -1917,7 +1941,7 @@ function validateStrictChildRun({ runId, expectedRunAttempts, }) { - const run = client.getRun(runId); + const run = await client.getRun(runId); const effectiveRunAttempt = normalizePositiveInteger( run.run_attempt, `${child.name} run attempt`, @@ -1956,27 +1980,27 @@ function validateStrictChildRun({ runId, parentEvidence.manifest, parentJobs, - client.getJobLog(parentJob.id), + await client.getJobLog(parentJob.id), repository, plannedChild?.runAttempt, plannedChild !== undefined, ); let jobs; let composite; + let currentAttemptJobs; if (plannedChild && childEvidence) { if (childEvidence.effectiveRunAttempt > effectiveRunAttempt) { throw new Error(`manifest child composite evidence mismatch: ${child.name}`); } - const attempts = Array.from( - { length: childEvidence.effectiveRunAttempt - plannedChild.runAttempt + 1 }, - (_, index) => { - const runAttempt = plannedChild.runAttempt + index; - return { - jobs: client.getRunAttemptJobs(runId, runAttempt), - runAttempt, - }; - }, - ); + const attempts = []; + for ( + let runAttempt = plannedChild.runAttempt; + runAttempt <= childEvidence.effectiveRunAttempt; + runAttempt += 1 + ) { + currentAttemptJobs = await client.getRunAttemptJobs(runId, runAttempt); + attempts.push({ jobs: currentAttemptJobs, runAttempt }); + } const evidence = composeReleaseChildAttemptEvidence({ attempts, expected: { @@ -2018,7 +2042,7 @@ function validateStrictChildRun({ jobs = run.conclusion === "success" && child.manifestKey !== "productPerformance" ? [] - : client.getParentJobs(runId); + : await client.getParentJobs(runId); } if ( run.repository?.full_name !== repository || @@ -2037,12 +2061,16 @@ function validateStrictChildRun({ throw new Error(`manifest child run does not pass release policy: ${child.name}`); } if (child.manifestKey === "productPerformance") { - const currentAttemptJobs = composite - ? client - .getRunAttemptJobs(runId, effectiveRunAttempt) - .map((job) => Object.assign({}, job, { run_attempt: effectiveRunAttempt })) - : jobs; - validatePerformanceArtifactOnlyJobs(currentAttemptJobs, effectiveRunAttempt); + // A composite may carry earlier successes; the publication guard must pass + // in the current raw attempt, already fetched while composing the evidence. + validatePerformanceArtifactOnlyJobs( + composite + ? currentAttemptJobs.map((job) => + Object.assign({}, job, { run_attempt: effectiveRunAttempt }), + ) + : jobs, + effectiveRunAttempt, + ); } return { @@ -2078,6 +2106,7 @@ function validateStrictChildRun({ * @param {{ * manifestPath?: string, * repository?: string, + * reuseRequest?: { releaseProfile: string, runReleaseSoak: string, targetSha: string, validationInputs: Record }, * runId: string, * expectedChangedPaths?: string[], * expectedEvidencePolicy?: string, @@ -2093,10 +2122,11 @@ function validateStrictChildRun({ * verifierSourceSha: string, * }} options */ -export function validateReleaseRunEvidence( +export async function validateReleaseRunEvidence( { manifestPath, repository = DEFAULT_REPO, + reuseRequest, runId, expectedChangedPaths, expectedEvidencePolicy, @@ -2127,7 +2157,7 @@ export function validateReleaseRunEvidence( ); const evidenceClient = client ?? createReleaseEvidenceClient(normalizedRepository); const verifier = resolveVerifierIdentity(verifierSourceSha, verifierSourceContent); - const currentEvidence = loadValidatedParentEvidence({ + const currentEvidence = await loadValidatedParentEvidence({ client: evidenceClient, expectedRunAttempts: remainingExpectedRunAttempts, manifestPath, @@ -2142,6 +2172,41 @@ export function validateReleaseRunEvidence( expectedSelectedRunId, expectedTargetSha, }; + if (reuseRequest !== undefined) { + // Reject mismatched searches before fetching root/child evidence. Matching + // metadata only admits a candidate to the full verifier below; it never passes it. + const manifest = currentEvidence.manifest; + if ( + manifest.version !== 4 || + manifest.evidenceReuse || + manifest.rerunGroup !== "all" || + manifest.releaseProfile !== reuseRequest.releaseProfile || + manifest.runReleaseSoak !== reuseRequest.runReleaseSoak || + JSON.stringify(canonicalJson(manifest.validationInputs)) !== + JSON.stringify(canonicalJson(reuseRequest.validationInputs)) + ) { + throw new Error( + "ineligible reuse candidate: requires a direct full run with matching profile, soak, and inputs", + ); + } + const exactTarget = manifest.targetSha === reuseRequest.targetSha; + validateRequestedEvidenceReuse( + manifest, + manifest, + manifest, + { + expectedChangedPaths: exactTarget ? [] : ["CHANGELOG.md"], + expectedEvidencePolicy: exactTarget + ? EXACT_TARGET_EVIDENCE_REUSE_POLICY + : CHANGELOG_ONLY_EVIDENCE_REUSE_POLICY, + expectedEvidenceSha: manifest.targetSha, + expectedRootRunId: manifest.runId, + expectedSelectedRunId: manifest.runId, + expectedTargetSha: reuseRequest.targetSha, + }, + (base, head) => evidenceClient.compareCommits(base, head), + ); + } const producerIdentities = new Map([ [ currentEvidence.manifest.runId, @@ -2160,7 +2225,7 @@ export function validateReleaseRunEvidence( let selectedEvidence = currentEvidence; const reuse = currentEvidence.manifest.evidenceReuse; if (reuse) { - rootEvidence = loadValidatedParentEvidence({ + rootEvidence = await loadValidatedParentEvidence({ client: evidenceClient, expectedRunAttempts: remainingExpectedRunAttempts, repository: normalizedRepository, @@ -2169,7 +2234,7 @@ export function validateReleaseRunEvidence( selectedEvidence = reuse.selectedRunId === reuse.runId ? rootEvidence - : loadValidatedParentEvidence({ + : await loadValidatedParentEvidence({ client: evidenceClient, expectedRunAttempts: remainingExpectedRunAttempts, repository: normalizedRepository, @@ -2271,7 +2336,7 @@ export function validateReleaseRunEvidence( throw new Error("release validation manifest composite child set is invalid"); } const dispatchEvidence = rootEvidence; - const parentJobs = evidenceClient.getParentJobs(dispatchEvidence.manifest.runId); + const parentJobs = await evidenceClient.getParentJobs(dispatchEvidence.manifest.runId); const childEntries = executionPlan ? expectedChildren.map((child) => { const manifestRunId = rootEvidence.manifest.childRunIds[child.manifestKey]; @@ -2281,20 +2346,30 @@ export function validateReleaseRunEvidence( return { child, runId: child.plannedChild.runId }; }) : manifestChildEntries(rootEvidence.manifest, expectedChildren, selectedKeys); - const children = childEntries.map(({ child, runId: childRunId }) => - validateStrictChildRun({ - child, - childEvidence: rootEvidence.manifest.childEvidence?.[child.manifestKey], - client: evidenceClient, - parentEvidence: dispatchEvidence, - parentJobs, - plannedChild: child.plannedChild, - releaseProfile: rootEvidence.manifest.releaseProfile, - repository: normalizedRepository, - runId: childRunId, - expectedRunAttempts: remainingExpectedRunAttempts, - }), + // The fixed child set bounds concurrent reads (at most seven). Each child + // walks its attempts/pages serially; drain all reads before returning or failing. + const childResults = await Promise.allSettled( + childEntries.map(({ child, runId: childRunId }) => + validateStrictChildRun({ + child, + childEvidence: rootEvidence.manifest.childEvidence?.[child.manifestKey], + client: evidenceClient, + parentEvidence: dispatchEvidence, + parentJobs, + plannedChild: child.plannedChild, + releaseProfile: rootEvidence.manifest.releaseProfile, + repository: normalizedRepository, + runId: childRunId, + expectedRunAttempts: remainingExpectedRunAttempts, + }), + ), ); + const children = childResults.map((result) => { + if (result.status === "rejected") { + throw result.reason; + } + return result.value; + }); if (remainingExpectedRunAttempts?.size) { throw new Error( `expected run attempts contain unvalidated run IDs: ${[...remainingExpectedRunAttempts.keys()].join(", ")}`, @@ -2370,6 +2445,7 @@ function parseReleaseCiSummaryArgs(argv) { json: false, manifestPath: undefined, repository: DEFAULT_REPO, + reuseRequest: undefined, runId: undefined, trustedWorkflowFullRef: undefined, trustedWorkflowRef: "main", @@ -2388,6 +2464,8 @@ function parseReleaseCiSummaryArgs(argv) { options.repository = argv[++index]; } else if (argument === "--manifest") { options.manifestPath = argv[++index]; + } else if (argument === "--reuse-request-json") { + options.reuseRequest = normalizeJsonObject(JSON.parse(argv[++index]), "reuse request"); } else if (argument === "--trusted-workflow-ref") { options.trustedWorkflowRef = argv[++index]; } else if (argument === "--trusted-workflow-full-ref") { @@ -2454,6 +2532,9 @@ function parseReleaseCiSummaryArgs(argv) { if (!options.validate && options.manifestPath) { throw new Error("--manifest requires --validate-run"); } + if (!options.validate && options.reuseRequest !== undefined) { + throw new Error("--reuse-request-json requires --validate-run"); + } if (!options.validate && options.expectedRunAttempts !== undefined) { throw new Error("--expected-run-attempts-json requires --validate-run"); } @@ -2474,7 +2555,7 @@ function printUsage() { [ "usage: release-ci-summary.mjs ", " release-ci-summary.mjs --watch [--interval seconds]", - " release-ci-summary.mjs --validate-run [--repo owner/name] [--trusted-workflow-ref main --trusted-workflow-full-ref refs/heads/main] [--trusted-workflow-sha sha] [--manifest path] [--verifier-source-sha sha --verifier-source-file path] [--expected-target-sha sha --expected-evidence-sha sha --expected-evidence-policy policy --expected-root-run-id id --expected-selected-run-id id --expected-changed-paths-json json] [--expected-run-attempts-json json] --json", + " release-ci-summary.mjs --validate-run [--repo owner/name] [--trusted-workflow-ref main --trusted-workflow-full-ref refs/heads/main] [--trusted-workflow-sha sha] [--manifest path] [--verifier-source-sha sha --verifier-source-file path] [--expected-target-sha sha --expected-evidence-sha sha --expected-evidence-policy policy --expected-root-run-id id --expected-selected-run-id id --expected-changed-paths-json json] [--expected-run-attempts-json json] [--reuse-request-json json] --json", ].join("\n"), ); } @@ -2650,7 +2731,7 @@ async function main() { if (options.validate) { try { - const evidence = validateReleaseRunEvidence({ + const evidence = await validateReleaseRunEvidence({ expectedChangedPaths: options.expectedChangedPaths, expectedEvidencePolicy: options.expectedEvidencePolicy, expectedEvidenceSha: options.expectedEvidenceSha, @@ -2660,6 +2741,7 @@ async function main() { expectedTargetSha: options.expectedTargetSha, manifestPath: options.manifestPath, repository, + reuseRequest: options.reuseRequest, runId, trustedWorkflowFullRef: options.trustedWorkflowFullRef, trustedWorkflowRef: options.trustedWorkflowRef, @@ -2826,43 +2908,46 @@ async function main() { sourceManifest.workflowRef, selectedKeys, ); - const sourceParentJobs = findParentJobsAll(sourceManifest.runId, repository); - children = manifestChildEntries(sourceManifest, expectedChildren, selectedKeys).map( - ({ child, runId: childRunId }) => { - const run = githubRestJson(`actions/runs/${childRunId}`, repository); - const originAttempt = resolveManifestChildOriginAttempt( - run, - child, - sourceManifest, - sourceParentJobs, + const sourceParentJobs = await findParentJobsAll(sourceManifest.runId, repository); + children = []; + for (const { child, runId: childRunId } of manifestChildEntries( + sourceManifest, + expectedChildren, + selectedKeys, + )) { + 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, + await parentJobLog(parentJob.id, repository), + repository, + ); + if (child.manifestKey === "productPerformance") { + validatePerformanceArtifactOnlyJobs( + await findParentJobsAll(childRunId, repository), + run.run_attempt, ); - 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 }; - }, - ); + } + children.push({ child, run: validatedRun }); + } } else { console.log("candidate-sha: unavailable (release validation manifest not uploaded)"); if (parent.status === "completed" && parent.conclusion === "success") { diff --git a/test/scripts/find-reusable-release-validation.test.ts b/test/scripts/find-reusable-release-validation.test.ts index b13215e393a3..28780df60ad5 100644 --- a/test/scripts/find-reusable-release-validation.test.ts +++ b/test/scripts/find-reusable-release-validation.test.ts @@ -430,6 +430,7 @@ exec cat "\${fixture}.json" const FAKE_VALIDATOR = `#!/usr/bin/env node import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { isDeepStrictEqual } from "node:util"; const runIndex = process.argv.indexOf("--validate-run"); const repoIndex = process.argv.indexOf("--repo"); @@ -438,6 +439,7 @@ const trustedFullRefIndex = process.argv.indexOf("--trusted-workflow-full-ref"); const trustedShaIndex = process.argv.indexOf("--trusted-workflow-sha"); const verifierShaIndex = process.argv.indexOf("--verifier-source-sha"); const verifierFileIndex = process.argv.indexOf("--verifier-source-file"); +const reuseRequestIndex = process.argv.indexOf("--reuse-request-json"); if ( runIndex < 0 || repoIndex < 0 || @@ -446,6 +448,8 @@ if ( trustedShaIndex < 0 || verifierShaIndex < 0 || verifierFileIndex < 0 || + reuseRequestIndex < 0 || + !isDeepStrictEqual(JSON.parse(process.argv[reuseRequestIndex + 1]), JSON.parse(process.env.FAKE_REUSE_REQUEST)) || process.argv[repoIndex + 1] !== "openclaw/openclaw" || process.argv[trustedRefIndex + 1] !== process.env.FAKE_TRUSTED_WORKFLOW_REF || process.argv[trustedFullRefIndex + 1] !== process.env.FAKE_TRUSTED_WORKFLOW_FULL_REF || @@ -609,6 +613,12 @@ function runResolver(args: { FAKE_TRUSTED_WORKFLOW_REF: trustedWorkflowRef, FAKE_TRUSTED_WORKFLOW_SHA: trustedWorkflowSha, FAKE_VALIDATOR_FIXTURES: args.fixtures, + FAKE_REUSE_REQUEST: JSON.stringify({ + targetSha: args.targetSha, + releaseProfile: args.releaseProfile ?? "full", + runReleaseSoak: args.runReleaseSoak ?? "true", + validationInputs: args.inputs === undefined ? DEFAULT_INPUTS : args.inputs, + }), FAKE_VERIFIER_SHA: verifierSha, GITHUB_OUTPUT: "", OPENCLAW_RELEASE_CI_SUMMARY_VALIDATOR: args.validatorPath, diff --git a/test/scripts/full-release-validation-continuation-workflow.test.ts b/test/scripts/full-release-validation-continuation-workflow.test.ts index 4f65b875b09f..f2678668f730 100644 --- a/test/scripts/full-release-validation-continuation-workflow.test.ts +++ b/test/scripts/full-release-validation-continuation-workflow.test.ts @@ -1,4 +1,7 @@ -import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; import { parse } from "yaml"; @@ -19,6 +22,68 @@ function step(job: string, name: string) { return match; } +describe("full release metadata checkouts", () => { + it.each([ + { + job: "resolve_target", + targetCheckout: "Checkout target package manifest", + imports: [ + "release-tooling-identity.mjs", + "full-release-candidate-contract.mjs", + "full-release-validation-policy.mjs", + ], + }, + { + job: "evidence_reuse", + targetCheckout: "Checkout target SHA", + imports: ["release-ci-summary.mjs"], + }, + ])("runs $job tooling from only its sparse files", ({ job, targetCheckout, imports }) => { + const root = mkdtempSync(join(tmpdir(), "openclaw-release-sparse-")); + try { + for (const name of ["Checkout trusted workflow helper", targetCheckout]) { + const checkout = step(job, name).with as Record; + expect(checkout["sparse-checkout-cone-mode"]).toBe(false); + const paths = String(checkout["sparse-checkout"] ?? "") + .split("\n") + .map((path) => path.trim()) + .filter(Boolean); + expect(paths.length).toBeGreaterThan(0); + for (const path of paths) { + const destination = join(root, String(checkout.path), path); + mkdirSync(dirname(destination), { recursive: true }); + copyFileSync(path, destination); + } + } + + const runNode = (args: string[], cwd = root) => + execFileSync(process.execPath, args, { + cwd, + encoding: "utf8", + timeout: 10_000, + env: { ...process.env, NODE_OPTIONS: "", NODE_PATH: "" }, + }); + expect( + runNode([ + "--input-type=module", + "-e", + imports.map((file) => `await import("./workflow/scripts/${file}");`).join("\n"), + ]), + ).toBe(""); + if (job === "evidence_reuse") { + expect( + runNode( + [join(root, "workflow/scripts/release-preflight.mjs"), "--macos-versions-only"], + join(root, "target"), + ), + ).toContain("macOS app version metadata OK"); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("full release same-parent recovery workflow", () => { it("has no continuation payload and dispatches child work only on attempt one", () => { expect(workflow.on.workflow_dispatch.inputs).not.toHaveProperty("continuation_plan_json"); diff --git a/test/scripts/plain-gh.test.ts b/test/scripts/plain-gh.test.ts index dd8fa41465cb..9d0bdd9ff422 100644 --- a/test/scripts/plain-gh.test.ts +++ b/test/scripts/plain-gh.test.ts @@ -15,6 +15,7 @@ import { execGhApiRead, execGhJson, execGhRead, + execGhReadAsync, execPlainGh, plainGhAuthenticatedEnv, resolvePlainGhBin, @@ -285,7 +286,7 @@ describe("plain gh subprocess contracts", () => { expect(fixture.calls()).toEqual([{ route: "protected", argv: ["--version"], override: null }]); }); - it("keeps explicit reads override-independent and parses normalized JSON", () => { + it("keeps explicit reads override-independent and parses normalized JSON", async () => { const fixture = makeFixture(); fixture.env.OPENCLAW_GH_BIN = "/invalid-explicit-override"; const options = { encoding: "utf8" as const, env: fixture.env }; @@ -298,10 +299,38 @@ describe("plain gh subprocess contracts", () => { route: "protected", override: null, }); - expect(fixture.calls()).toHaveLength(2); + expect(JSON.parse(await execGhReadAsync(["--version"], options))).toMatchObject({ + route: "protected", + override: null, + colors: { NO_COLOR: "1", FORCE_COLOR: "0", CLICOLOR: "0", CLICOLOR_FORCE: "0" }, + }); + expect(fixture.calls()).toHaveLength(3); expect(fixture.env.OPENCLAW_GH_BIN).toBe("/invalid-explicit-override"); }); + it("preserves asynchronous read refusals and buffer limits without another route", async () => { + const fixture = makeFixture(); + fixture.env.OPENCLAW_GH_BIN = fixture.override; + fixture.env.FAKE_GH_REJECT = "1"; + const options = { env: fixture.env, timeout: 10_000, killSignal: "SIGKILL" as const }; + await expect(execGhReadAsync(["--version"], options)).rejects.toMatchObject({ + code: 23, + stderr: "policy denied\n", + stdout: "protected refusal\n", + }); + delete fixture.env.FAKE_GH_REJECT; + fixture.env.FAKE_GH_BYTES = "4096"; + await expect( + execGhReadAsync(["--version"], { ...options, maxBuffer: 1024 }), + ).rejects.toMatchObject({ + code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", + }); + expect(fixture.calls()).toEqual([ + { route: "protected", argv: ["--version"], override: null }, + { route: "protected", argv: ["--version"], override: null }, + ]); + }); + it.each([execPlainGh, execGhRead])( "preserves binary output, streaming, and caller buffer limits (%#)", (execute) => { diff --git a/test/scripts/release-ci-summary.test.ts b/test/scripts/release-ci-summary.test.ts index d239e90ef4d4..ea540d2c7f44 100644 --- a/test/scripts/release-ci-summary.test.ts +++ b/test/scripts/release-ci-summary.test.ts @@ -232,7 +232,7 @@ ${shimBody} "--eval", `import { createReleaseEvidenceClient } from ${JSON.stringify(pathToFileURL(resolve(SCRIPT)).href)}; try { - process.stdout.write(createReleaseEvidenceClient("owner/repo").getJobLog("123")); + process.stdout.write(await createReleaseEvidenceClient("owner/repo").getJobLog("123")); } catch (error) { process.stdout.write(JSON.stringify({ message: error instanceof Error ? error.message : String(error), @@ -762,6 +762,68 @@ type ReleaseCiWatchState = { url?: string; }; +function trustedMainFullFixture() { + const fixture = trustedMainPackageFixture({ manifestVersion: 3 }); + const children = expectedChildDispatches(fixture.runId, 1, "main", 3).filter( + (child) => child.manifestKey !== "npmTelegram", + ); + const runs = children.map((child, index) => ({ + ...fixture.childRun, + display_title: child.displayTitle, + id: 101 + index, + path: `.github/workflows/${child.workflow}`, + })); + const jobs = children.map((child, index) => ({ + ...fixture.parentJob, + id: 201 + index, + name: child.parentJobName, + })); + const manifest = { + ...fixture.manifest, + childRuns: { + ...fixture.manifest.childRuns, + ...Object.fromEntries( + children.map((child, index) => { + const runId = String(expectDefined(runs[index], "child run").id); + return [ + child.manifestKey, + child.manifestKey === "productPerformance" + ? { blocking: true, conclusion: "success", runId } + : runId, + ]; + }), + ), + }, + rerunGroup: "all", + version: 4, + }; + const client = { + ...fixture.client, + getJobLog: vi.fn((jobId: number) => { + const index = jobs.findIndex((job) => job.id === jobId); + const child = expectDefined(children[index], "dispatch child"); + const run = expectDefined(runs[index], "child run"); + return `TARGET_SHA: ${fixture.targetSha}\n-f publish_reports=false\nDispatched ${child.workflow}: https://github.com/openclaw/openclaw/actions/runs/${run.id} (attempt 1)`; + }), + getParentJobs: vi.fn((runId: string) => + runId === fixture.runId + ? jobs + : [{ ...fixture.parentJob, name: "Verify artifact-only report mode" }], + ), + getRun: vi.fn((runId: string) => + runId === fixture.runId + ? fixture.parentRun + : expectDefined( + runs.find((run) => String(run.id) === runId), + "child run", + ), + ), + loadExecutionPlan: vi.fn(() => undefined), + loadManifest: () => ({ artifact: fixture.artifact, manifest }), + }; + return { ...fixture, client, manifest, runs }; +} + function createReleaseCiWatchFixture(states: ReleaseCiWatchState[]) { const root = mkdtempSync(join(tmpdir(), "release-ci-watch-")); const callsPath = join(root, "calls.jsonl"); @@ -856,6 +918,15 @@ describe("release CI summary child correlation", () => { { args: ["29071366025", "--interval", "0"], message: "positive number of seconds" }, { args: ["--validate-run", "29071366025", "--watch"], message: "cannot be combined" }, { args: ["--manifest", "/tmp/manifest.json"], message: "requires --validate-run" }, + { args: ["123", "--reuse-request-json", "{}"], message: "requires --validate-run" }, + { + args: ["--validate-run", "123", "--reuse-request-json", "null"], + message: "reuse request is invalid", + }, + { + args: ["--validate-run", "123", "--reuse-request-json", "[]"], + message: "reuse request is invalid", + }, { args: ["--validate-run", "29071366025", "--verifier-source-file", "/tmp/verifier.mjs"], message: "requires --verifier-source-sha", @@ -1111,18 +1182,20 @@ describe("release CI summary child correlation", () => { }, ); - it("bridges only attempt-one manifest v2 artifacts with the legacy stable name", () => { + it("bridges only attempt-one manifest v2 artifacts with the legacy stable name", async () => { const legacyV2 = trustedMainPackageFixture(); legacyV2.artifact.name = `full-release-validation-${legacyV2.runId}`; expect( - validateReleaseRunEvidence( - { - repository: "openclaw/openclaw", - runId: legacyV2.runId, - verifierSourceContent: readFileSync(SCRIPT), - verifierSourceSha: "c".repeat(40), - }, - legacyV2.client, + ( + await validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: legacyV2.runId, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + legacyV2.client, + ) ).root.artifact.name, ).toBe(legacyV2.artifact.name); @@ -1131,7 +1204,7 @@ describe("release CI summary child correlation", () => { workflowSha: "a".repeat(40), }); legacyV3.artifact.name = `full-release-validation-${legacyV3.runId}`; - expect(() => + await expect( validateReleaseRunEvidence( { repository: "openclaw/openclaw", @@ -1141,16 +1214,16 @@ describe("release CI summary child correlation", () => { }, legacyV3.client, ), - ).toThrow("legacy release validation manifest artifact is not compatible"); + ).rejects.toThrow("legacy release validation manifest artifact is not compatible"); }); - it("normalizes a pre-tooling trusted-main producer separately from the current verifier", () => { + it("normalizes a pre-tooling trusted-main producer separately from the current verifier", async () => { const fixture = trustedMainPackageFixture({ targetSha: "8".repeat(40), workflowSha: "0".repeat(40), }); const verifierSourceSha = "c".repeat(40); - const evidence = validateReleaseRunEvidence( + const evidence = await validateReleaseRunEvidence( { repository: "openclaw/openclaw", runId: fixture.runId, @@ -1214,9 +1287,147 @@ describe("release CI summary child correlation", () => { }); }); + it.each(["version", "reused", "group", "profile", "soak", "inputs", "target"])( + "rejects an ineligible reuse %s before fetching child evidence", + async (mismatch) => { + const fixture = trustedMainFullFixture(); + const reuseRequest = { + releaseProfile: "full", + runReleaseSoak: "true", + targetSha: fixture.targetSha, + validationInputs: { ...fixture.manifest.validationInputs }, + }; + switch (mismatch) { + case "version": + fixture.manifest.version = 3; + break; + case "reused": + fixture.manifest.evidenceReuse = { + changedPaths: [], + evidenceSha: fixture.targetSha, + policy: "exact-target-full-validation-v1", + runId: "29090000000", + selectedRunId: "29090000000", + }; + break; + case "group": + fixture.manifest.rerunGroup = "package"; + break; + case "profile": + reuseRequest.releaseProfile = "beta"; + break; + case "soak": + reuseRequest.runReleaseSoak = "false"; + break; + case "inputs": + reuseRequest.validationInputs.provider = "anthropic"; + break; + case "target": + reuseRequest.targetSha = "7".repeat(40); + fixture.client.compareCommits = () => ({ + files: [{ filename: "src/index.ts", status: "modified" }], + merge_base_commit: { sha: fixture.targetSha }, + status: "ahead", + }); + } + await expect( + validateReleaseRunEvidence( + { + reuseRequest, + runId: fixture.runId, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + fixture.client, + ), + ).rejects.toThrow( + mismatch === "target" ? "failed commit comparison" : "ineligible reuse candidate", + ); + expect(fixture.client.getRun).toHaveBeenCalledExactlyOnceWith(fixture.runId); + expect(fixture.client.loadExecutionPlan).not.toHaveBeenCalled(); + expect(fixture.client.getParentJobs).not.toHaveBeenCalled(); + expect(fixture.client.getJobLog).not.toHaveBeenCalled(); + }, + ); + + it.each([false, true])( + "collects independent child evidence concurrently and drains reads (failure=%s)", + async (failure) => { + const fixture = trustedMainFullFixture(); + let active = 0; + let peak = 0; + let completed = 0; + const client = { + ...fixture.client, + async getJobLog(jobId: number) { + active += 1; + peak = Math.max(peak, active); + await new Promise((complete) => setImmediate(complete)); + active -= 1; + completed += 1; + if (failure && jobId === 201) { + throw new Error("dispatch log unavailable"); + } + return fixture.client.getJobLog(jobId); + }, + }; + const validation = Promise.resolve().then(() => + validateReleaseRunEvidence( + { + runId: fixture.runId, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + client, + ), + ); + if (failure) { + await expect(validation).rejects.toThrow("dispatch log unavailable"); + } else { + await expect(validation).resolves.toMatchObject({ valid: true }); + } + expect(peak).toBeGreaterThan(1); + expect(peak).toBeLessThanOrEqual(7); + expect(completed).toBe(6); + expect(active).toBe(0); + }, + ); + + it.each([false, true])( + "retains full child proof for eligible reuse (changelog=%s)", + async (changelog) => { + const fixture = trustedMainFullFixture(); + fixture.client.compareCommits = () => ({ + files: [{ filename: "CHANGELOG.md", status: "modified" }], + merge_base_commit: { sha: fixture.targetSha }, + status: "ahead", + }); + const options = { + reuseRequest: { + releaseProfile: fixture.manifest.releaseProfile, + runReleaseSoak: fixture.manifest.runReleaseSoak, + targetSha: changelog ? "7".repeat(40) : fixture.targetSha, + validationInputs: { ...fixture.manifest.validationInputs }, + }, + runId: fixture.runId, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }; + const evidence = await validateReleaseRunEvidence(options, fixture.client); + expect(evidence.valid).toBe(true); + expect(evidence.children).toHaveLength(6); + expect(fixture.client.getJobLog).toHaveBeenCalledTimes(6); + + expectDefined(fixture.runs[0], "CI run").head_sha = "f".repeat(40); + await expect(validateReleaseRunEvidence(options, fixture.client)).rejects.toThrow( + "manifest child dispatch tuple mismatch", + ); + }, + ); + it.each(["", "2026.8.1-owner-approved"])( "recomputes mixed-attempt evidence with Telegram waiver %j", - (telegramWaiver) => { + async (telegramWaiver) => { const fixture = trustedMainPackageFixture({ manifestVersion: 3, workflowSha: "a".repeat(40), @@ -1389,7 +1600,7 @@ describe("release CI summary child correlation", () => { }, fixture.client, ); - const evidence = validate(); + const evidence = await validate(); expect(evidence.children).toEqual([ expect.objectContaining({ compositeJobsSha256: releaseChecksEvidence.compositeJobsSha256, @@ -1399,10 +1610,10 @@ describe("release CI summary child correlation", () => { ]); if (telegramWaiver) { delete manifest.validationInputs.telegramWaiver; - expect(() => validate()).toThrow(/Telegram waiver/u); + await expect(validate()).rejects.toThrow(/Telegram waiver/u); Object.assign(manifest.validationInputs, waiver); client.loadExecutionPlan = () => undefined as never; - expect(() => validate()).toThrow(/Telegram waiver/u); + await expect(validate()).rejects.toThrow(/Telegram waiver/u); client.loadExecutionPlan = () => executionPlan; } @@ -1412,7 +1623,7 @@ describe("release CI summary child correlation", () => { [{ [fixture.runId]: 2 }, "expected run attempts omitted"], [{ [fixture.runId]: 2, [String(fixture.childRun.id)]: 2, "999": 1 }, "unvalidated run IDs"], ] as const) { - expect(() => validate(expectedRunAttempts)).toThrow(message); + await expect(validate(expectedRunAttempts)).rejects.toThrow(message); } const staleJobs = [ @@ -1442,7 +1653,9 @@ describe("release CI summary child correlation", () => { triggeringActor: "github-actions[bot]", }; manifest.childEvidence.releaseChecks = staleEvidence; - expect(() => validate({ [fixture.runId]: 2, [String(fixture.childRun.id)]: 2 })).toThrowError( + await expect( + validate({ [fixture.runId]: 2, [String(fixture.childRun.id)]: 2 }), + ).rejects.toThrowError( expect.objectContaining({ message: "successful parent manifest predates OpenClaw Release Checks attempt 2", refreshable: true, @@ -1452,7 +1665,9 @@ describe("release CI summary child correlation", () => { manifest.childEvidence.releaseChecks = releaseChecksEvidence; const loadManifest = client.loadManifest.bind(client); client.loadManifest = () => undefined as never; - expect(() => validate({ [fixture.runId]: 2, [String(fixture.childRun.id)]: 2 })).toThrowError( + await expect( + validate({ [fixture.runId]: 2, [String(fixture.childRun.id)]: 2 }), + ).rejects.toThrowError( expect.objectContaining({ message: `successful parent run is missing its release validation manifest: ${fixture.runId}`, refreshable: true, @@ -1463,7 +1678,7 @@ describe("release CI summary child correlation", () => { releaseChecksEvidence.jobs[0]!.conclusion = "failure"; let malformedError: unknown; try { - validate(); + await validate(); } catch (error) { malformedError = error; } @@ -1474,7 +1689,7 @@ describe("release CI summary child correlation", () => { releaseChecksEvidence.jobs[0]!.conclusion = "success"; fixture.childRun.actor = { login: "release-operator" }; - expect(() => validate()).toThrow("execution plan child dispatch tuple mismatch"); + await expect(validate()).rejects.toThrow("execution plan child dispatch tuple mismatch"); }, ); @@ -1522,60 +1737,40 @@ describe("release CI summary child correlation", () => { [profile, "Run QA Lab live Telegram lane"], [profile, "Run package acceptance / Telegram package acceptance / Run Telegram package E2E"], ]), - ])("accepts %s advisory %s failures through canonical policy", (releaseProfile, jobName) => { - const fixture = trustedMainPackageFixture(); - fixture.manifest.releaseProfile = releaseProfile; - fixture.childRun.conclusion = "failure"; - const originalClient = { ...fixture.client }; - fixture.client.getParentJobs = (requestedRunId: string) => - requestedRunId === String(fixture.childRun.id) - ? [ - { - completed_at: "2026-07-10T01:10:00Z", - conclusion: "failure", - id: 86293408711, - name: jobName, - run_attempt: 1, - started_at: "2026-07-10T01:00:00Z", - status: "completed", - steps: [], - }, - { - completed_at: "2026-07-10T01:10:00Z", - conclusion: "success", - id: 86293408712, - name: "Verify release checks", - run_attempt: 1, - started_at: "2026-07-10T01:00:00Z", - status: "completed", - steps: [], - }, - ] - : originalClient.getParentJobs(requestedRunId); + ])( + "accepts %s advisory %s failures through canonical policy", + async (releaseProfile, jobName) => { + const fixture = trustedMainPackageFixture(); + fixture.manifest.releaseProfile = releaseProfile; + fixture.childRun.conclusion = "failure"; + const originalClient = { ...fixture.client }; + fixture.client.getParentJobs = (requestedRunId: string) => + requestedRunId === String(fixture.childRun.id) + ? [ + { + completed_at: "2026-07-10T01:10:00Z", + conclusion: "failure", + id: 86293408711, + name: jobName, + run_attempt: 1, + started_at: "2026-07-10T01:00:00Z", + status: "completed", + steps: [], + }, + { + completed_at: "2026-07-10T01:10:00Z", + conclusion: "success", + id: 86293408712, + name: "Verify release checks", + run_attempt: 1, + started_at: "2026-07-10T01:00:00Z", + status: "completed", + steps: [], + }, + ] + : originalClient.getParentJobs(requestedRunId); - const evidence = validateReleaseRunEvidence( - { - repository: "openclaw/openclaw", - runId: fixture.runId, - verifierSourceContent: readFileSync(SCRIPT), - verifierSourceSha: "c".repeat(40), - }, - fixture.client, - ); - expect(evidence.conclusions).toMatchObject({ - allRequiredSucceeded: true, - children: { releaseChecks: "failure" }, - }); - }); - - it("accepts a trusted-main producer when the candidate is the same main commit", () => { - const sharedSha = "a".repeat(40); - const fixture = trustedMainPackageFixture({ - targetSha: sharedSha, - workflowSha: sharedSha, - }); - expect( - validateReleaseRunEvidence( + const evidence = await validateReleaseRunEvidence( { repository: "openclaw/openclaw", runId: fixture.runId, @@ -1583,6 +1778,31 @@ describe("release CI summary child correlation", () => { verifierSourceSha: "c".repeat(40), }, fixture.client, + ); + expect(evidence.conclusions).toMatchObject({ + allRequiredSucceeded: true, + children: { releaseChecks: "failure" }, + }); + }, + ); + + it("accepts a trusted-main producer when the candidate is the same main commit", async () => { + const sharedSha = "a".repeat(40); + const fixture = trustedMainPackageFixture({ + targetSha: sharedSha, + workflowSha: sharedSha, + }); + expect( + ( + await validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: fixture.runId, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + fixture.client, + ) ).root, ).toMatchObject({ targetSha: sharedSha, @@ -1591,12 +1811,12 @@ describe("release CI summary child correlation", () => { }); }); - it("binds v3 producer evidence to the exact trusted branch ref", () => { + it("binds v3 producer evidence to the exact trusted branch ref", async () => { const fixture = trustedMainPackageFixture({ manifestVersion: 3, workflowSha: "a".repeat(40), }); - const evidence = validateReleaseRunEvidence( + const evidence = await validateReleaseRunEvidence( { repository: "openclaw/openclaw", runId: fixture.runId, @@ -1614,7 +1834,7 @@ describe("release CI summary child correlation", () => { }); }); - it("accepts a Unicode trusted workflow ref", () => { + it("accepts a Unicode trusted workflow ref", async () => { const workflowRef = "release/unicode-\u{1f4a5}"; const fixture = trustedMainPackageFixture({ manifestVersion: 3, @@ -1622,7 +1842,7 @@ describe("release CI summary child correlation", () => { workflowRef, workflowSha: "a".repeat(40), }); - const evidence = validateReleaseRunEvidence( + const evidence = await validateReleaseRunEvidence( { repository: "openclaw/openclaw", runId: fixture.runId, @@ -1639,14 +1859,14 @@ describe("release CI summary child correlation", () => { }); }); - it("rejects a v3 producer dispatched from a tag named main", () => { + it("rejects a v3 producer dispatched from a tag named main", async () => { const fixture = trustedMainPackageFixture({ manifestVersion: 3, workflowFullRef: "refs/tags/main", workflowRefType: "tag", workflowSha: "a".repeat(40), }); - expect(() => + await expect( validateReleaseRunEvidence( { repository: "openclaw/openclaw", @@ -1656,16 +1876,16 @@ describe("release CI summary child correlation", () => { }, fixture.client, ), - ).toThrow("producer workflow full ref is not trusted"); + ).rejects.toThrow("producer workflow full ref is not trusted"); }); - it("rejects a legacy producer outside the trusted main verifier lineage", () => { + it("rejects a legacy producer outside the trusted main verifier lineage", async () => { const fixture = trustedMainPackageFixture({ workflowSha: "a".repeat(40) }); fixture.client.compareCommitLineage = () => ({ merge_base_commit: { sha: "d".repeat(40) }, status: "diverged", }); - expect(() => + await expect( validateReleaseRunEvidence( { repository: "openclaw/openclaw", @@ -1675,16 +1895,16 @@ describe("release CI summary child correlation", () => { }, fixture.client, ), - ).toThrow("producer is not on the trusted main verifier lineage"); + ).rejects.toThrow("producer is not on the trusted main verifier lineage"); }); - it("rejects a candidate branch producer even when its SHA differs from the target", () => { + it("rejects a candidate branch producer even when its SHA differs from the target", async () => { const fixture = trustedMainPackageFixture({ targetSha: "8".repeat(40), workflowRef: "release/2026.7.1", workflowSha: "7".repeat(40), }); - expect(() => + await expect( validateReleaseRunEvidence( { repository: "openclaw/openclaw", @@ -1695,10 +1915,10 @@ describe("release CI summary child correlation", () => { }, fixture.client, ), - ).toThrow("producer must run from trusted workflow ref: main"); + ).rejects.toThrow("producer must run from trusted workflow ref: main"); }); - it("accepts canonical SHA-pinned v3 evidence on the trusted main lineage", () => { + it("accepts canonical SHA-pinned v3 evidence on the trusted main lineage", async () => { const workflowSha = "7".repeat(40); const workflowRef = `release-ci/${workflowSha.slice(0, 12)}-1783705000000`; const fixture = trustedMainPackageFixture({ @@ -1711,14 +1931,16 @@ describe("release CI summary child correlation", () => { fixture.manifest.targetRef = fixture.targetSha; expect( - validateReleaseRunEvidence( - { - repository: "openclaw/openclaw", - runId: fixture.runId, - verifierSourceContent: readFileSync(SCRIPT), - verifierSourceSha: "c".repeat(40), - }, - fixture.client, + ( + await validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: fixture.runId, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + fixture.client, + ) ).root, ).toMatchObject({ workflowFullRef: `refs/heads/${workflowRef}`, @@ -1728,7 +1950,7 @@ describe("release CI summary child correlation", () => { }); }); - it("accepts canonical SHA-pinned v3 evidence exactly bound to a protected tooling tag", () => { + it("accepts canonical SHA-pinned v3 evidence exactly bound to a protected tooling tag", async () => { const workflowSha = "7".repeat(40); const workflowRef = `release-ci/${workflowSha.slice(0, 12)}-1783705000000`; const trustedWorkflowRef = `release-publish/${workflowSha.slice(0, 12)}-123`; @@ -1742,7 +1964,7 @@ describe("release CI summary child correlation", () => { fixture.manifest.targetRef = fixture.targetSha; expect( - validateReleaseRunEvidence( + await validateReleaseRunEvidence( { repository: "openclaw/openclaw", runId: fixture.runId, @@ -1766,7 +1988,7 @@ describe("release CI summary child correlation", () => { }); }); - it("accepts protected-tag evidence from an older trusted tooling ancestor", () => { + it("accepts protected-tag evidence from an older trusted tooling ancestor", async () => { const trustedWorkflowSha = "7".repeat(40); const trustedWorkflowRef = `release-publish/${trustedWorkflowSha.slice(0, 12)}-123`; const olderWorkflowSha = "6".repeat(40); @@ -1793,7 +2015,7 @@ describe("release CI summary child correlation", () => { }; expect( - validateReleaseRunEvidence( + await validateReleaseRunEvidence( { repository: "openclaw/openclaw", runId: olderFixture.runId, @@ -1814,7 +2036,7 @@ describe("release CI summary child correlation", () => { }); }); - it("rejects protected-tag evidence from a same-name branch or unrelated producer", () => { + it("rejects protected-tag evidence from a same-name branch or unrelated producer", async () => { const trustedWorkflowSha = "7".repeat(40); const trustedWorkflowRef = `release-publish/${trustedWorkflowSha.slice(0, 12)}-123`; const validFixture = trustedMainPackageFixture({ @@ -1822,7 +2044,7 @@ describe("release CI summary child correlation", () => { workflowSha: trustedWorkflowSha, }); - expect(() => + await expect( validateReleaseRunEvidence( { repository: "openclaw/openclaw", @@ -1835,7 +2057,7 @@ describe("release CI summary child correlation", () => { }, validFixture.client, ), - ).toThrow("must be a protected tag"); + ).rejects.toThrow("must be a protected tag"); const unrelatedWorkflowSha = "6".repeat(40); const unrelatedWorkflowRef = `release-ci/${unrelatedWorkflowSha.slice(0, 12)}-1783705000000`; @@ -1855,7 +2077,7 @@ describe("release CI summary child correlation", () => { merge_base_commit: { sha: "5".repeat(40) }, status: "diverged", }); - expect(() => + await expect( validateReleaseRunEvidence( { repository: "openclaw/openclaw", @@ -1868,7 +2090,7 @@ describe("release CI summary child correlation", () => { }, unrelatedFixture.client, ), - ).toThrow("not on the trusted tooling lineage"); + ).rejects.toThrow("not on the trusted tooling lineage"); const sameNameFixture = trustedMainPackageFixture({ manifestVersion: 3, @@ -1876,7 +2098,7 @@ describe("release CI summary child correlation", () => { workflowRef: trustedWorkflowRef, workflowSha: trustedWorkflowSha, }); - expect(() => + await expect( validateReleaseRunEvidence( { repository: "openclaw/openclaw", @@ -1889,10 +2111,10 @@ describe("release CI summary child correlation", () => { }, sameNameFixture.client, ), - ).toThrow("canonical release-ci branch"); + ).rejects.toThrow("canonical release-ci branch"); }); - it("rejects a protected tooling tag that moved or disappeared after sealing", () => { + it("rejects a protected tooling tag that moved or disappeared after sealing", async () => { const workflowSha = "7".repeat(40); const workflowRef = `release-ci/${workflowSha.slice(0, 12)}-1783705000000`; const trustedWorkflowRef = `release-publish/${workflowSha.slice(0, 12)}-123`; @@ -1918,21 +2140,21 @@ describe("release CI summary child correlation", () => { object: { sha: "6".repeat(40) }, ref: fullRef, }); - expect(() => validateReleaseRunEvidence(options, fixture.client)).toThrow( + await expect(validateReleaseRunEvidence(options, fixture.client)).rejects.toThrow( "protected tooling tag moved", ); fixture.client.getRef = () => { throw new Error("HTTP 404"); }; - expect(() => validateReleaseRunEvidence(options, fixture.client)).toThrow( + await expect(validateReleaseRunEvidence(options, fixture.client)).rejects.toThrow( "protected tooling tag is unavailable", ); }); it.each(["main", "refs/heads/main"])( "accepts a REST workflow path qualified with %s", - (qualifiedRef) => { + async (qualifiedRef) => { const fixture = trustedMainPackageFixture({ manifestVersion: 3, parentPath: `.github/workflows/full-release-validation.yml@${qualifiedRef}`, @@ -1940,14 +2162,16 @@ describe("release CI summary child correlation", () => { }); expect( - validateReleaseRunEvidence( - { - repository: "openclaw/openclaw", - runId: fixture.runId, - verifierSourceContent: readFileSync(SCRIPT), - verifierSourceSha: "c".repeat(40), - }, - fixture.client, + ( + await validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: fixture.runId, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + fixture.client, + ) ).root, ).toMatchObject({ workflowFullRef: "refs/heads/main" }); }, @@ -1987,7 +2211,7 @@ describe("release CI summary child correlation", () => { }); }); - it("rejects a SHA-pinned evidenceReuse field even when false", () => { + it("rejects a SHA-pinned evidenceReuse field even when false", async () => { const workflowSha = "7".repeat(40); const workflowRef = `release-ci/${workflowSha.slice(0, 12)}-1783705000000`; const fixture = trustedMainPackageFixture({ @@ -1999,7 +2223,7 @@ describe("release CI summary child correlation", () => { fixture.manifest.targetRef = fixture.targetSha; fixture.manifest.evidenceReuse = false; - expect(() => + await expect( validateReleaseRunEvidence( { repository: "openclaw/openclaw", @@ -2009,12 +2233,12 @@ describe("release CI summary child correlation", () => { }, fixture.client, ), - ).toThrow("evidence reuse is invalid"); + ).rejects.toThrow("evidence reuse is invalid"); }); - it("rejects dirty verifier bytes and a forged verifier source SHA", () => { + it("rejects dirty verifier bytes and a forged verifier source SHA", async () => { const fixture = trustedMainPackageFixture(); - expect(() => + await expect( validateReleaseRunEvidence( { repository: "openclaw/openclaw", @@ -2024,8 +2248,8 @@ describe("release CI summary child correlation", () => { }, fixture.client, ), - ).toThrow("verifier script differs from its source SHA"); - expect(() => + ).rejects.toThrow("verifier script differs from its source SHA"); + await expect( validateReleaseRunEvidence( { repository: "openclaw/openclaw", @@ -2034,7 +2258,7 @@ describe("release CI summary child correlation", () => { }, fixture.client, ), - ).toThrow("verifier source blob is unavailable"); + ).rejects.toThrow("verifier source blob is unavailable"); }); it("binds verifier bytes from the repository root even outside the caller cwd", () => {