From dfb018f971c00406d05b0ef86bc73f15de581bf3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 21 Aug 2026 04:00:44 -0700 Subject: [PATCH] fix(release): harden candidate artifact provenance --- .../workflows/release-candidate-artifacts.yml | 120 ++++++++++-- .../release-candidate-receipt-contract.d.mts | 2 +- .../release-candidate-receipt-contract.mjs | 4 +- scripts/release-candidate-receipt-locator.mts | 175 +++++++++++------- ...ndidate-receipt-lock-v1.compatibility.json | 2 +- .../fixtures/candidate-receipt-v1.source.json | 2 +- .../scripts/release-candidate-receipt.test.ts | 128 +++++++++++-- 7 files changed, 333 insertions(+), 100 deletions(-) diff --git a/.github/workflows/release-candidate-artifacts.yml b/.github/workflows/release-candidate-artifacts.yml index 778378269b0f..90824a703b5f 100644 --- a/.github/workflows/release-candidate-artifacts.yml +++ b/.github/workflows/release-candidate-artifacts.yml @@ -46,19 +46,35 @@ jobs: persist-credentials: false submodules: false - - name: Validate ReleasePlanLock + - name: Setup trusted verifier runtime + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24.x" + + - name: Setup trusted verifier pnpm + uses: ./.github/actions/setup-pnpm-store-cache + with: + node-version: "24.x" + + - name: Install trusted verifier dependencies + run: pnpm install --filter . --frozen-lockfile --prefer-offline --ignore-scripts + + - name: Verify repository-derived ReleasePlan authority id: plan env: DISPATCH_ID: ${{ inputs.dispatch_id }} + GH_TOKEN: ${{ github.token }} RELEASE_PLAN_LOCK_BASE64: ${{ inputs.release_plan_lock_base64 }} WORKFLOW_FULL_REF: ${{ github.ref }} WORKFLOW_SHA: ${{ github.sha }} shell: bash run: | set -euo pipefail - node --input-type=module <<'NODE' + node --import tsx --input-type=module <<'NODE' + import { execFileSync } from "node:child_process"; import fs from "node:fs"; import { parseReleasePlanLockJson } from "./scripts/release-plan-contract.mjs"; + import { verifyReleasePlanLock } from "./scripts/release-plan-producer.mts"; const dispatchId = process.env.DISPATCH_ID ?? ""; if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/u.test(dispatchId)) { @@ -72,13 +88,60 @@ jobs: if (bytes.toString("base64") !== encoded) { throw new Error("release_plan_lock_base64 is not canonical base64"); } - const lock = parseReleasePlanLockJson(bytes.toString("utf8")); - if (lock.plan.tooling.sha !== process.env.WORKFLOW_SHA) { - throw new Error("ReleasePlan tooling SHA must equal the candidate producer workflow SHA"); + const lockJson = bytes.toString("utf8"); + // This parse extracts claimed source coordinates only. The repository-derived + // verifier below is the authority for every plan field and tooling route. + const claimedLock = parseReleasePlanLockJson(lockJson); + const workflowSha = process.env.WORKFLOW_SHA ?? ""; + const workflowFullRef = process.env.WORKFLOW_FULL_REF ?? ""; + const candidateSha = claimedLock.plan.candidate_sha; + const gh = (args) => + execFileSync("gh", args, { + encoding: "utf8", + killSignal: "SIGKILL", + maxBuffer: 16 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + timeout: 60_000, + }); + const candidateCommit = JSON.parse( + gh([ + "api", + `repos/${process.env.GITHUB_REPOSITORY}/commits/${candidateSha}`, + "--method", + "GET", + ]), + ); + if (candidateCommit.sha !== candidateSha) { + throw new Error("ReleasePlan candidate SHA is not an exact commit in the repository"); } - if (lock.plan.tooling.ref !== process.env.WORKFLOW_FULL_REF) { - throw new Error("ReleasePlan tooling ref must equal the candidate producer workflow ref"); + execFileSync("git", ["fetch", "--force", "--no-tags", "origin", candidateSha], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (claimedLock.plan.purpose === "postpublish-confidence") { + const tagRef = claimedLock.plan.target_context_ref; + execFileSync("git", ["fetch", "--force", "origin", `${tagRef}:${tagRef}`], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); } + const intent = + claimedLock.plan.purpose === "main-qualification" + ? "main-qualification" + : claimedLock.plan.purpose === "postpublish-confidence" + ? "postpublish-confidence" + : "publish"; + const source = { + candidateRef: claimedLock.plan.target_context_ref, + candidateSha, + toolingFullRef: workflowFullRef, + toolingSha: workflowSha, + intent, + ...(intent === "main-qualification" + ? { validationIntent: claimedLock.plan.validation.intent } + : {}), + }; + const lock = verifyReleasePlanLock(lockJson, source); const outputPath = process.env.GITHUB_OUTPUT; if (!outputPath) { throw new Error("GITHUB_OUTPUT is required"); @@ -162,18 +225,43 @@ jobs: - name: Build root Dockerfile image env: + # Candidate images use deterministic build metadata so retries do not alter provenance. + BUILD_TIMESTAMP: "2000-01-01T00:00:00.000Z" IMAGE_REF: openclaw-release-candidate-root:${{ needs.validate_release_plan.outputs.candidate_sha }} + TARGET_SHA: ${{ needs.validate_release_plan.outputs.candidate_sha }} shell: bash run: | set -euo pipefail timeout --kill-after=30s 45m docker buildx build \ --progress=plain \ --load \ + --build-arg "GIT_COMMIT=$TARGET_SHA" \ + --build-arg "OPENCLAW_BUILD_TIMESTAMP=$BUILD_TIMESTAMP" \ --build-arg OPENCLAW_EXTENSIONS=matrix \ --tag "$IMAGE_REF" \ --file ./Dockerfile \ . + - name: Verify root image build provenance + env: + IMAGE_REF: openclaw-release-candidate-root:${{ needs.validate_release_plan.outputs.candidate_sha }} + TARGET_SHA: ${{ needs.validate_release_plan.outputs.candidate_sha }} + shell: bash + run: | + set -euo pipefail + embedded_commit="$( + docker run --rm --entrypoint node "$IMAGE_REF" -e ' + const fs = require("node:fs"); + const info = JSON.parse(fs.readFileSync("/app/dist/build-info.json", "utf8")); + if (typeof info.commit !== "string") process.exit(2); + process.stdout.write(info.commit); + ' + )" + if [[ "$embedded_commit" != "$TARGET_SHA" ]]; then + echo "Root image build commit ${embedded_commit:-missing} does not match ${TARGET_SHA}." >&2 + exit 1 + fi + - name: Pack root Dockerfile image artifact id: image_artifact env: @@ -264,7 +352,7 @@ jobs: jq -e \ --arg id "$workflow_id" \ --arg path "$EXPECTED_WORKFLOW_PATH" \ - '(.id | tostring) == $id and .path == $path and .state == "active"' \ + '(.id | tostring) == $id and .path == $path' \ <<< "$workflow_json" >/dev/null echo "workflow_id=$workflow_id" >> "$GITHUB_OUTPUT" @@ -278,10 +366,10 @@ jobs: PACKAGE_ARTIFACT_ID: ${{ needs.candidate_artifacts.outputs.package_artifact_id }} PACKAGE_ARTIFACT_NAME: ${{ needs.candidate_artifacts.outputs.package_artifact_name }} PACKAGE_SHA256: ${{ needs.candidate_artifacts.outputs.package_sha256 }} - PLUGIN_REGISTRY_ARTIFACT_DIGEST: ${{ needs.candidate_artifacts.outputs.prepublish_plugin_registry_artifact_digest }} - PLUGIN_REGISTRY_ARTIFACT_ID: ${{ needs.candidate_artifacts.outputs.prepublish_plugin_registry_artifact_id }} - PLUGIN_REGISTRY_ARTIFACT_NAME: ${{ needs.candidate_artifacts.outputs.prepublish_plugin_registry_artifact_name }} - PLUGIN_REGISTRY_MANIFEST_SHA256: ${{ needs.candidate_artifacts.outputs.prepublish_plugin_registry_manifest_sha256 }} + E2E_PLUGIN_REGISTRY_ARTIFACT_DIGEST: ${{ needs.candidate_artifacts.outputs.prepublish_plugin_registry_artifact_digest }} + E2E_PLUGIN_REGISTRY_ARTIFACT_ID: ${{ needs.candidate_artifacts.outputs.prepublish_plugin_registry_artifact_id }} + E2E_PLUGIN_REGISTRY_ARTIFACT_NAME: ${{ needs.candidate_artifacts.outputs.prepublish_plugin_registry_artifact_name }} + E2E_PLUGIN_REGISTRY_MANIFEST_SHA256: ${{ needs.candidate_artifacts.outputs.prepublish_plugin_registry_manifest_sha256 }} RELEASE_PLAN_DIGEST: ${{ needs.validate_release_plan.outputs.release_plan_digest }} ROOT_IMAGE_ARCHIVE_SHA256: ${{ needs.root_image.outputs.archive_sha256 }} ROOT_IMAGE_ARTIFACT_DIGEST: ${{ needs.root_image.outputs.artifact_digest }} @@ -330,11 +418,11 @@ jobs: }, artifacts: { docker_image: artifact("DOCKER_IMAGE", "DOCKER_IMAGE_ARCHIVE_SHA256"), - package: artifact("PACKAGE", "PACKAGE_SHA256"), - plugin_registry: artifact( - "PLUGIN_REGISTRY", - "PLUGIN_REGISTRY_MANIFEST_SHA256", + e2e_plugin_registry: artifact( + "E2E_PLUGIN_REGISTRY", + "E2E_PLUGIN_REGISTRY_MANIFEST_SHA256", ), + package: artifact("PACKAGE", "PACKAGE_SHA256"), root_image: artifact("ROOT_IMAGE", "ROOT_IMAGE_ARCHIVE_SHA256"), }, }); diff --git a/scripts/release-candidate-receipt-contract.d.mts b/scripts/release-candidate-receipt-contract.d.mts index 6155555b46d1..a637d947be4c 100644 --- a/scripts/release-candidate-receipt-contract.d.mts +++ b/scripts/release-candidate-receipt-contract.d.mts @@ -18,8 +18,8 @@ export type CandidateReceipt = { }; artifacts: { docker_image: CandidateReceiptArtifact; + e2e_plugin_registry: CandidateReceiptArtifact; package: CandidateReceiptArtifact; - plugin_registry: CandidateReceiptArtifact; root_image: CandidateReceiptArtifact; }; }; diff --git a/scripts/release-candidate-receipt-contract.mjs b/scripts/release-candidate-receipt-contract.mjs index ae5ddefae326..99dacc43a9ef 100644 --- a/scripts/release-candidate-receipt-contract.mjs +++ b/scripts/release-candidate-receipt-contract.mjs @@ -13,7 +13,7 @@ const SHA_PATTERN = /^[a-f0-9]{40}$/u; const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u; const POSITIVE_DECIMAL_PATTERN = /^[1-9][0-9]*$/u; const ASCII_PATTERN = /^[\x20-\x7e]+$/u; -const ARTIFACT_KEYS = ["docker_image", "package", "plugin_registry", "root_image"]; +const ARTIFACT_KEYS = ["docker_image", "e2e_plugin_registry", "package", "root_image"]; const compareAscii = (left, right) => (left < right ? -1 : left > right ? 1 : 0); function fail(message) { @@ -158,8 +158,8 @@ export function validateCandidateReceipt(value) { } } const exactArtifactNames = { + e2e_plugin_registry: `docker-e2e-prepublish-plugin-registry${expectedNameSuffix}`, package: `docker-e2e-package${expectedNameSuffix}`, - plugin_registry: `docker-e2e-prepublish-plugin-registry${expectedNameSuffix}`, root_image: `release-candidate-root-image${expectedNameSuffix}`, }; for (const [key, expectedName] of Object.entries(exactArtifactNames)) { diff --git a/scripts/release-candidate-receipt-locator.mts b/scripts/release-candidate-receipt-locator.mts index a71fd2ab3057..9dbcd444db90 100644 --- a/scripts/release-candidate-receipt-locator.mts +++ b/scripts/release-candidate-receipt-locator.mts @@ -15,6 +15,12 @@ import { type JsonRecord = Record; type RunGh = (args: string[]) => string; +type NormalizedCandidateReceiptLocatorOptions = CandidateReceiptLocatorOptions & { + releasePlanDigest: string; + timeoutMs: number; + workflowId: string; + workflowSha: string; +}; export type CandidateReceiptLocatorOptions = { dispatchId: string; @@ -39,6 +45,8 @@ const DISPATCH_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/u; const GH_COMMAND_TIMEOUT_MS = 60_000; const DEFAULT_TIMEOUT_MS = 2 * 60 * 60 * 1000; const POLL_INTERVAL_MS = 15_000; +const API_RETRY_ATTEMPTS = 3; +const API_RETRY_DELAY_MS = 1_000; function fail(message: string): never { throw new Error(message); @@ -47,7 +55,7 @@ function fail(message: string): never { function parseJson(raw: string, label: string): unknown { try { return JSON.parse(raw) as unknown; - } catch (error) { + } catch (error: unknown) { throw new Error(`${label} returned invalid JSON`, { cause: error }); } } @@ -91,7 +99,9 @@ function digest(value: unknown, label: string): string { return normalized; } -function requireOptions(options: CandidateReceiptLocatorOptions) { +function requireOptions( + options: CandidateReceiptLocatorOptions, +): NormalizedCandidateReceiptLocatorOptions { if (options.repo !== REPOSITORY) { fail(`candidate receipt repository must be ${REPOSITORY}`); } @@ -138,7 +148,7 @@ function validateArtifactMetadata( name: string; runId: string; }, -) { +): void { const workflowRun = record(artifact.workflow_run, `${expected.name} workflow_run`); if ( positiveDecimal(artifact.id, `${expected.name} artifact id`) !== expected.id || @@ -162,7 +172,7 @@ export function validateCandidateReceiptProvenance(params: { lock: CandidateReceiptLock; run: unknown; workflow: unknown; -}) { +}): CandidateReceiptLock { const lock = validateCandidateReceiptLock(params.lock); const run = record(params.run, "candidate receipt run"); const workflow = record(params.workflow, "candidate receipt workflow"); @@ -185,10 +195,9 @@ export function validateCandidateReceiptProvenance(params: { if ( positiveDecimal(workflow.id, "candidate receipt workflow id") !== params.expectedWorkflowId || requiredString(workflow.path, "candidate receipt workflow path") !== - CANDIDATE_RECEIPT_WORKFLOW_PATH || - workflow.state !== "active" + CANDIDATE_RECEIPT_WORKFLOW_PATH ) { - fail("candidate receipt workflow identity does not match the canonical active workflow"); + fail("candidate receipt workflow identity does not match the canonical workflow"); } const receipt = lock.receipt; @@ -245,18 +254,18 @@ function runGhCommand( maxBuffer: number; timeout: number; }, -) { +): string { return execFileSync(command, args, options); } async function pollUntil( deadline: number, - poll: () => T | undefined, + poll: () => Promise | T | undefined, sleep: (milliseconds: number) => Promise, timeoutMessage: string, ): Promise { while (Date.now() <= deadline) { - const result = poll(); + const result = await poll(); if (result !== undefined) { return result; } @@ -265,18 +274,38 @@ async function pollUntil( fail(timeoutMessage); } +async function retryOperation( + deadline: number, + sleep: (milliseconds: number) => Promise, + label: string, + operation: () => Promise | T, +): Promise { + let lastError: unknown; + let attempts = 0; + for (let attempt = 1; attempt <= API_RETRY_ATTEMPTS; attempt += 1) { + attempts = attempt; + try { + return await operation(); + } catch (error: unknown) { + lastError = error; + } + if (attempt === API_RETRY_ATTEMPTS || Date.now() >= deadline) { + break; + } + await sleep(Math.min(API_RETRY_DELAY_MS * attempt, Math.max(1, deadline - Date.now()))); + } + throw new Error(`${label} failed after ${attempts} attempts`, { cause: lastError }); +} + function discoverRun( - api: (endpoint: string) => unknown, + responseValue: unknown, params: { dispatchId: string; workflowId: string; workflowSha: string; }, ): { runAttempt: string; runId: string } | undefined { - const response = record( - api(`actions/workflows/${params.workflowId}/runs?event=workflow_dispatch&per_page=100`), - "candidate receipt workflow runs response", - ); + const response = record(responseValue, "candidate receipt workflow runs response"); if (!Array.isArray(response.workflow_runs)) { fail("candidate receipt workflow runs response must contain workflow_runs"); } @@ -303,19 +332,6 @@ function discoverRun( }; } -function requireCurrentAttempt( - api: (endpoint: string) => unknown, - runId: string, - runAttempt: string, -) { - const latestRun = record(api(`actions/runs/${runId}`), "candidate receipt latest run"); - if ( - positiveDecimal(latestRun.run_attempt, "candidate receipt latest run attempt") !== runAttempt - ) { - fail("candidate receipt producer attempt was superseded by a rerun"); - } -} - export async function locateCandidateReceipt( rawOptions: CandidateReceiptLocatorOptions, ): Promise { @@ -330,17 +346,18 @@ export async function locateCandidateReceipt( new Promise((resolve) => { setTimeout(resolve, milliseconds); })); - const api = (endpoint: string): unknown => - parseJson(runGh(["api", `repos/${options.repo}/${endpoint}`, "--method", "GET"]), endpoint); const deadline = Date.now() + options.timeoutMs; - const workflow = api(`actions/workflows/${options.workflowId}`); + const api = (endpoint: string): Promise => + retryOperation(deadline, sleep, endpoint, () => + parseJson(runGh(["api", `repos/${options.repo}/${endpoint}`, "--method", "GET"]), endpoint), + ); + const workflow = await api(`actions/workflows/${options.workflowId}`); const workflowRecord = record(workflow, "candidate receipt workflow"); if ( positiveDecimal(workflowRecord.id, "candidate receipt workflow id") !== options.workflowId || - workflowRecord.path !== CANDIDATE_RECEIPT_WORKFLOW_PATH || - workflowRecord.state !== "active" + workflowRecord.path !== CANDIDATE_RECEIPT_WORKFLOW_PATH ) { - fail("candidate receipt workflow identity does not match the canonical active workflow"); + fail("candidate receipt workflow identity does not match the canonical workflow"); } const exactRun = @@ -348,21 +365,26 @@ export async function locateCandidateReceipt( ? { runAttempt: options.runAttempt, runId: options.runId } : await pollUntil( deadline, - () => - discoverRun(api, { - dispatchId: options.dispatchId, - workflowId: options.workflowId, - workflowSha: options.workflowSha, - }), + async () => + discoverRun( + await api( + `actions/workflows/${options.workflowId}/runs?event=workflow_dispatch&per_page=100`, + ), + { + dispatchId: options.dispatchId, + workflowId: options.workflowId, + workflowSha: options.workflowSha, + }, + ), sleep, "timed out locating the candidate receipt producer run", ); const run = await pollUntil( deadline, - () => { + async () => { const current = record( - api(`actions/runs/${exactRun.runId}/attempts/${exactRun.runAttempt}`), + await api(`actions/runs/${exactRun.runId}/attempts/${exactRun.runAttempt}`), "candidate receipt run attempt", ); if (current.status !== "completed") { @@ -376,16 +398,19 @@ export async function locateCandidateReceipt( sleep, "timed out waiting for the candidate receipt producer", ); - requireCurrentAttempt(api, exactRun.runId, exactRun.runAttempt); - const artifacts = api(`actions/runs/${exactRun.runId}/artifacts?per_page=100`); + // A completed attempt is immutable evidence. Later reruns do not revoke it; + // consumers revalidate this exact run, attempt, receipt, and artifact set at use. const receiptArtifactName = `release-candidate-receipt-${exactRun.runId}-${exactRun.runAttempt}`; - const receiptArtifact = artifactRecords(artifacts).find( - (entry) => entry.name === receiptArtifactName, + const receiptArtifact = await pollUntil( + deadline, + async () => { + const response = await api(`actions/runs/${exactRun.runId}/artifacts?per_page=100`); + return artifactRecords(response).find((entry) => entry.name === receiptArtifactName); + }, + sleep, + "timed out waiting for the candidate receipt lock artifact", ); - if (!receiptArtifact) { - fail("candidate receipt lock artifact is missing from the producer run"); - } validateArtifactMetadata(receiptArtifact, { digest: digest(receiptArtifact.digest, "candidate receipt lock artifact digest"), id: positiveDecimal(receiptArtifact.id, "candidate receipt lock artifact id"), @@ -394,20 +419,41 @@ export async function locateCandidateReceipt( }); const downloadDir = mkdtempSync(join(tmpdir(), "openclaw-candidate-receipt-")); + const receiptPath = join(downloadDir, RECEIPT_FILE_NAME); try { - runGh([ - "run", - "download", - exactRun.runId, - "--repo", - options.repo, - "--name", - receiptArtifactName, - "--dir", - downloadDir, - ]); - const parsedLock = parseCandidateReceiptLockJson( - readFileSync(join(downloadDir, RECEIPT_FILE_NAME), "utf8"), + await retryOperation(deadline, sleep, "candidate receipt lock download", () => { + rmSync(receiptPath, { force: true }); + return runGh([ + "run", + "download", + exactRun.runId, + "--repo", + options.repo, + "--name", + receiptArtifactName, + "--dir", + downloadDir, + ]); + }); + const parsedLock = parseCandidateReceiptLockJson(readFileSync(receiptPath, "utf8")); + const expectedArtifactIds = new Set( + Object.values(parsedLock.receipt.artifacts).map((artifact) => artifact.artifact_id), + ); + const artifacts = await pollUntil( + deadline, + async () => { + const response = await api(`actions/runs/${exactRun.runId}/artifacts?per_page=100`); + const visibleArtifactIds = new Set( + artifactRecords(response).map((artifact) => + positiveDecimal(artifact.id, "candidate receipt artifact id"), + ), + ); + return [...expectedArtifactIds].every((id) => visibleArtifactIds.has(id)) + ? response + : undefined; + }, + sleep, + "timed out waiting for candidate artifact metadata propagation", ); const validatedLock = validateCandidateReceiptProvenance({ artifacts, @@ -421,9 +467,6 @@ export async function locateCandidateReceipt( run, workflow, }); - // A rerun invalidates the just-read artifact namespace even if it starts - // between the first attempt check and the final receipt read. - requireCurrentAttempt(api, exactRun.runId, exactRun.runAttempt); return validatedLock; } finally { rmSync(downloadDir, { force: true, recursive: true }); @@ -468,7 +511,7 @@ async function main(argv: string[] = process.argv.slice(2)): Promise { } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { - void main().catch((error) => { + void main().catch((error: unknown) => { console.error(error instanceof Error ? error.message : String(error)); console.error("[release-candidate-receipt-locator] FAILED (exit 1)"); process.exitCode = 1; diff --git a/test/fixtures/candidate-receipt-lock-v1.compatibility.json b/test/fixtures/candidate-receipt-lock-v1.compatibility.json index da6c148d7dcd..ebff064f9982 100644 --- a/test/fixtures/candidate-receipt-lock-v1.compatibility.json +++ b/test/fixtures/candidate-receipt-lock-v1.compatibility.json @@ -1 +1 @@ -{"digest":"sha256:2bc324c84b0aeee94c65c144f53580bfa4f74178e0f632560b790e7213685915","receipt":{"artifacts":{"docker_image":{"artifact_digest":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","artifact_id":"103","artifact_name":"docker-e2e-shared-images-release-candidate-aaaaaaaaaaaa-12345-2","content_digest":"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"},"package":{"artifact_digest":"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","artifact_id":"101","artifact_name":"docker-e2e-package-12345-2","content_digest":"sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"},"plugin_registry":{"artifact_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","artifact_id":"102","artifact_name":"docker-e2e-prepublish-plugin-registry-12345-2","content_digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222"},"root_image":{"artifact_digest":"sha256:3333333333333333333333333333333333333333333333333333333333333333","artifact_id":"104","artifact_name":"release-candidate-root-image-12345-2","content_digest":"sha256:4444444444444444444444444444444444444444444444444444444444444444"}},"producer":{"repository":"openclaw/openclaw","run_attempt":"2","run_id":"12345","workflow_id":"987","workflow_path":".github/workflows/release-candidate-artifacts.yml","workflow_sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"release_plan_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","schema":"openclaw.candidate-receipt.v1"},"schema":"openclaw.candidate-receipt-lock.v1"} +{"digest":"sha256:53d9d8af3975f7bcbbf552e71ec5a946762849ce07100a3f6d94dc5609992405","receipt":{"artifacts":{"docker_image":{"artifact_digest":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","artifact_id":"103","artifact_name":"docker-e2e-shared-images-release-candidate-aaaaaaaaaaaa-12345-2","content_digest":"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"},"e2e_plugin_registry":{"artifact_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","artifact_id":"102","artifact_name":"docker-e2e-prepublish-plugin-registry-12345-2","content_digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222"},"package":{"artifact_digest":"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","artifact_id":"101","artifact_name":"docker-e2e-package-12345-2","content_digest":"sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"},"root_image":{"artifact_digest":"sha256:3333333333333333333333333333333333333333333333333333333333333333","artifact_id":"104","artifact_name":"release-candidate-root-image-12345-2","content_digest":"sha256:4444444444444444444444444444444444444444444444444444444444444444"}},"producer":{"repository":"openclaw/openclaw","run_attempt":"2","run_id":"12345","workflow_id":"987","workflow_path":".github/workflows/release-candidate-artifacts.yml","workflow_sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"release_plan_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","schema":"openclaw.candidate-receipt.v1"},"schema":"openclaw.candidate-receipt-lock.v1"} diff --git a/test/fixtures/candidate-receipt-v1.source.json b/test/fixtures/candidate-receipt-v1.source.json index 7169039c65ac..04f781ba550f 100644 --- a/test/fixtures/candidate-receipt-v1.source.json +++ b/test/fixtures/candidate-receipt-v1.source.json @@ -1 +1 @@ -{"artifacts":{"docker_image":{"artifact_digest":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","artifact_id":"103","artifact_name":"docker-e2e-shared-images-release-candidate-aaaaaaaaaaaa-12345-2","content_digest":"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"},"package":{"artifact_digest":"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","artifact_id":"101","artifact_name":"docker-e2e-package-12345-2","content_digest":"sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"},"plugin_registry":{"artifact_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","artifact_id":"102","artifact_name":"docker-e2e-prepublish-plugin-registry-12345-2","content_digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222"},"root_image":{"artifact_digest":"sha256:3333333333333333333333333333333333333333333333333333333333333333","artifact_id":"104","artifact_name":"release-candidate-root-image-12345-2","content_digest":"sha256:4444444444444444444444444444444444444444444444444444444444444444"}},"producer":{"repository":"openclaw/openclaw","run_attempt":"2","run_id":"12345","workflow_id":"987","workflow_path":".github/workflows/release-candidate-artifacts.yml","workflow_sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"release_plan_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","schema":"openclaw.candidate-receipt.v1"} +{"artifacts":{"docker_image":{"artifact_digest":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","artifact_id":"103","artifact_name":"docker-e2e-shared-images-release-candidate-aaaaaaaaaaaa-12345-2","content_digest":"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"},"e2e_plugin_registry":{"artifact_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","artifact_id":"102","artifact_name":"docker-e2e-prepublish-plugin-registry-12345-2","content_digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222"},"package":{"artifact_digest":"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","artifact_id":"101","artifact_name":"docker-e2e-package-12345-2","content_digest":"sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"},"root_image":{"artifact_digest":"sha256:3333333333333333333333333333333333333333333333333333333333333333","artifact_id":"104","artifact_name":"release-candidate-root-image-12345-2","content_digest":"sha256:4444444444444444444444444444444444444444444444444444444444444444"}},"producer":{"repository":"openclaw/openclaw","run_attempt":"2","run_id":"12345","workflow_id":"987","workflow_path":".github/workflows/release-candidate-artifacts.yml","workflow_sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"release_plan_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","schema":"openclaw.candidate-receipt.v1"} diff --git a/test/scripts/release-candidate-receipt.test.ts b/test/scripts/release-candidate-receipt.test.ts index 3c3a3c14de2f..ec00c9a2f5ce 100644 --- a/test/scripts/release-candidate-receipt.test.ts +++ b/test/scripts/release-candidate-receipt.test.ts @@ -50,7 +50,6 @@ function workflowFixture(overrides: Record = {}) { return { id: Number(workflowId), path: ".github/workflows/release-candidate-artifacts.yml", - state: "active", ...overrides, }; } @@ -157,9 +156,20 @@ describe("candidate receipt contract", () => { expect(receipt).not.toHaveProperty("candidate_sha"); expect(receipt).not.toHaveProperty("version"); expect(receipt).not.toHaveProperty("validation"); + expect(receipt.artifacts).toHaveProperty("e2e_plugin_registry"); + expect(receipt.artifacts).not.toHaveProperty("plugin_registry"); expect(() => validateCandidateReceipt({ ...sourceFixture, candidate_sha: "a".repeat(40) }), ).toThrow("candidate receipt keys must be exactly"); + expect(() => + validateCandidateReceipt({ + ...sourceFixture, + artifacts: { + ...(sourceFixture.artifacts as Record), + plugin_registry: (sourceFixture.artifacts as Record).e2e_plugin_registry, + }, + }), + ).toThrow("candidate receipt artifacts keys must be exactly"); }); }); @@ -245,7 +255,26 @@ describe("candidate receipt locator", () => { ); }); - it("discovers one nonce-bound run, polls its exact attempt, and reads its receipt artifact", async () => { + it("bounds transient API failures", async () => { + const runGh = vi.fn(() => { + throw new Error("GitHub API unavailable"); + }); + await expect( + locateCandidateReceipt({ + dispatchId, + releasePlanDigest: lockFixture.receipt.release_plan_digest, + repo: "openclaw/openclaw", + runGh, + sleep: async () => {}, + timeoutMs: 1000, + workflowId, + workflowSha, + }), + ).rejects.toThrow("failed after 3 attempts"); + expect(runGh).toHaveBeenCalledTimes(3); + }); + + it("retries APIs and artifact propagation before reading the exact receipt", async () => { const receiptArtifactName = `release-candidate-receipt-${runId}-${runAttempt}`; const artifactResponse = artifactsFixture(); artifactResponse.artifacts.push({ @@ -256,6 +285,12 @@ describe("candidate receipt locator", () => { workflow_run: { id: Number(runId) }, }); artifactResponse.total_count = 5; + const incompleteArtifactResponse = { + artifacts: artifactResponse.artifacts.filter( + (artifact) => artifact.id !== Number(lockFixture.receipt.artifacts.root_image.artifact_id), + ), + total_count: 4, + }; const responses = new Map([ [ `api repos/openclaw/openclaw/actions/workflows/${workflowId} --method GET`, @@ -269,12 +304,13 @@ describe("candidate receipt locator", () => { `api repos/openclaw/openclaw/actions/runs/${runId}/attempts/${runAttempt} --method GET`, runFixture(), ], - [`api repos/openclaw/openclaw/actions/runs/${runId} --method GET`, runFixture()], [ `api repos/openclaw/openclaw/actions/runs/${runId}/artifacts?per_page=100 --method GET`, artifactResponse, ], ]); + let workflowAttempts = 0; + let artifactAttempts = 0; const runGh = vi.fn((args: string[]) => { if (args[0] === "run" && args[1] === "download") { const dir = args[args.indexOf("--dir") + 1]; @@ -284,12 +320,33 @@ describe("candidate receipt locator", () => { writeFileSync(resolve(dir, "candidate-receipt-lock.json"), lockText); return ""; } + if ( + args.join(" ") === + `api repos/openclaw/openclaw/actions/workflows/${workflowId} --method GET` && + workflowAttempts++ === 0 + ) { + throw new Error("transient GitHub API failure"); + } + if ( + args.join(" ") === + `api repos/openclaw/openclaw/actions/runs/${runId}/artifacts?per_page=100 --method GET` + ) { + artifactAttempts += 1; + return JSON.stringify( + artifactAttempts === 1 + ? { artifacts: artifactsFixture().artifacts, total_count: 4 } + : artifactAttempts === 2 + ? incompleteArtifactResponse + : artifactResponse, + ); + } const response = responses.get(args.join(" ")); if (!response) { throw new Error(`unexpected gh invocation: ${args.join(" ")}`); } return JSON.stringify(response); }); + const sleep = vi.fn(async () => {}); await expect( locateCandidateReceipt({ @@ -297,12 +354,15 @@ describe("candidate receipt locator", () => { releasePlanDigest: lockFixture.receipt.release_plan_digest, repo: "openclaw/openclaw", runGh, - sleep: async () => {}, + sleep, timeoutMs: 1000, workflowId, workflowSha, }), ).resolves.toEqual(lockFixture); + expect(workflowAttempts).toBe(2); + expect(artifactAttempts).toBe(3); + expect(sleep).toHaveBeenCalled(); expect(runGh).toHaveBeenCalledWith([ "run", "download", @@ -316,17 +376,35 @@ describe("candidate receipt locator", () => { ]); }); - it("rejects a superseded exact attempt", async () => { + it("keeps an exact successful attempt valid without consulting later reruns", async () => { + const receiptArtifactName = `release-candidate-receipt-${runId}-${runAttempt}`; + const artifactResponse = artifactsFixture(); + artifactResponse.artifacts.push({ + digest: `sha256:${"5".repeat(64)}`, + expired: false, + id: 105, + name: receiptArtifactName, + workflow_run: { id: Number(runId) }, + }); + artifactResponse.total_count = 5; const runGh = vi.fn((args: string[]) => { const key = args.join(" "); + if (args[0] === "run" && args[1] === "download") { + const dir = args[args.indexOf("--dir") + 1]; + if (!dir) { + throw new Error("missing download dir"); + } + writeFileSync(resolve(dir, "candidate-receipt-lock.json"), lockText); + return ""; + } if (key.includes(`actions/workflows/${workflowId} --method GET`)) { return JSON.stringify(workflowFixture()); } if (key.includes(`actions/runs/${runId}/attempts/${runAttempt}`)) { return JSON.stringify(runFixture()); } - if (key.includes(`actions/runs/${runId} --method GET`)) { - return JSON.stringify(runFixture({ run_attempt: 3 })); + if (key.includes(`actions/runs/${runId}/artifacts?per_page=100`)) { + return JSON.stringify(artifactResponse); } throw new Error(`unexpected gh invocation: ${key}`); }); @@ -343,7 +421,13 @@ describe("candidate receipt locator", () => { workflowId, workflowSha, }), - ).rejects.toThrow("superseded by a rerun"); + ).resolves.toEqual(lockFixture); + expect(runGh).not.toHaveBeenCalledWith([ + "api", + `repos/openclaw/openclaw/actions/runs/${runId}`, + "--method", + "GET", + ]); }); }); @@ -411,13 +495,16 @@ describe("release candidate artifact producer workflow", () => { expect(text).not.toContain("--push"); }); - it("validates canonical ReleasePlan bytes and derives candidate inputs without copying plan fields", () => { + it("verifies repository-derived ReleasePlan authority before exposing candidate inputs", () => { const validate = workflowJob(workflow, "validate_release_plan"); - const step = workflowStep(validate, "Validate ReleasePlanLock"); + const step = workflowStep(validate, "Verify repository-derived ReleasePlan authority"); expect(step.run).toContain("parseReleasePlanLockJson"); + expect(step.run).toContain("verifyReleasePlanLock"); expect(step.run).toContain("dispatch_id must be one safe unique caller nonce"); - expect(step.run).toContain("lock.plan.tooling.sha !== process.env.WORKFLOW_SHA"); - expect(step.run).toContain("lock.plan.tooling.ref !== process.env.WORKFLOW_FULL_REF"); + expect(step.run).toContain("repos/${process.env.GITHUB_REPOSITORY}/commits/${candidateSha}"); + expect(step.run).toContain('"fetch", "--force", "--no-tags", "origin", candidateSha'); + expect(step.run).toContain("toolingFullRef: workflowFullRef"); + expect(step.run).toContain("toolingSha: workflowSha"); expect(step.run).toContain("candidate_sha=${lock.plan.candidate_sha}"); expect(step.run).toContain("release_plan_digest=${lock.digest}"); expect(step.run).toContain("release_profile=${lock.plan.validation.profile}"); @@ -440,6 +527,20 @@ describe("release candidate artifact producer workflow", () => { shared_image_policy: "no-push-artifact", }); expect(root["runs-on"]).toBe("blacksmith-32vcpu-ubuntu-2404"); + const build = workflowStep(root, "Build root Dockerfile image"); + expect(build.env).toMatchObject({ + BUILD_TIMESTAMP: "2000-01-01T00:00:00.000Z", + TARGET_SHA: "${{ needs.validate_release_plan.outputs.candidate_sha }}", + }); + expect(build.run).toContain('--build-arg "GIT_COMMIT=$TARGET_SHA"'); + expect(build.run).toContain('--build-arg "OPENCLAW_BUILD_TIMESTAMP=$BUILD_TIMESTAMP"'); + const verify = workflowStep(root, "Verify root image build provenance"); + expect(verify.run).toContain("/app/dist/build-info.json"); + expect(verify.run).toContain('embedded_commit" != "$TARGET_SHA'); + const stepNames = root.steps?.map((step) => step.name) ?? []; + expect(stepNames.indexOf("Verify root image build provenance")).toBeLessThan( + stepNames.indexOf("Pack root Dockerfile image artifact"), + ); expect(workflowStep(root, "Pack root Dockerfile image artifact").run).toContain( "scripts/docker/shared-image-artifact.sh", ); @@ -459,11 +560,12 @@ describe("release candidate artifact producer workflow", () => { ); expect(provenance.run).toContain(".display_title == $title"); expect(provenance.run).toContain("actions/workflows/${workflow_id}"); + expect(provenance.run).not.toContain('.state == "active"'); const create = workflowStep(receipt, "Create canonical CandidateReceiptLock"); expect(create.run).toContain("createCandidateReceiptLock"); expect(create.run).toContain('docker_image: artifact("DOCKER_IMAGE"'); + expect(create.run).toContain("e2e_plugin_registry: artifact("); expect(create.run).toContain('package: artifact("PACKAGE"'); - expect(create.run).toContain("plugin_registry: artifact("); expect(create.run).toContain('root_image: artifact("ROOT_IMAGE"'); expect(workflowStep(receipt, "Upload CandidateReceiptLock").with).toMatchObject({ name: "release-candidate-receipt-${{ github.run_id }}-${{ github.run_attempt }}",