Skip to content

Stamping a build ​

A running service cannot say which commit it is. BuildInfoStamp fixes that with no generated file: the compile bakes the stamp into the binary, and a run from source resolves it from git when the application starts. GET /health/stats reports it.

typescript
// .configs/builders/compile.ts - the compile bakes the stamp in
import { BunCompiler } from '@venizia/dev-configs/compilers';
import { BuildInfoStamp } from '@venizia/ignis-boot/build-info';

await BunCompiler.compile({
  entrypoint: './src/index.ts',
  outfile: './dist/service',
  define: await BuildInfoStamp.define(),
});
typescript
// src/index.ts - the entrypoint registers it
import { BuildInfoStamp } from '@venizia/ignis-boot/build-info';

await BuildInfoStamp.register();
// start the application as you do today

That is all. A clean checkout typechecks, tests and boots with nothing generated, because nothing is imported from a file that might not exist.

Before you start ​

  • @venizia/ignis-boot and @venizia/dev-configs in the application. @venizia/ignis-boot/build-info imports no TypeScript, so importing it at run time does not pull the scanner into the bundle.
  • The resolver runs under bun. It asks git through Bun Shell.

How it works ​

Where it runsregister() uses
A compiled binaryThe stamp define() baked in at compile. It never runs git
bun run src/index.ts, bun --watch, a testBuildInfoResolver: CI variables, then git, then package.json

define() returns one record, { IGNIS_BUILD_INFO: '<the stamp as JSON>' }. The compiler replaces that name in the bundle; register() reads it. BuildInfoStamp.GLOBAL_NAME holds the name, so you never type it.

register() sets commit, branch and builtAt. It leaves service and version to your application's getAppInfo(), so each application in a process keeps its own name and version.

The registry is a globalThis slot keyed by Symbol.for, so two copies of the package in one process - an ESM import beside a CommonJS require - still see one stamp.

1. Bake it at compile ​

Call BuildInfoStamp.define() in the compile script and pass the result as define. BunCompiler hands it to Bun.build, and a script that calls Bun.build itself passes the same record:

typescript
import { BuildInfoStamp } from '@venizia/ignis-boot/build-info';

await Bun.build({
  entrypoints: ['./src/index.ts'],
  compile: { outfile: './dist/service' },
  define: await BuildInfoStamp.define(),
});

define({ root }) resolves from another directory, for a build that does not run at the package root. The stamp is read once, when the compile starts, so builtAt is the compile time.

2. Register it once, at the entrypoint ​

Call await BuildInfoStamp.register() before the application starts. The health check reads the registry when a request arrives, so the call only has to come first.

3. Read it back ​

GET /health/stats reports the stamp with process and memory figures. The route is closed unless you open it - see Health check.

json
"build": {
  "service": "@nx/sale", "version": "1.4.2",
  "commit": "8f80b2a90d12", "branch": "develop",
  "builtAt": "2026-09-10T15:00:00.000Z"
}

Wire the CI variables when git will not be there ​

The resolver reads environment variables first, then git, then package.json. A build from a tarball has no .git, and that is the case the variables exist for. Export them in the step that compiles, because define() reads them there.

Your pipeline exportsFills
APP_ENV_BUILD_VERSION or APP_BUILD_VERSIONversion
APP_ENV_BUILD_COMMIT_TAG or APP_BUILD_COMMITcommit
APP_BUILD_BRANCHbranch
APP_ENV_BUILD_DATE or APP_BUILD_DATEbuiltAt

GitLab and GitHub names (CI_COMMIT_SHA, GITHUB_SHA, and the rest) are read without any wiring - the reference table lists the full order.

A field reading unspecified is telling you something ​

Anything the resolver cannot find is the literal unspecified. That is deliberate: a variable your pipeline forgot shows up as a word you can grep for, never as an absent key.

You seeIt means
commit: "unspecified" in a CI buildNo .git and no commit variable at compile. Export one from the table above
Every field unspecifiedregister() never ran; getAppInfo() still supplies service and version
branch: "unspecified" in a fresh repositoryA repository with no commit yet - git exits 128 and the resolver refuses its output

Do not read the stamp back from disk in production ​

A bun build --compile binary ships no node_modules, no .git and no readable package.json, and a Distroless image has no git to spawn. That is why the compile bakes the stamp: the binary never looks for any of them. The git fallback is for a run from source, where .git is there.

Running under Node ​

The resolver asks git through Bun.$. Under plain Node that is undefined, so the git-derived fields read unspecified and the start still succeeds rather than throwing. Export the CI variables above when a Node run has to be exact.

A frontend or a shell build ​

A Vite app or a Tauri shell cannot import the resolver. Write the record to a file in your build script and fetch it:

typescript
import { BuildInfoResolver } from '@venizia/ignis-boot/build-info';

const stamp = await BuildInfoResolver.getInstance().resolve();
await Bun.write('public/build-info.json', JSON.stringify(stamp, null, 2));

Moving off ignis-build-info ​

The ignis-build-info binary and the generated src/_build_info.ts are gone. See the changelog for the before and after.

See also ​

Files: