Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 53 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,22 +50,69 @@ interface ResolveBuildIdentityOptions {
## `resolvePredictedIdentity(build, predictedVersion)`

```ts
function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: string | undefined): BuildIdentity & { predicted?: boolean };
type DisplayIdentity = { kind: 'release' | 'predicted' | 'commit'; version: string; url: string; date: string; commit: string };

function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: string | undefined): DisplayIdentity;
```

An unreleased build's `version` is a short commit hash, which isn't always what you want to show a user -- often what's actually useful is *the version this commit will become once it releases*. This function lets a caller upgrade the displayed label to a predicted version (e.g. computed by running a commit-analyzer-style tool such as `semantic-release`'s own dry-run mode) without ever upgrading the URL to a release page that doesn't exist yet:
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:

- If `build.kind === 'release'`, it is returned **completely unchanged** -- a confirmed release always wins outright, prediction or not.
- Otherwise, when `predictedVersion` is a real, non-empty string, the result's `version` becomes that (trimmed) prediction, `predicted` becomes `true`, and `url`/`date` are copied verbatim from `build` -- the link still points at the real commit.
- 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.
- With no usable prediction (`undefined`, empty, or whitespace-only), `build` is returned unchanged.

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.

```ts
import { resolveBuildIdentity, resolvePredictedIdentity } from '@exadev/build-identity';
import { resolveBuildIdentity, resolvePredictedIdentity, predictNextVersion, loadCommitAnalyzer } from '@exadev/build-identity';

const build = resolveBuildIdentity(process.cwd(), 'exadev/build-identity');
const predictedVersion = await predictNextVersion(process.cwd(), releaseRules, await loadCommitAnalyzer());
const identity = resolvePredictedIdentity(build, predictedVersion);
```

## `predictNextVersion(repoRoot, releaseRules, analyzeCommits, options?)`

```ts
type ReleaseLevel = 'major' | 'minor' | 'patch';
type ReleaseRule = { type: string; release: ReleaseLevel | false } | { breaking: true; release: ReleaseLevel | false };
type AnalyzeCommits = (pluginConfig: unknown, context: unknown) => Promise<unknown>;

function predictNextVersion(repoRoot: string, releaseRules: readonly ReleaseRule[], analyzeCommits: AnalyzeCommits, options?: PredictNextVersionOptions): Promise<string | undefined>;

interface PredictNextVersionOptions {
tagName?: (version: string) => string; // matches resolveBuildIdentity's own option of the same name
logger?: { log: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; // defaults to discarding
}
```

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.

`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.

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.

**`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.

## `loadCommitAnalyzer()`

```ts
function loadCommitAnalyzer(): Promise<AnalyzeCommits>;
```

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`.

```ts
import { resolveBuildIdentity, resolvePredictedIdentity, predictNextVersion, loadCommitAnalyzer } from '@exadev/build-identity';

const releaseRules = [
{ breaking: true, release: 'major' },
{ type: 'feat', release: 'minor' },
{ type: 'fix', release: 'patch' },
];

const build = resolveBuildIdentity(process.cwd(), 'exadev/build-identity');
const predictedVersion = await computeNextVersionSomehow(); // out of scope for this package
const predictedVersion = await predictNextVersion(process.cwd(), releaseRules, await loadCommitAnalyzer());
const identity = resolvePredictedIdentity(build, predictedVersion);
```

Expand Down Expand Up @@ -111,7 +158,7 @@ export default defineConfig({
});
```

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.
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.

## Conventions

Expand Down
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,21 @@
"type": "git",
"url": "git+https://github.com/ExaDev/build-identity.git"
},
"peerDependencies": {
"@semantic-release/commit-analyzer": "^13.0.1"
},
"peerDependenciesMeta": {
"@semantic-release/commit-analyzer": {
"optional": true
}
},
"devDependencies": {
"@arethetypeswrong/cli": "0.18.5",
"@commitlint/cli": "21.2.1",
"@commitlint/config-conventional": "21.2.0",
"@exadev/eslint-config": "^2.10.4",
"@semantic-release/changelog": "7.0.0",
"@semantic-release/commit-analyzer": "13.0.1",
"@semantic-release/git": "11.0.1",
"@types/node": "24.13.3",
"@vitest/coverage-v8": "4.1.10",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export { resolveBuildIdentity } from './resolve-build-identity';
export { resolvePredictedIdentity } from './resolve-predicted-identity';
export type { BuildIdentity, ResolveBuildIdentityOptions } from './types';
export { predictNextVersion } from './predict-next-version';
export { loadCommitAnalyzer } from './load-commit-analyzer';
export type { BuildIdentity, DisplayIdentity, ResolveBuildIdentityOptions } from './types';
export type { AnalyzeCommits, PredictNextVersionOptions, ReleaseLevel, ReleaseRule } from './predict-next-version';
25 changes: 25 additions & 0 deletions src/load-commit-analyzer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { loadCommitAnalyzer } from './load-commit-analyzer';

describe('loadCommitAnalyzer', () => {
it('resolves to a callable analyzeCommits function', async () => {
const analyzeCommits = await loadCommitAnalyzer();

expect(typeof analyzeCommits).toBe('function');
});

it('resolves to a function that genuinely runs commit-analyzer -- not a stub -- when called with real commits and rules', async () => {
const analyzeCommits = await loadCommitAnalyzer();

const releaseType = await analyzeCommits(
{ releaseRules: [{ type: 'feat', release: 'minor' }] },
{
commits: [{ hash: 'abc123', message: 'feat: add a thing' }],
logger: { log: () => undefined, error: () => undefined },
cwd: process.cwd(),
},
);

expect(releaseType).toBe('minor');
});
});
30 changes: 30 additions & 0 deletions src/load-commit-analyzer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { AnalyzeCommits } from './predict-next-version';

// Read through a variable, never a string literal: TypeScript resolves an import()'s type by statically looking up a declaration for its specifier, and @semantic-release/commit-analyzer ships none. A literal specifier would force a choice between two bad options -- a hand-written ambient declaration (which either lies about a contract this undocumented plugin export never promised, or, left as a bare `declare module` with no members, implicitly types the *entire* module `any`, not just the one export used here) or none at all (a compile error). Reading the specifier through a variable sidesteps that lookup entirely: TypeScript never attempts to resolve types for a dynamically-computed specifier, so the result is genuinely `unknown` -- exactly what it should be for a module with no real, published contract -- verified by the runtime guard below rather than asserted by a type this package has no authority to make.
const COMMIT_ANALYZER_SPECIFIER = '@semantic-release/commit-analyzer';

function hasAnalyzeCommits(value: unknown): value is { analyzeCommits: AnalyzeCommits } {
if (typeof value !== 'object' || value === null) return false;
if (!('analyzeCommits' in value)) return false;
return typeof value.analyzeCommits === 'function';
}

/**
* 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.
*
* `@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.
*/
export async function loadCommitAnalyzer(): Promise<AnalyzeCommits> {
let commitAnalyzerModule: unknown;
try {
commitAnalyzerModule = await import(COMMIT_ANALYZER_SPECIFIER);
} catch (error) {
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 });
}

if (!hasAnalyzeCommits(commitAnalyzerModule)) {
throw new Error('@semantic-release/commit-analyzer has no analyzeCommits export.');
}

return commitAnalyzerModule.analyzeCommits;
}
28 changes: 28 additions & 0 deletions src/package-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

/** 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. */
export function defaultTagName(version: string): string {
return `v${version}`;
}

function isPackageJsonWithVersion(value: unknown): value is { version: string } {
if (typeof value !== 'object' || value === null) {
return false;
}
if (!('version' in value)) {
return false;
}
return typeof value.version === 'string' && value.version.length > 0;
}

/** 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. */
export function readPackageVersion(repoRoot: string): string {
const packageJsonPath = join(repoRoot, 'package.json');
const raw = readFileSync(packageJsonPath, 'utf8');
const parsed: unknown = JSON.parse(raw);
if (!isPackageJsonWithVersion(parsed)) {
throw new Error(`${packageJsonPath} must contain a non-empty string "version" field`);
}
return parsed.version;
}
Loading