fix(release): harden Docker channel promotion

This commit is contained in:
Dallin Romney
2026-07-23 09:23:03 +09:00
parent 869c5d8b4d
commit ecc07ba7a3
7 changed files with 473 additions and 112 deletions
+3 -21
View File
@@ -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"
+6 -5
View File
@@ -164,14 +164,15 @@ gh workflow run ci.yml --ref main -f target_ref=<branch-or-sha> -f include_andro
gh workflow run full-release-validation.yml --ref main -f ref=<branch-or-sha>
```
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
+11
View File
@@ -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;
+148 -9
View File
@@ -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}.`);
}
+10
View File
@@ -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?: {
+41 -30
View File
@@ -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]}`) {
+254 -47
View File
@@ -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<string, string>();
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<string, string>;
@@ -70,27 +106,41 @@ describe("Docker channel promotion", () => {
it("preflights every source before moving and verifying aliases", () => {
const calls: string[][] = [];
const targetDigests = new Map<string, string>();
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<string, string>();
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",