@exadev/build-identity 0.0.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joseph Mearman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # @exadev/build-identity
2
+
3
+ [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/build-identity) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/@exadev/build-identity) [![Release](https://img.shields.io/github/v/release/ExaDev/build-identity)](https://github.com/ExaDev/build-identity/releases/latest) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/build-identity/ci.yml?branch=main)](https://github.com/ExaDev/build-identity/actions)
4
+
5
+ > 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.
6
+
7
+ ## Why
8
+
9
+ 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.
10
+
11
+ ## Getting started
12
+
13
+ ```sh
14
+ pnpm add -D @exadev/build-identity
15
+ ```
16
+
17
+ ```ts
18
+ import { resolveBuildIdentity } from '@exadev/build-identity';
19
+
20
+ const identity = resolveBuildIdentity(process.cwd(), 'exadev/build-identity');
21
+ // { 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' }
22
+ // or, on any commit that isn't itself tagged:
23
+ // { kind: 'commit', version: 'a1b2c3d', url: 'https://github.com/exadev/build-identity/commit/a1b2c3d4e5f6...', date: '2026-09-08T09:12:03+01:00' }
24
+ ```
25
+
26
+ ## `resolveBuildIdentity(repoRoot, repoSlug, options?)`
27
+
28
+ ```ts
29
+ type BuildIdentity =
30
+ | { kind: 'release'; version: string; url: string; date: string }
31
+ | { kind: 'commit'; version: string; url: string; date: string };
32
+
33
+ function resolveBuildIdentity(repoRoot: string, repoSlug: string, options?: ResolveBuildIdentityOptions): BuildIdentity;
34
+
35
+ interface ResolveBuildIdentityOptions {
36
+ tagName?: (version: string) => string; // defaults to (version) => `v${version}`
37
+ }
38
+ ```
39
+
40
+ - **`repoRoot`** -- path to the git working tree to inspect. Must contain `package.json` at its root; that file's `"version"` field is the released version this function checks for.
41
+ - **`repoSlug`** -- the GitHub `"owner/repo"` slug used to build both the release and commit URLs (e.g. `"exadev/build-identity"`).
42
+ - **`options.tagName`** -- turns the version into the tag name expected to mark its release. The default assumes the common `v<version>` convention (`"1.4.0"` -> `"v1.4.0"`); pass your own function for a repo that tags differently (a bare version, a package-scoped prefix in a monorepo, and so on).
43
+
44
+ **The one property this function exists to guarantee:** it returns `kind: 'release'` if, and only if, a tag named `tagName(version)` provably points at the exact commit `HEAD` is on right now -- checked live against git (`git tag --list <tag> --points-at HEAD`), never inferred from `package.json`, an environment variable, or any other proxy that could be true before the tag actually exists. 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).
45
+
46
+ `resolveBuildIdentity` throws rather than defaulting whenever it can't establish a real identity -- `repoRoot` isn't a git repository, `package.json` is missing or has no non-empty string `"version"` field, 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.
47
+
48
+ ## `resolvePredictedIdentity(build, predictedVersion)`
49
+
50
+ ```ts
51
+ function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: string | undefined): BuildIdentity & { predicted?: boolean };
52
+ ```
53
+
54
+ 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 running a commit-analyzer-style tool such as `semantic-release`'s own dry-run mode) without ever upgrading the URL to a release page that doesn't exist yet:
55
+
56
+ - If `build.kind === 'release'`, it is returned **completely unchanged** -- a confirmed release always wins outright, prediction or not.
57
+ - Otherwise, when `predictedVersion` is a real, non-empty string, the result's `version` becomes that (trimmed) prediction, `predicted` becomes `true`, and `url`/`date` are copied verbatim from `build` -- the link still points at the real commit.
58
+ - With no usable prediction (`undefined`, empty, or whitespace-only), `build` is returned unchanged.
59
+
60
+ 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.
61
+
62
+ ```ts
63
+ import { resolveBuildIdentity, resolvePredictedIdentity } from '@exadev/build-identity';
64
+
65
+ const build = resolveBuildIdentity(process.cwd(), 'exadev/build-identity');
66
+ const predictedVersion = await computeNextVersionSomehow(); // out of scope for this package
67
+ const identity = resolvePredictedIdentity(build, predictedVersion);
68
+ ```
69
+
70
+ ## Framework-agnosticism
71
+
72
+ 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.
73
+
74
+ ### Next.js
75
+
76
+ ```ts
77
+ // next.config.ts
78
+ import { resolveBuildIdentity } from '@exadev/build-identity';
79
+ import type { NextConfig } from 'next';
80
+
81
+ const buildIdentity = resolveBuildIdentity(process.cwd(), 'exadev/example');
82
+
83
+ const config: NextConfig = {
84
+ env: {
85
+ NEXT_PUBLIC_BUILD_KIND: buildIdentity.kind,
86
+ NEXT_PUBLIC_BUILD_VERSION: buildIdentity.version,
87
+ NEXT_PUBLIC_BUILD_URL: buildIdentity.url,
88
+ },
89
+ };
90
+
91
+ export default config;
92
+ ```
93
+
94
+ ### Vite
95
+
96
+ ```ts
97
+ // vite.config.ts
98
+ import { resolveBuildIdentity } from '@exadev/build-identity';
99
+ import { defineConfig } from 'vite';
100
+
101
+ const buildIdentity = resolveBuildIdentity(process.cwd(), 'exadev/example');
102
+
103
+ export default defineConfig({
104
+ define: {
105
+ __BUILD_KIND__: JSON.stringify(buildIdentity.kind),
106
+ __BUILD_VERSION__: JSON.stringify(buildIdentity.version),
107
+ __BUILD_URL__: JSON.stringify(buildIdentity.url),
108
+ },
109
+ });
110
+ ```
111
+
112
+ Either way, the values are inlined at build time -- the running app never shells out to git itself, and `resolveBuildIdentity`/`resolvePredictedIdentity` never ship as part of the app's own bundle.
113
+
114
+ ## Conventions
115
+
116
+ 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`.
117
+
118
+ ## Publishing
119
+
120
+ 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.
package/dist/index.cjs ADDED
@@ -0,0 +1,113 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let node_fs = require("node:fs");
3
+ let node_path = require("node:path");
4
+ let node_child_process = require("node:child_process");
5
+ //#region src/git.ts
6
+ function runGit(repoRoot, args) {
7
+ return (0, node_child_process.execFileSync)("git", args, {
8
+ cwd: repoRoot,
9
+ encoding: "utf8"
10
+ }).trim();
11
+ }
12
+ /**
13
+ * Reads HEAD's full hash, short hash, and committer date from the given git working tree. Throws whatever `git` itself throws (e.g. `repoRoot` is not a git repository, or has no commits) -- there is no sensible default identity for a build that isn't sitting in real git history.
14
+ */
15
+ function getHeadCommit(repoRoot) {
16
+ return {
17
+ fullSha: runGit(repoRoot, ["rev-parse", "HEAD"]),
18
+ shortSha: runGit(repoRoot, [
19
+ "rev-parse",
20
+ "--short",
21
+ "HEAD"
22
+ ]),
23
+ date: runGit(repoRoot, [
24
+ "log",
25
+ "-1",
26
+ "--format=%cI",
27
+ "HEAD"
28
+ ])
29
+ };
30
+ }
31
+ /**
32
+ * True only if `tagName` both exists and points at the exact commit HEAD is on right now. Uses `git tag --list <tag> --points-at HEAD`, which returns `tagName` itself when both hold and nothing otherwise -- there is no separate "tag exists but points elsewhere" case to confuse this with.
33
+ */
34
+ function tagPointsAtHead(repoRoot, tagName) {
35
+ return runGit(repoRoot, [
36
+ "tag",
37
+ "--list",
38
+ tagName,
39
+ "--points-at",
40
+ "HEAD"
41
+ ]).split("\n").map((line) => line.trim()).includes(tagName);
42
+ }
43
+ //#endregion
44
+ //#region src/resolve-build-identity.ts
45
+ const REPO_SLUG_PATTERN = /^[^/\s]+\/[^/\s]+$/;
46
+ function defaultTagName(version) {
47
+ return `v${version}`;
48
+ }
49
+ function isPackageJsonWithVersion(value) {
50
+ if (typeof value !== "object" || value === null) return false;
51
+ if (!("version" in value)) return false;
52
+ return typeof value.version === "string" && value.version.length > 0;
53
+ }
54
+ function readPackageVersion(repoRoot) {
55
+ const packageJsonPath = (0, node_path.join)(repoRoot, "package.json");
56
+ const raw = (0, node_fs.readFileSync)(packageJsonPath, "utf8");
57
+ const parsed = JSON.parse(raw);
58
+ if (!isPackageJsonWithVersion(parsed)) throw new Error(`${packageJsonPath} must contain a non-empty string "version" field`);
59
+ return parsed.version;
60
+ }
61
+ function assertRepoSlug(repoSlug) {
62
+ if (!REPO_SLUG_PATTERN.test(repoSlug)) throw new Error(`repoSlug must be a GitHub "owner/repo" slug, got: ${JSON.stringify(repoSlug)}`);
63
+ }
64
+ /**
65
+ * Resolves what the current build actually is: a real, already-tagged release, or a build made from a commit that hasn't been released yet.
66
+ *
67
+ * This is the one property the whole package exists to guarantee: `kind: 'release'` is returned if and only if a tag named `tagName(version)` (default `v${version}`) provably points at the exact commit HEAD is on right now, checked live against git -- never inferred from `package.json` alone, a CI environment variable, or any other proxy that could be true before the tag actually exists. A caller can render `url` as a link the moment it gets a result back, in either case, without first checking whether that link is real.
68
+ *
69
+ * @param repoRoot Path to the git working tree to inspect (must contain `package.json` at its root).
70
+ * @param repoSlug The GitHub `owner/repo` slug used to build both release and commit URLs.
71
+ */
72
+ function resolveBuildIdentity(repoRoot, repoSlug, options = {}) {
73
+ assertRepoSlug(repoSlug);
74
+ const version = readPackageVersion(repoRoot);
75
+ const tagName = (options.tagName ?? defaultTagName)(version);
76
+ const head = getHeadCommit(repoRoot);
77
+ if (tagPointsAtHead(repoRoot, tagName)) return {
78
+ kind: "release",
79
+ version,
80
+ url: `https://github.com/${repoSlug}/releases/tag/${tagName}`,
81
+ date: head.date
82
+ };
83
+ return {
84
+ kind: "commit",
85
+ version: head.shortSha,
86
+ url: `https://github.com/${repoSlug}/commit/${head.fullSha}`,
87
+ date: head.date
88
+ };
89
+ }
90
+ //#endregion
91
+ //#region src/resolve-predicted-identity.ts
92
+ /**
93
+ * Upgrades an unreleased build's displayed version to a predicted one (e.g. the version a commit-analyzer-style tool computes the next release will be), without ever upgrading its URL.
94
+ *
95
+ * A confirmed release always wins outright: if `build.kind === 'release'`, this returns `build` completely unchanged, prediction or not -- a real, already-tagged release can never be second-guessed by a prediction. Otherwise, when `predictedVersion` is a real, non-empty string, the returned `version` becomes that predicted label, `predicted` becomes `true`, and `url` is copied verbatim from `build` -- it still points at the real commit, never at a release page that doesn't exist yet. With no usable prediction, `build` is returned unchanged.
96
+ *
97
+ * This function does no git or filesystem access of its own: predicting the next version (e.g. by running a commit-analyzer) is entirely the caller's job. It exists to keep that prediction, and the guarantee `resolveBuildIdentity` makes about `url`, cleanly separate.
98
+ */
99
+ function resolvePredictedIdentity(build, predictedVersion) {
100
+ if (build.kind === "release") return build;
101
+ const trimmedPrediction = predictedVersion?.trim();
102
+ if (trimmedPrediction === void 0 || trimmedPrediction.length === 0) return build;
103
+ return {
104
+ kind: "commit",
105
+ version: trimmedPrediction,
106
+ url: build.url,
107
+ date: build.date,
108
+ predicted: true
109
+ };
110
+ }
111
+ //#endregion
112
+ exports.resolveBuildIdentity = resolveBuildIdentity;
113
+ exports.resolvePredictedIdentity = resolvePredictedIdentity;
@@ -0,0 +1,54 @@
1
+ //#region src/types.d.ts
2
+ /**
3
+ * What a build actually is, as far as git can prove it right now.
4
+ *
5
+ * A build is `'release'` only when a real, existing tag points at the exact commit being built -- never when a release is merely expected, scheduled, or about to happen. Every other build, including one sitting directly on top of the commit a release will eventually tag, is `'commit'`.
6
+ */
7
+ type BuildIdentity = {
8
+ readonly kind: 'release';
9
+ /** The released version, read from `package.json` (e.g. `"1.4.0"`). */
10
+ readonly version: string;
11
+ /** The tag/release page for this exact version. */
12
+ readonly url: string;
13
+ /** ISO 8601 date of the commit this release was tagged at. */
14
+ readonly date: string;
15
+ } | {
16
+ readonly kind: 'commit';
17
+ /** The short commit hash this build was made from -- there is no released version to show yet. */
18
+ readonly version: string;
19
+ /** The permalink to this exact commit. */
20
+ readonly url: string;
21
+ /** ISO 8601 date of this commit. */
22
+ readonly date: string;
23
+ };
24
+ interface ResolveBuildIdentityOptions {
25
+ /**
26
+ * Turns the version read from `package.json` into the git tag name that is expected to mark its release. Defaults to a `v` prefix (`"1.4.0"` -> `"v1.4.0"`), the convention this package's own release tooling and GitHub's own release UI both use -- override it for a repo that tags releases differently (a bare version, a package-scoped prefix in a monorepo, and so on).
27
+ */
28
+ readonly tagName?: (version: string) => string;
29
+ }
30
+ //#endregion
31
+ //#region src/resolve-build-identity.d.ts
32
+ /**
33
+ * Resolves what the current build actually is: a real, already-tagged release, or a build made from a commit that hasn't been released yet.
34
+ *
35
+ * This is the one property the whole package exists to guarantee: `kind: 'release'` is returned if and only if a tag named `tagName(version)` (default `v${version}`) provably points at the exact commit HEAD is on right now, checked live against git -- never inferred from `package.json` alone, a CI environment variable, or any other proxy that could be true before the tag actually exists. A caller can render `url` as a link the moment it gets a result back, in either case, without first checking whether that link is real.
36
+ *
37
+ * @param repoRoot Path to the git working tree to inspect (must contain `package.json` at its root).
38
+ * @param repoSlug The GitHub `owner/repo` slug used to build both release and commit URLs.
39
+ */
40
+ declare function resolveBuildIdentity(repoRoot: string, repoSlug: string, options?: ResolveBuildIdentityOptions): BuildIdentity;
41
+ //#endregion
42
+ //#region src/resolve-predicted-identity.d.ts
43
+ /**
44
+ * Upgrades an unreleased build's displayed version to a predicted one (e.g. the version a commit-analyzer-style tool computes the next release will be), without ever upgrading its URL.
45
+ *
46
+ * A confirmed release always wins outright: if `build.kind === 'release'`, this returns `build` completely unchanged, prediction or not -- a real, already-tagged release can never be second-guessed by a prediction. Otherwise, when `predictedVersion` is a real, non-empty string, the returned `version` becomes that predicted label, `predicted` becomes `true`, and `url` is copied verbatim from `build` -- it still points at the real commit, never at a release page that doesn't exist yet. With no usable prediction, `build` is returned unchanged.
47
+ *
48
+ * This function does no git or filesystem access of its own: predicting the next version (e.g. by running a commit-analyzer) is entirely the caller's job. It exists to keep that prediction, and the guarantee `resolveBuildIdentity` makes about `url`, cleanly separate.
49
+ */
50
+ declare function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: string | undefined): BuildIdentity & {
51
+ readonly predicted?: boolean;
52
+ };
53
+ //#endregion
54
+ export { type BuildIdentity, type ResolveBuildIdentityOptions, resolveBuildIdentity, resolvePredictedIdentity };
@@ -0,0 +1,54 @@
1
+ //#region src/types.d.ts
2
+ /**
3
+ * What a build actually is, as far as git can prove it right now.
4
+ *
5
+ * A build is `'release'` only when a real, existing tag points at the exact commit being built -- never when a release is merely expected, scheduled, or about to happen. Every other build, including one sitting directly on top of the commit a release will eventually tag, is `'commit'`.
6
+ */
7
+ type BuildIdentity = {
8
+ readonly kind: 'release';
9
+ /** The released version, read from `package.json` (e.g. `"1.4.0"`). */
10
+ readonly version: string;
11
+ /** The tag/release page for this exact version. */
12
+ readonly url: string;
13
+ /** ISO 8601 date of the commit this release was tagged at. */
14
+ readonly date: string;
15
+ } | {
16
+ readonly kind: 'commit';
17
+ /** The short commit hash this build was made from -- there is no released version to show yet. */
18
+ readonly version: string;
19
+ /** The permalink to this exact commit. */
20
+ readonly url: string;
21
+ /** ISO 8601 date of this commit. */
22
+ readonly date: string;
23
+ };
24
+ interface ResolveBuildIdentityOptions {
25
+ /**
26
+ * Turns the version read from `package.json` into the git tag name that is expected to mark its release. Defaults to a `v` prefix (`"1.4.0"` -> `"v1.4.0"`), the convention this package's own release tooling and GitHub's own release UI both use -- override it for a repo that tags releases differently (a bare version, a package-scoped prefix in a monorepo, and so on).
27
+ */
28
+ readonly tagName?: (version: string) => string;
29
+ }
30
+ //#endregion
31
+ //#region src/resolve-build-identity.d.ts
32
+ /**
33
+ * Resolves what the current build actually is: a real, already-tagged release, or a build made from a commit that hasn't been released yet.
34
+ *
35
+ * This is the one property the whole package exists to guarantee: `kind: 'release'` is returned if and only if a tag named `tagName(version)` (default `v${version}`) provably points at the exact commit HEAD is on right now, checked live against git -- never inferred from `package.json` alone, a CI environment variable, or any other proxy that could be true before the tag actually exists. A caller can render `url` as a link the moment it gets a result back, in either case, without first checking whether that link is real.
36
+ *
37
+ * @param repoRoot Path to the git working tree to inspect (must contain `package.json` at its root).
38
+ * @param repoSlug The GitHub `owner/repo` slug used to build both release and commit URLs.
39
+ */
40
+ declare function resolveBuildIdentity(repoRoot: string, repoSlug: string, options?: ResolveBuildIdentityOptions): BuildIdentity;
41
+ //#endregion
42
+ //#region src/resolve-predicted-identity.d.ts
43
+ /**
44
+ * Upgrades an unreleased build's displayed version to a predicted one (e.g. the version a commit-analyzer-style tool computes the next release will be), without ever upgrading its URL.
45
+ *
46
+ * A confirmed release always wins outright: if `build.kind === 'release'`, this returns `build` completely unchanged, prediction or not -- a real, already-tagged release can never be second-guessed by a prediction. Otherwise, when `predictedVersion` is a real, non-empty string, the returned `version` becomes that predicted label, `predicted` becomes `true`, and `url` is copied verbatim from `build` -- it still points at the real commit, never at a release page that doesn't exist yet. With no usable prediction, `build` is returned unchanged.
47
+ *
48
+ * This function does no git or filesystem access of its own: predicting the next version (e.g. by running a commit-analyzer) is entirely the caller's job. It exists to keep that prediction, and the guarantee `resolveBuildIdentity` makes about `url`, cleanly separate.
49
+ */
50
+ declare function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: string | undefined): BuildIdentity & {
51
+ readonly predicted?: boolean;
52
+ };
53
+ //#endregion
54
+ export { type BuildIdentity, type ResolveBuildIdentityOptions, resolveBuildIdentity, resolvePredictedIdentity };
package/dist/index.js ADDED
@@ -0,0 +1,111 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { execFileSync } from "node:child_process";
4
+ //#region src/git.ts
5
+ function runGit(repoRoot, args) {
6
+ return execFileSync("git", args, {
7
+ cwd: repoRoot,
8
+ encoding: "utf8"
9
+ }).trim();
10
+ }
11
+ /**
12
+ * Reads HEAD's full hash, short hash, and committer date from the given git working tree. Throws whatever `git` itself throws (e.g. `repoRoot` is not a git repository, or has no commits) -- there is no sensible default identity for a build that isn't sitting in real git history.
13
+ */
14
+ function getHeadCommit(repoRoot) {
15
+ return {
16
+ fullSha: runGit(repoRoot, ["rev-parse", "HEAD"]),
17
+ shortSha: runGit(repoRoot, [
18
+ "rev-parse",
19
+ "--short",
20
+ "HEAD"
21
+ ]),
22
+ date: runGit(repoRoot, [
23
+ "log",
24
+ "-1",
25
+ "--format=%cI",
26
+ "HEAD"
27
+ ])
28
+ };
29
+ }
30
+ /**
31
+ * True only if `tagName` both exists and points at the exact commit HEAD is on right now. Uses `git tag --list <tag> --points-at HEAD`, which returns `tagName` itself when both hold and nothing otherwise -- there is no separate "tag exists but points elsewhere" case to confuse this with.
32
+ */
33
+ function tagPointsAtHead(repoRoot, tagName) {
34
+ return runGit(repoRoot, [
35
+ "tag",
36
+ "--list",
37
+ tagName,
38
+ "--points-at",
39
+ "HEAD"
40
+ ]).split("\n").map((line) => line.trim()).includes(tagName);
41
+ }
42
+ //#endregion
43
+ //#region src/resolve-build-identity.ts
44
+ const REPO_SLUG_PATTERN = /^[^/\s]+\/[^/\s]+$/;
45
+ function defaultTagName(version) {
46
+ return `v${version}`;
47
+ }
48
+ function isPackageJsonWithVersion(value) {
49
+ if (typeof value !== "object" || value === null) return false;
50
+ if (!("version" in value)) return false;
51
+ return typeof value.version === "string" && value.version.length > 0;
52
+ }
53
+ function readPackageVersion(repoRoot) {
54
+ const packageJsonPath = join(repoRoot, "package.json");
55
+ const raw = readFileSync(packageJsonPath, "utf8");
56
+ const parsed = JSON.parse(raw);
57
+ if (!isPackageJsonWithVersion(parsed)) throw new Error(`${packageJsonPath} must contain a non-empty string "version" field`);
58
+ return parsed.version;
59
+ }
60
+ function assertRepoSlug(repoSlug) {
61
+ if (!REPO_SLUG_PATTERN.test(repoSlug)) throw new Error(`repoSlug must be a GitHub "owner/repo" slug, got: ${JSON.stringify(repoSlug)}`);
62
+ }
63
+ /**
64
+ * Resolves what the current build actually is: a real, already-tagged release, or a build made from a commit that hasn't been released yet.
65
+ *
66
+ * This is the one property the whole package exists to guarantee: `kind: 'release'` is returned if and only if a tag named `tagName(version)` (default `v${version}`) provably points at the exact commit HEAD is on right now, checked live against git -- never inferred from `package.json` alone, a CI environment variable, or any other proxy that could be true before the tag actually exists. A caller can render `url` as a link the moment it gets a result back, in either case, without first checking whether that link is real.
67
+ *
68
+ * @param repoRoot Path to the git working tree to inspect (must contain `package.json` at its root).
69
+ * @param repoSlug The GitHub `owner/repo` slug used to build both release and commit URLs.
70
+ */
71
+ function resolveBuildIdentity(repoRoot, repoSlug, options = {}) {
72
+ assertRepoSlug(repoSlug);
73
+ const version = readPackageVersion(repoRoot);
74
+ const tagName = (options.tagName ?? defaultTagName)(version);
75
+ const head = getHeadCommit(repoRoot);
76
+ if (tagPointsAtHead(repoRoot, tagName)) return {
77
+ kind: "release",
78
+ version,
79
+ url: `https://github.com/${repoSlug}/releases/tag/${tagName}`,
80
+ date: head.date
81
+ };
82
+ return {
83
+ kind: "commit",
84
+ version: head.shortSha,
85
+ url: `https://github.com/${repoSlug}/commit/${head.fullSha}`,
86
+ date: head.date
87
+ };
88
+ }
89
+ //#endregion
90
+ //#region src/resolve-predicted-identity.ts
91
+ /**
92
+ * Upgrades an unreleased build's displayed version to a predicted one (e.g. the version a commit-analyzer-style tool computes the next release will be), without ever upgrading its URL.
93
+ *
94
+ * A confirmed release always wins outright: if `build.kind === 'release'`, this returns `build` completely unchanged, prediction or not -- a real, already-tagged release can never be second-guessed by a prediction. Otherwise, when `predictedVersion` is a real, non-empty string, the returned `version` becomes that predicted label, `predicted` becomes `true`, and `url` is copied verbatim from `build` -- it still points at the real commit, never at a release page that doesn't exist yet. With no usable prediction, `build` is returned unchanged.
95
+ *
96
+ * This function does no git or filesystem access of its own: predicting the next version (e.g. by running a commit-analyzer) is entirely the caller's job. It exists to keep that prediction, and the guarantee `resolveBuildIdentity` makes about `url`, cleanly separate.
97
+ */
98
+ function resolvePredictedIdentity(build, predictedVersion) {
99
+ if (build.kind === "release") return build;
100
+ const trimmedPrediction = predictedVersion?.trim();
101
+ if (trimmedPrediction === void 0 || trimmedPrediction.length === 0) return build;
102
+ return {
103
+ kind: "commit",
104
+ version: trimmedPrediction,
105
+ url: build.url,
106
+ date: build.date,
107
+ predicted: true
108
+ };
109
+ }
110
+ //#endregion
111
+ export { resolveBuildIdentity, resolvePredictedIdentity };
package/package.json CHANGED
@@ -1,5 +1,75 @@
1
1
  {
2
2
  "name": "@exadev/build-identity",
3
- "version": "0.0.0",
4
- "private": false
3
+ "version": "1.0.0",
4
+ "description": "Resolve a build's true identity (a real release tag, or the commit it was built from) from git state, with an optional predicted-version display layer",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "license": "MIT",
8
+ "packageManager": "pnpm@11.6.0",
9
+ "engines": {
10
+ "node": ">=20"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "main": "./dist/index.cjs",
16
+ "module": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": {
21
+ "import": "./dist/index.d.ts",
22
+ "require": "./dist/index.d.cts"
23
+ },
24
+ "import": "./dist/index.js",
25
+ "require": "./dist/index.cjs"
26
+ }
27
+ },
28
+ "files": [
29
+ "dist"
30
+ ],
31
+ "keywords": [
32
+ "build-info",
33
+ "version",
34
+ "git",
35
+ "release",
36
+ "commit"
37
+ ],
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/ExaDev/build-identity.git"
41
+ },
42
+ "devDependencies": {
43
+ "@arethetypeswrong/cli": "0.18.5",
44
+ "@commitlint/cli": "21.2.1",
45
+ "@commitlint/config-conventional": "21.2.0",
46
+ "@exadev/eslint-config": "^2.10.4",
47
+ "@semantic-release/changelog": "7.0.0",
48
+ "@semantic-release/git": "11.0.1",
49
+ "@types/node": "24.13.3",
50
+ "@vitest/coverage-v8": "4.1.10",
51
+ "eslint": "10.8.0",
52
+ "husky": "9.1.7",
53
+ "lint-staged": "17.3.0",
54
+ "publint": "0.3.22",
55
+ "semantic-release": "25.0.8",
56
+ "tsdown": "0.22.14",
57
+ "turbo": "2.10.8",
58
+ "typescript": "6.0.3",
59
+ "typescript-eslint": "8.67.0",
60
+ "vitest": "4.1.10"
61
+ },
62
+ "scripts": {
63
+ "build": "turbo run _build",
64
+ "_build": "tsdown",
65
+ "lint": "turbo run _lint",
66
+ "_lint": "eslint . --fix --cache --max-warnings 0",
67
+ "typecheck": "turbo run _typecheck",
68
+ "_typecheck": "tsc -p tsconfig.json",
69
+ "test": "turbo run _test",
70
+ "_test": "vitest run",
71
+ "prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && tsdown && publint && attw --pack",
72
+ "prepare": "husky",
73
+ "release": "semantic-release"
74
+ }
5
75
  }