/** * Writes the build stamp that the admin reads back (#233). * * Runs once, at the end of the Docker build, against the compiled output: * * npm run build && node dist/writeBuildInfo.js * * It has to run after `tsc` because `tsc` writes into `dist/` and would not * remove a JSON file placed there first — but ordering it explicitly means the * stamp is never left over from a previous build. * * Never fails the build. A missing or unreadable `.git` produces a stamp * saying `unknown`, which is a worse answer than a commit and a much better * one than a deploy that stopped. `.git` is absent from the final image by * design; only this build stage sees it. */ import { writeFileSync, existsSync } from 'fs'; import path from 'path'; import { resolveCommit, gitSourceAt, BUILD_INFO_PATH, UNKNOWN_COMMIT, BuildInfo } from './buildInfo'; /** * Where `.git` is, relative to wherever this was run from. * * Two layouts, both real. In the container the repository's `.git` is copied * beside the backend, so it sits in the working directory. Locally the backend * is a subdirectory of the repository, so it is one level up. An explicit * argument wins over both, which is what makes this testable by hand. */ function findGitDir(explicit?: string): string | null { const candidates = [ explicit, path.join(process.cwd(), '.git'), path.join(process.cwd(), '..', '.git') ].filter((candidate): candidate is string => typeof candidate === 'string'); return candidates.find((candidate) => existsSync(candidate)) ?? null; } /** * A commit passed in by whoever is building, or null. * * This is the half that actually works in the environments that matter. The * Dockerfile deliberately does not copy `.git` — doing so broke every Portainer * deploy (#235) — and #237 established that building in CI changes nothing, * because the copy is what was missing rather than the history. So the builder * has to hand the commit over rather than the build going to look for it (#248). * * Empty is treated as absent. A `--build-arg GIT_COMMIT=` with nothing after it * is what an unset shell variable expands to, and stamping the image with an * empty string would be worse than saying "unknown" — it reads as a commit that * happens to be blank rather than as one nobody supplied. */ function passedCommit(value: string | undefined): string | null { return value !== undefined && value.trim() !== '' ? value.trim() : null; } export function buildStamp(gitDir: string | null, passed?: string): BuildInfo { const supplied = passedCommit(passed); return { // The passed value wins. It is the only one available where this matters, // and reading .git remains the fallback so a local build still stamps // itself without anyone having to remember the argument. commit: supplied ?? (gitDir ? resolveCommit(gitSourceAt(gitDir)) : UNKNOWN_COMMIT), // Whole seconds: this is read by a person comparing it to when they // pressed a button, not by anything that needs precision. builtAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') }; } // Guarded so that importing this module cannot rewrite the stamp of a running // deployment — the same reasoning as backfillImageReencode.ts (#231). if (require.main === module) { const gitDir = findGitDir(process.argv[2]); const stamp = buildStamp(gitDir, process.env.GIT_COMMIT); if (stamp.commit === UNKNOWN_COMMIT) { // Loud, because a deploy that cannot say what it is defeats the point of // the stamp — but a warning, not a failure. const where = gitDir ? ` at ${gitDir}` : ''; console.warn( `[build-info] no GIT_COMMIT passed and no readable .git found${where} — ` + `the admin will report the commit as "${UNKNOWN_COMMIT}". ` + `Pass --build-arg GIT_COMMIT="$(git rev-parse --short HEAD)" to stamp it.` ); } writeFileSync(BUILD_INFO_PATH, `${JSON.stringify(stamp, null, 2)}\n`, 'utf8'); console.info(`[build-info] ${stamp.commit} built ${stamp.builtAt}`); }