@exadev/build-identity 1.1.0 → 2.1.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/README.md CHANGED
@@ -23,6 +23,8 @@ const identity = resolveBuildIdentity(process.cwd(), 'exadev/build-identity');
23
23
  // { kind: 'commit', version: 'a1b2c3d', url: 'https://github.com/exadev/build-identity/commit/a1b2c3d4e5f6...', date: '2026-09-08T09:12:03+01:00', commit: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2' }
24
24
  ```
25
25
 
26
+ The same thing is available as a command, for build and deploy steps that cannot import anything -- see [`build-identity` (CLI)](#build-identity-cli).
27
+
26
28
  ## `resolveBuildIdentity(repoRoot, repoSlug, options?)`
27
29
 
28
30
  ```ts
@@ -50,29 +52,165 @@ interface ResolveBuildIdentityOptions {
50
52
  ## `resolvePredictedIdentity(build, predictedVersion)`
51
53
 
52
54
  ```ts
53
- function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: string | undefined): BuildIdentity & { predicted?: boolean };
55
+ type DisplayIdentity = { kind: 'release' | 'predicted' | 'commit'; version: string; url: string; date: string; commit: string };
56
+
57
+ function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: string | undefined): DisplayIdentity;
54
58
  ```
55
59
 
56
- 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:
60
+ 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:
57
61
 
58
62
  - If `build.kind === 'release'`, it is returned **completely unchanged** -- a confirmed release always wins outright, prediction or not.
59
- - 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.
63
+ - Otherwise, when `predictedVersion` is a real, non-empty string, the result is `kind: 'predicted'` with `version` set to that (trimmed) prediction -- `url`/`date`/`commit` are copied verbatim from `build`, still describing the real commit.
60
64
  - With no usable prediction (`undefined`, empty, or whitespace-only), `build` is returned unchanged.
61
65
 
62
66
  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.
63
67
 
64
68
  ```ts
65
- import { resolveBuildIdentity, resolvePredictedIdentity } from '@exadev/build-identity';
69
+ import { resolveBuildIdentity, resolvePredictedIdentity, predictNextVersion, loadCommitAnalyzer } from '@exadev/build-identity';
70
+
71
+ const build = resolveBuildIdentity(process.cwd(), 'exadev/build-identity');
72
+ const predictedVersion = await predictNextVersion(process.cwd(), releaseRules, await loadCommitAnalyzer());
73
+ const identity = resolvePredictedIdentity(build, predictedVersion);
74
+ ```
75
+
76
+ ## `predictNextVersion(repoRoot, releaseRules, analyzeCommits, options?)`
77
+
78
+ ```ts
79
+ type ReleaseLevel = 'major' | 'minor' | 'patch';
80
+ type ReleaseRule = { type: string; release: ReleaseLevel | false } | { breaking: true; release: ReleaseLevel | false };
81
+ type AnalyzeCommits = (pluginConfig: unknown, context: unknown) => Promise<unknown>;
82
+
83
+ function predictNextVersion(repoRoot: string, releaseRules: readonly ReleaseRule[], analyzeCommits: AnalyzeCommits, options?: PredictNextVersionOptions): Promise<string | undefined>;
84
+
85
+ interface PredictNextVersionOptions {
86
+ tagName?: (version: string) => string; // matches resolveBuildIdentity's own option of the same name
87
+ logger?: { log: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; // defaults to discarding
88
+ }
89
+ ```
90
+
91
+ 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.
92
+
93
+ `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.
94
+
95
+ Returns `undefined` when there is genuinely no predicted release -- no commits since the last tag, or none of them are release-worthy -- the same "nothing to report" case `resolvePredictedIdentity` already treats as valid, not an error. Throws for a genuine setup problem instead of defaulting: `repoRoot` isn't a git repository, or `package.json` has no usable version.
96
+
97
+ **`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.
98
+
99
+ ## `loadCommitAnalyzer()`
100
+
101
+ ```ts
102
+ function loadCommitAnalyzer(): Promise<AnalyzeCommits>;
103
+ ```
104
+
105
+ 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`.
106
+
107
+ ```ts
108
+ import { resolveBuildIdentity, resolvePredictedIdentity, predictNextVersion, loadCommitAnalyzer } from '@exadev/build-identity';
109
+
110
+ const releaseRules = [
111
+ { breaking: true, release: 'major' },
112
+ { type: 'feat', release: 'minor' },
113
+ { type: 'fix', release: 'patch' },
114
+ ];
66
115
 
67
116
  const build = resolveBuildIdentity(process.cwd(), 'exadev/build-identity');
68
- const predictedVersion = await computeNextVersionSomehow(); // out of scope for this package
117
+ const predictedVersion = await predictNextVersion(process.cwd(), releaseRules, await loadCommitAnalyzer());
69
118
  const identity = resolvePredictedIdentity(build, predictedVersion);
70
119
  ```
71
120
 
121
+ ## `build-identity` (CLI)
122
+
123
+ 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](#framework-agnosticism) below.
124
+
125
+ ```sh
126
+ pnpm add -D @exadev/build-identity
127
+ pnpm exec build-identity --repo exadev/build-identity
128
+ ```
129
+
130
+ ```
131
+ Usage: build-identity [options]
132
+
133
+ Options:
134
+ -V, --version output the version number
135
+ --repo <owner/repo> GitHub slug used to build the release and commit URLs
136
+ --root <directory> git working tree to inspect (default: the current working directory)
137
+ --tag-name <template> tag name marking a release, with {version} standing in for the version (default: v{version})
138
+ --predict also predict the version this commit's next release would be
139
+ --release-rules <json> commit-analyzer release rules for --predict, as a JSON array
140
+ --release-rules-file <path> file holding the same JSON array as --release-rules
141
+ --format <format> output format: json or env (default: "json")
142
+ --prefix <prefix> prepended to each variable name in --format env (default: "BUILD_")
143
+ --verbose send commit-analyzer's own per-commit narration to stderr
144
+ -h, --help display help for command
145
+ ```
146
+
147
+ `--repo` is the only required flag, and the command's whole output is a single `DisplayIdentity` on stdout:
148
+
149
+ ```sh
150
+ $ build-identity --repo exadev/build-identity
151
+ {
152
+ "kind": "commit",
153
+ "version": "a1b2c3d",
154
+ "url": "https://github.com/exadev/build-identity/commit/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
155
+ "date": "2026-09-08T09:12:03+01:00",
156
+ "commit": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
157
+ }
158
+ ```
159
+
160
+ 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.
161
+
162
+ ### Prediction
163
+
164
+ `--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 tag.
165
+
166
+ 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:
167
+
168
+ ```sh
169
+ build-identity --repo exadev/build-identity --predict \
170
+ --release-rules '[{"breaking":true,"release":"major"},{"type":"feat","release":"minor"},{"type":"fix","release":"patch"}]'
171
+
172
+ build-identity --repo exadev/build-identity --predict --release-rules-file release-rules.json
173
+ ```
174
+
175
+ A confirmed release still wins outright: on a commit an actual release tag points at, `--predict` changes nothing at all.
176
+
177
+ ### `--format env`
178
+
179
+ ```sh
180
+ $ build-identity --repo exadev/build-identity --format env --prefix RELEASE_
181
+ RELEASE_KIND=commit
182
+ RELEASE_VERSION=a1b2c3d
183
+ RELEASE_URL=https://github.com/exadev/build-identity/commit/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
184
+ RELEASE_DATE=2026-09-08T09:12:03+01:00
185
+ RELEASE_COMMIT=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
186
+ ```
187
+
188
+ The 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.
189
+
190
+ ```yaml
191
+ - name: Resolve the build's identity
192
+ run: pnpm exec build-identity --repo my-org/my-repo --predict --release-rules-file release-rules.json --format env --prefix RELEASE_ >> "$GITHUB_ENV"
193
+
194
+ - name: Deploy
195
+ run: wrangler deploy --var RELEASE_VERSION:$RELEASE_VERSION --var RELEASE_COMMIT:$RELEASE_COMMIT
196
+ ```
197
+
198
+ Where 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:
199
+
200
+ ```sh
201
+ echo "MY_OWN_VERSION_NAME=$(build-identity --repo my-org/my-repo | jq -r .version)" >> "$GITHUB_ENV"
202
+ ```
203
+
204
+ `--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.
205
+
206
+ 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.
207
+
72
208
  ## Framework-agnosticism
73
209
 
74
210
  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.
75
211
 
212
+ **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.
213
+
76
214
  ### Next.js
77
215
 
78
216
  ```ts
@@ -111,7 +249,7 @@ export default defineConfig({
111
249
  });
112
250
  ```
113
251
 
114
- 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.
252
+ 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.
115
253
 
116
254
  ## Conventions
117
255
 
package/dist/cli.js ADDED
@@ -0,0 +1,379 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { format } from "node:util";
4
+ import { Command, InvalidArgumentError, Option } from "commander";
5
+ import { join } from "node:path";
6
+ import { execFileSync } from "node:child_process";
7
+ //#region package.json
8
+ var version = "2.1.0";
9
+ //#endregion
10
+ //#region src/format-identity.ts
11
+ const OUTPUT_FORMATS = ["json", "env"];
12
+ function isOutputFormat(value) {
13
+ return value === "json" || value === "env";
14
+ }
15
+ /** Every field of `DisplayIdentity`, in the order it is declared there. The `env` format derives its variable names by upper-casing these directly, so there is no second name mapping that could drift out of step with the type itself. */
16
+ const IDENTITY_FIELDS = [
17
+ "kind",
18
+ "version",
19
+ "url",
20
+ "date",
21
+ "commit"
22
+ ];
23
+ /** A prefix is glued straight onto an already-upper-case field name, so it only has to keep the result a legal shell/GitHub Actions variable name. An empty prefix is legitimate (bare `KIND=`, `VERSION=`, ...) for a caller that genuinely wants the unprefixed names. */
24
+ const ENV_PREFIX_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$|^$/;
25
+ function isEnvPrefix(value) {
26
+ return ENV_PREFIX_PATTERN.test(value);
27
+ }
28
+ /**
29
+ * Renders a resolved identity for stdout, in whichever of the two formats the caller asked for.
30
+ *
31
+ * `json` emits the `DisplayIdentity` object verbatim -- the same five field names the library's own type declares, with no CLI-invented additions. In particular there is no separate `predicted` boolean: `kind` is already `'predicted'` in exactly that case, and a second field asserting the same fact would be one more thing every writer has to keep in step.
32
+ *
33
+ * `env` emits one `PREFIX_FIELD=value` line per field, upper-cased, ready to append straight to `$GITHUB_ENV` (or `eval`/`source` in a plain shell). Each repo maps these generic names onto whatever it calls them itself; this package never emits a consumer-specific name.
34
+ */
35
+ function formatIdentity(identity, options) {
36
+ if (options.format === "json") return JSON.stringify(identity, null, 2);
37
+ if (!isEnvPrefix(options.prefix)) throw new Error(`--prefix must be empty or a legal variable-name prefix ([A-Za-z_][A-Za-z0-9_]*), got: ${JSON.stringify(options.prefix)}`);
38
+ return IDENTITY_FIELDS.map((field) => {
39
+ const value = identity[field];
40
+ if (/[\r\n]/.test(value)) throw new Error(`${field} contains a line break, which cannot be represented as a KEY=VALUE line: ${JSON.stringify(value)}`);
41
+ return `${options.prefix}${field.toUpperCase()}=${value}`;
42
+ }).join("\n");
43
+ }
44
+ //#endregion
45
+ //#region src/load-commit-analyzer.ts
46
+ const COMMIT_ANALYZER_SPECIFIER = "@semantic-release/commit-analyzer";
47
+ function hasAnalyzeCommits(value) {
48
+ if (typeof value !== "object" || value === null) return false;
49
+ if (!("analyzeCommits" in value)) return false;
50
+ return typeof value.analyzeCommits === "function";
51
+ }
52
+ /**
53
+ * Loads `@semantic-release/commit-analyzer`'s own `analyzeCommits` export -- the tested, correct way to obtain the `AnalyzeCommits` implementation `predictNextVersion` (`./predict-next-version`) needs. Split out from `predictNextVersion` itself so that function stays a pure, easily-fakeable function of its own arguments; this is the one place in the package that actually touches the untyped module.
54
+ *
55
+ * `@semantic-release/commit-analyzer` is an optional peer dependency (see this package's own `package.json`): not required to use `resolveBuildIdentity`/`resolvePredictedIdentity`, only to call this function. Throws a clear error, rather than a bare "Cannot find module", when it isn't installed or doesn't export `analyzeCommits` -- a version bump of the real package removing or renaming that export fails loudly here, not silently.
56
+ */
57
+ async function loadCommitAnalyzer() {
58
+ let commitAnalyzerModule;
59
+ try {
60
+ commitAnalyzerModule = await import(COMMIT_ANALYZER_SPECIFIER);
61
+ } catch (error) {
62
+ throw new Error("@semantic-release/commit-analyzer is not installed -- it is an optional peer dependency of @exadev/build-identity, required only for loadCommitAnalyzer/predictNextVersion.", { cause: error });
63
+ }
64
+ if (!hasAnalyzeCommits(commitAnalyzerModule)) throw new Error("@semantic-release/commit-analyzer has no analyzeCommits export.");
65
+ return commitAnalyzerModule.analyzeCommits;
66
+ }
67
+ //#endregion
68
+ //#region src/package-version.ts
69
+ /** Turns a version into the tag name expected to mark its release -- the shared default (and shared option shape) both `resolveBuildIdentity` and `predictNextVersion` accept, so a repo that tags differently only has to say so once per call site, not maintain two independent conventions. */
70
+ function defaultTagName(version) {
71
+ return `v${version}`;
72
+ }
73
+ function isPackageJsonWithVersion(value) {
74
+ if (typeof value !== "object" || value === null) return false;
75
+ if (!("version" in value)) return false;
76
+ return typeof value.version === "string" && value.version.length > 0;
77
+ }
78
+ /** The single semantic-release-managed version, read from `package.json` at `repoRoot`'s root -- shared by `resolveBuildIdentity` (what this build's version is, if released) and `predictNextVersion` (the version to diff commits since). Throws rather than defaulting, matching this package's own "no sensible placeholder identity" stance. */
79
+ function readPackageVersion(repoRoot) {
80
+ const packageJsonPath = join(repoRoot, "package.json");
81
+ const raw = readFileSync(packageJsonPath, "utf8");
82
+ const parsed = JSON.parse(raw);
83
+ if (!isPackageJsonWithVersion(parsed)) throw new Error(`${packageJsonPath} must contain a non-empty string "version" field`);
84
+ return parsed.version;
85
+ }
86
+ //#endregion
87
+ //#region src/predict-next-version.ts
88
+ const GIT_FORMAT = "%x00%H%x00%B";
89
+ const FIELD_SEP = "\0";
90
+ /**
91
+ * Pure parsing of `git log --format=%x00%H%x00%B` output -- split out so it's testable against a raw string fixture with no real git process. NUL is git's own pretty-format escape for a real NUL byte, the one byte git guarantees can never appear inside a commit hash or message (git refuses to store one), making it the only delimiter genuinely safe against arbitrary commit content. Each record is `<hash><NUL><message>`, with a leading NUL on the whole format string, so splitting the entire raw output on NUL and dropping the resulting leading empty token leaves an exact (hash, message) pairing for every commit. Git appends its own trailing newline per record (tformat behaviour); trim() absorbs it the same way it absorbs genuine trailing blank lines in a real commit body.
92
+ */
93
+ function parseGitLogOutput(raw) {
94
+ const tokens = raw.split(FIELD_SEP);
95
+ tokens.shift();
96
+ const commits = [];
97
+ for (let index = 0; index < tokens.length; index += 2) commits.push({
98
+ hash: tokens[index] ?? "",
99
+ message: (tokens[index + 1] ?? "").trim()
100
+ });
101
+ return commits;
102
+ }
103
+ function readCommitsSince(repoRoot, sinceTag) {
104
+ return parseGitLogOutput(execFileSync("git", [
105
+ "log",
106
+ `${sinceTag}..HEAD`,
107
+ `--format=${GIT_FORMAT}`
108
+ ], {
109
+ cwd: repoRoot,
110
+ encoding: "utf-8"
111
+ }));
112
+ }
113
+ function isReleaseLevel(value) {
114
+ return value === "major" || value === "minor" || value === "patch";
115
+ }
116
+ /** A trivial major.minor.patch increment -- every version this package works with is a plain X.Y.Z, never a prerelease or build-metadata identifier (see `resolveBuildIdentity`'s own tag convention), so there is no broader semver grammar here worth delegating to a library for. */
117
+ function bumpVersion(version, releaseType) {
118
+ const [major = 0, minor = 0, patch = 0] = version.split(".").map(Number);
119
+ if (releaseType === "major") return `${String(major + 1)}.0.0`;
120
+ if (releaseType === "minor") return `${String(major)}.${String(minor + 1)}.0`;
121
+ return `${String(major)}.${String(minor)}.${String(patch + 1)}`;
122
+ }
123
+ const NOOP_LOGGER = {
124
+ log: () => void 0,
125
+ error: () => void 0
126
+ };
127
+ /**
128
+ * Predicts the version this repo's next release would be, from commits since the last tagged release -- WITHOUT running semantic-release's own top-level orchestrator. That orchestrator's core (not any plugin) verifies push access to the remote as part of resolving branches before analysis ever runs: genuinely slow, and needing credentials a prediction has no real reason to hold. This calls `analyzeCommits` directly instead -- no network, no registry lookups -- exactly the same hook `@semantic-release/commit-analyzer` exports, supplied by the caller (see `AnalyzeCommits`'s own doc comment for why this package never imports that module itself).
129
+ *
130
+ * Returns `undefined` when there is genuinely no predicted release -- no commits since the last tag, or none of them are release-worthy under `releaseRules` -- the same "nothing to report" case `resolvePredictedIdentity` already treats as valid, not an error. Throws for a genuine setup problem instead of defaulting: `repoRoot` isn't a git repository, or `package.json` has no usable version.
131
+ *
132
+ * @param repoRoot Path to the git working tree to inspect (must contain `package.json` at its root, same as `resolveBuildIdentity`).
133
+ * @param releaseRules The commit-analyzer release rules this repo's own commit convention defines.
134
+ * @param analyzeCommits `@semantic-release/commit-analyzer`'s own `analyzeCommits` export -- `loadCommitAnalyzer` (`./load-commit-analyzer`) is the tested way to obtain one.
135
+ */
136
+ async function predictNextVersion(repoRoot, releaseRules, analyzeCommits, options = {}) {
137
+ const lastVersion = readPackageVersion(repoRoot);
138
+ const tagName = (options.tagName ?? defaultTagName)(lastVersion);
139
+ const logger = options.logger ?? NOOP_LOGGER;
140
+ const commits = readCommitsSince(repoRoot, tagName);
141
+ if (commits.length === 0) return;
142
+ const releaseType = await analyzeCommits({ releaseRules: releaseRules.map((rule) => ({ ...rule })) }, {
143
+ commits,
144
+ logger,
145
+ cwd: repoRoot
146
+ });
147
+ if (!isReleaseLevel(releaseType)) return;
148
+ return bumpVersion(lastVersion, releaseType);
149
+ }
150
+ //#endregion
151
+ //#region src/parse-release-rules.ts
152
+ function isUnknownArray(value) {
153
+ return Array.isArray(value);
154
+ }
155
+ function isReleaseValue(value) {
156
+ return value === false || isReleaseLevel(value);
157
+ }
158
+ function isReleaseRule(value) {
159
+ if (typeof value !== "object" || value === null) return false;
160
+ if (!("release" in value) || !isReleaseValue(value.release)) return false;
161
+ if ("breaking" in value) return value.breaking === true;
162
+ if (!("type" in value)) return false;
163
+ return typeof value.type === "string" && value.type.length > 0;
164
+ }
165
+ /**
166
+ * Validates a JSON array of `@semantic-release/commit-analyzer` release rules supplied on the command line, into the `ReleaseRule[]` `predictNextVersion` takes.
167
+ *
168
+ * The library deliberately hardcodes no rules of its own -- a repo's commit-type-to-release-level convention is real, repo-specific configuration -- so the CLI cannot either, and this is the only route by which it learns them. Every failure names `source` (the flag or file path the JSON came from) so a malformed rule set is traceable back to whichever of the two supplied it.
169
+ */
170
+ function parseReleaseRules(raw, source) {
171
+ let parsed;
172
+ try {
173
+ parsed = JSON.parse(raw);
174
+ } catch (cause) {
175
+ throw new Error(`${source} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
176
+ }
177
+ if (!isUnknownArray(parsed)) throw new Error(`${source} must be a JSON array of commit-analyzer release rules, e.g. [{"breaking":true,"release":"major"},{"type":"feat","release":"minor"}]`);
178
+ const rules = [];
179
+ for (const [index, entry] of parsed.entries()) {
180
+ if (!isReleaseRule(entry)) throw new Error(`${source}: entry ${String(index)} must be {"type":<string>,"release":"major"|"minor"|"patch"|false} or {"breaking":true,"release":"major"|"minor"|"patch"|false}, got: ${JSON.stringify(entry)}`);
181
+ rules.push(entry);
182
+ }
183
+ return rules;
184
+ }
185
+ //#endregion
186
+ //#region src/git.ts
187
+ function runGit(repoRoot, args) {
188
+ return execFileSync("git", args, {
189
+ cwd: repoRoot,
190
+ encoding: "utf8"
191
+ }).trim();
192
+ }
193
+ /**
194
+ * Reads HEAD's full hash, short hash, and author date from the given git working tree. Author date, not committer date: a rebase-merged commit keeps its original author date but gets a fresh committer date at rebase time, and every consumer of this identity treats the date as "when this commit was authored", not "when it was last rewritten onto its current position". 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.
195
+ */
196
+ function getHeadCommit(repoRoot) {
197
+ return {
198
+ fullSha: runGit(repoRoot, ["rev-parse", "HEAD"]),
199
+ shortSha: runGit(repoRoot, [
200
+ "rev-parse",
201
+ "--short",
202
+ "HEAD"
203
+ ]),
204
+ date: runGit(repoRoot, [
205
+ "log",
206
+ "-1",
207
+ "--format=%aI",
208
+ "HEAD"
209
+ ])
210
+ };
211
+ }
212
+ /**
213
+ * 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.
214
+ */
215
+ function tagPointsAtHead(repoRoot, tagName) {
216
+ return runGit(repoRoot, [
217
+ "tag",
218
+ "--list",
219
+ tagName,
220
+ "--points-at",
221
+ "HEAD"
222
+ ]).split("\n").map((line) => line.trim()).includes(tagName);
223
+ }
224
+ //#endregion
225
+ //#region src/resolve-build-identity.ts
226
+ const REPO_SLUG_PATTERN = /^[^/\s]+\/[^/\s]+$/;
227
+ function assertRepoSlug(repoSlug) {
228
+ if (!REPO_SLUG_PATTERN.test(repoSlug)) throw new Error(`repoSlug must be a GitHub "owner/repo" slug, got: ${JSON.stringify(repoSlug)}`);
229
+ }
230
+ /**
231
+ * 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.
232
+ *
233
+ * 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.
234
+ *
235
+ * @param repoRoot Path to the git working tree to inspect (must contain `package.json` at its root).
236
+ * @param repoSlug The GitHub `owner/repo` slug used to build both release and commit URLs.
237
+ */
238
+ function resolveBuildIdentity(repoRoot, repoSlug, options = {}) {
239
+ assertRepoSlug(repoSlug);
240
+ const version = readPackageVersion(repoRoot);
241
+ const tagName = (options.tagName ?? defaultTagName)(version);
242
+ const head = getHeadCommit(repoRoot);
243
+ if (tagPointsAtHead(repoRoot, tagName)) return {
244
+ kind: "release",
245
+ version,
246
+ url: `https://github.com/${repoSlug}/releases/tag/${tagName}`,
247
+ date: head.date,
248
+ commit: head.fullSha
249
+ };
250
+ return {
251
+ kind: "commit",
252
+ version: head.shortSha,
253
+ url: `https://github.com/${repoSlug}/commit/${head.fullSha}`,
254
+ date: head.date,
255
+ commit: head.fullSha
256
+ };
257
+ }
258
+ //#endregion
259
+ //#region src/resolve-predicted-identity.ts
260
+ /**
261
+ * Upgrades an unreleased build's displayed version to a predicted one (e.g. the version `predictNextVersion` computes the next release will be), without ever upgrading its URL.
262
+ *
263
+ * 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 result is `kind: 'predicted'` with `version` set to that predicted label -- `url`/`date`/`commit` are copied verbatim from `build`, still describing the real commit, never a release page that doesn't exist yet. With no usable prediction, `build` is returned unchanged (still a valid `DisplayIdentity`, since every `BuildIdentity` kind is also a `DisplayIdentity` kind).
264
+ *
265
+ * This function does no git or filesystem access of its own: predicting the next version is entirely the caller's job, typically `predictNextVersion` (`./predict-next-version`). It exists to keep that prediction, and the guarantee `resolveBuildIdentity` makes about `url`, cleanly separate.
266
+ */
267
+ function resolvePredictedIdentity(build, predictedVersion) {
268
+ if (build.kind === "release") return build;
269
+ const trimmedPrediction = predictedVersion?.trim();
270
+ if (trimmedPrediction === void 0 || trimmedPrediction.length === 0) return build;
271
+ return {
272
+ kind: "predicted",
273
+ version: trimmedPrediction,
274
+ url: build.url,
275
+ date: build.date,
276
+ commit: build.commit
277
+ };
278
+ }
279
+ //#endregion
280
+ //#region src/cli-program.ts
281
+ const VERSION_PLACEHOLDER = "{version}";
282
+ const DEFAULT_TAG_TEMPLATE = `v${VERSION_PLACEHOLDER}`;
283
+ const DEFAULT_ENV_PREFIX = "BUILD_";
284
+ /** `predictNextVersion` narrates through a caller-supplied logger and explicitly warns against routing it to stdout, which carries the machine-readable result. `--verbose` sends it to stderr instead, so a CI step can see why a prediction came out the way it did without contaminating what the next step parses. */
285
+ const STDERR_LOGGER = {
286
+ log: (...args) => {
287
+ writeStderr(args);
288
+ },
289
+ error: (...args) => {
290
+ writeStderr(args);
291
+ }
292
+ };
293
+ function writeStderr(args) {
294
+ process.stderr.write(`${format(...args)}\n`);
295
+ }
296
+ /**
297
+ * A `{version}` template rather than a function, since a command line cannot carry one: `resolveBuildIdentity` and `predictNextVersion` are both given the same resulting function, keeping "which tag marks a release" one convention across the two calls exactly as the library's own docs advise.
298
+ */
299
+ function parseTagTemplate(template) {
300
+ if (!template.includes(VERSION_PLACEHOLDER)) throw new InvalidArgumentError(`--tag-name must contain the ${VERSION_PLACEHOLDER} placeholder, got: ${JSON.stringify(template)}`);
301
+ return (version) => template.replaceAll(VERSION_PLACEHOLDER, version);
302
+ }
303
+ function parseFormat(value) {
304
+ if (!isOutputFormat(value)) throw new InvalidArgumentError(`--format must be one of: ${OUTPUT_FORMATS.join(", ")}`);
305
+ return value;
306
+ }
307
+ function parsePrefix(value) {
308
+ if (!isEnvPrefix(value)) throw new InvalidArgumentError(`--prefix must be empty or a legal variable-name prefix ([A-Za-z_][A-Za-z0-9_]*), got: ${JSON.stringify(value)}`);
309
+ return value;
310
+ }
311
+ function parseInlineReleaseRules(value) {
312
+ try {
313
+ return parseReleaseRules(value, "--release-rules");
314
+ } catch (cause) {
315
+ throw new InvalidArgumentError(cause instanceof Error ? cause.message : String(cause));
316
+ }
317
+ }
318
+ function readReleaseRulesFile(path) {
319
+ let raw;
320
+ try {
321
+ raw = readFileSync(path, "utf8");
322
+ } catch (cause) {
323
+ throw new Error(`--release-rules-file ${path} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
324
+ }
325
+ return parseReleaseRules(raw, `--release-rules-file ${path}`);
326
+ }
327
+ /**
328
+ * The whole CLI as a function of its already-parsed flags: resolves the identity and returns the exact text the command prints, so the wiring between the library's three functions is testable without a subprocess and without capturing stdout.
329
+ *
330
+ * Prediction is gated behind `--predict` rather than inferred: it needs `@semantic-release/commit-analyzer`, an optional peer dependency of this package, and reads the whole commit range since the last tag. Both are real costs a caller that only wants `resolveBuildIdentity`'s answer should not silently pay.
331
+ */
332
+ async function runBuildIdentity(flags) {
333
+ if (flags.releaseRules !== void 0 && flags.releaseRulesFile !== void 0) throw new Error("--release-rules and --release-rules-file are alternatives; pass one, not both");
334
+ const rules = flags.releaseRules ?? (flags.releaseRulesFile === void 0 ? void 0 : readReleaseRulesFile(flags.releaseRulesFile));
335
+ if (flags.predict === true && rules === void 0) throw new Error("--predict needs this repo's own commit-type release rules: pass --release-rules or --release-rules-file");
336
+ if (flags.predict !== true && rules !== void 0) throw new Error("--release-rules/--release-rules-file only take effect with --predict, which was not passed");
337
+ const build = resolveBuildIdentity(flags.root, flags.repo, { tagName: flags.tagName });
338
+ let predictedVersion;
339
+ if (rules !== void 0) {
340
+ const options = {
341
+ tagName: flags.tagName,
342
+ ...flags.verbose === true ? { logger: STDERR_LOGGER } : {}
343
+ };
344
+ predictedVersion = await predictNextVersion(flags.root, rules, await loadCommitAnalyzer(), options);
345
+ }
346
+ return formatIdentity(resolvePredictedIdentity(build, predictedVersion), {
347
+ format: flags.format,
348
+ prefix: flags.prefix
349
+ });
350
+ }
351
+ /**
352
+ * Builds the commander program without parsing argv or exiting the process, so the command tree stays testable in isolation. There is no subcommand: the package does exactly one thing, and a subcommand naming it again would be pure ceremony.
353
+ */
354
+ function createProgram() {
355
+ const program = new Command("build-identity");
356
+ program.description("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.");
357
+ program.version(version);
358
+ program.requiredOption("--repo <owner/repo>", "GitHub slug used to build the release and commit URLs, e.g. exadev/build-identity");
359
+ program.addOption(new Option("--root <directory>", "git working tree to inspect; must contain package.json at its root").default(process.cwd(), "the current working directory"));
360
+ program.addOption(new Option("--tag-name <template>", `tag name marking a release, with ${VERSION_PLACEHOLDER} standing in for the version`).argParser(parseTagTemplate).default(defaultTagName, DEFAULT_TAG_TEMPLATE));
361
+ program.option("--predict", "also predict the version this commit's next release would be, and show that instead of a bare commit hash when there is no release tag on HEAD");
362
+ program.option("--release-rules <json>", "commit-analyzer release rules for --predict, as a JSON array", parseInlineReleaseRules);
363
+ program.option("--release-rules-file <path>", "file holding the same JSON array as --release-rules, for a rule set too awkward to quote inline");
364
+ program.option("--format <format>", `output format: ${OUTPUT_FORMATS.join(" or ")}`, parseFormat, "json");
365
+ program.option("--prefix <prefix>", "prepended to each variable name in --format env", parsePrefix, DEFAULT_ENV_PREFIX);
366
+ program.option("--verbose", "send commit-analyzer's own per-commit narration to stderr");
367
+ program.action(async (flags) => {
368
+ process.stdout.write(`${await runBuildIdentity(flags)}\n`);
369
+ });
370
+ return program;
371
+ }
372
+ //#endregion
373
+ //#region src/cli.ts
374
+ createProgram().parseAsync(process.argv).catch((cause) => {
375
+ process.stderr.write(`build-identity: ${cause instanceof Error ? cause.stack ?? cause.message : String(cause)}\n`);
376
+ process.exitCode = 1;
377
+ });
378
+ //#endregion
379
+ export {};
package/dist/index.cjs CHANGED
@@ -1,7 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let node_child_process = require("node:child_process");
2
3
  let node_fs = require("node:fs");
3
4
  let node_path = require("node:path");
4
- let node_child_process = require("node:child_process");
5
5
  //#region src/git.ts
6
6
  function runGit(repoRoot, args) {
7
7
  return (0, node_child_process.execFileSync)("git", args, {
@@ -41,8 +41,8 @@ function tagPointsAtHead(repoRoot, tagName) {
41
41
  ]).split("\n").map((line) => line.trim()).includes(tagName);
42
42
  }
43
43
  //#endregion
44
- //#region src/resolve-build-identity.ts
45
- const REPO_SLUG_PATTERN = /^[^/\s]+\/[^/\s]+$/;
44
+ //#region src/package-version.ts
45
+ /** Turns a version into the tag name expected to mark its release -- the shared default (and shared option shape) both `resolveBuildIdentity` and `predictNextVersion` accept, so a repo that tags differently only has to say so once per call site, not maintain two independent conventions. */
46
46
  function defaultTagName(version) {
47
47
  return `v${version}`;
48
48
  }
@@ -51,6 +51,7 @@ function isPackageJsonWithVersion(value) {
51
51
  if (!("version" in value)) return false;
52
52
  return typeof value.version === "string" && value.version.length > 0;
53
53
  }
54
+ /** The single semantic-release-managed version, read from `package.json` at `repoRoot`'s root -- shared by `resolveBuildIdentity` (what this build's version is, if released) and `predictNextVersion` (the version to diff commits since). Throws rather than defaulting, matching this package's own "no sensible placeholder identity" stance. */
54
55
  function readPackageVersion(repoRoot) {
55
56
  const packageJsonPath = (0, node_path.join)(repoRoot, "package.json");
56
57
  const raw = (0, node_fs.readFileSync)(packageJsonPath, "utf8");
@@ -58,6 +59,9 @@ function readPackageVersion(repoRoot) {
58
59
  if (!isPackageJsonWithVersion(parsed)) throw new Error(`${packageJsonPath} must contain a non-empty string "version" field`);
59
60
  return parsed.version;
60
61
  }
62
+ //#endregion
63
+ //#region src/resolve-build-identity.ts
64
+ const REPO_SLUG_PATTERN = /^[^/\s]+\/[^/\s]+$/;
61
65
  function assertRepoSlug(repoSlug) {
62
66
  if (!REPO_SLUG_PATTERN.test(repoSlug)) throw new Error(`repoSlug must be a GitHub "owner/repo" slug, got: ${JSON.stringify(repoSlug)}`);
63
67
  }
@@ -92,25 +96,113 @@ function resolveBuildIdentity(repoRoot, repoSlug, options = {}) {
92
96
  //#endregion
93
97
  //#region src/resolve-predicted-identity.ts
94
98
  /**
95
- * 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.
99
+ * Upgrades an unreleased build's displayed version to a predicted one (e.g. the version `predictNextVersion` computes the next release will be), without ever upgrading its URL.
96
100
  *
97
- * 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.
101
+ * 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 result is `kind: 'predicted'` with `version` set to that predicted label -- `url`/`date`/`commit` are copied verbatim from `build`, still describing the real commit, never a release page that doesn't exist yet. With no usable prediction, `build` is returned unchanged (still a valid `DisplayIdentity`, since every `BuildIdentity` kind is also a `DisplayIdentity` kind).
98
102
  *
99
- * 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.
103
+ * This function does no git or filesystem access of its own: predicting the next version is entirely the caller's job, typically `predictNextVersion` (`./predict-next-version`). It exists to keep that prediction, and the guarantee `resolveBuildIdentity` makes about `url`, cleanly separate.
100
104
  */
101
105
  function resolvePredictedIdentity(build, predictedVersion) {
102
106
  if (build.kind === "release") return build;
103
107
  const trimmedPrediction = predictedVersion?.trim();
104
108
  if (trimmedPrediction === void 0 || trimmedPrediction.length === 0) return build;
105
109
  return {
106
- kind: "commit",
110
+ kind: "predicted",
107
111
  version: trimmedPrediction,
108
112
  url: build.url,
109
113
  date: build.date,
110
- commit: build.commit,
111
- predicted: true
114
+ commit: build.commit
112
115
  };
113
116
  }
114
117
  //#endregion
118
+ //#region src/predict-next-version.ts
119
+ const GIT_FORMAT = "%x00%H%x00%B";
120
+ const FIELD_SEP = "\0";
121
+ /**
122
+ * Pure parsing of `git log --format=%x00%H%x00%B` output -- split out so it's testable against a raw string fixture with no real git process. NUL is git's own pretty-format escape for a real NUL byte, the one byte git guarantees can never appear inside a commit hash or message (git refuses to store one), making it the only delimiter genuinely safe against arbitrary commit content. Each record is `<hash><NUL><message>`, with a leading NUL on the whole format string, so splitting the entire raw output on NUL and dropping the resulting leading empty token leaves an exact (hash, message) pairing for every commit. Git appends its own trailing newline per record (tformat behaviour); trim() absorbs it the same way it absorbs genuine trailing blank lines in a real commit body.
123
+ */
124
+ function parseGitLogOutput(raw) {
125
+ const tokens = raw.split(FIELD_SEP);
126
+ tokens.shift();
127
+ const commits = [];
128
+ for (let index = 0; index < tokens.length; index += 2) commits.push({
129
+ hash: tokens[index] ?? "",
130
+ message: (tokens[index + 1] ?? "").trim()
131
+ });
132
+ return commits;
133
+ }
134
+ function readCommitsSince(repoRoot, sinceTag) {
135
+ return parseGitLogOutput((0, node_child_process.execFileSync)("git", [
136
+ "log",
137
+ `${sinceTag}..HEAD`,
138
+ `--format=${GIT_FORMAT}`
139
+ ], {
140
+ cwd: repoRoot,
141
+ encoding: "utf-8"
142
+ }));
143
+ }
144
+ function isReleaseLevel(value) {
145
+ return value === "major" || value === "minor" || value === "patch";
146
+ }
147
+ /** A trivial major.minor.patch increment -- every version this package works with is a plain X.Y.Z, never a prerelease or build-metadata identifier (see `resolveBuildIdentity`'s own tag convention), so there is no broader semver grammar here worth delegating to a library for. */
148
+ function bumpVersion(version, releaseType) {
149
+ const [major = 0, minor = 0, patch = 0] = version.split(".").map(Number);
150
+ if (releaseType === "major") return `${String(major + 1)}.0.0`;
151
+ if (releaseType === "minor") return `${String(major)}.${String(minor + 1)}.0`;
152
+ return `${String(major)}.${String(minor)}.${String(patch + 1)}`;
153
+ }
154
+ const NOOP_LOGGER = {
155
+ log: () => void 0,
156
+ error: () => void 0
157
+ };
158
+ /**
159
+ * Predicts the version this repo's next release would be, from commits since the last tagged release -- WITHOUT running semantic-release's own top-level orchestrator. That orchestrator's core (not any plugin) verifies push access to the remote as part of resolving branches before analysis ever runs: genuinely slow, and needing credentials a prediction has no real reason to hold. This calls `analyzeCommits` directly instead -- no network, no registry lookups -- exactly the same hook `@semantic-release/commit-analyzer` exports, supplied by the caller (see `AnalyzeCommits`'s own doc comment for why this package never imports that module itself).
160
+ *
161
+ * Returns `undefined` when there is genuinely no predicted release -- no commits since the last tag, or none of them are release-worthy under `releaseRules` -- the same "nothing to report" case `resolvePredictedIdentity` already treats as valid, not an error. Throws for a genuine setup problem instead of defaulting: `repoRoot` isn't a git repository, or `package.json` has no usable version.
162
+ *
163
+ * @param repoRoot Path to the git working tree to inspect (must contain `package.json` at its root, same as `resolveBuildIdentity`).
164
+ * @param releaseRules The commit-analyzer release rules this repo's own commit convention defines.
165
+ * @param analyzeCommits `@semantic-release/commit-analyzer`'s own `analyzeCommits` export -- `loadCommitAnalyzer` (`./load-commit-analyzer`) is the tested way to obtain one.
166
+ */
167
+ async function predictNextVersion(repoRoot, releaseRules, analyzeCommits, options = {}) {
168
+ const lastVersion = readPackageVersion(repoRoot);
169
+ const tagName = (options.tagName ?? defaultTagName)(lastVersion);
170
+ const logger = options.logger ?? NOOP_LOGGER;
171
+ const commits = readCommitsSince(repoRoot, tagName);
172
+ if (commits.length === 0) return;
173
+ const releaseType = await analyzeCommits({ releaseRules: releaseRules.map((rule) => ({ ...rule })) }, {
174
+ commits,
175
+ logger,
176
+ cwd: repoRoot
177
+ });
178
+ if (!isReleaseLevel(releaseType)) return;
179
+ return bumpVersion(lastVersion, releaseType);
180
+ }
181
+ //#endregion
182
+ //#region src/load-commit-analyzer.ts
183
+ const COMMIT_ANALYZER_SPECIFIER = "@semantic-release/commit-analyzer";
184
+ function hasAnalyzeCommits(value) {
185
+ if (typeof value !== "object" || value === null) return false;
186
+ if (!("analyzeCommits" in value)) return false;
187
+ return typeof value.analyzeCommits === "function";
188
+ }
189
+ /**
190
+ * Loads `@semantic-release/commit-analyzer`'s own `analyzeCommits` export -- the tested, correct way to obtain the `AnalyzeCommits` implementation `predictNextVersion` (`./predict-next-version`) needs. Split out from `predictNextVersion` itself so that function stays a pure, easily-fakeable function of its own arguments; this is the one place in the package that actually touches the untyped module.
191
+ *
192
+ * `@semantic-release/commit-analyzer` is an optional peer dependency (see this package's own `package.json`): not required to use `resolveBuildIdentity`/`resolvePredictedIdentity`, only to call this function. Throws a clear error, rather than a bare "Cannot find module", when it isn't installed or doesn't export `analyzeCommits` -- a version bump of the real package removing or renaming that export fails loudly here, not silently.
193
+ */
194
+ async function loadCommitAnalyzer() {
195
+ let commitAnalyzerModule;
196
+ try {
197
+ commitAnalyzerModule = await import(COMMIT_ANALYZER_SPECIFIER);
198
+ } catch (error) {
199
+ throw new Error("@semantic-release/commit-analyzer is not installed -- it is an optional peer dependency of @exadev/build-identity, required only for loadCommitAnalyzer/predictNextVersion.", { cause: error });
200
+ }
201
+ if (!hasAnalyzeCommits(commitAnalyzerModule)) throw new Error("@semantic-release/commit-analyzer has no analyzeCommits export.");
202
+ return commitAnalyzerModule.analyzeCommits;
203
+ }
204
+ //#endregion
205
+ exports.loadCommitAnalyzer = loadCommitAnalyzer;
206
+ exports.predictNextVersion = predictNextVersion;
115
207
  exports.resolveBuildIdentity = resolveBuildIdentity;
116
208
  exports.resolvePredictedIdentity = resolvePredictedIdentity;
package/dist/index.d.cts CHANGED
@@ -31,6 +31,16 @@ interface ResolveBuildIdentityOptions {
31
31
  */
32
32
  readonly tagName?: (version: string) => string;
33
33
  }
34
+ /**
35
+ * What a build should actually display -- `BuildIdentity` refined by an optional prediction (`resolvePredictedIdentity`). All three kinds carry the identical field set; only the meaning of `kind`/`version` differs: `'release'` and `'commit'` mean exactly what they do on `BuildIdentity` (a confirmed release always wins outright, unchanged), and `'predicted'` means "not yet released, but a commit-analyzer-style tool predicts this commit will become `version` once it is" -- `url`/`date`/`commit` still describe the real underlying commit, never a release page that doesn't exist yet.
36
+ */
37
+ interface DisplayIdentity {
38
+ readonly kind: 'release' | 'predicted' | 'commit';
39
+ readonly version: string;
40
+ readonly url: string;
41
+ readonly date: string;
42
+ readonly commit: string;
43
+ }
34
44
  //#endregion
35
45
  //#region src/resolve-build-identity.d.ts
36
46
  /**
@@ -45,14 +55,54 @@ declare function resolveBuildIdentity(repoRoot: string, repoSlug: string, option
45
55
  //#endregion
46
56
  //#region src/resolve-predicted-identity.d.ts
47
57
  /**
48
- * 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.
58
+ * Upgrades an unreleased build's displayed version to a predicted one (e.g. the version `predictNextVersion` computes the next release will be), without ever upgrading its URL.
49
59
  *
50
- * 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.
60
+ * 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 result is `kind: 'predicted'` with `version` set to that predicted label -- `url`/`date`/`commit` are copied verbatim from `build`, still describing the real commit, never a release page that doesn't exist yet. With no usable prediction, `build` is returned unchanged (still a valid `DisplayIdentity`, since every `BuildIdentity` kind is also a `DisplayIdentity` kind).
51
61
  *
52
- * 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.
62
+ * This function does no git or filesystem access of its own: predicting the next version is entirely the caller's job, typically `predictNextVersion` (`./predict-next-version`). It exists to keep that prediction, and the guarantee `resolveBuildIdentity` makes about `url`, cleanly separate.
53
63
  */
54
- declare function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: string | undefined): BuildIdentity & {
55
- readonly predicted?: boolean;
64
+ declare function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: string | undefined): DisplayIdentity;
65
+ //#endregion
66
+ //#region src/predict-next-version.d.ts
67
+ type ReleaseLevel = 'major' | 'minor' | 'patch';
68
+ /** One @semantic-release/commit-analyzer release rule -- either `{ type, release }` (a conventional-commit type, e.g. `"feat"`) or `{ breaking: true, release }` (any commit whose footer/body declares a breaking change, regardless of type). `release` also accepts `false`, commit-analyzer's own way to say "this type never triggers a release" -- needed to override a broader rule or preset default, distinct from simply omitting the type (which instead falls through to whatever the preset's own default is). This package hardcodes no rules of its own: a repo's commit-type-to-release-level convention is real, repo-specific configuration. */
69
+ type ReleaseRule = {
70
+ readonly type: string;
71
+ readonly release: ReleaseLevel | false;
72
+ } | {
73
+ readonly breaking: true;
74
+ readonly release: ReleaseLevel | false;
56
75
  };
76
+ /**
77
+ * The shape of `@semantic-release/commit-analyzer`'s own `analyzeCommits` plugin hook -- untyped and undocumented as a standalone export (it exists only because semantic-release's own core loads plugins dynamically), so this package can promise no more about it than "callable with these two arguments, returns a promise." `predictNextVersion` takes an implementation of this type as a parameter, rather than loading `@semantic-release/commit-analyzer` itself, so it stays a pure function of its own arguments -- trivially testable with a fake analyzer, exactly how this file's own tests exercise it. `./load-commit-analyzer`'s `loadCommitAnalyzer` is the tested, correct way to obtain a real one; pass its result straight through when you don't need a different implementation.
78
+ */
79
+ type AnalyzeCommits = (pluginConfig: unknown, context: unknown) => Promise<unknown>;
80
+ interface PredictNextVersionOptions {
81
+ /** Matches `resolveBuildIdentity`'s own option of the same name -- pass the same function to both when a repo tags non-default, so "which tag marks the last release" stays one convention rather than two that could drift apart. Defaults to the `v${version}` convention. */
82
+ tagName?: (version: string) => string;
83
+ /** Receives @semantic-release/commit-analyzer's own per-commit narration (e.g. "Analyzing commit: ...", "The release type for the commit is minor"). Defaults to discarding it. Pass your own to route it to stderr or a real logger -- never stdout, so a caller parsing a single predicted-version line from this process's own stdout is never at risk of it being contaminated. */
84
+ logger?: {
85
+ log: (...args: readonly unknown[]) => void;
86
+ error: (...args: readonly unknown[]) => void;
87
+ };
88
+ }
89
+ /**
90
+ * Predicts the version this repo's next release would be, from commits since the last tagged release -- WITHOUT running semantic-release's own top-level orchestrator. That orchestrator's core (not any plugin) verifies push access to the remote as part of resolving branches before analysis ever runs: genuinely slow, and needing credentials a prediction has no real reason to hold. This calls `analyzeCommits` directly instead -- no network, no registry lookups -- exactly the same hook `@semantic-release/commit-analyzer` exports, supplied by the caller (see `AnalyzeCommits`'s own doc comment for why this package never imports that module itself).
91
+ *
92
+ * Returns `undefined` when there is genuinely no predicted release -- no commits since the last tag, or none of them are release-worthy under `releaseRules` -- the same "nothing to report" case `resolvePredictedIdentity` already treats as valid, not an error. Throws for a genuine setup problem instead of defaulting: `repoRoot` isn't a git repository, or `package.json` has no usable version.
93
+ *
94
+ * @param repoRoot Path to the git working tree to inspect (must contain `package.json` at its root, same as `resolveBuildIdentity`).
95
+ * @param releaseRules The commit-analyzer release rules this repo's own commit convention defines.
96
+ * @param analyzeCommits `@semantic-release/commit-analyzer`'s own `analyzeCommits` export -- `loadCommitAnalyzer` (`./load-commit-analyzer`) is the tested way to obtain one.
97
+ */
98
+ declare function predictNextVersion(repoRoot: string, releaseRules: readonly ReleaseRule[], analyzeCommits: AnalyzeCommits, options?: PredictNextVersionOptions): Promise<string | undefined>;
99
+ //#endregion
100
+ //#region src/load-commit-analyzer.d.ts
101
+ /**
102
+ * Loads `@semantic-release/commit-analyzer`'s own `analyzeCommits` export -- the tested, correct way to obtain the `AnalyzeCommits` implementation `predictNextVersion` (`./predict-next-version`) needs. Split out from `predictNextVersion` itself so that function stays a pure, easily-fakeable function of its own arguments; this is the one place in the package that actually touches the untyped module.
103
+ *
104
+ * `@semantic-release/commit-analyzer` is an optional peer dependency (see this package's own `package.json`): not required to use `resolveBuildIdentity`/`resolvePredictedIdentity`, only to call this function. Throws a clear error, rather than a bare "Cannot find module", when it isn't installed or doesn't export `analyzeCommits` -- a version bump of the real package removing or renaming that export fails loudly here, not silently.
105
+ */
106
+ declare function loadCommitAnalyzer(): Promise<AnalyzeCommits>;
57
107
  //#endregion
58
- export { type BuildIdentity, type ResolveBuildIdentityOptions, resolveBuildIdentity, resolvePredictedIdentity };
108
+ export { type AnalyzeCommits, type BuildIdentity, type DisplayIdentity, type PredictNextVersionOptions, type ReleaseLevel, type ReleaseRule, type ResolveBuildIdentityOptions, loadCommitAnalyzer, predictNextVersion, resolveBuildIdentity, resolvePredictedIdentity };
package/dist/index.d.ts CHANGED
@@ -31,6 +31,16 @@ interface ResolveBuildIdentityOptions {
31
31
  */
32
32
  readonly tagName?: (version: string) => string;
33
33
  }
34
+ /**
35
+ * What a build should actually display -- `BuildIdentity` refined by an optional prediction (`resolvePredictedIdentity`). All three kinds carry the identical field set; only the meaning of `kind`/`version` differs: `'release'` and `'commit'` mean exactly what they do on `BuildIdentity` (a confirmed release always wins outright, unchanged), and `'predicted'` means "not yet released, but a commit-analyzer-style tool predicts this commit will become `version` once it is" -- `url`/`date`/`commit` still describe the real underlying commit, never a release page that doesn't exist yet.
36
+ */
37
+ interface DisplayIdentity {
38
+ readonly kind: 'release' | 'predicted' | 'commit';
39
+ readonly version: string;
40
+ readonly url: string;
41
+ readonly date: string;
42
+ readonly commit: string;
43
+ }
34
44
  //#endregion
35
45
  //#region src/resolve-build-identity.d.ts
36
46
  /**
@@ -45,14 +55,54 @@ declare function resolveBuildIdentity(repoRoot: string, repoSlug: string, option
45
55
  //#endregion
46
56
  //#region src/resolve-predicted-identity.d.ts
47
57
  /**
48
- * 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.
58
+ * Upgrades an unreleased build's displayed version to a predicted one (e.g. the version `predictNextVersion` computes the next release will be), without ever upgrading its URL.
49
59
  *
50
- * 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.
60
+ * 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 result is `kind: 'predicted'` with `version` set to that predicted label -- `url`/`date`/`commit` are copied verbatim from `build`, still describing the real commit, never a release page that doesn't exist yet. With no usable prediction, `build` is returned unchanged (still a valid `DisplayIdentity`, since every `BuildIdentity` kind is also a `DisplayIdentity` kind).
51
61
  *
52
- * 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.
62
+ * This function does no git or filesystem access of its own: predicting the next version is entirely the caller's job, typically `predictNextVersion` (`./predict-next-version`). It exists to keep that prediction, and the guarantee `resolveBuildIdentity` makes about `url`, cleanly separate.
53
63
  */
54
- declare function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: string | undefined): BuildIdentity & {
55
- readonly predicted?: boolean;
64
+ declare function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: string | undefined): DisplayIdentity;
65
+ //#endregion
66
+ //#region src/predict-next-version.d.ts
67
+ type ReleaseLevel = 'major' | 'minor' | 'patch';
68
+ /** One @semantic-release/commit-analyzer release rule -- either `{ type, release }` (a conventional-commit type, e.g. `"feat"`) or `{ breaking: true, release }` (any commit whose footer/body declares a breaking change, regardless of type). `release` also accepts `false`, commit-analyzer's own way to say "this type never triggers a release" -- needed to override a broader rule or preset default, distinct from simply omitting the type (which instead falls through to whatever the preset's own default is). This package hardcodes no rules of its own: a repo's commit-type-to-release-level convention is real, repo-specific configuration. */
69
+ type ReleaseRule = {
70
+ readonly type: string;
71
+ readonly release: ReleaseLevel | false;
72
+ } | {
73
+ readonly breaking: true;
74
+ readonly release: ReleaseLevel | false;
56
75
  };
76
+ /**
77
+ * The shape of `@semantic-release/commit-analyzer`'s own `analyzeCommits` plugin hook -- untyped and undocumented as a standalone export (it exists only because semantic-release's own core loads plugins dynamically), so this package can promise no more about it than "callable with these two arguments, returns a promise." `predictNextVersion` takes an implementation of this type as a parameter, rather than loading `@semantic-release/commit-analyzer` itself, so it stays a pure function of its own arguments -- trivially testable with a fake analyzer, exactly how this file's own tests exercise it. `./load-commit-analyzer`'s `loadCommitAnalyzer` is the tested, correct way to obtain a real one; pass its result straight through when you don't need a different implementation.
78
+ */
79
+ type AnalyzeCommits = (pluginConfig: unknown, context: unknown) => Promise<unknown>;
80
+ interface PredictNextVersionOptions {
81
+ /** Matches `resolveBuildIdentity`'s own option of the same name -- pass the same function to both when a repo tags non-default, so "which tag marks the last release" stays one convention rather than two that could drift apart. Defaults to the `v${version}` convention. */
82
+ tagName?: (version: string) => string;
83
+ /** Receives @semantic-release/commit-analyzer's own per-commit narration (e.g. "Analyzing commit: ...", "The release type for the commit is minor"). Defaults to discarding it. Pass your own to route it to stderr or a real logger -- never stdout, so a caller parsing a single predicted-version line from this process's own stdout is never at risk of it being contaminated. */
84
+ logger?: {
85
+ log: (...args: readonly unknown[]) => void;
86
+ error: (...args: readonly unknown[]) => void;
87
+ };
88
+ }
89
+ /**
90
+ * Predicts the version this repo's next release would be, from commits since the last tagged release -- WITHOUT running semantic-release's own top-level orchestrator. That orchestrator's core (not any plugin) verifies push access to the remote as part of resolving branches before analysis ever runs: genuinely slow, and needing credentials a prediction has no real reason to hold. This calls `analyzeCommits` directly instead -- no network, no registry lookups -- exactly the same hook `@semantic-release/commit-analyzer` exports, supplied by the caller (see `AnalyzeCommits`'s own doc comment for why this package never imports that module itself).
91
+ *
92
+ * Returns `undefined` when there is genuinely no predicted release -- no commits since the last tag, or none of them are release-worthy under `releaseRules` -- the same "nothing to report" case `resolvePredictedIdentity` already treats as valid, not an error. Throws for a genuine setup problem instead of defaulting: `repoRoot` isn't a git repository, or `package.json` has no usable version.
93
+ *
94
+ * @param repoRoot Path to the git working tree to inspect (must contain `package.json` at its root, same as `resolveBuildIdentity`).
95
+ * @param releaseRules The commit-analyzer release rules this repo's own commit convention defines.
96
+ * @param analyzeCommits `@semantic-release/commit-analyzer`'s own `analyzeCommits` export -- `loadCommitAnalyzer` (`./load-commit-analyzer`) is the tested way to obtain one.
97
+ */
98
+ declare function predictNextVersion(repoRoot: string, releaseRules: readonly ReleaseRule[], analyzeCommits: AnalyzeCommits, options?: PredictNextVersionOptions): Promise<string | undefined>;
99
+ //#endregion
100
+ //#region src/load-commit-analyzer.d.ts
101
+ /**
102
+ * Loads `@semantic-release/commit-analyzer`'s own `analyzeCommits` export -- the tested, correct way to obtain the `AnalyzeCommits` implementation `predictNextVersion` (`./predict-next-version`) needs. Split out from `predictNextVersion` itself so that function stays a pure, easily-fakeable function of its own arguments; this is the one place in the package that actually touches the untyped module.
103
+ *
104
+ * `@semantic-release/commit-analyzer` is an optional peer dependency (see this package's own `package.json`): not required to use `resolveBuildIdentity`/`resolvePredictedIdentity`, only to call this function. Throws a clear error, rather than a bare "Cannot find module", when it isn't installed or doesn't export `analyzeCommits` -- a version bump of the real package removing or renaming that export fails loudly here, not silently.
105
+ */
106
+ declare function loadCommitAnalyzer(): Promise<AnalyzeCommits>;
57
107
  //#endregion
58
- export { type BuildIdentity, type ResolveBuildIdentityOptions, resolveBuildIdentity, resolvePredictedIdentity };
108
+ export { type AnalyzeCommits, type BuildIdentity, type DisplayIdentity, type PredictNextVersionOptions, type ReleaseLevel, type ReleaseRule, type ResolveBuildIdentityOptions, loadCommitAnalyzer, predictNextVersion, resolveBuildIdentity, resolvePredictedIdentity };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { readFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
- import { execFileSync } from "node:child_process";
4
4
  //#region src/git.ts
5
5
  function runGit(repoRoot, args) {
6
6
  return execFileSync("git", args, {
@@ -40,8 +40,8 @@ function tagPointsAtHead(repoRoot, tagName) {
40
40
  ]).split("\n").map((line) => line.trim()).includes(tagName);
41
41
  }
42
42
  //#endregion
43
- //#region src/resolve-build-identity.ts
44
- const REPO_SLUG_PATTERN = /^[^/\s]+\/[^/\s]+$/;
43
+ //#region src/package-version.ts
44
+ /** Turns a version into the tag name expected to mark its release -- the shared default (and shared option shape) both `resolveBuildIdentity` and `predictNextVersion` accept, so a repo that tags differently only has to say so once per call site, not maintain two independent conventions. */
45
45
  function defaultTagName(version) {
46
46
  return `v${version}`;
47
47
  }
@@ -50,6 +50,7 @@ function isPackageJsonWithVersion(value) {
50
50
  if (!("version" in value)) return false;
51
51
  return typeof value.version === "string" && value.version.length > 0;
52
52
  }
53
+ /** The single semantic-release-managed version, read from `package.json` at `repoRoot`'s root -- shared by `resolveBuildIdentity` (what this build's version is, if released) and `predictNextVersion` (the version to diff commits since). Throws rather than defaulting, matching this package's own "no sensible placeholder identity" stance. */
53
54
  function readPackageVersion(repoRoot) {
54
55
  const packageJsonPath = join(repoRoot, "package.json");
55
56
  const raw = readFileSync(packageJsonPath, "utf8");
@@ -57,6 +58,9 @@ function readPackageVersion(repoRoot) {
57
58
  if (!isPackageJsonWithVersion(parsed)) throw new Error(`${packageJsonPath} must contain a non-empty string "version" field`);
58
59
  return parsed.version;
59
60
  }
61
+ //#endregion
62
+ //#region src/resolve-build-identity.ts
63
+ const REPO_SLUG_PATTERN = /^[^/\s]+\/[^/\s]+$/;
60
64
  function assertRepoSlug(repoSlug) {
61
65
  if (!REPO_SLUG_PATTERN.test(repoSlug)) throw new Error(`repoSlug must be a GitHub "owner/repo" slug, got: ${JSON.stringify(repoSlug)}`);
62
66
  }
@@ -91,24 +95,110 @@ function resolveBuildIdentity(repoRoot, repoSlug, options = {}) {
91
95
  //#endregion
92
96
  //#region src/resolve-predicted-identity.ts
93
97
  /**
94
- * 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.
98
+ * Upgrades an unreleased build's displayed version to a predicted one (e.g. the version `predictNextVersion` computes the next release will be), without ever upgrading its URL.
95
99
  *
96
- * 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.
100
+ * 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 result is `kind: 'predicted'` with `version` set to that predicted label -- `url`/`date`/`commit` are copied verbatim from `build`, still describing the real commit, never a release page that doesn't exist yet. With no usable prediction, `build` is returned unchanged (still a valid `DisplayIdentity`, since every `BuildIdentity` kind is also a `DisplayIdentity` kind).
97
101
  *
98
- * 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.
102
+ * This function does no git or filesystem access of its own: predicting the next version is entirely the caller's job, typically `predictNextVersion` (`./predict-next-version`). It exists to keep that prediction, and the guarantee `resolveBuildIdentity` makes about `url`, cleanly separate.
99
103
  */
100
104
  function resolvePredictedIdentity(build, predictedVersion) {
101
105
  if (build.kind === "release") return build;
102
106
  const trimmedPrediction = predictedVersion?.trim();
103
107
  if (trimmedPrediction === void 0 || trimmedPrediction.length === 0) return build;
104
108
  return {
105
- kind: "commit",
109
+ kind: "predicted",
106
110
  version: trimmedPrediction,
107
111
  url: build.url,
108
112
  date: build.date,
109
- commit: build.commit,
110
- predicted: true
113
+ commit: build.commit
111
114
  };
112
115
  }
113
116
  //#endregion
114
- export { resolveBuildIdentity, resolvePredictedIdentity };
117
+ //#region src/predict-next-version.ts
118
+ const GIT_FORMAT = "%x00%H%x00%B";
119
+ const FIELD_SEP = "\0";
120
+ /**
121
+ * Pure parsing of `git log --format=%x00%H%x00%B` output -- split out so it's testable against a raw string fixture with no real git process. NUL is git's own pretty-format escape for a real NUL byte, the one byte git guarantees can never appear inside a commit hash or message (git refuses to store one), making it the only delimiter genuinely safe against arbitrary commit content. Each record is `<hash><NUL><message>`, with a leading NUL on the whole format string, so splitting the entire raw output on NUL and dropping the resulting leading empty token leaves an exact (hash, message) pairing for every commit. Git appends its own trailing newline per record (tformat behaviour); trim() absorbs it the same way it absorbs genuine trailing blank lines in a real commit body.
122
+ */
123
+ function parseGitLogOutput(raw) {
124
+ const tokens = raw.split(FIELD_SEP);
125
+ tokens.shift();
126
+ const commits = [];
127
+ for (let index = 0; index < tokens.length; index += 2) commits.push({
128
+ hash: tokens[index] ?? "",
129
+ message: (tokens[index + 1] ?? "").trim()
130
+ });
131
+ return commits;
132
+ }
133
+ function readCommitsSince(repoRoot, sinceTag) {
134
+ return parseGitLogOutput(execFileSync("git", [
135
+ "log",
136
+ `${sinceTag}..HEAD`,
137
+ `--format=${GIT_FORMAT}`
138
+ ], {
139
+ cwd: repoRoot,
140
+ encoding: "utf-8"
141
+ }));
142
+ }
143
+ function isReleaseLevel(value) {
144
+ return value === "major" || value === "minor" || value === "patch";
145
+ }
146
+ /** A trivial major.minor.patch increment -- every version this package works with is a plain X.Y.Z, never a prerelease or build-metadata identifier (see `resolveBuildIdentity`'s own tag convention), so there is no broader semver grammar here worth delegating to a library for. */
147
+ function bumpVersion(version, releaseType) {
148
+ const [major = 0, minor = 0, patch = 0] = version.split(".").map(Number);
149
+ if (releaseType === "major") return `${String(major + 1)}.0.0`;
150
+ if (releaseType === "minor") return `${String(major)}.${String(minor + 1)}.0`;
151
+ return `${String(major)}.${String(minor)}.${String(patch + 1)}`;
152
+ }
153
+ const NOOP_LOGGER = {
154
+ log: () => void 0,
155
+ error: () => void 0
156
+ };
157
+ /**
158
+ * Predicts the version this repo's next release would be, from commits since the last tagged release -- WITHOUT running semantic-release's own top-level orchestrator. That orchestrator's core (not any plugin) verifies push access to the remote as part of resolving branches before analysis ever runs: genuinely slow, and needing credentials a prediction has no real reason to hold. This calls `analyzeCommits` directly instead -- no network, no registry lookups -- exactly the same hook `@semantic-release/commit-analyzer` exports, supplied by the caller (see `AnalyzeCommits`'s own doc comment for why this package never imports that module itself).
159
+ *
160
+ * Returns `undefined` when there is genuinely no predicted release -- no commits since the last tag, or none of them are release-worthy under `releaseRules` -- the same "nothing to report" case `resolvePredictedIdentity` already treats as valid, not an error. Throws for a genuine setup problem instead of defaulting: `repoRoot` isn't a git repository, or `package.json` has no usable version.
161
+ *
162
+ * @param repoRoot Path to the git working tree to inspect (must contain `package.json` at its root, same as `resolveBuildIdentity`).
163
+ * @param releaseRules The commit-analyzer release rules this repo's own commit convention defines.
164
+ * @param analyzeCommits `@semantic-release/commit-analyzer`'s own `analyzeCommits` export -- `loadCommitAnalyzer` (`./load-commit-analyzer`) is the tested way to obtain one.
165
+ */
166
+ async function predictNextVersion(repoRoot, releaseRules, analyzeCommits, options = {}) {
167
+ const lastVersion = readPackageVersion(repoRoot);
168
+ const tagName = (options.tagName ?? defaultTagName)(lastVersion);
169
+ const logger = options.logger ?? NOOP_LOGGER;
170
+ const commits = readCommitsSince(repoRoot, tagName);
171
+ if (commits.length === 0) return;
172
+ const releaseType = await analyzeCommits({ releaseRules: releaseRules.map((rule) => ({ ...rule })) }, {
173
+ commits,
174
+ logger,
175
+ cwd: repoRoot
176
+ });
177
+ if (!isReleaseLevel(releaseType)) return;
178
+ return bumpVersion(lastVersion, releaseType);
179
+ }
180
+ //#endregion
181
+ //#region src/load-commit-analyzer.ts
182
+ const COMMIT_ANALYZER_SPECIFIER = "@semantic-release/commit-analyzer";
183
+ function hasAnalyzeCommits(value) {
184
+ if (typeof value !== "object" || value === null) return false;
185
+ if (!("analyzeCommits" in value)) return false;
186
+ return typeof value.analyzeCommits === "function";
187
+ }
188
+ /**
189
+ * Loads `@semantic-release/commit-analyzer`'s own `analyzeCommits` export -- the tested, correct way to obtain the `AnalyzeCommits` implementation `predictNextVersion` (`./predict-next-version`) needs. Split out from `predictNextVersion` itself so that function stays a pure, easily-fakeable function of its own arguments; this is the one place in the package that actually touches the untyped module.
190
+ *
191
+ * `@semantic-release/commit-analyzer` is an optional peer dependency (see this package's own `package.json`): not required to use `resolveBuildIdentity`/`resolvePredictedIdentity`, only to call this function. Throws a clear error, rather than a bare "Cannot find module", when it isn't installed or doesn't export `analyzeCommits` -- a version bump of the real package removing or renaming that export fails loudly here, not silently.
192
+ */
193
+ async function loadCommitAnalyzer() {
194
+ let commitAnalyzerModule;
195
+ try {
196
+ commitAnalyzerModule = await import(COMMIT_ANALYZER_SPECIFIER);
197
+ } catch (error) {
198
+ throw new Error("@semantic-release/commit-analyzer is not installed -- it is an optional peer dependency of @exadev/build-identity, required only for loadCommitAnalyzer/predictNextVersion.", { cause: error });
199
+ }
200
+ if (!hasAnalyzeCommits(commitAnalyzerModule)) throw new Error("@semantic-release/commit-analyzer has no analyzeCommits export.");
201
+ return commitAnalyzerModule.analyzeCommits;
202
+ }
203
+ //#endregion
204
+ export { loadCommitAnalyzer, predictNextVersion, resolveBuildIdentity, resolvePredictedIdentity };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exadev/build-identity",
3
- "version": "1.1.0",
3
+ "version": "2.1.0",
4
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
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -12,6 +12,9 @@
12
12
  "publishConfig": {
13
13
  "access": "public"
14
14
  },
15
+ "bin": {
16
+ "build-identity": "./dist/cli.js"
17
+ },
15
18
  "main": "./dist/index.cjs",
16
19
  "module": "./dist/index.js",
17
20
  "types": "./dist/index.d.ts",
@@ -33,18 +36,28 @@
33
36
  "version",
34
37
  "git",
35
38
  "release",
36
- "commit"
39
+ "commit",
40
+ "cli"
37
41
  ],
38
42
  "repository": {
39
43
  "type": "git",
40
44
  "url": "git+https://github.com/ExaDev/build-identity.git"
41
45
  },
46
+ "peerDependencies": {
47
+ "@semantic-release/commit-analyzer": "^13.0.1"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "@semantic-release/commit-analyzer": {
51
+ "optional": true
52
+ }
53
+ },
42
54
  "devDependencies": {
43
55
  "@arethetypeswrong/cli": "0.18.5",
44
56
  "@commitlint/cli": "21.2.1",
45
57
  "@commitlint/config-conventional": "21.2.0",
46
58
  "@exadev/eslint-config": "^2.10.4",
47
59
  "@semantic-release/changelog": "7.0.0",
60
+ "@semantic-release/commit-analyzer": "13.0.1",
48
61
  "@semantic-release/git": "11.0.1",
49
62
  "@types/node": "24.13.3",
50
63
  "@vitest/coverage-v8": "4.1.10",
@@ -71,5 +84,8 @@
71
84
  "prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && tsdown && publint && attw --pack",
72
85
  "prepare": "husky",
73
86
  "release": "semantic-release"
87
+ },
88
+ "dependencies": {
89
+ "commander": "^14.0.3"
74
90
  }
75
91
  }