mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
105038e658
* ci: publish release images to Vercel registry * ci: publish beta images to Vercel registry * fix(ci): allow beta Vercel dispatch * fix(ci): publish VCR-compatible image indexes * fix(ci): allow VCR readiness propagation * fix(ci): use Sandbox as VCR readiness proof * fix(ci): promote clean VCR channel indexes * fix(release): harden VCR publication * fix(release): bind VCR publishing to verified inputs * fix(test): follow script declaration migration * fix(release): isolate VCR mirroring * test(release): align VCR secret ownership
509 lines
18 KiB
JavaScript
509 lines
18 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";
|
|
|
|
const IMAGETOOLS_TIMEOUT_MS = 20 * 60_000;
|
|
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
const IMAGE_INDEX_MEDIA_TYPE = "application/vnd.oci.image.index.v1+json";
|
|
const IMAGE_MANIFEST_MEDIA_TYPES = new Set([
|
|
"application/vnd.docker.distribution.manifest.v2+json",
|
|
"application/vnd.oci.image.manifest.v1+json",
|
|
]);
|
|
const ARCHITECTURES = Object.freeze(["amd64", "arm64"]);
|
|
const VARIANTS = Object.freeze([
|
|
{ aliasKey: "default", suffix: "" },
|
|
{ aliasKey: "slim", suffix: "-slim" },
|
|
{ aliasKey: "browser", suffix: "-browser" },
|
|
]);
|
|
|
|
function resolveVariants(includeBrowser) {
|
|
if (typeof includeBrowser !== "boolean") {
|
|
throw new Error("includeBrowser must be a boolean.");
|
|
}
|
|
return includeBrowser ? VARIANTS : VARIANTS.filter(({ aliasKey }) => aliasKey !== "browser");
|
|
}
|
|
|
|
function requireImageName(value, label) {
|
|
const normalized = value?.trim();
|
|
if (!normalized || /\s|@/.test(normalized) || /:[^/]+$/.test(normalized)) {
|
|
throw new Error(`${label} must be an untagged container image name.`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
/** Build the immutable tag-copy plan for one Docker release. */
|
|
export function createVercelContainerRegistryPublishPlan({
|
|
includeBrowser,
|
|
version,
|
|
sourceImage,
|
|
targetImage,
|
|
}) {
|
|
const policy = resolveDockerReleasePolicy(version);
|
|
const source = requireImageName(sourceImage, "Source image");
|
|
const target = requireImageName(targetImage, "Target image");
|
|
const variants = resolveVariants(includeBrowser);
|
|
const copies = [];
|
|
const readinessTags = [];
|
|
for (const { suffix } of variants) {
|
|
const manifestTag = `${policy.version}${suffix}`;
|
|
readinessTags.push(manifestTag);
|
|
copies.push({
|
|
sourceRef: `${source}:${manifestTag}`,
|
|
targetRef: `${target}:${manifestTag}`,
|
|
targetTag: manifestTag,
|
|
});
|
|
for (const architecture of ARCHITECTURES) {
|
|
const architectureTag = `${manifestTag}-${architecture}`;
|
|
copies.push({
|
|
sourceRef: `${source}:${architectureTag}`,
|
|
targetRef: `${target}:${architectureTag}`,
|
|
targetTag: architectureTag,
|
|
});
|
|
}
|
|
}
|
|
return {
|
|
channel: policy.channel,
|
|
copies,
|
|
readinessTags,
|
|
sourceImage: source,
|
|
targetImage: target,
|
|
version: policy.version,
|
|
};
|
|
}
|
|
|
|
function runImagetools(args, execFileSyncImpl, { inherit = false } = {}) {
|
|
return execFileSyncImpl("docker", ["buildx", "imagetools", ...args], {
|
|
encoding: "utf8",
|
|
killSignal: "SIGKILL",
|
|
maxBuffer: 20 * 1024 * 1024,
|
|
stdio: inherit ? "inherit" : ["ignore", "pipe", "pipe"],
|
|
timeout: IMAGETOOLS_TIMEOUT_MS,
|
|
});
|
|
}
|
|
|
|
function requireDigest(value, imageRef) {
|
|
const digest = String(value ?? "").trim();
|
|
if (!DIGEST_PATTERN.test(digest)) {
|
|
throw new Error(`${imageRef} did not resolve to a valid sha256 digest.`);
|
|
}
|
|
return digest;
|
|
}
|
|
|
|
function parseImmutableSourceRefs(values, includeBrowser) {
|
|
if (!Array.isArray(values)) {
|
|
throw new Error("sourceRefs must be an array of alias=immutable-ref entries.");
|
|
}
|
|
const variants = resolveVariants(includeBrowser);
|
|
const expectedAliases = new Set(variants.map(({ aliasKey }) => aliasKey));
|
|
const byAlias = new Map();
|
|
let sourceImage;
|
|
|
|
for (const value of values) {
|
|
const separator = value.indexOf("=");
|
|
const aliasKey = value.slice(0, separator).trim();
|
|
const immutableRef = value.slice(separator + 1).trim();
|
|
if (separator <= 0 || !expectedAliases.has(aliasKey)) {
|
|
throw new Error(`Unexpected VCR source alias in ${JSON.stringify(value)}.`);
|
|
}
|
|
if (byAlias.has(aliasKey)) {
|
|
throw new Error(`Duplicate VCR source alias: ${aliasKey}.`);
|
|
}
|
|
|
|
const atIndex = immutableRef.lastIndexOf("@");
|
|
const image = requireImageName(immutableRef.slice(0, atIndex), `${aliasKey} source image`);
|
|
const digest = requireDigest(immutableRef.slice(atIndex + 1), immutableRef);
|
|
const normalizedRef = `${image}@${digest}`;
|
|
if (sourceImage && sourceImage !== image) {
|
|
throw new Error(`All immutable VCR source refs must use ${sourceImage}; got ${image}.`);
|
|
}
|
|
sourceImage = image;
|
|
byAlias.set(aliasKey, normalizedRef);
|
|
}
|
|
|
|
for (const aliasKey of expectedAliases) {
|
|
if (!byAlias.has(aliasKey)) {
|
|
throw new Error(`Missing immutable VCR source ref for ${aliasKey}.`);
|
|
}
|
|
}
|
|
if (!sourceImage) {
|
|
throw new Error("At least one immutable VCR source ref is required.");
|
|
}
|
|
return { byAlias, sourceImage };
|
|
}
|
|
|
|
function inspectRawManifest(imageRef, execFileSyncImpl) {
|
|
const raw = String(runImagetools(["inspect", imageRef, "--raw"], execFileSyncImpl));
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
throw new Error(`${imageRef} did not return valid manifest JSON.`);
|
|
}
|
|
}
|
|
|
|
function inspectManifestDescriptor(imageRef, execFileSyncImpl) {
|
|
const raw = String(
|
|
runImagetools(["inspect", imageRef, "--format", "{{json .Manifest}}"], execFileSyncImpl),
|
|
);
|
|
try {
|
|
const descriptor = JSON.parse(raw);
|
|
return {
|
|
digest: requireDigest(descriptor.digest, imageRef),
|
|
mediaType: descriptor.mediaType,
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message.includes("sha256 digest")) {
|
|
throw error;
|
|
}
|
|
throw new Error(`${imageRef} did not return a valid manifest descriptor.`, { cause: error });
|
|
}
|
|
}
|
|
|
|
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) {
|
|
return /(?:manifest unknown|no such manifest|:\s*not found(?:\s|$))/i.test(
|
|
formatCommandError(error),
|
|
);
|
|
}
|
|
|
|
function inspectImageVersion(imageRef, execFileSyncImpl, { allowMissing = false } = {}) {
|
|
const versions = new Map();
|
|
for (const [index, architecture] of ARCHITECTURES.entries()) {
|
|
const platform = `linux/${architecture}`;
|
|
let raw;
|
|
try {
|
|
raw = runImagetools(
|
|
["inspect", imageRef, "--format", `{{json (index .Image "${platform}")}}`],
|
|
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 ${platform} 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 ${platform}.`,
|
|
);
|
|
}
|
|
versions.set(platform, 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 resolvePlatformDigests(imageRef, execFileSyncImpl, architectures) {
|
|
const manifest = inspectRawManifest(imageRef, execFileSyncImpl);
|
|
if (manifest.mediaType !== IMAGE_INDEX_MEDIA_TYPE || !Array.isArray(manifest.manifests)) {
|
|
throw new Error(`${imageRef} must resolve to an OCI image index.`);
|
|
}
|
|
return Object.fromEntries(
|
|
architectures.map((architecture) => {
|
|
const matches = manifest.manifests.filter(
|
|
(entry) =>
|
|
entry?.platform?.os === "linux" &&
|
|
entry.platform.architecture === architecture &&
|
|
IMAGE_MANIFEST_MEDIA_TYPES.has(entry.mediaType),
|
|
);
|
|
if (matches.length !== 1) {
|
|
throw new Error(
|
|
`${imageRef} must contain exactly one linux/${architecture} image manifest; found ${matches.length}.`,
|
|
);
|
|
}
|
|
return [architecture, requireDigest(matches[0].digest, imageRef)];
|
|
}),
|
|
);
|
|
}
|
|
|
|
function verifyCleanIndex(imageRef, expectedDigests, execFileSyncImpl) {
|
|
const manifest = inspectRawManifest(imageRef, execFileSyncImpl);
|
|
if (manifest.mediaType !== IMAGE_INDEX_MEDIA_TYPE || !Array.isArray(manifest.manifests)) {
|
|
throw new Error(`${imageRef} must resolve to a clean OCI image index.`);
|
|
}
|
|
if (manifest.manifests.length !== ARCHITECTURES.length) {
|
|
throw new Error(
|
|
`${imageRef} must contain exactly ${ARCHITECTURES.length} platform manifests; found ${manifest.manifests.length}.`,
|
|
);
|
|
}
|
|
for (const architecture of ARCHITECTURES) {
|
|
const matches = manifest.manifests.filter(
|
|
(entry) => entry?.platform?.os === "linux" && entry.platform.architecture === architecture,
|
|
);
|
|
if (matches.length !== 1) {
|
|
throw new Error(
|
|
`${imageRef} must contain exactly one linux/${architecture} manifest; found ${matches.length}.`,
|
|
);
|
|
}
|
|
const digest = requireDigest(matches[0].digest, imageRef);
|
|
if (digest !== expectedDigests[architecture]) {
|
|
throw new Error(
|
|
`${imageRef} linux/${architecture} resolved to ${digest}, expected ${expectedDigests[architecture]}.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Publish every immutable release tag with byte-identical platform manifests. */
|
|
export function publishVercelContainerRegistryImages(params, options = {}) {
|
|
const execFileSyncImpl = options.execFileSyncImpl ?? execFileSync;
|
|
const log = options.log ?? console.log;
|
|
const immutableSources = parseImmutableSourceRefs(params.sourceRefs, params.includeBrowser);
|
|
const plan = createVercelContainerRegistryPublishPlan({
|
|
includeBrowser: params.includeBrowser,
|
|
sourceImage: immutableSources.sourceImage,
|
|
targetImage: params.targetImage,
|
|
version: params.version,
|
|
});
|
|
const selectedVariants = resolveVariants(params.includeBrowser);
|
|
|
|
// Docker release indexes also contain provenance attestation manifests with
|
|
// unknown/unknown platforms. VCR stores those indexes but does not prepare
|
|
// them for Sandbox, so publish a clean amd64+arm64 index from the exact image
|
|
// manifest digests and keep the architecture tags as carbon-copy manifests.
|
|
// Every source ref was attestation-verified by the caller as an immutable
|
|
// index, so a mutable release-tag rewrite cannot change these copy inputs.
|
|
const variants = selectedVariants.map(({ aliasKey, suffix }) => {
|
|
const manifestTag = `${plan.version}${suffix}`;
|
|
const manifestSourceRef = immutableSources.byAlias.get(aliasKey);
|
|
const platformDigests = resolvePlatformDigests(
|
|
manifestSourceRef,
|
|
execFileSyncImpl,
|
|
ARCHITECTURES,
|
|
);
|
|
return { manifestTag, platformDigests };
|
|
});
|
|
|
|
for (const { manifestTag, platformDigests } of variants) {
|
|
const manifestTargetRef = `${plan.targetImage}:${manifestTag}`;
|
|
const platformSourceRefs = ARCHITECTURES.map(
|
|
(architecture) => `${plan.sourceImage}@${platformDigests[architecture]}`,
|
|
);
|
|
runImagetools(
|
|
["create", "--progress", "plain", "--tag", manifestTargetRef, ...platformSourceRefs],
|
|
execFileSyncImpl,
|
|
{ inherit: true },
|
|
);
|
|
verifyCleanIndex(manifestTargetRef, platformDigests, execFileSyncImpl);
|
|
const manifestDescriptor = inspectManifestDescriptor(manifestTargetRef, execFileSyncImpl);
|
|
if (manifestDescriptor.mediaType !== IMAGE_INDEX_MEDIA_TYPE) {
|
|
throw new Error(
|
|
`${manifestTargetRef} must resolve to an OCI image index, got ${manifestDescriptor.mediaType}.`,
|
|
);
|
|
}
|
|
log(`Verified ${manifestTargetRef} as a clean linux/amd64+linux/arm64 index.`);
|
|
for (const architecture of ARCHITECTURES) {
|
|
const targetRef = `${plan.targetImage}:${manifestTag}-${architecture}`;
|
|
const sourceDigest = platformDigests[architecture];
|
|
runImagetools(
|
|
[
|
|
"create",
|
|
"--progress",
|
|
"plain",
|
|
"--prefer-index=false",
|
|
"--tag",
|
|
targetRef,
|
|
`${plan.sourceImage}@${sourceDigest}`,
|
|
],
|
|
execFileSyncImpl,
|
|
{ inherit: true },
|
|
);
|
|
const descriptor = inspectManifestDescriptor(targetRef, execFileSyncImpl);
|
|
if (!IMAGE_MANIFEST_MEDIA_TYPES.has(descriptor.mediaType)) {
|
|
throw new Error(
|
|
`${targetRef} must resolve to an image manifest, got ${descriptor.mediaType}.`,
|
|
);
|
|
}
|
|
if (descriptor.digest !== sourceDigest) {
|
|
throw new Error(`${targetRef} resolved to ${descriptor.digest}, expected ${sourceDigest}.`);
|
|
}
|
|
log(`Verified ${targetRef} -> ${sourceDigest}.`);
|
|
}
|
|
}
|
|
|
|
return plan;
|
|
}
|
|
|
|
/** Promote moving aliases only after Sandbox proves the immutable image is ready. */
|
|
export function promoteVercelContainerRegistryAliases(params, options = {}) {
|
|
const execFileSyncImpl = options.execFileSyncImpl ?? execFileSync;
|
|
const log = options.log ?? console.log;
|
|
const policy = resolveDockerReleasePolicy(params.version);
|
|
const targetImage = requireImageName(params.targetImage, "Target image");
|
|
const publishedVariants = resolveVariants(params.includeBrowser).map(({ aliasKey, suffix }) => {
|
|
const manifestTag = `${policy.version}${suffix}`;
|
|
const manifestTargetRef = `${targetImage}:${manifestTag}`;
|
|
const platformDigests = resolvePlatformDigests(
|
|
manifestTargetRef,
|
|
execFileSyncImpl,
|
|
ARCHITECTURES,
|
|
);
|
|
verifyCleanIndex(manifestTargetRef, platformDigests, execFileSyncImpl);
|
|
const manifestDescriptor = inspectManifestDescriptor(manifestTargetRef, execFileSyncImpl);
|
|
if (manifestDescriptor.mediaType !== IMAGE_INDEX_MEDIA_TYPE) {
|
|
throw new Error(
|
|
`${manifestTargetRef} must resolve to an OCI image index, got ${manifestDescriptor.mediaType}.`,
|
|
);
|
|
}
|
|
return { aliasKey, manifestDigest: manifestDescriptor.digest, manifestTag };
|
|
});
|
|
|
|
// VCR owns a separate release-wide concurrency group, so these read-then-write
|
|
// alias updates cannot race a newer release without holding the Docker lock.
|
|
const promotions = publishedVariants.flatMap(({ aliasKey, manifestDigest, manifestTag }) => {
|
|
const targetRefs = policy.movingAliases[aliasKey].map((alias) => `${targetImage}:${alias}`);
|
|
return targetRefs.length === 0 ? [] : [{ manifestDigest, manifestTag, targetRefs }];
|
|
});
|
|
|
|
// Preflight every clean source and existing alias before the first moving-tag
|
|
// write so an out-of-order retry cannot partially roll a channel backward.
|
|
for (const { manifestDigest, targetRefs } of promotions) {
|
|
const sourceDigestRef = `${targetImage}@${manifestDigest}`;
|
|
const sourceVersion = inspectImageVersion(sourceDigestRef, execFileSyncImpl);
|
|
if (sourceVersion !== policy.version) {
|
|
throw new Error(
|
|
`${sourceDigestRef} reports version ${sourceVersion}, expected ${policy.version}.`,
|
|
);
|
|
}
|
|
for (const targetRef of targetRefs) {
|
|
const currentVersion = inspectImageVersion(targetRef, execFileSyncImpl, {
|
|
allowMissing: true,
|
|
});
|
|
if (currentVersion === null) {
|
|
continue;
|
|
}
|
|
const comparison = compareReleaseVersions(policy.version, currentVersion);
|
|
if (comparison === null) {
|
|
throw new Error(
|
|
`Cannot compare candidate version ${policy.version} with ${targetRef} version ${currentVersion}.`,
|
|
);
|
|
}
|
|
if (comparison < 0) {
|
|
throw new Error(
|
|
`Refusing to move ${targetRef} backward from ${currentVersion} to ${policy.version}.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const { manifestDigest, manifestTag, targetRefs } of promotions) {
|
|
const targetArgs = targetRefs.flatMap((targetRef) => ["--tag", targetRef]);
|
|
const sourceDigestRef = `${targetImage}@${manifestDigest}`;
|
|
runImagetools(
|
|
["create", "--prefer-index=false", ...targetArgs, sourceDigestRef],
|
|
execFileSyncImpl,
|
|
{ inherit: true },
|
|
);
|
|
for (const targetRef of targetRefs) {
|
|
const descriptor = inspectManifestDescriptor(targetRef, execFileSyncImpl);
|
|
if (descriptor.mediaType !== IMAGE_INDEX_MEDIA_TYPE) {
|
|
throw new Error(`${targetRef} must resolve to an OCI image index.`);
|
|
}
|
|
if (descriptor.digest !== manifestDigest) {
|
|
throw new Error(
|
|
`${targetRef} resolved to ${descriptor.digest}, expected ${manifestDigest}.`,
|
|
);
|
|
}
|
|
log(`Verified ${targetRef} -> ${manifestTag} (${manifestDigest}).`);
|
|
}
|
|
}
|
|
return {
|
|
channel: policy.channel,
|
|
targetImage,
|
|
version: policy.version,
|
|
};
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log(
|
|
"Usage: node scripts/vercel-container-registry-publish.mjs --version YYYY.M.P --target-image REGISTRY/IMAGE [--include-browser] (--source-ref ALIAS=REGISTRY/IMAGE@sha256:DIGEST [...] | --promote-aliases)",
|
|
);
|
|
}
|
|
|
|
function main() {
|
|
const { values } = parseArgs({
|
|
args: process.argv.slice(2),
|
|
options: {
|
|
help: { type: "boolean", short: "h" },
|
|
"include-browser": { type: "boolean" },
|
|
"promote-aliases": { type: "boolean" },
|
|
"source-ref": { type: "string", multiple: true },
|
|
"target-image": { type: "string" },
|
|
version: { type: "string" },
|
|
},
|
|
strict: true,
|
|
});
|
|
if (values.help) {
|
|
printHelp();
|
|
return;
|
|
}
|
|
if (!values.version || !values["target-image"]) {
|
|
throw new Error("--version and --target-image are required.");
|
|
}
|
|
if (values["promote-aliases"]) {
|
|
if (values["source-ref"]) {
|
|
throw new Error("--promote-aliases cannot be combined with --source-ref.");
|
|
}
|
|
const result = promoteVercelContainerRegistryAliases({
|
|
includeBrowser: values["include-browser"] ?? false,
|
|
targetImage: values["target-image"],
|
|
version: values.version,
|
|
});
|
|
console.log(`Promoted ${result.channel} aliases for ${result.targetImage}:${result.version}.`);
|
|
return;
|
|
}
|
|
if (!values["source-ref"]) {
|
|
throw new Error("--source-ref is required when publishing immutable images.");
|
|
}
|
|
const plan = publishVercelContainerRegistryImages({
|
|
includeBrowser: values["include-browser"] ?? false,
|
|
sourceRefs: values["source-ref"],
|
|
targetImage: values["target-image"],
|
|
version: values.version,
|
|
});
|
|
console.log(
|
|
`Published ${plan.copies.length} immutable ${plan.channel} tags to ${plan.targetImage}.`,
|
|
);
|
|
}
|
|
|
|
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(`vercel-container-registry-publish: ${message}`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|