Skip to content
40 changes: 26 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,21 @@ type BuildIdentity =
function resolveBuildIdentity(repoRoot: string, repoSlug: string, options?: ResolveBuildIdentityOptions): BuildIdentity;

interface ResolveBuildIdentityOptions {
tagName?: (version: string) => string; // defaults to (version) => `v${version}`
tagTemplate?: string; // defaults to 'v{version}'
}
```

- **`repoRoot`** -- path to the git working tree to inspect. Must contain `package.json` at its root; that file's `"version"` field is the released version this function checks for.
- **`repoRoot`**: path to the git working tree to inspect. The released version comes from the release tag on `HEAD`; no file in it is read.
- **`repoSlug`** -- the GitHub `"owner/repo"` slug used to build both the release and commit URLs (e.g. `"exadev/build-identity"`).
- **`options.tagName`** -- turns the version into the tag name expected to mark its release. The default assumes the common `v<version>` convention (`"1.4.0"` -> `"v1.4.0"`); pass your own function for a repo that tags differently (a bare version, a package-scoped prefix in a monorepo, and so on).
- **`options.tagTemplate`** -- the tag name marking a release, with `{version}` standing in for the version exactly once. The default is the common `v{version}` convention (`"1.4.0"` -> `"v1.4.0"`), the same as semantic-release's default `tagFormat`; pass your own for a repo that tags differently (a bare version, a package-scoped prefix in a monorepo, and so on). Tags are read back through it, so a tag that does not fit it, or that carries a prerelease version, is never a release.

**The one property this function exists to guarantee:** it returns `kind: 'release'` if, and only if, a tag named `tagName(version)` provably points at the exact commit `HEAD` is on right now -- checked live against git (`git tag --list <tag> --points-at HEAD`), never inferred from `package.json`, an environment variable, or any other proxy that could be true before the tag actually exists. Every other case, including a commit sitting directly on top of the commit a release will eventually tag, returns `kind: 'commit'` instead, with `version` set to the short commit hash (there is no released version to show yet) and `url` pointing at that exact commit's permalink (using the full SHA, not the short one, so the link stays a valid, unambiguous permalink).
**The one property this function exists to guarantee:** it returns `kind: 'release'` if, and only if, a release tag provably points at the exact commit `HEAD` is on right now, checked live against git (`git tag --points-at HEAD`), never inferred from a version in `package.json`, an environment variable, or any other proxy that could be true before the tag actually exists. The version is read from that tag, so a repository whose releases are only tags (no release commit bumping `package.json`) resolves correctly; with several release tags on `HEAD` the highest wins, and a lower one on `HEAD` (a backport tagged after a newer release) is still a release. Every other case, including a commit sitting directly on top of the commit a release will eventually tag, returns `kind: 'commit'` instead, with `version` set to the short commit hash (there is no released version to show yet) and `url` pointing at that exact commit's permalink (using the full SHA, not the short one, so the link stays a valid, unambiguous permalink).

Only `HEAD` itself is inspected, never its history, so this works in a shallow clone and never throws for a missing tag. A clone made without tags (a default `actions/checkout`) has no tag at `HEAD` to find and reports `kind: 'commit'` even on a released commit: the honest answer for a release git cannot see. Check out with `fetch-depth: 0` where a released commit should report as one.

**`commit`** is always the full git commit SHA of the exact commit this build resolves to, in both branches -- present so a caller who needs the raw SHA (to compare two builds for identity, or to construct its own link) never has to parse it back out of `url`, which never carries one at all in the `'release'` case.

`resolveBuildIdentity` throws rather than defaulting whenever it can't establish a real identity -- `repoRoot` isn't a git repository, `package.json` is missing or has no non-empty string `"version"` field, or `repoSlug` isn't a real `"owner/repo"` slug. There is no sensible placeholder identity for a build that isn't sitting in real, readable git history.
`resolveBuildIdentity` throws rather than defaulting whenever it can't establish a real identity -- `repoRoot` isn't a git repository, the tag template does not hold `{version}` exactly once, or `repoSlug` isn't a real `"owner/repo"` slug. There is no sensible placeholder identity for a build that isn't sitting in real, readable git history.

## `resolvePredictedIdentity(build, predictedVersion)`

Expand Down Expand Up @@ -85,7 +87,7 @@ type AnalyzeCommits = (pluginConfig: unknown, context: unknown) => Promise<unkno
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
tagTemplate?: string; // matches resolveBuildIdentity's own option of the same name
logger?: { log: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; // defaults to discarding
}
```
Expand All @@ -94,7 +96,9 @@ Predicts the version a repo's next release would be, from the commits since its

`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.
The last release is the one [`findLastRelease`](#findlastreleasereporoot-tagtemplate) finds from the tags, and only the commits since its tag are analysed. It needs the full history and tags: run it on a checkout made with `fetch-depth: 0`.

Returns `undefined` when there is genuinely no predicted release (no commits since the last release tag, or none of them release-worthy): the same "nothing to report" case `resolvePredictedIdentity` already treats as valid, not an error. Throws for a genuine setup problem instead of predicting from a guess, exactly as `findLastRelease` does: a shallow clone, no release tag reachable from `HEAD`, `repoRoot` isn't a git repository, or the tag template is malformed. A wrong base version would predict a release that may already exist as a tag, so there is no fallback, not even a first-release `1.0.0`.

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

Expand All @@ -120,20 +124,28 @@ const predictedVersion = await predictNextVersion(process.cwd(), releaseRules, a
const identity = resolvePredictedIdentity(build, predictedVersion);
```

## `readPackageVersion(repoRoot)`
## `findLastRelease(repoRoot, tagTemplate?)`

```ts
function readPackageVersion(repoRoot: string): string;
interface LastRelease { version: string; tagName: string }

function findLastRelease(repoRoot: string, tagTemplate?: string): LastRelease; // tagTemplate defaults to DEFAULT_TAG_TEMPLATE, 'v{version}'
```

The plain `"version"` field from `package.json` at `repoRoot`, read unconditionally and with no git access at all. This is deliberately not the same thing as `resolveBuildIdentity(...).version`, which is a build *identity* -- the released version only when a tag genuinely points at HEAD, and a short commit hash otherwise. Reach for this one when you want the last released semantic version regardless of whether this checkout happens to be sitting on its tag: an OpenAPI document's `info.version`, a user agent string, anything wanting a stable semver rather than an honest answer about what is deployed.
The last released version and the tag marking it, found the way semantic-release's own core finds it: the highest `major.minor.patch` version among the tags reachable from `HEAD` (`git tag --merged HEAD`) that fit `tagTemplate`. A prerelease tag, a tag in another format and a tag on a commit `HEAD` does not contain (a release cut from another branch) are never the last release. `predictNextVersion` starts from this.

Reach for it directly when you want the last released semantic version regardless of whether this checkout happens to be sitting on its tag: an OpenAPI document's `info.version`, a user agent string, anything wanting a stable semver rather than an honest answer about what is deployed.

It needs the full history and tags (`actions/checkout` with `fetch-depth: 0`), and throws rather than inventing a version when it cannot prove one, saying what to do:

Throws rather than defaulting when `package.json` is missing or its `"version"` is absent, non-string, or empty -- the same stance every other export in this package takes. `defaultTagName` (`v${version}`) is the shared convention `resolveBuildIdentity` and `predictNextVersion` both apply to this value.
- **A shallow clone**, even one with some tags: a higher release tag could sit beyond the cut. Fetch full history and tags first (`git fetch --unshallow --tags`).
- **No release tag reachable from `HEAD`**: the clone was made without its tags (`git fetch --tags`), the template does not match how the repository tags, or it has never released.
- `repoRoot` isn't a git repository, or the template is malformed.

```ts
import { readPackageVersion, resolveBuildIdentity } from '@exadev/build-identity';
import { findLastRelease, resolveBuildIdentity } from '@exadev/build-identity';

const appVersion = readPackageVersion(process.cwd()); // "1.4.0", tagged or not
const appVersion = findLastRelease(process.cwd()).version; // "1.4.0", tagged or not
const build = resolveBuildIdentity(process.cwd(), 'exadev/example'); // "1.4.0" or "a1b2c3d"
```

Expand Down Expand Up @@ -180,7 +192,7 @@ Those are exactly the five fields `DisplayIdentity` declares, under exactly its

### Prediction

`--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.
`--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 release tag, so it needs the full history and tags (`actions/checkout` with `fetch-depth: 0`) and fails in a shallow clone.

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:

Expand Down
18 changes: 11 additions & 7 deletions src/cli-program.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import type { Command } from 'commander';
import { createProgram, DEFAULT_ENV_PREFIX, runBuildIdentity, type CliFlags } from './cli-program';
import { defaultTagName } from './package-version';
import type { ReleaseRule } from './predict-next-version';
import { DEFAULT_TAG_TEMPLATE } from './release-tags';
import { createTestRepo, type TestRepo } from './test-repo';

const COMMIT_TYPE_RULES: readonly ReleaseRule[] = [
Expand All @@ -28,7 +28,7 @@ async function parseArgs(...args: readonly string[]): Promise<Command> {

function flags(overrides: Partial<CliFlags> & Pick<CliFlags, 'repo' | 'root'>): CliFlags {
return {
tagName: defaultTagName,
tagName: DEFAULT_TAG_TEMPLATE,
predict: undefined,
releaseRules: undefined,
releaseRulesFile: undefined,
Expand All @@ -48,7 +48,7 @@ describe('createProgram defaults', () => {
});

it('defaults the tag name to the same v-prefixed convention the library itself defaults to', () => {
expect(createProgram().opts<CliFlags>().tagName('1.4.0')).toBe(defaultTagName('1.4.0'));
expect(createProgram().opts<CliFlags>().tagName).toBe(DEFAULT_TAG_TEMPLATE);
});
});

Expand All @@ -66,7 +66,11 @@ describe('createProgram argument parsing', () => {
});

it('rejects a --tag-name template with no {version} placeholder', async () => {
await expect(parseArgs('--repo', 'exadev/example', '--tag-name', 'latest')).rejects.toThrow(/\{version\}/);
await expect(parseArgs('--repo', 'exadev/example', '--tag-name', 'latest')).rejects.toThrow(/--tag-name.*\{version\}/);
});

it('rejects a --tag-name template with the {version} placeholder more than once, since a tag could not be read back into one version', async () => {
await expect(parseArgs('--repo', 'exadev/example', '--tag-name', '{version}-{version}')).rejects.toThrow(/--tag-name.*exactly once/);
});

it('rejects --release-rules that is not valid JSON', async () => {
Expand All @@ -77,11 +81,11 @@ describe('createProgram argument parsing', () => {
await expect(parseArgs('--repo', 'exadev/example', '--release-rules', '[{"type":"feat","release":"huge"}]')).rejects.toThrow(/entry 0 must be/);
});

it('turns a --tag-name template into the substituting function both library calls receive', () => {
it('passes a valid --tag-name template through unchanged, for both library calls to read tags with', () => {
// parseOptions rather than parseAsync: this asserts the option coercion alone, without also running the action against a real repository.
const program = silentProgram();
program.parseOptions(['--repo', 'exadev/example', '--tag-name', 'release-{version}']);
expect(program.opts<CliFlags>().tagName('2.0.0')).toBe('release-2.0.0');
expect(program.opts<CliFlags>().tagName).toBe('release-{version}');
});
});

Expand Down Expand Up @@ -111,7 +115,7 @@ describe('runBuildIdentity', () => {
repo.tag('release-2.0.0');
repo.commit('feat: add a thing');
const output = await runBuildIdentity(
flags({ repo: 'exadev/example', root: repo.root, tagName: (version) => `release-${version}`, predict: true, releaseRules: COMMIT_TYPE_RULES }),
flags({ repo: 'exadev/example', root: repo.root, tagName: 'release-{version}', predict: true, releaseRules: COMMIT_TYPE_RULES }),
);
expect(JSON.parse(output)).toMatchObject({ kind: 'predicted', version: '2.1.0' });
});
Expand Down
Loading
Loading