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
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,13 +236,56 @@ that CI triggered by pushing this branch will result in release artifacts
being built and uploaded to the artifact provider you wish to use during the
subsequent `publish` step.

**Version Specification**

The `NEW-VERSION` argument can be specified in three ways:

1. **Explicit version** (e.g., `1.2.3`): Release with the specified version
2. **Bump type** (`major`, `minor`, or `patch`): Automatically increment the latest tag
3. **Auto** (`auto`): Analyze commits since the last tag and determine bump type from conventional commit patterns

The bump type and auto options require `minVersion: '2.14.0'` or higher in `.craft.yml`.

**Auto-versioning Details**

When using `auto`, craft analyzes commits since the last tag and matches them against
categories in `.github/release.yml` (or the default conventional commits config).
Each category can have a `semver` field (`major`, `minor`, or `patch`) that determines
the version bump. The highest bump type across all matched commits is used:

- Breaking changes (e.g., `feat!:`, `fix!:`) trigger a **major** bump
- New features (`feat:`) trigger a **minor** bump
- Bug fixes, docs, chores trigger a **patch** bump

Example `.github/release.yml` with semver fields:

```yaml
changelog:
categories:
- title: Breaking Changes
commit_patterns:
- '^\w+(\(\w+\))?!:'
semver: major
- title: Features
commit_patterns:
- '^feat(\(\w+\))?:'
semver: minor
- title: Bug Fixes
commit_patterns:
- '^fix(\(\w+\))?:'
semver: patch
```

```shell
craft prepare NEW-VERSION

🚢 Prepare a new release branch

Positionals:
NEW-VERSION The new version you want to release [string] [required]
NEW-VERSION The new version to release. Can be: a semver string (e.g.,
"1.2.3"), a bump type ("major", "minor", or "patch"), or "auto"
to determine automatically from conventional commits.
[string] [required]

Options:
--no-input Suppresses all user prompts [default: false]
Expand Down
28 changes: 25 additions & 3 deletions src/commands/__tests__/prepare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,31 @@ describe('checkVersionOrPart', () => {
}
});

test('return true for auto version', () => {
expect(
checkVersionOrPart(
{
newVersion: 'auto',
},
null
)
).toBe(true);
});

test('return true for version bump types', () => {
const bumpTypes = ['major', 'minor', 'patch'];
for (const bumpType of bumpTypes) {
expect(
checkVersionOrPart(
{
newVersion: bumpType,
},
null
)
).toBe(true);
}
});

test('throw an error for invalid version', () => {
const invalidVersions = [
{
Expand All @@ -80,9 +105,6 @@ describe('checkVersionOrPart', () => {
e:
'Invalid version or version part specified: "v2.3.3". Removing the "v" prefix will likely fix the issue',
},
{ v: 'major', e: 'Version part is not supported yet' },
{ v: 'minor', e: 'Version part is not supported yet' },
{ v: 'patch', e: 'Version part is not supported yet' },
];
for (const t of invalidVersions) {
const fn = () => {
Expand Down
80 changes: 72 additions & 8 deletions src/commands/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getConfiguration,
DEFAULT_RELEASE_BRANCH_NAME,
getGlobalGitHubConfig,
requiresMinVersion,
} from '../config';
import { logger } from '../logger';
import { ChangelogPolicy } from '../schemas/project_config';
Expand All @@ -26,6 +27,13 @@ import {
reportError,
} from '../utils/errors';
import { getGitClient, getDefaultBranch, getLatestTag } from '../utils/git';
import {
getChangelogWithBumpType,
calculateNextVersion,
validateBumpType,
isBumpType,
type BumpType,
} from '../utils/autoVersion';
import { isDryRun, promptConfirmation } from '../utils/helpers';
import { formatJson } from '../utils/strings';
import { spawnProcess } from '../utils/system';
Expand All @@ -40,10 +48,16 @@ export const description = '🚢 Prepare a new release branch';
/** Default path to bump-version script, relative to project root */
const DEFAULT_BUMP_VERSION_PATH = join('scripts', 'bump-version.sh');

/** Minimum craft version required for auto-versioning */
const AUTO_VERSION_MIN_VERSION = '2.14.0';

export const builder: CommandBuilder = (yargs: Argv) =>
yargs
.positional('NEW-VERSION', {
description: 'The new version you want to release',
description:
'The new version to release. Can be: a semver string (e.g., "1.2.3"), ' +
'a bump type ("major", "minor", or "patch"), or "auto" to determine automatically ' +
'from conventional commits. Bump types and "auto" require minVersion >= 2.14.0 in .craft.yml',
type: 'string',
})
.option('rev', {
Expand Down Expand Up @@ -106,17 +120,27 @@ const SLEEP_BEFORE_PUBLISH_SECONDS = 30;
/**
* Checks the provided version argument for validity
*
* We check that the argument is either a valid version string, or a valid
* semantic version part.
* We check that the argument is either a valid version string, 'auto' for
* automatic version detection, a version bump type (major/minor/patch), or
* a valid semantic version.
*
* @param argv Parsed yargs arguments
* @param _opt A list of options and aliases
*/
export function checkVersionOrPart(argv: Arguments<any>, _opt: any): boolean {
const version = argv.newVersion;
if (['major', 'minor', 'patch'].indexOf(version) > -1) {
throw Error('Version part is not supported yet');
} else if (isValidVersion(version)) {

// Allow 'auto' for automatic version detection
if (version === 'auto') {
return true;
}

// Allow version bump types (major, minor, patch)
if (isBumpType(version)) {
return true;
}

if (isValidVersion(version)) {
return true;
} else {
let errMsg = `Invalid version or version part specified: "${version}"`;
Expand Down Expand Up @@ -403,7 +427,9 @@ async function prepareChangelog(
}
if (!changeset.body) {
replaceSection = changeset.name;
changeset.body = await generateChangesetFromGit(git, oldVersion);
// generateChangesetFromGit is memoized, so this won't duplicate API calls
const result = await generateChangesetFromGit(git, oldVersion);
changeset.body = result.changelog;
}
if (changeset.name === DEFAULT_UNRELEASED_TITLE) {
replaceSection = changeset.name;
Expand Down Expand Up @@ -469,7 +495,7 @@ export async function prepareMain(argv: PrepareOptions): Promise<any> {
// Get repo configuration
const config = getConfiguration();
const githubConfig = await getGlobalGitHubConfig();
const newVersion = argv.newVersion;
let newVersion = argv.newVersion;

const git = await getGitClient();

Expand All @@ -485,6 +511,44 @@ export async function prepareMain(argv: PrepareOptions): Promise<any> {
checkGitStatus(repoStatus, rev);
}

// Handle automatic version detection or version bump types
const isVersionBumpType = isBumpType(newVersion);

if (newVersion === 'auto' || isVersionBumpType) {
if (!requiresMinVersion(AUTO_VERSION_MIN_VERSION)) {
const featureName = isVersionBumpType
? 'Version bump types'
: 'Auto-versioning';
throw new ConfigurationError(
`${featureName} requires minVersion >= ${AUTO_VERSION_MIN_VERSION} in .craft.yml. ` +
'Please update your configuration or specify the version explicitly.'
);
}

const latestTag = await getLatestTag(git);

// Determine bump type - either from arg or from commit analysis
// Note: generateChangesetFromGit is memoized, so calling getChangelogWithBumpType
// here and later in prepareChangelog won't result in duplicate GitHub API calls
let bumpType: BumpType;
if (newVersion === 'auto') {
const changelogResult = await getChangelogWithBumpType(git, latestTag);
validateBumpType(changelogResult); // Throws if no valid bump type
bumpType = changelogResult.bumpType;
} else {
bumpType = newVersion as BumpType;
}

// Calculate new version from latest tag
const currentVersion =
latestTag && latestTag.replace(/^v/, '').match(/^\d/)
? latestTag.replace(/^v/, '')
: '0.0.0';

newVersion = calculateNextVersion(currentVersion, bumpType);
logger.info(`Version bump: ${currentVersion} -> ${newVersion} (${bumpType} bump)`);
}

logger.info(`Releasing version ${newVersion} from ${rev}`);
if (!argv.rev && rev !== defaultBranch) {
logger.warn("You're not on your default branch, so I have to ask...");
Expand Down
28 changes: 28 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,34 @@ function checkMinimalConfigVersion(config: CraftProjectConfig): void {
}
}

/**
* Checks if the project's minVersion configuration meets a required minimum.
*
* This is used to gate features that require a certain version of craft.
* For example, auto-versioning requires minVersion >= 2.14.0.
*
* @param requiredVersion The minimum version required for the feature
* @returns true if the project's minVersion is >= requiredVersion, false otherwise
*/
export function requiresMinVersion(requiredVersion: string): boolean {
const config = getConfiguration();
const minVersionRaw = config.minVersion;

if (!minVersionRaw) {
// If no minVersion is configured, the feature is not available
return false;
}

const configuredMinVersion = parseVersion(minVersionRaw);
const required = parseVersion(requiredVersion);

if (!configuredMinVersion || !required) {
return false;
}

return versionGreaterOrEqualThan(configuredMinVersion, required);
}

/**
* Return the parsed global GitHub configuration
*/
Expand Down
Loading