everything-dev
Version:
A consolidated product package for building Module Federation apps with oRPC APIs.
228 lines (226 loc) • 8.1 kB
JavaScript
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