Resolve a build's true identity -- a real, already-tagged release, or the commit it was built from -- from live git state, with an optional predicted-version display layer.
A build banner or footer link (v1.4.0, linking to a release page) is only honest the moment a real release actually exists at that commit. Deriving that label from package.json alone, or from a CI environment variable, or from "this is the main branch so it must be the latest release" is exactly how a link to a release page that doesn't exist yet gets shipped -- typically the commit that triggers the release pipeline itself, before the tag and GitHub Release the link points at have actually been created. This package makes that check live and unavoidable: it shells out to git, checks whether the release tag genuinely points at HEAD right now, and only then returns a 'release' identity.
pnpm add -D @exadev/build-identityimport { resolveBuildIdentity } from '@exadev/build-identity';
const identity = resolveBuildIdentity(process.cwd(), 'exadev/build-identity');
// { kind: 'release', version: '1.4.0', url: 'https://github.com/exadev/build-identity/releases/tag/v1.4.0', date: '2026-09-08T09:12:03+01:00', commit: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2' }
// or, on any commit that isn't itself tagged:
// { kind: 'commit', version: 'a1b2c3d', url: 'https://github.com/exadev/build-identity/commit/a1b2c3d4e5f6...', date: '2026-09-08T09:12:03+01:00', commit: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2' }The same thing is available as a command, for build and deploy steps that cannot import anything -- see build-identity (CLI).
type BuildIdentity =
| { kind: 'release'; version: string; url: string; date: string; commit: string }
| { kind: 'commit'; version: string; url: string; date: string; commit: string };
function resolveBuildIdentity(repoRoot: string, repoSlug: string, options?: ResolveBuildIdentityOptions): BuildIdentity;
interface ResolveBuildIdentityOptions {
tagTemplate?: string; // defaults to 'v{version}'
}repoRoot: path to the git working tree to inspect. The released version comes from the release tag onHEAD; no file in it is read.repoSlug-- the GitHub"owner/repo"slug used to build both the release and commit URLs (e.g."exadev/build-identity").options.tagTemplate-- the tag name marking a release, with{version}standing in for the version exactly once. The default is the commonv{version}convention ("1.4.0"->"v1.4.0"), the same as semantic-release's defaulttagFormat; pass your own for a repo that tags differently (a bare version, a package-scoped prefix in a monorepo, and so on). Tags are read back through it, so a tag that does not fit it, or that carries a prerelease version, is never a release.
The one property this function exists to guarantee: it returns kind: 'release' if, and only if, a release tag provably points at the exact commit HEAD is on right now, checked live against git (git tag --points-at HEAD), never inferred from a version in package.json, an environment variable, or any other proxy that could be true before the tag actually exists. The version is read from that tag, so a repository whose releases are only tags (no release commit bumping package.json) resolves correctly; with several release tags on HEAD the highest wins, and a lower one on HEAD (a backport tagged after a newer release) is still a release. Every other case, including a commit sitting directly on top of the commit a release will eventually tag, returns kind: 'commit' instead, with version set to the short commit hash (there is no released version to show yet) and url pointing at that exact commit's permalink (using the full SHA, not the short one, so the link stays a valid, unambiguous permalink).
Only HEAD itself is inspected, never its history, so this works in a shallow clone and never throws for a missing tag. A clone made without tags (a default actions/checkout) has no tag at HEAD to find and reports kind: 'commit' even on a released commit: the honest answer for a release git cannot see. Check out with fetch-depth: 0 where a released commit should report as one.
commit is always the full git commit SHA of the exact commit this build resolves to, in both branches -- present so a caller who needs the raw SHA (to compare two builds for identity, or to construct its own link) never has to parse it back out of url, which never carries one at all in the 'release' case.
resolveBuildIdentity throws rather than defaulting whenever it can't establish a real identity -- repoRoot isn't a git repository, the tag template does not hold {version} exactly once, or repoSlug isn't a real "owner/repo" slug. There is no sensible placeholder identity for a build that isn't sitting in real, readable git history.
type DisplayIdentity = { kind: 'release' | 'predicted' | 'commit'; version: string; url: string; date: string; commit: string };
function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: string | undefined): DisplayIdentity;An unreleased build's version is a short commit hash, which isn't always what you want to show a user -- often what's actually useful is the version this commit will become once it releases. This function lets a caller upgrade the displayed label to a predicted version (e.g. computed by predictNextVersion, below, or semantic-release's own dry-run mode) without ever upgrading the URL to a release page that doesn't exist yet:
- If
build.kind === 'release', it is returned completely unchanged -- a confirmed release always wins outright, prediction or not. - Otherwise, when
predictedVersionis a real, non-empty string, the result iskind: 'predicted'withversionset to that (trimmed) prediction --url/date/commitare copied verbatim frombuild, still describing the real commit. - With no usable prediction (
undefined, empty, or whitespace-only),buildis returned unchanged.
This function does no git or filesystem access of its own -- computing the predicted version is entirely the caller's job, kept deliberately out of this package's core so the one property resolveBuildIdentity guarantees stays easy to audit on its own.
import { resolveBuildIdentity, resolvePredictedIdentity, predictNextVersion, loadCommitAnalyzer } from '@exadev/build-identity';
const build = resolveBuildIdentity(process.cwd(), 'exadev/build-identity');
const predictedVersion = await predictNextVersion(process.cwd(), releaseRules, await loadCommitAnalyzer());
const identity = resolvePredictedIdentity(build, predictedVersion);type ReleaseLevel = 'major' | 'minor' | 'patch';
type ReleaseRule = { type: string; release: ReleaseLevel | false } | { breaking: true; release: ReleaseLevel | false };
type AnalyzeCommits = (pluginConfig: unknown, context: unknown) => Promise<unknown>;
function predictNextVersion(repoRoot: string, releaseRules: readonly ReleaseRule[], analyzeCommits: AnalyzeCommits, options?: PredictNextVersionOptions): Promise<string | undefined>;
interface PredictNextVersionOptions {
tagTemplate?: string; // matches resolveBuildIdentity's own option of the same name
logger?: { log: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; // defaults to discarding
}Predicts the version a repo's next release would be, from the commits since its last tagged release -- without running semantic-release's own top-level orchestrator. That orchestrator verifies push access to the remote as part of resolving branches before analysis ever runs, on every call, dry-run or not -- slow, and needing credentials a prediction has no real reason to hold. predictNextVersion instead calls @semantic-release/commit-analyzer's own analyzeCommits hook directly: no network, no registry lookups, no push check.
releaseRules is your own repo's commit-type-to-release-level convention (the same shape @semantic-release/commit-analyzer's own releaseRules option takes) -- this package hardcodes none of its own.
The last release is the one findLastRelease finds from the tags, and only the commits since its tag are analysed. It needs the full history and tags: run it on a checkout made with fetch-depth: 0.
Returns undefined when there is genuinely no predicted release (no commits since the last release tag, or none of them release-worthy): the same "nothing to report" case resolvePredictedIdentity already treats as valid, not an error. Throws for a genuine setup problem instead of predicting from a guess, exactly as findLastRelease does: a shallow clone, no release tag reachable from HEAD, repoRoot isn't a git repository, or the tag template is malformed. A wrong base version would predict a release that may already exist as a tag, so there is no fallback, not even a first-release 1.0.0.
analyzeCommits is supplied by the caller rather than imported by this package: @semantic-release/commit-analyzer ships no type declarations and analyzeCommits isn't part of its documented public API, so loading it safely is a concern specific to your own toolchain. predictNextVersion itself stays a pure function of its arguments -- easy to test with a fake analyzer.
function loadCommitAnalyzer(): Promise<AnalyzeCommits>;The tested, correct way to obtain a real analyzeCommits for predictNextVersion above -- the one place in this package that actually loads @semantic-release/commit-analyzer. @semantic-release/commit-analyzer is an optional peer dependency: install it yourself if you use this function (or predictNextVersion); every other export in this package works without it. Throws a clear error, rather than a bare "Cannot find module", when it isn't installed or doesn't export analyzeCommits.
import { resolveBuildIdentity, resolvePredictedIdentity, predictNextVersion, loadCommitAnalyzer } from '@exadev/build-identity';
const releaseRules = [
{ breaking: true, release: 'major' },
{ type: 'feat', release: 'minor' },
{ type: 'fix', release: 'patch' },
];
const build = resolveBuildIdentity(process.cwd(), 'exadev/build-identity');
const predictedVersion = await predictNextVersion(process.cwd(), releaseRules, await loadCommitAnalyzer());
const identity = resolvePredictedIdentity(build, predictedVersion);interface LastRelease { version: string; tagName: string }
function findLastRelease(repoRoot: string, tagTemplate?: string): LastRelease; // tagTemplate defaults to DEFAULT_TAG_TEMPLATE, 'v{version}'The last released version and the tag marking it, found the way semantic-release's own core finds it: the highest major.minor.patch version among the tags reachable from HEAD (git tag --merged HEAD) that fit tagTemplate. A prerelease tag, a tag in another format and a tag on a commit HEAD does not contain (a release cut from another branch) are never the last release. predictNextVersion starts from this.
Reach for it directly when you want the last released semantic version regardless of whether this checkout happens to be sitting on its tag: an OpenAPI document's info.version, a user agent string, anything wanting a stable semver rather than an honest answer about what is deployed.
It needs the full history and tags (actions/checkout with fetch-depth: 0), and throws rather than inventing a version when it cannot prove one, saying what to do:
- A shallow clone, even one with some tags: a higher release tag could sit beyond the cut. Fetch full history and tags first (
git fetch --unshallow --tags). - No release tag reachable from
HEAD: the clone was made without its tags (git fetch --tags), the template does not match how the repository tags, or it has never released. repoRootisn't a git repository, or the template is malformed.
import { findLastRelease, resolveBuildIdentity } from '@exadev/build-identity';
const appVersion = findLastRelease(process.cwd()).version; // "1.4.0", tagged or not
const build = resolveBuildIdentity(process.cwd(), 'exadev/example'); // "1.4.0" or "a1b2c3d"The same three functions, wired together, as a command. It exists for build and deploy steps that are a shell invocation rather than a JavaScript config file, so nothing in them can import this package at all -- wrangler deploy --var RELEASE_VERSION:... being the case it was built for. A step that is a JavaScript config file (next.config.ts, vite.config.ts) should import the functions directly instead; see Framework-agnosticism below.
pnpm add -D @exadev/build-identity
pnpm exec build-identity --repo exadev/build-identityUsage: build-identity [options]
Options:
-V, --version output the version number
--repo <owner/repo> GitHub slug used to build the release and commit URLs
--root <directory> git working tree to inspect (default: the current working directory)
--tag-name <template> tag name marking a release, with {version} standing in for the version (default: v{version})
--predict also predict the version this commit's next release would be
--release-rules <json> commit-analyzer release rules for --predict, as a JSON array
--release-rules-file <path> file holding the same JSON array as --release-rules
--format <format> output format: json or env (default: "json")
--prefix <prefix> prepended to each variable name in --format env (default: "BUILD_")
--verbose send commit-analyzer's own per-commit narration to stderr
-h, --help display help for command
--repo is the only required flag, and the command's whole output is a single DisplayIdentity on stdout:
$ build-identity --repo exadev/build-identity
{
"kind": "commit",
"version": "a1b2c3d",
"url": "https://github.com/exadev/build-identity/commit/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"date": "2026-09-08T09:12:03+01:00",
"commit": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
}Those are exactly the five fields DisplayIdentity declares, under exactly its own names -- there is no CLI-specific shape and no consumer-specific naming. In particular there is no separate predicted boolean: kind is already "predicted" in precisely that case, and a second field asserting the same fact would just be one more thing that could disagree with the first.
--predict adds the predictNextVersion step, turning an unreleased build's short commit hash into the version that commit would release as. It is opt-in rather than automatic because it costs something real: it needs @semantic-release/commit-analyzer (this package's optional peer dependency) to be installed, and it reads every commit since the last release tag, so it needs the full history and tags (actions/checkout with fetch-depth: 0) and fails in a shallow clone.
Release rules are yours, not this package's -- your repo's commit-type-to-release-level convention is real configuration, and nothing here invents a default for it. Pass them inline, or from a file when quoting a full rule set through YAML and a shell gets unreadable:
build-identity --repo exadev/build-identity --predict \
--release-rules '[{"breaking":true,"release":"major"},{"type":"feat","release":"minor"},{"type":"fix","release":"patch"}]'
build-identity --repo exadev/build-identity --predict --release-rules-file release-rules.jsonA confirmed release still wins outright: on a commit an actual release tag points at, --predict changes nothing at all.
$ build-identity --repo exadev/build-identity --format env --prefix RELEASE_
RELEASE_KIND=commit
RELEASE_VERSION=a1b2c3d
RELEASE_URL=https://github.com/exadev/build-identity/commit/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
RELEASE_DATE=2026-09-08T09:12:03+01:00
RELEASE_COMMIT=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2The variable names are the same generic field names, upper-cased, behind a prefix of your choosing. This format is deliberately additional surface rather than "emit JSON and document a jq one-liner": appending to $GITHUB_ENV is the realistic use for both of the cases that motivated this CLI, and the one-liner alternative would put a jq dependency and a quoting-sensitive shell expression into every consumer's workflow to produce output this package can simply print. A value containing a line break is refused outright rather than emitted, since it would silently swallow or inject $GITHUB_ENV entries.
- name: Resolve the build's identity
run: pnpm exec build-identity --repo my-org/my-repo --predict --release-rules-file release-rules.json --format env --prefix RELEASE_ >> "$GITHUB_ENV"
- name: Deploy
run: wrangler deploy --var RELEASE_VERSION:$RELEASE_VERSION --var RELEASE_COMMIT:$RELEASE_COMMITWhere a repo's own variable names differ from the generic ones, map them in that repo's own workflow rather than expecting this package to know them -- --prefix covers most of it, and jq covers the rest:
echo "MY_OWN_VERSION_NAME=$(build-identity --repo my-org/my-repo | jq -r .version)" >> "$GITHUB_ENV"--verbose routes commit-analyzer's own per-commit narration to stderr, never stdout, so it is always safe to pipe or capture stdout while debugging why a prediction came out as it did.
The CLI is the one part of this package with a runtime dependency (commander, itself dependency-free). It is bundled into dist/cli.js alone: importing the library never loads it. It stays on commander@14 deliberately, rather than the newest major: commander@15 raises its own floor to Node 22.12, which would contradict this package's declared engines of Node 20 and upwards. Raise it alongside that floor, not before it.
This package does pure Node.js filesystem and git access only -- no bundler, framework, or UI assumptions. Wiring its result into a running app is a build-time concern for whichever bundler that app already uses, done in that bundler's own config file, not in this package.
A config file that is itself JavaScript does not need the CLI -- it can import the functions directly, which is both simpler and better typed than shelling out and parsing JSON back. Reach for build-identity (above) only where there is genuinely nothing to import from: a wrangler deploy --var ... line, a docker build --build-arg ... line, a plain sh deploy script.
// next.config.ts
import { resolveBuildIdentity } from '@exadev/build-identity';
import type { NextConfig } from 'next';
const buildIdentity = resolveBuildIdentity(process.cwd(), 'exadev/example');
const config: NextConfig = {
env: {
NEXT_PUBLIC_BUILD_KIND: buildIdentity.kind,
NEXT_PUBLIC_BUILD_VERSION: buildIdentity.version,
NEXT_PUBLIC_BUILD_URL: buildIdentity.url,
},
};
export default config;// vite.config.ts
import { resolveBuildIdentity } from '@exadev/build-identity';
import { defineConfig } from 'vite';
const buildIdentity = resolveBuildIdentity(process.cwd(), 'exadev/example');
export default defineConfig({
define: {
__BUILD_KIND__: JSON.stringify(buildIdentity.kind),
__BUILD_VERSION__: JSON.stringify(buildIdentity.version),
__BUILD_URL__: JSON.stringify(buildIdentity.url),
},
});Either way, the values are inlined at build time -- the running app never shells out to git itself, and resolveBuildIdentity/resolvePredictedIdentity/predictNextVersion/loadCommitAnalyzer never ship as part of the app's own bundle.
British English throughout. Conventional commits, enforced by commitlint (commitlint.config.ts) and released automatically by semantic-release (release.config.ts) on every push to main. Strict TypeScript, no any, no type assertions -- see eslint.config.ts, which lints this package with @exadev/eslint-config.
Releases are handled entirely by .github/workflows/ci.yml's release job: semantic-release analyses commits since the last tag, bumps the version, publishes to npmjs.org over OIDC trusted publishing (no stored NPM_TOKEN), and creates the tag and GitHub Release. See that workflow for the exact steps, and CONTRIBUTING.md for what a brand-new trusted-publish package needed once, by hand, before that automation could run unattended.