build-meta
v0.1.1
Published
Writes a meta.json with the package version, build date, environment, git branch, and last commit for the current project
Readme
build-meta
Stamps a build with its own provenance: package version, build date, environment, git branch, and the last commit's author and hash.
By default it writes a meta.js that installs the metadata on window['build-meta'] when the page loads, plus a meta.d.ts that types it, so nothing has to be imported or wired up by hand. A meta.json is available too, on request.
- No runtime dependencies
- No bundler plugin and no framework coupling
- Typed out of the box
- Runs on Bun or Node 24+, needs
giton thePATH
Contents
- Install
- Quick start
- Using the metadata
- The meta object
- Generating a meta.json
- CLI reference
- Failures
- Development
Install
bun add -d build-meta # or: npm i -D build-metaNote The version currently on npm (0.0.12) is the older implementation, which depends on
yargs,moment,moment-timezone,jsonfile, and several git helpers. The dependency-free rewrite documented here lives in this repository and has not been published yet.
Quick start
Run it from the directory that holds your package.json, and point --src-folder at the folder that should receive the files:
build-meta --src-folder srcThat writes src/meta.js and src/meta.d.ts. Wire it into your build:
{
"scripts": {
"prebuild": "build-meta --src-folder src --env production"
}
}Then load it once, from your entry point:
import './meta.js';or from your HTML:
<script src="/meta.js"></script>That is the whole integration. There is no import binding to thread through your app and no assignment to write:
// no longer necessary
import meta from './meta.json';
window.yourcompany = window.yourcompany || {};
window.yourcompany.meta = window.yourcompany.meta || meta;Using the metadata
Once the file has loaded, the metadata is on the global under the build-meta key:
const meta = window['build-meta'];
console.log(meta.version); // "1.2.3"
console.log(meta.lastCommitHash); // "907761dd591f6bc3a69a514092acfb8c147b73cf"The key contains a hyphen, so it is reachable as window['build-meta'] and never as window.build-meta, which is not valid JavaScript.
Three details are worth knowing:
- It does not overwrite. The assignment is
g['build-meta'] = g['build-meta'] || { … }, so if something already claimed the key, that value survives. - It does not need a
window. Where there is nowindowat all, such as during server-side rendering, it falls back toglobalThisinstead of throwing. - It leaks nothing else. The file is a plain script wrapped in an IIFE, so it works from a
<script>tag and from a side-effectimportalike, and adds no other global.
Here is the generated file in full:
(function () {
var g = typeof window !== 'undefined' ? window : globalThis;
g["build-meta"] = g["build-meta"] || {
"version": "1.2.3",
"buildDate": "09-06-2026 12:42:23 PM ET",
"buildDateISO": "2026-09-06T16:42:23.349Z",
"buildEnv": "production",
"branchName": "main",
"lastCommitAuthor": "CJ Rivas",
"lastCommitHash": "907761dd591f6bc3a69a514092acfb8c147b73cf"
};
})();TypeScript
A meta.d.ts is written next to meta.js on every run, so there is nothing to install and nothing to declare:
// Generated by build-meta. Describes the object meta.js installs on the global.
type BuildMeta = {
version: string;
buildDate: string;
buildDateISO: string;
buildEnv: string;
branchName: string;
lastCommitAuthor: string;
lastCommitHash: string;
};
interface Window {
"build-meta"?: BuildMeta;
}It is a global script rather than a module, so as long as the folder is covered by your tsconfig.json, window['build-meta'] is typed everywhere with no import:
const version = window['build-meta']?.version; // string | undefined
function report(meta: BuildMeta): string {
return `${meta.version} on ${meta.branchName}`;
}The key is optional because nothing guarantees the script has loaded by the time your code reads it. BuildMeta describes the object that was actually emitted, so a package.json with no version produces types with no version either, rather than promising a field that is not there. It is a type alias; Window is the one interface, because augmenting the DOM's Window is declaration merging and only interfaces merge.
The meta object
| Field | Example | Source |
| --- | --- | --- |
| version | "1.2.3" | The version in the package.json of the working directory. Omitted from every output if that file has none. |
| buildDate | "09-06-2026 12:42:23 PM ET" | Wall clock reading in America/Toronto, for a human to read. |
| buildDateISO | "2026-09-06T16:42:23.349Z" | The same instant in UTC ISO-8601, for Date.parse and for sorting. |
| buildEnv | "production" | --env, else NODE_ENV, else PROFILE, else development. |
| branchName | "main" | git rev-parse --abbrev-ref HEAD. |
| lastCommitAuthor | "CJ Rivas" | git log -1 --format=%an. |
| lastCommitHash | "907761d…" | git rev-parse HEAD, the full 40 characters. |
A few consequences of those sources:
versionis read from thepackage.jsonin the current working directory, not from the git root and not from build-meta's own package. In a monorepo, run it from the package you want described.buildDateis always Eastern Time. The timezone and the format are hardcoded so that stamps from different machines and CI regions stay comparable;buildDateISOis the one to compute with.branchNameis the literal stringHEADwhen the repository is in a detached HEAD state. Many CI systems check out a detached commit, so expect"branchName": "HEAD"there unless a branch is checked out explicitly.
The same object is printed to stdout on every run.
Generating a meta.json
meta.json is opt in. Ask for it with --output-json:
build-meta --src-folder src --output-jsonIt lands in the current working directory, next to the package.json that was read. meta.js and meta.d.ts are still written as well. To put it somewhere else, name the directory:
build-meta --src-folder src --json-out-dir public--json-out-dir implies --output-json, so the two flags never have to be passed together. The directory must already exist.
{
"version": "1.2.3",
"buildDate": "09-06-2026 12:42:23 PM ET",
"buildDateISO": "2026-09-06T16:42:23.349Z",
"buildEnv": "production",
"branchName": "main",
"lastCommitAuthor": "CJ Rivas",
"lastCommitHash": "907761dd591f6bc3a69a514092acfb8c147b73cf"
}All three outputs are derived from one in-memory object, so they can never disagree.
CLI reference
build-meta --src-folder <dir> [--env <name>] [--output-json] [--json-out-dir <dir>]| Flag | Required | Default | Description |
| --- | --- | --- | --- |
| --src-folder <dir> | yes | — | Directory to receive meta.js and meta.d.ts. |
| --env <name> | no | NODE_ENV, PROFILE, development | Sets buildEnv. An empty value falls through to the rest of the chain. |
| --output-json | no | off | Also write a meta.json. |
| --json-out-dir <dir> | no | working directory | Where meta.json goes. Implies --output-json. |
Every path is resolved against the current working directory, never the git root. Relative paths, absolute paths, and --flag=value all work. Target directories must already exist; the CLI does not create them.
Parsing is strict: an unknown flag or a stray positional argument fails the run rather than being ignored, so build-meta src is rejected and there is no positional shorthand.
Failures
Every failure exits with status 1.
| Cause | Output |
| --- | --- |
| --src-folder missing | build-meta: --src-folder <dir> is required |
| Unknown flag, or a positional argument | parseArgs error and a stack trace |
| No package.json in the working directory | Module resolution error and a stack trace |
| Not a git repository, or git not on the PATH | git's own stderr, then a stack trace |
| A target folder that does not exist | ENOENT and a stack trace |
Only the first case is handled with a friendly message; the rest surface as uncaught exceptions.
The git commands all run before anything is written, so a bad --src-folder fails at the last step with the git work already done. meta.js and meta.d.ts are written before meta.json, so a bad --json-out-dir fails with both already on disk. The non-zero exit is what stops the build.
Development
The repository is TypeScript, developed and tested with Bun. All the logic is in src/build-meta.ts; bun run build compiles it to the stdlib-only CommonJS bin/build-meta.js that ships, which is why consumers need neither Bun nor TypeScript.
bun run build # tsgo -p tsconfig.build.json, writes bin/build-meta.js
bun run typecheck # tsgo -p tsconfig.json, checks src and test
bun run test # builds, then runs the suite against the compiled CLI
bun run lint # oxlint
bun run format # oxfmt
bun run clean # rm -rf binbin/ is generated and is not committed. bun install rebuilds it through the prepare script, and so does bun publish.
Continuous integration
Two workflows live in .github/workflows/. pull-request-checks.yml runs lint, formatting, typecheck, build and tests on every pull request, then proves the two properties the local loop cannot see: that the compiled bin/build-meta.js runs on a bare Node 24 as well as on Bun, and that the package still has zero runtime dependencies with an emit that reaches for nothing but node: builtins. sanity-check.yml runs the same suite on every push to main and adds a check that the packed tarball installs into an empty project and runs.
Notes on the suite
The suite spawns the compiled CLI in a child process against throwaway git repositories under the OS temp directory, because the CLI does all its work at module load. The generated meta.js is loaded for real in a child process and the global read back, and the generated meta.d.ts is compiled with tsgo against sample consumer code, rather than either being pattern matched as text.
