@exadev/build-identity 1.1.0 → 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 +53 -6
- package/dist/index.cjs +101 -9
- package/dist/index.d.cts +56 -6
- package/dist/index.d.ts +56 -6
- package/dist/index.js +100 -10
- package/package.json +10 -1
package/README.md
CHANGED
|
@@ -50,22 +50,69 @@ interface ResolveBuildIdentityOptions {
|
|
|
50
50
|
## `resolvePredictedIdentity(build, predictedVersion)`
|
|
51
51
|
|
|
52
52
|
```ts
|
|
53
|
-
|
|
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;
|
|
54
56
|
```
|
|
55
57
|
|
|
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
|
|
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:
|
|
57
59
|
|
|
58
60
|
- 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'
|
|
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.
|
|
60
62
|
- With no usable prediction (`undefined`, empty, or whitespace-only), `build` is returned unchanged.
|
|
61
63
|
|
|
62
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.
|
|
63
65
|
|
|
64
66
|
```ts
|
|
65
|
-
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
|
+
];
|
|
66
113
|
|
|
67
114
|
const build = resolveBuildIdentity(process.cwd(), 'exadev/build-identity');
|
|
68
|
-
const predictedVersion = await
|
|
115
|
+
const predictedVersion = await predictNextVersion(process.cwd(), releaseRules, await loadCommitAnalyzer());
|
|
69
116
|
const identity = resolvePredictedIdentity(build, predictedVersion);
|
|
70
117
|
```
|
|
71
118
|
|
|
@@ -111,7 +158,7 @@ export default defineConfig({
|
|
|
111
158
|
});
|
|
112
159
|
```
|
|
113
160
|
|
|
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.
|
|
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.
|
|
115
162
|
|
|
116
163
|
## Conventions
|
|
117
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
|
}
|
|
@@ -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
|
|
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
|
|
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
|
|
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: "
|
|
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
|
|
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
|
|
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
|
|
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):
|
|
55
|
-
|
|
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
|
|
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
|
|
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
|
|
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):
|
|
55
|
-
|
|
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/
|
|
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
|
}
|
|
@@ -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
|
|
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
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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",
|