From 152acb171ab7892eafc9764dcda76d14092287f7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 8 Sep 2026 15:44:48 +0100 Subject: [PATCH 1/4] feat: expose the resolved identity as a build-identity command A build or deploy step that is a shell invocation rather than a JavaScript config file cannot import this package at all, so it has had no way to reach resolveBuildIdentity/predictNextVersion/resolvePredictedIdentity. `wrangler deploy --var RELEASE_VERSION:...` is the concrete case: the value has to exist as a shell variable before wrangler runs, and no config file is involved anywhere in that path. The command reuses those three functions rather than reimplementing any of them, and prints a single DisplayIdentity under exactly the field names the type itself declares. Nothing here is named for a consuming repo, and there is no `predicted` boolean alongside `kind: "predicted"` -- a second field asserting the same fact is one more thing that can disagree with the first. Prediction sits behind an explicit --predict rather than happening by default: it needs the optional @semantic-release/commit-analyzer peer dependency and reads every commit since the last tag, neither of which a caller that only wants the release-or-commit answer should pay for silently. Release rules must come from the caller for the same reason the library takes them as an argument -- a repo's commit-type convention is real configuration this package has no authority to default. --format env is deliberate extra surface over "print JSON, document a jq one-liner". Appending to $GITHUB_ENV is the realistic use in both of the cases that prompted this, and the jq alternative would push a dependency and a quoting-sensitive shell expression into every consumer's workflow to produce output the command can simply print. Its names stay generic (an upper-cased field behind --prefix), so mapping onto a repo's own names remains that repo's job. A value carrying a line break is refused rather than emitted, since it would otherwise swallow or inject $GITHUB_ENV entries unnoticed. commander is the package's first runtime dependency, and is bundled into dist/cli.js alone: importing the library never loads it. --- package.json | 9 +- pnpm-lock.yaml | 10 ++ src/cli-program.test.ts | 182 ++++++++++++++++++++++++++++++++ src/cli-program.ts | 143 +++++++++++++++++++++++++ src/cli.e2e.test.ts | 133 +++++++++++++++++++++++ src/cli.ts | 10 ++ src/format-identity.test.ts | 80 ++++++++++++++ src/format-identity.ts | 51 +++++++++ src/parse-release-rules.test.ts | 57 ++++++++++ src/parse-release-rules.ts | 52 +++++++++ tsdown.config.ts | 33 ++++-- 11 files changed, 751 insertions(+), 9 deletions(-) create mode 100644 src/cli-program.test.ts create mode 100644 src/cli-program.ts create mode 100644 src/cli.e2e.test.ts create mode 100644 src/cli.ts create mode 100644 src/format-identity.test.ts create mode 100644 src/format-identity.ts create mode 100644 src/parse-release-rules.test.ts create mode 100644 src/parse-release-rules.ts diff --git a/package.json b/package.json index 3c14dcb..d7f73de 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,9 @@ "publishConfig": { "access": "public" }, + "bin": { + "build-identity": "./dist/cli.js" + }, "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -33,7 +36,8 @@ "version", "git", "release", - "commit" + "commit", + "cli" ], "repository": { "type": "git", @@ -80,5 +84,8 @@ "prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && tsdown && publint && attw --pack", "prepare": "husky", "release": "semantic-release" + }, + "dependencies": { + "commander": "^15.0.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27572e9..32e227f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,10 @@ overrides: importers: .: + dependencies: + commander: + specifier: ^15.0.0 + version: 15.0.0 devDependencies: '@arethetypeswrong/cli': specifier: 0.18.5 @@ -1011,6 +1015,10 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} @@ -3527,6 +3535,8 @@ snapshots: commander@10.0.1: {} + commander@15.0.0: {} + compare-func@2.0.0: dependencies: array-ify: 1.0.0 diff --git a/src/cli-program.test.ts b/src/cli-program.test.ts new file mode 100644 index 0000000..3f4c8dc --- /dev/null +++ b/src/cli-program.test.ts @@ -0,0 +1,182 @@ +import { writeFileSync } from 'node:fs'; +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 { createTestRepo, type TestRepo } from './test-repo'; + +const COMMIT_TYPE_RULES: readonly ReleaseRule[] = [ + { breaking: true, release: 'major' }, + { type: 'feat', release: 'minor' }, + { type: 'fix', release: 'patch' }, + { type: 'chore', release: false }, +]; + +/** Turns commander's own report-and-exit behaviour into a throw, and discards its usage output, so a parse failure is assertable rather than killing the test process. */ +function silentProgram(): Command { + const program = createProgram(); + program.exitOverride(); + program.configureOutput({ writeOut: () => undefined, writeErr: () => undefined }); + return program; +} + +async function parseArgs(...args: readonly string[]): Promise { + return silentProgram().parseAsync([...args], { from: 'user' }); +} + +function flags(overrides: Partial & Pick): CliFlags { + return { + tagName: defaultTagName, + predict: undefined, + releaseRules: undefined, + releaseRulesFile: undefined, + format: 'json', + prefix: DEFAULT_ENV_PREFIX, + verbose: undefined, + ...overrides, + }; +} + +describe('createProgram defaults', () => { + it('defaults root to the current working directory, format to json, and prefix to BUILD_', () => { + const opts = createProgram().opts(); + expect(opts.root).toBe(process.cwd()); + expect(opts.format).toBe('json'); + expect(opts.prefix).toBe(DEFAULT_ENV_PREFIX); + }); + + 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')); + }); +}); + +describe('createProgram argument parsing', () => { + it('requires --repo', async () => { + await expect(parseArgs()).rejects.toThrow(/--repo/); + }); + + it('rejects an unknown --format', async () => { + await expect(parseArgs('--repo', 'exadev/example', '--format', 'yaml')).rejects.toThrow(/--format must be one of/); + }); + + it('rejects a --prefix that would not be a legal variable name', async () => { + await expect(parseArgs('--repo', 'exadev/example', '--prefix', '1BUILD_')).rejects.toThrow(/--prefix/); + }); + + it('rejects a --tag-name template with no {version} placeholder', async () => { + await expect(parseArgs('--repo', 'exadev/example', '--tag-name', 'latest')).rejects.toThrow(/\{version\}/); + }); + + it('rejects --release-rules that is not valid JSON', async () => { + await expect(parseArgs('--repo', 'exadev/example', '--release-rules', '[{')).rejects.toThrow(/--release-rules is not valid JSON/); + }); + + it('rejects --release-rules holding something other than release rules', async () => { + 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', () => { + // 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'); + }); +}); + +describe('runBuildIdentity', () => { + let repo: TestRepo | undefined; + + afterEach(() => { + repo?.cleanup(); + repo = undefined; + }); + + it('reports a commit identity as JSON when no tag points at HEAD', async () => { + repo = createTestRepo({ name: 'fixture', version: '1.4.0' }); + const parsed: unknown = JSON.parse(await runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root }))); + expect(parsed).toMatchObject({ kind: 'commit', url: expect.stringContaining('https://github.com/exadev/example/commit/') as unknown }); + }); + + it('reports a release identity when the release tag points at HEAD', async () => { + repo = createTestRepo({ name: 'fixture', version: '1.4.0' }); + repo.tag('v1.4.0'); + const parsed: unknown = JSON.parse(await runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root }))); + expect(parsed).toMatchObject({ kind: 'release', version: '1.4.0' }); + }); + + it('honours a caller-supplied tag convention for both the release check and the prediction base', async () => { + repo = createTestRepo({ name: 'fixture', version: '2.0.0' }); + 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 }), + ); + expect(JSON.parse(output)).toMatchObject({ kind: 'predicted', version: '2.1.0' }); + }); + + it('shows the predicted next version instead of a commit hash when --predict is given', async () => { + repo = createTestRepo({ name: 'fixture', version: '1.4.0' }); + repo.tag('v1.4.0'); + repo.commit('feat: add a thing'); + const parsed: unknown = JSON.parse(await runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root, predict: true, releaseRules: COMMIT_TYPE_RULES }))); + expect(parsed).toMatchObject({ kind: 'predicted', version: '1.5.0' }); + }); + + it('falls back to the commit identity when --predict finds nothing release-worthy', async () => { + repo = createTestRepo({ name: 'fixture', version: '1.4.0' }); + repo.tag('v1.4.0'); + repo.commit('chore: tidy up'); + const parsed: unknown = JSON.parse(await runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root, predict: true, releaseRules: COMMIT_TYPE_RULES }))); + expect(parsed).toMatchObject({ kind: 'commit' }); + }); + + it('never lets a prediction override a confirmed release', async () => { + repo = createTestRepo({ name: 'fixture', version: '1.4.0' }); + repo.tag('v1.4.0'); + const parsed: unknown = JSON.parse(await runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root, predict: true, releaseRules: COMMIT_TYPE_RULES }))); + expect(parsed).toMatchObject({ kind: 'release', version: '1.4.0' }); + }); + + it('reads rules from --release-rules-file', async () => { + repo = createTestRepo({ name: 'fixture', version: '1.4.0' }); + repo.tag('v1.4.0'); + repo.commit('fix: correct a thing'); + const rulesFile = join(repo.root, 'release-rules.json'); + writeFileSync(rulesFile, JSON.stringify(COMMIT_TYPE_RULES)); + const parsed: unknown = JSON.parse(await runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root, predict: true, releaseRulesFile: rulesFile }))); + expect(parsed).toMatchObject({ kind: 'predicted', version: '1.4.1' }); + }); + + it('names the file in a --release-rules-file read failure', async () => { + repo = createTestRepo({ name: 'fixture', version: '1.4.0' }); + const missing = join(repo.root, 'absent.json'); + await expect(runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root, predict: true, releaseRulesFile: missing }))).rejects.toThrow(/could not be read/); + }); + + it('rejects both rule sources at once rather than silently preferring one', async () => { + repo = createTestRepo({ name: 'fixture', version: '1.4.0' }); + await expect( + runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root, predict: true, releaseRules: COMMIT_TYPE_RULES, releaseRulesFile: 'rules.json' })), + ).rejects.toThrow(/pass one, not both/); + }); + + it('rejects --predict with no rules, rather than predicting against a rule set this package invented', async () => { + repo = createTestRepo({ name: 'fixture', version: '1.4.0' }); + await expect(runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root, predict: true }))).rejects.toThrow(/--release-rules/); + }); + + it('rejects rules supplied without --predict, rather than accepting a flag that would do nothing', async () => { + repo = createTestRepo({ name: 'fixture', version: '1.4.0' }); + await expect(runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root, releaseRules: COMMIT_TYPE_RULES }))).rejects.toThrow(/only take effect with --predict/); + }); + + it('emits prefixed KEY=VALUE lines in env format', async () => { + repo = createTestRepo({ name: 'fixture', version: '1.4.0' }); + repo.tag('v1.4.0'); + const lines = (await runBuildIdentity(flags({ repo: 'exadev/example', root: repo.root, format: 'env', prefix: 'RELEASE_' }))).split('\n'); + expect(lines[0]).toBe('RELEASE_KIND=release'); + expect(lines[1]).toBe('RELEASE_VERSION=1.4.0'); + }); +}); diff --git a/src/cli-program.ts b/src/cli-program.ts new file mode 100644 index 0000000..d5a33c3 --- /dev/null +++ b/src/cli-program.ts @@ -0,0 +1,143 @@ +import { readFileSync } from 'node:fs'; +import { inspect } from 'node:util'; +import { Command, InvalidArgumentError, Option } from 'commander'; +// resolveJsonModule lets rolldown (via tsdown) inline this package's own declared version straight into the bundle at build time -- no runtime fs read. +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 { 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. */ +const STDERR_LOGGER = { + log: (...args: readonly unknown[]): void => { writeStderr(args); }, + error: (...args: readonly unknown[]): void => { writeStderr(args); }, +}; + +function writeStderr(args: readonly unknown[]): void { + process.stderr.write(`${args.map((arg) => (typeof arg === 'string' ? arg : inspect(arg))).join(' ')}\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)}`); + } + return (version: string): string => template.replaceAll(VERSION_PLACEHOLDER, version); +} + +function parseFormat(value: string): OutputFormat { + if (!isOutputFormat(value)) { + throw new InvalidArgumentError(`--format must be one of: ${OUTPUT_FORMATS.join(', ')}`); + } + return value; +} + +function parsePrefix(value: string): string { + if (!isEnvPrefix(value)) { + throw new InvalidArgumentError(`--prefix must be empty or a legal variable-name prefix ([A-Za-z_][A-Za-z0-9_]*), got: ${JSON.stringify(value)}`); + } + return value; +} + +function parseInlineReleaseRules(value: string): ReleaseRule[] { + try { + return parseReleaseRules(value, '--release-rules'); + } catch (cause) { + throw new InvalidArgumentError(cause instanceof Error ? cause.message : String(cause)); + } +} + +export interface CliFlags { + readonly repo: string; + readonly root: string; + readonly tagName: (version: string) => string; + readonly predict: boolean | undefined; + readonly releaseRules: readonly ReleaseRule[] | undefined; + readonly releaseRulesFile: string | undefined; + readonly format: OutputFormat; + readonly prefix: string; + readonly verbose: boolean | undefined; +} + +function readReleaseRulesFile(path: string): ReleaseRule[] { + let raw: string; + try { + raw = readFileSync(path, 'utf8'); + } catch (cause) { + throw new Error(`--release-rules-file ${path} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`, { cause }); + } + return parseReleaseRules(raw, `--release-rules-file ${path}`); +} + +/** + * The whole CLI as a function of its already-parsed flags: resolves the identity and returns the exact text the command prints, so the wiring between the library's three functions is testable without a subprocess and without capturing stdout. + * + * Prediction is gated behind `--predict` rather than inferred: it needs `@semantic-release/commit-analyzer`, an optional peer dependency of this package, and reads the whole commit range since the last tag. Both are real costs a caller that only wants `resolveBuildIdentity`'s answer should not silently pay. + */ +export async function runBuildIdentity(flags: CliFlags): Promise { + if (flags.releaseRules !== undefined && flags.releaseRulesFile !== undefined) { + throw new Error('--release-rules and --release-rules-file are alternatives; pass one, not both'); + } + + const rules = flags.releaseRules ?? (flags.releaseRulesFile === undefined ? undefined : readReleaseRulesFile(flags.releaseRulesFile)); + + if (flags.predict === true && rules === undefined) { + throw new Error('--predict needs this repo\'s own commit-type release rules: pass --release-rules or --release-rules-file'); + } + if (flags.predict !== true && rules !== undefined) { + 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 }); + + let predictedVersion: string | undefined; + if (rules !== undefined) { + const options: PredictNextVersionOptions = { tagName: flags.tagName, ...(flags.verbose === true ? { logger: STDERR_LOGGER } : {}) }; + predictedVersion = await predictNextVersion(flags.root, rules, await loadCommitAnalyzer(), options); + } + + return formatIdentity(resolvePredictedIdentity(build, predictedVersion), { format: flags.format, prefix: flags.prefix }); +} + +/** + * Builds the commander program without parsing argv or exiting the process, so the command tree stays testable in isolation. There is no subcommand: the package does exactly one thing, and a subcommand naming it again would be pure ceremony. + */ +export function createProgram(): Command { + const program = new Command('build-identity'); + program.description("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."); + program.version(packageVersion); + + 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'), + ); + // 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