Files
openclaw/scripts/docker-channel-promote.mjs
Dallin Romney 74cc99f2d1 fix(release): publish extended-stable Docker channels safely (#112494)
* fix(release): isolate extended-stable Docker aliases

* fix(release): harden Docker channel promotion

* docs(release): pin Docker policy into tagged tree

* refactor(release): isolate Docker channel promotion

* fix(release): queue Docker publications

* fix(release): harden docker channel promotion

* docs(release): tighten extended-stable guidance

* fix(release): promote Docker aliases after verification

* chore(release): format maintainer instructions

* refactor(release): separate release version policy

* docs(release): clarify extended-stable gateway scope

* fix(release): harden Docker channel promotion
2026-07-23 10:03:11 +09:00

289 lines
9.3 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync } from "node:child_process";
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 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.");
}
const policy = resolveDockerReleasePolicy(version);
const promotions = [];
for (const image of images) {
for (const { aliasKey, suffix } of VARIANTS) {
const aliases = policy.movingAliases[aliasKey];
if (aliases.length === 0) {
continue;
}
promotions.push({
image,
sourceRef: `${image}:${version}${suffix}`,
targetRefs: aliases.map((alias) => `${image}:${alias}`),
});
}
}
if (promotions.length === 0) {
throw new Error(`Docker ${policy.channel} releases have no moving aliases to promote.`);
}
return { channel: policy.channel, promotions, version: policy.version };
}
function runDocker(args, execFileSyncImpl) {
return execFileSyncImpl("docker", args, {
encoding: "utf8",
killSignal: "SIGKILL",
maxBuffer: 20 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
timeout: DOCKER_TIMEOUT_MS,
});
}
function inspectManifestDigest(imageRef, execFileSyncImpl) {
const raw = runDocker(
["buildx", "imagetools", "inspect", imageRef, "--format", "{{json .Manifest}}"],
execFileSyncImpl,
);
let digest;
try {
digest = JSON.parse(raw).digest;
} catch (error) {
throw new Error(`Could not parse the manifest for ${imageRef}.`, { cause: error });
}
if (typeof digest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(digest)) {
throw new Error(`The manifest for ${imageRef} did not contain a valid sha256 digest.`);
}
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 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) => {
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]);
runDocker(
[
"buildx",
"imagetools",
"create",
"--prefer-index=false",
...targetArgs,
promotion.sourceDigestRef,
],
execFileSyncImpl,
);
for (const targetRef of promotion.targetRefs) {
const targetDigest = inspectManifestDigest(targetRef, execFileSyncImpl);
if (targetDigest !== promotion.sourceDigest) {
throw new Error(
`${targetRef} resolved to ${targetDigest}, expected ${promotion.sourceDigest}.`,
);
}
log(`Verified ${targetRef} -> ${promotion.sourceDigest}.`);
}
}
return plan;
}
function printHelp() {
console.log(
"Usage: node scripts/docker-channel-promote.mjs --version YYYY.M.P --image REGISTRY/IMAGE [--image REGISTRY/IMAGE] [--allow-rollback]",
);
}
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" },
},
strict: true,
});
if (values.help) {
printHelp();
return;
}
const version = values.version?.trim();
if (!version) {
throw new Error("--version is required.");
}
const images = (values.image ?? []).map((image) => image.trim());
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 },
{ allowRollback: values["allow-rollback"] },
);
console.log(`Promoted Docker ${plan.channel} aliases for ${plan.version}.`);
}
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
try {
main();
} catch (error) {
console.error(
`docker-channel-promote: ${error instanceof Error ? error.message : String(error)}`,
);
process.exitCode = 1;
}
}