Skip to content

Commit

Permalink
Add --dry-run option
Browse files Browse the repository at this point in the history
  • Loading branch information
ecraig12345 committed Mar 7, 2024
1 parent dbc7282 commit 3a3dde3
Show file tree
Hide file tree
Showing 18 changed files with 243 additions and 101 deletions.
3 changes: 2 additions & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"args": ["--", "--runInBand", "--watch", "--testTimeout=1000000", "${fileBasenameNoExtension}"],
"sourceMaps": true,
"outputCapture": "std",
"console": "integratedTerminal"
"console": "integratedTerminal",
"runtimeVersion": "14.20.0"
},
{
"type": "node",
Expand Down
7 changes: 7 additions & 0 deletions change/beachball-8d4c287a-d12c-44c1-867c-47032836dde4.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Add --dry-run option",
"packageName": "beachball",
"email": "[email protected]",
"dependentChangeType": "patch"
}
31 changes: 16 additions & 15 deletions docs/cli/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
9 changes: 9 additions & 0 deletions docs/cli/publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
54 changes: 45 additions & 9 deletions src/__e2e__/publishE2E.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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();

Expand All @@ -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();

Expand All @@ -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();

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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();

Expand All @@ -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();

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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');
});
});
13 changes: 13 additions & 0 deletions src/__fixtures__/mockNpm.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
36 changes: 29 additions & 7 deletions src/__fixtures__/mockNpm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<typeof npm>[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;
Expand All @@ -208,18 +222,26 @@ 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)) {
// note that EPUBLISHCONFLICT matches the actual npm output, but the rest of the message is different
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}`;
}
2 changes: 1 addition & 1 deletion src/__fixtures__/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
});
Expand Down
22 changes: 22 additions & 0 deletions src/__functional__/packageManager/packagePublish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions src/__tests__/packageManager/npmArgs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Loading

0 comments on commit 3a3dde3

Please sign in to comment.