From 3a3dde3dc6c337e0c79472aae317273feb75957d Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Wed, 6 Mar 2024 17:50:59 -0800 Subject: [PATCH] Add --dry-run option --- .vscode/launch.json | 3 +- ...-8d4c287a-d12c-44c1-867c-47032836dde4.json | 7 ++ docs/cli/options.md | 31 ++++---- docs/cli/publish.md | 9 +++ src/__e2e__/publishE2E.test.ts | 54 ++++++++++--- src/__fixtures__/mockNpm.test.ts | 13 +++ src/__fixtures__/mockNpm.ts | 36 +++++++-- src/__fixtures__/registry.ts | 2 +- .../packageManager/packagePublish.test.ts | 22 ++++++ src/__tests__/packageManager/npmArgs.test.ts | 5 ++ src/__tests__/publish/tagPackages.test.ts | 79 +++++++++---------- src/commands/publish.ts | 40 ++++++---- src/options/getCliOptions.ts | 1 + src/packageManager/npmArgs.ts | 3 +- src/publish/bumpAndPush.ts | 14 +++- src/publish/tagPackages.ts | 22 ++++-- src/types/BeachballOptions.ts | 1 + src/types/NpmOptions.ts | 2 +- 18 files changed, 243 insertions(+), 101 deletions(-) create mode 100644 change/beachball-8d4c287a-d12c-44c1-867c-47032836dde4.json diff --git a/.vscode/launch.json b/.vscode/launch.json index 8327d709c..946f1b0ad 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,7 +14,8 @@ "args": ["--", "--runInBand", "--watch", "--testTimeout=1000000", "${fileBasenameNoExtension}"], "sourceMaps": true, "outputCapture": "std", - "console": "integratedTerminal" + "console": "integratedTerminal", + "runtimeVersion": "14.20.0" }, { "type": "node", diff --git a/change/beachball-8d4c287a-d12c-44c1-867c-47032836dde4.json b/change/beachball-8d4c287a-d12c-44c1-867c-47032836dde4.json new file mode 100644 index 000000000..0aeb6d384 --- /dev/null +++ b/change/beachball-8d4c287a-d12c-44c1-867c-47032836dde4.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Add --dry-run option", + "packageName": "beachball", + "email": "elcraig@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/docs/cli/options.md b/docs/cli/options.md index ad947fef4..2bcc28852 100644 --- a/docs/cli/options.md +++ b/docs/cli/options.md @@ -26,18 +26,19 @@ See the [`change` page](./change). These options are applicable for the `publish` command, as well as `bump` and/or `canary` in some cases. -| Option | Alias | Default | Description | -| ----------------------------- | ----- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `--authType` | `-a` | `'authtoken'` | type of token argument, affecting how it is applied to npm commands. | -| `--message` | `-m` | `'applying package updates'` | custom message for the checkin | -| `--git-tags`, `--no-git-tags` | | `true` (`--git-tags`) | whether to create git tags for published packages | -| `--publish`, `--no-publish` | | `true` (`--publish`) | whether to publish to the npm registry | -| `--push`, `--no-push` | | `true` (`--push`) | whether to push changes back to git remote origin | -| `--prerelease-prefix` | | | prerelease prefix for packages that are specified to receive a prerelease bump (`--prerelease-prefix beta` makes the `x.y.z-beta` version) | -| `--registry` | `-r` | `'https://registry.npmjs.org'` | npm registry for publishing | -| `--retries` | | `3` | number of retries for a package publish before failing | -| `--tag` | `-t` | `'latest'` | dist-tag for npm publishes | -| `--token` | `-n` | | credential to use with npm commands. its type is specified with the `--authType` argument | -| `--verbose` | | `false` | prints additional information to the console | -| `--yes` | `-y` | if CI detected, `true` | skips the prompts for publish | -| `--new` | | `false` | publishes new packages if not in registry | +| Option | Alias | Default | Description | +| ----------------------------- | ----- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | +| `--authType` | `-a` | `'authtoken'` | type of token argument, affecting how it is applied to npm commands. | +| `--message` | `-m` | `'applying package updates'` | custom message for the checkin | +| `--git-tags`, `--no-git-tags` | | `true` (`--git-tags`) | whether to create git tags for published packages | +| `--publish`, `--no-publish` | | `true` (`--publish`) | whether to publish to the npm registry | +| `--push`, `--no-push` | | `true` (`--push`) | whether to push changes back to git remote origin | +| `--dry-run` | | | do a dry run of publishing: locally make all changes, run `npm publish --dry-run`, and commit (no tags), but don't push | +| `--prerelease-prefix` | | | prerelease prefix for packages that should receive a prerelease bump (`--prerelease-prefix beta` makes the `x.y.z-beta` version) | +| `--registry` | `-r` | `'https://registry.npmjs.org'` | npm registry for publishing | +| `--retries` | | `3` | number of retries for a package publish before failing | +| `--tag` | `-t` | `'latest'` | dist-tag for npm publishes | +| `--token` | `-n` | | credential to use with npm commands. its type is specified with the `--authType` argument | +| `--verbose` | | `false` | prints additional information to the console | +| `--yes` | `-y` | if CI detected, `true` | skips the prompts for publish | +| `--new` | | `false` | publishes new packages if not in registry | diff --git a/docs/cli/publish.md b/docs/cli/publish.md index 02699bff8..e627c390a 100644 --- a/docs/cli/publish.md +++ b/docs/cli/publish.md @@ -37,6 +37,15 @@ The `publish` command is designed to run steps in an order that minimizes the ch It might be surprising that `beachball publish` does so many steps, especially the step about reverting changes! In most version bumping systems that automate syncing the git repo and npm registry, they assume that the source code is still fresh once it's time to push changes back to the git repository. This is rarely the case for large repos with many developers. So, `beachball` fetches the latest changes before pushing back to the target branch to avoid merge conflicts. +### Dry run + +If you'd like to do a dry run of publishing, the `--dry-run` option works as follows: + +1. Makes all changes locally +2. Runs `npm publish --dry-run` (skipped if the `publish` option is disabled) +3. Commits the changes locally and merges them into the target branch, but does _not_ tag or push (skipped if the `bump` or `push` option is disabled) +4. Stays on the current branch (and doesn't delete the publish branch) so you can inspect changes + ### Example CI workflow See the [CI integration page](../concepts/ci-integration) details and examples for how to run `beachball publish` in CI. diff --git a/src/__e2e__/publishE2E.test.ts b/src/__e2e__/publishE2E.test.ts index 88f7d030c..04e0ed555 100644 --- a/src/__e2e__/publishE2E.test.ts +++ b/src/__e2e__/publishE2E.test.ts @@ -12,6 +12,7 @@ import { publish } from '../commands/publish'; import { getDefaultOptions } from '../options/getDefaultOptions'; import { BeachballOptions } from '../types/BeachballOptions'; import { initNpmMock } from '../__fixtures__/mockNpm'; +import { getPackageInfos } from '../monorepo/getPackageInfos'; // Spawning actual npm to run commands against a fake registry is extremely slow, so mock it for // this test (packagePublish covers the more complete npm registry scenario). @@ -52,7 +53,7 @@ describe('publish command (e2e)', () => { } }); - it('can perform a successful npm publish', async () => { + it('publishes a single package', async () => { repositoryFactory = new RepositoryFactory('single'); const repo = repositoryFactory.cloneRepository(); @@ -73,7 +74,7 @@ describe('publish command (e2e)', () => { expect(repo.getCurrentTags()).toEqual(['foo_v1.1.0']); }); - it('can perform a successful npm publish in detached HEAD', async () => { + it('publishes from detached HEAD', async () => { repositoryFactory = new RepositoryFactory('single'); const repo = repositoryFactory.cloneRepository(); @@ -92,7 +93,7 @@ describe('publish command (e2e)', () => { }); }); - it('can perform a successful npm publish from a race condition', async () => { + it('publishes from a race condition', async () => { repositoryFactory = new RepositoryFactory('single'); const repo = repositoryFactory.cloneRepository(); @@ -132,7 +133,7 @@ describe('publish command (e2e)', () => { expect(fetchCount).toBe(2); }); - it('can perform a successful npm publish from a race condition in the dependencies', async () => { + it('publishes from a race condition in the dependencies', async () => { repositoryFactory = new RepositoryFactory('single'); const repo = repositoryFactory.cloneRepository(); @@ -179,7 +180,7 @@ describe('publish command (e2e)', () => { expect(contents.dependencies.baz).toBeUndefined(); }); - it('can perform a successful npm publish without bump', async () => { + it('publishes without bump', async () => { repositoryFactory = new RepositoryFactory('single'); const repo = repositoryFactory.cloneRepository(); @@ -285,7 +286,7 @@ describe('publish command (e2e)', () => { expect(repo.getCurrentTags()).toEqual(['bar_v1.3.4', 'foo_v1.1.0']); }); - it('should not perform npm publish on out-of-scope package', async () => { + it("doesn't publish an out-of-scope package", async () => { repositoryFactory = new RepositoryFactory('monorepo'); const repo = repositoryFactory.cloneRepository(); @@ -311,7 +312,7 @@ describe('publish command (e2e)', () => { expect(repo.getCurrentTags()).toEqual(['bar_v1.4.0']); }); - it('should respect prepublish hooks', async () => { + it('respects prepublish hooks', async () => { repositoryFactory = new RepositoryFactory('monorepo'); const repo = repositoryFactory.cloneRepository(); @@ -352,7 +353,7 @@ describe('publish command (e2e)', () => { expect(fooPackageJson.onPublish.main).toBe('lib/index.js'); }); - it('should respect postpublish hooks', async () => { + it('respects postpublish hooks', async () => { repositoryFactory = new RepositoryFactory('monorepo'); const repo = repositoryFactory.cloneRepository(); let notified; @@ -409,7 +410,7 @@ describe('publish command (e2e)', () => { expect(fetchCount).toBe(0); }); - it('should specify fetch depth when depth param is defined', async () => { + it('specifies fetch depth when depth param is defined', async () => { repositoryFactory = new RepositoryFactory('single'); const repo = repositoryFactory.cloneRepository(); @@ -465,4 +466,39 @@ describe('publish command (e2e)', () => { const manifestJson = fs.readFileSync(repo.pathTo('foo.txt')); expect(manifestJson.toString()).toMatchInlineSnapshot(`"foo"`); }); + + it.only('does a dry run if requested', async () => { + repositoryFactory = new RepositoryFactory('monorepo'); + const repo = repositoryFactory.cloneRepository(); + + // bump baz => dependent bump bar => dependent bump foo + generateChangeFiles(['baz'], repo.rootPath); + expect(repositoryFactory.fixture.folders!.packages.foo.dependencies!.bar).toBeTruthy(); + expect(repositoryFactory.fixture.folders!.packages.bar.dependencies!.baz).toBeTruthy(); + + repo.push(); + + await publish({ ...getOptions(repo), dryRun: true }); + + // not published to registry + await npmShow('baz', { shouldFail: true }); + await npmShow('bar', { shouldFail: true }); + await npmShow('foo', { shouldFail: true }); + + // versions are bumped locally + let packageInfos = getPackageInfos(repo.rootPath); + expect(packageInfos.foo.version).toEqual('1.0.1'); + expect(packageInfos.bar.version).toEqual('1.3.5'); + expect(packageInfos.baz.version).toEqual('1.4.0'); + + repo.checkout(defaultBranchName); + repo.pull(); + // no tags created + expect(repo.getCurrentTags()).toEqual([]); + // versions haven't been updated in remote + packageInfos = getPackageInfos(repo.rootPath); + expect(packageInfos.foo.version).toEqual('1.0.0'); + expect(packageInfos.bar.version).toEqual('1.3.4'); + expect(packageInfos.baz.version).toEqual('1.3.4'); + }); }); diff --git a/src/__fixtures__/mockNpm.test.ts b/src/__fixtures__/mockNpm.test.ts index 52a2e1f1e..e2d4c80cd 100644 --- a/src/__fixtures__/mockNpm.test.ts +++ b/src/__fixtures__/mockNpm.test.ts @@ -1,6 +1,8 @@ +// // The npm test fixture got complicated enough to need tests... // But this added complexity greatly speeds up the other npm-related tests by removing the // dependency on actual npm CLI calls and a fake registry (which are very slow). +// import { afterAll, afterEach, beforeAll, describe, expect, it, jest } from '@jest/globals'; import fs from 'fs-extra'; @@ -271,6 +273,17 @@ describe('_mockNpmPublish', () => { }, }); }); + + it('does a dry run', () => { + const data = _makeRegistryData({}); + packageJson = { name: 'foo', version: '1.0.0', main: 'index.js' }; + + const result = _mockNpmPublish(data, ['--dry-run'], { cwd: 'fake' }); + // logs like it published + expect(result).toEqual(getPublishResult({ tag: 'latest' })); + // doesn't actually publish + expect(data).toEqual({}); + }); }); describe('mockNpm', () => { diff --git a/src/__fixtures__/mockNpm.ts b/src/__fixtures__/mockNpm.ts index 0e014fa45..f63dbe94a 100644 --- a/src/__fixtures__/mockNpm.ts +++ b/src/__fixtures__/mockNpm.ts @@ -105,7 +105,7 @@ export function initNpmMock(): NpmMock { return { mock: npmMock, publishPackage: (packageJson, tag = 'latest') => { - mockPublishPackage(registryData, packageJson, tag); + mockPublishPackage({ registryData, packageJson, tag }); }, setCommandOverride: (command, override) => { overrideMocks[command] = override; @@ -186,20 +186,34 @@ export const _mockNpmShow: MockNpmCommand = (registryData, args) => { return { stdout, stderr: '', all: stdout, success: true, failed: false } as NpmResult; }; +const supportedOptions = ['--registry', '--dry-run', '--tag', '--loglevel', '--access']; + /** (exported for testing) Mock npm publish to the registry data */ export const _mockNpmPublish: MockNpmCommand = (registryData, args: string[], opts: Parameters[1]) => { if (!opts?.cwd) { throw new Error('cwd is required for mock npm publish'); } + // Verify we're not using the mock on unexpected new args, which might require new behavior + // (ignore the --//some.registry arg) + const unsupportedOptions = args.filter(arg => /^--[^/]/.test(arg) && !supportedOptions.includes(arg)); + if (unsupportedOptions.length) { + // If this happens, add handling for the new option if needed, and add it to supportedOptions + throw new Error( + 'mock npm publish was called with unexpected options (which may require new handling): ' + + unsupportedOptions.join(', ') + ); + } + // Read package.json from cwd to find the published package name and version. // (If this fails, let the exception propagate for easier debugging.) const packageJson = fs.readJsonSync(path.join(opts.cwd, 'package.json')) as PackageJson; const tag = args.includes('--tag') ? args[args.indexOf('--tag') + 1] : 'latest'; + const dryRun = args.includes('--dry-run'); try { - const stdout = mockPublishPackage(registryData, packageJson, tag); + const stdout = mockPublishPackage({ registryData, packageJson, tag, dryRun }); return { stdout, stderr: '', all: stdout, success: true, failed: false }; } catch (err) { const stderr = (err as Error).message; @@ -208,7 +222,13 @@ export const _mockNpmPublish: MockNpmCommand = (registryData, args: string[], op }; /** Publish a new package version to the mock registry */ -function mockPublishPackage(registryData: MockNpmRegistry, packageJson: PackageJson, tag: string) { +function mockPublishPackage(params: { + registryData: MockNpmRegistry; + packageJson: PackageJson; + tag: string; + dryRun?: boolean; +}) { + const { registryData, packageJson, tag, dryRun } = params; const { name, version } = packageJson; if (registryData[name]?.versions?.includes(version)) { @@ -216,10 +236,12 @@ function mockPublishPackage(registryData: MockNpmRegistry, packageJson: PackageJ throw new Error(`[fake] EPUBLISHCONFLICT ${name}@${version} already exists in registry`); } - registryData[name] ??= { versions: [], 'dist-tags': {}, versionData: {} }; - registryData[name].versions.push(version); - registryData[name]['dist-tags'][tag] = version; - registryData[name].versionData[version] = packageJson; + if (!dryRun) { + registryData[name] ??= { versions: [], 'dist-tags': {}, versionData: {} }; + registryData[name].versions.push(version); + registryData[name]['dist-tags'][tag] = version; + registryData[name].versionData[version] = packageJson; + } return `[fake] published ${name}@${version} with tag ${tag}`; } diff --git a/src/__fixtures__/registry.ts b/src/__fixtures__/registry.ts index cfe9bfab0..eee466771 100644 --- a/src/__fixtures__/registry.ts +++ b/src/__fixtures__/registry.ts @@ -101,7 +101,7 @@ export class Registry { this.server.stderr.on('data', data => { const dataStr = String(data); - if (!dataStr.includes('Debugger attached')) { + if (!dataStr.toLowerCase().includes('debugger')) { reject(new Error(dataStr)); } }); diff --git a/src/__functional__/packageManager/packagePublish.test.ts b/src/__functional__/packageManager/packagePublish.test.ts index 152df1427..7dbf365b9 100644 --- a/src/__functional__/packageManager/packagePublish.test.ts +++ b/src/__functional__/packageManager/packagePublish.test.ts @@ -99,6 +99,28 @@ describe('packagePublish', () => { }); }); + it('does not publish if dryRun is specified', async () => { + // This might be redundant to test, but we want to be very sure we're passing the right option + // to npm here to avoid accidental publishing. + await registry.reset(); + const testPackageInfo = getTestPackageInfo(); + const publishResult = await packagePublish(testPackageInfo, { + dryRun: true, + registry: registry.getUrl(), + retries: 2, + }); + expect(publishResult).toEqual(successResult); + expect(npmSpy).toHaveBeenCalledTimes(1); + + const allLogs = logs.getMockLines('all'); + expect(allLogs).toMatch(`Publishing - ${testSpec} with tag ${testTag}`); + expect(allLogs).toMatch('publish command:'); + expect(allLogs).toMatch(`[log] Published!`); + + // version shouldn't exist + await npmShow(testName, { registry, shouldFail: true }); + }); + it('errors and does not retry on republish', async () => { // Use real npm for this because the republish detection relies on the real error message await registry.reset(); diff --git a/src/__tests__/packageManager/npmArgs.test.ts b/src/__tests__/packageManager/npmArgs.test.ts index e98859d75..ce3863ed4 100644 --- a/src/__tests__/packageManager/npmArgs.test.ts +++ b/src/__tests__/packageManager/npmArgs.test.ts @@ -77,4 +77,9 @@ describe('getNpmPublishArgs', () => { const args = getNpmPublishArgs(packageInfos.basic, { ...options, token: 'testToken' }).join(' '); expect(args).toMatch('--//testRegistry:_authToken=testToken'); }); + + it('does dry run if specified', () => { + const args = getNpmPublishArgs(packageInfos.basic, { ...options, dryRun: true }).join(' '); + expect(args).toMatch('--dry-run'); + }); }); diff --git a/src/__tests__/publish/tagPackages.test.ts b/src/__tests__/publish/tagPackages.test.ts index 965328b14..700b6a44c 100644 --- a/src/__tests__/publish/tagPackages.test.ts +++ b/src/__tests__/publish/tagPackages.test.ts @@ -6,6 +6,7 @@ import { tagPackages } from '../../publish/tagPackages'; import { generateTag } from '../../git/generateTag'; import { BumpInfo } from '../../types/BumpInfo'; import { makePackageInfos } from '../../__fixtures__/packageInfos'; +import { BeachballOptions } from '../../types/BeachballOptions'; jest.mock('workspace-tools', () => ({ gitFailFast: jest.fn(), @@ -15,42 +16,26 @@ const createTagParameters = (tag: string, cwd: string) => { return [['tag', '-a', '-f', tag, '-m', tag], { cwd }]; }; -const noTagBumpInfo: Partial = { - calculatedChangeTypes: { - foo: 'minor', - bar: 'major', - }, - packageInfos: makePackageInfos({ - foo: { - version: '1.0.0', - combinedOptions: { gitTags: false }, +const makeBumpInfo = (packageOptions: Record<'foo' | 'bar', Partial>): BumpInfo => { + const bumpInfo: Partial = { + calculatedChangeTypes: { + foo: 'minor', + bar: 'major', }, - bar: { - version: '1.0.1', - combinedOptions: { gitTags: false }, - }, - }), - modifiedPackages: new Set(['foo', 'bar']), - newPackages: new Set(), -}; - -const oneTagBumpInfo: Partial = { - calculatedChangeTypes: { - foo: 'minor', - bar: 'major', - }, - packageInfos: makePackageInfos({ - foo: { - version: '1.0.0', - combinedOptions: { gitTags: true }, - }, - bar: { - version: '1.0.1', - combinedOptions: { gitTags: false }, - }, - }), - modifiedPackages: new Set(['foo', 'bar']), - newPackages: new Set(), + packageInfos: makePackageInfos({ + foo: { + version: '1.0.0', + combinedOptions: packageOptions.foo, + }, + bar: { + version: '1.0.1', + combinedOptions: packageOptions.bar, + }, + }), + modifiedPackages: new Set(['foo', 'bar']), + newPackages: new Set(), + }; + return bumpInfo as BumpInfo; }; const emptyBumpInfo: Partial = { @@ -61,7 +46,7 @@ const emptyBumpInfo: Partial = { }; describe('tagPackages', () => { - initMockLogs(); + const logs = initMockLogs(); beforeEach(() => { (gitFailFast as Mock).mockReset(); @@ -72,17 +57,19 @@ describe('tagPackages', () => { }); it('does not create tag for packages with gitTags=false', () => { + const bumpInfo = makeBumpInfo({ foo: { gitTags: false }, bar: { gitTags: false } }); // Also verifies that if `gitTags` is false overall, it doesn't create a git tag for the dist tag (`tag`) - tagPackages(noTagBumpInfo as BumpInfo, { path: '', gitTags: false, tag: '' }); + tagPackages(bumpInfo, { path: '', gitTags: false, tag: '' }); expect(gitFailFast).not.toHaveBeenCalled(); }); it('creates tag for packages with gitTags=true', () => { - tagPackages(oneTagBumpInfo as BumpInfo, { path: '', gitTags: false, tag: '' }); + const bumpInfo = makeBumpInfo({ foo: { gitTags: true }, bar: { gitTags: false } }); + tagPackages(bumpInfo, { path: '', gitTags: false, tag: '' }); expect(gitFailFast).toHaveBeenCalledTimes(1); - // verify git is being called to create new auto tag for foo and bar - const newFooTag = generateTag('foo', oneTagBumpInfo.packageInfos!['foo'].version); + // verify git is being called to create new auto tag for foo + const newFooTag = generateTag('foo', bumpInfo.packageInfos!['foo'].version); expect(gitFailFast).toHaveBeenCalledWith(...createTagParameters(newFooTag, '')); }); @@ -101,4 +88,16 @@ describe('tagPackages', () => { expect(gitFailFast).toBeCalledTimes(1); expect(gitFailFast).toHaveBeenCalledWith(...createTagParameters('abc', '')); }); + + it('does not create tags with dryRun=true', () => { + // In dry run mode, it just logs the tags that would be created + const bumpInfo = makeBumpInfo({ foo: { gitTags: true }, bar: { gitTags: true } }); + tagPackages(bumpInfo, { path: '', dryRun: true, gitTags: true, tag: 'foo' }); + expect(gitFailFast).not.toHaveBeenCalled(); + expect(logs.getMockLines('log').split('\n')).toEqual([ + 'Would tag - foo@1.0.0', + 'Would tag - bar@1.0.1', + 'Would tag - foo', + ]); + }); }); diff --git a/src/commands/publish.ts b/src/commands/publish.ts index 8a3cd1184..08e23151a 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -21,21 +21,27 @@ export async function publish(options: BeachballOptions): Promise { return; } // Collate the changes per package - const currentBranch = getBranchName(cwd); - const currentHash = getCurrentHash(cwd); + const startingBranch = getBranchName(cwd); + const startingHash = getCurrentHash(cwd); - console.log(`\nPublishing with the following configuration: + console.log(`\nPublishing ${options.dryRun ? 'dry run ' : ''}with the following configuration: registry: ${registry} - current branch: ${currentBranch} - current hash: ${currentHash} + current branch: ${startingBranch} + current hash: ${startingHash} target branch: ${branch} tag: ${tag} bumps versions: ${options.bump ? 'yes' : 'no'} - publishes to npm registry: ${options.publish ? 'yes' : 'no'} - pushes to remote git repo: ${options.bump && options.push && options.branch ? 'yes' : 'no'} + publishes to npm registry: ${options.dryRun ? 'dry run' : options.publish ? 'yes' : 'no'} + pushes to remote git repo: ${ + options.bump && options.push && options.branch + ? options.dryRun + ? "commits changes but doesn't push" + : 'yes' + : 'no' + } `); @@ -83,21 +89,25 @@ export async function publish(options: BeachballOptions): Promise { console.log('Skipping git push and tagging'); } + if (options.dryRun) { + console.log('\nDry run complete (skipping cleanup so you can inspect the results)\n'); + return; + } + // Step 3. // Clean up: switch back to current branch, delete publish branch console.log('\nCleaning up'); - const revParseSuccessful = currentBranch || currentHash; - if (currentBranch && currentBranch !== 'HEAD') { - console.log(`git checkout ${currentBranch}`); - gitFailFast(['checkout', currentBranch], { cwd }); - } else if (currentHash) { + if (startingBranch && startingBranch !== 'HEAD') { + console.log(`git checkout ${startingBranch}`); + gitFailFast(['checkout', startingBranch], { cwd }); + } else if (startingHash) { console.log(`Looks like the repo was detached from a branch`); - console.log(`git checkout ${currentHash}`); - gitFailFast(['checkout', currentHash], { cwd }); + console.log(`git checkout ${startingHash}`); + gitFailFast(['checkout', startingHash], { cwd }); } - if (revParseSuccessful) { + if (startingBranch || startingHash) { console.log(`deleting temporary publish branch ${publishBranch}`); const deletionResult = git(['branch', '-D', publishBranch], { cwd }); if (!deletionResult.success) { diff --git a/src/options/getCliOptions.ts b/src/options/getCliOptions.ts index dbd5330b9..26d453c13 100644 --- a/src/options/getCliOptions.ts +++ b/src/options/getCliOptions.ts @@ -10,6 +10,7 @@ const booleanOptions = [ 'bump', 'bumpDeps', 'commit', + 'dryRun', 'disallowDeletedChangeFiles', 'fetch', 'forceVersions', diff --git a/src/packageManager/npmArgs.ts b/src/packageManager/npmArgs.ts index f1ed3bc1c..98a9ed32b 100644 --- a/src/packageManager/npmArgs.ts +++ b/src/packageManager/npmArgs.ts @@ -3,10 +3,11 @@ import { NpmOptions } from '../types/NpmOptions'; import { PackageInfo } from '../types/PackageInfo'; export function getNpmPublishArgs(packageInfo: PackageInfo, options: NpmOptions): string[] { - const { registry, token, authType, access } = options; + const { registry, token, authType, access, dryRun } = options; const pkgCombinedOptions = packageInfo.combinedOptions; const args = [ 'publish', + ...(dryRun ? ['--dry-run'] : []), '--registry', registry, '--tag', diff --git a/src/publish/bumpAndPush.ts b/src/publish/bumpAndPush.ts index 8d281cae9..455e275f8 100644 --- a/src/publish/bumpAndPush.ts +++ b/src/publish/bumpAndPush.ts @@ -63,13 +63,19 @@ export async function bumpAndPush(bumpInfo: BumpInfo, publishBranch: string, opt console.log('\nCreating git tags for new versions...'); tagPackages(bumpInfo, options); + // prepare to push (skip for dry run) + const pushArgs = ['push', '--no-verify', '--follow-tags', '--verbose', remote, `HEAD:${remoteBranch}`]; + if (options.dryRun) { + console.log(`Skipping pushing to ${branch} for dry run`); + console.log(`Would have run: git ${pushArgs.join(' ')}`); + completed = true; + continue; + } + // push console.log(`\nPushing to ${branch}...`); - const pushResult = await gitAsync( - ['push', '--no-verify', '--follow-tags', '--verbose', remote, `HEAD:${remoteBranch}`], - { cwd, verbose, timeout: gitTimeout } - ); + const pushResult = await gitAsync(pushArgs, { cwd, verbose, timeout: gitTimeout }); if (pushResult.success) { completed = true; } else if (pushResult.timedOut) { diff --git a/src/publish/tagPackages.ts b/src/publish/tagPackages.ts index 8f233a6b3..bdd00c108 100644 --- a/src/publish/tagPackages.ts +++ b/src/publish/tagPackages.ts @@ -12,9 +12,13 @@ function createTag(tag: string, cwd: string): void { * Also, if git tags aren't disabled for the repo and the overall dist-tag (`options.tag`) has a * non-default value (not "latest"), create a git tag for the dist-tag. */ -export function tagPackages(bumpInfo: BumpInfo, options: Pick): void { - const { gitTags, tag: distTag, path: cwd } = options; +export function tagPackages( + bumpInfo: BumpInfo, + options: Pick +): void { + const { gitTags, tag: distTag, path: cwd, dryRun } = options; const { modifiedPackages, newPackages } = bumpInfo; + const tagVerb = dryRun ? 'Would tag' : 'Tagging'; for (const pkg of [...modifiedPackages, ...newPackages]) { const packageInfo = bumpInfo.packageInfos[pkg]; @@ -23,13 +27,17 @@ export function tagPackages(bumpInfo: BumpInfo, options: Pick> & - Partial>; + Partial>;