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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
53 changes: 53 additions & 0 deletions docs/src/content/docs/release-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
5 changes: 3 additions & 2 deletions docs/src/content/docs/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 41 additions & 0 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
3 changes: 2 additions & 1 deletion src/commands/__tests__/prepare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
169 changes: 169 additions & 0 deletions src/commands/__tests__/publish-main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -66,6 +67,9 @@ vi.mock('../../utils/helpers', async importOriginal => ({
...(await importOriginal<typeof import('../../utils/helpers')>()),
promptConfirmation: vi.fn(),
}));
vi.mock('../../utils/preReleaseCommand', () => ({
runPreReleaseCommand: vi.fn(),
}));
vi.mock('../../utils/publishState', async importOriginal => ({
...(await importOriginal<typeof import('../../utils/publishState')>()),
getPublishStatePath: vi.fn(),
Expand All @@ -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),
Expand Down Expand Up @@ -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<string, unknown>): 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();
});
});
Loading
Loading