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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,17 @@ pnpm add -D @exadev/build-identity
import { resolveBuildIdentity } from '@exadev/build-identity';

const identity = resolveBuildIdentity(process.cwd(), 'exadev/build-identity');
// { kind: 'release', version: '1.4.0', url: 'https://github.com/exadev/build-identity/releases/tag/v1.4.0', date: '2026-09-08T09:12:03+01:00' }
// { kind: 'release', version: '1.4.0', url: 'https://github.com/exadev/build-identity/releases/tag/v1.4.0', date: '2026-09-08T09:12:03+01:00', commit: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2' }
// or, on any commit that isn't itself tagged:
// { kind: 'commit', version: 'a1b2c3d', url: 'https://github.com/exadev/build-identity/commit/a1b2c3d4e5f6...', date: '2026-09-08T09:12:03+01:00' }
// { kind: 'commit', version: 'a1b2c3d', url: 'https://github.com/exadev/build-identity/commit/a1b2c3d4e5f6...', date: '2026-09-08T09:12:03+01:00', commit: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2' }
```

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

```ts
type BuildIdentity =
| { kind: 'release'; version: string; url: string; date: string }
| { kind: 'commit'; version: string; url: string; date: string };
| { kind: 'release'; version: string; url: string; date: string; commit: string }
| { kind: 'commit'; version: string; url: string; date: string; commit: string };

function resolveBuildIdentity(repoRoot: string, repoSlug: string, options?: ResolveBuildIdentityOptions): BuildIdentity;

Expand All @@ -43,6 +43,8 @@ interface ResolveBuildIdentityOptions {

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

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

## `resolvePredictedIdentity(build, predictedVersion)`
Expand Down
3 changes: 3 additions & 0 deletions src/resolve-build-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ describe('resolveBuildIdentity', () => {
it('returns a release identity when v<version> tags HEAD exactly', () => {
repo = createTestRepo({ name: 'fixture', version: '1.4.0' });
repo.tag('v1.4.0');
const fullSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo.root, encoding: 'utf8' }).trim();
const identity = resolveBuildIdentity(repo.root, 'exadev/example');
expect(identity.kind).toBe('release');
expect(identity.version).toBe('1.4.0');
expect(identity.url).toBe('https://github.com/exadev/example/releases/tag/v1.4.0');
expect(identity.date).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(identity.commit).toBe(fullSha);
});

it('never claims a release when the matching tag exists but HEAD has since moved past it -- the core correctness property', () => {
Expand Down Expand Up @@ -65,6 +67,7 @@ describe('resolveBuildIdentity', () => {
expect(identity.url).toBe(`https://github.com/exadev/example/commit/${fullSha}`);
expect(fullSha.startsWith(identity.version)).toBe(true);
expect(identity.version.length).toBeLessThan(fullSha.length);
expect(identity.commit).toBe(fullSha);
});

it('throws rather than defaulting when package.json has no version field', () => {
Expand Down
2 changes: 2 additions & 0 deletions src/resolve-build-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export function resolveBuildIdentity(repoRoot: string, repoSlug: string, options
version,
url: `https://github.com/${repoSlug}/releases/tag/${tagName}`,
date: head.date,
commit: head.fullSha,
};
}

Expand All @@ -63,5 +64,6 @@ export function resolveBuildIdentity(repoRoot: string, repoSlug: string, options
version: head.shortSha,
url: `https://github.com/${repoSlug}/commit/${head.fullSha}`,
date: head.date,
commit: head.fullSha,
};
}
3 changes: 3 additions & 0 deletions src/resolve-predicted-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ const release: BuildIdentity = {
version: '1.4.0',
url: 'https://github.com/exadev/example/releases/tag/v1.4.0',
date: '2026-01-01T00:00:00Z',
commit: 'abc1234def5678901234567890123456789abcd',
};

const commit: BuildIdentity = {
kind: 'commit',
version: 'abc1234',
url: 'https://github.com/exadev/example/commit/abc1234def5678901234567890123456789abcd',
date: '2026-01-02T00:00:00Z',
commit: 'abc1234def5678901234567890123456789abcd',
};

describe('resolvePredictedIdentity', () => {
Expand All @@ -28,6 +30,7 @@ describe('resolvePredictedIdentity', () => {
version: '1.5.0',
url: commit.url,
date: commit.date,
commit: commit.commit,
predicted: true,
});
});
Expand Down
1 change: 1 addition & 0 deletions src/resolve-predicted-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function resolvePredictedIdentity(build: BuildIdentity, predictedVersion:
version: trimmedPrediction,
url: build.url,
date: build.date,
commit: build.commit,
predicted: true,
};
}
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export type BuildIdentity =
readonly url: string;
/** ISO 8601 date of the commit this release was tagged at. */
readonly date: string;
/** Full git commit SHA of the commit this release was tagged at -- the same commit `url` and `date` describe, exposed as its own field so a caller never has to parse it back out of a release URL that never contains one. */
readonly commit: string;
}
| {
readonly kind: 'commit';
Expand All @@ -21,6 +23,8 @@ export type BuildIdentity =
readonly url: string;
/** ISO 8601 date of this commit. */
readonly date: string;
/** Full git commit SHA this build was made from -- the same commit `version`'s short form and `url`'s permalink describe. */
readonly commit: string;
};

export interface ResolveBuildIdentityOptions {
Expand Down