diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000..945844fdf --- /dev/null +++ b/PLAN.md @@ -0,0 +1,83 @@ +# Implementation plan: rename `canary` → `prerelease`, simplify ChangeType + +This document tracked the plan as it was implemented. + +## Decisions (open questions resolved) + +1. `prereleasePrefix` defaults to `"prerelease"` (applied in the `prerelease` + command itself rather than in `getDefaultOptions`, to keep `configList` + output minimal). So `beachball prerelease` works with zero configuration. +2. With `identifierBase: false` and the same target version already published, + `beachball prerelease` errors with a message recommending `identifierBase: '0'`. + +## High-level changes + +- Rename command `canary` → `prerelease`. No alias for `canary`. +- Remove `canaryName` option. `prereleasePrefix` takes over its role (the + suffix used by the prerelease command, e.g. `"beta"`, `"canary"`, `"pr30"`). +- `prereleasePrefix` no longer affects `bump`/`publish`. +- Shrink `ChangeType` to `'patch' | 'minor' | 'major' | 'none'`. +- Migration: change files / CLI types `premajor|preminor|prepatch` are coerced + to `major|minor|patch` with a warning; `prerelease` is a hard error. +- `bump`/`publish` strip an existing prerelease component from + `package.json#version` before applying the change file's bump (assumption E). +- New `getPrereleaseVersion(...)` pure helper; `beachball prerelease` queries + `listPackageVersions` for the highest existing counter. + +## Checklist + +- [x] Update `ChangeType` in `src/types/ChangeInfo.ts` +- [x] Migration logic for old types in `src/changefile/readChangeFiles.ts` + (warn + coerce `pre*`, error on `prerelease`); also handles + `dependentChangeType` +- [x] CLI `--type` and `--dependent-change-type` migration in + `src/options/getCliOptions.ts` (same coercion + error) +- [x] `bumpPackageInfoVersion`: drop `prereleasePrefix`/`identifierBase` params, + strip prerelease component before `semver.inc` (handles assumption E) +- [x] New `src/bump/getPrereleaseVersion.ts` pure helper + unit tests +- [x] Rename `src/commands/canary.ts` → `src/commands/prerelease.ts`; rewrite + to use `listPackageVersions` + `getPrereleaseVersion` +- [x] `src/cli.ts`: replace `canary` case with `prerelease` +- [x] `src/types/BeachballOptions.ts`: drop `canaryName` +- [x] `src/options/getCliOptions.ts`: drop `canaryName`; derive `tag` from + `prereleasePrefix` for the `prerelease` command +- [x] `src/options/getDefaultOptions.ts`: drop `canaryName` default (default + for `prereleasePrefix` is applied at command level, not here) +- [x] `src/commands/configGet.ts`: drop `canaryName: true` +- [x] `src/help.ts`: replace `--canary-name` docs with `prerelease` section; + remove `--prerelease-prefix` from `bump`/`publish` sections +- [x] `src/changefile/getQuestionsForPackage.ts`: drop "Prerelease" choice +- [x] Changelog rendering: drop `pre*`/`prerelease` `groupNames` entries; + prune snapshot +- [x] Update existing tests (configList snapshot, getCliOptions, bumpInMemory, + bumpPackageInfoVersion, changeTypes, getQuestionsForPackage, + updateRelatedChangeType, writeChangelog, e2e bump) +- [x] Add new tests: - `__tests__/bump/getPrereleaseVersion.test.ts` - `__functional__/commands/prerelease.test.ts` - migration tests in `__functional__/changefile/readChangeFiles.test.ts` - legacy `--type` / `--dependent-change-type` tests in + `__functional__/options/getCliOptions.test.ts` +- [x] Docs: create `docs/cli/prerelease.md`; add to sidebar in + `docs/.vuepress/config.ts`; remove `--prerelease-prefix` rows from + `docs/cli/bump.md` and `docs/cli/publish.md`; rewrite the + `prereleasePrefix` row in `docs/overview/configuration.md`; add + "Prereleases" section to `docs/concepts/change-types.md`. Built with + `yarn docs:build`. +- [x] Generate Beachball change file (major) +- [x] Run `yarn build`, `yarn test`, `yarn lint` +- [ ] `parallel_validation` + +## Notes/discoveries + +- The single repo fixture has only the package `foo`; tests use that name. +- `createCommandContext` is `@deprecated` and triggers `etc/no-deprecated`; + command implementations and their tests use + `// eslint-disable-next-line etc/no-deprecated -- ...` to suppress. +- `cliOptions.command === 'prerelease'` overrides `--tag`, mirroring previous + canary behavior. `--prerelease-prefix` outside of the `prerelease` command + does NOT affect tag. +- `bumpInMemory.test.ts` previously had four `prereleasePrefix`-driven + scenarios; all are removed since `bump` no longer produces prereleases. + Replaced with a focused test for prerelease → release promotion. +- `updateRelatedChangeType` tests previously expected `preminor` as a fallback + when `minor` was disallowed; with pre\* types removed, the algorithm now + falls back to `patch` instead. +- `writeChangelog` "includes pre\* changes" tests are obsolete; replaced with + one test exercising `major`/`minor`/`patch` headers. diff --git a/change/beachball-e78cf575-a41a-4ddb-a3ba-ff80340e848a.json b/change/beachball-e78cf575-a41a-4ddb-a3ba-ff80340e848a.json new file mode 100644 index 000000000..81fca68d7 --- /dev/null +++ b/change/beachball-e78cf575-a41a-4ddb-a3ba-ff80340e848a.json @@ -0,0 +1,7 @@ +{ + "packageName": "beachball", + "type": "major", + "dependentChangeType": "patch", + "comment": "Rename `canary` command to `prerelease`, repurpose `prereleasePrefix` (and remove `canaryName`), and shrink `ChangeType` to `patch | minor | major | none`. Old `pre*` change types in change files are auto-coerced (with a warning); `prerelease` change type is now an error. `bump`/`publish` no longer produce prerelease versions and now strip any prerelease component from the current version before bumping (prerelease → release promotion).", + "email": "198982749+Copilot@users.noreply.github.com" +} diff --git a/docs/.vuepress/config.ts b/docs/.vuepress/config.ts index e22415c39..50a4e6170 100644 --- a/docs/.vuepress/config.ts +++ b/docs/.vuepress/config.ts @@ -42,6 +42,7 @@ export default defineUserConfig({ '/cli/change', '/cli/check', '/cli/config', + '/cli/prerelease', '/cli/publish', '/cli/sync', ], diff --git a/docs/cli/bump.md b/docs/cli/bump.md index b3ab65eeb..b4ffdf8a0 100644 --- a/docs/cli/bump.md +++ b/docs/cli/bump.md @@ -20,7 +20,6 @@ $ 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 | diff --git a/docs/cli/prerelease.md b/docs/cli/prerelease.md new file mode 100644 index 000000000..f1f6031bb --- /dev/null +++ b/docs/cli/prerelease.md @@ -0,0 +1,55 @@ +--- +tags: + - cli +category: doc +--- + +# `prerelease` + +Publishes a prerelease version (such as a canary, beta, or per-PR release) for the current change set, **without committing changes back to git or deleting change files**. + +```bash +$ beachball prerelease +``` + +This command is useful for sharing prereleases of in-progress work — for example, a per-PR build that consumers can install to test changes before merging. + +## How it works + +For each package that has a change file (or that becomes modified due to dependency bumps), `beachball prerelease`: + +1. Looks up the change type from the change files (just like `bump` and `publish`). +2. Computes the **target release version** by applying the change type to the current `package.json` version, after stripping any existing prerelease component. (For example, `0.2.0-beta.0` with a `minor` change type produces a target of `0.3.0`.) +3. Queries the npm registry for existing published versions that match `-.`, finds the highest counter `N`, and publishes `-.`. +4. Publishes to npm under the dist-tag matching `prereleasePrefix` (e.g. `beta`). + +If no matching prerelease versions have been published yet, the counter starts from [`identifierBase`](#options). + +The change files and `package.json` versions are **not** committed back to git. This means subsequent prerelease runs from the same change set will produce incrementing prerelease versions — useful for iterating on a PR without polluting git history. + +## Workflow example + +| Step | Command | Result | +| ---- | ---------------------------------------------------------------- | ------------------------------------------------------------------- | +| 1 | A developer creates a change file for a `minor` change to `foo`. | `change/foo-….json` exists; `foo` is at version `1.2.3`. | +| 2 | CI runs `beachball prerelease --prerelease-prefix pr30`. | `foo@1.3.0-pr30.0` is published with dist-tag `pr30`. | +| 3 | The developer pushes another commit to the PR; CI re-runs. | `foo@1.3.0-pr30.1` is published. | +| 4 | The PR is merged; main runs `beachball publish`. | `foo@1.3.0` is published with dist-tag `latest`, normal git commit. | + +Step 4 above demonstrates the **prerelease-to-release promotion** behavior of `bump`/`publish`: when a package's current `package.json` version contains a prerelease component (such as if `prerelease` ran on the same branch as `publish` would later run), the prerelease component is stripped before applying the bump, so the user gets the intuitive target release version. + +## Options + +[General options](./options) also apply for this command. + + +| Option | Default | Description | +| ------ | ------- | ----------- | +| `--prerelease-prefix` | `'prerelease'` | Suffix used for the prerelease version, e.g. `"beta"` produces `1.2.3-beta.0`. The same value is used as the npm dist-tag. | +| `--identifier-base` | `'0'` | Starting counter for prereleases when no matching versions are published yet. `'0'` (default) starts at `.0`, `'1'` starts at `.1`, or `false` omits the numeric counter entirely (in which case re-running on the same target version will error). | + +## Migrating from `beachball canary` + +This command replaces the old `beachball canary` command. The previous `canaryName` option has been removed; use `prereleasePrefix` instead. + +In addition, `bump` and `publish` no longer support producing prerelease versions directly. If you previously used `prereleasePrefix` with `bump`/`publish`, switch to `beachball prerelease` for prerelease publishing. diff --git a/docs/cli/publish.md b/docs/cli/publish.md index a728479f6..1c6192e44 100644 --- a/docs/cli/publish.md +++ b/docs/cli/publish.md @@ -19,7 +19,6 @@ 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 | | `--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..47cf6fd99 100644 --- a/docs/concepts/change-types.md +++ b/docs/concepts/change-types.md @@ -38,6 +38,12 @@ Packages with a major version of 0 are considered unstable per the semver spec. Some repos or packages may restrict which change types are allowed using the [`disallowedChangeTypes`](../overview/configuration#options) config option. For example, `major` bumps are often disallowed to ensure coordination of major release efforts. Any disallowed options will be omitted from the interactive prompt, and a change file or `--type` argument that uses a disallowed type will cause an error. +## Prereleases + +To publish a prerelease version (such as a canary, beta, or per-PR build), use the [`beachball prerelease`](../cli/prerelease) command. There is no `prerelease` change type — instead, choose one of `patch`, `minor`, `major`, or `none` based on the impact of your changes, and let `beachball prerelease` handle the prerelease versioning. + +> **Note:** Older Beachball versions accepted `premajor`, `preminor`, `prepatch`, and `prerelease` as change types. These have been removed; existing change files using `premajor`/`preminor`/`prepatch` are auto-migrated to `major`/`minor`/`patch` (with a deprecation warning), and change files using `prerelease` will produce an error so they can be recreated with the appropriate type. + ## Tips for reviewers Change files show up as part of the PR diff, making it easy to verify the change type during code review. Common things to watch for: diff --git a/docs/overview/configuration.md b/docs/overview/configuration.md index 61402a2e0..57ebe7059 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` | `'prerelease'` | repo | Suffix used by the [`prerelease`](../cli/prerelease) command for prerelease versions, e.g. `"beta"` produces versions like `1.2.3-beta.0`. | | `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/__e2e__/bump.test.ts b/packages/beachball/src/__e2e__/bump.test.ts index bbfb4b34a..641d78dae 100644 --- a/packages/beachball/src/__e2e__/bump.test.ts +++ b/packages/beachball/src/__e2e__/bump.test.ts @@ -405,41 +405,30 @@ describe('bump command', () => { expect(changelogJson3).toBeNull(); }); - // Prerelease scenarios are covered in more detail in bumpInMemory.test.ts - it('bumps to prerelease and uses prerelease version for dependents', async () => { + // The previous prerelease-via-bump scenario was removed when prerelease publishing was moved + // to the dedicated `beachball prerelease` command. The prerelease -> release promotion behavior + // (where bumping a package currently on a prerelease version produces the next release) is + // covered by bumpInMemory.test.ts and bumpPackageInfoVersion.test.ts. + it('promotes a prerelease version to a release version when bumping', async () => { const monorepo: RepoFixture['folders'] = { packages: { - 'pkg-1': { version: '1.0.0' }, - 'pkg-2': { version: '1.0.0', dependencies: { 'pkg-1': '1.0.0' } }, - 'pkg-3': { version: '1.0.0', devDependencies: { 'pkg-2': '1.0.0' } }, - 'pkg-4': { version: '1.0.0', peerDependencies: { 'pkg-3': '1.0.0' } }, - 'pkg-5': { version: '1.0.0', optionalDependencies: { 'pkg-4': '1.0.0' } }, + 'pkg-1': { version: '1.0.0-beta.0' }, + 'pkg-2': { version: '1.0.0', dependencies: { 'pkg-1': '1.0.0-beta.0' } }, }, }; repositoryFactory = new RepositoryFactory({ folders: monorepo }); repo = repositoryFactory.cloneRepository(); - const { options, parsedOptions } = getOptions({ - bumpDeps: true, - prereleasePrefix: 'beta', - }); - generateChangeFiles([{ packageName: 'pkg-1', type: 'prerelease', dependentChangeType: 'prerelease' }], options); + const { options, parsedOptions } = getOptions({ bumpDeps: true }); + generateChangeFiles([{ packageName: 'pkg-1', type: 'patch' }], options); repo.push(); await bumpWrapper(parsedOptions); const packageInfos = getPackageInfos(parsedOptions); - - const newVersion = '1.0.1-beta.0'; - expect(packageInfos['pkg-1'].version).toBe(newVersion); - expect(packageInfos['pkg-2'].version).toBe(newVersion); - expect(packageInfos['pkg-3'].version).toBe(newVersion); - expect(packageInfos['pkg-4'].version).toBe(newVersion); - - expect(packageInfos['pkg-2'].dependencies!['pkg-1']).toBe(newVersion); - expect(packageInfos['pkg-3'].devDependencies!['pkg-2']).toBe(newVersion); - expect(packageInfos['pkg-4'].peerDependencies!['pkg-3']).toBe(newVersion); - expect(packageInfos['pkg-5'].optionalDependencies!['pkg-4']).toBe(newVersion); + expect(packageInfos['pkg-1'].version).toBe('1.0.1'); + expect(packageInfos['pkg-2'].version).toBe('1.0.1'); + expect(packageInfos['pkg-2'].dependencies!['pkg-1']).toBe('1.0.1'); const changeFiles = getChangeFiles(options); expect(changeFiles).toHaveLength(0); diff --git a/packages/beachball/src/__functional__/changefile/readChangeFiles.test.ts b/packages/beachball/src/__functional__/changefile/readChangeFiles.test.ts index 53da7aae7..bc364486a 100644 --- a/packages/beachball/src/__functional__/changefile/readChangeFiles.test.ts +++ b/packages/beachball/src/__functional__/changefile/readChangeFiles.test.ts @@ -188,6 +188,46 @@ describe('readChangeFiles', () => { expect(logs.mocks.warn).not.toHaveBeenCalled(); }); + it.each<['premajor' | 'preminor' | 'prepatch', 'major' | 'minor' | 'patch']>([ + ['premajor', 'major'], + ['preminor', 'minor'], + ['prepatch', 'patch'], + ])('coerces legacy change type %s to %s with a warning', (legacyType, replacement) => { + tempRoot = createTestFileStructureType('monorepo'); + const { options, packageInfos, scopedPackages } = getOptionsAndPackages(); + + generateChangeFiles([{ packageName: 'foo', type: legacyType as 'patch' }], options); + const changeSet = readChangeFiles(options, packageInfos, scopedPackages); + + expect(changeSet).toHaveLength(1); + expect(changeSet[0].change.type).toBe(replacement); + expect(logs.mocks.warn).toHaveBeenCalledWith( + expect.stringContaining(`legacy change type "${legacyType}", which has been renamed to "${replacement}"`) + ); + }); + + it('errors on legacy change type "prerelease"', () => { + tempRoot = createTestFileStructureType('monorepo'); + const { options, packageInfos, scopedPackages } = getOptionsAndPackages(); + + generateChangeFiles([{ packageName: 'foo', type: 'prerelease' as 'patch' }], options); + + expect(() => readChangeFiles(options, packageInfos, scopedPackages)).toThrow( + /change type "prerelease", which is no longer supported/ + ); + }); + + it('coerces legacy dependentChangeType "preminor" to "minor" with a warning', () => { + tempRoot = createTestFileStructureType('monorepo'); + const { options, packageInfos, scopedPackages } = getOptionsAndPackages(); + + generateChangeFiles([{ packageName: 'foo', type: 'minor', dependentChangeType: 'preminor' as 'minor' }], options); + const changeSet = readChangeFiles(options, packageInfos, scopedPackages); + + expect(changeSet[0].change.dependentChangeType).toBe('minor'); + expect(logs.mocks.warn).toHaveBeenCalledWith(expect.stringContaining('legacy dependentChangeType "preminor"')); + }); + it('runs transform.changeFiles functions if provided', () => { const editedComment = 'Edited comment for testing'; tempRoot = createTestFileStructureType('monorepo'); diff --git a/packages/beachball/src/__functional__/changelog/writeChangelog.test.ts b/packages/beachball/src/__functional__/changelog/writeChangelog.test.ts index 1d46ae35e..1efe6a2f5 100644 --- a/packages/beachball/src/__functional__/changelog/writeChangelog.test.ts +++ b/packages/beachball/src/__functional__/changelog/writeChangelog.test.ts @@ -425,16 +425,15 @@ describe('writeChangelog', () => { expect(readChangelogJson(repo.pathTo('packages/foo'))).toBeNull(); }); - it('includes pre* changes', async () => { + it('renders major, minor, and patch changes in the expected sections', async () => { repo = sharedSingleRepo; const { options, packageInfos } = getOptionsAndPackages(); generateChangeFiles( [ - { packageName: 'foo', comment: 'comment 1', type: 'premajor' }, - { packageName: 'foo', comment: 'comment 2', type: 'preminor' }, - { packageName: 'foo', comment: 'comment 3', type: 'prepatch' }, - { packageName: 'foo', comment: 'comment 4', type: 'prerelease' }, + { packageName: 'foo', comment: 'comment 1', type: 'major' }, + { packageName: 'foo', comment: 'comment 2', type: 'minor' }, + { packageName: 'foo', comment: 'comment 3', type: 'patch' }, ], options ); @@ -442,31 +441,9 @@ describe('writeChangelog', () => { await writeChangelogWrapper({ options, packageInfos }); const changelogMd = readChangelogMd(repo.rootPath); - expect(changelogMd).toContain('### Major changes (pre-release)\n\n- comment 1'); - expect(changelogMd).toContain('### Minor changes (pre-release)\n\n- comment 2'); - expect(changelogMd).toContain('### Patches (pre-release)\n\n- comment 3'); - expect(changelogMd).toContain('### Changes\n\n- comment 4'); - }); - - it('includes pre* changes', async () => { - repo = repositoryFactory.cloneRepository(); - const { options, packageInfos } = getOptionsAndPackages(); - - generateChangeFiles( - [ - getChange('foo', 'comment 1', 'premajor'), - getChange('foo', 'comment 2', 'preminor'), - getChange('foo', 'comment 3', 'prepatch'), - ], - options - ); - - await writeChangelogWrapper({ options, packageInfos }); - - const changelogMd = readChangelogMd(repo.rootPath); - expect(changelogMd).toContain('### Major changes (pre-release)\n\n- comment 1'); - expect(changelogMd).toContain('### Minor changes (pre-release)\n\n- comment 2'); - expect(changelogMd).toContain('### Patches (pre-release)\n\n- comment 3'); + expect(changelogMd).toContain('### Major changes\n\n- comment 1'); + expect(changelogMd).toContain('### Minor changes\n\n- comment 2'); + expect(changelogMd).toContain('### Patches\n\n- comment 3'); }); it('writes only CHANGELOG.md if generateChangelog is "md"', async () => { diff --git a/packages/beachball/src/__functional__/commands/prerelease.test.ts b/packages/beachball/src/__functional__/commands/prerelease.test.ts new file mode 100644 index 000000000..d3e4da22c --- /dev/null +++ b/packages/beachball/src/__functional__/commands/prerelease.test.ts @@ -0,0 +1,200 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { generateChangeFiles } from '../../__fixtures__/changeFiles'; +import { defaultRemoteBranchName } from '../../__fixtures__/gitDefaults'; +import { initMockLogs } from '../../__fixtures__/mockLogs'; +import type { Repository } from '../../__fixtures__/repository'; +import { RepositoryFactory } from '../../__fixtures__/repositoryFactory'; +import { prerelease } from '../../commands/prerelease'; +import { createCommandContext } from '../../monorepo/createCommandContext'; +import { getParsedOptions } from '../../options/getOptions'; +import { listPackageVersions } from '../../packageManager/listPackageVersions'; +import { publishToRegistry } from '../../publish/publishToRegistry'; +import type { RepoOptions } from '../../types/BeachballOptions'; +import { performBump } from '../../bump/performBump'; + +// +// These tests focus on the prerelease command. listPackageVersions, publishToRegistry, and +// performBump are mocked so the tests don't hit the network or write to the filesystem. +// +jest.mock('../../packageManager/listPackageVersions'); +jest.mock('../../publish/publishToRegistry'); +jest.mock('../../bump/performBump'); + +describe('prerelease command', () => { + initMockLogs({ mockBeforeEach: true }); + + const mockListPackageVersions = listPackageVersions as jest.MockedFunction; + const mockPublishToRegistry = publishToRegistry as jest.MockedFunction; + const mockPerformBump = performBump as jest.MockedFunction; + + let singleRepoFactory: RepositoryFactory; + let repo: Repository | undefined; + + function getOptionsForPrerelease(repoOptions?: Partial) { + const parsedOptions = getParsedOptions({ + cwd: repo!.rootPath, + argv: ['node', 'beachball', 'prerelease', '--yes'], + env: {}, + testRepoOptions: { + branch: defaultRemoteBranchName, + registry: 'fake', + fetch: false, + push: false, + gitTags: false, + access: 'public', + ...repoOptions, + }, + }); + return parsedOptions.options; + } + + beforeAll(() => { + singleRepoFactory = new RepositoryFactory('single'); + }); + + beforeEach(() => { + mockListPackageVersions.mockImplementation(() => Promise.resolve({})); + mockPublishToRegistry.mockImplementation(() => Promise.resolve()); + mockPerformBump.mockImplementation(() => Promise.resolve()); + }); + + afterEach(() => { + jest.resetAllMocks(); + repo = undefined; + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('computes prerelease version using the default prefix and identifierBase', async () => { + repo = singleRepoFactory.cloneRepository(); + const options = getOptionsForPrerelease(); + generateChangeFiles([{ packageName: 'foo', type: 'patch' }], options); + + // eslint-disable-next-line etc/no-deprecated -- test helper + const context = createCommandContext(options); + await prerelease(options, context); + + expect(mockPerformBump).toHaveBeenCalledTimes(1); + const bumpInfo = mockPerformBump.mock.calls[0][0]; + expect(bumpInfo.packageInfos['foo'].version).toBe('1.0.1-prerelease.0'); + }); + + it('uses the configured prereleasePrefix', async () => { + repo = singleRepoFactory.cloneRepository(); + const options = getOptionsForPrerelease({ prereleasePrefix: 'beta' }); + generateChangeFiles([{ packageName: 'foo', type: 'patch' }], options); + + // eslint-disable-next-line etc/no-deprecated -- test helper + const context = createCommandContext(options); + await prerelease(options, context); + + const bumpInfo = mockPerformBump.mock.calls[0][0]; + expect(bumpInfo.packageInfos['foo'].version).toBe('1.0.1-beta.0'); + }); + + it('strips the prerelease component from the current version (issue #676)', async () => { + // 0.2.0 + minor change should produce 0.3.0-beta.0, not 0.2.1-beta.0. + // This is the fix for https://github.com/microsoft/beachball/issues/676 issue 2. + repo = singleRepoFactory.cloneRepository(); + const options = getOptionsForPrerelease({ prereleasePrefix: 'beta' }); + generateChangeFiles([{ packageName: 'foo', type: 'minor' }], options); + + // eslint-disable-next-line etc/no-deprecated -- test helper + const context = createCommandContext(options); + // Pretend foo is currently on a prerelease. + context.originalPackageInfos['foo'].version = '0.2.0-beta.0'; + await prerelease(options, context); + + const bumpInfo = mockPerformBump.mock.calls[0][0]; + expect(bumpInfo.packageInfos['foo'].version).toBe('0.3.0-beta.0'); + }); + + it('increments the counter when matching versions are already published', async () => { + repo = singleRepoFactory.cloneRepository(); + const options = getOptionsForPrerelease({ prereleasePrefix: 'beta' }); + generateChangeFiles([{ packageName: 'foo', type: 'patch' }], options); + + mockListPackageVersions.mockImplementation(() => Promise.resolve({ foo: ['1.0.1-beta.0', '1.0.1-beta.1'] })); + + // eslint-disable-next-line etc/no-deprecated -- test helper + const context = createCommandContext(options); + await prerelease(options, context); + + const bumpInfo = mockPerformBump.mock.calls[0][0]; + expect(bumpInfo.packageInfos['foo'].version).toBe('1.0.1-beta.2'); + }); + + it('respects identifierBase: "1"', async () => { + repo = singleRepoFactory.cloneRepository(); + const options = getOptionsForPrerelease({ + prereleasePrefix: 'beta', + identifierBase: '1', + }); + generateChangeFiles([{ packageName: 'foo', type: 'patch' }], options); + + // eslint-disable-next-line etc/no-deprecated -- test helper + const context = createCommandContext(options); + await prerelease(options, context); + + const bumpInfo = mockPerformBump.mock.calls[0][0]; + expect(bumpInfo.packageInfos['foo'].version).toBe('1.0.1-beta.1'); + }); + + it('respects identifierBase: false', async () => { + repo = singleRepoFactory.cloneRepository(); + const options = getOptionsForPrerelease({ + prereleasePrefix: 'beta', + identifierBase: false, + }); + generateChangeFiles([{ packageName: 'foo', type: 'patch' }], options); + + // eslint-disable-next-line etc/no-deprecated -- test helper + const context = createCommandContext(options); + await prerelease(options, context); + + const bumpInfo = mockPerformBump.mock.calls[0][0]; + expect(bumpInfo.packageInfos['foo'].version).toBe('1.0.1-beta'); + }); + + it('errors with identifierBase: false if the resulting version is already published', async () => { + repo = singleRepoFactory.cloneRepository(); + const options = getOptionsForPrerelease({ + prereleasePrefix: 'beta', + identifierBase: false, + }); + generateChangeFiles([{ packageName: 'foo', type: 'patch' }], options); + + mockListPackageVersions.mockImplementation(() => Promise.resolve({ foo: ['1.0.1-beta'] })); + + // eslint-disable-next-line etc/no-deprecated -- test helper + const context = createCommandContext(options); + await expect(prerelease(options, context)).rejects.toThrow(/already exists/); + }); + + it('forces keepChangeFiles and disables changelog generation', async () => { + repo = singleRepoFactory.cloneRepository(); + const options = getOptionsForPrerelease(); + generateChangeFiles([{ packageName: 'foo', type: 'patch' }], options); + + // eslint-disable-next-line etc/no-deprecated -- test helper + const context = createCommandContext(options); + await prerelease(options, context); + + expect(options.keepChangeFiles).toBe(true); + expect(options.generateChangelog).toBe(false); + }); + + it('publishes to the registry when publish is true', async () => { + repo = singleRepoFactory.cloneRepository(); + const options = getOptionsForPrerelease({ publish: true }); + generateChangeFiles([{ packageName: 'foo', type: 'patch' }], options); + + // eslint-disable-next-line etc/no-deprecated -- test helper + const context = createCommandContext(options); + await prerelease(options, context); + + expect(mockPublishToRegistry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/beachball/src/__functional__/options/getCliOptions.test.ts b/packages/beachball/src/__functional__/options/getCliOptions.test.ts index 274fbdb58..0ca64cb2c 100644 --- a/packages/beachball/src/__functional__/options/getCliOptions.test.ts +++ b/packages/beachball/src/__functional__/options/getCliOptions.test.ts @@ -138,19 +138,45 @@ describe('getCliOptions', () => { expect(options).toEqual({ ...defaults, configPath: 'path/to/config.json', forceVersions: true, fromRef: 'main' }); }); - it('for canary command, adds canary tag and ignores regular tag', () => { - const options = getCliOptionsTest(['canary', '--tag', 'bar']); - expect(options).toEqual({ ...defaults, command: 'canary', tag: 'canary' }); + it('for prerelease command, defaults tag to "prerelease" and ignores regular tag', () => { + const options = getCliOptionsTest(['prerelease', '--tag', 'bar']); + expect(options).toEqual({ ...defaults, command: 'prerelease', tag: 'prerelease' }); }); - it('for canary command, uses canaryName as tag and ignores regular tag', () => { - const options = getCliOptionsTest(['canary', '--canary-name', 'foo', '--tag', 'bar']); - expect(options).toEqual({ ...defaults, command: 'canary', canaryName: 'foo', tag: 'foo' }); + it('for prerelease command, uses prereleasePrefix as tag and ignores regular tag', () => { + const options = getCliOptionsTest(['prerelease', '--prerelease-prefix', 'foo', '--tag', 'bar']); + expect(options).toEqual({ ...defaults, command: 'prerelease', prereleasePrefix: 'foo', tag: 'foo' }); }); - it('does not set tag to canaryName for non-canary command', () => { - const options = getCliOptionsTest(['publish', '--canary-name', 'foo', '--tag', 'bar']); - expect(options).toEqual({ ...defaults, command: 'publish', canaryName: 'foo', tag: 'bar' }); + it('does not set tag from prereleasePrefix for non-prerelease command', () => { + const options = getCliOptionsTest(['publish', '--prerelease-prefix', 'foo', '--tag', 'bar']); + expect(options).toEqual({ ...defaults, command: 'publish', prereleasePrefix: 'foo', tag: 'bar' }); + }); + + it.each<['premajor' | 'preminor' | 'prepatch', 'major' | 'minor' | 'patch']>([ + ['premajor', 'major'], + ['preminor', 'minor'], + ['prepatch', 'patch'], + ])('coerces legacy --type %s to %s with a warning', (legacyType, replacement) => { + const options = getCliOptionsTest(['change', '--type', legacyType]); + expect(options).toEqual({ ...defaults, type: replacement }); + }); + + it('errors on legacy --type prerelease', () => { + expect(() => getCliOptionsTest(['change', '--type', 'prerelease'])).toThrow( + /--type "prerelease" is no longer supported/ + ); + }); + + it('coerces legacy --dependent-change-type preminor to minor with a warning', () => { + const options = getCliOptionsTest(['change', '--dependent-change-type', 'preminor']); + expect(options).toEqual({ ...defaults, dependentChangeType: 'minor' }); + }); + + it('errors on legacy --dependent-change-type prerelease', () => { + expect(() => getCliOptionsTest(['change', '--dependent-change-type', 'prerelease'])).toThrow( + /--dependent-change-type "prerelease" is no longer supported/ + ); }); it('falls back to given cwd as path if findProjectRoot fails', () => { diff --git a/packages/beachball/src/__tests__/bump/bumpInMemory.test.ts b/packages/beachball/src/__tests__/bump/bumpInMemory.test.ts index 65c9593c4..9b56307e2 100644 --- a/packages/beachball/src/__tests__/bump/bumpInMemory.test.ts +++ b/packages/beachball/src/__tests__/bump/bumpInMemory.test.ts @@ -328,106 +328,25 @@ describe('bumpInMemory', () => { expect(packageInfos['pkg-3']).toEqual({ ...originalPackageInfos['pkg-3'], version: '1.0.1' }); }); - it('bumps to prerelease using prefix, and uses prerelease version for dependents', () => { + it('strips prerelease component from current version before bumping', () => { + // assumption E: `bump`/`publish` no longer support producing prerelease versions. + // If a package is currently on a prerelease (e.g. after `beachball prerelease` ran), + // running `bump` with a change file should produce the corresponding release version, + // not another prerelease. This is the "prerelease -> release promotion" behavior. const { bumpInfo } = gatherBumpInfoWrapper({ packageFolders: { - 'pkg-1': {}, - 'pkg-2': { dependencies: { 'pkg-1': '1.0.0' } }, - 'pkg-3': { peerDependencies: { 'pkg-2': '1.0.0' } }, + 'pkg-1': { version: '1.0.0-beta.0' }, + 'pkg-2': { version: '1.0.0', dependencies: { 'pkg-1': '1.0.0-beta.0' } }, }, - repoOptions: { - bumpDeps: true, - prereleasePrefix: 'beta', - }, - changes: [{ packageName: 'pkg-1', type: 'prerelease' }], - }); - - const { packageInfos, calculatedChangeTypes } = bumpInfo; - // dependents are calculated as patch but later changed to prerelease - expect(calculatedChangeTypes).toEqual({ 'pkg-1': 'prerelease', 'pkg-2': 'patch', 'pkg-3': 'patch' }); - - const newVersion = '1.0.1-beta.0'; - expect(packageInfos['pkg-1'].version).toBe(newVersion); - expect(packageInfos['pkg-2'].version).toBe(newVersion); - expect(packageInfos['pkg-3'].version).toBe(newVersion); - - expect(packageInfos['pkg-2'].dependencies!['pkg-1']).toBe(newVersion); - expect(packageInfos['pkg-3'].peerDependencies!['pkg-2']).toBe(newVersion); - }); - - it('bumps to prerelease and uses the specified identifier base', () => { - const { bumpInfo } = gatherBumpInfoWrapper({ - packageFolders: { - 'pkg-1': {}, - 'pkg-2': { dependencies: { 'pkg-1': '1.0.0' } }, - }, - repoOptions: { - bumpDeps: true, - prereleasePrefix: 'beta', - identifierBase: '1', - }, - changes: [{ packageName: 'pkg-1', type: 'prerelease' }], - }); - - const { packageInfos, calculatedChangeTypes } = bumpInfo; - // dependents are calculated as patch but later changed to prerelease - expect(calculatedChangeTypes).toEqual({ 'pkg-1': 'prerelease', 'pkg-2': 'patch' }); - - const newVersion = '1.0.1-beta.1'; - expect(packageInfos['pkg-1'].version).toBe(newVersion); - expect(packageInfos['pkg-2'].version).toBe(newVersion); - expect(packageInfos['pkg-2'].dependencies!['pkg-1']).toBe(newVersion); - }); - - it('bumps to prerelease with no identifier base', () => { - const { bumpInfo } = gatherBumpInfoWrapper({ - packageFolders: { - 'pkg-1': {}, - 'pkg-2': { dependencies: { 'pkg-1': '1.0.0' } }, - }, - repoOptions: { - bumpDeps: true, - prereleasePrefix: 'beta', - identifierBase: false, - }, - changes: [{ packageName: 'pkg-1', type: 'prerelease' }], + repoOptions: { bumpDeps: true }, + changes: [{ packageName: 'pkg-1', type: 'patch' }], }); const { packageInfos, calculatedChangeTypes } = bumpInfo; - // dependents are calculated as patch but later changed to prerelease - expect(calculatedChangeTypes).toEqual({ 'pkg-1': 'prerelease', 'pkg-2': 'patch' }); - - const newVersion = '1.0.1-beta'; - expect(packageInfos['pkg-1'].version).toBe(newVersion); - expect(packageInfos['pkg-2'].version).toBe(newVersion); - expect(packageInfos['pkg-2'].dependencies!['pkg-1']).toBe(newVersion); - }); - - it('bumps all packages and increments prefixed versions in dependents', () => { - const { bumpInfo } = gatherBumpInfoWrapper({ - packageFolders: { - 'pkg-1': { version: '1.0.1-beta.0' }, - 'pkg-2': { version: '1.0.0', dependencies: { 'pkg-1': '1.0.0' } }, - 'pkg-3': { version: '1.0.0', devDependencies: { 'pkg-2': '1.0.0' } }, - }, - repoOptions: { - bumpDeps: true, - prereleasePrefix: 'beta', - }, - changes: [{ packageName: 'pkg-1', type: 'prerelease', dependentChangeType: 'prerelease' }], - }); - const { packageInfos, calculatedChangeTypes } = bumpInfo; - - expect(calculatedChangeTypes).toEqual({ 'pkg-1': 'prerelease', 'pkg-2': 'prerelease', 'pkg-3': 'prerelease' }); - - const pkg1NewVersion = '1.0.1-beta.1'; - const othersNewVersion = '1.0.1-beta.0'; - expect(packageInfos['pkg-1'].version).toBe(pkg1NewVersion); - expect(packageInfos['pkg-2'].version).toBe(othersNewVersion); - expect(packageInfos['pkg-3'].version).toBe(othersNewVersion); - - expect(packageInfos['pkg-2'].dependencies!['pkg-1']).toBe(pkg1NewVersion); - expect(packageInfos['pkg-3'].devDependencies!['pkg-2']).toBe(othersNewVersion); + expect(calculatedChangeTypes).toEqual({ 'pkg-1': 'patch', 'pkg-2': 'patch' }); + expect(packageInfos['pkg-1'].version).toBe('1.0.1'); + expect(packageInfos['pkg-2'].version).toBe('1.0.1'); + expect(packageInfos['pkg-2'].dependencies!['pkg-1']).toBe('1.0.1'); }); it('does not modify dependency ranges of packages that are not bumped', () => { diff --git a/packages/beachball/src/__tests__/bump/bumpPackageInfoVersion.test.ts b/packages/beachball/src/__tests__/bump/bumpPackageInfoVersion.test.ts index 5cb18072a..8a7072b78 100644 --- a/packages/beachball/src/__tests__/bump/bumpPackageInfoVersion.test.ts +++ b/packages/beachball/src/__tests__/bump/bumpPackageInfoVersion.test.ts @@ -4,7 +4,6 @@ import { makePackageInfos } from '../../__fixtures__/packageInfos'; import type { ChangeType } from '../../types/ChangeInfo'; import { initMockLogs } from '../../__fixtures__/mockLogs'; import type { PackageInfo } from '../../types/PackageInfo'; -import type { BeachballOptions } from '../../types/BeachballOptions'; type PartialBumpInfo = Parameters[1]; @@ -20,7 +19,6 @@ describe('bumpPackageInfoVersion', () => { /** Extra info (defaults to version 1.0.0), or null for nonexistent package */ packageInfo?: Partial | null; changeType: ChangeType | undefined; - options?: Parameters[2]; }) { const { changeType, packageInfo } = params; const bumpInfo: PartialBumpInfo = { @@ -29,7 +27,7 @@ describe('bumpPackageInfoVersion', () => { modifiedPackages: new Set(), }; - bumpPackageInfoVersion(name, bumpInfo, params.options || {}); + bumpPackageInfoVersion(name, bumpInfo); return bumpInfo; } @@ -57,8 +55,6 @@ describe('bumpPackageInfoVersion', () => { it('logs and skips when change type is "none"', () => { const bumpInfo = bumpPackageInfoVersionWrapper({ changeType: 'none', - // prereleasePrefix should be ignored here - options: { prereleasePrefix: 'beta' }, }); expect(logs.mocks.log).toHaveBeenCalledWith('"pkg" has a "none" change type, so no version bump is required.'); expect(bumpInfo.packageInfos[name].version).toBe('1.0.0'); @@ -79,10 +75,6 @@ describe('bumpPackageInfoVersion', () => { ['major', '2.0.0'], ['minor', '1.1.0'], ['patch', '1.0.1'], - ['prerelease', '1.0.1-0'], - ['premajor', '2.0.0-0'], - ['preminor', '1.1.0-0'], - ['prepatch', '1.0.1-0'], ])('bumps %s version', (changeType, expectedVersion) => { const bumpInfo = bumpPackageInfoVersionWrapper({ changeType, @@ -91,66 +83,24 @@ describe('bumpPackageInfoVersion', () => { expect(bumpInfo.modifiedPackages).toContain(name); }); - // This should probably be changed, but documenting it for now - // https://github.com/microsoft/beachball/issues/1098 - it.each(['major', 'minor', 'patch'])( - 'bumps as prerelease when prereleasePrefix is set and changeType is %s', - changeType => { - const bumpInfo = bumpPackageInfoVersionWrapper({ - changeType, - options: { prereleasePrefix: 'beta' }, - }); - expect(bumpInfo.packageInfos[name].version).toBe('1.0.1-beta.0'); - expect(bumpInfo.modifiedPackages).toContain(name); - } - ); - - it.each<[ChangeType, string]>([ - ['prerelease', '1.0.1-beta.0'], - ['premajor', '2.0.0-beta.0'], - ['preminor', '1.1.0-beta.0'], - ['prepatch', '1.0.1-beta.0'], - ])('uses prereleasePrefix for changeType %s', (changeType, expectedVersion) => { + // The publish/bump prerelease -> release promotion case (assumption E in the design): + // If the current version has a prerelease component, it's stripped before applying the + // bump, so the user gets the intuitive "release" version after a prerelease cycle. + it.each<[string, ChangeType, string]>([ + ['1.0.0-beta.0', 'patch', '1.0.1'], + ['1.0.0-beta.0', 'minor', '1.1.0'], + ['1.0.0-beta.0', 'major', '2.0.0'], + ['1.0.1-beta.5', 'patch', '1.0.2'], + ['0.2.0-beta.0', 'minor', '0.3.0'], + ])('strips prerelease component before bumping (%s + %s -> %s)', (currentVersion, changeType, expectedVersion) => { const bumpInfo = bumpPackageInfoVersionWrapper({ changeType, - options: { prereleasePrefix: 'beta' }, - }); - 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', - packageInfo: { version: '1.0.1-beta.2' }, - }); - expect(bumpInfo.packageInfos[name].version).toBe('1.0.1-beta.3'); - expect(bumpInfo.modifiedPackages).toContain(name); - }); - - it.each<[BeachballOptions['identifierBase'], string]>([ - [undefined, '1.0.1-beta.0'], // default - ['0', '1.0.1-beta.0'], - ['1', '1.0.1-beta.1'], - [false, '1.0.1-beta'], // disable numeric identifier - ])('uses identifierBase %s for prerelease', (identifierBase, expectedVersion) => { - const bumpInfo = bumpPackageInfoVersionWrapper({ - changeType: 'prerelease', - options: { identifierBase, prereleasePrefix: 'beta' }, + packageInfo: { version: currentVersion }, }); expect(bumpInfo.packageInfos[name].version).toBe(expectedVersion); expect(bumpInfo.modifiedPackages).toContain(name); }); - it('uses both prereleasePrefix and identifierBase when provided', () => { - const bumpInfo = bumpPackageInfoVersionWrapper({ - changeType: 'prerelease', - options: { prereleasePrefix: 'beta', identifierBase: '1' }, - }); - expect(bumpInfo.packageInfos[name].version).toBe('1.0.1-beta.1'); - expect(bumpInfo.modifiedPackages).toContain(name); - }); - it('warns and skips if change type is not valid semver', () => { const bumpInfo = bumpPackageInfoVersionWrapper({ changeType: 'invalid-type' as ChangeType, @@ -161,27 +111,4 @@ describe('bumpPackageInfoVersion', () => { expect(bumpInfo.packageInfos[name].version).toBe('1.0.0'); expect(bumpInfo.modifiedPackages.size).toBe(0); }); - - it('warns and skips if prereleasePrefix is invalid', () => { - const bumpInfo = bumpPackageInfoVersionWrapper({ - changeType: 'prerelease', - options: { prereleasePrefix: '!!!' }, - }); - expect(logs.mocks.warn).toHaveBeenCalledWith( - 'Invalid version bump requested for "pkg": from version "1.0.0", change type "prerelease", prerelease prefix "!!!"' - ); - expect(bumpInfo.packageInfos[name].version).toBe('1.0.0'); - expect(bumpInfo.modifiedPackages.size).toBe(0); - }); - - // documenting semver package behavior - it('ignores invalid identifierBase', () => { - const bumpInfo = bumpPackageInfoVersionWrapper({ - changeType: 'prerelease', - options: { prereleasePrefix: 'beta', identifierBase: 'nope' as BeachballOptions['identifierBase'] }, - }); - expect(logs.mocks.warn).not.toHaveBeenCalled(); - expect(bumpInfo.packageInfos[name].version).toBe('1.0.1-beta.0'); - expect(bumpInfo.modifiedPackages).toContain(name); - }); }); diff --git a/packages/beachball/src/__tests__/bump/getPrereleaseVersion.test.ts b/packages/beachball/src/__tests__/bump/getPrereleaseVersion.test.ts new file mode 100644 index 000000000..75d048854 --- /dev/null +++ b/packages/beachball/src/__tests__/bump/getPrereleaseVersion.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from '@jest/globals'; +import { getPrereleaseVersion } from '../../bump/getPrereleaseVersion'; + +describe('getPrereleaseVersion', () => { + it('uses identifierBase 0 by default when no matching versions exist', () => { + expect( + getPrereleaseVersion({ + currentVersion: '1.0.0', + changeType: 'patch', + prereleasePrefix: 'beta', + existingVersions: [], + }) + ).toBe('1.0.1-beta.0'); + }); + + it('uses identifierBase "1" when no matching versions exist', () => { + expect( + getPrereleaseVersion({ + currentVersion: '1.0.0', + changeType: 'patch', + prereleasePrefix: 'beta', + identifierBase: '1', + existingVersions: [], + }) + ).toBe('1.0.1-beta.1'); + }); + + it('omits the numeric counter with identifierBase: false', () => { + expect( + getPrereleaseVersion({ + currentVersion: '1.0.0', + changeType: 'patch', + prereleasePrefix: 'beta', + identifierBase: false, + existingVersions: [], + }) + ).toBe('1.0.1-beta'); + }); + + it('errors with identifierBase: false if the resulting version already exists', () => { + expect(() => + getPrereleaseVersion({ + currentVersion: '1.0.0', + changeType: 'patch', + prereleasePrefix: 'beta', + identifierBase: false, + existingVersions: ['1.0.1-beta'], + }) + ).toThrow(/already exists/); + }); + + it.each([ + ['1.0.0', 'patch' as const, '1.0.1-beta.0'], + ['1.0.0', 'minor' as const, '1.1.0-beta.0'], + ['1.0.0', 'major' as const, '2.0.0-beta.0'], + ['1.0.0', 'none' as const, '1.0.0-beta.0'], + ])('applies change type %s to %s -> %s', (currentVersion, changeType, expected) => { + expect( + getPrereleaseVersion({ + currentVersion, + changeType, + prereleasePrefix: 'beta', + existingVersions: [], + }) + ).toBe(expected); + }); + + it('strips an existing prerelease component from the current version before bumping', () => { + // This is the fix for issue #676: 0.2.0-beta.0 + minor change should produce 0.3.0-beta.0, + // not 0.2.1-beta.0 (which is what naive `semver.inc(v, 'minor')` on the prerelease would do). + expect( + getPrereleaseVersion({ + currentVersion: '0.2.0-beta.0', + changeType: 'minor', + prereleasePrefix: 'beta', + existingVersions: [], + }) + ).toBe('0.3.0-beta.0'); + }); + + it('finds the next counter when matching versions exist', () => { + expect( + getPrereleaseVersion({ + currentVersion: '1.0.0', + changeType: 'patch', + prereleasePrefix: 'beta', + existingVersions: ['1.0.1-beta.0', '1.0.1-beta.1', '1.0.1-beta.2'], + }) + ).toBe('1.0.1-beta.3'); + }); + + it('handles non-contiguous existing counters', () => { + expect( + getPrereleaseVersion({ + currentVersion: '1.0.0', + changeType: 'patch', + prereleasePrefix: 'beta', + existingVersions: ['1.0.1-beta.0', '1.0.1-beta.5', '1.0.1-beta.2'], + }) + ).toBe('1.0.1-beta.6'); + }); + + it('ignores existing versions for unrelated targets', () => { + expect( + getPrereleaseVersion({ + currentVersion: '1.0.0', + changeType: 'minor', + prereleasePrefix: 'beta', + existingVersions: ['1.0.1-beta.0', '1.0.1-beta.1', '0.9.0-beta.0'], + }) + ).toBe('1.1.0-beta.0'); + }); + + it('ignores existing versions with a different prefix', () => { + expect( + getPrereleaseVersion({ + currentVersion: '1.0.0', + changeType: 'patch', + prereleasePrefix: 'beta', + existingVersions: ['1.0.1-canary.0', '1.0.1-alpha.5'], + }) + ).toBe('1.0.1-beta.0'); + }); + + it('ignores existing versions with non-numeric or compound suffixes', () => { + expect( + getPrereleaseVersion({ + currentVersion: '1.0.0', + changeType: 'patch', + prereleasePrefix: 'beta', + existingVersions: ['1.0.1-beta', '1.0.1-beta.0.extra', '1.0.1-beta.abc'], + }) + ).toBe('1.0.1-beta.0'); + }); + + it('throws when prereleasePrefix is empty', () => { + expect(() => + getPrereleaseVersion({ + currentVersion: '1.0.0', + changeType: 'patch', + prereleasePrefix: '', + existingVersions: [], + }) + ).toThrow(/prereleasePrefix is required/); + }); +}); diff --git a/packages/beachball/src/__tests__/bump/updateRelatedChangeType.test.ts b/packages/beachball/src/__tests__/bump/updateRelatedChangeType.test.ts index 9b0d50ae7..c1c782130 100644 --- a/packages/beachball/src/__tests__/bump/updateRelatedChangeType.test.ts +++ b/packages/beachball/src/__tests__/bump/updateRelatedChangeType.test.ts @@ -252,11 +252,8 @@ describe('updateRelatedChangeType', () => { expect(bumpInfo.calculatedChangeTypes).toEqual({ bar: 'major', - // This points out an interesting artifact of the new pre* support that should probably be - // better rationalized... (prior to that change, this would have been 'patch', which is - // more likely the expected behavior in general) - // https://github.com/microsoft/beachball/issues/947 - foo: 'preminor', + // With pre* change types removed, disallowing 'minor' falls back to 'patch'. + foo: 'patch', }); }); @@ -274,11 +271,8 @@ describe('updateRelatedChangeType', () => { expect(bumpInfo.calculatedChangeTypes).toEqual({ bar: 'major', - // This points out an interesting artifact of the new pre* support that should probably be - // better rationalized... (prior to that change, this would have been 'patch', which is - // more likely the expected behavior in general) - // https://github.com/microsoft/beachball/issues/947 - foo: 'preminor', + // With pre* change types removed, disallowing 'minor'/'major' falls back to 'patch'. + foo: 'patch', }); }); @@ -419,12 +413,12 @@ describe('updateRelatedChangeType', () => { }, }); - // 'a' gets preminor because minor is disallowed for it. - // 'b' gets minor (not preminor) because the original dependentChangeType propagates, + // 'a' gets patch because minor is disallowed for it. + // 'b' gets minor (not patch) because the original dependentChangeType propagates, // not the downgraded type. expect(bumpInfo.calculatedChangeTypes).toEqual({ dep: 'major', - a: 'preminor', + a: 'patch', b: 'minor', }); }); @@ -448,7 +442,7 @@ describe('updateRelatedChangeType', () => { expect(bumpInfo.calculatedChangeTypes).toEqual({ dep: 'major', foo: 'minor', - bar: 'preminor', + bar: 'patch', }); }); diff --git a/packages/beachball/src/__tests__/changefile/changeTypes.test.ts b/packages/beachball/src/__tests__/changefile/changeTypes.test.ts index ef62c26a4..4bc524f94 100644 --- a/packages/beachball/src/__tests__/changefile/changeTypes.test.ts +++ b/packages/beachball/src/__tests__/changefile/changeTypes.test.ts @@ -21,9 +21,7 @@ describe('getMaxChangeType', () => { expect(getMaxChangeType(['minor', 'patch'], null)).toBe('minor'); expect(getMaxChangeType(['minor', 'major'], null)).toBe('major'); expect(getMaxChangeType(['patch', 'major'], null)).toBe('major'); - expect(getMaxChangeType(['patch', 'prerelease'], null)).toBe('patch'); expect(getMaxChangeType(['patch', 'none'], null)).toBe('patch'); - expect(getMaxChangeType(['prerelease', 'none'], null)).toBe('prerelease'); }); it('handles longer array of changeTypes with max in middle', () => { @@ -32,25 +30,14 @@ describe('getMaxChangeType', () => { }); it('returns none if all given change types are disallowed', () => { - const changeType = getMaxChangeType( - ['patch', 'major'], - ['major', 'minor', 'patch', 'prerelease', 'premajor', 'preminor', 'prepatch'] - ); + const changeType = getMaxChangeType(['patch', 'major'], ['major', 'minor', 'patch']); expect(changeType).toBe('none'); }); it('returns next greatest change type if max is disallowed', () => { - const changeType = getMaxChangeType(['patch', 'major'], ['major', 'premajor', 'preminor', 'prepatch']); + const changeType = getMaxChangeType(['patch', 'major'], ['major']); expect(changeType).toBe('minor'); }); - - it('handles prerelease only case', () => { - const changeType = getMaxChangeType( - ['patch', 'major'], - ['major', 'minor', 'patch', 'premajor', 'preminor', 'prepatch'] - ); - expect(changeType).toBe('prerelease'); - }); }); describe('initializePackageChangeTypes', () => { diff --git a/packages/beachball/src/__tests__/changefile/getQuestionsForPackage.test.ts b/packages/beachball/src/__tests__/changefile/getQuestionsForPackage.test.ts index bb86ae2f1..c9530967c 100644 --- a/packages/beachball/src/__tests__/changefile/getQuestionsForPackage.test.ts +++ b/packages/beachball/src/__tests__/changefile/getQuestionsForPackage.test.ts @@ -88,23 +88,6 @@ describe('getQuestionsForPackage', () => { expect(choices).toEqual(['patch', 'minor', 'none']); }); - it('allows prerelease change for package with prerelease version', () => { - const questions = getQuestionsWrapper({ - 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']); - }); - - // this is a bit weird as well, but documenting current behavior - it('excludes prerelease if disallowed', () => { - const questions = getQuestionsWrapper({ - 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']); - }); - it('excludes the change type question when options.type is specified', () => { const questions = getQuestionsWrapper({ options: { type: 'patch', message: '' }, @@ -121,11 +104,10 @@ describe('getQuestionsForPackage', () => { expect(questions![0].name).toBe('comment'); }); - it('excludes the change type question when prerelease is implicitly the only valid option', () => { + it('excludes the change type question when only "none" is the valid option', () => { const questions = getQuestionsWrapper({ packageInfo: { - version: '1.0.0-beta.1', - beachball: { disallowedChangeTypes: ['major', 'minor', 'patch', 'none'] }, + beachball: { disallowedChangeTypes: ['major', 'minor', 'patch'] }, }, }); expect(questions).toHaveLength(1); diff --git a/packages/beachball/src/__tests__/changelog/__snapshots__/renderPackageChangelog.test.ts.snap b/packages/beachball/src/__tests__/changelog/__snapshots__/renderPackageChangelog.test.ts.snap index 5e174b49b..23412a814 100644 --- a/packages/beachball/src/__tests__/changelog/__snapshots__/renderPackageChangelog.test.ts.snap +++ b/packages/beachball/src/__tests__/changelog/__snapshots__/renderPackageChangelog.test.ts.snap @@ -29,29 +29,13 @@ Thu, 22 Aug 2019 21:20:40 GMT - major change (user1@example.com) -### Major changes (pre-release) - -- premajor change (user1@example.com) - ### Minor changes - minor change (user1@example.com) -### Minor changes (pre-release) - -- preminor change (user1@example.com) - ### Patches -- patch change (user1@example.com) - -### Patches (pre-release) - -- prepatch change (user1@example.com) - -### Changes - -- prerelease change (user1@example.com)" +- patch change (user1@example.com)" `; exports[`changelog renderers - renderPackageChangelog uses custom renderChangeTypeHeader 1`] = ` diff --git a/packages/beachball/src/__tests__/commands/configList.test.ts b/packages/beachball/src/__tests__/commands/configList.test.ts index cedf7f183..d5062f8cb 100644 --- a/packages/beachball/src/__tests__/commands/configList.test.ts +++ b/packages/beachball/src/__tests__/commands/configList.test.ts @@ -53,7 +53,6 @@ describe('configList', () => { branch: "origin/foo" bump: true bumpDeps: true - canaryName: undefined changeDir: "change" changehint: "Run \\"beachball change\\" to create a change file" changelog: diff --git a/packages/beachball/src/bump/bumpInMemory.ts b/packages/beachball/src/bump/bumpInMemory.ts index c912b597d..da809ce1e 100644 --- a/packages/beachball/src/bump/bumpInMemory.ts +++ b/packages/beachball/src/bump/bumpInMemory.ts @@ -66,7 +66,7 @@ export function bumpInMemory(options: BeachballOptions, context: Omit `1.0.1` + * (not `1.0.0`). This corresponds to publishing a release after a prerelease cycle. + * * **This mutates `info.version` and `bumpInfo.modifiedPackages`!** */ export function bumpPackageInfoVersion( pkgName: string, - bumpInfo: Pick, - options: Pick + bumpInfo: Pick ): void { const { calculatedChangeTypes, packageInfos, modifiedPackages } = bumpInfo; const info = packageInfos[pkgName]; @@ -24,35 +27,29 @@ export function bumpPackageInfoVersion( } else if (info.private) { console.warn(`Skipping bumping private package "${pkgName}"`); } else { - // Ensure we can bump the correct versions - const effectiveChangeType = - options.prereleasePrefix && !['premajor', 'preminor', 'prepatch'].includes(changeType) - ? 'prerelease' - : changeType; - - // Attempt to update the version - const newVersion = semver.inc( - info.version, - effectiveChangeType, - undefined, - options.prereleasePrefix || undefined, - options.identifierBase - ); + // If the current version is a prerelease (e.g. "1.0.0-beta.0"), strip the prerelease + // component before applying the bump. This handles the prerelease -> release promotion + // case: `1.0.0-beta.0` with a `patch` change becomes `1.0.1` (not `1.0.0`, which would be + // a downgrade in many situations and is not what users expect after a stabilization cycle). + const baseVersion = stripPrerelease(info.version); + const newVersion = semver.inc(baseVersion, changeType); if (newVersion) { info.version = newVersion; modifiedPackages.add(pkgName); } else { - let message = `Invalid version bump requested for "${pkgName}": from version "${info.version}", change type "${effectiveChangeType}"`; - if (effectiveChangeType.startsWith('pre')) { - if (options.prereleasePrefix) { - message += `, prerelease prefix "${options.prereleasePrefix}"`; - } - if (options.identifierBase) { - message += `, identifier base "${options.identifierBase}"`; - } - } - console.warn(message); + console.warn( + `Invalid version bump requested for "${pkgName}": from version "${info.version}", change type "${changeType}"` + ); } } } + +/** Strip any prerelease component from a semver version. */ +function stripPrerelease(version: string): string { + const parsed = semver.parse(version); + if (!parsed) { + return version; + } + return `${parsed.major}.${parsed.minor}.${parsed.patch}`; +} diff --git a/packages/beachball/src/bump/getPrereleaseVersion.ts b/packages/beachball/src/bump/getPrereleaseVersion.ts new file mode 100644 index 000000000..e083b8025 --- /dev/null +++ b/packages/beachball/src/bump/getPrereleaseVersion.ts @@ -0,0 +1,101 @@ +import semver from 'semver'; +import type { ChangeType } from '../types/ChangeInfo'; +import type { BeachballOptions } from '../types/BeachballOptions'; + +export interface GetPrereleaseVersionParams { + /** The package's current `package.json` version (may be a prerelease). */ + currentVersion: string; + /** The aggregate change type for this package (from change files). */ + changeType: ChangeType; + /** Suffix to use, e.g. `'beta'`, `'canary'`, `'pr30'`. */ + prereleasePrefix: string; + /** + * `'0'` (default) starts at `.0`, `'1'` starts at `.1`, `false` omits the numeric counter. + */ + identifierBase?: BeachballOptions['identifierBase']; + /** Existing published versions of the package, used to find the next available counter. */ + existingVersions: string[]; +} + +/** + * Calculate the next prerelease version for a package, based on its current version, + * the aggregate change type from change files, and the versions already published. + * + * Algorithm: + * 1. Strip any prerelease component from `currentVersion`. + * 2. Apply `changeType` (or pass through for `'none'`) to get the target release version. + * 3. Find the highest counter `N` among published versions matching + * `${target}-${prereleasePrefix}.` and return `${target}-${prereleasePrefix}.`. + * (Or `` from `identifierBase` if no matching versions exist.) + * + * When `identifierBase: false` and the resulting non-numeric version already exists, + * an error is thrown (the caller should suggest using `identifierBase: '0'` instead). + */ +export function getPrereleaseVersion(params: GetPrereleaseVersionParams): string { + const { currentVersion, changeType, prereleasePrefix, identifierBase, existingVersions } = params; + + if (!prereleasePrefix) { + throw new Error('prereleasePrefix is required to compute a prerelease version'); + } + + // Strip any existing prerelease component so the bump operates on the underlying release. + const baseVersion = stripPrerelease(currentVersion); + + // Compute the target release version. For 'none', stay on the current release. + let targetRelease: string; + if (changeType === 'none') { + targetRelease = baseVersion; + } else { + const incremented = semver.inc(baseVersion, changeType); + if (!incremented) { + throw new Error(`Failed to compute target version from "${currentVersion}" with change type "${changeType}"`); + } + targetRelease = incremented; + } + + if (identifierBase === false) { + const candidate = `${targetRelease}-${prereleasePrefix}`; + if (existingVersions.includes(candidate)) { + throw new Error( + `Prerelease version "${candidate}" already exists in the registry. ` + + `Set "identifierBase" to "0" or "1" to enable an auto-incrementing counter for prereleases.` + ); + } + return candidate; + } + + // Find the next counter among existing versions matching `${target}-${prefix}.`. + const prefixWithDot = `${targetRelease}-${prereleasePrefix}.`; + let maxCounter = -1; + for (const existing of existingVersions) { + if (existing.startsWith(prefixWithDot)) { + const rest = existing.slice(prefixWithDot.length); + // Only accept a single numeric component (no further build metadata or extra dots). + if (/^\d+$/.test(rest)) { + const n = parseInt(rest, 10); + if (n > maxCounter) maxCounter = n; + } + } + } + + if (maxCounter === -1) { + // No existing matching prerelease - use the configured starting counter. + const startBase = identifierBase === '1' ? 1 : 0; + return `${targetRelease}-${prereleasePrefix}.${startBase}`; + } + + return `${targetRelease}-${prereleasePrefix}.${maxCounter + 1}`; +} + +/** + * Return the version with any prerelease/build component stripped. + * `1.2.3-beta.4` -> `1.2.3` + * `1.2.3` -> `1.2.3` + */ +function stripPrerelease(version: string): string { + const parsed = semver.parse(version); + if (!parsed) { + throw new Error(`Invalid semver version: "${version}"`); + } + return `${parsed.major}.${parsed.minor}.${parsed.patch}`; +} diff --git a/packages/beachball/src/changefile/changeTypes.ts b/packages/beachball/src/changefile/changeTypes.ts index b6380bce1..70d8079e0 100644 --- a/packages/beachball/src/changefile/changeTypes.ts +++ b/packages/beachball/src/changefile/changeTypes.ts @@ -4,16 +4,17 @@ import type { ChangeSet, ChangeType } from '../types/ChangeInfo'; /** * List of all change types from least to most significant. */ -export const SortedChangeTypes = [ - 'none', - 'prerelease', - 'prepatch', - 'patch', - 'preminor', - 'minor', - 'premajor', - 'major', -] as const satisfies readonly ChangeType[]; +export const SortedChangeTypes = ['none', 'patch', 'minor', 'major'] as const satisfies readonly ChangeType[]; + +/** + * Legacy change types from older Beachball versions, in priority order matching their replacements. + * Used to migrate old change files at read time. + */ +export const LegacyChangeTypeMap = { + prepatch: 'patch', + preminor: 'minor', + premajor: 'major', +} as const satisfies Record; /** `'none'` change type (smallest weight) */ export const MinChangeType: ChangeType = 'none'; diff --git a/packages/beachball/src/changefile/getQuestionsForPackage.ts b/packages/beachball/src/changefile/getQuestionsForPackage.ts index f4a76d9aa..610649480 100644 --- a/packages/beachball/src/changefile/getQuestionsForPackage.ts +++ b/packages/beachball/src/changefile/getQuestionsForPackage.ts @@ -1,5 +1,4 @@ import type prompts from 'prompts'; -import semver from 'semver'; import type { ChangeType } from '../types/ChangeInfo'; import type { BeachballOptions } from '../types/BeachballOptions'; import type { DefaultPrompt } from '../types/ChangeFilePrompt'; @@ -41,7 +40,6 @@ function getChangeTypePrompt(params: { options: Pick; }): (prompts.PromptObject & Required>) | undefined { const { pkg, packageInfos, packageGroups, options } = params; - const packageInfo = packageInfos[pkg]; const disallowedChangeTypes = getDisallowedChangeTypes(pkg, packageInfos, packageGroups, options) || []; @@ -50,9 +48,7 @@ function getChangeTypePrompt(params: { return; } - const showPrereleaseOption = !!semver.prerelease(packageInfo.version); const changeTypeChoices: prompts.Choice[] = [ - ...(showPrereleaseOption ? [{ value: 'prerelease', title: ' Prerelease - bump prerelease version' }] : []), { value: 'patch', title: ' Patch - bug fixes; no API changes.' }, { value: 'minor', title: ' Minor - small feature; backwards compatible API changes.' }, { diff --git a/packages/beachball/src/changefile/readChangeFiles.ts b/packages/beachball/src/changefile/readChangeFiles.ts index 38e8bdb3d..ebdf9da88 100644 --- a/packages/beachball/src/changefile/readChangeFiles.ts +++ b/packages/beachball/src/changefile/readChangeFiles.ts @@ -1,4 +1,5 @@ -import type { ChangeSet, ChangeInfo, ChangeInfoMultiple } from '../types/ChangeInfo'; +import type { ChangeSet, ChangeInfo, ChangeInfoMultiple, ChangeType } from '../types/ChangeInfo'; +import { LegacyChangeTypeMap } from './changeTypes'; import { getChangePath } from '../paths'; import fs from 'fs'; import path from 'path'; @@ -88,6 +89,40 @@ export function readChangeFiles( // Filter the changes from this file for (const change of changes) { + // Migrate legacy change types from older Beachball versions. + // - `pre*` => the stripped equivalent (with a warning) + // - `prerelease` => hard error; the user must recreate the change file + if ((change.type as string) === 'prerelease') { + throw new Error( + `Change file ${changeFilePath} uses change type "prerelease", which is no longer supported. ` + + `Delete this change file and recreate it with a "patch", "minor", "major", or "none" change type. ` + + `To publish a prerelease version, use the "beachball prerelease" command instead.` + ); + } + const legacyReplacement = LegacyChangeTypeMap[change.type as keyof typeof LegacyChangeTypeMap]; + if (legacyReplacement) { + console.warn( + `Change file ${changeFilePath} uses legacy change type "${change.type}", which has been renamed to "${legacyReplacement}". ` + + `The change file will be processed as "${legacyReplacement}". To remove this warning, update the file (or recreate it).` + ); + change.type = legacyReplacement; + } + // Same migration for the dependentChangeType field + if ((change.dependentChangeType as string) === 'prerelease') { + throw new Error( + `Change file ${changeFilePath} uses dependentChangeType "prerelease", which is no longer supported. ` + + `Delete this change file and recreate it with a "patch", "minor", "major", or "none" dependentChangeType.` + ); + } + const legacyDepReplacement = LegacyChangeTypeMap[change.dependentChangeType as keyof typeof LegacyChangeTypeMap]; + if (legacyDepReplacement) { + console.warn( + `Change file ${changeFilePath} uses legacy dependentChangeType "${change.dependentChangeType}", which has been renamed to "${legacyDepReplacement}". ` + + `The change file will be processed as "${legacyDepReplacement}". To remove this warning, update the file (or recreate it).` + ); + change.dependentChangeType = legacyDepReplacement as ChangeType; + } + // Log warnings about change entries for nonexistent and private packages. // (This may happen if a package is renamed or its private flag is changed.) const warningType = !packageInfos[change.packageName] diff --git a/packages/beachball/src/changelog/renderPackageChangelog.ts b/packages/beachball/src/changelog/renderPackageChangelog.ts index bbd3f466a..7368c0a3b 100644 --- a/packages/beachball/src/changelog/renderPackageChangelog.ts +++ b/packages/beachball/src/changelog/renderPackageChangelog.ts @@ -5,16 +5,12 @@ import { SortedChangeTypes } from '../changefile/changeTypes'; const groupNames: { [k in ChangeType]: string } = { major: 'Major changes', - premajor: 'Major changes (pre-release)', minor: 'Minor changes', - preminor: 'Minor changes (pre-release)', patch: 'Patches', - prepatch: 'Patches (pre-release)', - prerelease: 'Changes', none: '', // not used }; -// Skip 'none' changes, then order from major down to prerelease +// Skip 'none' changes, then order from major down to patch const changeTypeOrder = SortedChangeTypes.slice(1).reverse(); export const defaultRenderers: Required = { diff --git a/packages/beachball/src/cli.ts b/packages/beachball/src/cli.ts index 2022795db..5b4066ced 100644 --- a/packages/beachball/src/cli.ts +++ b/packages/beachball/src/cli.ts @@ -1,6 +1,6 @@ import { findGitRoot } from 'workspace-tools'; import { bump } from './commands/bump'; -import { canary } from './commands/canary'; +import { prerelease } from './commands/prerelease'; import { change } from './commands/change'; import { configGet } from './commands/configGet'; import { configList } from './commands/configList'; @@ -61,9 +61,9 @@ import { getPackageGroups } from './monorepo/getPackageGroups'; break; } - case 'canary': { + case 'prerelease': { const { context } = validate(parsedOptions, { checkDependencies: true }); - await canary(options, context); + await prerelease(options, context); break; } diff --git a/packages/beachball/src/commands/canary.ts b/packages/beachball/src/commands/canary.ts deleted file mode 100644 index 0c507b0e2..000000000 --- a/packages/beachball/src/commands/canary.ts +++ /dev/null @@ -1,56 +0,0 @@ -import semver from 'semver'; -import { bumpInMemory } from '../bump/bumpInMemory'; -import { performBump } from '../bump/performBump'; -import { setDependentVersions } from '../bump/setDependentVersions'; -import { listPackageVersions } from '../packageManager/listPackageVersions'; -import { publishToRegistry } from '../publish/publishToRegistry'; -import type { BeachballOptions } from '../types/BeachballOptions'; -import type { CommandContext } from '../types/CommandContext'; -import { createCommandContext } from '../monorepo/createCommandContext'; - -/** - * Bump and publish a "canary" prerelease version. - * @param context Command context from `validate()` - */ -export async function canary(options: BeachballOptions, context: CommandContext): Promise; -/** @deprecated Use other signature */ -export async function canary(options: BeachballOptions): Promise; -export async function canary(options: BeachballOptions, context?: CommandContext): Promise { - // eslint-disable-next-line etc/no-deprecated -- compat code - context ??= createCommandContext(options); - - const bumpInfo = context.bumpInfo || bumpInMemory(options, context); - const { originalPackageInfos } = context; - - options.keepChangeFiles = true; - options.generateChangelog = false; - - if (options.all) { - for (const pkg of Object.keys(originalPackageInfos)) { - bumpInfo.modifiedPackages.add(pkg); - } - } - - const packageVersions = await listPackageVersions([...bumpInfo.modifiedPackages], options); - - for (const pkg of bumpInfo.modifiedPackages) { - let newVersion = originalPackageInfos[pkg].version; - - do { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - newVersion = semver.inc(newVersion, 'prerelease', options.canaryName || 'canary')!; - } while (packageVersions[pkg].includes(newVersion)); - - bumpInfo.packageInfos[pkg].version = newVersion; - } - - setDependentVersions({ bumpInfo, options }); - - await performBump(bumpInfo, options); - - if (options.publish || options.packToPath) { - await publishToRegistry(bumpInfo, options); - } else { - console.log('Skipping publish'); - } -} diff --git a/packages/beachball/src/commands/configGet.ts b/packages/beachball/src/commands/configGet.ts index 1aee04752..831dc1088 100644 --- a/packages/beachball/src/commands/configGet.ts +++ b/packages/beachball/src/commands/configGet.ts @@ -21,7 +21,6 @@ const repoOptionKeys: Record = { branch: true, bump: true, bumpDeps: true, - canaryName: true, changeFilePrompt: true, changehint: true, changeDir: true, diff --git a/packages/beachball/src/commands/prerelease.ts b/packages/beachball/src/commands/prerelease.ts new file mode 100644 index 000000000..195427fd8 --- /dev/null +++ b/packages/beachball/src/commands/prerelease.ts @@ -0,0 +1,69 @@ +import { bumpInMemory } from '../bump/bumpInMemory'; +import { getPrereleaseVersion } from '../bump/getPrereleaseVersion'; +import { performBump } from '../bump/performBump'; +import { setDependentVersions } from '../bump/setDependentVersions'; +import { listPackageVersions } from '../packageManager/listPackageVersions'; +import { publishToRegistry } from '../publish/publishToRegistry'; +import type { BeachballOptions } from '../types/BeachballOptions'; +import type { CommandContext } from '../types/CommandContext'; +import { createCommandContext } from '../monorepo/createCommandContext'; + +/** + * Bump and publish a prerelease version (e.g. for canary, beta, or per-PR releases). + * + * For each modified package, the new version is computed as + * `${target}-${prereleasePrefix}.`, where `target` is the result of applying the change + * type from change files to the current release version (with any prerelease component + * stripped), and `` is the next available counter according to existing published + * versions. + * + * Unlike `bump`/`publish`, this command does not commit changes back to git or delete + * change files. + */ +export async function prerelease(options: BeachballOptions, context: CommandContext): Promise; +/** @deprecated Use other signature */ +export async function prerelease(options: BeachballOptions): Promise; +export async function prerelease(options: BeachballOptions, context?: CommandContext): Promise { + // eslint-disable-next-line etc/no-deprecated -- compat code + context ??= createCommandContext(options); + + // Default the suffix to "prerelease" so the command works with zero configuration. + const prereleasePrefix = options.prereleasePrefix || 'prerelease'; + + const bumpInfo = context.bumpInfo || bumpInMemory(options, context); + const { originalPackageInfos } = context; + + options.keepChangeFiles = true; + options.generateChangelog = false; + + if (options.all) { + for (const pkg of Object.keys(originalPackageInfos)) { + bumpInfo.modifiedPackages.add(pkg); + } + } + + const packageVersions = await listPackageVersions([...bumpInfo.modifiedPackages], options); + + for (const pkg of bumpInfo.modifiedPackages) { + const currentVersion = originalPackageInfos[pkg].version; + const changeType = bumpInfo.calculatedChangeTypes[pkg] || 'none'; + + bumpInfo.packageInfos[pkg].version = getPrereleaseVersion({ + currentVersion, + changeType, + prereleasePrefix, + identifierBase: options.identifierBase, + existingVersions: packageVersions[pkg] || [], + }); + } + + setDependentVersions({ bumpInfo, options }); + + await performBump(bumpInfo, options); + + if (options.publish || options.packToPath) { + await publishToRegistry(bumpInfo, options); + } else { + console.log('Skipping publish'); + } +} diff --git a/packages/beachball/src/help.ts b/packages/beachball/src/help.ts index ee3c265b5..32a07ff19 100644 --- a/packages/beachball/src/help.ts +++ b/packages/beachball/src/help.ts @@ -27,6 +27,8 @@ Commands: bump - bumps versions as well as generating changelogs publish - bumps, publishes to npm registry (optionally does dist-tags), and pushes changelogs back into the default branch + prerelease - publishes a prerelease for the current change set without + committing back to git (e.g. for canary, beta, or per-PR releases) sync - synchronize published versions of packages from the registry with local package.json versions config get - get the value of a config setting (with any overrides) @@ -63,7 +65,6 @@ Options supported by all commands except 'config': 'bump' options: --keep-change-files - don't delete the change files from disk after bumping - --prerelease-prefix - prerelease prefix for packages that will receive a prerelease bump 'publish' options: @@ -80,6 +81,13 @@ Options supported by all commands except 'config': --tag, -t - dist-tag for npm publishes (default: "latest") --yes, -y - skip the confirmation prompts +'prerelease' options: + + --prerelease-prefix - suffix used for the prerelease version (e.g. "beta" produces + versions like 1.2.3-beta.0). Default: "prerelease" + --identifier-base - "0" (default) or "1" for the starting prerelease counter, + or "false" to omit the numeric counter + 'sync' options: --registry, -r - registry (default https://registry.npmjs.org) diff --git a/packages/beachball/src/options/getCliOptions.ts b/packages/beachball/src/options/getCliOptions.ts index ddc040b14..700f02212 100644 --- a/packages/beachball/src/options/getCliOptions.ts +++ b/packages/beachball/src/options/getCliOptions.ts @@ -45,7 +45,6 @@ const stringOptions = [ 'access', 'authType', 'branch', - 'canaryName', 'changehint', 'changeDir', 'configPath', @@ -178,8 +177,34 @@ export function getCliOptions(processOrArgv: ProcessInfo | string[]): ParsedOpti : getDefaultRemoteBranch({ branch: branchArg, verbose: args.verbose as boolean | undefined, cwd }); } - if (cliOptions.command === 'canary') { - cliOptions.tag = cliOptions.canaryName || 'canary'; + if (cliOptions.command === 'prerelease') { + cliOptions.tag = cliOptions.prereleasePrefix || 'prerelease'; + } + + // Migrate legacy `--type` and `--dependent-change-type` values from older Beachball versions. + // (Same migration is applied to change files in `readChangeFiles.ts`.) + for (const optName of ['type', 'dependentChangeType'] as const) { + const optValue = cliOptions[optName] as string | undefined | null; + if (optValue === 'prerelease') { + throw new Error( + `--${optName === 'type' ? 'type' : 'dependent-change-type'} "prerelease" is no longer supported. ` + + `Use "patch", "minor", "major", or "none" instead. To publish a prerelease version, use "beachball prerelease".` + ); + } + const legacyMap: Record = { + prepatch: 'patch', + preminor: 'minor', + premajor: 'major', + }; + if (optValue && legacyMap[optValue]) { + const replacement = legacyMap[optValue]; + console.warn( + `--${ + optName === 'type' ? 'type' : 'dependent-change-type' + } "${optValue}" is deprecated; using "${replacement}" instead.` + ); + cliOptions[optName] = replacement; + } } for (const key of Object.keys(cliOptions) as (keyof CliOptions)[]) { diff --git a/packages/beachball/src/options/getDefaultOptions.ts b/packages/beachball/src/options/getDefaultOptions.ts index f8e6090bd..0ef295e44 100644 --- a/packages/beachball/src/options/getDefaultOptions.ts +++ b/packages/beachball/src/options/getDefaultOptions.ts @@ -12,7 +12,6 @@ export function getDefaultOptions(): BeachballOptions { branch: 'origin/master', bump: true, bumpDeps: true, - canaryName: undefined, changehint: 'Run "beachball change" to create a change file', changeDir: 'change', command: 'change', diff --git a/packages/beachball/src/types/BeachballOptions.ts b/packages/beachball/src/types/BeachballOptions.ts index 89afd6b0c..7f32aff3b 100644 --- a/packages/beachball/src/types/BeachballOptions.ts +++ b/packages/beachball/src/types/BeachballOptions.ts @@ -23,7 +23,6 @@ export interface CliOptions | 'branch' | 'bump' | 'bumpDeps' - | 'canaryName' | 'changehint' | 'changeDir' | 'commit' @@ -115,7 +114,6 @@ export interface RepoOptions { * @default true */ bumpDeps: boolean; - canaryName?: string; /** Options for customizing change file prompt. */ changeFilePrompt?: ChangeFilePromptOptions; /** @@ -210,7 +208,10 @@ export interface RepoOptions { * In tests which don't use the filesystem, this may be an empty string or fake path. */ path: string; - /** Prerelease prefix for packages that are specified to receive a prerelease bump */ + /** + * Suffix used by the `prerelease` command for prerelease versions, e.g. `"beta"` -> `1.2.3-beta.0`. + * @default 'prerelease' + */ prereleasePrefix?: string | null; /** * This is for prerelease. Set it to "0" for zero-based or "1" for one-based. diff --git a/packages/beachball/src/types/ChangeInfo.ts b/packages/beachball/src/types/ChangeInfo.ts index 3122a6faf..b4ae15181 100644 --- a/packages/beachball/src/types/ChangeInfo.ts +++ b/packages/beachball/src/types/ChangeInfo.ts @@ -1,4 +1,10 @@ -export type ChangeType = 'prerelease' | 'prepatch' | 'patch' | 'preminor' | 'minor' | 'premajor' | 'major' | 'none'; +export type ChangeType = 'patch' | 'minor' | 'major' | 'none'; + +/** + * Legacy change types that have been removed (mapped to their replacements at read time). + * `prerelease` is no longer accepted. `pre*` types are coerced to their stripped equivalents. + */ +export type LegacyChangeType = 'prerelease' | 'prepatch' | 'preminor' | 'premajor'; /** * Info saved in each change file.