/** * Image release entrypoint. * It builds the pinned image, pushes it, and prints the digest. * Run this before updating `KNOT_IMAGE_DIGEST` in `.env`. */ import { $ } from "bun"; import { join, resolve } from "node:path"; import { envFrom, requireEnvVarsFrom } from "./lib"; const requiredEnvNames = [ "KNOT_IMAGE_REPO", "DOCKERHUB_USERNAME", "DOCKERHUB_PUSH_TOKEN", ] as const; const knotDockerDirectory = resolve(import.meta.dir, "../vendor/knot-docker"); /** Returns the usage text. */ function usage(): string { return [ "Usage:", " bun run release-image", "", "Build the vendored knot image, push it, and print the immutable digest to paste into `.env`.", ].join("\n"); } /** Builds a timestamped image tag. */ function buildImageTag(imageRepository: string, releaseTime: Date): string { const timestamp = releaseTime .toISOString() .replaceAll(":", "") .replaceAll(".", ""); return `${imageRepository}:${timestamp}`; } /** Reads the manifest digest. */ function readManifestDigest(manifestText: string): string { const manifest = JSON.parse(manifestText) as { digest?: unknown }; if (typeof manifest.digest !== "string" || manifest.digest === "") { throw new Error("docker buildx imagetools inspect did not return a digest"); } return manifest.digest; } /** Checks that the vendored build context exists. */ async function ensureKnotDockerExists(): Promise { if (!(await Bun.file(join(knotDockerDirectory, "Dockerfile")).exists())) { throw new Error( "knot-docker submodule is missing; run git submodule update --init --recursive", ); } } /** Logs in to Docker Hub. */ async function dockerLogin(username: string, token: string): Promise { const login = Bun.spawn( ["docker", "login", "--username", username, "--password-stdin"], { stdin: "pipe", stdout: "inherit", stderr: "inherit", }, ); login.stdin.write(`${token}\n`); login.stdin.end(); const exitCode = await login.exited; if (exitCode !== 0) { throw new Error(`docker login failed with exit code ${exitCode}`); } } /** Builds and pushes the image tag. */ async function dockerBuildAndPush(imageTag: string): Promise { const build = await $`${[ "docker", "buildx", "build", "--platform", "linux/amd64", "--tag", imageTag, "--push", knotDockerDirectory, ]}`.nothrow(); const exitCode = build.exitCode; if (exitCode !== 0) { throw new Error(`docker buildx build failed with exit code ${exitCode}`); } } /** Reads the pushed image digest. */ async function inspectManifestDigest(imageTag: string): Promise { const inspect = await $`${[ "docker", "buildx", "imagetools", "inspect", imageTag, "--format", "{{json .Manifest}}", ]}` .quiet() .nothrow(); const exitCode = inspect.exitCode; if (exitCode !== 0) { throw new Error( `docker buildx imagetools inspect failed with exit code ${exitCode}`, ); } return readManifestDigest(await inspect.text()); } /** Builds, pushes, and prints the release digest. */ async function releaseImage(): Promise { requireEnvVarsFrom(Bun.env, requiredEnvNames); await ensureKnotDockerExists(); const imageRepository = envFrom(Bun.env, "KNOT_IMAGE_REPO"); const imageTag = buildImageTag(imageRepository, new Date()); await dockerLogin( envFrom(Bun.env, "DOCKERHUB_USERNAME"), envFrom(Bun.env, "DOCKERHUB_PUSH_TOKEN"), ); await dockerBuildAndPush(imageTag); const digest = await inspectManifestDigest(imageTag); console.log(`KNOT_IMAGE_DIGEST=${digest}`); console.log(`KNOT_IMAGE_REF=${imageRepository}@${digest}`); } /** Runs the CLI entrypoint. */ export async function main(args: readonly string[] = Bun.argv.slice(2)): Promise { if (args.includes("--help") || args.includes("-h") || args.includes("help")) { console.log(usage()); return 0; } await releaseImage(); return 0; } if (import.meta.main) { try { const exitCode = await main(); process.exit(exitCode); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(message); process.exit(1); } }