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