build-identity.ts94 lines · main
1import { readFileSync, statSync } from 'node:fs';
2import { resolve } from 'node:path';
3
4/**
5 * Build identity helpers — shared across apps/api, apps/realtime,
6 * apps/runtime so every service answers /info identically. The resolver
7 * chain is:
8 *
9 * 1. BRIVEN_BUILD_SHA / BRIVEN_BUILD_AT — passed as Dockerfile ARGs by
10 * scripts/deploy-kvm4.sh. Preferred source for the deploy script
11 * path.
12 * 2. .git/HEAD inside the image — fallback for Dokploy auto-deploys
13 * which run `docker compose build` without --build-arg. Requires
14 * the .git dir to be in the build context (we removed it from
15 * .dockerignore for this reason).
16 * 3. The string "dev" — final fallback for fresh local checkouts /
17 * tests / anything where neither source resolves.
18 *
19 * "dev" is also treated as "unset" when seen in the env var because
20 * that's the ARG default in the Dockerfile — when Dokploy builds
21 * without passing the build-arg, the runtime env resolves to the
22 * literal string "dev", and we want the .git branch to fire then too.
23 */
24
25const DEV_SENTINEL = 'dev';
26
27function envValue(name: string): string | null {
28 const v = process.env[name]?.trim();
29 return !v || v === DEV_SENTINEL ? null : v;
30}
31
32/**
33 * Read the commit sha from .git/HEAD without shelling out to `git`.
34 * Handles three layouts:
35 * 1. detached HEAD — HEAD contains the sha directly
36 * 2. ref pointing at a loose — .git/refs/heads/<name> exists
37 * 3. ref pointing at a packed — entry lives in .git/packed-refs
38 *
39 * Returns null on any I/O failure so the caller can fall back to "dev".
40 */
41export function resolveShaFromGit(gitDir: string): string | null {
42 try {
43 const head = readFileSync(resolve(gitDir, 'HEAD'), 'utf8').trim();
44 if (!head.startsWith('ref:')) return /^[0-9a-f]{40}$/.test(head) ? head : null;
45 const ref = head.slice(4).trim();
46 try {
47 const sha = readFileSync(resolve(gitDir, ref), 'utf8').trim();
48 if (/^[0-9a-f]{40}$/.test(sha)) return sha;
49 } catch {
50 // loose ref missing — try packed-refs
51 }
52 const packed = readFileSync(resolve(gitDir, 'packed-refs'), 'utf8');
53 for (const line of packed.split('\n')) {
54 if (line.startsWith('#') || line.startsWith('^')) continue;
55 const [sha, name] = line.split(' ');
56 if (name === ref && sha && /^[0-9a-f]{40}$/.test(sha)) return sha;
57 }
58 return null;
59 } catch {
60 return null;
61 }
62}
63
64/**
65 * Build timestamp fallback — mtime of .git/HEAD inside the image. The
66 * mtime is updated whenever HEAD moves (checkout / commit / fetch+reset),
67 * so inside a freshly-built image it reflects when the docker build
68 * copied the .git tree.
69 */
70export function resolveBuildAtFromGit(gitDir: string): string | null {
71 try {
72 const stat = statSync(resolve(gitDir, 'HEAD'));
73 return new Date(stat.mtimeMs).toISOString();
74 } catch {
75 return null;
76 }
77}
78
79export interface BuildIdentity {
80 buildSha: string;
81 buildAt: string;
82}
83
84/**
85 * Resolve build identity for a service. Each app passes the path to
86 * its repo root's .git directory (typically two levels up from the
87 * runtime working dir — `apps/<name>` → `../../.git`).
88 */
89export function resolveBuildIdentity(repoRootGitDir: string): BuildIdentity {
90 const buildSha = envValue('BRIVEN_BUILD_SHA') ?? resolveShaFromGit(repoRootGitDir) ?? DEV_SENTINEL;
91 const buildAt =
92 envValue('BRIVEN_BUILD_AT') ?? resolveBuildAtFromGit(repoRootGitDir) ?? DEV_SENTINEL;
93 return { buildSha, buildAt };
94}