feat(admin): show the deployed commit and build time in the admin (#233)
There was no way to tell which build an environment was running. That is not hypothetical: minutes after #232 merged, `npm run backfill:images` in QA failed with `tsx: not found` because the container was still serving the pre-merge image, and the only thing that revealed it was npm echoing the old script line. Had the change been anywhere other than a package.json script, the container would have looked healthy while running the wrong code.
The header now reads something like `a5076cc · built 29 Aug 20:36`. The commit answers "is this the code I expect"; the build time answers "did my redeploy actually rebuild", which is a different question and the one that would have caught the case above.
The commit is read out of `.git` directly rather than by shelling out, because node:20-bookworm-slim has no git binary and adding an apt layer so the image can print seven characters is a poor trade. `.git` is copied into the build stage only — verified absent from the final image — so no repository history reaches a deployed container.
Resolution is pure and separately tested across every shape that actually occurs: a detached HEAD holding the object name, which is what a checkout of a ref produces; a symbolic HEAD followed to a loose ref file; the same followed to packed-refs, which is what a fresh clone commonly has; peeled `^` tag lines ignored so an annotated tag cannot yield the wrong commit; and every failure path returning `unknown`. That last part is the one that matters most — this runs during a Docker build, and a version stamp must never be the thing that stops a deploy.
Served from a gated /api/admin/version rather than folded into /api/config. That endpoint is public, and a commit hash there would tell any storefront visitor exactly which revision of a public repository is deployed. An integration test asserts the gate and asserts the public config does not carry it, because the boundary is the whole point rather than an implementation detail.
Verified in the built image rather than argued: the stamp inside it reads a5076cc, matching `git rev-parse --short HEAD`, and a running container serves it from /api/admin/version while /api/config returns only what it did before.
Backend: 296 unit, 263 integration, tsc clean, lint unchanged at six pre-existing warnings. Frontend builds clean with its two pre-existing warnings untouched.
Closes #233
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
export function buildStamp(gitDir: string | null): BuildInfo {
|
||||
return {
|
||||
commit: 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);
|
||||
|
||||
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 readable .git found${where} — ` +
|
||||
`the admin will report the commit as "${UNKNOWN_COMMIT}"`
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(BUILD_INFO_PATH, `${JSON.stringify(stamp, null, 2)}\n`, 'utf8');
|
||||
console.info(`[build-info] ${stamp.commit} built ${stamp.builtAt}`);
|
||||
}
|
||||
Reference in New Issue
Block a user