#!/usr/bin/env node import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { appendFileSync, createReadStream, lstatSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs"; import path from "node:path"; import { isDeepStrictEqual, parseArgs } from "node:util"; import { promoteDockerChannel } from "./docker-channel-promote.mjs"; import { isDirectRunUrl } from "./lib/direct-run.mjs"; import { resolveDockerReleasePolicy } from "./lib/docker-release-policy.mjs"; import { resolveReleaseTagPackageIdentity } from "./lib/release-version.mjs"; import { collectDockerAttestationErrors, inspectRaw, parsePlatform, verifyDockerAttestations, } from "./verify-docker-attestations.mjs"; const ARCHITECTURES = ["amd64", "arm64"]; const SEAL_JOB_NAME = "Seal prepared Docker images"; const WORKFLOW_PATH = ".github/workflows/docker-release-prepare.yml"; const PRODUCER_WORKFLOWS = new Set([ ".github/workflows/full-release-validation.yml", ".github/workflows/full-release-artifacts.yml", ".github/workflows/openclaw-release-publish.yml", ".github/workflows/docker-image-refresh.yml", WORKFLOW_PATH, ]); const SHA = /^[a-f0-9]{40}$/u; const DIGEST = /^sha256:[a-f0-9]{64}$/u; const POSITIVE_INTEGER = /^[1-9][0-9]*$/u; const INDEX_MEDIA_TYPE = "application/vnd.oci.image.index.v1+json"; /** * Release commands use explicit argv/options and return UTF-8 stdout. * @type {(command: string, args: readonly string[], options: import("node:child_process").ExecFileSyncOptionsWithStringEncoding) => string} */ const execReleaseCommand = execFileSync; function requireValue(condition, message) { if (!condition) { throw new Error(message); } } function sha256(value) { return createHash("sha256").update(value).digest("hex"); } function readJson(file) { requireValue(lstatSync(file).isFile(), `Expected a regular file: ${file}`); return JSON.parse(readFileSync(file, "utf8")); } function writeJson(file, value) { mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); } function run(command, args, options = {}) { return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120_000, maxBuffer: 20 * 1024 * 1024, ...options, }); } function ghJson(endpoint) { return JSON.parse( run("gh", [ "api", endpoint, "--method", "GET", "--jq", endpoint.includes("/compare/") ? "{status}" : ".", ]), ); } export function dockerReleaseArtifactName(sourceSha, runAttempt) { requireValue( SHA.test(sourceSha) && POSITIVE_INTEGER.test(String(runAttempt)), "Invalid Docker artifact identity.", ); return `docker-release-${sourceSha}-${runAttempt}`; } export function preparedDockerEvidenceFromFullRelease({ manifest, sourceSha, runId, runAttempt }) { const prepared = manifest.publicationArtifacts?.docker; // Historical successful FRV manifests did not prepare publication images. // Their recovery route invokes the same preparation owner before publishing. if (prepared === undefined) { return null; } requireValue( manifest.targetSha === sourceSha && String(manifest.runId) === String(runId) && String(manifest.runAttempt) === String(runAttempt), "Docker qualification does not match the selected full release run.", ); // FRV authorizes the release; its recorded producer owns the immutable bytes. // Parent retries retain that producer's run and attempt. requireValue( typeof prepared?.preparedRunId === "string" && POSITIVE_INTEGER.test(prepared.preparedRunId) && typeof prepared.preparedRunAttempt === "string" && POSITIVE_INTEGER.test(prepared.preparedRunAttempt) && prepared.preparedArtifactName === dockerReleaseArtifactName(sourceSha, prepared.preparedRunAttempt) && /^[a-f0-9]{64}$/u.test(prepared.preparedManifestSha256), "Full release Docker qualification tuple is incomplete or stale.", ); return prepared; } export function validateDockerReleaseIdentity({ tag, sourceSha, imageTagSuffix = "", packageVersion, }) { requireValue(SHA.test(sourceSha), "Release SHA must be a full lowercase commit SHA."); requireValue( imageTagSuffix === "" || /^-r[0-9]{8}$/u.test(imageTagSuffix), "Invalid Docker image tag suffix.", ); requireValue( typeof tag === "string" && tag.startsWith("v"), "Docker releases require a v-prefixed tag.", ); const policy = resolveDockerReleasePolicy(tag.slice(1)); const baseTag = packageVersion === undefined ? null : resolveReleaseTagPackageIdentity(tag, packageVersion).baseTag; return { ...policy, baseTag }; } /** Verify the OCI closure before smoke testing or registry writes. */ export async function verifyDockerReleaseLayout({ directory, architecture, sourceSha, version, builtAt, expectedDigest, }) { requireValue(ARCHITECTURES.includes(architecture), "Unsupported Docker release architecture."); requireValue(DIGEST.test(expectedDigest), "Missing BuildKit image digest."); requireValue( readJson(path.join(directory, "oci-layout")).imageLayoutVersion === "1.0.0", "Unsupported OCI layout.", ); const layout = readJson(path.join(directory, "index.json")); requireValue( layout.manifests?.length === 1 && layout.manifests[0].digest === expectedDigest, "OCI layout does not match the BuildKit digest.", ); const verified = new Map(); async function blob(descriptor, json = false) { requireValue( DIGEST.test(descriptor?.digest) && Number.isSafeInteger(descriptor.size) && descriptor.size >= 0, "Invalid OCI descriptor.", ); const file = path.join(directory, "blobs", "sha256", descriptor.digest.slice(7)); const stat = lstatSync(file); requireValue( stat.isFile() && stat.size === descriptor.size, `OCI blob size/type mismatch: ${descriptor.digest}`, ); if (!verified.has(descriptor.digest)) { const hash = createHash("sha256"); for await (const chunk of createReadStream(file)) { hash.update(chunk); } requireValue( `sha256:${hash.digest("hex")}` === descriptor.digest, `OCI blob digest mismatch: ${descriptor.digest}`, ); verified.set(descriptor.digest, descriptor.size); } return json ? readJson(file) : undefined; } const index = await blob(layout.manifests[0], true); requireValue( index.mediaType === INDEX_MEDIA_TYPE && Array.isArray(index.manifests), "Expected an OCI image index with attestations.", ); const manifests = new Map(); for (const descriptor of index.manifests) { const manifest = await blob(descriptor, true); await blob(manifest.config); requireValue(Array.isArray(manifest.layers), "OCI manifest has no layers."); for (const layer of manifest.layers) { await blob(layer); } manifests.set(descriptor.digest, manifest); } const images = index.manifests.filter( (descriptor) => descriptor.platform?.os === "linux" && descriptor.platform?.architecture === architecture, ); requireValue(images.length === 1, `Expected one linux/${architecture} image.`); const errors = collectDockerAttestationErrors({ imageRef: expectedDigest, index, requiredPlatforms: [parsePlatform(`linux/${architecture}`)], inspectAttestation: (digest) => manifests.get(digest), }); requireValue(errors.length === 0, errors.join("\n")); const image = manifests.get(images[0].digest); const config = await blob(image.config, true); const labels = config.config?.Labels; requireValue( config.os === "linux" && config.architecture === architecture && labels?.["org.opencontainers.image.revision"] === sourceSha && labels?.["org.opencontainers.image.version"] === version && labels?.["org.opencontainers.image.created"] === builtAt, "Docker image platform or source/version/build-time labels do not match preparation.", ); return { indexDigest: expectedDigest, imageDigest: images[0].digest, configDigest: image.config.digest, manifests: index.manifests, bytes: [...verified.values()].reduce((sum, size) => sum + size, 0), }; } const WORKSPACE_SMOKE = ` set -eu smoke_home="$(mktemp -d)" smoke_cwd="$(mktemp -d)" trap 'rm -rf "$smoke_home" "$smoke_cwd"' EXIT export HOME="$smoke_home" USERPROFILE="$smoke_home" OPENCLAW_HOME="$smoke_home" export OPENCLAW_NO_ONBOARD=1 OPENCLAW_SUPPRESS_NOTES=1 OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 export OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK=1 AWS_EC2_METADATA_DISABLED=true export AWS_CONFIG_FILE="$smoke_home/aws-config" AWS_SHARED_CREDENTIALS_FILE="$smoke_home/aws-credentials" cd "$smoke_cwd" set +e smoke_output="$(node /app/openclaw.mjs agent --message "workspace bootstrap smoke" --session-id workspace-bootstrap-smoke --local --timeout 1 --json 2>&1)" smoke_status=$? set -e printf '%s\\n' "$smoke_output" if printf '%s\\n' "$smoke_output" | grep -q "Missing workspace template:"; then exit 1; fi if [ "$smoke_status" -ne 0 ]; then echo "Agent exited $smoke_status after workspace bootstrap (provider credentials are intentionally absent)."; fi `; function smokeImage(directory, architecture, variant, configDigest) { const image = `openclaw-release-smoke:${architecture}-${variant}`; // Only the smoke copy enters Docker's single-image store. The OCI artifact // retains the original index, SBOM, and provenance for digest-preserving promotion. run( "skopeo", [ "--override-os", "linux", "--override-arch", architecture, "copy", `oci:${directory}`, `docker-daemon:${image}`, ], { timeout: 600_000, stdio: "inherit" }, ); requireValue( run("docker", ["image", "inspect", "--format", "{{.Id}}", image]).trim() === configDigest, "Smoke image does not match the prepared OCI image.", ); const script = variant === "browser" ? String.raw`set -eu; browser="$(find /home/node/.cache/ms-playwright -maxdepth 5 -type f \( -name chrome -o -name chromium -o -name chrome-headless-shell \) -print | head -1)"; test -n "$browser"; "$browser" --version` : WORKSPACE_SMOKE; run("docker", ["run", "--rm", "--entrypoint", "/bin/sh", image, "-lc", script], { timeout: 120_000, stdio: "inherit", }); } function releaseContext(env) { const job = JSON.parse(env.JOB_CONTEXT ?? "null"); const policy = validateDockerReleaseIdentity({ tag: env.RELEASE_TAG, sourceSha: env.RELEASE_SHA, imageTagSuffix: env.IMAGE_TAG_SUFFIX ?? "", }); requireValue( SHA.test(env.GITHUB_WORKFLOW_SHA) && POSITIVE_INTEGER.test(env.GITHUB_RUN_ID) && POSITIVE_INTEGER.test(env.GITHUB_RUN_ATTEMPT), "Missing immutable Docker workflow identity.", ); requireValue( /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(env.GITHUB_REPOSITORY), "Invalid Docker repository.", ); requireValue( job?.workflow_sha === env.GITHUB_WORKFLOW_SHA && job.workflow_repository === env.GITHUB_REPOSITORY && job.workflow_file_path === WORKFLOW_PATH && job.workflow_ref?.startsWith(`${env.GITHUB_REPOSITORY}/${WORKFLOW_PATH}@`), "Docker preparation must execute its pinned canonical workflow.", ); return { schemaVersion: 1, repository: env.GITHUB_REPOSITORY, sourceSha: env.RELEASE_SHA, toolingSha: env.GITHUB_WORKFLOW_SHA, tag: env.RELEASE_TAG, version: policy.version, imageTagSuffix: env.IMAGE_TAG_SUFFIX ?? "", builtAt: env.BUILT_AT, includeBrowser: env.INCLUDE_BROWSER === "true", producer: { runId: env.GITHUB_RUN_ID, runAttempt: env.GITHUB_RUN_ATTEMPT, workflowRef: env.GITHUB_WORKFLOW_REF, workflowSha: env.GITHUB_WORKFLOW_SHA, preparationWorkflowRef: job.workflow_ref, }, }; } async function prepareArchitecture(values, env) { const context = releaseContext(env); const architecture = values.architecture; requireValue(ARCHITECTURES.includes(architecture), "Unsupported preparation architecture."); const variants = context.includeBrowser ? ["default", "browser"] : ["default"]; const images = []; for (const variant of variants) { const directory = path.join(values.directory, variant); const image = await verifyDockerReleaseLayout({ ...context, directory, architecture, expectedDigest: variant === "default" ? env.DEFAULT_DIGEST : env.BROWSER_DIGEST, }); smokeImage(directory, architecture, variant, image.configDigest); images.push({ variant, ...image, smoke: "success", attestations: "success" }); } writeJson(values.output, { ...context, architecture, images }); } function artifactByName(repository, runId, name, readApi) { const result = readApi( `repos/${repository}/actions/runs/${runId}/artifacts?name=${encodeURIComponent(name)}&per_page=100`, ); const artifacts = result.artifacts?.filter((artifact) => artifact.name === name) ?? []; requireValue( artifacts.length === 1 && !artifacts[0].expired && POSITIVE_INTEGER.test(String(artifacts[0].id)) && DIGEST.test(artifacts[0].digest), `Missing, expired, or ambiguous Docker artifact: ${name}`, ); return artifacts[0]; } export function sealDockerRelease({ metadataDirectory, context, checkRunId, readApi = ghJson }) { const artifactName = dockerReleaseArtifactName(context.sourceSha, context.producer.runAttempt); const architectures = ARCHITECTURES.map((architecture) => { const metadata = readJson(path.join(metadataDirectory, `${architecture}.json`)); for (const key of Object.keys(context)) { requireValue( JSON.stringify(metadata[key]) === JSON.stringify(context[key]), `Docker ${architecture} metadata mismatch: ${key}`, ); } requireValue(metadata.architecture === architecture, "Docker architecture metadata mismatch."); const artifact = artifactByName( context.repository, context.producer.runId, `${artifactName}-${architecture}`, readApi, ); return { architecture, artifact: { id: String(artifact.id), name: artifact.name, digest: artifact.digest, sizeBytes: artifact.size_in_bytes, }, images: metadata.images, }; }); requireValue( POSITIVE_INTEGER.test(String(checkRunId)), "Missing Docker seal check-run identity.", ); const jobs = []; for (let page = 1; ; page += 1) { const result = readApi( `repos/${context.repository}/actions/runs/${context.producer.runId}/attempts/${context.producer.runAttempt}/jobs?per_page=100&page=${page}`, ); requireValue( Array.isArray(result.jobs) && Number.isSafeInteger(result.total_count), "Invalid Docker producer jobs response.", ); jobs.push( ...result.jobs.filter( (job) => job.check_run_url === `https://api.github.com/repos/${context.repository}/check-runs/${checkRunId}`, ), ); if (page * 100 >= result.total_count) { break; } } requireValue( jobs.length === 1 && (jobs[0].name === SEAL_JOB_NAME || jobs[0].name.endsWith(` / ${SEAL_JOB_NAME}`)), "Cannot resolve the exact Docker seal job.", ); const manifest = { ...context, producer: { ...context.producer, jobId: String(jobs[0].id), jobName: jobs[0].name }, artifactName, architectures, }; validateDockerReleaseManifest(manifest, { ...context, artifactName, runId: context.producer.runId, runAttempt: context.producer.runAttempt, }); return manifest; } export function validateDockerReleaseManifest(manifest, expected) { const policy = validateDockerReleaseIdentity({ tag: expected.tag, sourceSha: expected.sourceSha, imageTagSuffix: expected.imageTagSuffix ?? "", }); const artifactName = dockerReleaseArtifactName(expected.sourceSha, expected.runAttempt); requireValue( manifest.schemaVersion === 1 && manifest.repository === expected.repository && manifest.sourceSha === expected.sourceSha && manifest.tag === expected.tag && manifest.version === policy.version && manifest.imageTagSuffix === (expected.imageTagSuffix ?? "") && manifest.artifactName === artifactName && expected.artifactName === artifactName, "Prepared Docker manifest does not match the release.", ); requireValue( SHA.test(manifest.toolingSha) && manifest.producer?.workflowSha === manifest.toolingSha && manifest.producer.runId === String(expected.runId) && manifest.producer.runAttempt === String(expected.runAttempt) && POSITIVE_INTEGER.test(manifest.producer.jobId) && (manifest.producer.jobName === SEAL_JOB_NAME || manifest.producer.jobName?.endsWith(` / ${SEAL_JOB_NAME}`)), "Prepared Docker producer identity mismatch.", ); requireValue( typeof manifest.includeBrowser === "boolean" && typeof manifest.builtAt === "string" && Number.isFinite(Date.parse(manifest.builtAt)), "Invalid Docker build context.", ); requireValue( expected.includeBrowser === undefined || manifest.includeBrowser === expected.includeBrowser, "Prepared Docker browser support differs from the finalized source.", ); requireValue( Array.isArray(manifest.architectures) && manifest.architectures.length === ARCHITECTURES.length, "Prepared Docker release must include both native architectures.", ); for (const [index, architecture] of ARCHITECTURES.entries()) { const entry = manifest.architectures[index]; requireValue( entry.architecture === architecture && entry.artifact?.name === `${artifactName}-${architecture}` && POSITIVE_INTEGER.test(entry.artifact.id) && DIGEST.test(entry.artifact.digest), "Prepared Docker payload identity mismatch.", ); const variants = manifest.includeBrowser ? ["default", "browser"] : ["default"]; requireValue( Array.isArray(entry.images) && entry.images.length === variants.length, "Prepared Docker variants are incomplete.", ); for (const [imageIndex, variant] of variants.entries()) { const image = entry.images[imageIndex]; requireValue( image.variant === variant && image.smoke === "success" && image.attestations === "success" && [image.indexDigest, image.imageDigest, image.configDigest].every((digest) => DIGEST.test(digest), ) && Array.isArray(image.manifests), "Prepared Docker image proof is incomplete.", ); } } return manifest; } function verifyDockerProducerRun(runInfo, manifest, runAttempt) { const { repository, toolingSha, producer } = manifest; const workflowPath = String(runInfo.path).split("@", 1)[0]; const prefix = `${repository}/${workflowPath}@`; requireValue( PRODUCER_WORKFLOWS.has(workflowPath) && producer.workflowRef?.startsWith(prefix), "Untrusted Docker producer workflow.", ); const fullRef = producer.workflowRef.slice(prefix.length); requireValue( /^refs\/(heads|tags)\/.+$/u.test(fullRef) && runInfo.head_branch === fullRef.replace(/^refs\/(heads|tags)\//u, ""), "Docker producer ref mismatch.", ); const trustedEvent = runInfo.event === "workflow_dispatch" || (workflowPath === ".github/workflows/docker-image-refresh.yml" && runInfo.event === "schedule" && fullRef === "refs/heads/main"); requireValue( String(runInfo.id) === producer.runId && String(runInfo.run_attempt) === runAttempt && runInfo.head_sha === toolingSha && runInfo.repository?.full_name === repository && runInfo.head_repository?.full_name === repository && trustedEvent, "Docker producer run/attempt/source mismatch.", ); return { workflowPath, fullRef }; } /** Preparation belongs to its exact successful seal job. A publisher retry * advances the parent attempt without rebuilding that immutable payload. */ export function verifyDockerReleaseProducer( manifest, { publisherSha, publisherRunId = "", publisherRunAttempt = "", readApi = ghJson }, ) { const { repository, toolingSha, producer } = manifest; requireValue(SHA.test(publisherSha), "Invalid Docker publisher tooling SHA."); const currentRun = readApi(`repos/${repository}/actions/runs/${producer.runId}`); const currentAttempt = String(currentRun.run_attempt); requireValue( POSITIVE_INTEGER.test(currentAttempt) && BigInt(currentAttempt) >= BigInt(producer.runAttempt), "Docker producer attempt is not available.", ); const { workflowPath, fullRef } = verifyDockerProducerRun(currentRun, manifest, currentAttempt); requireValue( (currentRun.status === "completed" && currentRun.conclusion === "success") || (currentRun.status === "in_progress" && currentRun.conclusion === null), "Docker producer is neither active nor successfully completed.", ); let preparedRun = currentRun; if (currentAttempt !== producer.runAttempt) { preparedRun = readApi( `repos/${repository}/actions/runs/${producer.runId}/attempts/${producer.runAttempt}`, ); verifyDockerProducerRun(preparedRun, manifest, producer.runAttempt); // Failed publication may reuse its own successful seal; unrelated publishers // still require a successful producer parent, even when that run is retried. const resumingOwnPublication = producer.runId === publisherRunId && currentAttempt === publisherRunAttempt && currentRun.head_sha === publisherSha && currentRun.status === "in_progress" && preparedRun.status === "completed" && ["failure", "cancelled", "timed_out"].includes(preparedRun.conclusion); requireValue( (preparedRun.status === "completed" && preparedRun.conclusion === "success") || resumingOwnPublication, "Historical Docker producer did not qualify for this publication.", ); } // Preparation linkage belongs to its recorded attempt; a publisher-only retry // need not list that completed reusable workflow in its current attempt. requireValue( producer.preparationWorkflowRef === `${repository}/${WORKFLOW_PATH}@${fullRef}` && (workflowPath === WORKFLOW_PATH || preparedRun.referenced_workflows?.some( (workflow) => workflow.path === `${repository}/${WORKFLOW_PATH}@${toolingSha}` && workflow.sha === toolingSha && workflow.ref === fullRef, )), "Docker producer did not execute the pinned preparation workflow.", ); const sealJob = readApi(`repos/${repository}/actions/jobs/${producer.jobId}`); requireValue( String(sealJob.id) === producer.jobId && String(sealJob.run_id) === producer.runId && String(sealJob.run_attempt) === producer.runAttempt && sealJob.name === producer.jobName && sealJob.status === "completed" && sealJob.conclusion === "success" && sealJob.head_sha === toolingSha, "Exact Docker preparation job has not completed successfully.", ); for (const target of new Set(["main", publisherSha])) { const comparison = readApi(`repos/${repository}/compare/${toolingSha}...${target}`); requireValue( comparison.status === "ahead" || comparison.status === "identical", `Docker producer tooling is not on ${target} ancestry.`, ); } for (const entry of manifest.architectures) { const artifact = artifactByName(repository, producer.runId, entry.artifact.name, readApi); requireValue( String(artifact.id) === entry.artifact.id && artifact.digest === entry.artifact.digest && artifact.workflow_run?.id === Number(producer.runId) && artifact.workflow_run?.head_sha === toolingSha, "Prepared Docker payload artifact changed.", ); } return manifest; } function loadPreparedManifest(values, env) { const bytes = readFileSync(values.manifest); requireValue( /^[a-f0-9]{64}$/u.test(values["manifest-sha256"]) && sha256(bytes) === values["manifest-sha256"], "Prepared Docker manifest digest mismatch.", ); const manifest = validateDockerReleaseManifest(JSON.parse(bytes), { repository: env.GITHUB_REPOSITORY, sourceSha: env.RELEASE_SHA, tag: env.RELEASE_TAG, imageTagSuffix: env.IMAGE_TAG_SUFFIX ?? "", includeBrowser: env.INCLUDE_BROWSER === "true", artifactName: values["artifact-name"], runId: values["run-id"], runAttempt: values["run-attempt"], }); return verifyDockerReleaseProducer(manifest, { publisherSha: env.GITHUB_WORKFLOW_SHA, publisherRunId: env.GITHUB_RUN_ID, publisherRunAttempt: env.GITHUB_RUN_ATTEMPT, }); } function verifyFinalTag(manifest, readApi = ghJson) { let object = readApi(`repos/${manifest.repository}/git/ref/tags/${manifest.tag}`).object; for (let depth = 0; object?.type === "tag" && depth < 5; depth += 1) { object = readApi(`repos/${manifest.repository}/git/tags/${object.sha}`).object; } requireValue( object?.type === "commit" && object.sha === manifest.sourceSha, "Finalized Docker release tag moved or does not match prepared source.", ); } function verifyRemoteDigest(ref, expectedDigest, execFileSyncImpl) { requireValue( resolveRemoteDigest(ref, execFileSyncImpl) === expectedDigest, `Registry did not preserve the prepared digest: ${ref}`, ); } function resolveRemoteDigest(ref, execFileSyncImpl) { const metadata = JSON.parse( execFileSyncImpl( "docker", ["buildx", "imagetools", "inspect", ref, "--format", "{{json .Manifest}}"], { encoding: "utf8", timeout: 120_000, maxBuffer: 20 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], }, ), ); requireValue(DIGEST.test(metadata.digest), `Missing registry digest: ${ref}`); return metadata.digest; } export async function publishDockerRelease({ manifest, payloadDirectory, images, execFileSyncImpl = execReleaseCommand, verifyTag = verifyFinalTag, promote = promoteDockerChannel, }) { const prepared = []; // Verify every payload before the first registry mutation, including siblings. for (const entry of manifest.architectures) { for (const image of entry.images) { const directory = path.join(payloadDirectory, entry.artifact.name, image.variant); const verified = await verifyDockerReleaseLayout({ ...manifest, directory, architecture: entry.architecture, expectedDigest: image.indexDigest, }); requireValue( verified.imageDigest === image.imageDigest && verified.configDigest === image.configDigest && JSON.stringify(verified.manifests) === JSON.stringify(image.manifests), "Prepared OCI image proof changed.", ); prepared.push({ ...image, architecture: entry.architecture, directory }); } } verifyTag(manifest); const execute = (command, args) => execFileSyncImpl(command, args, { encoding: "utf8", timeout: 1_200_000, stdio: ["ignore", "pipe", "pipe"], maxBuffer: 20 * 1024 * 1024, }); const version = `${manifest.version}${manifest.imageTagSuffix}`; for (const image of prepared) { const suffix = image.variant === "default" ? "" : "-browser"; for (const registry of images) { const ref = `${registry}:${version}${suffix}-${image.architecture}`; execute("skopeo", [ "copy", "--all", "--preserve-digests", `oci:${image.directory}`, `docker://${ref}`, ]); verifyRemoteDigest(ref, image.indexDigest, execFileSyncImpl); if (image.variant === "default") { const slim = `${registry}:${version}-slim-${image.architecture}`; execute("docker", [ "buildx", "imagetools", "create", "--prefer-index=false", "--tag", slim, `${registry}@${image.indexDigest}`, ]); verifyRemoteDigest(slim, image.indexDigest, execFileSyncImpl); } } } const sourceDigests = []; for (const variant of manifest.includeBrowser ? ["default", "browser"] : ["default"]) { const selected = prepared.filter((image) => image.variant === variant); const suffixes = variant === "default" ? ["", "-slim"] : ["-browser"]; for (const [registryIndex, registry] of images.entries()) { const tags = suffixes.map((suffix) => `${registry}:${version}${suffix}`); execute("docker", [ "buildx", "imagetools", "create", ...tags.flatMap((tag) => ["--tag", tag]), ...selected.map((image) => `${registry}@${image.indexDigest}`), ]); const combinedDigest = resolveRemoteDigest(tags[0], execFileSyncImpl); const raw = inspectRaw(`${registry}@${combinedDigest}`, { execFileSyncImpl }); const combined = JSON.parse(raw); const byDigest = (left, right) => left.digest.localeCompare(right.digest); const expectedDescriptors = selected.flatMap((image) => image.manifests).toSorted(byDigest); requireValue( Array.isArray(combined.manifests) && isDeepStrictEqual(combined.manifests.toSorted(byDigest), expectedDescriptors), "Published multiarch index changed image or attestation descriptors.", ); for (const tag of tags.slice(1)) { verifyRemoteDigest(tag, combinedDigest, execFileSyncImpl); } verifyDockerAttestations({ imageRefs: [`${registry}@${combinedDigest}`], requiredPlatforms: ARCHITECTURES.map((architecture) => parsePlatform(`linux/${architecture}`), ), execFileSyncImpl, }); if (registryIndex === 0) { for (const suffix of suffixes) { sourceDigests.push(`${suffix === "" ? "default" : suffix.slice(1)}=${combinedDigest}`); } } } } if (resolveDockerReleasePolicy(manifest.version).channel !== "beta") { verifyTag(manifest); promote( { version: manifest.version, imageTagSuffix: manifest.imageTagSuffix, images, includeBrowser: manifest.includeBrowser, }, { execFileSyncImpl }, ); } return sourceDigests.join("\n"); } async function main() { const command = process.argv[2]; const { values } = parseArgs({ args: process.argv.slice(3), options: Object.fromEntries( [ "directory", "architecture", "output", "manifest", "manifest-sha256", "artifact-name", "run-id", "run-attempt", ].map((key) => [key, { type: "string" }]), ), }); const env = process.env; if (command === "prepare") { await prepareArchitecture(values, env); } else if (command === "seal") { const manifest = sealDockerRelease({ metadataDirectory: values.directory, context: releaseContext(env), checkRunId: env.SEAL_CHECK_RUN_ID, }); writeJson(values.output, manifest); appendFileSync(env.GITHUB_OUTPUT, `manifest_sha256=${sha256(readFileSync(values.output))}\n`); } else if (command === "verify" || command === "publish") { const manifest = loadPreparedManifest(values, env); if (command === "verify") { appendFileSync( env.GITHUB_OUTPUT, `artifact_ids=${manifest.architectures.map((entry) => entry.artifact.id).join(",")}\n`, ); } if (command === "publish") { const started = Date.now(); const sourceDigests = await publishDockerRelease({ manifest, payloadDirectory: values.directory, images: [`ghcr.io/${env.GITHUB_REPOSITORY.toLowerCase()}`, "docker.io/openclaw/openclaw"], }); appendFileSync(env.GITHUB_OUTPUT, `vcr_source_digests< sum + entry.artifact.sizeBytes, 0)}.\n`, ); } } else { throw new Error("Usage: docker-release-artifacts.mjs prepare|seal|verify|publish [options]"); } } if (isDirectRunUrl(process.argv[1], import.meta.url)) { main().catch((/** @type {unknown} */ error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }); }