mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 08:21:35 +00:00
* 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
289 lines
9.3 KiB
JavaScript
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;
|
|
}
|
|
}
|