import { readFileSync } from "node:fs"; import { describe, expect, it, vi } from "vitest"; import { parse } from "yaml"; import { createDockerChannelPromotionPlan, promoteDockerChannel, } from "../../scripts/docker-channel-promote.mjs"; 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; if?: string; name?: string; run?: string; uses?: string; with?: Record; }; type WorkflowJob = { concurrency?: { group?: string; "cancel-in-progress"?: boolean; queue?: string }; environment?: string; needs?: string | string[]; permissions?: Record; steps?: WorkflowStep[]; }; type Workflow = { concurrency?: { group?: string; "cancel-in-progress"?: boolean; queue?: string }; jobs?: Record; }; function readWorkflow(path: string): Workflow { return parse(readFileSync(path, "utf8")) as Workflow; } function requireJob(workflow: Workflow, name: string): WorkflowJob { const job = workflow.jobs?.[name]; if (!job) { throw new Error(`Missing workflow job: ${name}`); } return job; } describe("Docker channel promotion", () => { it("plans every extended-stable image variant in both registries", () => { expect(createDockerChannelPromotionPlan({ version: "2026.6.33", images })).toEqual({ channel: "extended-stable", promotions: images.flatMap((image) => [ { image, sourceRef: `${image}:2026.6.33`, targetRefs: [`${image}:extended-stable`], }, { image, sourceRef: `${image}:2026.6.33-slim`, targetRefs: [`${image}:extended-stable-slim`], }, { image, sourceRef: `${image}:2026.6.33-browser`, targetRefs: [`${image}:extended-stable-browser`], }, ]), version: "2026.6.33", }); }); it("preflights every source before moving and verifying aliases", () => { const calls: string[][] = []; 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, verifyAttestationsImpl }, ); const firstCreate = calls.findIndex((args) => args[2] === "create"); 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", [ "buildx", "imagetools", "create", "--prefer-index=false", "--tag", "ghcr.io/openclaw/openclaw:extended-stable", `ghcr.io/openclaw/openclaw@${digest}`, ], expect.objectContaining({ timeout: 120_000 }), ); }); 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); if (calls.length === 3) { throw new Error("missing manifest"); } return JSON.stringify({ digest }); }); expect(() => 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 version-specific source", () => { const execFileSyncImpl = createDockerMock({ candidateVersion: "2026.6.33", currentVersion: "2026.6.33", wrongTargetDigest: changedDigest, }); expect(() => 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("does not expose an attestation bypass", () => { const source = readFileSync("scripts/docker-channel-promote.mjs", "utf8"); expect(source).not.toContain("attestation-policy"); expect(source).not.toContain("attestationPolicy"); }); 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", () => { expect(() => createDockerChannelPromotionPlan({ version: "2026.7.2-beta.3", images })).toThrow( "no moving aliases", ); }); 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"); const verifyAttestations = requireJob(releaseWorkflow, "verify-attestations"); const resolve = requireJob(workflow, "resolve"); const approve = requireJob(workflow, "approve"); const promote = requireJob(workflow, "promote"); expect(releaseWorkflow.concurrency).toEqual({ group: "docker-release-publish", "cancel-in-progress": false, queue: "max", }); expect(verifyAttestations.permissions).toEqual({ contents: "read", packages: "write" }); const manifestTagStep = createManifest.steps?.find( (step) => step.name === "Resolve manifest tags", ); expect(manifestTagStep?.run).not.toContain("alias"); expect(manifestTagStep?.env).not.toHaveProperty("DEFAULT_ALIASES"); const releaseSteps = verifyAttestations.steps ?? []; const resolveRefsStep = releaseSteps.find((step) => step.name === "Resolve image refs"); expect(resolveRefsStep?.run).not.toContain("alias"); expect(resolveRefsStep?.env).not.toHaveProperty("DEFAULT_ALIASES"); const releaseAttestationIndex = releaseSteps.findIndex( (step) => step.name === "Verify Docker attestations", ); const releasePromotionIndex = releaseSteps.findIndex( (step) => step.name === "Promote and verify channel aliases", ); expect(releaseAttestationIndex).toBeGreaterThan(-1); expect(releasePromotionIndex).toBeGreaterThan(releaseAttestationIndex); expect(releaseSteps[releasePromotionIndex]?.if).toBe( "${{ needs.resolve_release_policy.outputs.channel != 'beta' }}", ); 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")), ), ).toHaveLength(1); expect(resolve.permissions).toEqual({ contents: "read" }); expect(resolve.steps?.find((step) => step.uses?.startsWith("actions/checkout@"))?.with).toEqual( expect.objectContaining({ ref: "${{ github.sha }}", "persist-credentials": false }), ); expect(approve.needs).toBe("resolve"); 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.concurrency).toEqual({ group: "docker-release-publish", "cancel-in-progress": false, queue: "max", }); expect(promote.steps?.find((step) => step.uses?.startsWith("actions/checkout@"))?.with).toEqual( expect.objectContaining({ ref: "${{ github.sha }}", "persist-credentials": false }), ); const steps = promote.steps ?? []; const promotionIndex = steps.findIndex( (step) => step.name === "Promote and verify channel aliases", ); 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", ); expect(packageWriters.map(([name]) => name)).toEqual(["promote"]); expect(packageWriters[0]?.[1].needs).toContain("approve"); }); });