diff --git a/scripts/release-ci-summary.mjs b/scripts/release-ci-summary.mjs index 91143c5e0ff9..a7e866df1cab 100755 --- a/scripts/release-ci-summary.mjs +++ b/scripts/release-ci-summary.mjs @@ -10,7 +10,7 @@ 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"; +import { execGhRead, 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"; @@ -98,14 +98,17 @@ export function runReleaseCiGh(args, params = {}) { const execFileSyncImpl = params.execFileSyncImpl ?? execFileSync; const timeoutMs = params.timeoutMs ?? GH_COMMAND_TIMEOUT_MS; const stdio = params.stdio ?? ["ignore", "pipe", "pipe"]; - return execFileSyncImpl(resolvePlainGhBin(), args, { - encoding: "utf8", - env: plainGhEnv(), - killSignal: "SIGKILL", - maxBuffer: 64 * 1024 * 1024, - stdio, - timeout: timeoutMs, - }); + return execGhRead( + args, + { + encoding: "utf8", + killSignal: "SIGKILL", + maxBuffer: 64 * 1024 * 1024, + stdio, + timeout: timeoutMs, + }, + { execFileSyncImpl }, + ); } function gh(args) { @@ -131,8 +134,12 @@ export function artifactDownloadArgs(artifactId, repository = DEFAULT_REPO) { function downloadArtifactZip(artifactId, destination, repository = DEFAULT_REPO) { const output = openSync(destination, "w"); try { - runReleaseCiGh(artifactDownloadArgs(artifactId, repository), { + execFileSync(resolvePlainGhBin(), artifactDownloadArgs(artifactId, repository), { + env: plainGhEnv(), + killSignal: "SIGKILL", + maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", output, "pipe"], + timeout: GH_COMMAND_TIMEOUT_MS, }); } finally { closeSync(output); @@ -1045,6 +1052,9 @@ function validateCompletedParentRun(parentView, parentRest, repository, runId) { export function createReleaseEvidenceClient(repository = DEFAULT_REPO) { const normalizedRepository = normalizeRepository(repository); return { + compareCommitLineage(base, head) { + return githubRestJson(`compare/${base}...${head}?per_page=1&page=2`, normalizedRepository); + }, compareCommits(base, head) { return githubRestJson(`compare/${base}...${head}`, normalizedRepository); }, @@ -1149,7 +1159,7 @@ export function validateTrustedProducerIdentity(evidence, client, verifier, trus workflowRefProof = shaPinned ? "manifest-v3-sha-pinned-main-ancestry" : "manifest-v3-branch"; } - const comparison = client.compareCommits(manifest.workflowSha, verifier.sourceSha); + const comparison = client.compareCommitLineage(manifest.workflowSha, verifier.sourceSha); if ( !["ahead", "identical"].includes(String(comparison.status)) || comparison.merge_base_commit?.sha !== manifest.workflowSha diff --git a/test/scripts/release-ci-summary.test.ts b/test/scripts/release-ci-summary.test.ts index 026f357b0a12..df85390e3460 100644 --- a/test/scripts/release-ci-summary.test.ts +++ b/test/scripts/release-ci-summary.test.ts @@ -1,6 +1,6 @@ import { execFileSync, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -51,6 +51,128 @@ describe("GitHub API commands", () => { "repos/owner/repo/actions/artifacts/456/zip", ]); }); + + it.skipIf(!hasUnzip)("uses cached gh for evidence reads and plain gh only for ZIP bytes", () => { + const root = mkdtempSync(join(tmpdir(), "release-ci-gh-routing-")); + const workflowSha = "0".repeat(40); + const targetSha = "8".repeat(40); + const verifierSha = "c".repeat(40); + const fixture = trustedMainPackageFixture({ targetSha, workflowSha }); + const runId = fixture.runId; + const childRunId = String(fixture.childRun.id); + const artifactId = fixture.artifact.id; + const archive = makeStoredZip({ + [MANIFEST_ARTIFACT_ENTRY]: JSON.stringify(fixture.manifest), + }); + const archivePath = join(root, "manifest.zip"); + const fixturesPath = join(root, "fixtures.json"); + const shimLog = join(root, "shim.log"); + const plainLog = join(root, "plain.log"); + const shimGh = join(root, "gh"); + const plainGh = join(root, "plain-gh"); + fixture.artifact.digest = artifactDigest(archive); + fixture.artifact.size_in_bytes = archive.length; + writeFileSync(archivePath, archive); + writeFileSync( + fixturesPath, + JSON.stringify({ + artifact: fixture.artifact, + artifactList: { artifacts: [fixture.artifact] }, + child: fixture.childRun, + jobLog: `TARGET_SHA: ${targetSha}\nDispatched: https://github.com/openclaw/openclaw/actions/runs/${childRunId}`, + jobs: { jobs: [fixture.parentJob] }, + lineage: { merge_base_commit: { sha: workflowSha }, status: "ahead" }, + parent: fixture.parentRun, + parentView: fixture.parentView, + rate: { resources: { core: { limit: 5000, remaining: 4999, reset: 2_000_000_000 } } }, + }), + ); + writeFileSync( + shimGh, + `#!/usr/bin/env node +import { appendFileSync, readFileSync } from "node:fs"; +const args = process.argv.slice(2); +appendFileSync(process.env.SHIM_LOG, JSON.stringify(args) + "\\n"); +const fixtures = JSON.parse(readFileSync(process.env.FIXTURES, "utf8")); +const endpoint = args[1] ?? ""; +let output; +if (args[0] === "run" && args[1] === "view") output = fixtures.parentView; +else if (endpoint === "rate_limit") output = fixtures.rate; +else if (endpoint === "repos/openclaw/openclaw/actions/runs/${runId}") output = fixtures.parent; +else if (endpoint.startsWith("repos/openclaw/openclaw/actions/runs/${runId}/artifacts?")) output = fixtures.artifactList; +else if (endpoint === "repos/openclaw/openclaw/actions/artifacts/${artifactId}") output = fixtures.artifact; +else if (endpoint.startsWith("repos/openclaw/openclaw/actions/runs/${runId}/jobs?")) output = fixtures.jobs; +else if (endpoint === "repos/openclaw/openclaw/actions/runs/${childRunId}") output = fixtures.child; +else if (endpoint === "repos/openclaw/openclaw/actions/jobs/${fixture.parentJob.id}/logs") output = fixtures.jobLog; +else if (endpoint === "repos/openclaw/openclaw/compare/${workflowSha}...${verifierSha}?per_page=1&page=2") output = fixtures.lineage; +else { console.error("unexpected cached gh request: " + args.join(" ")); process.exit(43); } +process.stdout.write(typeof output === "string" ? output : JSON.stringify(output)); +`, + ); + writeFileSync( + plainGh, + `#!/usr/bin/env node +import { appendFileSync, readFileSync } from "node:fs"; +const args = process.argv.slice(2); +appendFileSync(process.env.PLAIN_LOG, JSON.stringify(args) + "\\n"); +if (args[0] !== "api" || args[1] !== "repos/openclaw/openclaw/actions/artifacts/${artifactId}/zip") { + console.error("plain gh used for evidence read: " + args.join(" ")); + process.exit(42); +} +process.stdout.write(readFileSync(process.env.ARCHIVE)); +`, + ); + chmodSync(shimGh, 0o755); + chmodSync(plainGh, 0o755); + + try { + const env = { + ...process.env, + ARCHIVE: archivePath, + FIXTURES: fixturesPath, + OPENCLAW_GH_BIN: plainGh, + PATH: `${root}:${process.env.PATH ?? ""}`, + PLAIN_LOG: plainLog, + SHIM_LOG: shimLog, + }; + const lineageResult = spawnSync( + process.execPath, + [ + "--input-type=module", + "--eval", + `import { createReleaseEvidenceClient } from ${JSON.stringify(pathToFileURL(resolve(SCRIPT)).href)}; + process.stdout.write(JSON.stringify(createReleaseEvidenceClient("openclaw/openclaw").compareCommitLineage("${workflowSha}", "${verifierSha}")));`, + ], + { encoding: "utf8", env }, + ); + expect(lineageResult.status).toBe(0); + expect(JSON.parse(lineageResult.stdout)).toEqual({ + merge_base_commit: { sha: workflowSha }, + status: "ahead", + }); + + const result = spawnSync(process.execPath, [SCRIPT, runId], { encoding: "utf8", env }); + + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + expect(result.stdout).toContain( + `child: ${childRunId} OpenClaw Release Checks completed/success`, + ); + const shimCalls = readFileSync(shimLog, "utf8"); + const plainCalls = readFileSync(plainLog, "utf8"); + expect(shimCalls).toContain('"run","view"'); + expect(shimCalls).toContain(`"repos/openclaw/openclaw/actions/runs/${runId}"`); + expect(shimCalls).toContain( + `"repos/openclaw/openclaw/compare/${workflowSha}...${verifierSha}?per_page=1&page=2"`, + ); + expect(shimCalls).not.toContain(`/actions/artifacts/${artifactId}/zip`); + expect(plainCalls.trim()).toBe( + JSON.stringify(["api", `repos/openclaw/openclaw/actions/artifacts/${artifactId}/zip`]), + ); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); }); describe("runReleaseCiGh", () => { @@ -367,14 +489,16 @@ function trustedMainPackageFixture({ id: Number(runId), }, }; + const compareCommits = (base: string, head: string) => { + expect(base).toBe(workflowSha); + return { + merge_base_commit: { sha: workflowSha }, + status: base === head ? "identical" : "ahead", + }; + }; const client = { - compareCommits(base: string, head: string) { - expect(base).toBe(workflowSha); - return { - merge_base_commit: { sha: workflowSha }, - status: base === head ? "identical" : "ahead", - }; - }, + compareCommitLineage: compareCommits, + compareCommits, getJobLog(jobId: number) { expect(jobId).toBe(parentJob.id); return [ @@ -406,7 +530,18 @@ function trustedMainPackageFixture({ }, }; - return { artifact, childRun, client, manifest, parentRun, runId, targetSha, workflowSha }; + return { + artifact, + childRun, + client, + manifest, + parentJob, + parentRun, + parentView, + runId, + targetSha, + workflowSha, + }; } describe("release CI summary child correlation", () => { @@ -889,7 +1024,7 @@ describe("release CI summary child correlation", () => { it("rejects a legacy producer outside the trusted main verifier lineage", () => { const fixture = trustedMainPackageFixture({ workflowSha: "a".repeat(40) }); - fixture.client.compareCommits = () => ({ + fixture.client.compareCommitLineage = () => ({ merge_base_commit: { sha: "d".repeat(40) }, status: "diverged", });