diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 376051f56..c17d004cf 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -32,6 +32,7 @@ targets: | `versioning` | How the next version is chosen: `auto`, `manual`, or `calver`. | [Versioning](../versioning/#versioning-policies) | | `preReleaseCommand` | Command that bumps versions during `craft prepare`. | [Release Workflow](../release-workflow/#pre-release-command) | | `postReleaseCommand` | Command that runs after `craft publish`. | [Release Workflow](../release-workflow/#post-release-command) | +| `postReleaseBump` | Bump to the next development version after `craft publish`. | [Release Workflow](../release-workflow/#post-release-development-version-bump) | | `releaseBranchPrefix` | Release branch prefix. Default: `release`. | [Release Workflow](../release-workflow/#release-branch-name) | | `statusProvider` | How Craft checks that CI passed. | [Release Workflow](../release-workflow/#status-provider) | | `artifactProvider` | Where Craft downloads build artifacts from. | [Release Workflow](../release-workflow/#artifact-provider) | diff --git a/docs/src/content/docs/release-workflow.md b/docs/src/content/docs/release-workflow.md index 6fb588864..1c8d455c0 100644 --- a/docs/src/content/docs/release-workflow.md +++ b/docs/src/content/docs/release-workflow.md @@ -227,6 +227,59 @@ branch; override it with `--merge-target`. automatically for compiled Node.js GitHub Actions (repositories with a `dist/` folder), unless `noMerge` is set explicitly. +### Post-release development version bump + +Many projects set their version to the next development version after a +release, so builds from the default branch, such as nightlies, sort above the +release. `postReleaseBump` makes Craft do this: + +```yaml +minVersion: '3.7.0' +postReleaseBump: true +``` + +After it merges the release branch, Craft writes the development version with +the same step `craft prepare` uses to write the release version: the +[pre-release command](#pre-release-command) if you set one, otherwise the +[automatic version bump](../versioning/#automatic-version-bumping). Then it +commits the change as `meta: Bump new development version` with +`#skip-changelog`, pulls with rebase, and pushes to the merge target. + +`postReleaseBump: true` uses the defaults below. A bump type is shorthand for +the `bump` setting, for example `postReleaseBump: patch`. For anything else, +use the object form: + +```yaml +postReleaseBump: + bump: calver + version: '{{version}}.dev0' +``` + +| Setting | Description | Default | +| --- | --- | --- | +| `bump` | How to get the next version: `patch`, `minor`, `major`, or `calver` (the first version of the next CalVer period). | `calver` with the `calver` policy, else `minor` | +| `version` | Template for the development version. `{{version}}` is the next version. A value without it, such as `nightly`, is used as is. | `{{version}}-dev.0` | + +| Released | Configuration | Development version | +| --- | --- | --- | +| `1.2.3` | `postReleaseBump: true` | `1.3.0-dev.0` | +| `1.2.3` | `postReleaseBump: patch` | `1.2.4-dev.0` | +| `26.10.1` | `postReleaseBump: true` with the `calver` policy | `26.11.0-dev.0` | +| `26.10.1` | `version: '{{version}}.dev0'` with the `calver` policy | `26.11.0.dev0` | +| `26.10.1` | `version: nightly` | `nightly` | + +- Keep the development marker a pre-release, such as `-dev.0`, so the version + sorts between the release and the next one. Build metadata such as `+dev` + doesn't work: SemVer ignores it when ordering versions, npm drops it, and + PyPI rejects it. +- Craft skips the bump when the release branch isn't merged (`--no-merge`, + `noMerge`, or a publish to only some targets) and after a pre-release + version. +- A failed bump doesn't fail the release. Craft logs a warning and the + version must be bumped by hand. +- The bump runs before the [post-release command](#post-release-command). + Remove any version bump from that command when you turn this on. + ### Post-release command This command runs after a successful `craft publish`. Default: `bash scripts/post-release.sh`. diff --git a/docs/src/content/docs/workspaces.md b/docs/src/content/docs/workspaces.md index 5810d198f..647d2c6bf 100644 --- a/docs/src/content/docs/workspaces.md +++ b/docs/src/content/docs/workspaces.md @@ -62,8 +62,9 @@ setting by declaring it: - These fields can be set per workspace: `targets`, `changelog`, `versioning`, `releaseBranchPrefix`, `preReleaseCommand`, - `postReleaseCommand`, `requireNames`, `statusProvider`, `artifactProvider`, - `noMerge`, and `github`. + `postReleaseCommand`, `postReleaseBump`, `requireNames`, `statusProvider`, + `artifactProvider`, `noMerge`, and `github`. Set `postReleaseBump: false` + to turn off an inherited development version bump. - A declared field **replaces** the inherited value. For example, a workspace's `targets` list replaces the top-level list, and a workspace `changelog` object replaces the top-level object. diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 39b1d6b86..b951eb0e7 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -112,6 +112,47 @@ describe('validateConfiguration', () => { expect(validateConfiguration(data)).toEqual(data); }); + test.each([ + [{}, {}], + [ + { bump: 'calver', version: '{{version}}.dev0' }, + { bump: 'calver', version: '{{version}}.dev0' }, + ], + [{ version: 'nightly' }, { version: 'nightly' }], + [true, {}], + ['patch', { bump: 'patch' }], + ['calver', { bump: 'calver' }], + [false, false], + ])('accepts postReleaseBump %j', (postReleaseBump, expected) => { + expect(validateConfiguration({ postReleaseBump })).toEqual({ + postReleaseBump: expected, + }); + }); + + test('lets a workspace opt out of an inherited postReleaseBump', () => { + const data = { + postReleaseBump: { bump: 'patch' }, + workspaces: { 'packages/cli': { postReleaseBump: false } }, + }; + + expect(validateConfiguration(data)).toEqual(data); + }); + + test.each([ + { bump: 'preminor' }, + { version: ' ' }, + { version: '${NPM_TOKEN}' }, + { version: '{{{version}}}' }, + { version: '$(id)' }, + { preid: 'dev' }, + 'preminor', + [], + ])('rejects invalid postReleaseBump %j', postReleaseBump => { + expect(() => validateConfiguration({ postReleaseBump })).toThrow( + /postReleaseBump/, + ); + }); + test('normalizes opt-in AI summary settings from the active schema', () => { loadConfigurationFromString('changelog:\n policy: auto\n aiSummary: {}'); expect(getChangelogConfig().aiSummary).toEqual({ diff --git a/src/commands/__tests__/prepare.test.ts b/src/commands/__tests__/prepare.test.ts index 0e96b9452..74ce3fb6b 100644 --- a/src/commands/__tests__/prepare.test.ts +++ b/src/commands/__tests__/prepare.test.ts @@ -2,7 +2,8 @@ import { vi, describe, test, expect, beforeEach, type Mock } from 'vitest'; import { requiresMinVersion } from '../../config'; import { spawnProcess } from '../../utils/system'; import { runAutomaticVersionBumps } from '../../utils/versionBump'; -import { runPreReleaseCommand, checkVersionOrPart } from '../prepare'; +import { runPreReleaseCommand } from '../../utils/preReleaseCommand'; +import { checkVersionOrPart } from '../prepare'; vi.mock('../../utils/system'); vi.mock('../../utils/versionBump'); diff --git a/src/commands/__tests__/publish-main.test.ts b/src/commands/__tests__/publish-main.test.ts index d5b34e1a6..6c5894bbe 100644 --- a/src/commands/__tests__/publish-main.test.ts +++ b/src/commands/__tests__/publish-main.test.ts @@ -27,6 +27,7 @@ import { getTargetByName } from '../../targets'; import { BaseTarget } from '../../targets/base'; import { safeFs } from '../../utils/dryRun'; import { promptConfirmation } from '../../utils/helpers'; +import { runPreReleaseCommand } from '../../utils/preReleaseCommand'; import { getPublishStatePath } from '../../utils/publishState'; import { findReleaseBranches, @@ -66,6 +67,9 @@ vi.mock('../../utils/helpers', async importOriginal => ({ ...(await importOriginal()), promptConfirmation: vi.fn(), })); +vi.mock('../../utils/preReleaseCommand', () => ({ + runPreReleaseCommand: vi.fn(), +})); vi.mock('../../utils/publishState', async importOriginal => ({ ...(await importOriginal()), getPublishStatePath: vi.fn(), @@ -76,6 +80,7 @@ function createMockGit(revision: string): SimpleGit { branch: vi.fn().mockResolvedValue(undefined), branchLocal: vi.fn().mockResolvedValue({ all: [] }), checkout: vi.fn().mockResolvedValue(undefined), + commit: vi.fn().mockResolvedValue(undefined), merge: vi.fn().mockResolvedValue(undefined), pull: vi.fn().mockResolvedValue(undefined), push: vi.fn().mockResolvedValue(undefined), @@ -235,3 +240,167 @@ describe('publishMain release branch handling', () => { ); }); }); + +describe('publishMain post-release bump', () => { + const revision = 'd'.repeat(40); + let git: SimpleGit; + + function mockConfig(config: Record): void { + vi.mocked(getConfiguration).mockReturnValue({ + targets: [{ name: 'test' }], + postReleaseCommand: '', + ...config, + }); + } + + beforeEach(async () => { + vi.clearAllMocks(); + testState.directory = await mkdtemp( + pathJoin(tmpdir(), 'craft-publish-bump-'), + ); + vi.mocked(getPublishStatePath).mockReturnValue( + pathJoin(testState.directory, 'publish-state.json'), + ); + mockConfig({ postReleaseBump: {} }); + vi.mocked(getStatusProviderFromConfig).mockResolvedValue({} as never); + vi.mocked(getArtifactProviderFromConfig).mockResolvedValue({ + listArtifactsForRevision: vi.fn().mockResolvedValue([]), + setDownloadDirectory: vi.fn(), + } as never); + vi.mocked(getGlobalGitHubConfig).mockResolvedValue({ + owner: 'getsentry', + repo: 'craft', + }); + vi.mocked(expandWorkspaceTargets).mockResolvedValue([{ name: 'test' }]); + vi.mocked(getTargetByName).mockReturnValue(TestTarget); + vi.mocked(promptConfirmation).mockResolvedValue(undefined); + vi.mocked(getNoMergeConfig).mockReturnValue({ + noMerge: false, + source: 'config', + }); + vi.mocked(getActiveWorkspace).mockReturnValue(undefined); + vi.mocked(getDefaultBranch).mockResolvedValue('main'); + vi.mocked(runPreReleaseCommand).mockResolvedValue(true); + vi.spyOn(safeFs, 'unlink').mockResolvedValue(undefined); + vi.spyOn(safeFs, 'writeFileSync').mockImplementation(() => undefined); + + git = createMockGit(revision); + vi.mocked(git.status).mockResolvedValue({ + files: [{ path: 'package.json' }], + } as never); + vi.mocked(getGitClient).mockResolvedValue(git); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await rm(testState.directory, { recursive: true, force: true }); + }); + + test('writes the development version with the pre-release bump, then commits and pushes', async () => { + mockConfig({ + postReleaseBump: {}, + preReleaseCommand: 'bash scripts/bump-version.sh', + }); + + await publishMain({ ...defaultOptions, mergeTarget: undefined }); + + expect(runPreReleaseCommand).toHaveBeenCalledWith({ + oldVersion: '1.2.3', + newVersion: '1.3.0-dev.0', + preReleaseCommand: 'bash scripts/bump-version.sh', + targets: [{ name: 'test' }], + rootDir: expect.any(String), + }); + expect(git.commit).toHaveBeenCalledWith( + 'meta: Bump new development version\n\n#skip-changelog', + ['--all'], + ); + expect(git.pull).toHaveBeenLastCalledWith('origin', 'main', ['--rebase']); + expect(git.push).toHaveBeenLastCalledWith('origin', 'main'); + }); + + test.each([ + ['patch bump', '1.2.3', {}, { bump: 'patch' }, '1.2.4-dev.0'], + ['major bump', '1.2.3', {}, { bump: 'major' }, '2.0.0-dev.0'], + [ + 'custom template', + '1.2.3', + {}, + { version: '{{version}}-SNAPSHOT' }, + '1.3.0-SNAPSHOT', + ], + ['literal version', '26.10.1', {}, { version: 'nightly' }, 'nightly'], + [ + 'CalVer policy', + '26.10.1', + { policy: 'calver' }, + { version: '{{version}}.dev0' }, + '26.11.0.dev0', + ], + [ + 'CalVer year rollover', + '26.12.3', + { policy: 'calver' }, + {}, + '27.1.0-dev.0', + ], + [ + 'custom CalVer format', + '2026.10.31.0', + { policy: 'calver', calver: { format: '%Y.%m.%d' } }, + {}, + '2026.11.01.0-dev.0', + ], + ])( + 'derives the development version for a %s', + async (_name, newVersion, versioning, postReleaseBump, expected) => { + mockConfig({ versioning, postReleaseBump }); + + await publishMain({ ...defaultOptions, newVersion }); + + expect(runPreReleaseCommand).toHaveBeenCalledWith( + expect.objectContaining({ + oldVersion: newVersion, + newVersion: expected, + }), + ); + }, + ); + + test('skips the bump when the release branch is not merged', async () => { + await publishMain({ ...defaultOptions, noMerge: true }); + + expect(runPreReleaseCommand).not.toHaveBeenCalled(); + expect(git.commit).not.toHaveBeenCalled(); + }); + + test('skips the bump after a pre-release', async () => { + await publishMain({ ...defaultOptions, newVersion: '2.0.0-beta.1' }); + + expect(runPreReleaseCommand).not.toHaveBeenCalled(); + expect(git.commit).not.toHaveBeenCalled(); + }); + + test('does not commit when nothing wrote a version', async () => { + vi.mocked(runPreReleaseCommand).mockResolvedValue(false); + + await publishMain(defaultOptions); + + expect(git.commit).not.toHaveBeenCalled(); + }); + + test('warns instead of failing the release when the bump fails', async () => { + vi.mocked(runPreReleaseCommand).mockRejectedValue( + new Error('bump script exploded'), + ); + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + + await publishMain(defaultOptions); + + expect(captureException).toHaveBeenCalledWith(expect.any(Error)); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to bump the development version'), + ); + expect(git.commit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index 18ed377f5..c8658de14 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -1,7 +1,6 @@ import { existsSync, promises as fsPromises } from 'fs'; -import { join, relative } from 'path'; +import { relative } from 'path'; -import * as shellQuote from 'shell-quote'; import type { SimpleGit, StatusResult } from 'simple-git'; import type { Arguments, Argv, CommandBuilder } from 'yargs'; import { createDryRunIsolation, safeFs } from '../utils/dryRun'; @@ -19,11 +18,7 @@ import { requiresMinVersion, } from '../config'; import { logger } from '../logger'; -import { - ChangelogPolicy, - type TargetConfig, - VersioningPolicy, -} from '../schemas/project_config'; +import { ChangelogPolicy, VersioningPolicy } from '../schemas/project_config'; import { sleep } from '../utils/async'; import { type BumpType, @@ -61,14 +56,10 @@ import { writeGitHubActionsFile, } from '../utils/helpers'; import { formatJson } from '../utils/strings'; -import { spawnProcess } from '../utils/system'; -import { buildReleaseCommandEnv } from '../utils/releaseCommandEnv'; +import { runPreReleaseCommand } from '../utils/preReleaseCommand'; import { withTracing } from '../utils/tracing'; import { getVersion, isValidVersion } from '../utils/version'; -import { - readProjectVersion, - runAutomaticVersionBumps, -} from '../utils/versionBump'; +import { readProjectVersion } from '../utils/versionBump'; import { type PublishOptions, handler as publishMainHandler } from './publish'; import { getTargetIds } from './targets'; @@ -77,15 +68,9 @@ export const command = ['prepare [NEW-VERSION]']; export const aliases = ['p', 'prerelease', 'prepublish', 'prepare', 'release']; 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'; -/** Minimum craft version required for automatic version bumping from targets */ -const AUTO_BUMP_MIN_VERSION = '2.21.0'; - export const builder: CommandBuilder = (yargs: Argv) => yargs .positional('NEW-VERSION', { @@ -368,111 +353,6 @@ async function commitChanges( await git.commit(message); } -interface PreReleaseOptions { - oldVersion: string; - newVersion: string; - preReleaseCommand?: string; - targets?: TargetConfig[]; - rootDir: string; -} - -/** - * Run pre-release command or automatic version bumping. - * - * Priority: custom command > automatic bumping (minVersion >= 2.21.0) > default script - */ -export async function runPreReleaseCommand( - options: PreReleaseOptions, -): Promise { - const { oldVersion, newVersion, preReleaseCommand, targets, rootDir } = - options; - - if (preReleaseCommand !== undefined && preReleaseCommand.length === 0) { - logger.warn('Not running the pre-release command: no command specified'); - return false; - } - - if (preReleaseCommand) { - return runCustomPreReleaseCommand( - oldVersion, - newVersion, - preReleaseCommand, - ); - } - - if ( - requiresMinVersion(AUTO_BUMP_MIN_VERSION) && - targets && - targets.length > 0 - ) { - logger.info('Running automatic version bumping from targets...'); - const result = await runAutomaticVersionBumps(targets, rootDir, newVersion); - - if (!result.anyBumped) { - if (result.bumpableTargets.length === 0) { - logger.warn( - 'None of your configured targets support automatic version bumping. ' + - 'Consider adding a preReleaseCommand to bump versions manually.', - ); - } else { - throw new ConfigurationError( - `Targets [${result.skippedTargets.join(', ')}] support version bumping ` + - 'but did not find applicable files in your project. ' + - 'Define a preReleaseCommand to bump versions manually, or set ' + - "preReleaseCommand: '' to skip version bumping.", - ); - } - } - - return result.anyBumped; - } - - return runCustomPreReleaseCommand(oldVersion, newVersion, undefined); -} - -/** - * Run a custom pre-release command (or the default bump-version.sh script) - */ -async function runCustomPreReleaseCommand( - oldVersion: string, - newVersion: string, - preReleaseCommand?: string, -): Promise { - let sysCommand: string; - let args: string[]; - - // This is a workaround for the case when the old version is empty, which - // should only happen when the project is new and has no version yet. - // Instead of using an empty string, we use "0.0.0" as the old version to - // avoid breaking the pre-release command as most scripts expect a non-empty - // version string. - const nonEmptyOldVersion = oldVersion || '0.0.0'; - - if (preReleaseCommand) { - [sysCommand, ...args] = shellQuote.parse(preReleaseCommand) as string[]; - } else { - sysCommand = '/bin/bash'; - args = [DEFAULT_BUMP_VERSION_PATH]; - } - - args = [...args, nonEmptyOldVersion, newVersion]; - logger.info('Running the pre-release command...'); - - // The pre-release command comes from .craft.yml, which is - // attacker-influenceable via untrusted PRs. We forward only an - // allowlisted env plus the CRAFT_* version vars to avoid leaking - // secrets into a potentially malicious command. See - // src/utils/releaseCommandEnv.ts for the exact allowlist and rationale. - await spawnProcess(sysCommand, args, { - env: buildReleaseCommandEnv({ - CRAFT_NEW_VERSION: newVersion, - CRAFT_OLD_VERSION: nonEmptyOldVersion, - }), - }); - - return true; -} - /** * Checks that it is safe to perform the release right now * diff --git a/src/commands/publish.ts b/src/commands/publish.ts index 02b926647..587b7773b 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readFileSync } from 'fs'; import { safeFs } from '../utils/dryRun'; import { dirname, join } from 'path'; +import * as semver from 'semver'; import * as shellQuote from 'shell-quote'; import stringLength from 'string-length'; @@ -16,11 +17,15 @@ import { expandWorkspaceTargets, getNoMergeConfig, getActiveWorkspace, + getReleaseUnitDir, } from '../config'; import { formatTable, logger } from '../logger'; import { + type CraftProjectConfig, type GitHubGlobalConfig, + type PostReleaseBumpConfig, TargetConfig, + VersioningPolicy, } from '../schemas/project_config'; import { getAllTargetNames, getTargetByName, SpecialTarget } from '../targets'; import { BaseTarget } from '../targets/base'; @@ -31,7 +36,7 @@ import { } from '../utils/errors'; import { withTempDir } from '../utils/files'; import { stringToRegexp } from '../utils/filters'; -import { promptConfirmation } from '../utils/helpers'; +import { isDryRun, promptConfirmation } from '../utils/helpers'; import { formatArtifactConfigForError, formatSize } from '../utils/strings'; import { catchKeyboardInterrupt, @@ -52,6 +57,8 @@ import { import { withTracing } from '../utils/tracing'; import { buildReleaseCommandEnv } from '../utils/releaseCommandEnv'; import { getPublishStatePath } from '../utils/publishState'; +import { DEFAULT_CALVER_CONFIG, getNextCalVerPeriod } from '../utils/calver'; +import { runPreReleaseCommand } from '../utils/preReleaseCommand'; /** Default path to post-release script, relative to project root */ const DEFAULT_POST_RELEASE_SCRIPT_PATH = join('scripts', 'post-release.sh'); @@ -491,18 +498,7 @@ export async function handleReleaseBranch( logger.debug(`Checking out merge target branch:`, mergeTarget); await git.checkout(mergeTarget); - logger.debug(`Pulling latest changes from ${remoteName}/${mergeTarget}`); - try { - await git.pull(remoteName, mergeTarget, ['--rebase']); - } catch (pullError) { - // Pull --rebase failure can leave the repo in an active rebase state - try { - await git.raw(['rebase', '--abort']); - } catch { - logger.trace('git rebase --abort failed (may be no rebase in progress)'); - } - throw pullError; - } + await pullWithRebase(git, remoteName, mergeTarget); // Stage 1: Merge — if this fails, it's a merge conflict logger.debug(`Merging ${mergeSource} into: ${mergeTarget}`); @@ -607,6 +603,119 @@ export async function handleReleaseBranch( } } +/** + * Pull with `--rebase`, aborting the rebase if the pull fails so the + * repository isn't left mid-rebase. + */ +async function pullWithRebase( + git: SimpleGit, + remoteName: string, + branch: string, +): Promise { + logger.debug(`Pulling latest changes from ${remoteName}/${branch}`); + try { + await git.pull(remoteName, branch, ['--rebase']); + } catch (pullError) { + try { + await git.raw(['rebase', '--abort']); + } catch { + logger.trace('git rebase --abort failed (may be no rebase in progress)'); + } + throw pullError; + } +} + +/** Default `postReleaseBump.version` template */ +const DEFAULT_DEVELOPMENT_VERSION_TEMPLATE = '{{version}}-dev.0'; + +/** + * Bump the release unit to the next development version on the merge target, + * then commit and push the change. + * + * The version is written by the same step `craft prepare` uses: the + * `preReleaseCommand`, or the automatic target bumps when there is none. + * + * @param git Git client, with the merge target checked out + * @param remoteName The git remote to push to + * @param mergeTarget Branch the release was merged into + * @param releasedVersion Version that was just published + * @param bumpConfig The `postReleaseBump` configuration + * @param config The release unit's configuration + */ +async function runPostReleaseBump( + git: SimpleGit, + remoteName: string, + mergeTarget: string, + releasedVersion: string, + bumpConfig: PostReleaseBumpConfig, + config: CraftProjectConfig, +): Promise { + if (semver.prerelease(releasedVersion)) { + // A development version derived from a pre-release would sort above the + // stable release that hasn't shipped yet. + logger.info( + `Not bumping the development version after pre-release ${releasedVersion}.`, + ); + return; + } + const bump = + bumpConfig.bump ?? + (config.versioning?.policy === VersioningPolicy.CalVer + ? 'calver' + : 'minor'); + const nextVersion = + bump === 'calver' + ? getNextCalVerPeriod( + releasedVersion, + config.versioning?.calver?.format ?? DEFAULT_CALVER_CONFIG.format, + ) + : semver.inc(releasedVersion, bump); + if (!nextVersion) { + throw new Error( + `Cannot derive the next ${bump} version from "${releasedVersion}".`, + ); + } + const devVersion = ( + bumpConfig.version ?? DEFAULT_DEVELOPMENT_VERSION_TEMPLATE + ).replaceAll('{{version}}', nextVersion); + + if (isDryRun()) { + logger.info( + `[dry-run] Would bump the development version to ${devVersion} and push it to ${remoteName}/${mergeTarget}.`, + ); + return; + } + + logger.info(`Bumping the development version to ${devVersion}...`); + const bumped = await runPreReleaseCommand({ + oldVersion: releasedVersion, + newVersion: devVersion, + preReleaseCommand: config.preReleaseCommand, + targets: config.targets, + rootDir: getReleaseUnitDir(), + }); + if (!bumped) { + logger.warn( + 'Not bumping the development version: nothing wrote a version.', + ); + return; + } + + const repoStatus = await git.status(); + if (repoStatus.files.length === 0) { + logger.info(`The development version is already ${devVersion}.`); + return; + } + await git.commit('meta: Bump new development version\n\n#skip-changelog', [ + '--all', + ]); + await pullWithRebase(git, remoteName, mergeTarget); + await git.push(remoteName, mergeTarget); + logger.info( + `Pushed development version ${devVersion} to ${remoteName}/${mergeTarget}.`, + ); +} + /** * Run an external post-release command * @@ -868,6 +977,7 @@ export async function publishMain(argv: PublishOptions): Promise { ); } + let mergedInto: string | undefined; if (noMerge) { const source = argv.noMerge ? 'CLI option' @@ -881,21 +991,28 @@ export async function publishMain(argv: PublishOptions): Promise { earlierStateExists ) { // Publishing done, MERGE DAT BRANCH! + let mergeTarget = argv.mergeTarget; try { + mergeTarget ||= await getDefaultBranch(git, argv.remote); await handleReleaseBranch( git, argv.remote, branchName, revision, - argv.mergeTarget, + mergeTarget, argv.keepBranch, rev ? revision : branchName, ); + mergedInto = mergeTarget; } catch (mergeError) { // The merge is a housekeeping step — it must not block the success // signal for a fully-published release. Report to Sentry for // observability but don't fail the command. captureException(mergeError); + if (mergeError instanceof BranchCleanupError) { + // The merge itself was pushed; only the branch cleanup failed. + mergedInto = mergeTarget; + } const lines: string[] = []; if (mergeError instanceof BranchCleanupError) { @@ -995,6 +1112,37 @@ export async function publishMain(argv: PublishOptions): Promise { logger.warn(msg.join('\n')); } + if (config.postReleaseBump) { + if (mergedInto) { + try { + await runPostReleaseBump( + git, + argv.remote, + mergedInto, + newVersion, + config.postReleaseBump, + config, + ); + } catch (bumpError) { + // Like the merge, the bump runs after every target has published, so + // it must not fail the release. + captureException(bumpError); + logger.warn( + [ + `Failed to bump the development version after ${newVersion}.`, + `All publish targets completed successfully — bump the version on ${mergedInto} manually.`, + ``, + `Error: ${bumpError instanceof Error ? bumpError.message : String(bumpError)}`, + ].join('\n'), + ); + } + } else { + logger.info( + 'Not bumping the development version: the release branch was not merged.', + ); + } + } + // Run the post-release script await runPostReleaseCommand(newVersion, config.postReleaseCommand); } diff --git a/src/schemas/project_config.ts b/src/schemas/project_config.ts index a6f010bf9..ebddf99af 100644 --- a/src/schemas/project_config.ts +++ b/src/schemas/project_config.ts @@ -314,6 +314,57 @@ export const ChangelogConfigSchema = union([ }), ]); +/** + * Opt-in bump to the next development version after a successful publish. + */ +const PostReleaseBumpTypeSchema = picklist([ + 'patch', + 'minor', + 'major', + 'calver', +]); + +const PostReleaseBumpObjectSchema = strictObject({ + /** How to get the next version. Default: `calver` for CalVer, else `minor`. */ + bump: optional(PostReleaseBumpTypeSchema), + /** + * Development version template. Default: `{{version}}-dev.0`. Only version + * characters are allowed: the value reaches spawned commands as an argument, + * where `${VAR}` would be expanded from the environment. + */ + version: optional( + pipe( + string(), + regex( + /^(?:[0-9A-Za-z._+-]|\{\{version\}\})+$/, + 'Use only letters, digits, ".", "_", "+", "-", and {{version}}.', + ), + ), + ), +}); + +/** + * `true` turns the bump on with the defaults, a bump type such as `patch` is + * shorthand for `{ bump: patch }`, and `false` turns it off. Both shorthands + * are normalized to the object form. + */ +const PostReleaseBumpSchema = union([ + PostReleaseBumpObjectSchema, + pipe( + PostReleaseBumpTypeSchema, + transform(bump => ({ bump })), + ), + pipe( + literal(true), + transform(() => ({})), + ), + literal(false), +]); + +export type PostReleaseBumpConfig = InferOutput< + typeof PostReleaseBumpObjectSchema +>; + /** * Fields that describe how a single release unit is built and published. * @@ -332,6 +383,7 @@ const releaseUnitFields = { targets: optional(array(TargetConfigSchema)), preReleaseCommand: optional(string()), postReleaseCommand: optional(string()), + postReleaseBump: optional(nonArray(PostReleaseBumpSchema)), releaseBranchPrefix: optional(string()), changelog: optional(nonArray(ChangelogConfigSchema)), changelogPolicy: optional(picklist(['auto', 'simple', 'none'])), diff --git a/src/utils/__tests__/calver.test.ts b/src/utils/__tests__/calver.test.ts index ea5244e9e..561e1d781 100644 --- a/src/utils/__tests__/calver.test.ts +++ b/src/utils/__tests__/calver.test.ts @@ -3,6 +3,7 @@ import { formatCalVerDate, calculateCalVer, DEFAULT_CALVER_CONFIG, + getNextCalVerPeriod, } from '../calver'; // Mock the config module to control tagPrefix @@ -206,3 +207,23 @@ describe('calculateCalVer', () => { expect(version).toBe('24.12.2'); }); }); + +describe('getNextCalVerPeriod', () => { + test.each([ + ['26.10.1', '%y.%-m', '26.11.0'], + ['26.12.3', '%y.%-m', '27.1.0'], + ['2026.09.0', '%Y.%m', '2026.10.0'], + ['2026.02.28.4', '%Y.%m.%d', '2026.03.01.0'], + ['2026.0', '%Y', '2027.0'], + ])('%s with %s gives %s', (version, format, expected) => { + expect(getNextCalVerPeriod(version, format)).toBe(expected); + }); + + test.each([ + ['1.2.3-beta.1', '%y.%-m'], + ['26.10', '%y.%-m'], + ['26.10.1', 'static'], + ])('returns undefined for %s with %s', (version, format) => { + expect(getNextCalVerPeriod(version, format)).toBeUndefined(); + }); +}); diff --git a/src/utils/calver.ts b/src/utils/calver.ts index be01b4aaf..b7a5daa83 100644 --- a/src/utils/calver.ts +++ b/src/utils/calver.ts @@ -101,3 +101,65 @@ export async function calculateCalVer( return version; } + +const CALVER_TOKEN_PATTERNS: Record = { + Y: '(\\d{4})', + y: '(\\d{2})', + m: '(\\d{2})', + '-m': '(\\d{1,2})', + d: '(\\d{2})', + '-d': '(\\d{1,2})', +}; + +/** + * Returns the first version of the CalVer period after the one `version` + * belongs to. The period is the smallest unit in `format`: with `%y.%-m`, + * `26.10.1` gives `26.11.0` and `26.12.3` gives `27.1.0`. + * + * @param version A CalVer version in the `{datePart}.{patch}` form + * @param format The strftime-like format of the date part + * @returns The next period's first version, or undefined if `version` doesn't + * match `format` + */ +export function getNextCalVerPeriod( + version: string, + format: string, +): string | undefined { + const tokens: string[] = []; + const datePattern = format + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .replace(/%(Y|y|-m|m|-d|d)/g, (_match, token: string) => { + tokens.push(token); + return CALVER_TOKEN_PATTERNS[token]; + }); + const match = new RegExp(`^${datePattern}\\.\\d+$`).exec(version); + if (!match || tokens.length === 0) { + return undefined; + } + + let year = 0; + let month = 1; + let day = 1; + tokens.forEach((token, index) => { + const value = Number(match[index + 1]); + if (token === 'Y') { + year = value; + } else if (token === 'y') { + year = 2000 + value; + } else if (token.endsWith('m')) { + month = value; + } else { + day = value; + } + }); + + const date = new Date(year, month - 1, day); + if (tokens.some(token => token.endsWith('d'))) { + date.setDate(date.getDate() + 1); + } else if (tokens.some(token => token.endsWith('m'))) { + date.setMonth(date.getMonth() + 1); + } else { + date.setFullYear(date.getFullYear() + 1); + } + return `${formatCalVerDate(date, format)}.0`; +} diff --git a/src/utils/preReleaseCommand.ts b/src/utils/preReleaseCommand.ts new file mode 100644 index 000000000..ffcaf3872 --- /dev/null +++ b/src/utils/preReleaseCommand.ts @@ -0,0 +1,121 @@ +import { join } from 'path'; +import * as shellQuote from 'shell-quote'; + +import { requiresMinVersion } from '../config'; +import { logger } from '../logger'; +import { type TargetConfig } from '../schemas/project_config'; +import { ConfigurationError } from './errors'; +import { buildReleaseCommandEnv } from './releaseCommandEnv'; +import { spawnProcess } from './system'; +import { runAutomaticVersionBumps } from './versionBump'; + +/** 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 automatic version bumping from targets */ +const AUTO_BUMP_MIN_VERSION = '2.21.0'; + +interface PreReleaseOptions { + oldVersion: string; + newVersion: string; + preReleaseCommand?: string; + targets?: TargetConfig[]; + rootDir: string; +} + +/** + * Run pre-release command or automatic version bumping. + * + * Priority: custom command > automatic bumping (minVersion >= 2.21.0) > default script + */ +export async function runPreReleaseCommand( + options: PreReleaseOptions, +): Promise { + const { oldVersion, newVersion, preReleaseCommand, targets, rootDir } = + options; + + if (preReleaseCommand !== undefined && preReleaseCommand.length === 0) { + logger.warn('Not running the pre-release command: no command specified'); + return false; + } + + if (preReleaseCommand) { + return runCustomPreReleaseCommand( + oldVersion, + newVersion, + preReleaseCommand, + ); + } + + if ( + requiresMinVersion(AUTO_BUMP_MIN_VERSION) && + targets && + targets.length > 0 + ) { + logger.info('Running automatic version bumping from targets...'); + const result = await runAutomaticVersionBumps(targets, rootDir, newVersion); + + if (!result.anyBumped) { + if (result.bumpableTargets.length === 0) { + logger.warn( + 'None of your configured targets support automatic version bumping. ' + + 'Consider adding a preReleaseCommand to bump versions manually.', + ); + } else { + throw new ConfigurationError( + `Targets [${result.skippedTargets.join(', ')}] support version bumping ` + + 'but did not find applicable files in your project. ' + + 'Define a preReleaseCommand to bump versions manually, or set ' + + "preReleaseCommand: '' to skip version bumping.", + ); + } + } + + return result.anyBumped; + } + + return runCustomPreReleaseCommand(oldVersion, newVersion, undefined); +} + +/** + * Run a custom pre-release command (or the default bump-version.sh script) + */ +async function runCustomPreReleaseCommand( + oldVersion: string, + newVersion: string, + preReleaseCommand?: string, +): Promise { + let sysCommand: string; + let args: string[]; + + // This is a workaround for the case when the old version is empty, which + // should only happen when the project is new and has no version yet. + // Instead of using an empty string, we use "0.0.0" as the old version to + // avoid breaking the pre-release command as most scripts expect a non-empty + // version string. + const nonEmptyOldVersion = oldVersion || '0.0.0'; + + if (preReleaseCommand) { + [sysCommand, ...args] = shellQuote.parse(preReleaseCommand) as string[]; + } else { + sysCommand = '/bin/bash'; + args = [DEFAULT_BUMP_VERSION_PATH]; + } + + args = [...args, nonEmptyOldVersion, newVersion]; + logger.info('Running the pre-release command...'); + + // The pre-release command comes from .craft.yml, which is + // attacker-influenceable via untrusted PRs. We forward only an + // allowlisted env plus the CRAFT_* version vars to avoid leaking + // secrets into a potentially malicious command. See + // src/utils/releaseCommandEnv.ts for the exact allowlist and rationale. + await spawnProcess(sysCommand, args, { + env: buildReleaseCommandEnv({ + CRAFT_NEW_VERSION: newVersion, + CRAFT_OLD_VERSION: nonEmptyOldVersion, + }), + }); + + return true; +}