From ecc07ba7a36f38ee6910965c1634bc720ce5e24f Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Thu, 23 Jul 2026 09:23:03 +0900 Subject: [PATCH] fix(release): harden Docker channel promotion --- .github/workflows/docker-channel-promote.yml | 24 +- docs/ci.md | 11 +- scripts/docker-channel-promote.d.mts | 11 + scripts/docker-channel-promote.mjs | 157 +++++++++- scripts/verify-docker-attestations.d.mts | 10 + scripts/verify-docker-attestations.mjs | 71 +++-- test/scripts/docker-channel-promote.test.ts | 301 ++++++++++++++++--- 7 files changed, 473 insertions(+), 112 deletions(-) diff --git a/.github/workflows/docker-channel-promote.yml b/.github/workflows/docker-channel-promote.yml index 8ebf87eddd07..d8670f7143fd 100644 --- a/.github/workflows/docker-channel-promote.yml +++ b/.github/workflows/docker-channel-promote.yml @@ -162,26 +162,6 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Verify immutable source attestations - env: - VERSION: ${{ needs.resolve.outputs.version }} - GHCR_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }} - run: | - set -euo pipefail - source_refs=( - "${GHCR_IMAGE}:${VERSION}" - "${GHCR_IMAGE}:${VERSION}-slim" - "${GHCR_IMAGE}:${VERSION}-browser" - "${DOCKERHUB_IMAGE}:${VERSION}" - "${DOCKERHUB_IMAGE}:${VERSION}-slim" - "${DOCKERHUB_IMAGE}:${VERSION}-browser" - ) - node scripts/verify-docker-attestations.mjs \ - --platform linux/amd64 \ - --platform linux/arm64 \ - "${source_refs[@]}" - - name: Promote and verify channel aliases env: VERSION: ${{ needs.resolve.outputs.version }} @@ -192,9 +172,11 @@ jobs: node scripts/docker-channel-promote.mjs \ --version "${VERSION}" \ --image "${GHCR_IMAGE}" \ - --image "${DOCKERHUB_IMAGE}" + --image "${DOCKERHUB_IMAGE}" \ + --allow-rollback { echo "## Docker channel promotion" echo "- Version: ${VERSION}" echo "- Registries: ${GHCR_IMAGE}, ${DOCKERHUB_IMAGE}" + echo "- Rollback: explicitly approved" } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/ci.md b/docs/ci.md index d42f1769e27b..e1b4921ab51a 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -164,14 +164,15 @@ gh workflow run ci.yml --ref main -f target_ref= -f include_andro gh workflow run full-release-validation.yml --ref main -f ref= ``` -The monthly npm-only extended-stable path is the exception: dispatch both `OpenClaw NPM +The monthly Gateway extended-stable path is the exception: dispatch both `OpenClaw NPM Release` preflight and `Full Release Validation` from the exact `extended-stable/YYYY.M.33` branch, preserve their run IDs, and pass both IDs to the -direct npm publish run. See [Monthly npm-only extended-stable -publication](/reference/RELEASING#monthly-npm-only-extended-stable-publication) for +direct npm publish run. See [Monthly Gateway extended-stable +publication](/reference/RELEASING#monthly-gateway-extended-stable-publication) for the commands, exact identity requirements, registry readback, and selector -repair procedure. This path does not dispatch plugin, macOS, Windows, GitHub -Release, private dist-tag, or other platform publication. +repair procedure. It publishes the `openclaw` npm package, official npm plugins, +and Gateway Docker images. It does not publish the macOS app, Windows Hub, +mobile apps, a GitHub Release, ClawHub packages, or website artifacts. ## Runners diff --git a/scripts/docker-channel-promote.d.mts b/scripts/docker-channel-promote.d.mts index fb355c3286a7..b689d1cccdd8 100644 --- a/scripts/docker-channel-promote.d.mts +++ b/scripts/docker-channel-promote.d.mts @@ -20,7 +20,18 @@ export function createDockerChannelPromotionPlan(params: { export function promoteDockerChannel( params: { version: string; images: string[] }, options?: { + allowRollback?: boolean; execFileSyncImpl?: (command: string, args: string[], options: object) => string; log?: (message: string) => void; + verifyAttestationsImpl?: (params: { + imageRefs: string[]; + requiredPlatforms: Array<{ + architecture: string; + os: string; + variant?: string; + }>; + execFileSyncImpl: (command: string, args: string[], options: object) => string; + log: (message: string) => void; + }) => void; }, ): DockerChannelPromotionPlan; diff --git a/scripts/docker-channel-promote.mjs b/scripts/docker-channel-promote.mjs index 4589f562dea0..89579e255219 100644 --- a/scripts/docker-channel-promote.mjs +++ b/scripts/docker-channel-promote.mjs @@ -5,15 +5,21 @@ import process from "node:process"; 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 { parsePlatform, verifyDockerAttestations } from "./verify-docker-attestations.mjs"; const DOCKER_TIMEOUT_MS = 120_000; +const REQUIRED_PLATFORMS = Object.freeze([ + parsePlatform("linux/amd64"), + parsePlatform("linux/arm64"), +]); const VARIANTS = Object.freeze([ { aliasKey: "default", suffix: "" }, { aliasKey: "slim", suffix: "-slim" }, { aliasKey: "browser", suffix: "-browser" }, ]); -/** Build the immutable-source to moving-alias promotion plan. */ +/** Build the version-specific source to moving-alias promotion plan. */ export function createDockerChannelPromotionPlan({ version, images }) { if (images.length === 0) { throw new Error("At least one --image is required."); @@ -66,18 +72,147 @@ function inspectManifestDigest(imageRef, execFileSyncImpl) { return digest; } +function formatCommandError(error) { + if (!(error instanceof Error)) { + return String(error); + } + const output = [error.message]; + for (const field of ["stderr", "stdout"]) { + const value = error[field]; + if (typeof value === "string") { + output.push(value); + } else if (Buffer.isBuffer(value)) { + output.push(value.toString("utf8")); + } + } + return output.join("\n"); +} + +function isMissingManifestError(error) { + const message = formatCommandError(error); + return /(?:manifest unknown|no such manifest|:\s*not found(?:\s|$))/i.test(message); +} + +function formatPlatform(platform) { + const suffix = platform.variant ? `/${platform.variant}` : ""; + return `${platform.os}/${platform.architecture}${suffix}`; +} + +function inspectImageVersion(imageRef, execFileSyncImpl, { allowMissing = false } = {}) { + const versions = new Map(); + for (const [index, platform] of REQUIRED_PLATFORMS.entries()) { + const platformName = formatPlatform(platform); + let raw; + try { + // In formatted multi-platform inspection, Buildx keys .Image by os/arch. + // Read every promoted platform rather than trusting one config label. + raw = runDocker( + [ + "buildx", + "imagetools", + "inspect", + imageRef, + "--format", + `{{json (index .Image "${platformName}")}}`, + ], + execFileSyncImpl, + ); + } catch (error) { + if (allowMissing && index === 0 && isMissingManifestError(error)) { + return null; + } + throw error; + } + + let version; + try { + version = JSON.parse(raw)?.config?.Labels?.["org.opencontainers.image.version"]; + } catch (error) { + throw new Error(`Could not parse the ${platformName} image config for ${imageRef}.`, { + cause: error, + }); + } + if (typeof version !== "string" || version.trim().length === 0) { + throw new Error( + `${imageRef} does not have an org.opencontainers.image.version label for ${platformName}.`, + ); + } + versions.set(platformName, version.trim()); + } + const uniqueVersions = new Set(versions.values()); + if (uniqueVersions.size !== 1) { + const details = [...versions].map(([platform, version]) => `${platform}=${version}`).join(", "); + throw new Error(`${imageRef} has inconsistent platform versions: ${details}.`); + } + return uniqueVersions.values().next().value; +} + +function verifySourceVersions(resolved, version, execFileSyncImpl) { + for (const promotion of resolved) { + const sourceVersion = inspectImageVersion(promotion.sourceDigestRef, execFileSyncImpl); + if (sourceVersion !== version) { + throw new Error( + `${promotion.sourceDigestRef} reports version ${sourceVersion}, expected ${version}.`, + ); + } + } +} + +function preventChannelRollback(resolved, version, execFileSyncImpl) { + for (const promotion of resolved) { + for (const targetRef of promotion.targetRefs) { + const currentVersion = inspectImageVersion(targetRef, execFileSyncImpl, { + allowMissing: true, + }); + if (currentVersion === null) { + continue; + } + const comparison = compareReleaseVersions(version, currentVersion); + if (comparison === null) { + throw new Error( + `Cannot compare candidate version ${version} with ${targetRef} version ${currentVersion}.`, + ); + } + if (comparison < 0) { + throw new Error( + `Refusing to move ${targetRef} backward from ${currentVersion} to ${version}. ` + + "An approved repair may rerun with --allow-rollback.", + ); + } + } + } +} + /** Promote every planned alias and verify the registry result. */ export function promoteDockerChannel({ version, images }, options = {}) { const execFileSyncImpl = options.execFileSyncImpl ?? execFileSync; const log = options.log ?? console.log; + const verifyAttestationsImpl = options.verifyAttestationsImpl ?? verifyDockerAttestations; const plan = createDockerChannelPromotionPlan({ version, images }); - // Resolve every immutable source before the first alias write. A missing + // Resolve every version-specific source before the first alias write. A missing // release variant must not leave the channel partially promoted. - const resolved = plan.promotions.map((promotion) => ({ - ...promotion, - sourceDigest: inspectManifestDigest(promotion.sourceRef, execFileSyncImpl), - })); + const resolved = plan.promotions.map((promotion) => { + const sourceDigest = inspectManifestDigest(promotion.sourceRef, execFileSyncImpl); + return { + ...promotion, + sourceDigest, + sourceDigestRef: `${promotion.image}@${sourceDigest}`, + }; + }); + + // Attestation checks and writes share these digest refs so a concurrent tag + // rewrite cannot swap the content between verification and promotion. + verifyAttestationsImpl({ + imageRefs: resolved.map((promotion) => promotion.sourceDigestRef), + requiredPlatforms: REQUIRED_PLATFORMS, + execFileSyncImpl, + log, + }); + verifySourceVersions(resolved, plan.version, execFileSyncImpl); + if (!options.allowRollback) { + preventChannelRollback(resolved, plan.version, execFileSyncImpl); + } for (const promotion of resolved) { const targetArgs = promotion.targetRefs.flatMap((targetRef) => ["--tag", targetRef]); @@ -88,7 +223,7 @@ export function promoteDockerChannel({ version, images }, options = {}) { "create", "--prefer-index=false", ...targetArgs, - `${promotion.image}@${promotion.sourceDigest}`, + promotion.sourceDigestRef, ], execFileSyncImpl, ); @@ -107,7 +242,7 @@ export function promoteDockerChannel({ version, images }, options = {}) { function printHelp() { console.log( - "Usage: node scripts/docker-channel-promote.mjs --version YYYY.M.P --image REGISTRY/IMAGE [--image REGISTRY/IMAGE]", + "Usage: node scripts/docker-channel-promote.mjs --version YYYY.M.P --image REGISTRY/IMAGE [--image REGISTRY/IMAGE] [--allow-rollback]", ); } @@ -115,6 +250,7 @@ function main() { const { values } = parseArgs({ args: process.argv.slice(2), options: { + "allow-rollback": { type: "boolean" }, help: { type: "boolean", short: "h" }, image: { type: "string", multiple: true }, version: { type: "string" }, @@ -133,7 +269,10 @@ function main() { if (images.length === 0 || images.some((image) => image.length === 0)) { throw new Error("At least one non-empty --image is required."); } - const plan = promoteDockerChannel({ version, images }); + const plan = promoteDockerChannel( + { version, images }, + { allowRollback: values["allow-rollback"] }, + ); console.log(`Promoted Docker ${plan.channel} aliases for ${plan.version}.`); } diff --git a/scripts/verify-docker-attestations.d.mts b/scripts/verify-docker-attestations.d.mts index 540c20568345..8c248d89ea00 100644 --- a/scripts/verify-docker-attestations.d.mts +++ b/scripts/verify-docker-attestations.d.mts @@ -15,6 +15,16 @@ export function parsePlatform(value: unknown): { * Collects missing/mismatched attestation errors for required image platforms. */ export function collectDockerAttestationErrors(params: unknown): string[]; +export function verifyDockerAttestations(params: { + imageRefs: string[]; + requiredPlatforms: Array<{ + architecture: string; + os: string; + variant?: string; + }>; + execFileSyncImpl?: (command: string, args: string[], options: unknown) => string; + log?: (message: string) => void; +}): void; export function inspectRaw( imageRef: unknown, params?: { diff --git a/scripts/verify-docker-attestations.mjs b/scripts/verify-docker-attestations.mjs index 32f48fc966ad..145b81be40b5 100644 --- a/scripts/verify-docker-attestations.mjs +++ b/scripts/verify-docker-attestations.mjs @@ -40,6 +40,43 @@ function formatPlatform(platform) { : `${platform.os}/${platform.architecture}`; } +/** Verify required Docker attestations for every image reference. */ +export function verifyDockerAttestations(params) { + const { + imageRefs, + requiredPlatforms, + execFileSyncImpl = execFileSync, + log = console.log, + } = params; + const allErrors = []; + for (const imageRef of imageRefs) { + const index = parseJson(inspectRaw(imageRef, { execFileSyncImpl }), `${imageRef} index`); + const errors = collectDockerAttestationErrors({ + imageRef, + index, + requiredPlatforms, + inspectAttestation(digest) { + return parseJson( + inspectRaw(imageRefForDigest(imageRef, digest), { execFileSyncImpl }), + `${imageRef} attestation ${digest}`, + ); + }, + }); + if (errors.length === 0) { + log( + `Verified Docker attestations for ${imageRef}: ${requiredPlatforms + .map(formatPlatform) + .join(", ")}`, + ); + } + allErrors.push(...errors); + } + + if (allErrors.length > 0) { + throw new Error(allErrors.map((error) => `[docker-attestations] ${error}`).join("\n")); + } +} + function platformMatches(actual, expected) { return ( actual?.os === expected.os && @@ -184,36 +221,10 @@ async function main() { throw new Error("At least one --platform is required."); } - const allErrors = []; - for (const imageRef of parsed.imageRefs) { - const index = parseJson(inspectRaw(imageRef), `${imageRef} index`); - const errors = collectDockerAttestationErrors({ - imageRef, - index, - requiredPlatforms: parsed.requiredPlatforms, - inspectAttestation(digest) { - return parseJson( - inspectRaw(imageRefForDigest(imageRef, digest)), - `${imageRef} attestation ${digest}`, - ); - }, - }); - if (errors.length === 0) { - console.log( - `Verified Docker attestations for ${imageRef}: ${parsed.requiredPlatforms - .map(formatPlatform) - .join(", ")}`, - ); - } - allErrors.push(...errors); - } - - if (allErrors.length > 0) { - for (const error of allErrors) { - console.error(`[docker-attestations] ${error}`); - } - process.exit(1); - } + verifyDockerAttestations({ + imageRefs: parsed.imageRefs, + requiredPlatforms: parsed.requiredPlatforms, + }); } if (import.meta.url === `file://${process.argv[1]}`) { diff --git a/test/scripts/docker-channel-promote.test.ts b/test/scripts/docker-channel-promote.test.ts index 606cb3528288..64bb1356e2ab 100644 --- a/test/scripts/docker-channel-promote.test.ts +++ b/test/scripts/docker-channel-promote.test.ts @@ -8,6 +8,42 @@ import { const images = ["ghcr.io/openclaw/openclaw", "docker.io/openclaw/openclaw"]; const digest = `sha256:${"1".repeat(64)}`; +const changedDigest = `sha256:${"2".repeat(64)}`; + +function imageConfig(version: string): string { + return JSON.stringify({ + config: { Labels: { "org.opencontainers.image.version": version } }, + }); +} + +function createDockerMock(params: { + candidateVersion: string; + currentVersion?: string; + wrongTargetDigest?: string; +}) { + const targetDigests = new Map(); + return vi.fn((_command: string, args: string[]) => { + if (args[2] === "inspect") { + const ref = args[3]!; + if (args.at(-1)?.includes(".Image")) { + return imageConfig(ref.includes("@") ? params.candidateVersion : params.currentVersion!); + } + if (params.wrongTargetDigest && ref.includes(":extended-stable")) { + return JSON.stringify({ digest: params.wrongTargetDigest }); + } + return JSON.stringify({ digest: targetDigests.get(ref) ?? digest }); + } + const sourceDigest = args.at(-1)!.split("@")[1]!; + for (let index = 0; index < args.length; index += 1) { + if (args[index] === "--tag") { + targetDigests.set(args[index + 1]!, sourceDigest); + } + } + return ""; + }); +} + +const skipAttestationVerification = () => {}; type WorkflowStep = { env?: Record; @@ -70,27 +106,41 @@ describe("Docker channel promotion", () => { it("preflights every source before moving and verifying aliases", () => { const calls: string[][] = []; - const targetDigests = new Map(); - const execFileSyncImpl = vi.fn((_command: string, args: string[]) => { - calls.push(args); - if (args[2] === "inspect") { - return JSON.stringify({ digest: targetDigests.get(args[3]!) ?? digest }); - } - const sourceDigest = args.at(-1)!.split("@")[1]!; - for (let index = 0; index < args.length; index += 1) { - if (args[index] === "--tag") { - targetDigests.set(args[index + 1]!, sourceDigest); - } - } - return ""; + const docker = createDockerMock({ + candidateVersion: "2026.6.33", + currentVersion: "2026.6.33", }); + const execFileSyncImpl = vi.fn((command: string, args: string[]) => { + calls.push(args); + return docker(command, args); + }); + const verifyAttestationsImpl = vi.fn(); - promoteDockerChannel({ version: "2026.6.33", images }, { execFileSyncImpl }); + promoteDockerChannel( + { version: "2026.6.33", images }, + { execFileSyncImpl, verifyAttestationsImpl }, + ); const firstCreate = calls.findIndex((args) => args[2] === "create"); - expect(firstCreate).toBe(6); + expect(firstCreate).toBe(30); expect(calls.slice(0, firstCreate).every((args) => args[2] === "inspect")).toBe(true); expect(calls.filter((args) => args[2] === "create")).toHaveLength(6); + expect(verifyAttestationsImpl).toHaveBeenCalledWith( + expect.objectContaining({ + imageRefs: [ + `ghcr.io/openclaw/openclaw@${digest}`, + `ghcr.io/openclaw/openclaw@${digest}`, + `ghcr.io/openclaw/openclaw@${digest}`, + `docker.io/openclaw/openclaw@${digest}`, + `docker.io/openclaw/openclaw@${digest}`, + `docker.io/openclaw/openclaw@${digest}`, + ], + requiredPlatforms: [ + { architecture: "amd64", os: "linux", variant: undefined }, + { architecture: "arm64", os: "linux", variant: undefined }, + ], + }), + ); expect(execFileSyncImpl).toHaveBeenCalledWith( "docker", [ @@ -106,7 +156,7 @@ describe("Docker channel promotion", () => { ); }); - it("fails without mutating when any immutable source is missing", () => { + it("fails without mutating when any version-specific source is missing", () => { const calls: string[][] = []; const execFileSyncImpl = vi.fn((_command: string, args: string[]) => { calls.push(args); @@ -117,23 +167,196 @@ describe("Docker channel promotion", () => { }); expect(() => - promoteDockerChannel({ version: "2026.6.33", images }, { execFileSyncImpl }), + promoteDockerChannel( + { version: "2026.6.33", images }, + { execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification }, + ), ).toThrow("missing manifest"); expect(calls.some((args) => args[2] === "create")).toBe(false); }); - it("fails when a promoted alias does not match its immutable source", () => { - const wrongDigest = `sha256:${"2".repeat(64)}`; - const execFileSyncImpl = vi.fn((_command: string, args: string[]) => { - if (args[2] === "inspect" && args[3]?.endsWith(":extended-stable")) { - return JSON.stringify({ digest: wrongDigest }); - } - return args[2] === "inspect" ? JSON.stringify({ digest }) : ""; + it("fails when a promoted alias does not match its version-specific source", () => { + const execFileSyncImpl = createDockerMock({ + candidateVersion: "2026.6.33", + currentVersion: "2026.6.33", + wrongTargetDigest: changedDigest, }); expect(() => - promoteDockerChannel({ version: "2026.6.33", images }, { execFileSyncImpl }), - ).toThrow(`resolved to ${wrongDigest}, expected ${digest}`); + promoteDockerChannel( + { version: "2026.6.33", images }, + { execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification }, + ), + ).toThrow(`resolved to ${changedDigest}, expected ${digest}`); + }); + + it("refuses automatic channel rollback before writing aliases", () => { + const execFileSyncImpl = createDockerMock({ + candidateVersion: "2026.6.33", + currentVersion: "2026.6.34", + }); + + expect(() => + promoteDockerChannel( + { version: "2026.6.33", images: images.slice(0, 1) }, + { execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification }, + ), + ).toThrow( + "Refusing to move ghcr.io/openclaw/openclaw:extended-stable backward from 2026.6.34 to 2026.6.33", + ); + expect(execFileSyncImpl.mock.calls.some(([, args]) => args[2] === "create")).toBe(false); + }); + + it.each([ + ["same", "2026.6.33", "2026.6.33"], + ["newer", "2026.6.34", "2026.6.33"], + ])("allows an automatic %s-version promotion", (_label, candidateVersion, currentVersion) => { + const execFileSyncImpl = createDockerMock({ candidateVersion, currentVersion }); + + promoteDockerChannel( + { version: candidateVersion, images: images.slice(0, 1) }, + { execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification }, + ); + + expect(execFileSyncImpl.mock.calls.some(([, args]) => args[2] === "create")).toBe(true); + }); + + it("allows an explicitly approved rollback", () => { + const execFileSyncImpl = createDockerMock({ + candidateVersion: "2026.6.33", + currentVersion: "2026.6.34", + }); + + promoteDockerChannel( + { version: "2026.6.33", images: images.slice(0, 1) }, + { + allowRollback: true, + execFileSyncImpl, + verifyAttestationsImpl: skipAttestationVerification, + }, + ); + + expect(execFileSyncImpl.mock.calls.some(([, args]) => args[2] === "create")).toBe(true); + }); + + it("allows a first promotion when the target alias does not exist", () => { + let created = false; + const execFileSyncImpl = vi.fn((_command: string, args: string[]) => { + if (args[2] === "create") { + created = true; + return ""; + } + if (args.at(-1)?.includes(".Image")) { + if (!args[3]!.includes("@") && !created) { + const error = new Error("docker inspect failed"); + Object.assign(error, { stderr: `ERROR: ${args[3]}: not found` }); + throw error; + } + return imageConfig("2026.6.33"); + } + return JSON.stringify({ digest }); + }); + + promoteDockerChannel( + { version: "2026.6.33", images: images.slice(0, 1) }, + { execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification }, + ); + + expect(created).toBe(true); + }); + + it("fails closed when an existing alias cannot be inspected", () => { + const execFileSyncImpl = vi.fn((_command: string, args: string[]) => { + if (args.at(-1)?.includes(".Image") && !args[3]!.includes("@")) { + const error = new Error("unauthorized: authentication required"); + Object.assign(error, { stderr: "denied: requested access to the resource is denied" }); + throw error; + } + if (args.at(-1)?.includes(".Image")) { + return imageConfig("2026.6.33"); + } + return JSON.stringify({ digest }); + }); + + expect(() => + promoteDockerChannel( + { version: "2026.6.33", images: images.slice(0, 1) }, + { execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification }, + ), + ).toThrow("unauthorized"); + expect(execFileSyncImpl.mock.calls.some(([, args]) => args[2] === "create")).toBe(false); + }); + + it("promotes the same digests whose attestations were verified", () => { + let sourceDigest = digest; + const targetDigests = new Map(); + const execFileSyncImpl = vi.fn((_command: string, args: string[]) => { + if (args[2] === "create") { + const promotedDigest = args.at(-1)!.split("@")[1]!; + for (let index = 0; index < args.length; index += 1) { + if (args[index] === "--tag") { + targetDigests.set(args[index + 1]!, promotedDigest); + } + } + return ""; + } + if (args.at(-1)?.includes(".Image")) { + return imageConfig("2026.6.33"); + } + const ref = args[3]!; + return JSON.stringify({ digest: targetDigests.get(ref) ?? sourceDigest }); + }); + const verifiedRefs: string[] = []; + + promoteDockerChannel( + { version: "2026.6.33", images: images.slice(0, 1) }, + { + execFileSyncImpl, + verifyAttestationsImpl({ imageRefs }) { + verifiedRefs.push(...imageRefs); + sourceDigest = changedDigest; + }, + }, + ); + + expect(verifiedRefs).toEqual(Array(3).fill(`ghcr.io/openclaw/openclaw@${digest}`)); + expect( + execFileSyncImpl.mock.calls + .filter(([, args]) => args[2] === "create") + .map(([, args]) => args.at(-1)), + ).toEqual(Array(3).fill(`ghcr.io/openclaw/openclaw@${digest}`)); + }); + + it("rejects a source whose version label does not match the requested release", () => { + const execFileSyncImpl = createDockerMock({ + candidateVersion: "2026.6.34", + currentVersion: "2026.6.33", + }); + + expect(() => + promoteDockerChannel( + { version: "2026.6.33", images: images.slice(0, 1) }, + { execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification }, + ), + ).toThrow(`ghcr.io/openclaw/openclaw@${digest} reports version 2026.6.34, expected 2026.6.33`); + }); + + it("rejects a source whose platform version labels disagree", () => { + const execFileSyncImpl = vi.fn((_command: string, args: string[]) => { + if (args.at(-1)?.includes(".Image")) { + const version = args.at(-1)?.includes("linux/arm64") ? "2026.6.34" : "2026.6.33"; + return imageConfig(version); + } + return JSON.stringify({ digest }); + }); + + expect(() => + promoteDockerChannel( + { version: "2026.6.33", images: images.slice(0, 1) }, + { execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification }, + ), + ).toThrow("inconsistent platform versions: linux/amd64=2026.6.33, linux/arm64=2026.6.34"); + expect(execFileSyncImpl.mock.calls.some(([, args]) => args[2] === "create")).toBe(false); }); it("rejects channels without moving aliases", () => { @@ -142,7 +365,7 @@ describe("Docker channel promotion", () => { ); }); - it("uses the same attestation-gated promotion path for releases and repairs", () => { + it("uses the digest-bound promotion path for releases and approved repairs", () => { const workflow = readWorkflow(".github/workflows/docker-channel-promote.yml"); const releaseWorkflow = readWorkflow(".github/workflows/docker-release.yml"); const createManifest = requireJob(releaseWorkflow, "create-manifest"); @@ -183,6 +406,7 @@ describe("Docker channel promotion", () => { expect(releaseSteps[releasePromotionIndex]?.run).toContain( "node scripts/docker-channel-promote.mjs", ); + expect(releaseSteps[releasePromotionIndex]?.run).not.toContain("--allow-rollback"); expect( Object.values(releaseWorkflow.jobs ?? {}).flatMap((job) => (job.steps ?? []).filter((step) => step.run?.includes("docker-channel-promote.mjs")), @@ -208,30 +432,13 @@ describe("Docker channel promotion", () => { ); const steps = promote.steps ?? []; - const attestationIndex = steps.findIndex( - (step) => step.name === "Verify immutable source attestations", - ); const promotionIndex = steps.findIndex( (step) => step.name === "Promote and verify channel aliases", ); - expect(attestationIndex).toBeGreaterThan(-1); - expect(promotionIndex).toBeGreaterThan(attestationIndex); - - const attestationRun = steps[attestationIndex]?.run ?? ""; - for (const ref of [ - "${GHCR_IMAGE}:${VERSION}", - "${GHCR_IMAGE}:${VERSION}-slim", - "${GHCR_IMAGE}:${VERSION}-browser", - "${DOCKERHUB_IMAGE}:${VERSION}", - "${DOCKERHUB_IMAGE}:${VERSION}-slim", - "${DOCKERHUB_IMAGE}:${VERSION}-browser", - ]) { - expect(attestationRun).toContain(ref); - } - expect(attestationRun).toContain("node scripts/verify-docker-attestations.mjs"); - expect(attestationRun).toContain("--platform linux/amd64"); - expect(attestationRun).toContain("--platform linux/arm64"); + expect(steps.some((step) => step.run?.includes("verify-docker-attestations.mjs"))).toBe(false); + expect(promotionIndex).toBeGreaterThan(-1); expect(steps[promotionIndex]?.run).toContain("node scripts/docker-channel-promote.mjs"); + expect(steps[promotionIndex]?.run).toContain("--allow-rollback"); const packageWriters = Object.entries(workflow.jobs ?? {}).filter( ([, job]) => job.permissions?.packages === "write",