From 9bc3981271106124d8b8852596f3f5a27937b736 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:44:46 +0000 Subject: [PATCH 1/4] fix(prepare): Base stable releases on the last stable tag after prereleases Auto-versioning analyzed commits since the latest tag, even when that tag was a prerelease. Promoting a prerelease with no new commits failed with "no commits found since the last release", and the changelog of the stable release listed only the changes since the last prerelease. Auto-versioning and the changelog of a stable release now start from the latest stable tag. The bump still applies to the latest tag, so 14.0.0-alpha.5 becomes 14.0.0. Prereleases keep their changelog since the previous tag. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- docs/src/content/docs/versioning.md | 6 +++ src/__tests__/prepare-dry-run.e2e.test.ts | 38 +++++++++++++++++++ src/commands/prepare.ts | 22 ++++++++--- src/utils/__tests__/git.test.ts | 46 +++++++++++++++++++++++ src/utils/git.ts | 36 ++++++++++++++++++ 5 files changed, 143 insertions(+), 5 deletions(-) diff --git a/docs/src/content/docs/versioning.md b/docs/src/content/docs/versioning.md index 9638eddf7..420190c6c 100644 --- a/docs/src/content/docs/versioning.md +++ b/docs/src/content/docs/versioning.md @@ -62,6 +62,12 @@ Craft reads it from the files of the first configured target that [supports version bumping](#supported-targets) and declares a semver version above `0.0.0`. Without one, they increment `0.0.0`. +After a prerelease, bump types and `auto` promote it to a stable release: +`2.0.0-alpha.3` becomes `2.0.0`, or a higher version when the bump needs one. +`auto` picks the bump from the commits since the latest *stable* tag, and the +changelog of a stable release lists the changes since that tag, including those +already shipped in prereleases. + Bump types, `auto`, and `calver` require `minVersion: '2.14.0'` or later. ### Versioning policies diff --git a/src/__tests__/prepare-dry-run.e2e.test.ts b/src/__tests__/prepare-dry-run.e2e.test.ts index 4a61446c4..311fdca4b 100644 --- a/src/__tests__/prepare-dry-run.e2e.test.ts +++ b/src/__tests__/prepare-dry-run.e2e.test.ts @@ -701,6 +701,44 @@ targets: [] expect(combinedOutput).toContain('release/1.1.0'); }, 60000); + test('promotes the latest prerelease with changes since the last stable release', async () => { + tempDir = await createTestRepo(); + // eslint-disable-next-line no-restricted-syntax -- Test setup needs direct git access + const git = simpleGit(tempDir); + await writeFile( + join(tempDir, '.craft.yml'), + `minVersion: "2.14.0" +github: + owner: test-owner + repo: test-repo +versioning: + policy: auto +changelog: + policy: auto +preReleaseCommand: "" +targets: [] +`, + ); + await git.add('.'); + await git.commit('feat: Enable automatic versioning'); + await git.addTag('1.1.0-alpha.1'); + await git.push('origin', (await git.status()).current!); + + // No commits since the prerelease, so only the changes since the last + // stable release can determine the version. + const { stdout, stderr } = await execFileAsync( + CLI_BIN, + ['prepare', '--dry-run', '--no-input'], + { cwd: tempDir, env: { ...CLI_ENV, GITHUB_TOKEN: '' } }, + ); + + const output = stdout + stderr; + expect(output).toContain('Analyzing commits since 1.0.0'); + expect(output).toContain('Version bump: 1.1.0-alpha.1 -> 1.1.0'); + expect(output).toContain('## 1.1.0'); + expect(output).toContain('Add foo feature'); + }, 60000); + test('prepares a workspace changelog without changing the root changelog', async () => { tempDir = await createTestRepo(); // eslint-disable-next-line no-restricted-syntax -- Test setup needs direct git access diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index ee129d792..892670999 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -44,6 +44,7 @@ import { import { getDefaultBranch, getGitClient, + getLatestStableTag, getLatestTag, isRepoDirty, } from '../utils/git'; @@ -58,7 +59,7 @@ import { import { formatJson } from '../utils/strings'; import { runPreReleaseCommand } from '../utils/preReleaseCommand'; import { withTracing } from '../utils/tracing'; -import { getVersion, isValidVersion } from '../utils/version'; +import { getVersion, isValidVersion, parseVersion } from '../utils/version'; import { readProjectVersion } from '../utils/versionBump'; import { type PublishOptions, handler as publishMainHandler } from './publish'; @@ -693,7 +694,12 @@ async function resolveVersion( // Determine bump type - either from arg or from commit analysis let bumpType: BumpType; if (version === 'auto') { - const changelogResult = await getChangelogWithBumpType(git, latestTag); + // Bumps always produce a stable version, which ships every change since + // the last stable release, including those already in prereleases. + const changelogResult = await getChangelogWithBumpType( + git, + await getLatestStableTag(git, getGitTagPrefix()), + ); validateBumpType(changelogResult); bumpType = changelogResult.bumpType; } else { @@ -702,7 +708,8 @@ async function resolveVersion( // Calculate new version from the latest tag. Before the first tag, start // from the version in the release unit's project files, so a workspace at - // `1.0.0-dev.0` releases `1.0.0` instead of a bump of `0.0.0`. + // `1.0.0-dev.0` releases `1.0.0` instead of a bump of `0.0.0`. Bumping a + // prerelease promotes it, so `2.0.0-alpha.3` releases `2.0.0`. const currentVersion = (latestTag && getVersion(latestTag)) || (await readProjectVersion(config.targets || [], getReleaseUnitDir())) || @@ -802,13 +809,18 @@ export async function prepareMain(argv: PrepareOptions): Promise { // TL;DR - WARNING: // The order matters here, do not move this command above createReleaseBranch! const oldVersion = await getLatestTag(git, getGitTagPrefix()); + // A stable release lists every change since the last stable release, so + // its changelog covers the prereleases before it as well. + const changelogBase = parseVersion(newVersion)?.pre + ? oldVersion + : await getLatestStableTag(git, getGitTagPrefix()); // Check & update the changelog with the selected workspace's default path. const { filePath: changelogPath, policy: changelogPolicy } = getChangelogConfig(); const changelogBody = await prepareChangelog( git, - oldVersion, + changelogBase, newVersion, argv.noChangelog ? ChangelogPolicy.None : changelogPolicy, changelogPath, @@ -852,7 +864,7 @@ export async function prepareMain(argv: PrepareOptions): Promise { const releaseSha = await git.revparse(['HEAD']); setGitHubActionsOutput('branch', branchName); setGitHubActionsOutput('sha', releaseSha); - setGitHubActionsOutput('previous_tag', oldVersion || ''); + setGitHubActionsOutput('previous_tag', changelogBase || ''); setGitHubActionsOutput('targets', JSON.stringify(targetIds)); if (changelogBody) { // For CalVer releases, replace @-mentions with bold formatting in the diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index efab3d4d9..574e2c50e 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -7,6 +7,7 @@ import { createGitClient, getGitClient, getChangesSince, + getLatestStableTag, getLatestTag, isRepoDirty, findReleaseBranches, @@ -77,6 +78,51 @@ describe('getLatestTag', () => { }); }); +describe('getLatestStableTag', () => { + it('returns the latest tag when it is stable', async () => { + const git = { raw: vi.fn().mockResolvedValue('1.0.0\n') } as any; + + expect(await getLatestStableTag(git)).toBe('1.0.0'); + expect(git.raw).toHaveBeenCalledTimes(1); + }); + + it('skips prerelease tags until it reaches a stable one', async () => { + const git = { + raw: vi + .fn() + .mockResolvedValueOnce('cli@2.0.0-alpha.2') + .mockResolvedValueOnce('cli@2.0.0-alpha.1') + .mockResolvedValueOnce('cli@1.9.0'), + } as any; + + expect(await getLatestStableTag(git, 'cli@')).toBe('cli@1.9.0'); + expect(git.raw).toHaveBeenLastCalledWith([ + 'describe', + '--tags', + '--abbrev=0', + '--match', + 'cli@*', + '--exclude', + 'cli@2.0.0-alpha.2', + '--exclude', + 'cli@2.0.0-alpha.1', + ]); + }); + + it('returns an empty string when only prerelease tags are reachable', async () => { + const git = { + raw: vi + .fn() + .mockResolvedValueOnce('1.0.0-rc.1') + .mockRejectedValueOnce( + new Error("fatal: No tags can describe 'abc123'."), + ), + } as any; + + expect(await getLatestStableTag(git)).toBe(''); + }); +}); + describe('getGitClient', () => { it('preserves Git identity environment variables for commits', async () => { const dir = mkdtempSync(join(tmpdir(), 'craft-git-identity-')); diff --git a/src/utils/git.ts b/src/utils/git.ts index 9e30c7ccb..c856f49d3 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -25,6 +25,7 @@ import { logger } from '../logger'; import { distance as levenshtein } from 'fastest-levenshtein'; import path from 'node:path'; import { discoverWorkspaces } from './workspaces'; +import { parseVersion } from './version'; export interface GitChange { hash: string; @@ -70,6 +71,37 @@ export async function getDefaultBranch( export async function getLatestTag( git: SimpleGit, tagPrefix = '', +): Promise { + return describeLatestTag(git, tagPrefix, []); +} + +/** + * Returns the latest tag whose version is not a prerelease, or an empty string + * when there is none. + * + * A stable release that follows prereleases (e.g. `2.0.0` after + * `2.0.0-alpha.3`) contains every change since the previous stable release, + * so that release, not the latest prerelease, is the base for its version + * bump and changelog. + */ +export async function getLatestStableTag( + git: SimpleGit, + tagPrefix = '', +): Promise { + const prereleaseTags: string[] = []; + for (;;) { + const tag = await describeLatestTag(git, tagPrefix, prereleaseTags); + if (!tag || !parseVersion(tag.slice(tagPrefix.length))?.pre) { + return tag; + } + prereleaseTags.push(tag); + } +} + +async function describeLatestTag( + git: SimpleGit, + tagPrefix: string, + excludedTags: string[], ): Promise { try { // This part is courtesy of https://stackoverflow.com/a/7261049/90297 @@ -81,12 +113,16 @@ export async function getLatestTag( // is resolved per-product instead of picking whatever is newest overall. args.push('--match', `${tagPrefix}*`); } + for (const tag of excludedTags) { + args.push('--exclude', tag); + } return (await git.raw(args)).trim(); } catch (err) { // If there are no tags, return an empty string if ( err instanceof Error && (err.message.startsWith('fatal: No names found') || + err.message.startsWith('fatal: No tags can describe') || err.message.startsWith('Nothing to describe')) ) { return ''; From d59f1c6db3a3289b2117dcbb6df6a12684bed0f2 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:12:12 +0000 Subject: [PATCH 2/4] feat(prepare): Add pre version and keep auto on prereleases before a major - `pre` cuts the next prerelease: it continues the current series when the bump stays in it (14.0.0-alpha.5 -> 14.0.0-alpha.6) and starts a new one otherwise (13.9.0 with a major bump -> 14.0.0-alpha.0). - After a prerelease, `auto` cuts another prerelease instead of a major release. Pass the version or `major` to release it. - A prerelease's changelog lists the changes since the previous prerelease of the same major.minor.patch, or since the latest stable tag when it starts a new series. Stable releases still list the changes since the latest stable tag. --- action.yml | 3 +- docs/src/content/docs/versioning.md | 26 ++++++++-- src/__tests__/prepare-dry-run.e2e.test.ts | 41 +++++++++++++++- src/commands/__tests__/prepare.test.ts | 4 ++ src/commands/prepare.ts | 58 +++++++++++++++++------ src/utils/__tests__/autoVersion.test.ts | 21 ++++++++ src/utils/__tests__/git.test.ts | 33 +++++++++++++ src/utils/autoVersion.ts | 35 ++++++++++++++ src/utils/git.ts | 53 +++++++++++++++++---- 9 files changed, 244 insertions(+), 30 deletions(-) diff --git a/action.yml b/action.yml index 5919b2eff..80e48f1d0 100644 --- a/action.yml +++ b/action.yml @@ -5,7 +5,8 @@ inputs: version: description: > Version to release. Can be a semver string (e.g., "1.2.3"), - a bump type ("major", "minor", "patch"), or "auto" for automatic detection. + a bump type ("major", "minor", "patch"), "auto" for automatic detection, + or "pre" for the next prerelease. required: false merge_target: description: Target branch to merge into. Uses the default branch as a fallback. diff --git a/docs/src/content/docs/versioning.md b/docs/src/content/docs/versioning.md index 420190c6c..41ddba678 100644 --- a/docs/src/content/docs/versioning.md +++ b/docs/src/content/docs/versioning.md @@ -52,7 +52,8 @@ Pass the version to `craft prepare` in one of these forms: | --- | --- | | `1.2.3` | Release exactly this version. | | `major`, `minor`, `patch` | Increment the latest tag. | -| `auto` | Pick the bump from the commits since the latest tag, using the `semver` field of each [changelog category](../changelogs/#craft-extensions-to-releaseyml). | +| `auto` | Pick the bump from the commits since the latest stable tag, using the `semver` field of each [changelog category](../changelogs/#craft-extensions-to-releaseyml). | +| `pre` | Cut the next prerelease for the bump `auto` would pick. | | `calver` | Compute a [calendar version](#calendar-versioning-calver). | | *(omitted)* | Use `versioning.policy`. | @@ -64,11 +65,26 @@ above `0.0.0`. Without one, they increment `0.0.0`. After a prerelease, bump types and `auto` promote it to a stable release: `2.0.0-alpha.3` becomes `2.0.0`, or a higher version when the bump needs one. -`auto` picks the bump from the commits since the latest *stable* tag, and the -changelog of a stable release lists the changes since that tag, including those -already shipped in prereleases. +To be safe, `auto` doesn't promote a prerelease to a major release: after +`2.0.0-alpha.3` it cuts `2.0.0-alpha.4`. Pass `2.0.0` or `major` to release +`2.0.0`. -Bump types, `auto`, and `calver` require `minVersion: '2.14.0'` or later. +`pre` continues the latest prerelease series when the bump stays in it +(`2.0.0-alpha.3` becomes `2.0.0-alpha.4`) and starts a new one otherwise +(`1.9.0` with a major bump becomes `2.0.0-alpha.0`). + +Bump types, `auto`, `pre`, and `calver` require `minVersion: '2.14.0'` or later. + +### Changelog base + +The changelog of a release lists the changes since: + +- **Stable release**: the latest stable tag, including the changes already in + the prereleases since then. `2.0.0` lists the changes since `1.9.0`. +- **Prerelease**: the previous prerelease of the same `major.minor.patch`, or + the latest stable tag when there is none. `2.0.0-alpha.4` lists the changes + since `2.0.0-alpha.3`, and `2.1.0-alpha.0` lists the changes since `2.0.4`, + not since `2.0.0-alpha.3`. ### Versioning policies diff --git a/src/__tests__/prepare-dry-run.e2e.test.ts b/src/__tests__/prepare-dry-run.e2e.test.ts index 311fdca4b..e0dbfdba9 100644 --- a/src/__tests__/prepare-dry-run.e2e.test.ts +++ b/src/__tests__/prepare-dry-run.e2e.test.ts @@ -736,7 +736,46 @@ targets: [] expect(output).toContain('Analyzing commits since 1.0.0'); expect(output).toContain('Version bump: 1.1.0-alpha.1 -> 1.1.0'); expect(output).toContain('## 1.1.0'); - expect(output).toContain('Add foo feature'); + expect(output).toContain('Add foo feature in'); + }, 60000); + + test('cuts a prerelease when a major release follows a prerelease', async () => { + tempDir = await createTestRepo(); + // eslint-disable-next-line no-restricted-syntax -- Test setup needs direct git access + const git = simpleGit(tempDir); + await writeFile( + join(tempDir, '.craft.yml'), + `minVersion: "2.14.0" +github: + owner: test-owner + repo: test-repo +versioning: + policy: auto +changelog: + policy: auto +preReleaseCommand: "" +targets: [] +`, + ); + await git.add('.'); + await git.commit('feat!: Enable automatic versioning'); + await git.addTag('2.0.0-alpha.0'); + await writeFile(join(tempDir, 'baz.ts'), 'export const baz = 3;'); + await git.add('.'); + await git.commit('fix: Fix baz issue'); + await git.push('origin', (await git.status()).current!); + + const { stdout, stderr } = await execFileAsync( + CLI_BIN, + ['prepare', '--dry-run', '--no-input'], + { cwd: tempDir, env: { ...CLI_ENV, GITHUB_TOKEN: '' } }, + ); + + const output = stdout + stderr; + expect(output).toContain('Version bump: 2.0.0-alpha.0 -> 2.0.0-alpha.1'); + expect(output).toContain('## 2.0.0-alpha.1'); + expect(output).toContain('Fix baz issue in'); + expect(output).not.toContain('Add foo feature in'); }, 60000); test('prepares a workspace changelog without changing the root changelog', async () => { diff --git a/src/commands/__tests__/prepare.test.ts b/src/commands/__tests__/prepare.test.ts index 90d25c3bf..de400a246 100644 --- a/src/commands/__tests__/prepare.test.ts +++ b/src/commands/__tests__/prepare.test.ts @@ -292,6 +292,10 @@ describe('checkVersionOrPart', () => { ).toBe(true); }); + test('return true for pre version', () => { + expect(checkVersionOrPart({ newVersion: 'pre' }, null)).toBe(true); + }); + test('return true for version bump types', () => { const bumpTypes = ['major', 'minor', 'patch']; for (const bumpType of bumpTypes) { diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index 892670999..a9eca7d16 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -1,6 +1,7 @@ import { existsSync, promises as fsPromises } from 'fs'; import { relative } from 'path'; +import * as semver from 'semver'; import type { SimpleGit, StatusResult } from 'simple-git'; import type { Arguments, Argv, CommandBuilder } from 'yargs'; import { createDryRunIsolation, safeFs } from '../utils/dryRun'; @@ -22,6 +23,7 @@ import { ChangelogPolicy, VersioningPolicy } from '../schemas/project_config'; import { sleep } from '../utils/async'; import { type BumpType, + calculateNextPrerelease, calculateNextVersion, getChangelogWithBumpType, isBumpType, @@ -44,6 +46,7 @@ import { import { getDefaultBranch, getGitClient, + getChangelogBaseTag, getLatestStableTag, getLatestTag, isRepoDirty, @@ -78,7 +81,8 @@ export const builder: CommandBuilder = (yargs: Argv) => description: 'The new version to release. Can be: a semver string (e.g., "1.2.3"), ' + 'a bump type ("major", "minor", or "patch"), "auto" to determine automatically ' + - 'from conventional commits, or "calver" for calendar versioning. ' + + 'from conventional commits, "pre" for the next prerelease, or "calver" ' + + 'for calendar versioning. ' + 'If omitted, uses the versioning.policy from .craft.yml', type: 'string', }) @@ -157,7 +161,8 @@ const SLEEP_BEFORE_PUBLISH_SECONDS = 30; * Checks the provided version argument for validity * * We check that the argument is either a valid version string, 'auto' for - * automatic version detection, 'calver' for calendar versioning, a version + * automatic version detection, 'pre' for the next prerelease, 'calver' for + * calendar versioning, a version * bump type (major/minor/patch), or a valid semantic version. * Empty/undefined is also allowed (will use versioning.policy from config). * @@ -177,6 +182,11 @@ export function checkVersionOrPart(argv: Arguments, _opt: any): boolean { return true; } + // Allow 'pre' for the next prerelease + if (version === 'pre') { + return true; + } + // Allow 'calver' for calendar versioning if (version === 'calver') { return true; @@ -604,7 +614,7 @@ async function switchToDefaultBranch( } interface ResolveVersionOptions { - /** The raw version input from CLI (may be undefined, 'auto', 'calver', bump type, or semver) */ + /** The raw version input from CLI (may be undefined, 'auto', 'pre', 'calver', bump type, or semver) */ versionArg?: string; /** Override for CalVer offset (days to go back) */ calverOffset?: number; @@ -617,10 +627,11 @@ interface ResolveVersionOptions { * - No input: uses versioning.policy from config * - 'calver': calculates calendar version * - 'auto': analyzes commits to determine bump type + * - 'pre': analyzes commits to determine the next prerelease * - 'major'/'minor'/'patch': applies bump to latest tag * - Explicit semver: returns as-is * - * Before the first tag, 'auto' and bump types start from the version in the + * Before the first tag, 'auto', 'pre', and bump types start from the version in the * release unit's project files, read by its targets (see readProjectVersion). * * @param git Local git client @@ -678,7 +689,7 @@ async function resolveVersion( } // Handle automatic version detection or version bump types - if (version === 'auto' || isBumpType(version)) { + if (version === 'auto' || version === 'pre' || isBumpType(version)) { if (!requiresMinVersion(AUTO_VERSION_MIN_VERSION)) { const featureName = isBumpType(version) ? 'Version bump types' @@ -693,9 +704,9 @@ async function resolveVersion( // Determine bump type - either from arg or from commit analysis let bumpType: BumpType; - if (version === 'auto') { - // Bumps always produce a stable version, which ships every change since - // the last stable release, including those already in prereleases. + if (version === 'auto' || version === 'pre') { + // The next release ships every change since the last stable release, + // including those already in prereleases. const changelogResult = await getChangelogWithBumpType( git, await getLatestStableTag(git, getGitTagPrefix()), @@ -715,7 +726,26 @@ async function resolveVersion( (await readProjectVersion(config.targets || [], getReleaseUnitDir())) || '0.0.0'; - const newVersion = calculateNextVersion(currentVersion, bumpType); + let newVersion = calculateNextVersion(currentVersion, bumpType); + if (version === 'pre') { + newVersion = calculateNextPrerelease(currentVersion, bumpType); + } else if ( + version === 'auto' && + latestTag && + parseVersion(currentVersion)?.pre && + semver.minor(newVersion) === 0 && + semver.patch(newVersion) === 0 + ) { + // A major release after prereleases is cut explicitly, so `auto` keeps + // cutting prereleases until someone asks for the stable release. + const prerelease = calculateNextPrerelease(currentVersion, bumpType); + logger.info( + `The latest release ${currentVersion} is a prerelease and ${newVersion} ` + + `is a major release, so releasing ${prerelease} instead. ` + + `Run "craft prepare ${newVersion}" to release ${newVersion}.`, + ); + newVersion = prerelease; + } logger.info( `Version bump: ${currentVersion} -> ${newVersion} (${bumpType} bump)`, ); @@ -809,11 +839,11 @@ export async function prepareMain(argv: PrepareOptions): Promise { // TL;DR - WARNING: // The order matters here, do not move this command above createReleaseBranch! const oldVersion = await getLatestTag(git, getGitTagPrefix()); - // A stable release lists every change since the last stable release, so - // its changelog covers the prereleases before it as well. - const changelogBase = parseVersion(newVersion)?.pre - ? oldVersion - : await getLatestStableTag(git, getGitTagPrefix()); + const changelogBase = await getChangelogBaseTag( + git, + newVersion, + getGitTagPrefix(), + ); // Check & update the changelog with the selected workspace's default path. const { filePath: changelogPath, policy: changelogPolicy } = diff --git a/src/utils/__tests__/autoVersion.test.ts b/src/utils/__tests__/autoVersion.test.ts index 4d531f77a..b3d0ae641 100644 --- a/src/utils/__tests__/autoVersion.test.ts +++ b/src/utils/__tests__/autoVersion.test.ts @@ -25,6 +25,7 @@ import * as config from '../../config'; import { getChangesSince } from '../git'; import { getGitHubClient } from '../githubApi'; import { + calculateNextPrerelease, calculateNextVersion, getChangelogWithBumpType, validateBumpType, @@ -70,6 +71,26 @@ describe('calculateNextVersion', () => { }); }); +describe('calculateNextPrerelease', () => { + test('continues the prerelease series the bump stays in', () => { + expect(calculateNextPrerelease('2.0.0-alpha.3', 'minor')).toBe( + '2.0.0-alpha.4', + ); + expect(calculateNextPrerelease('2.0.0-rc.1', 'major')).toBe('2.0.0-rc.2'); + }); + + test('starts a new series when the bump leaves the current one', () => { + expect(calculateNextPrerelease('2.1.0-alpha.0', 'major')).toBe( + '3.0.0-alpha.0', + ); + }); + + test('starts a new series after a stable release', () => { + expect(calculateNextPrerelease('1.9.0', 'major')).toBe('2.0.0-alpha.0'); + expect(calculateNextPrerelease('1.9.0', 'patch')).toBe('1.9.1-alpha.0'); + }); +}); + describe('validateBumpType', () => { test('throws error when no commits found', () => { const result = { diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index 574e2c50e..d966fcc81 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -7,6 +7,7 @@ import { createGitClient, getGitClient, getChangesSince, + getChangelogBaseTag, getLatestStableTag, getLatestTag, isRepoDirty, @@ -123,6 +124,38 @@ describe('getLatestStableTag', () => { }); }); +describe('getChangelogBaseTag', () => { + function describeTags(...tags: string[]) { + const raw = vi.fn(); + for (const tag of tags) { + raw.mockResolvedValueOnce(tag); + } + return { raw } as any; + } + + it('follows the latest stable tag for a stable release', async () => { + const git = describeTags('2.0.0-alpha.1', '1.1.0-alpha.0', '1.0.0'); + + expect(await getChangelogBaseTag(git, '2.0.0')).toBe('1.0.0'); + }); + + it('follows the previous prerelease of the same series', async () => { + const git = describeTags('2.0.0-alpha.4', '1.9.0'); + + expect(await getChangelogBaseTag(git, '2.0.0-alpha.5')).toBe( + '2.0.0-alpha.4', + ); + }); + + it('follows the latest stable tag for a new prerelease series', async () => { + const git = describeTags('cli@2.0.0-alpha.3', 'cli@1.9.0'); + + expect(await getChangelogBaseTag(git, '2.1.0-alpha.0', 'cli@')).toBe( + 'cli@1.9.0', + ); + }); +}); + describe('getGitClient', () => { it('preserves Git identity environment variables for commits', async () => { const dir = mkdtempSync(join(tmpdir(), 'craft-git-identity-')); diff --git a/src/utils/autoVersion.ts b/src/utils/autoVersion.ts index df6054b6c..fa69d1a4b 100644 --- a/src/utils/autoVersion.ts +++ b/src/utils/autoVersion.ts @@ -39,6 +39,41 @@ export function calculateNextVersion( return newVersion; } +/** Prerelease identifier for a new prerelease series, e.g. `2.0.0-alpha.0`. */ +const NEW_PRERELEASE_ID = 'alpha'; + +/** + * Calculates the next prerelease for the given bump. + * + * Continues the current prerelease series when the bumped release belongs to + * it (`2.0.0-alpha.3` -> `2.0.0-alpha.4`), and starts a new series otherwise + * (`1.9.0` with a major bump -> `2.0.0-alpha.0`). + * + * @param currentVersion The current version string (e.g., "2.0.0-alpha.3") + * @param bumpType The type of bump the changes need + * @returns The new prerelease version string + */ +export function calculateNextPrerelease( + currentVersion: string, + bumpType: BumpType, +): string { + const release = calculateNextVersion(currentVersion, bumpType); + const current = semver.parse(currentVersion); + if ( + current?.prerelease.length && + release === `${current.major}.${current.minor}.${current.patch}` + ) { + const newVersion = semver.inc(current, 'prerelease'); + if (!newVersion) { + throw new Error( + `Failed to increment prerelease version "${currentVersion}"`, + ); + } + return newVersion; + } + return `${release}-${NEW_PRERELEASE_ID}.0`; +} + /** * Generates changelog and determines version bump type from commits. * This is a convenience wrapper around generateChangesetFromGit that logs progress. diff --git a/src/utils/git.ts b/src/utils/git.ts index c856f49d3..698f5d504 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -25,7 +25,7 @@ import { logger } from '../logger'; import { distance as levenshtein } from 'fastest-levenshtein'; import path from 'node:path'; import { discoverWorkspaces } from './workspaces'; -import { parseVersion } from './version'; +import { parseVersion, type SemVer } from './version'; export interface GitChange { hash: string; @@ -79,22 +79,57 @@ export async function getLatestTag( * Returns the latest tag whose version is not a prerelease, or an empty string * when there is none. * - * A stable release that follows prereleases (e.g. `2.0.0` after - * `2.0.0-alpha.3`) contains every change since the previous stable release, - * so that release, not the latest prerelease, is the base for its version - * bump and changelog. + * The next release contains every change since the latest stable release, + * including those already in prereleases, so that release is the base for + * picking its version bump. */ export async function getLatestStableTag( git: SimpleGit, tagPrefix = '', ): Promise { - const prereleaseTags: string[] = []; + return findLatestTag(git, tagPrefix, tagVersion => !tagVersion?.pre); +} + +/** + * Returns the tag whose changes a release of `version` follows, or an empty + * string when there is none. + * + * A stable release follows the latest stable tag. A prerelease follows the + * latest prerelease of the same `major.minor.patch` (`2.0.0-alpha.3` follows + * `2.0.0-alpha.2`), or the latest stable tag when it starts a new series + * (`2.1.0-alpha.0` follows `2.0.4`, not `2.0.0-alpha.3`). + */ +export async function getChangelogBaseTag( + git: SimpleGit, + version: string, + tagPrefix = '', +): Promise { + const release = parseVersion(version); + return findLatestTag( + git, + tagPrefix, + tagVersion => + !tagVersion?.pre || + (!!release?.pre && + tagVersion.major === release.major && + tagVersion.minor === release.minor && + tagVersion.patch === release.patch), + ); +} + +/** Returns the latest tag whose version `accept` accepts. */ +async function findLatestTag( + git: SimpleGit, + tagPrefix: string, + accept: (tagVersion: SemVer | null) => boolean, +): Promise { + const skippedTags: string[] = []; for (;;) { - const tag = await describeLatestTag(git, tagPrefix, prereleaseTags); - if (!tag || !parseVersion(tag.slice(tagPrefix.length))?.pre) { + const tag = await describeLatestTag(git, tagPrefix, skippedTags); + if (!tag || accept(parseVersion(tag.slice(tagPrefix.length)))) { return tag; } - prereleaseTags.push(tag); + skippedTags.push(tag); } } From ef72b279e36510e98faa117bd1f99f2490a93356 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:24:20 +0000 Subject: [PATCH 3/4] ref(prepare): Make pre cut a prerelease of the version auto would release calculatePrerelease() now takes the version auto would release instead of a bump type, so pre reads as "auto, then make it a prerelease": 1.9.0 -> 1.9.1-alpha.0 for a patch, 1.10.0-alpha.0 for a minor. Behavior is unchanged. Renames the e2e tests to name the versions they release. --- docs/src/content/docs/versioning.md | 10 +++++---- src/__tests__/prepare-dry-run.e2e.test.ts | 4 ++-- src/commands/prepare.ts | 7 ++++--- src/utils/__tests__/autoVersion.test.ts | 25 ++++++++++------------- src/utils/autoVersion.ts | 21 +++++++++---------- 5 files changed, 33 insertions(+), 34 deletions(-) diff --git a/docs/src/content/docs/versioning.md b/docs/src/content/docs/versioning.md index 41ddba678..887b4fcc5 100644 --- a/docs/src/content/docs/versioning.md +++ b/docs/src/content/docs/versioning.md @@ -53,7 +53,7 @@ Pass the version to `craft prepare` in one of these forms: | `1.2.3` | Release exactly this version. | | `major`, `minor`, `patch` | Increment the latest tag. | | `auto` | Pick the bump from the commits since the latest stable tag, using the `semver` field of each [changelog category](../changelogs/#craft-extensions-to-releaseyml). | -| `pre` | Cut the next prerelease for the bump `auto` would pick. | +| `pre` | Cut a prerelease of the version `auto` would release. | | `calver` | Compute a [calendar version](#calendar-versioning-calver). | | *(omitted)* | Use `versioning.policy`. | @@ -69,9 +69,11 @@ To be safe, `auto` doesn't promote a prerelease to a major release: after `2.0.0-alpha.3` it cuts `2.0.0-alpha.4`. Pass `2.0.0` or `major` to release `2.0.0`. -`pre` continues the latest prerelease series when the bump stays in it -(`2.0.0-alpha.3` becomes `2.0.0-alpha.4`) and starts a new one otherwise -(`1.9.0` with a major bump becomes `2.0.0-alpha.0`). +`pre` takes the version `auto` would release and cuts a prerelease of it. +After `1.9.0`, it cuts `1.9.1-alpha.0` when `auto` picks a patch bump, +`1.10.0-alpha.0` for a minor bump, and `2.0.0-alpha.0` for a major bump. When +the latest tag is already a prerelease of that version, `pre` cuts the next one +instead: `1.10.0-alpha.1` after `1.10.0-alpha.0`. Bump types, `auto`, `pre`, and `calver` require `minVersion: '2.14.0'` or later. diff --git a/src/__tests__/prepare-dry-run.e2e.test.ts b/src/__tests__/prepare-dry-run.e2e.test.ts index e0dbfdba9..e08d970e3 100644 --- a/src/__tests__/prepare-dry-run.e2e.test.ts +++ b/src/__tests__/prepare-dry-run.e2e.test.ts @@ -701,7 +701,7 @@ targets: [] expect(combinedOutput).toContain('release/1.1.0'); }, 60000); - test('promotes the latest prerelease with changes since the last stable release', async () => { + test('auto releases 1.1.0 after 1.1.0-alpha.1 with the changes since 1.0.0', async () => { tempDir = await createTestRepo(); // eslint-disable-next-line no-restricted-syntax -- Test setup needs direct git access const git = simpleGit(tempDir); @@ -739,7 +739,7 @@ targets: [] expect(output).toContain('Add foo feature in'); }, 60000); - test('cuts a prerelease when a major release follows a prerelease', async () => { + test('auto releases 2.0.0-alpha.1 instead of 2.0.0 after 2.0.0-alpha.0', async () => { tempDir = await createTestRepo(); // eslint-disable-next-line no-restricted-syntax -- Test setup needs direct git access const git = simpleGit(tempDir); diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index a9eca7d16..17430d17c 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -23,7 +23,7 @@ import { ChangelogPolicy, VersioningPolicy } from '../schemas/project_config'; import { sleep } from '../utils/async'; import { type BumpType, - calculateNextPrerelease, + calculatePrerelease, calculateNextVersion, getChangelogWithBumpType, isBumpType, @@ -728,7 +728,8 @@ async function resolveVersion( let newVersion = calculateNextVersion(currentVersion, bumpType); if (version === 'pre') { - newVersion = calculateNextPrerelease(currentVersion, bumpType); + // `pre` cuts a prerelease of the version `auto` would release. + newVersion = calculatePrerelease(newVersion, currentVersion); } else if ( version === 'auto' && latestTag && @@ -738,7 +739,7 @@ async function resolveVersion( ) { // A major release after prereleases is cut explicitly, so `auto` keeps // cutting prereleases until someone asks for the stable release. - const prerelease = calculateNextPrerelease(currentVersion, bumpType); + const prerelease = calculatePrerelease(newVersion, currentVersion); logger.info( `The latest release ${currentVersion} is a prerelease and ${newVersion} ` + `is a major release, so releasing ${prerelease} instead. ` + diff --git a/src/utils/__tests__/autoVersion.test.ts b/src/utils/__tests__/autoVersion.test.ts index b3d0ae641..4054d7082 100644 --- a/src/utils/__tests__/autoVersion.test.ts +++ b/src/utils/__tests__/autoVersion.test.ts @@ -25,7 +25,7 @@ import * as config from '../../config'; import { getChangesSince } from '../git'; import { getGitHubClient } from '../githubApi'; import { - calculateNextPrerelease, + calculatePrerelease, calculateNextVersion, getChangelogWithBumpType, validateBumpType, @@ -71,23 +71,20 @@ describe('calculateNextVersion', () => { }); }); -describe('calculateNextPrerelease', () => { - test('continues the prerelease series the bump stays in', () => { - expect(calculateNextPrerelease('2.0.0-alpha.3', 'minor')).toBe( - '2.0.0-alpha.4', - ); - expect(calculateNextPrerelease('2.0.0-rc.1', 'major')).toBe('2.0.0-rc.2'); +describe('calculatePrerelease', () => { + test('cuts the first prerelease of the version after a stable release', () => { + expect(calculatePrerelease('1.9.1', '1.9.0')).toBe('1.9.1-alpha.0'); + expect(calculatePrerelease('1.10.0', '1.9.0')).toBe('1.10.0-alpha.0'); + expect(calculatePrerelease('2.0.0', '1.9.0')).toBe('2.0.0-alpha.0'); }); - test('starts a new series when the bump leaves the current one', () => { - expect(calculateNextPrerelease('2.1.0-alpha.0', 'major')).toBe( - '3.0.0-alpha.0', - ); + test('cuts the next prerelease after a prerelease of the same version', () => { + expect(calculatePrerelease('2.0.0', '2.0.0-alpha.3')).toBe('2.0.0-alpha.4'); + expect(calculatePrerelease('2.0.0', '2.0.0-rc.1')).toBe('2.0.0-rc.2'); }); - test('starts a new series after a stable release', () => { - expect(calculateNextPrerelease('1.9.0', 'major')).toBe('2.0.0-alpha.0'); - expect(calculateNextPrerelease('1.9.0', 'patch')).toBe('1.9.1-alpha.0'); + test('cuts the first prerelease of the version after a prerelease of another version', () => { + expect(calculatePrerelease('3.0.0', '2.1.0-alpha.0')).toBe('3.0.0-alpha.0'); }); }); diff --git a/src/utils/autoVersion.ts b/src/utils/autoVersion.ts index fa69d1a4b..73768039e 100644 --- a/src/utils/autoVersion.ts +++ b/src/utils/autoVersion.ts @@ -43,25 +43,24 @@ export function calculateNextVersion( const NEW_PRERELEASE_ID = 'alpha'; /** - * Calculates the next prerelease for the given bump. + * Returns the next prerelease of `version`. * - * Continues the current prerelease series when the bumped release belongs to - * it (`2.0.0-alpha.3` -> `2.0.0-alpha.4`), and starts a new series otherwise - * (`1.9.0` with a major bump -> `2.0.0-alpha.0`). + * Starts at `-alpha.0` (`1.10.0` -> `1.10.0-alpha.0`). When the + * current version is already a prerelease of `version`, continues its series + * instead (`1.10.0` after `1.10.0-alpha.0` -> `1.10.0-alpha.1`). * - * @param currentVersion The current version string (e.g., "2.0.0-alpha.3") - * @param bumpType The type of bump the changes need + * @param version The stable version to cut a prerelease of (e.g., "1.10.0") + * @param currentVersion The current version string (e.g., "1.10.0-alpha.0") * @returns The new prerelease version string */ -export function calculateNextPrerelease( +export function calculatePrerelease( + version: string, currentVersion: string, - bumpType: BumpType, ): string { - const release = calculateNextVersion(currentVersion, bumpType); const current = semver.parse(currentVersion); if ( current?.prerelease.length && - release === `${current.major}.${current.minor}.${current.patch}` + version === `${current.major}.${current.minor}.${current.patch}` ) { const newVersion = semver.inc(current, 'prerelease'); if (!newVersion) { @@ -71,7 +70,7 @@ export function calculateNextPrerelease( } return newVersion; } - return `${release}-${NEW_PRERELEASE_ID}.0`; + return `${version}-${NEW_PRERELEASE_ID}.0`; } /** From d0daa1e5cb1b488415b581bc4f1b2c2f6ef70ae8 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:39:13 +0000 Subject: [PATCH 4/4] feat(prepare): Require minVersion 3.9.0 for pre and test tag lookups on a real repo - `pre` needs minVersion >= 3.9.0, like other versioning features that are gated on the Craft version that introduced them. - Replace the mocked getLatestStableTag tests, which asserted git arguments, with tests that read tags from a real repository. - Use 2.1.0-alpha.3 -> 2.1.0 as the promotion example in the docs, since a major release after a prerelease stays a prerelease with auto. --- docs/src/content/docs/configuration.md | 1 + docs/src/content/docs/versioning.md | 5 +- src/commands/prepare.ts | 13 +++ src/utils/__tests__/git.test.ts | 119 ++++++++++++------------- 4 files changed, 76 insertions(+), 62 deletions(-) diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 3d7cda29e..ce6d71ada 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -61,6 +61,7 @@ projects keep their behavior when Craft is upgraded: | `2.21.0` | [Automatic version bumping](../versioning/#automatic-version-bumping) when no `preReleaseCommand` is set. | | `2.29.0` | [Release workspaces](../workspaces/). | | `3.8.0` | [Extra version files](../versioning/#extra-version-files) with `versioning.files`. | +| `3.9.0` | The [`pre` version](../versioning/#choosing-the-next-version) for the next prerelease. | ## github diff --git a/docs/src/content/docs/versioning.md b/docs/src/content/docs/versioning.md index 887b4fcc5..9a319d000 100644 --- a/docs/src/content/docs/versioning.md +++ b/docs/src/content/docs/versioning.md @@ -64,7 +64,7 @@ Craft reads it from the files of the first configured target that above `0.0.0`. Without one, they increment `0.0.0`. After a prerelease, bump types and `auto` promote it to a stable release: -`2.0.0-alpha.3` becomes `2.0.0`, or a higher version when the bump needs one. +`2.1.0-alpha.3` becomes `2.1.0`, or a higher version when the bump needs one. To be safe, `auto` doesn't promote a prerelease to a major release: after `2.0.0-alpha.3` it cuts `2.0.0-alpha.4`. Pass `2.0.0` or `major` to release `2.0.0`. @@ -75,7 +75,8 @@ After `1.9.0`, it cuts `1.9.1-alpha.0` when `auto` picks a patch bump, the latest tag is already a prerelease of that version, `pre` cuts the next one instead: `1.10.0-alpha.1` after `1.10.0-alpha.0`. -Bump types, `auto`, `pre`, and `calver` require `minVersion: '2.14.0'` or later. +Bump types, `auto`, and `calver` require `minVersion: '2.14.0'` or later, and +`pre` requires `minVersion: '3.9.0'` or later. ### Changelog base diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index 17430d17c..235ae6e28 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -75,6 +75,9 @@ export const description = '🚢 Prepare a new release branch'; /** Minimum craft version required for auto-versioning */ const AUTO_VERSION_MIN_VERSION = '2.14.0'; +/** Minimum craft version required for the `pre` version */ +const PRERELEASE_VERSION_MIN_VERSION = '3.9.0'; + export const builder: CommandBuilder = (yargs: Argv) => yargs .positional('NEW-VERSION', { @@ -700,6 +703,16 @@ async function resolveVersion( ); } + if ( + version === 'pre' && + !requiresMinVersion(PRERELEASE_VERSION_MIN_VERSION) + ) { + throw new ConfigurationError( + `The "pre" version requires minVersion >= ${PRERELEASE_VERSION_MIN_VERSION} in .craft.yml. ` + + 'Please update your configuration or specify the version explicitly.', + ); + } + const latestTag = await getLatestTag(git, getGitTagPrefix()); // Determine bump type - either from arg or from commit analysis diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index d966fcc81..b8c067147 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -79,79 +79,78 @@ describe('getLatestTag', () => { }); }); -describe('getLatestStableTag', () => { - it('returns the latest tag when it is stable', async () => { - const git = { raw: vi.fn().mockResolvedValue('1.0.0\n') } as any; +describe('release base tags', () => { + let dir: string; + let client: ReturnType; - expect(await getLatestStableTag(git)).toBe('1.0.0'); - expect(git.raw).toHaveBeenCalledTimes(1); + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'craft-release-tags-')); + const git = (...args: string[]) => + execFileSync('git', ['-C', dir, ...args], { + env: { + ...process.env, + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_NOSYSTEM: '1', + }, + }); + git('init', '--quiet'); + git('config', 'user.name', 'Craft Test'); + git('config', 'user.email', 'craft-test@example.com'); + // Each release gets its own commit, so the tags form a linear history. + // Unprefixed lookups match every tag, so the prefixed workspace tags come + // before the unprefixed ones. + for (const tag of [ + 'cli@1.0.0', + 'cli@1.1.0-alpha.0', + 'mcp@1.0.0-alpha.0', + '1.9.0', + '2.0.0-alpha.0', + '2.0.0-alpha.1', + '', + ]) { + git( + '-c', + 'commit.gpgsign=false', + 'commit', + '--quiet', + '--allow-empty', + '-m', + tag ? `release: ${tag}` : 'Unreleased change', + ); + if (tag) { + git('tag', tag); + } + } + client = createGitClient(dir); }); - it('skips prerelease tags until it reaches a stable one', async () => { - const git = { - raw: vi - .fn() - .mockResolvedValueOnce('cli@2.0.0-alpha.2') - .mockResolvedValueOnce('cli@2.0.0-alpha.1') - .mockResolvedValueOnce('cli@1.9.0'), - } as any; - - expect(await getLatestStableTag(git, 'cli@')).toBe('cli@1.9.0'); - expect(git.raw).toHaveBeenLastCalledWith([ - 'describe', - '--tags', - '--abbrev=0', - '--match', - 'cli@*', - '--exclude', - 'cli@2.0.0-alpha.2', - '--exclude', - 'cli@2.0.0-alpha.1', - ]); + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); }); - it('returns an empty string when only prerelease tags are reachable', async () => { - const git = { - raw: vi - .fn() - .mockResolvedValueOnce('1.0.0-rc.1') - .mockRejectedValueOnce( - new Error("fatal: No tags can describe 'abc123'."), - ), - } as any; - - expect(await getLatestStableTag(git)).toBe(''); + it('finds the latest stable tag past newer prereleases', async () => { + expect(await getLatestStableTag(client)).toBe('1.9.0'); + expect(await getLatestStableTag(client, 'cli@')).toBe('cli@1.0.0'); }); -}); -describe('getChangelogBaseTag', () => { - function describeTags(...tags: string[]) { - const raw = vi.fn(); - for (const tag of tags) { - raw.mockResolvedValueOnce(tag); - } - return { raw } as any; - } - - it('follows the latest stable tag for a stable release', async () => { - const git = describeTags('2.0.0-alpha.1', '1.1.0-alpha.0', '1.0.0'); - - expect(await getChangelogBaseTag(git, '2.0.0')).toBe('1.0.0'); + it('finds no stable tag when only prereleases match the prefix', async () => { + expect(await getLatestStableTag(client, 'mcp@')).toBe(''); }); - it('follows the previous prerelease of the same series', async () => { - const git = describeTags('2.0.0-alpha.4', '1.9.0'); + it('bases a stable release on the latest stable tag', async () => { + expect(await getChangelogBaseTag(client, '2.0.0')).toBe('1.9.0'); + }); - expect(await getChangelogBaseTag(git, '2.0.0-alpha.5')).toBe( - '2.0.0-alpha.4', + it('bases a prerelease on the previous prerelease of its version', async () => { + expect(await getChangelogBaseTag(client, '2.0.0-alpha.2')).toBe( + '2.0.0-alpha.1', ); }); - it('follows the latest stable tag for a new prerelease series', async () => { - const git = describeTags('cli@2.0.0-alpha.3', 'cli@1.9.0'); - - expect(await getChangelogBaseTag(git, '2.1.0-alpha.0', 'cli@')).toBe( - 'cli@1.9.0', + it('bases the first prerelease of a version on the latest stable tag', async () => { + expect(await getChangelogBaseTag(client, '2.1.0-alpha.0')).toBe('1.9.0'); + expect(await getChangelogBaseTag(client, '1.2.0-alpha.0', 'cli@')).toBe( + 'cli@1.0.0', ); }); });