Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 27 additions & 2 deletions docs/src/content/docs/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 a prerelease of the version `auto` would release. |
| `calver` | Compute a [calendar version](#calendar-versioning-calver). |
| *(omitted)* | Use `versioning.policy`. |

Expand All @@ -62,7 +63,31 @@ 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`.

Bump types, `auto`, and `calver` require `minVersion: '2.14.0'` or later.
After a prerelease, bump types and `auto` promote it to a stable release:
`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`.

`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`, and `calver` require `minVersion: '2.14.0'` or later, and
`pre` requires `minVersion: '3.9.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

Expand Down
77 changes: 77 additions & 0 deletions src/__tests__/prepare-dry-run.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,83 @@ targets: []
expect(combinedOutput).toContain('release/1.1.0');
}, 60000);

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);
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 in');
}, 60000);

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);
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 () => {
tempDir = await createTestRepo();
// eslint-disable-next-line no-restricted-syntax -- Test setup needs direct git access
Expand Down
4 changes: 4 additions & 0 deletions src/commands/__tests__/prepare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
80 changes: 68 additions & 12 deletions src/commands/prepare.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -22,6 +23,7 @@ import { ChangelogPolicy, VersioningPolicy } from '../schemas/project_config';
import { sleep } from '../utils/async';
import {
type BumpType,
calculatePrerelease,
calculateNextVersion,
getChangelogWithBumpType,
isBumpType,
Expand All @@ -44,6 +46,8 @@ import {
import {
getDefaultBranch,
getGitClient,
getChangelogBaseTag,
getLatestStableTag,
getLatestTag,
isRepoDirty,
} from '../utils/git';
Expand All @@ -58,7 +62,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';
Expand All @@ -71,13 +75,17 @@ 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', {
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',
})
Expand Down Expand Up @@ -156,7 +164,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).
*
Expand All @@ -176,6 +185,11 @@ export function checkVersionOrPart(argv: Arguments<any>, _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;
Expand Down Expand Up @@ -603,7 +617,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;
Expand All @@ -616,10 +630,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
Expand Down Expand Up @@ -677,7 +692,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'
Expand All @@ -688,12 +703,27 @@ 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
let bumpType: BumpType;
if (version === 'auto') {
const changelogResult = await getChangelogWithBumpType(git, latestTag);
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()),
);
validateBumpType(changelogResult);
bumpType = changelogResult.bumpType;
} else {
Expand All @@ -702,13 +732,34 @@ 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())) ||
'0.0.0';

const newVersion = calculateNextVersion(currentVersion, bumpType);
let newVersion = calculateNextVersion(currentVersion, bumpType);
if (version === 'pre') {
// `pre` cuts a prerelease of the version `auto` would release.
newVersion = calculatePrerelease(newVersion, currentVersion);
} 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 = calculatePrerelease(newVersion, currentVersion);
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)`,
);
Expand Down Expand Up @@ -802,13 +853,18 @@ export async function prepareMain(argv: PrepareOptions): Promise<any> {
// TL;DR - WARNING:
// The order matters here, do not move this command above createReleaseBranch!
const oldVersion = await getLatestTag(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 } =
getChangelogConfig();
const changelogBody = await prepareChangelog(
git,
oldVersion,
changelogBase,
newVersion,
argv.noChangelog ? ChangelogPolicy.None : changelogPolicy,
changelogPath,
Expand Down Expand Up @@ -852,7 +908,7 @@ export async function prepareMain(argv: PrepareOptions): Promise<any> {
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
Expand Down
18 changes: 18 additions & 0 deletions src/utils/__tests__/autoVersion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import * as config from '../../config';
import { getChangesSince } from '../git';
import { getGitHubClient } from '../githubApi';
import {
calculatePrerelease,
calculateNextVersion,
getChangelogWithBumpType,
validateBumpType,
Expand Down Expand Up @@ -70,6 +71,23 @@ describe('calculateNextVersion', () => {
});
});

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('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('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');
});
});

describe('validateBumpType', () => {
test('throws error when no commits found', () => {
const result = {
Expand Down
Loading
Loading