From ea96e6feb3a4899af417b5d2e337e6ba4ff6289c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:05:13 +0000 Subject: [PATCH 1/4] feat(publish): Add built-in development version bump after release Add an opt-in postReleaseBump setting. After craft publish merges the release branch, Craft bumps the release unit to the next development version (default preminor with preid dev) through the same target logic as the automatic pre-release bump, commits it with #skip-changelog, pulls with rebase, and pushes to the merge target. The bump is skipped after a pre-release or when the release branch isn't merged, and a failure warns without failing the release. Workspaces inherit the setting and can opt out with postReleaseBump: false. Closes #949 Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- docs/src/content/docs/configuration.md | 1 + docs/src/content/docs/release-workflow.md | 28 ++++ docs/src/content/docs/workspaces.md | 5 +- src/__tests__/config.test.ts | 28 ++++ src/commands/__tests__/publish-main.test.ts | 118 +++++++++++++++ src/commands/publish.ts | 159 ++++++++++++++++++-- src/schemas/project_config.ts | 22 +++ 7 files changed, 345 insertions(+), 16 deletions(-) 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..56ac01534 100644 --- a/docs/src/content/docs/release-workflow.md +++ b/docs/src/content/docs/release-workflow.md @@ -227,6 +227,34 @@ 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 the manifest 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: + bump: preminor # or prepatch, premajor. Default: preminor + preid: dev # Default: dev +``` + +After it merges the release branch, Craft sets the release unit's version to +the next development version, for example `1.2.3` to `1.3.0-dev.0`, with the +same target logic as the +[automatic version bump](../versioning/#automatic-version-bumping). It commits the change +as `meta: Bump new development version` with `#skip-changelog`, then pulls +with rebase and pushes to the merge target. + +- 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..c665f0f05 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -112,6 +112,34 @@ describe('validateConfiguration', () => { expect(validateConfiguration(data)).toEqual(data); }); + test('fills postReleaseBump defaults', () => { + expect(validateConfiguration({ postReleaseBump: {} })).toEqual({ + postReleaseBump: { bump: 'preminor', preid: 'dev' }, + }); + }); + + test('lets a workspace opt out of an inherited postReleaseBump', () => { + const data = { + postReleaseBump: { bump: 'prepatch', preid: 'alpha' }, + workspaces: { 'packages/cli': { postReleaseBump: false } }, + }; + + expect(validateConfiguration(data)).toEqual(data); + }); + + test.each([ + { bump: 'minor' }, + { preid: '' }, + { preid: 'dev.1' }, + { unknown: true }, + [], + true, + ])('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__/publish-main.test.ts b/src/commands/__tests__/publish-main.test.ts index d5b34e1a6..9b4b5cae2 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 { runAutomaticVersionBumps } from '../../utils/versionBump'; 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/versionBump', () => ({ + runAutomaticVersionBumps: 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,116 @@ describe('publishMain release branch handling', () => { ); }); }); + +describe('publishMain post-release bump', () => { + const revision = 'd'.repeat(40); + let git: SimpleGit; + + beforeEach(async () => { + vi.clearAllMocks(); + testState.directory = await mkdtemp( + pathJoin(tmpdir(), 'craft-publish-bump-'), + ); + vi.mocked(getPublishStatePath).mockReturnValue( + pathJoin(testState.directory, 'publish-state.json'), + ); + vi.mocked(getConfiguration).mockReturnValue({ + targets: [{ name: 'test' }], + postReleaseCommand: '', + postReleaseBump: { bump: 'preminor', preid: 'dev' }, + }); + 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(runAutomaticVersionBumps).mockResolvedValue({ + anyBumped: true, + bumpableTargets: ['npm'], + skippedTargets: [], + }); + 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('bumps, commits, and pushes the development version after merging', async () => { + await publishMain({ ...defaultOptions, mergeTarget: undefined }); + + expect(runAutomaticVersionBumps).toHaveBeenCalledWith( + [{ name: 'test' }], + expect.any(String), + '1.3.0-dev.0', + ); + 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('skips the bump when the release branch is not merged', async () => { + await publishMain({ ...defaultOptions, noMerge: true }); + + expect(runAutomaticVersionBumps).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(runAutomaticVersionBumps).not.toHaveBeenCalled(); + expect(git.commit).not.toHaveBeenCalled(); + }); + + test('does not commit when no target bumped a version', async () => { + vi.mocked(runAutomaticVersionBumps).mockResolvedValue({ + anyBumped: false, + bumpableTargets: [], + skippedTargets: [], + }); + + await publishMain(defaultOptions); + + expect(git.commit).not.toHaveBeenCalled(); + }); + + test('warns instead of failing the release when the bump fails', async () => { + vi.mocked(runAutomaticVersionBumps).mockRejectedValue( + new Error('npm 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/publish.ts b/src/commands/publish.ts index 02b926647..ca10c9dc4 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,10 +17,12 @@ import { expandWorkspaceTargets, getNoMergeConfig, getActiveWorkspace, + getReleaseUnitDir, } from '../config'; import { formatTable, logger } from '../logger'; import { type GitHubGlobalConfig, + type PostReleaseBumpConfig, TargetConfig, } from '../schemas/project_config'; import { getAllTargetNames, getTargetByName, SpecialTarget } from '../targets'; @@ -31,7 +34,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 +55,7 @@ import { import { withTracing } from '../utils/tracing'; import { buildReleaseCommandEnv } from '../utils/releaseCommandEnv'; import { getPublishStatePath } from '../utils/publishState'; +import { runAutomaticVersionBumps } from '../utils/versionBump'; /** Default path to post-release script, relative to project root */ const DEFAULT_POST_RELEASE_SCRIPT_PATH = join('scripts', 'post-release.sh'); @@ -491,18 +495,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 +600,105 @@ 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; + } +} + +/** + * Bump the release unit to the next development version on the merge target, + * then commit and push the change. + * + * Uses the same target logic as the automatic pre-release bump, so it only + * touches the selected release unit's project files. + * + * @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 targets Target configs of the release unit + */ +async function runPostReleaseBump( + git: SimpleGit, + remoteName: string, + mergeTarget: string, + releasedVersion: string, + bumpConfig: PostReleaseBumpConfig, + targets: TargetConfig[], +): 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 devVersion = semver.inc( + releasedVersion, + bumpConfig.bump, + bumpConfig.preid, + ); + if (!devVersion) { + throw new Error( + `Cannot derive a development version from "${releasedVersion}".`, + ); + } + + 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 result = await runAutomaticVersionBumps( + targets, + getReleaseUnitDir(), + devVersion, + ); + if (!result.anyBumped) { + logger.warn( + 'Not bumping the development version: none of the configured targets ' + + 'found a version to bump.', + ); + 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 +960,7 @@ export async function publishMain(argv: PublishOptions): Promise { ); } + let mergedInto: string | undefined; if (noMerge) { const source = argv.noMerge ? 'CLI option' @@ -881,21 +974,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 +1095,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.targets || [], + ); + } 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..09d949f96 100644 --- a/src/schemas/project_config.ts +++ b/src/schemas/project_config.ts @@ -314,6 +314,25 @@ export const ChangelogConfigSchema = union([ }), ]); +/** + * Opt-in bump to the next development version after a successful publish. + */ +const PostReleaseBumpSchema = strictObject({ + bump: optional(picklist(['prepatch', 'preminor', 'premajor']), 'preminor'), + preid: optional( + pipe( + string(), + regex( + /^[0-9A-Za-z-]+$/, + 'Use only ASCII letters, digits, and hyphens in preid.', + ), + ), + 'dev', + ), +}); + +export type PostReleaseBumpConfig = InferOutput; + /** * Fields that describe how a single release unit is built and published. * @@ -332,6 +351,9 @@ const releaseUnitFields = { targets: optional(array(TargetConfigSchema)), preReleaseCommand: optional(string()), postReleaseCommand: optional(string()), + postReleaseBump: optional( + nonArray(union([PostReleaseBumpSchema, literal(false)])), + ), releaseBranchPrefix: optional(string()), changelog: optional(nonArray(ChangelogConfigSchema)), changelogPolicy: optional(picklist(['auto', 'simple', 'none'])), From 80bdb6f3729316bf53ffb83bfffe20844de2aecb Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:21:43 +0000 Subject: [PATCH 2/4] feat(publish): Write the dev version with the pre-release step, add CalVer and templates postReleaseBump now writes the development version with the same step craft prepare uses: the preReleaseCommand when set, otherwise the automatic target bumps. Repos whose post-release scripts just re-run their bump script with a dev version (sentry, snuba, self-hosted, sentry-godot) can switch to it. Replace bump/preid with bump (patch, minor, major, calver) and a version template ('{{version}}-dev.0' by default). calver moves to the first version of the next CalVer period, and the default for the calver policy. A template without {{version}}, such as 'nightly', is used as is. postReleaseBump: true enables the defaults. Move runPreReleaseCommand to src/utils/preReleaseCommand.ts so publish can use it without importing the prepare command. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- docs/src/content/docs/release-workflow.md | 32 +++-- src/__tests__/config.test.ts | 33 ++--- src/commands/__tests__/prepare.test.ts | 3 +- src/commands/__tests__/publish-main.test.ts | 109 ++++++++++++----- src/commands/prepare.ts | 128 +------------------- src/commands/publish.ts | 68 +++++++---- src/schemas/project_config.ts | 17 +-- src/utils/__tests__/calver.test.ts | 21 ++++ src/utils/calver.ts | 62 ++++++++++ src/utils/preReleaseCommand.ts | 121 ++++++++++++++++++ 10 files changed, 379 insertions(+), 215 deletions(-) create mode 100644 src/utils/preReleaseCommand.ts diff --git a/docs/src/content/docs/release-workflow.md b/docs/src/content/docs/release-workflow.md index 56ac01534..065c0f7ea 100644 --- a/docs/src/content/docs/release-workflow.md +++ b/docs/src/content/docs/release-workflow.md @@ -229,23 +229,35 @@ branch; override it with `--merge-target`. ### Post-release development version bump -Many projects set the manifest to the next development version after a +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: - bump: preminor # or prepatch, premajor. Default: preminor - preid: dev # Default: dev +postReleaseBump: true ``` -After it merges the release branch, Craft sets the release unit's version to -the next development version, for example `1.2.3` to `1.3.0-dev.0`, with the -same target logic as the -[automatic version bump](../versioning/#automatic-version-bumping). It commits the change -as `meta: Bump new development version` with `#skip-changelog`, then pulls -with rebase and pushes to the merge target. +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. + +Two settings shape the development version: + +| 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` | `bump: patch` | `1.2.4-dev.0` | +| `26.10.1` | `version: '{{version}}.dev0'` with the `calver` policy | `26.11.0.dev0` | +| `26.10.1` | `version: nightly` | `nightly` | - 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 diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index c665f0f05..d88fd3b9f 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -112,33 +112,34 @@ describe('validateConfiguration', () => { expect(validateConfiguration(data)).toEqual(data); }); - test('fills postReleaseBump defaults', () => { - expect(validateConfiguration({ postReleaseBump: {} })).toEqual({ - postReleaseBump: { bump: 'preminor', preid: 'dev' }, + test.each([ + true, + {}, + { bump: 'calver', version: '{{version}}.dev0' }, + { version: 'nightly' }, + ])('accepts postReleaseBump %j', postReleaseBump => { + expect(validateConfiguration({ postReleaseBump })).toEqual({ + postReleaseBump, }); }); test('lets a workspace opt out of an inherited postReleaseBump', () => { const data = { - postReleaseBump: { bump: 'prepatch', preid: 'alpha' }, + postReleaseBump: { bump: 'patch' }, workspaces: { 'packages/cli': { postReleaseBump: false } }, }; expect(validateConfiguration(data)).toEqual(data); }); - test.each([ - { bump: 'minor' }, - { preid: '' }, - { preid: 'dev.1' }, - { unknown: true }, - [], - true, - ])('rejects invalid postReleaseBump %j', postReleaseBump => { - expect(() => validateConfiguration({ postReleaseBump })).toThrow( - /postReleaseBump/, - ); - }); + test.each([{ bump: 'preminor' }, { version: ' ' }, { preid: 'dev' }, []])( + '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: {}'); 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 9b4b5cae2..16da6c25f 100644 --- a/src/commands/__tests__/publish-main.test.ts +++ b/src/commands/__tests__/publish-main.test.ts @@ -27,7 +27,7 @@ import { getTargetByName } from '../../targets'; import { BaseTarget } from '../../targets/base'; import { safeFs } from '../../utils/dryRun'; import { promptConfirmation } from '../../utils/helpers'; -import { runAutomaticVersionBumps } from '../../utils/versionBump'; +import { runPreReleaseCommand } from '../../utils/preReleaseCommand'; import { getPublishStatePath } from '../../utils/publishState'; import { findReleaseBranches, @@ -67,8 +67,8 @@ vi.mock('../../utils/helpers', async importOriginal => ({ ...(await importOriginal()), promptConfirmation: vi.fn(), })); -vi.mock('../../utils/versionBump', () => ({ - runAutomaticVersionBumps: vi.fn(), +vi.mock('../../utils/preReleaseCommand', () => ({ + runPreReleaseCommand: vi.fn(), })); vi.mock('../../utils/publishState', async importOriginal => ({ ...(await importOriginal()), @@ -245,6 +245,14 @@ 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( @@ -253,11 +261,7 @@ describe('publishMain post-release bump', () => { vi.mocked(getPublishStatePath).mockReturnValue( pathJoin(testState.directory, 'publish-state.json'), ); - vi.mocked(getConfiguration).mockReturnValue({ - targets: [{ name: 'test' }], - postReleaseCommand: '', - postReleaseBump: { bump: 'preminor', preid: 'dev' }, - }); + mockConfig({ postReleaseBump: true }); vi.mocked(getStatusProviderFromConfig).mockResolvedValue({} as never); vi.mocked(getArtifactProviderFromConfig).mockResolvedValue({ listArtifactsForRevision: vi.fn().mockResolvedValue([]), @@ -276,11 +280,7 @@ describe('publishMain post-release bump', () => { }); vi.mocked(getActiveWorkspace).mockReturnValue(undefined); vi.mocked(getDefaultBranch).mockResolvedValue('main'); - vi.mocked(runAutomaticVersionBumps).mockResolvedValue({ - anyBumped: true, - bumpableTargets: ['npm'], - skippedTargets: [], - }); + vi.mocked(runPreReleaseCommand).mockResolvedValue(true); vi.spyOn(safeFs, 'unlink').mockResolvedValue(undefined); vi.spyOn(safeFs, 'writeFileSync').mockImplementation(() => undefined); @@ -296,14 +296,21 @@ describe('publishMain post-release bump', () => { await rm(testState.directory, { recursive: true, force: true }); }); - test('bumps, commits, and pushes the development version after merging', async () => { + test('writes the development version with the pre-release bump, then commits and pushes', async () => { + mockConfig({ + postReleaseBump: true, + preReleaseCommand: 'bash scripts/bump-version.sh', + }); + await publishMain({ ...defaultOptions, mergeTarget: undefined }); - expect(runAutomaticVersionBumps).toHaveBeenCalledWith( - [{ name: 'test' }], - expect.any(String), - '1.3.0-dev.0', - ); + 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'], @@ -312,26 +319,70 @@ describe('publishMain post-release bump', () => { 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(runAutomaticVersionBumps).not.toHaveBeenCalled(); + 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(runAutomaticVersionBumps).not.toHaveBeenCalled(); + expect(runPreReleaseCommand).not.toHaveBeenCalled(); expect(git.commit).not.toHaveBeenCalled(); }); - test('does not commit when no target bumped a version', async () => { - vi.mocked(runAutomaticVersionBumps).mockResolvedValue({ - anyBumped: false, - bumpableTargets: [], - skippedTargets: [], - }); + test('does not commit when nothing wrote a version', async () => { + vi.mocked(runPreReleaseCommand).mockResolvedValue(false); await publishMain(defaultOptions); @@ -339,8 +390,8 @@ describe('publishMain post-release bump', () => { }); test('warns instead of failing the release when the bump fails', async () => { - vi.mocked(runAutomaticVersionBumps).mockRejectedValue( - new Error('npm exploded'), + vi.mocked(runPreReleaseCommand).mockRejectedValue( + new Error('bump script exploded'), ); const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); 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 ca10c9dc4..0a6b29054 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -21,9 +21,11 @@ import { } 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'; @@ -35,7 +37,11 @@ import { import { withTempDir } from '../utils/files'; import { stringToRegexp } from '../utils/filters'; import { isDryRun, promptConfirmation } from '../utils/helpers'; -import { formatArtifactConfigForError, formatSize } from '../utils/strings'; +import { + formatArtifactConfigForError, + formatSize, + renderTemplateSafe, +} from '../utils/strings'; import { catchKeyboardInterrupt, hasExecutable, @@ -55,7 +61,8 @@ import { import { withTracing } from '../utils/tracing'; import { buildReleaseCommandEnv } from '../utils/releaseCommandEnv'; import { getPublishStatePath } from '../utils/publishState'; -import { runAutomaticVersionBumps } from '../utils/versionBump'; +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'); @@ -622,19 +629,22 @@ async function pullWithRebase( } } +/** 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. * - * Uses the same target logic as the automatic pre-release bump, so it only - * touches the selected release unit's project files. + * 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 targets Target configs of the release unit + * @param config The release unit's configuration */ async function runPostReleaseBump( git: SimpleGit, @@ -642,7 +652,7 @@ async function runPostReleaseBump( mergeTarget: string, releasedVersion: string, bumpConfig: PostReleaseBumpConfig, - targets: TargetConfig[], + config: CraftProjectConfig, ): Promise { if (semver.prerelease(releasedVersion)) { // A development version derived from a pre-release would sort above the @@ -652,16 +662,27 @@ async function runPostReleaseBump( ); return; } - const devVersion = semver.inc( - releasedVersion, - bumpConfig.bump, - bumpConfig.preid, - ); - if (!devVersion) { + 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 a development version from "${releasedVersion}".`, + `Cannot derive the next ${bump} version from "${releasedVersion}".`, ); } + const devVersion = renderTemplateSafe( + bumpConfig.version ?? DEFAULT_DEVELOPMENT_VERSION_TEMPLATE, + { version: nextVersion }, + ); if (isDryRun()) { logger.info( @@ -671,15 +692,16 @@ async function runPostReleaseBump( } logger.info(`Bumping the development version to ${devVersion}...`); - const result = await runAutomaticVersionBumps( - targets, - getReleaseUnitDir(), - devVersion, - ); - if (!result.anyBumped) { + 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: none of the configured targets ' + - 'found a version to bump.', + 'Not bumping the development version: nothing wrote a version.', ); return; } @@ -1103,8 +1125,8 @@ export async function publishMain(argv: PublishOptions): Promise { argv.remote, mergedInto, newVersion, - config.postReleaseBump, - config.targets || [], + config.postReleaseBump === true ? {} : config.postReleaseBump, + config, ); } catch (bumpError) { // Like the merge, the bump runs after every target has published, so diff --git a/src/schemas/project_config.ts b/src/schemas/project_config.ts index 09d949f96..17de549df 100644 --- a/src/schemas/project_config.ts +++ b/src/schemas/project_config.ts @@ -318,17 +318,10 @@ export const ChangelogConfigSchema = union([ * Opt-in bump to the next development version after a successful publish. */ const PostReleaseBumpSchema = strictObject({ - bump: optional(picklist(['prepatch', 'preminor', 'premajor']), 'preminor'), - preid: optional( - pipe( - string(), - regex( - /^[0-9A-Za-z-]+$/, - 'Use only ASCII letters, digits, and hyphens in preid.', - ), - ), - 'dev', - ), + /** How to get the next version. Default: `calver` for CalVer, else `minor`. */ + bump: optional(picklist(['patch', 'minor', 'major', 'calver'])), + /** Development version template. Default: `{{version}}-dev.0`. */ + version: optional(pipe(string(), trim(), minLength(1))), }); export type PostReleaseBumpConfig = InferOutput; @@ -352,7 +345,7 @@ const releaseUnitFields = { preReleaseCommand: optional(string()), postReleaseCommand: optional(string()), postReleaseBump: optional( - nonArray(union([PostReleaseBumpSchema, literal(false)])), + nonArray(union([PostReleaseBumpSchema, boolean()])), ), releaseBranchPrefix: optional(string()), changelog: optional(nonArray(ChangelogConfigSchema)), 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; +} From 5edad5a779dc20e006e2babaed5d78c11ee568cf Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:11:43 +0000 Subject: [PATCH 3/4] feat(publish): Accept a bump type as postReleaseBump shorthand postReleaseBump: patch (or minor, major, calver) is shorthand for { bump: patch } with the default -dev.0 template. The schema normalizes true and the bump-type shorthand to the object form, so publish only deals with one shape. Document why the dev marker should be a pre-release suffix rather than +build metadata. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- docs/src/content/docs/release-workflow.md | 17 ++++++++-- src/__tests__/config.test.ts | 37 +++++++++++++-------- src/commands/__tests__/publish-main.test.ts | 4 +-- src/commands/publish.ts | 2 +- src/schemas/project_config.ts | 37 +++++++++++++++++---- 5 files changed, 72 insertions(+), 25 deletions(-) diff --git a/docs/src/content/docs/release-workflow.md b/docs/src/content/docs/release-workflow.md index 065c0f7ea..1c8d455c0 100644 --- a/docs/src/content/docs/release-workflow.md +++ b/docs/src/content/docs/release-workflow.md @@ -245,7 +245,15 @@ the same step `craft prepare` uses to write the release version: the commits the change as `meta: Bump new development version` with `#skip-changelog`, pulls with rebase, and pushes to the merge target. -Two settings shape the development version: +`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 | | --- | --- | --- | @@ -255,10 +263,15 @@ Two settings shape the development version: | Released | Configuration | Development version | | --- | --- | --- | | `1.2.3` | `postReleaseBump: true` | `1.3.0-dev.0` | -| `1.2.3` | `bump: patch` | `1.2.4-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. diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index d88fd3b9f..a4fe8439f 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -113,13 +113,19 @@ describe('validateConfiguration', () => { }); test.each([ - true, - {}, - { bump: 'calver', version: '{{version}}.dev0' }, - { version: 'nightly' }, - ])('accepts postReleaseBump %j', postReleaseBump => { + [{}, {}], + [ + { 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, + postReleaseBump: expected, }); }); @@ -132,14 +138,17 @@ describe('validateConfiguration', () => { expect(validateConfiguration(data)).toEqual(data); }); - test.each([{ bump: 'preminor' }, { version: ' ' }, { preid: 'dev' }, []])( - 'rejects invalid postReleaseBump %j', - postReleaseBump => { - expect(() => validateConfiguration({ postReleaseBump })).toThrow( - /postReleaseBump/, - ); - }, - ); + test.each([ + { bump: 'preminor' }, + { version: ' ' }, + { 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: {}'); diff --git a/src/commands/__tests__/publish-main.test.ts b/src/commands/__tests__/publish-main.test.ts index 16da6c25f..6c5894bbe 100644 --- a/src/commands/__tests__/publish-main.test.ts +++ b/src/commands/__tests__/publish-main.test.ts @@ -261,7 +261,7 @@ describe('publishMain post-release bump', () => { vi.mocked(getPublishStatePath).mockReturnValue( pathJoin(testState.directory, 'publish-state.json'), ); - mockConfig({ postReleaseBump: true }); + mockConfig({ postReleaseBump: {} }); vi.mocked(getStatusProviderFromConfig).mockResolvedValue({} as never); vi.mocked(getArtifactProviderFromConfig).mockResolvedValue({ listArtifactsForRevision: vi.fn().mockResolvedValue([]), @@ -298,7 +298,7 @@ describe('publishMain post-release bump', () => { test('writes the development version with the pre-release bump, then commits and pushes', async () => { mockConfig({ - postReleaseBump: true, + postReleaseBump: {}, preReleaseCommand: 'bash scripts/bump-version.sh', }); diff --git a/src/commands/publish.ts b/src/commands/publish.ts index 0a6b29054..13b9352b2 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -1125,7 +1125,7 @@ export async function publishMain(argv: PublishOptions): Promise { argv.remote, mergedInto, newVersion, - config.postReleaseBump === true ? {} : config.postReleaseBump, + config.postReleaseBump, config, ); } catch (bumpError) { diff --git a/src/schemas/project_config.ts b/src/schemas/project_config.ts index 17de549df..f42b9c582 100644 --- a/src/schemas/project_config.ts +++ b/src/schemas/project_config.ts @@ -317,14 +317,41 @@ export const ChangelogConfigSchema = union([ /** * Opt-in bump to the next development version after a successful publish. */ -const PostReleaseBumpSchema = strictObject({ +const PostReleaseBumpTypeSchema = picklist([ + 'patch', + 'minor', + 'major', + 'calver', +]); + +const PostReleaseBumpObjectSchema = strictObject({ /** How to get the next version. Default: `calver` for CalVer, else `minor`. */ - bump: optional(picklist(['patch', 'minor', 'major', 'calver'])), + bump: optional(PostReleaseBumpTypeSchema), /** Development version template. Default: `{{version}}-dev.0`. */ version: optional(pipe(string(), trim(), minLength(1))), }); -export type PostReleaseBumpConfig = InferOutput; +/** + * `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. @@ -344,9 +371,7 @@ const releaseUnitFields = { targets: optional(array(TargetConfigSchema)), preReleaseCommand: optional(string()), postReleaseCommand: optional(string()), - postReleaseBump: optional( - nonArray(union([PostReleaseBumpSchema, boolean()])), - ), + postReleaseBump: optional(nonArray(PostReleaseBumpSchema)), releaseBranchPrefix: optional(string()), changelog: optional(nonArray(ChangelogConfigSchema)), changelogPolicy: optional(picklist(['auto', 'simple', 'none'])), From 32a0c03a522311609b17a2286f828184706c6442 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:20:24 +0000 Subject: [PATCH 4/4] fix(publish): Restrict postReleaseBump.version to version characters The development version reaches spawned commands as an argument, and spawnProcess expands whole-argument ${VAR} values from the full process env. A crafted template such as '${NPM_TOKEN}' could write a secret into a version file that is then committed and pushed. Allow only letters, digits, '.', '_', '+', '-' and the {{version}} placeholder, and replace the placeholder directly instead of rendering it with mustache. --- src/__tests__/config.test.ts | 3 +++ src/commands/publish.ts | 13 ++++--------- src/schemas/project_config.ts | 16 ++++++++++++++-- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index a4fe8439f..b951eb0e7 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -141,6 +141,9 @@ describe('validateConfiguration', () => { test.each([ { bump: 'preminor' }, { version: ' ' }, + { version: '${NPM_TOKEN}' }, + { version: '{{{version}}}' }, + { version: '$(id)' }, { preid: 'dev' }, 'preminor', [], diff --git a/src/commands/publish.ts b/src/commands/publish.ts index 13b9352b2..587b7773b 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -37,11 +37,7 @@ import { import { withTempDir } from '../utils/files'; import { stringToRegexp } from '../utils/filters'; import { isDryRun, promptConfirmation } from '../utils/helpers'; -import { - formatArtifactConfigForError, - formatSize, - renderTemplateSafe, -} from '../utils/strings'; +import { formatArtifactConfigForError, formatSize } from '../utils/strings'; import { catchKeyboardInterrupt, hasExecutable, @@ -679,10 +675,9 @@ async function runPostReleaseBump( `Cannot derive the next ${bump} version from "${releasedVersion}".`, ); } - const devVersion = renderTemplateSafe( - bumpConfig.version ?? DEFAULT_DEVELOPMENT_VERSION_TEMPLATE, - { version: nextVersion }, - ); + const devVersion = ( + bumpConfig.version ?? DEFAULT_DEVELOPMENT_VERSION_TEMPLATE + ).replaceAll('{{version}}', nextVersion); if (isDryRun()) { logger.info( diff --git a/src/schemas/project_config.ts b/src/schemas/project_config.ts index f42b9c582..ebddf99af 100644 --- a/src/schemas/project_config.ts +++ b/src/schemas/project_config.ts @@ -327,8 +327,20 @@ const PostReleaseBumpTypeSchema = picklist([ 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`. */ - version: optional(pipe(string(), trim(), minLength(1))), + /** + * 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}}.', + ), + ), + ), }); /**