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
91 changes: 91 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const identity = resolveBuildIdentity(process.cwd(), 'exadev/build-identity');
// { kind: 'commit', version: 'a1b2c3d', url: 'https://github.com/exadev/build-identity/commit/a1b2c3d4e5f6...', date: '2026-09-08T09:12:03+01:00', commit: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2' }
```

The same thing is available as a command, for build and deploy steps that cannot import anything -- see [`build-identity` (CLI)](#build-identity-cli).

## `resolveBuildIdentity(repoRoot, repoSlug, options?)`

```ts
Expand Down Expand Up @@ -116,10 +118,99 @@ const predictedVersion = await predictNextVersion(process.cwd(), releaseRules, a
const identity = resolvePredictedIdentity(build, predictedVersion);
```

## `build-identity` (CLI)

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.

```sh
pnpm add -D @exadev/build-identity
pnpm exec build-identity --repo exadev/build-identity
```

```
Usage: build-identity [options]

Options:
-V, --version output the version number
--repo <owner/repo> GitHub slug used to build the release and commit URLs
--root <directory> git working tree to inspect (default: the current working directory)
--tag-name <template> tag name marking a release, with {version} standing in for the version (default: v{version})
--predict also predict the version this commit's next release would be
--release-rules <json> commit-analyzer release rules for --predict, as a JSON array
--release-rules-file <path> file holding the same JSON array as --release-rules
--format <format> output format: json or env (default: "json")
--prefix <prefix> prepended to each variable name in --format env (default: "BUILD_")
--verbose send commit-analyzer's own per-commit narration to stderr
-h, --help display help for command
```

`--repo` is the only required flag, and the command's whole output is a single `DisplayIdentity` on stdout:

```sh
$ build-identity --repo exadev/build-identity
{
"kind": "commit",
"version": "a1b2c3d",
"url": "https://github.com/exadev/build-identity/commit/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"date": "2026-09-08T09:12:03+01:00",
"commit": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
}
```

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.

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

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:

```sh
build-identity --repo exadev/build-identity --predict \
--release-rules '[{"breaking":true,"release":"major"},{"type":"feat","release":"minor"},{"type":"fix","release":"patch"}]'

build-identity --repo exadev/build-identity --predict --release-rules-file release-rules.json
```

A confirmed release still wins outright: on a commit an actual release tag points at, `--predict` changes nothing at all.

### `--format env`

```sh
$ build-identity --repo exadev/build-identity --format env --prefix RELEASE_
RELEASE_KIND=commit
RELEASE_VERSION=a1b2c3d
RELEASE_URL=https://github.com/exadev/build-identity/commit/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
RELEASE_DATE=2026-09-08T09:12:03+01:00
RELEASE_COMMIT=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
```

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.

```yaml
- name: Resolve the build's identity
run: pnpm exec build-identity --repo my-org/my-repo --predict --release-rules-file release-rules.json --format env --prefix RELEASE_ >> "$GITHUB_ENV"

- name: Deploy
run: wrangler deploy --var RELEASE_VERSION:$RELEASE_VERSION --var RELEASE_COMMIT:$RELEASE_COMMIT
```

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:

```sh
echo "MY_OWN_VERSION_NAME=$(build-identity --repo my-org/my-repo | jq -r .version)" >> "$GITHUB_ENV"
```

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

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.

## Framework-agnosticism

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.

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

### Next.js

```ts
Expand Down
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
"publishConfig": {
"access": "public"
},
"bin": {
"build-identity": "./dist/cli.js"
},
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand All @@ -33,7 +36,8 @@
"version",
"git",
"release",
"commit"
"commit",
"cli"
],
"repository": {
"type": "git",
Expand Down Expand Up @@ -80,5 +84,8 @@
"prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && tsdown && publint && attw --pack",
"prepare": "husky",
"release": "semantic-release"
},
"dependencies": {
"commander": "^14.0.3"
}
}
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

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

182 changes: 182 additions & 0 deletions src/cli-program.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { writeFileSync } from 'node:fs';
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 { createTestRepo, type TestRepo } from './test-repo';

const COMMIT_TYPE_RULES: readonly ReleaseRule[] = [
{ breaking: true, release: 'major' },
{ type: 'feat', release: 'minor' },
{ type: 'fix', release: 'patch' },
{ type: 'chore', release: false },
];

/** Turns commander's own report-and-exit behaviour into a throw, and discards its usage output, so a parse failure is assertable rather than killing the test process. */
function silentProgram(): Command {
const program = createProgram();
program.exitOverride();
program.configureOutput({ writeOut: () => undefined, writeErr: () => undefined });
return program;
}

async function parseArgs(...args: readonly string[]): Promise<Command> {
return silentProgram().parseAsync([...args], { from: 'user' });
}

function flags(overrides: Partial<CliFlags> & Pick<CliFlags, 'repo' | 'root'>): CliFlags {
return {
tagName: defaultTagName,
predict: undefined,
releaseRules: undefined,
releaseRulesFile: undefined,
format: 'json',
prefix: DEFAULT_ENV_PREFIX,
verbose: undefined,
...overrides,
};
}

describe('createProgram defaults', () => {
it('defaults root to the current working directory, format to json, and prefix to BUILD_', () => {
const opts = createProgram().opts<CliFlags>();
expect(opts.root).toBe(process.cwd());
expect(opts.format).toBe('json');
expect(opts.prefix).toBe(DEFAULT_ENV_PREFIX);
});

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'));
});
});

describe('createProgram argument parsing', () => {
it('requires --repo', async () => {
await expect(parseArgs()).rejects.toThrow(/--repo/);
});

it('rejects an unknown --format', async () => {
await expect(parseArgs('--repo', 'exadev/example', '--format', 'yaml')).rejects.toThrow(/--format must be one of/);
});

it('rejects a --prefix that would not be a legal variable name', async () => {
await expect(parseArgs('--repo', 'exadev/example', '--prefix', '1BUILD_')).rejects.toThrow(/--prefix/);
});

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

it('rejects --release-rules that is not valid JSON', async () => {
await expect(parseArgs('--repo', 'exadev/example', '--release-rules', '[{')).rejects.toThrow(/--release-rules is not valid JSON/);
});

it('rejects --release-rules holding something other than release rules', async () => {
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', () => {
// 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');
});
});

describe('runBuildIdentity', () => {
let repo: TestRepo | undefined;

afterEach(() => {
repo?.cleanup();
repo = undefined;
});

it('reports a commit identity as JSON when no tag points at HEAD', async () => {
repo = createTestRepo({ name: 'fixture', version: '1.4.0' });
const parsed: unknown = JSON.parse(await runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root })));
expect(parsed).toMatchObject({ kind: 'commit', url: expect.stringContaining('https://github.com/exadev/example/commit/') as unknown });
});

it('reports a release identity when the release tag points at HEAD', async () => {
repo = createTestRepo({ name: 'fixture', version: '1.4.0' });
repo.tag('v1.4.0');
const parsed: unknown = JSON.parse(await runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root })));
expect(parsed).toMatchObject({ kind: 'release', version: '1.4.0' });
});

it('honours a caller-supplied tag convention for both the release check and the prediction base', async () => {
repo = createTestRepo({ name: 'fixture', version: '2.0.0' });
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 }),
);
expect(JSON.parse(output)).toMatchObject({ kind: 'predicted', version: '2.1.0' });
});

it('shows the predicted next version instead of a commit hash when --predict is given', async () => {
repo = createTestRepo({ name: 'fixture', version: '1.4.0' });
repo.tag('v1.4.0');
repo.commit('feat: add a thing');
const parsed: unknown = JSON.parse(await runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root, predict: true, releaseRules: COMMIT_TYPE_RULES })));
expect(parsed).toMatchObject({ kind: 'predicted', version: '1.5.0' });
});

it('falls back to the commit identity when --predict finds nothing release-worthy', async () => {
repo = createTestRepo({ name: 'fixture', version: '1.4.0' });
repo.tag('v1.4.0');
repo.commit('chore: tidy up');
const parsed: unknown = JSON.parse(await runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root, predict: true, releaseRules: COMMIT_TYPE_RULES })));
expect(parsed).toMatchObject({ kind: 'commit' });
});

it('never lets a prediction override a confirmed release', async () => {
repo = createTestRepo({ name: 'fixture', version: '1.4.0' });
repo.tag('v1.4.0');
const parsed: unknown = JSON.parse(await runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root, predict: true, releaseRules: COMMIT_TYPE_RULES })));
expect(parsed).toMatchObject({ kind: 'release', version: '1.4.0' });
});

it('reads rules from --release-rules-file', async () => {
repo = createTestRepo({ name: 'fixture', version: '1.4.0' });
repo.tag('v1.4.0');
repo.commit('fix: correct a thing');
const rulesFile = join(repo.root, 'release-rules.json');
writeFileSync(rulesFile, JSON.stringify(COMMIT_TYPE_RULES));
const parsed: unknown = JSON.parse(await runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root, predict: true, releaseRulesFile: rulesFile })));
expect(parsed).toMatchObject({ kind: 'predicted', version: '1.4.1' });
});

it('names the file in a --release-rules-file read failure', async () => {
repo = createTestRepo({ name: 'fixture', version: '1.4.0' });
const missing = join(repo.root, 'absent.json');
await expect(runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root, predict: true, releaseRulesFile: missing }))).rejects.toThrow(/could not be read/);
});

it('rejects both rule sources at once rather than silently preferring one', async () => {
repo = createTestRepo({ name: 'fixture', version: '1.4.0' });
await expect(
runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root, predict: true, releaseRules: COMMIT_TYPE_RULES, releaseRulesFile: 'rules.json' })),
).rejects.toThrow(/pass one, not both/);
});

it('rejects --predict with no rules, rather than predicting against a rule set this package invented', async () => {
repo = createTestRepo({ name: 'fixture', version: '1.4.0' });
await expect(runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root, predict: true }))).rejects.toThrow(/--release-rules/);
});

it('rejects rules supplied without --predict, rather than accepting a flag that would do nothing', async () => {
repo = createTestRepo({ name: 'fixture', version: '1.4.0' });
await expect(runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root, releaseRules: COMMIT_TYPE_RULES }))).rejects.toThrow(/only take effect with --predict/);
});

it('emits prefixed KEY=VALUE lines in env format', async () => {
repo = createTestRepo({ name: 'fixture', version: '1.4.0' });
repo.tag('v1.4.0');
const lines = (await runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root, format: 'env', prefix: 'RELEASE_' }))).split('\n');
expect(lines[0]).toBe('RELEASE_KIND=release');
expect(lines[1]).toBe('RELEASE_VERSION=1.4.0');
});
});
Loading