UNPKG

everything-dev

Version:

A consolidated product package for building Module Federation apps with oRPC APIs.

228 lines (226 loc) • 8.1 kB
import { run } from "./utils/run.mjs"; import { colors, icons } from "./utils/theme.mjs"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; //#region src/image-deploy.ts /** * Image reference resolution precedence (ADR 0021): `ci.image` in * bos.config.json → `BOS_IMAGE` env → derived from `repository`. */ function resolveImageRef(input) { const envImage = input.env?.BOS_IMAGE; if (input.ciImage) return { image: input.ciImage, source: "ci" }; if (envImage) return { image: envImage, source: "env" }; const repo = input.repository?.replace(/^https?:\/\/(www\.)?github\.com\//i, "").replace(/\.git$/i, "").replace(/\/+$/, "").toLowerCase(); if (!repo?.includes("/") || repo.split("/").length !== 2) return void 0; return { image: `ghcr.io/${repo}`, source: "repository" }; } async function hasDocker() { try { return (await run("docker", [ "info", "--format", "{{.ServerVersion}}" ], { capture: true }))?.exitCode === 0; } catch { return false; } } function parseDigest(pushOutput) { return pushOutput.match(/digest:\s*(sha256:[a-f0-9]+)/i)?.[1]; } const SEMVER_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/; /** * The runtime framework version baked into the image is the workspace source's * `everything-dev` package version — the image builds from `configDir`, so that * file is the truth. Missing (child repos without the workspace) → undefined. */ function readFrameworkVersion(configDir) { try { const raw = readFileSync(join(configDir, "packages", "everything-dev", "package.json"), "utf-8"); const pkg = JSON.parse(raw); return typeof pkg.version === "string" && SEMVER_PATTERN.test(pkg.version) ? pkg.version : void 0; } catch { return; } } /** * Tag scheme (ADR 0021): `sha-<short>` always; the exact `v<version>` tag; a * floating major tag (`v2`) on stable releases only. `latest` is held during * prereleases (npm's `rc` dist-tag analog) so existing deployments pinned to * `:latest` keep serving the last stable image — and `:rc` is the floating * ref for the prerelease train (npm's `rc` dist-tag mirror): it moves on * every prerelease deploy and freezes at the last rc-train build once the * workspace releases a stable version. */ function computeImageTags(input) { const tags = [`sha-${input.shortSha}`]; const match = input.version?.trim().match(SEMVER_PATTERN); if (!match) return { tags, pushLatest: true, pushRc: false }; const [, major, minor, patch, prerelease] = match; const exact = prerelease ? `v${major}.${minor}.${patch}-${prerelease}` : `v${major}.${minor}.${patch}`; if (prerelease) return { tags: [...tags, exact], pushLatest: false, latestHoldReason: `${exact} is a prerelease — :latest still serves the last stable image`, pushRc: true }; return { tags: [ ...tags, exact, `v${major}` ], pushLatest: true, pushRc: false }; } const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; /** A Railway pin is `FROM image@<digest>` — anything else cannot resolve. */ function isValidImageDigest(digest) { return DIGEST_PATTERN.test(digest); } /** * Decide the image leg's source: a digest handed over from a pre-pushed image * (the CI image job) wins; otherwise the local docker build runs when the * toolchain and Dockerfile are present (ADR 0021, as amended). */ function resolveDeployImagePlan(input) { if (!input.imageRef) return { kind: "skip", reason: "set ci.image in bos.config.json (or BOS_IMAGE) to build and push the runtime image" }; if (input.imageDigest) return { kind: "prebuilt", image: input.imageRef.image, digest: input.imageDigest }; if (!input.hasDocker) return { kind: "skip", reason: "docker is not available" }; if (!input.hasDockerfile) return { kind: "skip", reason: "no Dockerfile at the config root" }; return { kind: "build", image: input.imageRef.image }; } /** * Image leg (ADR 0021): build the runtime stage, tag `sha-<short>` + version * tags, push them, capture the digest, then push the floating refs — `:latest` * unless the version is a prerelease, and `:rc` when it is. Callers skip the * leg with a notice when no image is configured (child repos get * build+upload+publish only). */ async function buildAndPushImage(input) { const shaResult = await run("git", [ "rev-parse", "--short=7", "HEAD" ], { cwd: input.configDir, capture: true }); const shortSha = shaResult?.stdout.trim(); if (!shaResult || shaResult.exitCode !== 0 || !shortSha) { const detail = shaResult?.stderr.trim(); throw new Error(`Failed to resolve the current git SHA in ${input.configDir} (exit code ${shaResult?.exitCode ?? "unknown"}${detail ? `: ${detail}` : ""})`); } const { tags, pushLatest, latestHoldReason, pushRc } = computeImageTags({ shortSha, version: readFrameworkVersion(input.configDir) }); const refs = tags.map((tag) => `${input.image}:${tag}`); const latestRef = `${input.image}:latest`; const rcRef = `${input.image}:rc`; const buildRefs = pushLatest ? [...refs, latestRef] : [...refs]; if (pushRc) buildRefs.push(rcRef); console.log(` Building runtime image ${colors.cyan(buildRefs.join(", "))}${pushLatest ? "" : colors.yellow(" (latest held)")}`); await run("docker", [ "build", "--target", "runtime", ...buildRefs.flatMap((ref) => ["-t", ref]), "." ], { cwd: input.configDir }); console.log(` Pushing ${colors.cyan(refs.join(", "))}...`); let digest; for (const ref of refs) { const push = await run("docker", ["push", ref], { cwd: input.configDir, capture: true }); if (!push || push.exitCode !== 0) throw new Error(`docker push ${ref} failed with exit code ${push?.exitCode ?? "unknown"}`); digest ??= parseDigest(push.stdout); if (input.verbose && push.stdout.trim()) console.log(colors.dim(push.stdout.trim())); } const pushFloating = async (ref) => { const push = await run("docker", ["push", ref], { cwd: input.configDir, capture: true }); if (!push || push.exitCode !== 0) throw new Error(`docker push ${ref} failed with exit code ${push?.exitCode ?? "unknown"}`); digest ??= parseDigest(push.stdout); if (input.verbose && push.stdout.trim()) console.log(colors.dim(push.stdout.trim())); return true; }; let latestPushed = false; if (pushLatest) latestPushed = await pushFloating(latestRef); else console.log(colors.yellow(` latest held: ${latestHoldReason}`)); const rcPushed = pushRc ? await pushFloating(rcRef) : false; if (!digest) console.log(colors.yellow(" Could not capture the image digest from push output")); return { image: input.image, tag: tags[0], tags, latestPushed, rcPushed, digest }; } /** * Railway leg (ADR 0021): pull-only deploy of the pushed image digest — a * thin `FROM image@digest` Dockerfile pins the digest so Railway never * rebuilds; `RAILWAY_DOCKERFILE_PATH` points at the generated file. */ async function deployImageToRailway(input) { const deployDir = join(input.configDir, ".bos", "deploy"); mkdirSync(deployDir, { recursive: true }); const dockerfilePath = join(deployDir, "Dockerfile"); writeFileSync(dockerfilePath, `FROM ${input.image}@${input.digest}\n`); console.log(` Deploying to Railway service ${colors.cyan(input.service)} (pull-only)...`); const result = await run("railway", [ "up", "--service", input.service, "--ci" ], { cwd: input.configDir, capture: true, env: { RAILWAY_DOCKERFILE_PATH: ".bos/deploy/Dockerfile" } }); if (result?.stdout) { for (const line of result.stdout.split("\n")) if (line.trim()) console.log(` ${colors.dim(line.trim())}`); } if (!result || result.exitCode !== 0) throw new Error(result?.stderr.trim() ? `railway up failed: ${result.stderr.trim().split("\n").slice(-3).join(" ")}` : `railway up failed with exit code ${result?.exitCode ?? "unknown"}`); console.log(colors.green(` ${icons.ok} Railway deploy complete`)); } //#endregion export { buildAndPushImage, computeImageTags, deployImageToRailway, hasDocker, isValidImageDigest, resolveDeployImagePlan, resolveImageRef }; //# sourceMappingURL=image-deploy.mjs.map