From 5a691dbbb838a39fa1b1031b7b994a1fd4f72b97 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:38:03 -0700 Subject: [PATCH] Allow prerelease promotion to stable Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../skills/beachball-change-file/SKILL.md | 9 +- ...-337f29c3-dd36-424e-a0e6-c3798a3cdf2b.json | 11 ++ docs/cli/bump.md | 8 +- docs/cli/change.md | 7 ++ docs/cli/publish.md | 2 +- docs/concepts/change-types.md | 13 +++ docs/overview/configuration.md | 2 +- .../bump/bumpPackageInfoVersion.test.ts | 17 +++ .../changefile/getQuestionsForPackage.test.ts | 27 ++++- .../changefile/promptForChange.test.ts | 4 +- ...ptForChange_promptForPackageChange.test.ts | 109 ++++++++++-------- .../src/bump/bumpPackageInfoVersion.ts | 5 +- .../src/changefile/getQuestionsForPackage.ts | 3 + 13 files changed, 153 insertions(+), 64 deletions(-) create mode 100644 change/change-337f29c3-dd36-424e-a0e6-c3798a3cdf2b.json diff --git a/.claude-plugin/plugins/beachball-change-file/skills/beachball-change-file/SKILL.md b/.claude-plugin/plugins/beachball-change-file/skills/beachball-change-file/SKILL.md index fcf6fa35d..bec002cd3 100644 --- a/.claude-plugin/plugins/beachball-change-file/skills/beachball-change-file/SKILL.md +++ b/.claude-plugin/plugins/beachball-change-file/skills/beachball-change-file/SKILL.md @@ -120,7 +120,9 @@ If the package's current version is 1.0.0 or greater and does NOT have a prerele - `"minor"`: New exported APIs, non-breaking signature changes to exported APIs, or more significant changes to internal logic. (If the package has a `/etc/*.api.md` file, checking its diff is the easiest way to see exported API changes.) - `"major"`: Breaking changes to exported APIs (removals or breaking signature changes), critical dependency updates, or behavior changes that might be breaking for the consumer. You MUST confirm with the user before choosing `"major"`. - `"none"`: None of the changes will impact consumers of the package (e.g. the changes are only to non-exported test-specific files or documentation). If you're not certain, prefer `"patch"`. -- There are additional options `prerelease|premajor|preminor|prepatch`, but you should only use one of these if explicitly requested by the user. +- There are additional prerelease options: + - Use `premajor`, `preminor`, or `prepatch` when the user-facing change should start a prerelease instead of a stable release. + - Use `prerelease` only for packages already on a prerelease version, to continue the prerelease sequence. #### Case 2: Version is 0.x.y and NOT prerelease @@ -132,8 +134,9 @@ If the package's major version is 0 and does NOT have a prerelease suffix, this #### Case 3: Version IS prerelease -ONLY if the package's current version includes a prerelease suffix, the typical options are `` (but you MUST respect `disallowedChangeTypes`): +ONLY if the package's current version includes a prerelease suffix, the typical options are `` (but you MUST respect `disallowedChangeTypes`): - `"prerelease"`: Any changes that impact consumers of the package +- `"patch"`, `"minor"`, or `"major"`: Promote or advance the package to a stable version according to semver. Use these when the current prerelease already represents the stable release you want to ship (for example, `2.0.0-rc.0` → `2.0.0`). - `"none"`: None of the changes will impact consumers of the package (e.g. the changes are only to non-exported test-specific files or documentation). If you're not certain, prefer `"prerelease"`. -- There are additional options `premajor|preminor|prepatch`, but you should only use one of these if explicitly requested by the user or all other change types are disallowed. +- There are additional options `premajor|preminor|prepatch`; use one when intentionally starting a new prerelease target from the current prerelease version, or when all other change types are disallowed. diff --git a/change/change-337f29c3-dd36-424e-a0e6-c3798a3cdf2b.json b/change/change-337f29c3-dd36-424e-a0e6-c3798a3cdf2b.json new file mode 100644 index 000000000..e7ecde783 --- /dev/null +++ b/change/change-337f29c3-dd36-424e-a0e6-c3798a3cdf2b.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "beachball", + "type": "minor", + "dependentChangeType": "patch", + "comment": "Add prerelease-to-stable promotion and prerelease change prompts.", + "email": "7559015+janechu@users.noreply.github.com" + } + ] +} diff --git a/docs/cli/bump.md b/docs/cli/bump.md index b3ab65eeb..792ec84a3 100644 --- a/docs/cli/bump.md +++ b/docs/cli/bump.md @@ -20,7 +20,7 @@ $ beachball bump [General options](./options) also apply for this command. -| Option | Description | -| --------------------- | -------------------------------------------------------------------------------- | -| `--keep-change-files` | don't delete the change files from disk after bumping | -| `--prerelease-prefix` | prerelease prefix (e.g. `beta`) for packages that will receive a prerelease bump | +| Option | Description | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--keep-change-files` | don't delete the change files from disk after bumping | +| `--prerelease-prefix` | prerelease prefix (e.g. `beta`); stable packages with major/minor/patch changes bump as prereleases, while existing prerelease packages can promote to stable | diff --git a/docs/cli/change.md b/docs/cli/change.md index 66e2ecbf6..ae6121b20 100644 --- a/docs/cli/change.md +++ b/docs/cli/change.md @@ -85,8 +85,15 @@ Please describe the changes for: some-pkg Minor - small feature; backwards compatible changes. None - this change does not affect the published package in any way. Major - major feature; breaking changes. + Prepatch - start a prerelease patch. + Preminor - start a prerelease minor. + Premajor - start a prerelease major. ``` +`Prepatch`, `preminor`, and `premajor` start prerelease versions. For a package that is already on a +prerelease version, use `prerelease` to continue the prerelease sequence or a stable type (`patch`, +`minor`, or `major`) to move to a stable version according to semver. + Next, it asks for a **description** of the change. You can type any text or choose from a list of recent commit messages. > Tip: These descriptions will be collated into a changelog when the change is published by `beachball publish`, so think about how to describe your change in a way that's helpful and relevant for consumers of the package. diff --git a/docs/cli/publish.md b/docs/cli/publish.md index a728479f6..a31ca9471 100644 --- a/docs/cli/publish.md +++ b/docs/cli/publish.md @@ -19,7 +19,7 @@ Publishing automates all the bumping and synchronizing of package versions in th | `--git-tags`, `--no-git-tags` | | `true` (`--git-tags`) | whether to create git tags for published package versions | | `--keep-change-files` | | | don't delete the change files from disk after bumping | | `--message` | `-m` | `'applying package updates'` | custom commit message | -| `--prerelease-prefix` | | | prerelease prefix (e.g. `beta`) for packages that will receive a prerelease bump | +| `--prerelease-prefix` | | | prerelease prefix (e.g. `beta`); stable packages with major/minor/patch changes bump as prereleases, while existing prerelease packages can promote to stable | | `--publish`, `--no-publish` | | `true` (`--publish`) | whether to publish to the npm registry | | `--push`, `--no-push` | | `true` (`--push`) | whether to commit changes and push them back to the git remote | | `--registry` | `-r` | `'https://registry.npmjs.org'` | npm registry for publishing | diff --git a/docs/concepts/change-types.md b/docs/concepts/change-types.md index 0128dee4a..d35da26cc 100644 --- a/docs/concepts/change-types.md +++ b/docs/concepts/change-types.md @@ -20,10 +20,23 @@ The available types follow [semantic versioning](https://semver.org/) convention | `patch` | Bug fixes or internal changes that don't affect exported API signatures | 1.0.0 → 1.0.1 | | `minor` | New exported APIs, non-breaking changes to exported API signatures, or significant changes to internal logic | 1.0.0 → 1.1.0 | | `major` | Breaking changes to exported APIs (removals or breaking signature changes), critical dependency updates, or behavior changes that could break consumers | 1.0.0 → 2.0.0 | +| `prepatch` | Start a prerelease for patch-level changes | 1.0.0 → 1.0.1-beta.0 | +| `preminor` | Start a prerelease for minor-level changes | 1.0.0 → 1.1.0-beta.0 | +| `premajor` | Start a prerelease for major-level changes | 1.0.0 → 2.0.0-beta.0 | | `none` | Changes that don't affect consumers at all (tests, documentation, internal config) | no bump | When in doubt between `minor`/`patch` or `patch`/`none`, it's generally best to choose the larger change type. +The prerelease examples assume `prereleasePrefix` is set to `beta`. Without a prefix, semver uses a +numeric prerelease identifier such as `1.0.1-0`. + +### Prerelease packages + +For packages already on a prerelease version, choose `prerelease` to continue the prerelease sequence. +Choose `patch`, `minor`, or `major` to move to a stable version when the current prerelease already +represents that release (for example, 2.0.0-rc.0 → 2.0.0). Choose `prepatch`, `preminor`, or +`premajor` to start a new prerelease target from the current version. + ### Zero-version packages (version 0.x.y) Packages with a major version of 0 are considered unstable per the semver spec. The conventions are slightly different: diff --git a/docs/overview/configuration.md b/docs/overview/configuration.md index 61402a2e0..4ed2f8da5 100644 --- a/docs/overview/configuration.md +++ b/docs/overview/configuration.md @@ -93,7 +93,7 @@ For the latest full list of supported options, see `RepoOptions` [in this file]( | `ignorePatterns` | `string[]` | | repo | Ignore changes in files matching these glob patterns ([see notes][6]) | | `npmReadConcurrency` | number | 5 | repo | Maximum concurrency for fetching package versions from the registry (see `concurrency` for write operations) | | `package` | `string` | | repo | Specifies which package the command relates to (overrides change detection based on `git diff`) | -| `prereleasePrefix` | `string` | | repo | Prerelease prefix, e.g. `"beta"`. Note that if this is specified, packages with change type major/minor/patch will be bumped as prerelease instead. | +| `prereleasePrefix` | `string` | | repo | Prerelease prefix, e.g. `"beta"`. Stable packages with major/minor/patch change types will be bumped as prerelease instead. Existing prerelease packages keep normal semver behavior, so major/minor/patch can promote to stable. | | `packStyle` | `'sequential' \| 'layer'` | `'sequential'` | repo | With `packToPath`, how to organize the tgz files. `'sequential'` uses numeric prefixes to ensure topological ordering. `'layer'` groups the packages into numbered subfolders based on dependency tree layers. | | `packToPath` | `string` | | repo | Instead of publishing to npm, pack packages to tgz files under the specified path. | | `publish` | `boolean` | `true` | repo | Whether to publish to npm registry | diff --git a/packages/beachball/src/__tests__/bump/bumpPackageInfoVersion.test.ts b/packages/beachball/src/__tests__/bump/bumpPackageInfoVersion.test.ts index 5cb18072a..bb053c06d 100644 --- a/packages/beachball/src/__tests__/bump/bumpPackageInfoVersion.test.ts +++ b/packages/beachball/src/__tests__/bump/bumpPackageInfoVersion.test.ts @@ -119,6 +119,23 @@ describe('bumpPackageInfoVersion', () => { expect(bumpInfo.modifiedPackages).toContain(name); }); + it.each<[ChangeType, string]>([ + ['major', '2.0.0'], + ['minor', '2.0.0'], + ['patch', '2.0.0'], + ])( + 'promotes prerelease package to stable for changeType %s when prereleasePrefix is set', + (changeType, expectedVersion) => { + const bumpInfo = bumpPackageInfoVersionWrapper({ + changeType, + packageInfo: { version: '2.0.0-rc.0' }, + options: { prereleasePrefix: 'rc' }, + }); + expect(bumpInfo.packageInfos[name].version).toBe(expectedVersion); + expect(bumpInfo.modifiedPackages).toContain(name); + } + ); + it('bumps to subsequent prerelease version with existing prefix', () => { const bumpInfo = bumpPackageInfoVersionWrapper({ changeType: 'prerelease', diff --git a/packages/beachball/src/__tests__/changefile/getQuestionsForPackage.test.ts b/packages/beachball/src/__tests__/changefile/getQuestionsForPackage.test.ts index bb86ae2f1..6c37c1e9a 100644 --- a/packages/beachball/src/__tests__/changefile/getQuestionsForPackage.test.ts +++ b/packages/beachball/src/__tests__/changefile/getQuestionsForPackage.test.ts @@ -46,6 +46,9 @@ describe('getQuestionsForPackage', () => { { title: expect.stringContaining('Minor'), value: 'minor' }, { title: expect.stringContaining('None'), value: 'none' }, { title: expect.stringContaining('Major'), value: 'major' }, + { title: expect.stringContaining('Prepatch'), value: 'prepatch' }, + { title: expect.stringContaining('Preminor'), value: 'preminor' }, + { title: expect.stringContaining('Premajor'), value: 'premajor' }, ], message: 'Change type', name: 'type', @@ -74,7 +77,9 @@ describe('getQuestionsForPackage', () => { it('errors if there are no valid change types for package', () => { const questions = getQuestionsWrapper({ - packageInfo: { beachball: { disallowedChangeTypes: ['major', 'minor', 'patch', 'none'] } }, + packageInfo: { + beachball: { disallowedChangeTypes: ['major', 'minor', 'patch', 'none', 'premajor', 'preminor', 'prepatch'] }, + }, }); expect(questions).toBeUndefined(); expect(logs.mocks.error).toHaveBeenCalledWith('No valid change types available for package "foo"'); @@ -85,7 +90,15 @@ describe('getQuestionsForPackage', () => { packageInfo: { beachball: { disallowedChangeTypes: ['major'] } }, }); const choices = (questions![0].choices as prompts.Choice[]).map(c => c.value as ChangeType); - expect(choices).toEqual(['patch', 'minor', 'none']); + expect(choices).toEqual(['patch', 'minor', 'none', 'prepatch', 'preminor', 'premajor']); + }); + + it('excludes prerelease bump choices if disallowed', () => { + const questions = getQuestionsWrapper({ + packageInfo: { beachball: { disallowedChangeTypes: ['prepatch', 'preminor', 'premajor'] } }, + }); + const choices = (questions![0].choices as prompts.Choice[]).map(c => c.value as ChangeType); + expect(choices).toEqual(['patch', 'minor', 'none', 'major']); }); it('allows prerelease change for package with prerelease version', () => { @@ -93,7 +106,7 @@ describe('getQuestionsForPackage', () => { packageInfo: { version: '1.0.0-beta.1' }, }); const choices = (questions![0].choices as prompts.Choice[]).map(c => c.value as ChangeType); - expect(choices).toEqual(['prerelease', 'patch', 'minor', 'none', 'major']); + expect(choices).toEqual(['prerelease', 'patch', 'minor', 'none', 'major', 'prepatch', 'preminor', 'premajor']); }); // this is a bit weird as well, but documenting current behavior @@ -102,7 +115,7 @@ describe('getQuestionsForPackage', () => { packageInfo: { version: '1.0.0-beta.1', beachball: { disallowedChangeTypes: ['prerelease'] } }, }); const choices = (questions![0].choices as prompts.Choice[]).map(c => c.value as ChangeType); - expect(choices).toEqual(['patch', 'minor', 'none', 'major']); + expect(choices).toEqual(['patch', 'minor', 'none', 'major', 'prepatch', 'preminor', 'premajor']); }); it('excludes the change type question when options.type is specified', () => { @@ -115,7 +128,9 @@ describe('getQuestionsForPackage', () => { it('excludes the change type question with only one valid option', () => { const questions = getQuestionsWrapper({ - packageInfo: { beachball: { disallowedChangeTypes: ['major', 'minor', 'none'] } }, + packageInfo: { + beachball: { disallowedChangeTypes: ['major', 'minor', 'none', 'premajor', 'preminor', 'prepatch'] }, + }, }); expect(questions).toHaveLength(1); expect(questions![0].name).toBe('comment'); @@ -125,7 +140,7 @@ describe('getQuestionsForPackage', () => { const questions = getQuestionsWrapper({ packageInfo: { version: '1.0.0-beta.1', - beachball: { disallowedChangeTypes: ['major', 'minor', 'patch', 'none'] }, + beachball: { disallowedChangeTypes: ['major', 'minor', 'patch', 'none', 'premajor', 'preminor', 'prepatch'] }, }, }); expect(questions).toHaveLength(1); diff --git a/packages/beachball/src/__tests__/changefile/promptForChange.test.ts b/packages/beachball/src/__tests__/changefile/promptForChange.test.ts index 7f840117b..139989adf 100644 --- a/packages/beachball/src/__tests__/changefile/promptForChange.test.ts +++ b/packages/beachball/src/__tests__/changefile/promptForChange.test.ts @@ -117,7 +117,9 @@ describe('promptForChange', () => { ...defaultParams(), packageInfos: makePackageInfos({ foo: {}, - bar: { beachball: { disallowedChangeTypes: ['major', 'minor', 'patch', 'none'] } }, + bar: { + beachball: { disallowedChangeTypes: ['major', 'minor', 'patch', 'none', 'premajor', 'preminor', 'prepatch'] }, + }, }), }); diff --git a/packages/beachball/src/__tests__/changefile/promptForChange_promptForPackageChange.test.ts b/packages/beachball/src/__tests__/changefile/promptForChange_promptForPackageChange.test.ts index 45c0477b2..eb637576f 100644 --- a/packages/beachball/src/__tests__/changefile/promptForChange_promptForPackageChange.test.ts +++ b/packages/beachball/src/__tests__/changefile/promptForChange_promptForPackageChange.test.ts @@ -88,16 +88,19 @@ describe('promptForChange _promptForPackageChange', () => { expect(logs.getMockLines('log')).toMatchInlineSnapshot(`"Please describe the changes for: foo"`); expect(stdout.getOutput()).toMatchInlineSnapshot(` - "? Change type » - Use arrow-keys. Return to submit. - > Patch - bug fixes; no API changes. - Minor - small feature; backwards compatible API changes. - None - this change does not affect the published package in any way. - Major - major feature; breaking changes. - √ Change type » Patch - bug fixes; no API changes. - ? Describe changes (type or choose one) » - > message - √ Describe changes (type or choose one) » message" - `); + "? Change type » - Use arrow-keys. Return to submit. + > Patch - bug fixes; no API changes. + Minor - small feature; backwards compatible API changes. + None - this change does not affect the published package in any way. + Major - major feature; breaking changes. + Prepatch - start a prerelease patch. + Preminor - start a prerelease minor. + Premajor - start a prerelease major. + √ Change type » Patch - bug fixes; no API changes. + ? Describe changes (type or choose one) » + > message + √ Describe changes (type or choose one) » message" + `); expect(answers).toEqual({ type: 'patch', comment: 'message' }); }); @@ -200,32 +203,41 @@ describe('promptForChange _promptForPackageChange', () => { await stdin.sendByChar('\n'); expect(stdout.getOutput()).toMatchInlineSnapshot(` - "? Change type » - Use arrow-keys. Return to submit. - > Patch - bug fixes; no API changes. - Minor - small feature; backwards compatible API changes. - None - this change does not affect the published package in any way. - Major - major feature; breaking changes. - ? Change type » - Use arrow-keys. Return to submit. - Patch - bug fixes; no API changes. - > Minor - small feature; backwards compatible API changes. - None - this change does not affect the published package in any way. - Major - major feature; breaking changes. - ? Change type » - Use arrow-keys. Return to submit. - Patch - bug fixes; no API changes. - Minor - small feature; backwards compatible API changes. - > None - this change does not affect the published package in any way. - Major - major feature; breaking changes. - √ Change type » None - this change does not affect the published package in any way. - ? Describe changes (type or choose one) » - > first - second - third - ? Describe changes (type or choose one) » - first - > second - third - √ Describe changes (type or choose one) » second" - `); + "? Change type » - Use arrow-keys. Return to submit. + > Patch - bug fixes; no API changes. + Minor - small feature; backwards compatible API changes. + None - this change does not affect the published package in any way. + Major - major feature; breaking changes. + Prepatch - start a prerelease patch. + Preminor - start a prerelease minor. + Premajor - start a prerelease major. + ? Change type » - Use arrow-keys. Return to submit. + Patch - bug fixes; no API changes. + > Minor - small feature; backwards compatible API changes. + None - this change does not affect the published package in any way. + Major - major feature; breaking changes. + Prepatch - start a prerelease patch. + Preminor - start a prerelease minor. + Premajor - start a prerelease major. + ? Change type » - Use arrow-keys. Return to submit. + Patch - bug fixes; no API changes. + Minor - small feature; backwards compatible API changes. + > None - this change does not affect the published package in any way. + Major - major feature; breaking changes. + Prepatch - start a prerelease patch. + Preminor - start a prerelease minor. + Premajor - start a prerelease major. + √ Change type » None - this change does not affect the published package in any way. + ? Describe changes (type or choose one) » + > first + second + third + ? Describe changes (type or choose one) » + first + > second + third + √ Describe changes (type or choose one) » second" + `); const answers = await answerPromise; expect(answers).toEqual({ type: 'none', comment: 'second' }); @@ -314,17 +326,20 @@ describe('promptForChange _promptForPackageChange', () => { `); expect(stdout.getOutput()).toMatchInlineSnapshot(` - "? Change type » - Use arrow-keys. Return to submit. - > Patch - bug fixes; no API changes. - Minor - small feature; backwards compatible API changes. - None - this change does not affect the published package in any way. - Major - major feature; breaking changes. - √ Change type » Patch - bug fixes; no API changes. - ? Describe changes (type or choose one) » - > message - ? Describe changes (type or choose one) » a - × Describe changes (type or choose one) » a" - `); + "? Change type » - Use arrow-keys. Return to submit. + > Patch - bug fixes; no API changes. + Minor - small feature; backwards compatible API changes. + None - this change does not affect the published package in any way. + Major - major feature; breaking changes. + Prepatch - start a prerelease patch. + Preminor - start a prerelease minor. + Premajor - start a prerelease major. + √ Change type » Patch - bug fixes; no API changes. + ? Describe changes (type or choose one) » + > message + ? Describe changes (type or choose one) » a + × Describe changes (type or choose one) » a" + `); expect(answers).toBeUndefined(); }); diff --git a/packages/beachball/src/bump/bumpPackageInfoVersion.ts b/packages/beachball/src/bump/bumpPackageInfoVersion.ts index 94bee41fc..da20b36f2 100644 --- a/packages/beachball/src/bump/bumpPackageInfoVersion.ts +++ b/packages/beachball/src/bump/bumpPackageInfoVersion.ts @@ -25,8 +25,11 @@ export function bumpPackageInfoVersion( console.warn(`Skipping bumping private package "${pkgName}"`); } else { // Ensure we can bump the correct versions + const stableChangeTypes = ['major', 'minor', 'patch']; + const isPrereleaseVersion = !!semver.prerelease(info.version); + const shouldPromoteToStable = isPrereleaseVersion && stableChangeTypes.includes(changeType); const effectiveChangeType = - options.prereleasePrefix && !['premajor', 'preminor', 'prepatch'].includes(changeType) + options.prereleasePrefix && !shouldPromoteToStable && !['premajor', 'preminor', 'prepatch'].includes(changeType) ? 'prerelease' : changeType; diff --git a/packages/beachball/src/changefile/getQuestionsForPackage.ts b/packages/beachball/src/changefile/getQuestionsForPackage.ts index f4a76d9aa..da0783ef1 100644 --- a/packages/beachball/src/changefile/getQuestionsForPackage.ts +++ b/packages/beachball/src/changefile/getQuestionsForPackage.ts @@ -60,6 +60,9 @@ function getChangeTypePrompt(params: { title: ' None - this change does not affect the published package in any way.', }, { value: 'major', title: ' Major - major feature; breaking changes.' }, + { value: 'prepatch', title: ' Prepatch - start a prerelease patch.' }, + { value: 'preminor', title: ' Preminor - start a prerelease minor.' }, + { value: 'premajor', title: ' Premajor - start a prerelease major.' }, ].filter(choice => !disallowedChangeTypes?.includes(choice.value as ChangeType)); if (!changeTypeChoices.length) {