@exadev/build-identity 1.0.1 → 2.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/README.md +59 -10
- package/dist/index.cjs +105 -10
- package/dist/index.d.cts +60 -6
- package/dist/index.d.ts +60 -6
- package/dist/index.js +104 -11
- package/package.json +10 -1
package/README.md
CHANGED
|
@@ -18,17 +18,17 @@ pnpm add -D @exadev/build-identity
|
|
|
18
18
|
import { resolveBuildIdentity } from '@exadev/build-identity';
|
|
19
19
|
|
|
20
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' }
|
|
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', commit: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2' }
|
|
22
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' }
|
|
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
26
|
## `resolveBuildIdentity(repoRoot, repoSlug, options?)`
|
|
27
27
|
|
|
28
28
|
```ts
|
|
29
29
|
type BuildIdentity =
|
|
30
|
-
| { kind: 'release'; version: string; url: string; date: string }
|
|
31
|
-
| { kind: 'commit'; version: string; url: string; date: string };
|
|
30
|
+
| { kind: 'release'; version: string; url: string; date: string; commit: string }
|
|
31
|
+
| { kind: 'commit'; version: string; url: string; date: string; commit: string };
|
|
32
32
|
|
|
33
33
|
function resolveBuildIdentity(repoRoot: string, repoSlug: string, options?: ResolveBuildIdentityOptions): BuildIdentity;
|
|
34
34
|
|
|
@@ -43,27 +43,76 @@ interface ResolveBuildIdentityOptions {
|
|
|
43
43
|
|
|
44
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
45
|
|
|
46
|
+
**`commit`** is always the full git commit SHA of the exact commit this build resolves to, in both branches -- present so a caller who needs the raw SHA (to compare two builds for identity, or to construct its own link) never has to parse it back out of `url`, which never carries one at all in the `'release'` case.
|
|
47
|
+
|
|
46
48
|
`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
49
|
|
|
48
50
|
## `resolvePredictedIdentity(build, predictedVersion)`
|
|
49
51
|
|
|
50
52
|
```ts
|
|
51
|
-
|
|
53
|
+
type DisplayIdentity = { kind: 'release' | 'predicted' | 'commit'; version: string; url: string; date: string; commit: string };
|
|
54
|
+
|
|
55
|
+
function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: string | undefined): DisplayIdentity;
|
|
52
56
|
```
|
|
53
57
|
|
|
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
|
|
58
|
+
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:
|
|
55
59
|
|
|
56
60
|
- 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'
|
|
61
|
+
- 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.
|
|
58
62
|
- With no usable prediction (`undefined`, empty, or whitespace-only), `build` is returned unchanged.
|
|
59
63
|
|
|
60
64
|
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
65
|
|
|
62
66
|
```ts
|
|
63
|
-
import { resolveBuildIdentity, resolvePredictedIdentity } from '@exadev/build-identity';
|
|
67
|
+
import { resolveBuildIdentity, resolvePredictedIdentity, predictNextVersion, loadCommitAnalyzer } from '@exadev/build-identity';
|
|
68
|
+
|
|
69
|
+
const build = resolveBuildIdentity(process.cwd(), 'exadev/build-identity');
|
|
70
|
+
const predictedVersion = await predictNextVersion(process.cwd(), releaseRules, await loadCommitAnalyzer());
|
|
71
|
+
const identity = resolvePredictedIdentity(build, predictedVersion);
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## `predictNextVersion(repoRoot, releaseRules, analyzeCommits, options?)`
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
type ReleaseLevel = 'major' | 'minor' | 'patch';
|
|
78
|
+
type ReleaseRule = { type: string; release: ReleaseLevel | false } | { breaking: true; release: ReleaseLevel | false };
|
|
79
|
+
type AnalyzeCommits = (pluginConfig: unknown, context: unknown) => Promise<unknown>;
|
|
80
|
+
|
|
81
|
+
function predictNextVersion(repoRoot: string, releaseRules: readonly ReleaseRule[], analyzeCommits: AnalyzeCommits, options?: PredictNextVersionOptions): Promise<string | undefined>;
|
|
82
|
+
|
|
83
|
+
interface PredictNextVersionOptions {
|
|
84
|
+
tagName?: (version: string) => string; // matches resolveBuildIdentity's own option of the same name
|
|
85
|
+
logger?: { log: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; // defaults to discarding
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
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.
|
|
90
|
+
|
|
91
|
+
`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.
|
|
92
|
+
|
|
93
|
+
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.
|
|
94
|
+
|
|
95
|
+
**`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.
|
|
96
|
+
|
|
97
|
+
## `loadCommitAnalyzer()`
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
function loadCommitAnalyzer(): Promise<AnalyzeCommits>;
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
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`.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { resolveBuildIdentity, resolvePredictedIdentity, predictNextVersion, loadCommitAnalyzer } from '@exadev/build-identity';
|
|
107
|
+
|
|
108
|
+
const releaseRules = [
|
|
109
|
+
{ breaking: true, release: 'major' },
|
|
110
|
+
{ type: 'feat', release: 'minor' },
|
|
111
|
+
{ type: 'fix', release: 'patch' },
|
|
112
|
+
];
|
|
64
113
|
|
|
65
114
|
const build = resolveBuildIdentity(process.cwd(), 'exadev/build-identity');
|
|
66
|
-
const predictedVersion = await
|
|
115
|
+
const predictedVersion = await predictNextVersion(process.cwd(), releaseRules, await loadCommitAnalyzer());
|
|
67
116
|
const identity = resolvePredictedIdentity(build, predictedVersion);
|
|
68
117
|
```
|
|
69
118
|
|
|
@@ -109,7 +158,7 @@ export default defineConfig({
|
|
|
109
158
|
});
|
|
110
159
|
```
|
|
111
160
|
|
|
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.
|
|
161
|
+
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.
|
|
113
162
|
|
|
114
163
|
## Conventions
|
|
115
164
|
|
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/
|
|
45
|
-
|
|
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
|
}
|
|
@@ -78,36 +82,127 @@ function resolveBuildIdentity(repoRoot, repoSlug, options = {}) {
|
|
|
78
82
|
kind: "release",
|
|
79
83
|
version,
|
|
80
84
|
url: `https://github.com/${repoSlug}/releases/tag/${tagName}`,
|
|
81
|
-
date: head.date
|
|
85
|
+
date: head.date,
|
|
86
|
+
commit: head.fullSha
|
|
82
87
|
};
|
|
83
88
|
return {
|
|
84
89
|
kind: "commit",
|
|
85
90
|
version: head.shortSha,
|
|
86
91
|
url: `https://github.com/${repoSlug}/commit/${head.fullSha}`,
|
|
87
|
-
date: head.date
|
|
92
|
+
date: head.date,
|
|
93
|
+
commit: head.fullSha
|
|
88
94
|
};
|
|
89
95
|
}
|
|
90
96
|
//#endregion
|
|
91
97
|
//#region src/resolve-predicted-identity.ts
|
|
92
98
|
/**
|
|
93
|
-
* Upgrades an unreleased build's displayed version to a predicted one (e.g. the version
|
|
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.
|
|
94
100
|
*
|
|
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
|
|
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).
|
|
96
102
|
*
|
|
97
|
-
* This function does no git or filesystem access of its own: predicting the next version
|
|
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.
|
|
98
104
|
*/
|
|
99
105
|
function resolvePredictedIdentity(build, predictedVersion) {
|
|
100
106
|
if (build.kind === "release") return build;
|
|
101
107
|
const trimmedPrediction = predictedVersion?.trim();
|
|
102
108
|
if (trimmedPrediction === void 0 || trimmedPrediction.length === 0) return build;
|
|
103
109
|
return {
|
|
104
|
-
kind: "
|
|
110
|
+
kind: "predicted",
|
|
105
111
|
version: trimmedPrediction,
|
|
106
112
|
url: build.url,
|
|
107
113
|
date: build.date,
|
|
108
|
-
|
|
114
|
+
commit: build.commit
|
|
109
115
|
};
|
|
110
116
|
}
|
|
111
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;
|
|
112
207
|
exports.resolveBuildIdentity = resolveBuildIdentity;
|
|
113
208
|
exports.resolvePredictedIdentity = resolvePredictedIdentity;
|
package/dist/index.d.cts
CHANGED
|
@@ -12,6 +12,8 @@ type BuildIdentity = {
|
|
|
12
12
|
readonly url: string;
|
|
13
13
|
/** ISO 8601 date of the commit this release was tagged at. */
|
|
14
14
|
readonly date: string;
|
|
15
|
+
/** Full git commit SHA of the commit this release was tagged at -- the same commit `url` and `date` describe, exposed as its own field so a caller never has to parse it back out of a release URL that never contains one. */
|
|
16
|
+
readonly commit: string;
|
|
15
17
|
} | {
|
|
16
18
|
readonly kind: 'commit';
|
|
17
19
|
/** The short commit hash this build was made from -- there is no released version to show yet. */
|
|
@@ -20,6 +22,8 @@ type BuildIdentity = {
|
|
|
20
22
|
readonly url: string;
|
|
21
23
|
/** ISO 8601 date of this commit. */
|
|
22
24
|
readonly date: string;
|
|
25
|
+
/** Full git commit SHA this build was made from -- the same commit `version`'s short form and `url`'s permalink describe. */
|
|
26
|
+
readonly commit: string;
|
|
23
27
|
};
|
|
24
28
|
interface ResolveBuildIdentityOptions {
|
|
25
29
|
/**
|
|
@@ -27,6 +31,16 @@ interface ResolveBuildIdentityOptions {
|
|
|
27
31
|
*/
|
|
28
32
|
readonly tagName?: (version: string) => string;
|
|
29
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
|
+
}
|
|
30
44
|
//#endregion
|
|
31
45
|
//#region src/resolve-build-identity.d.ts
|
|
32
46
|
/**
|
|
@@ -41,14 +55,54 @@ declare function resolveBuildIdentity(repoRoot: string, repoSlug: string, option
|
|
|
41
55
|
//#endregion
|
|
42
56
|
//#region src/resolve-predicted-identity.d.ts
|
|
43
57
|
/**
|
|
44
|
-
* Upgrades an unreleased build's displayed version to a predicted one (e.g. the version
|
|
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.
|
|
45
59
|
*
|
|
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
|
|
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).
|
|
47
61
|
*
|
|
48
|
-
* This function does no git or filesystem access of its own: predicting the next version
|
|
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.
|
|
49
63
|
*/
|
|
50
|
-
declare function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: string | undefined):
|
|
51
|
-
|
|
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;
|
|
52
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>;
|
|
53
107
|
//#endregion
|
|
54
|
-
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
|
@@ -12,6 +12,8 @@ type BuildIdentity = {
|
|
|
12
12
|
readonly url: string;
|
|
13
13
|
/** ISO 8601 date of the commit this release was tagged at. */
|
|
14
14
|
readonly date: string;
|
|
15
|
+
/** Full git commit SHA of the commit this release was tagged at -- the same commit `url` and `date` describe, exposed as its own field so a caller never has to parse it back out of a release URL that never contains one. */
|
|
16
|
+
readonly commit: string;
|
|
15
17
|
} | {
|
|
16
18
|
readonly kind: 'commit';
|
|
17
19
|
/** The short commit hash this build was made from -- there is no released version to show yet. */
|
|
@@ -20,6 +22,8 @@ type BuildIdentity = {
|
|
|
20
22
|
readonly url: string;
|
|
21
23
|
/** ISO 8601 date of this commit. */
|
|
22
24
|
readonly date: string;
|
|
25
|
+
/** Full git commit SHA this build was made from -- the same commit `version`'s short form and `url`'s permalink describe. */
|
|
26
|
+
readonly commit: string;
|
|
23
27
|
};
|
|
24
28
|
interface ResolveBuildIdentityOptions {
|
|
25
29
|
/**
|
|
@@ -27,6 +31,16 @@ interface ResolveBuildIdentityOptions {
|
|
|
27
31
|
*/
|
|
28
32
|
readonly tagName?: (version: string) => string;
|
|
29
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
|
+
}
|
|
30
44
|
//#endregion
|
|
31
45
|
//#region src/resolve-build-identity.d.ts
|
|
32
46
|
/**
|
|
@@ -41,14 +55,54 @@ declare function resolveBuildIdentity(repoRoot: string, repoSlug: string, option
|
|
|
41
55
|
//#endregion
|
|
42
56
|
//#region src/resolve-predicted-identity.d.ts
|
|
43
57
|
/**
|
|
44
|
-
* Upgrades an unreleased build's displayed version to a predicted one (e.g. the version
|
|
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.
|
|
45
59
|
*
|
|
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
|
|
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).
|
|
47
61
|
*
|
|
48
|
-
* This function does no git or filesystem access of its own: predicting the next version
|
|
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.
|
|
49
63
|
*/
|
|
50
|
-
declare function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: string | undefined):
|
|
51
|
-
|
|
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;
|
|
52
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>;
|
|
53
107
|
//#endregion
|
|
54
|
-
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/
|
|
44
|
-
|
|
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
|
}
|
|
@@ -77,35 +81,124 @@ function resolveBuildIdentity(repoRoot, repoSlug, options = {}) {
|
|
|
77
81
|
kind: "release",
|
|
78
82
|
version,
|
|
79
83
|
url: `https://github.com/${repoSlug}/releases/tag/${tagName}`,
|
|
80
|
-
date: head.date
|
|
84
|
+
date: head.date,
|
|
85
|
+
commit: head.fullSha
|
|
81
86
|
};
|
|
82
87
|
return {
|
|
83
88
|
kind: "commit",
|
|
84
89
|
version: head.shortSha,
|
|
85
90
|
url: `https://github.com/${repoSlug}/commit/${head.fullSha}`,
|
|
86
|
-
date: head.date
|
|
91
|
+
date: head.date,
|
|
92
|
+
commit: head.fullSha
|
|
87
93
|
};
|
|
88
94
|
}
|
|
89
95
|
//#endregion
|
|
90
96
|
//#region src/resolve-predicted-identity.ts
|
|
91
97
|
/**
|
|
92
|
-
* Upgrades an unreleased build's displayed version to a predicted one (e.g. the version
|
|
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.
|
|
93
99
|
*
|
|
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
|
|
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).
|
|
95
101
|
*
|
|
96
|
-
* This function does no git or filesystem access of its own: predicting the next version
|
|
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.
|
|
97
103
|
*/
|
|
98
104
|
function resolvePredictedIdentity(build, predictedVersion) {
|
|
99
105
|
if (build.kind === "release") return build;
|
|
100
106
|
const trimmedPrediction = predictedVersion?.trim();
|
|
101
107
|
if (trimmedPrediction === void 0 || trimmedPrediction.length === 0) return build;
|
|
102
108
|
return {
|
|
103
|
-
kind: "
|
|
109
|
+
kind: "predicted",
|
|
104
110
|
version: trimmedPrediction,
|
|
105
111
|
url: build.url,
|
|
106
112
|
date: build.date,
|
|
107
|
-
|
|
113
|
+
commit: build.commit
|
|
108
114
|
};
|
|
109
115
|
}
|
|
110
116
|
//#endregion
|
|
111
|
-
|
|
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": "
|
|
3
|
+
"version": "2.0.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,
|
|
@@ -39,12 +39,21 @@
|
|
|
39
39
|
"type": "git",
|
|
40
40
|
"url": "git+https://github.com/ExaDev/build-identity.git"
|
|
41
41
|
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@semantic-release/commit-analyzer": "^13.0.1"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"@semantic-release/commit-analyzer": {
|
|
47
|
+
"optional": true
|
|
48
|
+
}
|
|
49
|
+
},
|
|
42
50
|
"devDependencies": {
|
|
43
51
|
"@arethetypeswrong/cli": "0.18.5",
|
|
44
52
|
"@commitlint/cli": "21.2.1",
|
|
45
53
|
"@commitlint/config-conventional": "21.2.0",
|
|
46
54
|
"@exadev/eslint-config": "^2.10.4",
|
|
47
55
|
"@semantic-release/changelog": "7.0.0",
|
|
56
|
+
"@semantic-release/commit-analyzer": "13.0.1",
|
|
48
57
|
"@semantic-release/git": "11.0.1",
|
|
49
58
|
"@types/node": "24.13.3",
|
|
50
59
|
"@vitest/coverage-v8": "4.1.10",
|