From f39fdcd4aee2c03b924c725c65d4d2641ca8b093 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 20 Aug 2026 21:20:01 -0700 Subject: [PATCH] fix(release): bind VCR writers to trusted tooling --- .../workflows/openclaw-release-publish.yml | 7 ++ .../vercel-container-registry-publish.yml | 40 ++++++++- .../vercel-container-registry-publish.d.mts | 2 + scripts/vercel-container-registry-publish.mjs | 38 +++++--- .../vercel-container-registry-publish.test.ts | 87 ++++++++++++++++++- 5 files changed, 160 insertions(+), 14 deletions(-) diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml index dc44c55e9db9..87084f67c4ae 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -2577,11 +2577,18 @@ jobs: uses: ./.github/workflows/vercel-container-registry-publish.yml with: include_browser: ${{ needs.publish_docker.outputs.include_browser == 'true' }} + release_publish_run_attempt: ${{ github.run_attempt }} + release_publish_run_id: ${{ github.run_id }} source_refs: ${{ needs.publish_docker.outputs.vcr_source_refs }} + 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 }} version: ${{ needs.publish_docker.outputs.version }} secrets: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} permissions: + actions: read contents: read finalize_github_release: diff --git a/.github/workflows/vercel-container-registry-publish.yml b/.github/workflows/vercel-container-registry-publish.yml index 144ed113711f..cb0e5d46007c 100644 --- a/.github/workflows/vercel-container-registry-publish.yml +++ b/.github/workflows/vercel-container-registry-publish.yml @@ -15,6 +15,30 @@ on: description: Whether the tagged Docker release includes browser images required: true type: boolean + 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 + required: true + type: string + release_publish_run_attempt: + description: Exact parent OpenClaw Release Publish run attempt + required: true + type: string secrets: VERCEL_TOKEN: required: true @@ -22,6 +46,12 @@ on: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" NODE_VERSION: "24.15.0" + 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 }} concurrency: # Alias promotion is read-then-write. Serialize VCR independently so a slow @@ -37,14 +67,20 @@ jobs: # Docker publication and GitHub release finalization must continue. continue-on-error: true permissions: + actions: read contents: read steps: - name: Checkout trusted registry tooling uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: ${{ github.sha }} + ref: ${{ inputs.trusted_workflow_sha }} persist-credentials: false + - name: Validate trusted release tooling + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/release-tooling-identity.mjs verify-env + - name: Set up Docker Builder uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 @@ -100,6 +136,7 @@ jobs: - name: Copy and verify immutable release images id: copy_images env: + GH_TOKEN: ${{ github.token }} INCLUDE_BROWSER: ${{ inputs.include_browser }} SOURCE_REFS: ${{ inputs.source_refs }} TARGET_IMAGE: ${{ vars.VERCEL_VCR_IMAGE }} @@ -180,6 +217,7 @@ jobs: - name: Promote and verify channel aliases id: promote_aliases env: + GH_TOKEN: ${{ github.token }} INCLUDE_BROWSER: ${{ inputs.include_browser }} TARGET_IMAGE: ${{ vars.VERCEL_VCR_IMAGE }} VERSION: ${{ inputs.version }} diff --git a/scripts/vercel-container-registry-publish.d.mts b/scripts/vercel-container-registry-publish.d.mts index 976a1cfc4c3b..060827be7485 100644 --- a/scripts/vercel-container-registry-publish.d.mts +++ b/scripts/vercel-container-registry-publish.d.mts @@ -22,6 +22,7 @@ export function publishVercelContainerRegistryImages( targetImage: string; }, options?: { + beforeMutation?: () => void; execFileSyncImpl?: (command: string, args: string[], options: object) => unknown; log?: (message: string) => void; }, @@ -34,6 +35,7 @@ export function promoteVercelContainerRegistryAliases( targetImage: string; }, options?: { + beforeMutation?: () => void; execFileSyncImpl?: (command: string, args: string[], options: object) => unknown; log?: (message: string) => void; }, diff --git a/scripts/vercel-container-registry-publish.mjs b/scripts/vercel-container-registry-publish.mjs index 8104c8798f57..3a7198e23c91 100644 --- a/scripts/vercel-container-registry-publish.mjs +++ b/scripts/vercel-container-registry-publish.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"; const IMAGETOOLS_TIMEOUT_MS = 20 * 60_000; const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; @@ -277,6 +278,7 @@ function verifyCleanIndex(imageRef, expectedDigests, execFileSyncImpl) { /** Publish every immutable release tag with byte-identical platform manifests. */ export function publishVercelContainerRegistryImages(params, options = {}) { + const beforeMutation = options.beforeMutation ?? (() => {}); const execFileSyncImpl = options.execFileSyncImpl ?? execFileSync; const log = options.log ?? console.log; const immutableSources = parseImmutableSourceRefs(params.sourceRefs, params.includeBrowser); @@ -305,11 +307,14 @@ export function publishVercelContainerRegistryImages(params, options = {}) { return { manifestTag, platformDigests }; }); + // A prevalidated tooling branch can move during a multi-variant copy. Recheck + // the exact approved identity at each registry write, not once per step. for (const { manifestTag, platformDigests } of variants) { const manifestTargetRef = `${plan.targetImage}:${manifestTag}`; const platformSourceRefs = ARCHITECTURES.map( (architecture) => `${plan.sourceImage}@${platformDigests[architecture]}`, ); + beforeMutation(); runImagetools( ["create", "--progress", "plain", "--tag", manifestTargetRef, ...platformSourceRefs], execFileSyncImpl, @@ -326,6 +331,7 @@ export function publishVercelContainerRegistryImages(params, options = {}) { for (const architecture of ARCHITECTURES) { const targetRef = `${plan.targetImage}:${manifestTag}-${architecture}`; const sourceDigest = platformDigests[architecture]; + beforeMutation(); runImagetools( [ "create", @@ -357,6 +363,7 @@ export function publishVercelContainerRegistryImages(params, options = {}) { /** Promote moving aliases only after Sandbox proves the immutable image is ready. */ export function promoteVercelContainerRegistryAliases(params, options = {}) { + const beforeMutation = options.beforeMutation ?? (() => {}); const execFileSyncImpl = options.execFileSyncImpl ?? execFileSync; const log = options.log ?? console.log; const policy = resolveDockerReleasePolicy(params.version); @@ -417,9 +424,12 @@ export function promoteVercelContainerRegistryAliases(params, options = {}) { } } + // Alias promotion runs after the potentially long Sandbox smoke. Recheck at + // each write so elapsed time cannot turn stale tooling into a live publisher. for (const { manifestDigest, manifestTag, targetRefs } of promotions) { const targetArgs = targetRefs.flatMap((targetRef) => ["--tag", targetRef]); const sourceDigestRef = `${targetImage}@${manifestDigest}`; + beforeMutation(); runImagetools( ["create", "--prefer-index=false", ...targetArgs, sourceDigestRef], execFileSyncImpl, @@ -475,23 +485,29 @@ function main() { if (values["source-ref"]) { throw new Error("--promote-aliases cannot be combined with --source-ref."); } - const result = promoteVercelContainerRegistryAliases({ - includeBrowser: values["include-browser"] ?? false, - targetImage: values["target-image"], - version: values.version, - }); + const result = promoteVercelContainerRegistryAliases( + { + includeBrowser: values["include-browser"] ?? false, + targetImage: values["target-image"], + version: values.version, + }, + { beforeMutation: verifyReleaseToolingIdentityFromEnvironment }, + ); console.log(`Promoted ${result.channel} aliases for ${result.targetImage}:${result.version}.`); return; } if (!values["source-ref"]) { throw new Error("--source-ref is required when publishing immutable images."); } - const plan = publishVercelContainerRegistryImages({ - includeBrowser: values["include-browser"] ?? false, - sourceRefs: values["source-ref"], - targetImage: values["target-image"], - version: values.version, - }); + const plan = publishVercelContainerRegistryImages( + { + includeBrowser: values["include-browser"] ?? false, + sourceRefs: values["source-ref"], + targetImage: values["target-image"], + version: values.version, + }, + { beforeMutation: verifyReleaseToolingIdentityFromEnvironment }, + ); console.log( `Published ${plan.copies.length} immutable ${plan.channel} tags to ${plan.targetImage}.`, ); diff --git a/test/scripts/vercel-container-registry-publish.test.ts b/test/scripts/vercel-container-registry-publish.test.ts index 135a0f930f96..da2a32c9e57e 100644 --- a/test/scripts/vercel-container-registry-publish.test.ts +++ b/test/scripts/vercel-container-registry-publish.test.ts @@ -50,6 +50,7 @@ type WorkflowJob = { type Workflow = { concurrency?: { group?: string; "cancel-in-progress"?: boolean; queue?: string }; + env?: Record; jobs?: Record; on?: { workflow_call?: { @@ -59,8 +60,8 @@ type Workflow = { }; }; -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 { @@ -270,6 +271,27 @@ describe("Vercel Container Registry publishing", () => { ]); }); + it("revalidates trusted tooling before every immutable VCR write", () => { + const events: string[] = []; + const docker = successfulExecutor([]); + 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")); + + publishVercelContainerRegistryImages(publishParams("2026.7.2", true), { + beforeMutation, + execFileSyncImpl, + log: () => {}, + }); + + expect(beforeMutation).toHaveBeenCalledTimes(9); + expect(events).toEqual(Array.from({ length: 9 }, () => ["verify", "create"]).flat()); + }); + it("fails before writing when an immutable source is missing", () => { const calls: string[][] = []; const execFileSyncImpl = vi.fn((_command: string, args: string[]) => { @@ -372,6 +394,30 @@ describe("Vercel Container Registry publishing", () => { ]); }); + it("revalidates trusted tooling before every VCR alias promotion", () => { + const events: string[] = []; + const docker = successfulExecutor([]); + 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")); + + promoteVercelContainerRegistryAliases( + { + includeBrowser: true, + targetImage, + version: "2026.7.2", + }, + { beforeMutation, execFileSyncImpl, log: () => {} }, + ); + + expect(beforeMutation).toHaveBeenCalledTimes(3); + expect(events).toEqual(Array.from({ length: 3 }, () => ["verify", "create"]).flat()); + }); + it("refuses to move a VCR channel alias backward", () => { const calls: string[][] = []; const execFileSyncImpl = successfulExecutor(calls, { @@ -420,16 +466,34 @@ describe("Vercel Container Registry publishing", () => { expect(releasePublish.uses).toBe("./.github/workflows/vercel-container-registry-publish.yml"); expect(releasePublish.with).toMatchObject({ include_browser: "${{ needs.publish_docker.outputs.include_browser == 'true' }}", + release_publish_run_attempt: "${{ github.run_attempt }}", + release_publish_run_id: "${{ github.run_id }}", source_refs: "${{ needs.publish_docker.outputs.vcr_source_refs }}", + 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 }}", version: "${{ needs.publish_docker.outputs.version }}", }); expect(releasePublish.secrets).toEqual({ VERCEL_TOKEN: "${{ secrets.VERCEL_TOKEN }}", }); + expect(releasePublish.permissions).toEqual({ actions: "read", contents: "read" }); expect(finalizeRelease.needs).toEqual(["publish", "publish_docker"]); expect(finalizeRelease.if).not.toContain("publish_vcr"); expect(reusablePublish["continue-on-error"]).toBe(true); expect(reusablePublish["timeout-minutes"]).toBe(30); + expect(reusablePublish.permissions).toEqual({ actions: "read", contents: "read" }); + expect(reusable.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 }}", + }); const validateDispatch = manualResolve.steps?.find((step) => step.name?.includes("main-branch dispatch"), @@ -461,6 +525,14 @@ describe("Vercel Container Registry publishing", () => { required: true, type: "string", }); + expect(reusable.on?.workflow_call?.inputs).toMatchObject({ + release_publish_run_attempt: { required: true, type: "string" }, + release_publish_run_id: { required: true, 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(verifyAttestations.outputs?.vcr_source_refs).toBe( "${{ steps.vcr_source_refs.outputs.value }}", ); @@ -485,6 +557,15 @@ describe("Vercel Container Registry publishing", () => { expect(reusablePublish.steps?.find((step) => step.name === "Set up Docker Builder")?.uses).toBe( "docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5", ); + const trustedCheckout = reusablePublish.steps?.find( + (step) => step.name === "Checkout trusted registry tooling", + ); + expect(trustedCheckout?.with?.ref).toBe("${{ inputs.trusted_workflow_sha }}"); + const toolingValidation = reusablePublish.steps?.find( + (step) => step.name === "Validate trusted release tooling", + ); + expect(toolingValidation?.env?.GH_TOKEN).toBe("${{ github.token }}"); + expect(toolingValidation?.run).toBe("node scripts/release-tooling-identity.mjs verify-env"); const materializeVercel = reusablePublish.steps?.find( (step) => step.name === "Materialize locked Vercel CLI", ); @@ -508,6 +589,8 @@ describe("Vercel Container Registry publishing", () => { expect(copyIndex).toBeGreaterThan(-1); expect(smokeIndex).toBeGreaterThan(copyIndex ?? -1); expect(promoteIndex).toBeGreaterThan(smokeIndex ?? -1); + expect(reusablePublish.steps?.[copyIndex ?? -1]?.env?.GH_TOKEN).toBe("${{ github.token }}"); + expect(reusablePublish.steps?.[promoteIndex ?? -1]?.env?.GH_TOKEN).toBe("${{ github.token }}"); const smokeRun = reusablePublish.steps?.[smokeIndex ?? -1]?.run ?? ""; expect(smokeRun).toContain("sandbox run \\\n"); expect(smokeRun).toContain("image_not_ready");