From 71217cea38176ae1513a5f80aa0ccd6395062f10 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 13 Oct 2026 20:20:05 +0100 Subject: [PATCH 1/8] feat!: find the last release from tags, not package.json resolveBuildIdentity and predictNextVersion took the last release from package.json's version and looked up its tag. A repository whose releases are only a tag and a GitHub Release, with no release commit bumping package.json, therefore predicted from a stale version forever and never resolved a release. Both now start from findLastRelease: the highest major.minor.patch tag in the tag template that git tag --merged HEAD lists, the way semantic-release's core finds the last release. Prerelease tags, tags in another format and tags on commits HEAD does not contain are ignored. With no release tag reachable, prediction analyses the whole history and gives 1.0.0, as semantic-release does for a first release. BREAKING CHANGE: the tagName function option of resolveBuildIdentity and predictNextVersion is replaced by tagTemplate, a string holding {version} exactly once (the CLI's --tag-name template, unchanged). readPackageVersion and package.json reading are removed; findLastRelease gives the last released version from tags instead. --- README.md | 30 +++++----- src/cli-program.test.ts | 18 +++--- src/cli-program.ts | 29 +++++----- src/git.test.ts | 24 +++++++- src/git.ts | 9 +++ src/index.ts | 3 +- src/package-version.test.ts | 47 --------------- src/package-version.ts | 28 --------- src/predict-next-version.test.ts | 39 +++++++++---- src/predict-next-version.ts | 28 +++++---- src/release-tags.test.ts | 91 ++++++++++++++++++++++++++++++ src/release-tags.ts | 64 +++++++++++++++++++++ src/resolve-build-identity.test.ts | 41 ++++++++------ src/resolve-build-identity.ts | 15 +++-- src/types.ts | 6 +- 15 files changed, 309 insertions(+), 163 deletions(-) delete mode 100644 src/package-version.test.ts delete mode 100644 src/package-version.ts create mode 100644 src/release-tags.test.ts create mode 100644 src/release-tags.ts diff --git a/README.md b/README.md index a083e21..80b97d7 100644 --- a/README.md +++ b/README.md @@ -37,19 +37,19 @@ 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 its release tags (see [`findLastRelease`](#findlastreleasereporoot-tagtemplate)); 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` 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 --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, the last release reachable from `HEAD` (the highest release tag `git tag --merged HEAD` lists) provably points at the exact commit `HEAD` is on right now -- checked live against git (`git tag --list --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. 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. +`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)` @@ -85,7 +85,7 @@ type AnalyzeCommits = (pluginConfig: unknown, context: unknown) => Promise; 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 } ``` @@ -94,7 +94,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. Before a repository's first release, every commit in `HEAD`'s history is analysed and a release-worthy one predicts `1.0.0` (`FIRST_RELEASE_VERSION`), as semantic-release does. + +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 the tag template is malformed. **`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. @@ -120,20 +122,22 @@ 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 | undefined; // 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. Returns `undefined` before a repository's first release. `resolveBuildIdentity` and `predictNextVersion` both start from this. -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. +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 tags in the checkout, so a shallow CI clone has to fetch them. ```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" ``` diff --git a/src/cli-program.test.ts b/src/cli-program.test.ts index 3f4c8dc..bb2b857 100644 --- a/src/cli-program.test.ts +++ b/src/cli-program.test.ts @@ -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[] = [ @@ -28,7 +28,7 @@ async function parseArgs(...args: readonly string[]): Promise { function flags(overrides: Partial & Pick): CliFlags { return { - tagName: defaultTagName, + tagName: DEFAULT_TAG_TEMPLATE, predict: undefined, releaseRules: undefined, releaseRulesFile: undefined, @@ -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().tagName('1.4.0')).toBe(defaultTagName('1.4.0')); + expect(createProgram().opts().tagName).toBe(DEFAULT_TAG_TEMPLATE); }); }); @@ -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 () => { @@ -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().tagName('2.0.0')).toBe('release-2.0.0'); + expect(program.opts().tagName).toBe('release-{version}'); }); }); @@ -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' }); }); diff --git a/src/cli-program.ts b/src/cli-program.ts index e20e3a6..bb58899 100644 --- a/src/cli-program.ts +++ b/src/cli-program.ts @@ -5,14 +5,12 @@ import { Command, InvalidArgumentError, Option } from 'commander'; import { version as packageVersion } from '../package.json'; import { formatIdentity, isEnvPrefix, isOutputFormat, OUTPUT_FORMATS, type OutputFormat } from './format-identity'; import { loadCommitAnalyzer } from './load-commit-analyzer'; -import { defaultTagName } from './package-version'; import { parseReleaseRules } from './parse-release-rules'; import { predictNextVersion, type PredictNextVersionOptions, type ReleaseRule } from './predict-next-version'; +import { assertTagTemplate, DEFAULT_TAG_TEMPLATE, VERSION_PLACEHOLDER } from './release-tags'; import { resolveBuildIdentity } from './resolve-build-identity'; import { resolvePredictedIdentity } from './resolve-predicted-identity'; -const VERSION_PLACEHOLDER = '{version}'; -export const DEFAULT_TAG_TEMPLATE = `v${VERSION_PLACEHOLDER}`; export const DEFAULT_ENV_PREFIX = 'BUILD_'; /** `predictNextVersion` narrates through a caller-supplied logger and explicitly warns against routing it to stdout, which carries the machine-readable result. `--verbose` sends it to stderr instead, so a CI step can see why a prediction came out the way it did without contaminating what the next step parses. */ @@ -26,14 +24,14 @@ function writeStderr(args: readonly unknown[]): void { process.stderr.write(`${format(...args)}\n`); } -/** - * A `{version}` template rather than a function, since a command line cannot carry one: `resolveBuildIdentity` and `predictNextVersion` are both given the same resulting function, keeping "which tag marks a release" one convention across the two calls exactly as the library's own docs advise. - */ -function parseTagTemplate(template: string): (version: string) => string { - if (!template.includes(VERSION_PLACEHOLDER)) { - throw new InvalidArgumentError(`--tag-name must contain the ${VERSION_PLACEHOLDER} placeholder, got: ${JSON.stringify(template)}`); +/** Validates a `--tag-name` template up front, so a malformed one is a usage error naming the flag rather than a failure from inside the library call. `resolveBuildIdentity` and `predictNextVersion` are both given the same template, keeping "which tag marks a release" one convention across the two calls exactly as the library's own docs advise. */ +function parseTagTemplate(template: string): string { + try { + assertTagTemplate(template); + } catch (cause) { + throw new InvalidArgumentError(`--tag-name: ${cause instanceof Error ? cause.message : String(cause)}`); } - return (version: string): string => template.replaceAll(VERSION_PLACEHOLDER, version); + return template; } function parseFormat(value: string): OutputFormat { @@ -61,7 +59,7 @@ function parseInlineReleaseRules(value: string): ReleaseRule[] { export interface CliFlags { readonly repo: string; readonly root: string; - readonly tagName: (version: string) => string; + readonly tagName: string; readonly predict: boolean | undefined; readonly releaseRules: readonly ReleaseRule[] | undefined; readonly releaseRulesFile: string | undefined; @@ -99,11 +97,11 @@ export async function runBuildIdentity(flags: CliFlags): Promise { throw new Error('--release-rules/--release-rules-file only take effect with --predict, which was not passed'); } - const build = resolveBuildIdentity(flags.root, flags.repo, { tagName: flags.tagName }); + const build = resolveBuildIdentity(flags.root, flags.repo, { tagTemplate: flags.tagName }); let predictedVersion: string | undefined; if (rules !== undefined) { - const options: PredictNextVersionOptions = { tagName: flags.tagName, ...(flags.verbose === true ? { logger: STDERR_LOGGER } : {}) }; + const options: PredictNextVersionOptions = { tagTemplate: flags.tagName, ...(flags.verbose === true ? { logger: STDERR_LOGGER } : {}) }; predictedVersion = await predictNextVersion(flags.root, rules, await loadCommitAnalyzer(), options); } @@ -120,13 +118,12 @@ export function createProgram(): Command { program.requiredOption('--repo ', 'GitHub slug used to build the release and commit URLs, e.g. exadev/build-identity'); program.addOption( - new Option('--root ', 'git working tree to inspect; must contain package.json at its root').default(process.cwd(), 'the current working directory'), + new Option('--root ', 'git working tree to inspect').default(process.cwd(), 'the current working directory'), ); - // The default is described rather than shown: commander renders a default value through JSON.stringify, which turns the tagName function into nothing at all. program.addOption( new Option('--tag-name