diff --git a/README.md b/README.md index f414926..37df49d 100644 --- a/README.md +++ b/README.md @@ -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; @@ -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 --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)` diff --git a/src/resolve-build-identity.test.ts b/src/resolve-build-identity.test.ts index ab70a67..fb6d6e1 100644 --- a/src/resolve-build-identity.test.ts +++ b/src/resolve-build-identity.test.ts @@ -23,11 +23,13 @@ describe('resolveBuildIdentity', () => { it('returns a release identity when v 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', () => { @@ -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', () => { diff --git a/src/resolve-build-identity.ts b/src/resolve-build-identity.ts index 4579c47..b9042e3 100644 --- a/src/resolve-build-identity.ts +++ b/src/resolve-build-identity.ts @@ -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, }; } @@ -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, }; } diff --git a/src/resolve-predicted-identity.test.ts b/src/resolve-predicted-identity.test.ts index 00ab60e..e2539b4 100644 --- a/src/resolve-predicted-identity.test.ts +++ b/src/resolve-predicted-identity.test.ts @@ -7,6 +7,7 @@ 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 = { @@ -14,6 +15,7 @@ const commit: BuildIdentity = { version: 'abc1234', url: 'https://github.com/exadev/example/commit/abc1234def5678901234567890123456789abcd', date: '2026-01-02T00:00:00Z', + commit: 'abc1234def5678901234567890123456789abcd', }; describe('resolvePredictedIdentity', () => { @@ -28,6 +30,7 @@ describe('resolvePredictedIdentity', () => { version: '1.5.0', url: commit.url, date: commit.date, + commit: commit.commit, predicted: true, }); }); diff --git a/src/resolve-predicted-identity.ts b/src/resolve-predicted-identity.ts index a7144b0..16a0232 100644 --- a/src/resolve-predicted-identity.ts +++ b/src/resolve-predicted-identity.ts @@ -22,6 +22,7 @@ export function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: version: trimmedPrediction, url: build.url, date: build.date, + commit: build.commit, predicted: true, }; } diff --git a/src/types.ts b/src/types.ts index 3251af8..31de781 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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'; @@ -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 {