diff --git a/.github/workflows/docker-image-refresh.yml b/.github/workflows/docker-image-refresh.yml index b4cc4325a282..2480cef3d65b 100644 --- a/.github/workflows/docker-image-refresh.yml +++ b/.github/workflows/docker-image-refresh.yml @@ -134,5 +134,7 @@ jobs: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} permissions: + actions: read + attestations: read contents: read packages: write diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index a4907828b728..639bb4165715 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -16,6 +16,26 @@ on: required: false default: "" type: string + focused_release_evidence_run_id: + description: Optional immutable authorized beta focused evidence producer run + required: false + default: "" + type: string + focused_release_evidence_run_attempt: + description: Exact previously verified focused evidence producer attempt + required: false + default: "" + type: string + focused_release_evidence_workflow_full_ref: + description: Exact previously verified focused evidence producer tag + required: false + default: "" + type: string + focused_release_evidence_workflow_sha: + description: Exact previously verified focused evidence producer SHA + required: false + default: "" + type: string outputs: version: description: Resolved Docker release version without the v prefix @@ -178,6 +198,8 @@ jobs: if: ${{ always() && needs.approve_docker_publish.result == 'success' && needs.resolve_release_policy.result == 'success' && needs.validate_publish_config.result == 'success' }} runs-on: ubuntu-24.04 permissions: + actions: read + attestations: read contents: read outputs: built_at: ${{ steps.build_provenance.outputs.built_at }} @@ -188,6 +210,95 @@ jobs: with: ref: ${{ inputs.release_sha }} fetch-depth: 0 + persist-credentials: false + + - name: Checkout trusted focused evidence verifier + if: ${{ inputs.focused_release_evidence_run_id != '' }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + path: .release-harness + fetch-depth: 1 + persist-credentials: false + + - name: Revalidate focused evidence producer after Docker approval + if: ${{ inputs.focused_release_evidence_run_id != '' }} + env: + GH_TOKEN: ${{ github.token }} + FOCUSED_RELEASE_EVIDENCE_RUN_ID: ${{ inputs.focused_release_evidence_run_id }} + FOCUSED_RELEASE_EVIDENCE_RUN_ATTEMPT: ${{ inputs.focused_release_evidence_run_attempt }} + PRODUCER_WORKFLOW_FULL_REF: ${{ inputs.focused_release_evidence_workflow_full_ref }} + PRODUCER_WORKFLOW_SHA: ${{ inputs.focused_release_evidence_workflow_sha }} + run: | + set -euo pipefail + if [[ ! "${FOCUSED_RELEASE_EVIDENCE_RUN_ID}" =~ ^[1-9][0-9]*$ || + ! "${FOCUSED_RELEASE_EVIDENCE_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ || + ! "${PRODUCER_WORKFLOW_SHA}" =~ ^[a-f0-9]{40}$ || + ! "${PRODUCER_WORKFLOW_FULL_REF}" =~ ^refs/tags/release-publish/([a-f0-9]{12})-[1-9][0-9]*$ || + "${PRODUCER_WORKFLOW_SHA:0:12}" != "${BASH_REMATCH[1]}" ]]; then + echo "Focused evidence producer identity is missing or invalid after Docker approval." >&2 + exit 1 + fi + producer_ref="${PRODUCER_WORKFLOW_FULL_REF#refs/tags/}" + run_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${FOCUSED_RELEASE_EVIDENCE_RUN_ID}")" + if ! jq -e \ + --arg run_id "${FOCUSED_RELEASE_EVIDENCE_RUN_ID}" \ + --arg attempt "${FOCUSED_RELEASE_EVIDENCE_RUN_ATTEMPT}" \ + --arg producer_ref "${producer_ref}" \ + --arg producer_sha "${PRODUCER_WORKFLOW_SHA}" \ + '.id == ($run_id | tonumber) and .run_attempt == ($attempt | tonumber) and + .name == "Authorized Beta Focused Validation" and + .path == ".github/workflows/authorized-beta-focused-validation.yml" and + .event == "workflow_dispatch" and .status == "completed" and .conclusion == "success" and + .head_branch == $producer_ref and .head_sha == $producer_sha' \ + <<<"${run_json}" >/dev/null; then + echo "Focused evidence producer changed after Docker approval." >&2 + exit 1 + fi + remote_producer_sha="$( + gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${producer_ref}" \ + --jq '.object | select(.type == "commit") | .sha' + )" + if [[ "${remote_producer_sha}" != "${PRODUCER_WORKFLOW_SHA}" ]]; then + echo "Focused evidence producer tag is missing, moved, or annotated after Docker approval." >&2 + exit 1 + fi + + - name: Download focused release evidence after Docker approval + if: ${{ inputs.focused_release_evidence_run_id != '' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: authorized-beta-focused-v1-${{ inputs.focused_release_evidence_run_id }}-${{ inputs.focused_release_evidence_run_attempt }} + path: ${{ runner.temp }}/authorized-beta-focused-evidence + repository: ${{ github.repository }} + run-id: ${{ inputs.focused_release_evidence_run_id }} + github-token: ${{ github.token }} + + - name: Verify focused release evidence after Docker approval + if: ${{ inputs.focused_release_evidence_run_id != '' }} + env: + GH_TOKEN: ${{ github.token }} + FOCUSED_RELEASE_EVIDENCE_RUN_ID: ${{ inputs.focused_release_evidence_run_id }} + FOCUSED_RELEASE_EVIDENCE_RUN_ATTEMPT: ${{ inputs.focused_release_evidence_run_attempt }} + PRODUCER_WORKFLOW_FULL_REF: ${{ inputs.focused_release_evidence_workflow_full_ref }} + PRODUCER_WORKFLOW_SHA: ${{ inputs.focused_release_evidence_workflow_sha }} + run: | + set -euo pipefail + evidence="${RUNNER_TEMP}/authorized-beta-focused-evidence/evidence.json" + gh attestation verify "${evidence}" \ + --repo "${GITHUB_REPOSITORY}" \ + --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/authorized-beta-focused-validation.yml" \ + --signer-digest "${PRODUCER_WORKFLOW_SHA}" \ + --source-digest "${PRODUCER_WORKFLOW_SHA}" \ + --source-ref "${PRODUCER_WORKFLOW_FULL_REF}" \ + --deny-self-hosted-runners + node .release-harness/scripts/validate-authorized-beta-focused-evidence.mts verify \ + --candidate-root . \ + --artifact "${evidence}" \ + --producer-run-id "${FOCUSED_RELEASE_EVIDENCE_RUN_ID}" \ + --producer-run-attempt "${FOCUSED_RELEASE_EVIDENCE_RUN_ATTEMPT}" \ + --producer-workflow-full-ref "${PRODUCER_WORKFLOW_FULL_REF}" \ + --producer-workflow-sha "${PRODUCER_WORKFLOW_SHA}" - name: Resolve shared build provenance id: build_provenance diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml index a952633d59d0..6aa44221fe48 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -128,6 +128,8 @@ jobs: preflight_tarball_sha256: ${{ steps.manifest.outputs.tarball_sha256 }} full_release_validation_run_attempt: ${{ steps.full_run.outputs.attempt }} focused_release_evidence_run_attempt: ${{ steps.focused_run.outputs.attempt }} + focused_release_evidence_workflow_full_ref: ${{ steps.focused_run.outputs.workflow_full_ref }} + focused_release_evidence_workflow_sha: ${{ steps.focused_run.outputs.workflow_sha }} windows_node_installer_digests: ${{ steps.windows_source.outputs.installer_digests }} steps: - name: Validate inputs @@ -726,17 +728,63 @@ jobs: GH_TOKEN: ${{ github.token }} FOCUSED_RELEASE_EVIDENCE_RUN_ID: ${{ inputs.focused_release_evidence_run_id }} FOCUSED_RELEASE_EVIDENCE_RUN_ATTEMPT: ${{ inputs.focused_release_evidence_run_attempt }} + WORKFLOW_SHA: ${{ github.workflow_sha }} run: | set -euo pipefail - run_attempt="$( - gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${FOCUSED_RELEASE_EVIDENCE_RUN_ID}" \ - --jq '.run_attempt' - )" - if [[ "${run_attempt}" != "${FOCUSED_RELEASE_EVIDENCE_RUN_ATTEMPT}" ]]; then - echo "Focused evidence run attempt mismatch: expected ${FOCUSED_RELEASE_EVIDENCE_RUN_ATTEMPT}, got ${run_attempt}." >&2 + run_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${FOCUSED_RELEASE_EVIDENCE_RUN_ID}")" + if ! jq -e \ + --arg run_id "${FOCUSED_RELEASE_EVIDENCE_RUN_ID}" \ + --arg attempt "${FOCUSED_RELEASE_EVIDENCE_RUN_ATTEMPT}" \ + '.id == ($run_id | tonumber) and .run_attempt == ($attempt | tonumber) and + .name == "Authorized Beta Focused Validation" and + .path == ".github/workflows/authorized-beta-focused-validation.yml" and + .event == "workflow_dispatch" and .status == "completed" and .conclusion == "success"' \ + <<<"${run_json}" >/dev/null; then + echo "Focused evidence producer must be the exact successful authorized workflow run and attempt." >&2 exit 1 fi - echo "attempt=${run_attempt}" >> "$GITHUB_OUTPUT" + producer_ref="$(jq -r '.head_branch // empty' <<<"${run_json}")" + producer_sha="$(jq -r '.head_sha // empty' <<<"${run_json}")" + if [[ ! "${producer_ref}" =~ ^release-publish/([a-f0-9]{12})-[1-9][0-9]*$ ]]; then + echo "Focused evidence producer must use a protected release-publish tag." >&2 + exit 1 + fi + producer_prefix="${BASH_REMATCH[1]}" + if [[ ! "${producer_sha}" =~ ^[a-f0-9]{40}$ || "${producer_sha:0:12}" != "${producer_prefix}" ]]; then + echo "Focused evidence producer tag does not match its workflow SHA." >&2 + exit 1 + fi + remote_producer_sha="$( + gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${producer_ref}" \ + --jq '.object | select(.type == "commit") | .sha' + )" + if [[ "${remote_producer_sha}" != "${producer_sha}" ]]; then + echo "Focused evidence producer tag is missing, moved, or annotated." >&2 + exit 1 + fi + producer_workflow_path=".github/workflows/authorized-beta-focused-validation.yml" + producer_policy_path="scripts/authorized-beta-focused-policy.json" + for trusted_path in "${producer_workflow_path}" "${producer_policy_path}"; do + producer_blob="$(git rev-parse "${producer_sha}:${trusted_path}")" + consumer_blob="$(git rev-parse "${WORKFLOW_SHA}:${trusted_path}")" + if [[ "${producer_blob}" != "${consumer_blob}" ]]; then + echo "Focused evidence producer does not match the trusted ${trusted_path}." >&2 + exit 1 + fi + done + historical_tooling_sha="$( + git show "${WORKFLOW_SHA}:${producer_policy_path}" | + jq -er '.historicalToolingSha | select(type == "string" and test("^[a-f0-9]{40}$"))' + )" + if ! git merge-base --is-ancestor "${historical_tooling_sha}" "${producer_sha}"; then + echo "Focused evidence producer is not descended from the fixed authorized tooling." >&2 + exit 1 + fi + { + echo "attempt=${FOCUSED_RELEASE_EVIDENCE_RUN_ATTEMPT}" + echo "workflow_full_ref=refs/tags/${producer_ref}" + echo "workflow_sha=${producer_sha}" + } >> "$GITHUB_OUTPUT" - name: Download focused release evidence if: ${{ inputs.release_evidence_mode == 'authorized-beta-focused-v1' }} @@ -754,25 +802,25 @@ jobs: GH_TOKEN: ${{ github.token }} FOCUSED_RELEASE_EVIDENCE_RUN_ID: ${{ inputs.focused_release_evidence_run_id }} FOCUSED_RELEASE_EVIDENCE_RUN_ATTEMPT: ${{ steps.focused_run.outputs.attempt }} - WORKFLOW_FULL_REF: ${{ github.ref }} - WORKFLOW_SHA: ${{ github.workflow_sha }} + PRODUCER_WORKFLOW_FULL_REF: ${{ steps.focused_run.outputs.workflow_full_ref }} + PRODUCER_WORKFLOW_SHA: ${{ steps.focused_run.outputs.workflow_sha }} run: | set -euo pipefail evidence="${RUNNER_TEMP}/authorized-beta-focused-evidence/evidence.json" gh attestation verify "${evidence}" \ --repo "${GITHUB_REPOSITORY}" \ --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/authorized-beta-focused-validation.yml" \ - --signer-digest "${WORKFLOW_SHA}" \ - --source-digest "${WORKFLOW_SHA}" \ - --source-ref "${WORKFLOW_FULL_REF}" \ + --signer-digest "${PRODUCER_WORKFLOW_SHA}" \ + --source-digest "${PRODUCER_WORKFLOW_SHA}" \ + --source-ref "${PRODUCER_WORKFLOW_FULL_REF}" \ --deny-self-hosted-runners node "${RUNNER_TEMP}/release-validation-tooling/validate-authorized-beta-focused-evidence.mts" verify \ --candidate-root . \ --artifact "${evidence}" \ --producer-run-id "${FOCUSED_RELEASE_EVIDENCE_RUN_ID}" \ --producer-run-attempt "${FOCUSED_RELEASE_EVIDENCE_RUN_ATTEMPT}" \ - --producer-workflow-full-ref "${WORKFLOW_FULL_REF}" \ - --producer-workflow-sha "${WORKFLOW_SHA}" + --producer-workflow-full-ref "${PRODUCER_WORKFLOW_FULL_REF}" \ + --producer-workflow-sha "${PRODUCER_WORKFLOW_SHA}" - name: Validate release tag is reachable from a trusted release branch env: @@ -890,25 +938,25 @@ jobs: GH_TOKEN: ${{ github.token }} FOCUSED_RELEASE_EVIDENCE_RUN_ID: ${{ inputs.focused_release_evidence_run_id }} FOCUSED_RELEASE_EVIDENCE_RUN_ATTEMPT: ${{ needs.resolve_release_target.outputs.focused_release_evidence_run_attempt }} - WORKFLOW_FULL_REF: ${{ github.ref }} - WORKFLOW_SHA: ${{ github.workflow_sha }} + PRODUCER_WORKFLOW_FULL_REF: ${{ needs.resolve_release_target.outputs.focused_release_evidence_workflow_full_ref }} + PRODUCER_WORKFLOW_SHA: ${{ needs.resolve_release_target.outputs.focused_release_evidence_workflow_sha }} run: | set -euo pipefail evidence="${RUNNER_TEMP}/authorized-beta-focused-evidence/evidence.json" gh attestation verify "${evidence}" \ --repo "${GITHUB_REPOSITORY}" \ --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/authorized-beta-focused-validation.yml" \ - --signer-digest "${WORKFLOW_SHA}" \ - --source-digest "${WORKFLOW_SHA}" \ - --source-ref "${WORKFLOW_FULL_REF}" \ + --signer-digest "${PRODUCER_WORKFLOW_SHA}" \ + --source-digest "${PRODUCER_WORKFLOW_SHA}" \ + --source-ref "${PRODUCER_WORKFLOW_FULL_REF}" \ --deny-self-hosted-runners node .release-harness/scripts/validate-authorized-beta-focused-evidence.mts verify \ --candidate-root . \ --artifact "${evidence}" \ --producer-run-id "${FOCUSED_RELEASE_EVIDENCE_RUN_ID}" \ --producer-run-attempt "${FOCUSED_RELEASE_EVIDENCE_RUN_ATTEMPT}" \ - --producer-workflow-full-ref "${WORKFLOW_FULL_REF}" \ - --producer-workflow-sha "${WORKFLOW_SHA}" + --producer-workflow-full-ref "${PRODUCER_WORKFLOW_FULL_REF}" \ + --producer-workflow-sha "${PRODUCER_WORKFLOW_SHA}" - name: Setup Node environment uses: ./.github/actions/setup-node-env @@ -2743,10 +2791,16 @@ jobs: with: tag: ${{ inputs.tag }} release_sha: ${{ needs.resolve_release_target.outputs.sha }} + focused_release_evidence_run_id: ${{ inputs.release_evidence_mode == 'authorized-beta-focused-v1' && inputs.focused_release_evidence_run_id || '' }} + focused_release_evidence_run_attempt: ${{ needs.resolve_release_target.outputs.focused_release_evidence_run_attempt }} + focused_release_evidence_workflow_full_ref: ${{ needs.resolve_release_target.outputs.focused_release_evidence_workflow_full_ref }} + focused_release_evidence_workflow_sha: ${{ needs.resolve_release_target.outputs.focused_release_evidence_workflow_sha }} secrets: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} permissions: + actions: read + attestations: read contents: read packages: write diff --git a/test/scripts/authorized-beta-focused-evidence.test.ts b/test/scripts/authorized-beta-focused-evidence.test.ts index b34ad15b2173..96df7b876600 100644 --- a/test/scripts/authorized-beta-focused-evidence.test.ts +++ b/test/scripts/authorized-beta-focused-evidence.test.ts @@ -24,7 +24,11 @@ type ParsedWorkflow = { jobs?: Record< string, { + needs?: string | string[]; + outputs?: Record; + permissions?: Record; steps?: Array<{ + env?: Record; if?: string; name?: string; run?: string; @@ -83,6 +87,17 @@ function fixturePolicy(): { policy: AuthorizedBetaFocusedPolicy; root: string } execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: root }); execFileSync("git", ["config", "user.name", "Test"], { cwd: root }); writeFileSync(join(root, "published.txt"), "published\n"); + const historicalToolingSha = commit(root, "historical tooling"); + mkdirSync(join(root, ".github", "workflows"), { recursive: true }); + mkdirSync(join(root, "scripts")); + writeFileSync( + join(root, ".github", "workflows", "authorized-beta-focused-validation.yml"), + "name: Authorized Beta Focused Validation\n", + ); + writeFileSync( + join(root, "scripts", "authorized-beta-focused-policy.json"), + JSON.stringify({ historicalToolingSha }), + ); mkdirSync(join(root, "tests")); writeFileSync(join(root, "tests", "proof.test.ts"), "one\n"); const baseCandidateSha = commit(root, "base"); @@ -101,6 +116,7 @@ function fixturePolicy(): { policy: AuthorizedBetaFocusedPolicy; root: string } ...readAuthorizedBetaFocusedPolicy(), baseCandidateSha, candidateSha, + historicalToolingSha, reviewedHeadSha: candidateSha, candidateTreeSha, baseTreeSha, @@ -117,7 +133,215 @@ function fixturePolicy(): { policy: AuthorizedBetaFocusedPolicy; root: string } }; } +function resolveFocusedProducer( + options: { + annotatedTag?: boolean; + boundary?: "docker" | "resolve"; + consumer?: "ancestor" | "current" | "diverged"; + missingTag?: boolean; + producer?: "policy-drift" | "unanchored" | "workflow-drift"; + run?: Record; + tag?: Record; + } = {}, +) { + const { policy, root } = fixturePolicy(); + let producerSha = options.consumer === "current" ? policy.candidateSha : policy.baseCandidateSha; + const consumerSha = policy.candidateSha; + if (options.consumer === "diverged" || options.producer) { + if (options.producer === "unanchored") { + git(root, ["checkout", "--quiet", "--orphan", "unanchored-producer"]); + } else { + git(root, ["checkout", "--quiet", "--detach", policy.baseCandidateSha]); + } + if (options.producer === "policy-drift") { + writeFileSync( + join(root, "scripts", "authorized-beta-focused-policy.json"), + JSON.stringify({ historicalToolingSha: "f".repeat(40) }), + ); + } else if (options.producer === "workflow-drift") { + writeFileSync( + join(root, ".github", "workflows", "authorized-beta-focused-validation.yml"), + "name: Untrusted Validation\n", + ); + } else { + writeFileSync(join(root, "published.txt"), "protected producer branch\n"); + } + producerSha = commit(root, "protected producer"); + git(root, ["checkout", "--quiet", "--detach", consumerSha]); + } + const producerRef = `release-publish/${producerSha.slice(0, 12)}-123`; + const outputPath = join(root, "github-output"); + const isDockerBoundary = options.boundary === "docker"; + const workflow = parse( + readFileSync( + isDockerBoundary + ? ".github/workflows/docker-release.yml" + : ".github/workflows/openclaw-release-publish.yml", + "utf8", + ), + ) as ParsedWorkflow; + const run = { + id: 123, + run_attempt: 2, + name: "Authorized Beta Focused Validation", + path: ".github/workflows/authorized-beta-focused-validation.yml", + event: "workflow_dispatch", + status: "completed", + conclusion: "success", + head_branch: producerRef, + head_sha: producerSha, + ...options.run, + }; + const tag = { + ref: `refs/tags/${producerRef}`, + object: { sha: producerSha, type: options.annotatedTag ? "tag" : "commit" }, + ...options.tag, + }; + const result = spawnSync( + "bash", + [ + "-c", + [ + "gh() {", + ' if [[ "$2" == */actions/runs/* ]]; then', + ' if [[ "${3:-}" == --jq ]]; then', + ' printf "%s\\n" "$MOCK_RUN_JSON" | jq -r "$4"', + " else", + ' printf "%s\\n" "$MOCK_RUN_JSON"', + " fi", + ' elif [[ "$2" == */git/ref/tags/* && "$MOCK_TAG_MISSING" != true ]]; then', + ' printf "%s\\n" "$MOCK_TAG_JSON" | jq -r "$4"', + " else", + " return 1", + " fi", + "}", + namedStep( + workflow, + isDockerBoundary ? "resolve_build_provenance" : "resolve_release_target", + isDockerBoundary + ? "Revalidate focused evidence producer after Docker approval" + : "Resolve focused release evidence run", + ).run, + ].join("\n"), + ], + { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + FOCUSED_RELEASE_EVIDENCE_RUN_ATTEMPT: "2", + FOCUSED_RELEASE_EVIDENCE_RUN_ID: "123", + GITHUB_OUTPUT: outputPath, + GITHUB_REPOSITORY: "openclaw/openclaw", + MOCK_RUN_JSON: JSON.stringify(run), + MOCK_TAG_JSON: JSON.stringify(tag), + MOCK_TAG_MISSING: String(options.missingTag ?? false), + PRODUCER_WORKFLOW_FULL_REF: `refs/tags/${producerRef}`, + PRODUCER_WORKFLOW_SHA: producerSha, + WORKFLOW_SHA: consumerSha, + }, + }, + ); + return { consumerSha, outputPath, producerRef, producerSha, result }; +} + describe("authorized beta focused evidence", () => { + it.each(["ancestor", "current", "diverged"] as const)( + "accepts an exact protected focused producer from %s trusted tooling", + (consumer) => { + const { outputPath, producerRef, producerSha, result } = resolveFocusedProducer({ consumer }); + + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + expect(readFileSync(outputPath, "utf8")).toBe( + `attempt=2\nworkflow_full_ref=refs/tags/${producerRef}\nworkflow_sha=${producerSha}\n`, + ); + }, + ); + + it.each([ + { name: "unanchored producer", options: { producer: "unanchored" as const } }, + { name: "producer policy drift", options: { producer: "policy-drift" as const } }, + { name: "producer workflow drift", options: { producer: "workflow-drift" as const } }, + { + name: "moved producer tag", + options: { tag: { object: { sha: "f".repeat(40), type: "commit" } } }, + }, + { name: "missing producer tag", options: { missingTag: true } }, + { name: "annotated producer tag", options: { annotatedTag: true } }, + { + name: "producer SHA prefix mismatch", + options: { run: { head_branch: "release-publish/ffffffffffff-123" } }, + }, + { + name: "malformed producer tag", + options: { run: { head_branch: "release-publish/ffffffffffff-0" } }, + }, + { + name: "wrong producer workflow path", + options: { run: { path: ".github/workflows/openclaw-release-publish.yml" } }, + }, + { name: "wrong producer workflow name", options: { run: { name: "Other Validation" } } }, + { name: "wrong producer event", options: { run: { event: "push" } } }, + { name: "unfinished producer", options: { run: { status: "in_progress" } } }, + { name: "failed producer", options: { run: { conclusion: "failure" } } }, + { name: "wrong producer attempt", options: { run: { run_attempt: 3 } } }, + ])("rejects $name before focused artifact download", ({ options }) => { + expect(resolveFocusedProducer(options).result.status).not.toBe(0); + }); + + it("accepts the exact focused producer again after Docker approval", () => { + const { result } = resolveFocusedProducer({ boundary: "docker", consumer: "diverged" }); + + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + }); + + it.each([ + { + name: "moved producer tag", + options: { tag: { object: { sha: "f".repeat(40), type: "commit" } } }, + }, + { name: "missing producer tag", options: { missingTag: true } }, + { name: "rerun producer", options: { run: { run_attempt: 3 } } }, + { name: "substituted producer", options: { run: { head_sha: "f".repeat(40) } } }, + { name: "failed producer", options: { run: { conclusion: "failure" } } }, + ])("rejects $name after Docker approval before registry access", ({ options }) => { + expect(resolveFocusedProducer({ ...options, boundary: "docker" }).result.status).not.toBe(0); + }); + + it("gates every Docker build on post-approval focused evidence revalidation", () => { + const docker = parse( + readFileSync(".github/workflows/docker-release.yml", "utf8"), + ) as ParsedWorkflow; + const gate = docker.jobs?.resolve_build_provenance; + if (!gate) { + throw new Error("Docker build provenance gate is missing"); + } + + expect(gate.needs).toContain("approve_docker_publish"); + expect(gate.permissions).toMatchObject({ + actions: "read", + attestations: "read", + contents: "read", + }); + const names = (gate.steps ?? []).map((step) => step.name); + const revalidation = names.indexOf( + "Revalidate focused evidence producer after Docker approval", + ); + const download = names.indexOf("Download focused release evidence after Docker approval"); + const verification = names.indexOf("Verify focused release evidence after Docker approval"); + const provenance = names.indexOf("Resolve shared build provenance"); + expect(revalidation).toBeGreaterThan(-1); + expect(revalidation).toBeLessThan(download); + expect(download).toBeLessThan(verification); + expect(verification).toBeLessThan(provenance); + + for (const jobName of ["build-amd64", "build-arm64"]) { + expect(docker.jobs?.[jobName]?.needs).toContain("resolve_build_provenance"); + } + }); + it("pins the exact beta.3 candidate, inventories, trust split, and repaired leaves", () => { const policy = readAuthorizedBetaFocusedPolicy(); expect(policy.releaseTag).toBe("v2026.8.1-beta.3"); @@ -320,8 +544,11 @@ describe("authorized beta focused evidence", () => { const source = readFileSync(path, "utf8"); expect(source).toContain("Verify focused release evidence"); expect(source).toContain("gh attestation verify"); - expect(source).toContain('--signer-digest "${WORKFLOW_SHA}"'); - expect(source).toContain('--source-digest "${WORKFLOW_SHA}"'); + const signerSha = path.endsWith("openclaw-release-publish.yml") + ? "PRODUCER_WORKFLOW_SHA" + : "WORKFLOW_SHA"; + expect(source).toContain(`--signer-digest "\${${signerSha}}"`); + expect(source).toContain(`--source-digest "\${${signerSha}}"`); expect(source).toContain("validate-authorized-beta-focused-evidence.mts"); expect(source).toContain("inputs.release_evidence_mode == 'full-release-validation'"); expect(source).toContain("validate-full-release-validation-evidence.mjs"); @@ -348,6 +575,30 @@ describe("authorized beta focused evidence", () => { const resolveSteps = parentWorkflow.jobs?.resolve_release_target?.steps ?? []; const resolveStepNames = resolveSteps.map((step) => step.name); expect(resolveStepNames).not.toContain("Install focused release verifier dependency"); + expect(parentWorkflow.jobs?.resolve_release_target?.outputs).toMatchObject({ + focused_release_evidence_workflow_full_ref: + "${{ steps.focused_run.outputs.workflow_full_ref }}", + focused_release_evidence_workflow_sha: "${{ steps.focused_run.outputs.workflow_sha }}", + }); + for (const [jobName, stepName] of [ + ["resolve_release_target", "Verify focused release evidence"], + ["publish", "Verify focused release evidence after approval"], + ] as const) { + const verifyStep = namedStep(parentWorkflow, jobName, stepName); + const outputPrefix = + jobName === "publish" + ? "needs.resolve_release_target.outputs.focused_release_evidence_" + : "steps.focused_run.outputs."; + expect(verifyStep.env).toMatchObject({ + PRODUCER_WORKFLOW_FULL_REF: `\${{ ${outputPrefix}workflow_full_ref }}`, + PRODUCER_WORKFLOW_SHA: `\${{ ${outputPrefix}workflow_sha }}`, + }); + expect(verifyStep.run).toContain('--source-ref "${PRODUCER_WORKFLOW_FULL_REF}"'); + expect(verifyStep.run).toContain( + '--producer-workflow-full-ref "${PRODUCER_WORKFLOW_FULL_REF}"', + ); + expect(verifyStep.run).toContain('--producer-workflow-sha "${PRODUCER_WORKFLOW_SHA}"'); + } const publishSteps = parentWorkflow.jobs?.publish?.steps ?? []; const publishStepNames = publishSteps.map((step) => step.name); expect(publishStepNames.indexOf("Verify focused release evidence after approval")).toBeLessThan( diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 2135907dfae7..6de2937e33c3 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -2043,7 +2043,12 @@ NODE DOCKERHUB_USERNAME: "${{ secrets.DOCKERHUB_USERNAME }}", DOCKERHUB_TOKEN: "${{ secrets.DOCKERHUB_TOKEN }}", }); - expect(publish.permissions).toEqual({ contents: "read", packages: "write" }); + expect(publish.permissions).toEqual({ + actions: "read", + attestations: "read", + contents: "read", + packages: "write", + }); expect(releaseWorkflow.jobs.approve_docker_publish.environment).toBe("docker-release"); }); diff --git a/test/scripts/release-no-push-workflow.test.ts b/test/scripts/release-no-push-workflow.test.ts index c5145796212a..5b61891070b8 100644 --- a/test/scripts/release-no-push-workflow.test.ts +++ b/test/scripts/release-no-push-workflow.test.ts @@ -1483,6 +1483,14 @@ describe("release validation no-push transport", () => { expect(dockerCall.with).toEqual({ tag: "${{ inputs.tag }}", release_sha: "${{ needs.resolve_release_target.outputs.sha }}", + focused_release_evidence_run_id: + "${{ inputs.release_evidence_mode == 'authorized-beta-focused-v1' && inputs.focused_release_evidence_run_id || '' }}", + focused_release_evidence_run_attempt: + "${{ needs.resolve_release_target.outputs.focused_release_evidence_run_attempt }}", + focused_release_evidence_workflow_full_ref: + "${{ needs.resolve_release_target.outputs.focused_release_evidence_workflow_full_ref }}", + focused_release_evidence_workflow_sha: + "${{ needs.resolve_release_target.outputs.focused_release_evidence_workflow_sha }}", }); expect(dockerCall.secrets).toEqual({ DOCKERHUB_USERNAME: "${{ secrets.DOCKERHUB_USERNAME }}",