diff --git a/.github/workflows/docker-channel-promote.yml b/.github/workflows/docker-channel-promote.yml index 88927dff8091..93428b49e27d 100644 --- a/.github/workflows/docker-channel-promote.yml +++ b/.github/workflows/docker-channel-promote.yml @@ -14,6 +14,10 @@ env: IMAGE_NAME: ${{ github.repository }} DOCKERHUB_REGISTRY: docker.io DOCKERHUB_IMAGE_NAME: openclaw/openclaw + RELEASE_TOOLING_ALLOW_PREVALIDATED_REF: "false" + RELEASE_TOOLING_FULL_REF: ${{ github.ref }} + RELEASE_TOOLING_REF: ${{ github.ref_name }} + RELEASE_TOOLING_SHA: ${{ github.workflow_sha }} jobs: resolve: @@ -108,6 +112,7 @@ jobs: cancel-in-progress: false queue: max permissions: + actions: read contents: read packages: write steps: @@ -164,6 +169,7 @@ jobs: - name: Promote and verify channel aliases env: + GH_TOKEN: ${{ github.token }} VERSION: ${{ needs.resolve.outputs.version }} GHCR_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }} diff --git a/.github/workflows/docker-image-refresh.yml b/.github/workflows/docker-image-refresh.yml index e27d7f308cef..5656efc68c92 100644 --- a/.github/workflows/docker-image-refresh.yml +++ b/.github/workflows/docker-image-refresh.yml @@ -21,6 +21,7 @@ on: - cron: "17 3 * * 1" permissions: + actions: read contents: read env: @@ -130,9 +131,14 @@ jobs: tag: ${{ matrix.tag }} release_sha: ${{ matrix.release_sha }} image_tag_suffix: ${{ needs.plan.outputs.image_tag_suffix }} + trusted_workflow_allow_prevalidated_ref: false + trusted_workflow_full_ref: ${{ github.ref }} + trusted_workflow_ref: ${{ github.ref_name }} + trusted_workflow_sha: ${{ github.workflow_sha }} secrets: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} permissions: + actions: read contents: read packages: write diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 82f98897026a..a995385b50de 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -16,6 +16,32 @@ on: required: false default: "" type: string + trusted_workflow_ref: + description: Trusted release tooling ref name + required: true + type: string + trusted_workflow_full_ref: + description: Trusted release tooling full ref + required: true + type: string + trusted_workflow_sha: + description: Trusted release tooling commit SHA + required: true + type: string + trusted_workflow_allow_prevalidated_ref: + description: Allow an exact live branch outside main as trusted release tooling + required: true + type: boolean + release_publish_run_id: + description: Exact parent OpenClaw Release Publish run id when release-orchestrated + required: false + default: "" + type: string + release_publish_run_attempt: + description: Exact parent OpenClaw Release Publish run attempt when release-orchestrated + required: false + default: "" + type: string outputs: version: description: Resolved Docker release version without the v prefix @@ -45,11 +71,18 @@ env: IMAGE_NAME: ${{ github.repository }} DOCKERHUB_REGISTRY: docker.io DOCKERHUB_IMAGE_NAME: openclaw/openclaw + RELEASE_PUBLISH_RUN_ATTEMPT: ${{ inputs.release_publish_run_attempt }} + RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }} + RELEASE_TOOLING_ALLOW_PREVALIDATED_REF: ${{ inputs.trusted_workflow_allow_prevalidated_ref }} + RELEASE_TOOLING_FULL_REF: ${{ inputs.trusted_workflow_full_ref }} + RELEASE_TOOLING_REF: ${{ inputs.trusted_workflow_ref }} + RELEASE_TOOLING_SHA: ${{ inputs.trusted_workflow_sha }} jobs: validate_release_identity: runs-on: ubuntu-24.04 permissions: + actions: read contents: read steps: - name: Validate immutable tag and SHA inputs @@ -101,6 +134,21 @@ jobs: exit 1 fi + - &trusted_release_tooling_checkout + name: Checkout trusted release tooling + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ inputs.trusted_workflow_sha }} + path: .release-tooling + persist-credentials: false + sparse-checkout: scripts + + - &verify_release_tooling_step + name: Revalidate trusted release tooling + env: + GH_TOKEN: ${{ github.token }} + run: node .release-tooling/scripts/release-tooling-identity.mjs verify-env + resolve_release_policy: needs: validate_release_identity runs-on: ubuntu-24.04 @@ -113,7 +161,7 @@ jobs: - name: Checkout trusted workflow helpers uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: ${{ github.sha }} + ref: ${{ inputs.trusted_workflow_sha }} path: workflow-source persist-credentials: false @@ -206,6 +254,7 @@ jobs: # WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS. runs-on: ubuntu-24.04 permissions: + actions: read packages: write contents: read outputs: @@ -218,6 +267,8 @@ jobs: ref: ${{ needs.resolve_build_provenance.outputs.source_sha }} fetch-depth: 0 + - *trusted_release_tooling_checkout + - &buildkit_prepull_step name: Pre-pull BuildKit image shell: bash @@ -323,6 +374,8 @@ jobs: echo "EOF" } >> "$GITHUB_OUTPUT" + - *verify_release_tooling_step + - name: Build and push amd64 image id: build # WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY. @@ -342,6 +395,12 @@ jobs: provenance: mode=max push: true + - name: Revalidate trusted release tooling before browser push + if: steps.tags.outputs.browser != '' + env: + GH_TOKEN: ${{ github.token }} + run: node .release-tooling/scripts/release-tooling-identity.mjs verify-env + - name: Build and push amd64 browser image id: build-browser if: steps.tags.outputs.browser != '' @@ -435,6 +494,7 @@ jobs: # WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS. runs-on: ubuntu-24.04-arm permissions: + actions: read packages: write contents: read outputs: @@ -447,6 +507,8 @@ jobs: ref: ${{ needs.resolve_build_provenance.outputs.source_sha }} fetch-depth: 0 + - *trusted_release_tooling_checkout + - *buildkit_prepull_step - name: Set up Docker Builder uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 @@ -533,6 +595,8 @@ jobs: echo "EOF" } >> "$GITHUB_OUTPUT" + - *verify_release_tooling_step + - name: Build and push arm64 image id: build # WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY. @@ -552,6 +616,12 @@ jobs: provenance: mode=max push: true + - name: Revalidate trusted release tooling before browser push + if: steps.tags.outputs.browser != '' + env: + GH_TOKEN: ${{ github.token }} + run: node .release-tooling/scripts/release-tooling-identity.mjs verify-env + - name: Build and push arm64 browser image id: build-browser if: steps.tags.outputs.browser != '' @@ -653,6 +723,7 @@ jobs: # WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS. runs-on: ubuntu-24.04 permissions: + actions: read packages: write contents: read outputs: @@ -664,6 +735,8 @@ jobs: ref: ${{ needs.resolve_build_provenance.outputs.source_sha }} fetch-depth: 0 + - *trusted_release_tooling_checkout + - name: Login to GitHub Container Registry uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: @@ -737,6 +810,7 @@ jobs: - name: Create and push manifest shell: bash env: + GH_TOKEN: ${{ github.token }} DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }} SOURCE_REF: ${{ format('refs/tags/{0}', inputs.tag) }} TAGS: ${{ steps.tags.outputs.value }} @@ -763,6 +837,7 @@ jobs: [ -z "$tag" ] && continue args+=("-t" "$tag") done + node .release-tooling/scripts/release-tooling-identity.mjs verify-env docker buildx imagetools create "${args[@]}" "$amd64_digest" "$arm64_digest" } create_manifest "${AMD64_DIGEST}" "${ARM64_DIGEST}" "${tags[@]}" @@ -791,6 +866,7 @@ jobs: outputs: vcr_source_refs: ${{ steps.vcr_source_refs.outputs.value }} permissions: + actions: read contents: read packages: write steps: @@ -800,6 +876,8 @@ jobs: ref: ${{ needs.resolve_build_provenance.outputs.source_sha }} fetch-depth: 1 + - *trusted_release_tooling_checkout + - *buildkit_prepull_step - name: Set up Docker Builder uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 @@ -919,24 +997,24 @@ jobs: mapfile -t dockerhub_amd64_refs <<< "${DOCKERHUB_AMD64_REFS}" mapfile -t dockerhub_arm64_refs <<< "${DOCKERHUB_ARM64_REFS}" - node scripts/verify-docker-attestations.mjs \ + node .release-tooling/scripts/verify-docker-attestations.mjs \ --platform linux/amd64 \ --platform linux/arm64 \ "${multi_refs[@]}" - node scripts/verify-docker-attestations.mjs \ + node .release-tooling/scripts/verify-docker-attestations.mjs \ --platform linux/amd64 \ "${amd64_refs[@]}" - node scripts/verify-docker-attestations.mjs \ + node .release-tooling/scripts/verify-docker-attestations.mjs \ --platform linux/arm64 \ "${arm64_refs[@]}" - node scripts/verify-docker-attestations.mjs \ + node .release-tooling/scripts/verify-docker-attestations.mjs \ --platform linux/amd64 \ --platform linux/arm64 \ "${dockerhub_multi_refs[@]}" - node scripts/verify-docker-attestations.mjs \ + node .release-tooling/scripts/verify-docker-attestations.mjs \ --platform linux/amd64 \ "${dockerhub_amd64_refs[@]}" - node scripts/verify-docker-attestations.mjs \ + node .release-tooling/scripts/verify-docker-attestations.mjs \ --platform linux/arm64 \ "${dockerhub_arm64_refs[@]}" @@ -981,7 +1059,7 @@ jobs: immutable_refs+=("${immutable_ref}") done - node scripts/verify-docker-attestations.mjs \ + node .release-tooling/scripts/verify-docker-attestations.mjs \ --platform linux/amd64 \ --platform linux/arm64 \ "${immutable_refs[@]}" @@ -995,13 +1073,14 @@ jobs: - name: Promote and verify channel aliases if: ${{ needs.resolve_release_policy.outputs.channel != 'beta' }} env: + GH_TOKEN: ${{ github.token }} VERSION: ${{ needs.resolve_release_policy.outputs.version }} IMAGE_TAG_SUFFIX: ${{ inputs.image_tag_suffix }} GHCR_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }} run: | set -euo pipefail - node scripts/docker-channel-promote.mjs \ + node .release-tooling/scripts/docker-channel-promote.mjs \ --version "${VERSION}" \ --image-tag-suffix "${IMAGE_TAG_SUFFIX}" \ --image "${GHCR_IMAGE}" \ diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml index abdb25ebbd38..dc44c55e9db9 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -2556,10 +2556,17 @@ jobs: with: tag: ${{ inputs.tag }} release_sha: ${{ needs.resolve_release_target.outputs.sha }} + release_publish_run_attempt: ${{ github.run_attempt }} + release_publish_run_id: ${{ github.run_id }} + trusted_workflow_allow_prevalidated_ref: ${{ github.ref_type == 'branch' && github.ref_name != 'main' }} + trusted_workflow_full_ref: ${{ github.ref }} + trusted_workflow_ref: ${{ github.ref_name }} + trusted_workflow_sha: ${{ github.workflow_sha }} secrets: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} permissions: + actions: read contents: read packages: write diff --git a/scripts/docker-channel-promote.mjs b/scripts/docker-channel-promote.mjs index 22fa4e339592..92bf938f6da1 100644 --- a/scripts/docker-channel-promote.mjs +++ b/scripts/docker-channel-promote.mjs @@ -6,6 +6,7 @@ import { parseArgs } from "node:util"; import { isDirectRunUrl } from "./lib/direct-run.mjs"; import { resolveDockerReleasePolicy } from "./lib/docker-release-policy.mjs"; import { compareReleaseVersions } from "./lib/release-version.mjs"; +import { verifyReleaseToolingIdentityFromEnvironment } from "./release-tooling-identity.mjs"; import { parsePlatform, verifyDockerAttestations } from "./verify-docker-attestations.mjs"; const DOCKER_TIMEOUT_MS = 120_000; @@ -40,6 +41,7 @@ const VARIANTS = Object.freeze([ * @typedef {object} DockerPromotionOptions * @property {boolean} [allowRollback] * @property {DockerExec} [execFileSyncImpl] + * @property {() => void} [beforeMutation] * @property {(message: string) => void} [log] * @property {(params: DockerAttestationParams) => void} [verifyAttestationsImpl] */ @@ -223,6 +225,7 @@ function preventChannelRollback(resolved, version, execFileSyncImpl) { */ export function promoteDockerChannel({ version, imageTagSuffix = "", images }, options = {}) { const execFileSyncImpl = options.execFileSyncImpl ?? execFileSync; + const beforeMutation = options.beforeMutation ?? (() => {}); const log = options.log ?? console.log; const verifyAttestationsImpl = options.verifyAttestationsImpl ?? verifyDockerAttestations; const plan = createDockerChannelPromotionPlan({ @@ -257,6 +260,7 @@ export function promoteDockerChannel({ version, imageTagSuffix = "", images }, o for (const promotion of resolved) { const targetArgs = promotion.targetRefs.flatMap((targetRef) => ["--tag", targetRef]); + beforeMutation(); runDocker( [ "buildx", @@ -313,7 +317,10 @@ function main() { } const plan = promoteDockerChannel( { version, imageTagSuffix: values["image-tag-suffix"], images }, - { allowRollback: values["allow-rollback"] }, + { + allowRollback: values["allow-rollback"], + beforeMutation: verifyReleaseToolingIdentityFromEnvironment, + }, ); console.log(`Promoted Docker ${plan.channel} aliases for ${plan.version}.`); } diff --git a/scripts/release-tooling-identity.d.mts b/scripts/release-tooling-identity.d.mts index 8e2349f044c2..ca6a871dd544 100644 --- a/scripts/release-tooling-identity.d.mts +++ b/scripts/release-tooling-identity.d.mts @@ -36,6 +36,13 @@ export function verifyReleaseToolingIdentity( }, ): ReleaseToolingIdentity; +export function verifyReleaseToolingIdentityFromEnvironment( + env?: Record, + options?: { + runGh?: (args: string[]) => string; + }, +): ReleaseToolingIdentity; + export function validateReleasePublishParentRun(input: { identity: Pick; releasePublishRunAttempt: string; diff --git a/scripts/release-tooling-identity.mjs b/scripts/release-tooling-identity.mjs index f6d578c94a45..0f91b4bcefe1 100644 --- a/scripts/release-tooling-identity.mjs +++ b/scripts/release-tooling-identity.mjs @@ -376,6 +376,23 @@ export function verifyReleaseToolingIdentity({ return validated; } +export function verifyReleaseToolingIdentityFromEnvironment(env = process.env, { runGh } = {}) { + const allowPrevalidatedRef = env.RELEASE_TOOLING_ALLOW_PREVALIDATED_REF ?? "false"; + if (allowPrevalidatedRef !== "true" && allowPrevalidatedRef !== "false") { + fail("RELEASE_TOOLING_ALLOW_PREVALIDATED_REF must be true or false."); + } + return verifyReleaseToolingIdentity({ + allowPrevalidatedRef: allowPrevalidatedRef === "true", + releasePublishRunAttempt: env.RELEASE_PUBLISH_RUN_ATTEMPT, + releasePublishRunId: env.RELEASE_PUBLISH_RUN_ID, + repository: env.GITHUB_REPOSITORY, + runGh, + workflowFullRef: env.RELEASE_TOOLING_FULL_REF, + workflowRef: env.RELEASE_TOOLING_REF, + workflowSha: env.RELEASE_TOOLING_SHA, + }); +} + function validateParentRunIfRequested({ identity, releasePublishRunAttempt, @@ -455,6 +472,13 @@ function parseArgs(argv) { } function main(argv = process.argv.slice(2)) { + if (argv[0] === "verify-env") { + if (argv.length !== 1) { + fail("usage: release-tooling-identity.mjs verify-env"); + } + process.stdout.write(`${JSON.stringify(verifyReleaseToolingIdentityFromEnvironment())}\n`); + return; + } const options = parseArgs([...argv]); let identity; if (options.command === "resolve") { diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 13279177a59a..965978b22f09 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -1846,12 +1846,20 @@ NODE tag: "${{ matrix.tag }}", release_sha: "${{ matrix.release_sha }}", image_tag_suffix: "${{ needs.plan.outputs.image_tag_suffix }}", + trusted_workflow_allow_prevalidated_ref: false, + trusted_workflow_full_ref: "${{ github.ref }}", + trusted_workflow_ref: "${{ github.ref_name }}", + trusted_workflow_sha: "${{ github.workflow_sha }}", }); expect(publish.secrets).toEqual({ DOCKERHUB_USERNAME: "${{ secrets.DOCKERHUB_USERNAME }}", DOCKERHUB_TOKEN: "${{ secrets.DOCKERHUB_TOKEN }}", }); - expect(publish.permissions).toEqual({ contents: "read", packages: "write" }); + expect(publish.permissions).toEqual({ + actions: "read", + contents: "read", + packages: "write", + }); expect(releaseWorkflow.jobs.approve_docker_publish.environment).toBe("docker-release"); }); diff --git a/test/scripts/docker-channel-promote.test.ts b/test/scripts/docker-channel-promote.test.ts index 4615691c019c..9d52a467ed0b 100644 --- a/test/scripts/docker-channel-promote.test.ts +++ b/test/scripts/docker-channel-promote.test.ts @@ -67,11 +67,17 @@ type WorkflowJob = { type Workflow = { concurrency?: { group?: string; "cancel-in-progress"?: boolean; queue?: string }; + env?: Record; jobs?: Record; + on?: { + workflow_call?: { + inputs?: Record; + }; + }; }; -function readWorkflow(path: string): Workflow { - return parse(readFileSync(path, "utf8")) as Workflow; +function readWorkflow(workflowPath: string): Workflow { + return parse(readFileSync(workflowPath, "utf8")) as Workflow; } function requireJob(workflow: Workflow, name: string): WorkflowJob { @@ -258,6 +264,33 @@ describe("Docker channel promotion", () => { ); }); + it("revalidates trusted tooling immediately before every alias mutation", () => { + const events: string[] = []; + const docker = createDockerMock({ + candidateVersion: "2026.6.33", + currentVersion: "2026.6.33", + }); + const execFileSyncImpl = vi.fn((command: string, args: string[]) => { + if (args[2] === "create") { + events.push("create"); + } + return docker(command, args); + }); + const beforeMutation = vi.fn(() => events.push("verify")); + + promoteDockerChannel( + { version: "2026.6.33", images }, + { + beforeMutation, + execFileSyncImpl, + verifyAttestationsImpl: skipAttestationVerification, + }, + ); + + expect(beforeMutation).toHaveBeenCalledTimes(6); + expect(events).toEqual(Array.from({ length: 6 }, () => ["verify", "create"]).flat()); + }); + it("fails without mutating when any version-specific source is missing", () => { const calls: string[][] = []; const execFileSyncImpl = vi.fn((_command: string, args: string[]) => { @@ -669,7 +702,11 @@ describe("Docker channel promotion", () => { "cancel-in-progress": false, queue: "max", }); - expect(verifyAttestations.permissions).toEqual({ contents: "read", packages: "write" }); + expect(verifyAttestations.permissions).toEqual({ + actions: "read", + contents: "read", + packages: "write", + }); const manifestTagStep = createManifest.steps?.find( (step) => step.name === "Resolve manifest tags", @@ -693,7 +730,7 @@ describe("Docker channel promotion", () => { "${{ needs.resolve_release_policy.outputs.channel != 'beta' }}", ); expect(releaseSteps[releasePromotionIndex]?.run).toContain( - "node scripts/docker-channel-promote.mjs", + "node .release-tooling/scripts/docker-channel-promote.mjs", ); expect(releaseSteps[releasePromotionIndex]?.run).not.toContain("--allow-rollback"); expect( @@ -710,7 +747,11 @@ describe("Docker channel promotion", () => { expect(approve.environment).toBe("docker-release"); expect(approve.permissions).toEqual({}); expect(promote.needs).toEqual(["resolve", "approve"]); - expect(promote.permissions).toEqual({ contents: "read", packages: "write" }); + expect(promote.permissions).toEqual({ + actions: "read", + contents: "read", + packages: "write", + }); expect(promote.concurrency).toEqual({ group: "docker-release-publish", "cancel-in-progress": false, @@ -728,6 +769,7 @@ describe("Docker channel promotion", () => { expect(promotionIndex).toBeGreaterThan(-1); expect(steps[promotionIndex]?.run).toContain("node scripts/docker-channel-promote.mjs"); expect(steps[promotionIndex]?.run).toContain("--allow-rollback"); + expect(steps[promotionIndex]?.env?.GH_TOKEN).toBe("${{ github.token }}"); const packageWriters = Object.entries(workflow.jobs ?? {}).filter( ([, job]) => job.permissions?.packages === "write", @@ -735,4 +777,71 @@ describe("Docker channel promotion", () => { expect(packageWriters.map(([name]) => name)).toEqual(["promote"]); expect(packageWriters[0]?.[1].needs).toContain("approve"); }); + + it("binds every Docker registry mutation to live trusted tooling", () => { + const workflow = readWorkflow(".github/workflows/docker-release.yml"); + expect(workflow.on?.workflow_call?.inputs).toMatchObject({ + release_publish_run_attempt: { required: false, type: "string" }, + release_publish_run_id: { required: false, type: "string" }, + trusted_workflow_allow_prevalidated_ref: { required: true, type: "boolean" }, + trusted_workflow_full_ref: { required: true, type: "string" }, + trusted_workflow_ref: { required: true, type: "string" }, + trusted_workflow_sha: { required: true, type: "string" }, + }); + expect(workflow.env).toMatchObject({ + RELEASE_PUBLISH_RUN_ATTEMPT: "${{ inputs.release_publish_run_attempt }}", + RELEASE_PUBLISH_RUN_ID: "${{ inputs.release_publish_run_id }}", + RELEASE_TOOLING_ALLOW_PREVALIDATED_REF: + "${{ inputs.trusted_workflow_allow_prevalidated_ref }}", + RELEASE_TOOLING_FULL_REF: "${{ inputs.trusted_workflow_full_ref }}", + RELEASE_TOOLING_REF: "${{ inputs.trusted_workflow_ref }}", + RELEASE_TOOLING_SHA: "${{ inputs.trusted_workflow_sha }}", + }); + + for (const jobName of ["build-amd64", "build-arm64"]) { + const steps = requireJob(workflow, jobName).steps ?? []; + const trustedCheckout = steps.find( + (step) => step.name === "Checkout trusted release tooling", + ); + expect(trustedCheckout?.with).toMatchObject({ + path: ".release-tooling", + ref: "${{ inputs.trusted_workflow_sha }}", + }); + for (const writeStepName of [ + `Build and push ${jobName === "build-amd64" ? "amd64" : "arm64"} image`, + `Build and push ${jobName === "build-amd64" ? "amd64" : "arm64"} browser image`, + ]) { + const writeIndex = steps.findIndex((step) => step.name === writeStepName); + expect(writeIndex).toBeGreaterThan(0); + expect(steps[writeIndex - 1]?.run).toBe( + "node .release-tooling/scripts/release-tooling-identity.mjs verify-env", + ); + } + } + + const manifest = requireStep( + requireJob(workflow, "create-manifest"), + "Create and push manifest", + ); + expect(manifest.env?.GH_TOKEN).toBe("${{ github.token }}"); + expect(manifest.run).toMatch( + /node \.release-tooling\/scripts\/release-tooling-identity\.mjs verify-env\n\s+docker buildx imagetools create "\$\{args\[@\]\}"/u, + ); + + const attestations = requireStep( + requireJob(workflow, "verify-attestations"), + "Verify Docker attestations", + ); + expect(attestations.run).toContain( + "node .release-tooling/scripts/verify-docker-attestations.mjs", + ); + expect(attestations.run).not.toMatch(/(?:^|\n)\s*node scripts\/verify-docker-attestations/u); + + const promotion = requireStep( + requireJob(workflow, "verify-attestations"), + "Promote and verify channel aliases", + ); + expect(promotion.env?.GH_TOKEN).toBe("${{ github.token }}"); + expect(promotion.run).toContain("node .release-tooling/scripts/docker-channel-promote.mjs"); + }); }); diff --git a/test/scripts/release-no-push-workflow.test.ts b/test/scripts/release-no-push-workflow.test.ts index 8ea3d7f70d2b..ded95f33d215 100644 --- a/test/scripts/release-no-push-workflow.test.ts +++ b/test/scripts/release-no-push-workflow.test.ts @@ -1043,6 +1043,13 @@ describe("release validation no-push transport", () => { expect(dockerCall.with).toEqual({ tag: "${{ inputs.tag }}", release_sha: "${{ needs.resolve_release_target.outputs.sha }}", + release_publish_run_attempt: "${{ github.run_attempt }}", + release_publish_run_id: "${{ github.run_id }}", + trusted_workflow_allow_prevalidated_ref: + "${{ github.ref_type == 'branch' && github.ref_name != 'main' }}", + trusted_workflow_full_ref: "${{ github.ref }}", + trusted_workflow_ref: "${{ github.ref_name }}", + trusted_workflow_sha: "${{ github.workflow_sha }}", }); expect(dockerCall.secrets).toEqual({ DOCKERHUB_USERNAME: "${{ secrets.DOCKERHUB_USERNAME }}", diff --git a/test/scripts/release-tooling-identity.test.ts b/test/scripts/release-tooling-identity.test.ts index b68cd0800ecf..9f7be959a9ca 100644 --- a/test/scripts/release-tooling-identity.test.ts +++ b/test/scripts/release-tooling-identity.test.ts @@ -3,6 +3,7 @@ import { resolveReleaseToolingIdentity, validateReleaseToolingIdentity, verifyReleaseToolingIdentity, + verifyReleaseToolingIdentityFromEnvironment, } from "../../scripts/release-tooling-identity.mjs"; const SHA = "a".repeat(40); @@ -26,6 +27,58 @@ function protectedIdentity( } describe("release tooling identity", () => { + it("verifies the complete mutation-boundary identity from environment state", () => { + const runGh = vi.fn((args: string[]) => { + if (args[1]?.includes("/compare/")) { + return JSON.stringify({ status: "identical" }); + } + if (args[1]?.includes("/actions/runs/")) { + return JSON.stringify({ + event: "workflow_dispatch", + head_branch: "main", + head_sha: SHA, + id: Number(PARENT_RUN_ID), + path: ".github/workflows/openclaw-release-publish.yml@refs/heads/main", + repository: { full_name: "openclaw/openclaw" }, + run_attempt: Number(PARENT_RUN_ATTEMPT), + }); + } + throw new Error(`Unexpected gh call: ${args.join(" ")}`); + }); + + expect( + verifyReleaseToolingIdentityFromEnvironment( + { + GITHUB_REPOSITORY: "openclaw/openclaw", + RELEASE_PUBLISH_RUN_ATTEMPT: PARENT_RUN_ATTEMPT, + RELEASE_PUBLISH_RUN_ID: PARENT_RUN_ID, + RELEASE_TOOLING_ALLOW_PREVALIDATED_REF: "false", + RELEASE_TOOLING_FULL_REF: "refs/heads/main", + RELEASE_TOOLING_REF: "main", + RELEASE_TOOLING_SHA: SHA, + }, + { runGh }, + ), + ).toEqual({ + fullRef: "refs/heads/main", + ref: "main", + route: "main", + sha: SHA, + }); + }); + + it("rejects ambiguous mutation-boundary prevalidated-ref policy", () => { + expect(() => + verifyReleaseToolingIdentityFromEnvironment({ + GITHUB_REPOSITORY: "openclaw/openclaw", + RELEASE_TOOLING_ALLOW_PREVALIDATED_REF: "yes", + RELEASE_TOOLING_FULL_REF: "refs/heads/main", + RELEASE_TOOLING_REF: "main", + RELEASE_TOOLING_SHA: SHA, + }), + ).toThrow("RELEASE_TOOLING_ALLOW_PREVALIDATED_REF must be true or false"); + }); + it.each([ ["1", "main", "refs/heads/main"], ["2", "release/2026.8.1", "refs/heads/release/2026.8.1"],