Skip to content
Open
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ jobs:
with:
node-version: '22'
cache: pnpm
# registry-url is deliberately absent -- setup-node would otherwise write an .npmrc _authToken line that wins over the OIDC token exchange, breaking trusted publishing.
# registry-url is deliberately absent: setup-node would otherwise write an .npmrc _authToken line that wins over the OIDC token exchange, breaking trusted publishing.
- run: pnpm install --frozen-lockfile
- name: Upgrade npm for OIDC trusted publishing (needs npm CLI >=11.5.1)
run: npm install -g npm@latest
Expand All @@ -87,6 +87,6 @@ jobs:
run: HUSKY=0 pnpm exec semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Blanked, not omitted -- an inherited NPM_TOKEN/NODE_AUTH_TOKEN would otherwise be used in preference to the OIDC exchange.
# Blanked, not omitted: an inherited NPM_TOKEN/NODE_AUTH_TOKEN would otherwise be used in preference to the OIDC exchange.
NPM_TOKEN: ''
NODE_AUTH_TOKEN: ''
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ Commits are conventional (`feat:`, `fix:`, `chore:`, ...), enforced by commitlin

Every push to `main` runs `.github/workflows/ci.yml`'s `release` job: `semantic-release` analyses commits since the last tag, decides the next version, publishes to npmjs.org, and creates the git tag and GitHub Release. Nothing about the release commands themselves is manual.

**npm trusted publishing (OIDC) cannot bootstrap a package that has never been published before.** npm's own CLI (`npm trust --help`) states this as a hard prerequisite: "Package must exist: The package you're configuring must already exist on the npm registry." There is no way -- via the npmjs.com UI or the `npm trust` CLI -- to configure the trust relationship between this repository's workflow and an npm package name before that name has at least one published version. This is a platform limitation, not something this repository's own CI configuration can work around.
**npm trusted publishing (OIDC) cannot bootstrap a package that has never been published before.** npm's own CLI (`npm trust --help`) states this as a hard prerequisite: "Package must exist: The package you're configuring must already exist on the npm registry." There is no way, via the npmjs.com UI or the `npm trust` CLI, to configure the trust relationship between this repository's workflow and an npm package name before that name has at least one published version. This is a platform limitation, not something this repository's own CI configuration can work around.

Concretely, that meant the very first version of this package needed one manual, one-time step before `semantic-release`'s own OIDC-based publish could work unattended for every version after it:

1. A maintainer with npm publish rights ran a single `npm publish` from an authenticated local session (2FA-backed), creating `@exadev/build-identity` on the registry at a low placeholder version.
2. Once the package existed, `npm trust github @exadev/build-identity --repo ExaDev/build-identity --file .github/workflows/ci.yml --allow-publish` registered the trust relationship between this repository's `release` job and the package -- the CLI equivalent of the "Trusted Publisher" section under the package's settings on npmjs.com.
2. Once the package existed, `npm trust github @exadev/build-identity --repo ExaDev/build-identity --file .github/workflows/ci.yml --allow-publish` registered the trust relationship between this repository's `release` job and the package: the CLI equivalent of the "Trusted Publisher" section under the package's settings on npmjs.com.
3. From that point on, `.github/workflows/ci.yml`'s `release` job publishes every subsequent version over OIDC with no stored token at all (see the workflow's own comments on why `registry-url` is omitted from `setup-node` and why `NPM_TOKEN`/`NODE_AUTH_TOKEN` are explicitly blanked in the release step's environment).

If this package's npm name is ever unpublished, transferred, or otherwise needs its trusted-publisher configuration recreated from scratch, repeat steps 1-2 above -- there is no fully-automated path around npm's own package-must-exist requirement.
If this package's npm name is ever unpublished, transferred, or otherwise needs its trusted-publisher configuration recreated from scratch, repeat steps 1-2 above. There is no fully-automated path around npm's own package-must-exist requirement.
28 changes: 14 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/build-identity) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/@exadev/build-identity) [![Release](https://img.shields.io/github/v/release/ExaDev/build-identity)](https://github.com/ExaDev/build-identity/releases/latest) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/build-identity/ci.yml?branch=main)](https://github.com/ExaDev/build-identity/actions)

> Resolve a build's true identity -- a real, already-tagged release, or the commit it was built from -- from live git state, with an optional predicted-version display layer.
> Resolve a build's true identity from live git state: a real, already-tagged release, or the commit it was built from. Includes an optional predicted-version display layer.

## Why

A build banner or footer link (`v1.4.0`, linking to a release page) is only honest the moment a real release actually exists at that commit. Deriving that label from `package.json` alone, or from a CI environment variable, or from "this is the main branch so it must be the latest release" is exactly how a link to a release page that doesn't exist yet gets shipped -- typically the commit that triggers the release pipeline itself, before the tag and GitHub Release the link points at have actually been created. This package makes that check live and unavoidable: it shells out to git, checks whether the release tag genuinely points at `HEAD` right now, and only then returns a `'release'` identity.
A build banner or footer link (`v1.4.0`, linking to a release page) is only honest the moment a real release actually exists at that commit. Deriving that label from `package.json` alone, or from a CI environment variable, or from "this is the main branch so it must be the latest release" is exactly how a link to a release page that doesn't exist yet gets shipped, typically on the commit that triggers the release pipeline itself, before the tag and GitHub Release the link points at have actually been created. This package makes that check live and unavoidable: it shells out to git, checks whether the release tag genuinely points at `HEAD` right now, and only then returns a `'release'` identity.

## Getting started

Expand Down Expand Up @@ -37,27 +37,27 @@ interface ResolveBuildIdentityOptions {
}
```

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

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

`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, `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)`

```ts
function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: string | undefined): BuildIdentity & { predicted?: boolean };
```

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

- 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.
- 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`, so the link still points at 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.
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';
Expand All @@ -69,7 +69,7 @@ const identity = resolvePredictedIdentity(build, predictedVersion);

## 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.
This package does pure Node.js filesystem and git access only, with 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.

### Next.js

Expand Down Expand Up @@ -109,11 +109,11 @@ 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` never ship as part of the app's own bundle.

## Conventions

British English throughout. Conventional commits, enforced by commitlint (`commitlint.config.ts`) and released automatically by `semantic-release` (`release.config.ts`) on every push to `main`. Strict TypeScript, no `any`, no type assertions -- see `eslint.config.ts`, which lints this package with `@exadev/eslint-config`.
British English throughout. Conventional commits, enforced by commitlint (`commitlint.config.ts`) and released automatically by `semantic-release` (`release.config.ts`) on every push to `main`. Strict TypeScript, no `any`, no type assertions. See `eslint.config.ts`, which lints this package with `@exadev/eslint-config`.

## Publishing

Expand Down
2 changes: 1 addition & 1 deletion eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ export default defineConfig(
},
},
...exadev,
// src/index.ts is this package's own entry-point barrel -- keep it, don't ban it.
// src/index.ts is this package's own entry-point barrel. Keep it, don't ban it.
{ files: ['src/index.ts'], rules: { 'exadev/barrel-policy': ['error', { mode: 'single' }] } },
);
4 changes: 2 additions & 2 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# This is a single-package repo, not a real pnpm workspace -- packages is deliberately empty. Its only purpose is to give turbo a workspace root to anchor local task caching against, matching the same pattern used across ExaDev's other single-package tooling repos.
# This is a single-package repo, not a real pnpm workspace: packages is deliberately empty. Its only purpose is to give turbo a workspace root to anchor local task caching against, matching the same pattern used across ExaDev's other single-package tooling repos.
packages: []

# Pins transitive dependency versions that would otherwise resolve to the newest matching release -- kept in step with @exadev/eslint-config's own known-good resolutions so a fresh install doesn't pick up a patch release still inside the org's minimum-release-age window.
# Pins transitive dependency versions that would otherwise resolve to the newest matching release, kept in step with @exadev/eslint-config's own known-good resolutions so a fresh install doesn't pick up a patch release still inside the org's minimum-release-age window.
overrides:
'@eslint/plugin-kit': 0.7.2
'@typescript-eslint/eslint-plugin': 8.67.0
Expand Down
4 changes: 2 additions & 2 deletions release.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const commitTypes: readonly CommitType[] = [
];

/**
* Runs on `main`. Analyses commits since the last tag, bumps the version, publishes to npmjs.org (trusted OIDC publishing, no stored token -- see .github/workflows/ci.yml), creates a versioned tag and GitHub Release with generated notes, and commits CHANGELOG.md + package.json back to main.
* Runs on `main`. Analyses commits since the last tag, bumps the version, publishes to npmjs.org (trusted OIDC publishing, no stored token; see .github/workflows/ci.yml), creates a versioned tag and GitHub Release with generated notes, and commits CHANGELOG.md + package.json back to main.
*/
const config: Options = {
branches: ['main'],
Expand All @@ -40,7 +40,7 @@ const config: Options = {
[
'@semantic-release/release-notes-generator',
{
// Deliberately angular, not conventionalcommits -- conventional-changelog-writer's bundled commit partial doesn't match the conventionalcommits preset's function-based partial signature, producing a changelog with a version header and nothing under it.
// Deliberately angular, not conventionalcommits: conventional-changelog-writer's bundled commit partial doesn't match the conventionalcommits preset's function-based partial signature, producing a changelog with a version header and nothing under it.
preset: 'angular',
},
],
Expand Down
2 changes: 1 addition & 1 deletion src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('tagPointsAtHead', () => {
expect(tagPointsAtHead(repo.root, 'v1.0.0')).toBe(true);
});

it('is false when the tag exists but HEAD has since moved past it -- the case that must never be mistaken for a release', () => {
it('is false when the tag exists but HEAD has since moved past it: the case that must never be mistaken for a release', () => {
repo = createTestRepo();
repo.tag('v1.0.0');
repo.commit();
Expand Down
4 changes: 2 additions & 2 deletions src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ function runGit(repoRoot: string, args: readonly string[]): string {
}

/**
* Reads HEAD's full hash, short hash, and committer date from the given git working tree. Throws whatever `git` itself throws (e.g. `repoRoot` is not a git repository, or has no commits) -- there is no sensible default identity for a build that isn't sitting in real git history.
* Reads HEAD's full hash, short hash, and committer date from the given git working tree. Throws whatever `git` itself throws (e.g. `repoRoot` is not a git repository, or has no commits). There is no sensible default identity for a build that isn't sitting in real git history.
*/
export function getHeadCommit(repoRoot: string): HeadCommit {
const fullSha = runGit(repoRoot, ['rev-parse', 'HEAD']);
Expand All @@ -22,7 +22,7 @@ export function getHeadCommit(repoRoot: string): HeadCommit {
}

/**
* True only if `tagName` both exists and points at the exact commit HEAD is on right now. Uses `git tag --list <tag> --points-at HEAD`, which returns `tagName` itself when both hold and nothing otherwise -- there is no separate "tag exists but points elsewhere" case to confuse this with.
* True only if `tagName` both exists and points at the exact commit HEAD is on right now. Uses `git tag --list <tag> --points-at HEAD`, which returns `tagName` itself when both hold and nothing otherwise. There is no separate "tag exists but points elsewhere" case to confuse this with.
*/
export function tagPointsAtHead(repoRoot: string, tagName: string): boolean {
const output = runGit(repoRoot, ['tag', '--list', tagName, '--points-at', 'HEAD']);
Expand Down
2 changes: 1 addition & 1 deletion src/resolve-build-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('resolveBuildIdentity', () => {
expect(identity.date).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});

it('never claims a release when the matching tag exists but HEAD has since moved past it -- the core correctness property', () => {
it('never claims a release when the matching tag exists but HEAD has since moved past it: the core correctness property', () => {
repo = createTestRepo({ name: 'fixture', version: '1.4.0' });
repo.tag('v1.4.0');
repo.commit('feat: something new after the release tag');
Expand Down
Loading