diff --git a/README.md b/README.md index a083e21..e798d50 100644 --- a/README.md +++ b/README.md @@ -37,19 +37,21 @@ 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 the release tag on `HEAD`; 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, a release tag provably points at the exact commit `HEAD` is on right now, checked live against git (`git tag --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; with several release tags on `HEAD` the highest wins, and a lower one on `HEAD` (a backport tagged after a newer release) is still a release. 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). + +Only `HEAD` itself is inspected, never its history, so this works in a shallow clone and never throws for a missing tag. A clone made without tags (a default `actions/checkout`) has no tag at `HEAD` to find and reports `kind: 'commit'` even on a released commit: the honest answer for a release git cannot see. Check out with `fetch-depth: 0` where a released commit should report as one. **`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 +87,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 +96,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. It needs the full history and tags: run it on a checkout made with `fetch-depth: 0`. + +Returns `undefined` when there is genuinely no predicted release (no commits since the last release tag, or none of them release-worthy): the same "nothing to report" case `resolvePredictedIdentity` already treats as valid, not an error. Throws for a genuine setup problem instead of predicting from a guess, exactly as `findLastRelease` does: a shallow clone, no release tag reachable from `HEAD`, `repoRoot` isn't a git repository, or the tag template is malformed. A wrong base version would predict a release that may already exist as a tag, so there is no fallback, not even a first-release `1.0.0`. **`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 +124,28 @@ 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; // 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. `predictNextVersion` starts from this. + +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 full history and tags (`actions/checkout` with `fetch-depth: 0`), and throws rather than inventing a version when it cannot prove one, saying what to do: -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. +- **A shallow clone**, even one with some tags: a higher release tag could sit beyond the cut. Fetch full history and tags first (`git fetch --unshallow --tags`). +- **No release tag reachable from `HEAD`**: the clone was made without its tags (`git fetch --tags`), the template does not match how the repository tags, or it has never released. +- `repoRoot` isn't a git repository, or the template is malformed. ```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" ``` @@ -180,7 +192,7 @@ Those are exactly the five fields `DisplayIdentity` declares, under exactly its ### 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. +`--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 release tag, so it needs the full history and tags (`actions/checkout` with `fetch-depth: 0`) and fails in a shallow clone. 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: 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