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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ permissions: {}
jobs:
build:
strategy:
# Sometimes it can be helpful to see if a failure is specific to a particular environment
fail-fast: false
matrix:
include:
- os: ubuntu-latest
Expand Down Expand Up @@ -72,8 +74,31 @@ jobs:
if: matrix.os == 'ubuntu-latest' && matrix.node == 14 && matrix.npm == 8

- run: yarn lint:ci
if: matrix.os == 'ubuntu-latest' && matrix.node == 14 && matrix.npm == 8

- run: yarn test --verbose
id: test

# See packageManager.ts comments...
- name: test publish (Windows bash)
if: matrix.os == 'windows-latest'
shell: bash
run: yarn workspace beachball test packagePublish

# For a few specific test failures, it can be helpful to see npm debug logs
# (usually not relevant, but there's not a good way to distinguish by specific failure)
- name: (on test failure) Get npm cache path
if: failure() && steps.test.outcome == 'failure'
shell: bash
run: echo "NPM_CACHE_DIR=$(npm config get cache)" >> "$GITHUB_ENV"

- name: (on test failure) Upload npm logs as artifact
if: failure() && steps.test.outcome == 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: npm-logs-${{ matrix.os }}-node${{ matrix.node }}-npm${{ matrix.npm }}
path: ${{ env.NPM_CACHE_DIR }}/_logs
retention-days: 3

# The docs have a separate installation using Node 22 due to needing newer dependencies
docs:
Expand Down
6 changes: 3 additions & 3 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
"type": "node",
"request": "launch",
"name": "Debug current open test",
"runtimeExecutable": "npm",
"cwd": "${workspaceFolder}",
"runtimeArgs": ["run-script", "test"],
"runtimeExecutable": null,
"program": "${workspaceRoot}/scripts/debugTests.js",
"cwd": "${fileDirname}",
"args": ["--", "--runInBand", "--watch", "--testTimeout=1000000", "${file}"],
"sourceMaps": true,
"outputCapture": "std",
Expand Down
11 changes: 11 additions & 0 deletions change/change-7ef9ca8e-5091-4353-8feb-04a6da4310d3.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"type": "minor",
"comment": "Add support for providing npm token via NPM_TOKEN environment variable, and internally pass token to npm using an environment variable",
"packageName": "beachball",
"email": "elcraig@microsoft.com",
"dependentChangeType": "patch"
}
]
}
17 changes: 15 additions & 2 deletions docs/cli/publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Publishing automates all the bumping and synchronizing of package versions in th
<!-- prettier-ignore -->
| Option | Alias | Default | Description |
| ------ | ----- | ------- | ----------- |
| `--auth-type` | `-a` | `'authtoken'` | npm auth type: `'authtoken'` or `'password'` |
| `--auth-type` | `-a` | `'authtoken'` | npm auth type for `NPM_TOKEN` or `--token`: `'authtoken'` or `'password'` |
| `--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 |
Expand All @@ -25,10 +25,23 @@ Publishing automates all the bumping and synchronizing of package versions in th
| `--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 (type is specified by `--auth-type`) - locally, use `npm login` instead, or see [alternatives for CI](../concepts/ci-integration) |
| `--token` | `-n` | | Not recommended; see alternatives below |
| `--verbose` | | `false` | prints additional information to the console |
| `--yes` | `-y` | if CI detected, `true` | skips the prompts for publish |

#### Providing a token

There are a few different ways to handle npm authentication for `beachball publish`.

In CI, you should use [trusted publishing](https://docs.npmjs.com/trusted-publishers) if supported to remove the need for tokens. Unfortunately this isn't available in Azure DevOps.

If trusted publishing is unavailable or you're running `beachball` locally, you can do any of the following:

- Set the `NPM_TOKEN` environment variable (`beachball` passes this through to `npm`)
- Run `npm login` first (or a task which does the same)
- Manually set the token in [`.npmrc`](https://docs.npmjs.com/cli/v11/configuring-npm/npmrc#auth-related-configuration), possibly referencing an environment variable
- Old way: use `--token <token>` on the command line (not recommended)

### Algorithm

The `publish` command is designed to run steps in an order that minimizes the chances of mid-publish failure by doing validation upfront.
Expand Down
19 changes: 14 additions & 5 deletions docs/concepts/ci-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ There are a couple of options here:

## Setting options for publishing

### Providing an npm token

As mentioned above, if possible you should use [trusted publishing](https://docs.npmjs.com/trusted-publishers) to remove the need for tokens.

Other options:

- Set the `NPM_TOKEN` environment variable while running `beachball`
- Run `npm login` first (or a task which does the same)
- Manually set the token in [`.npmrc`](https://docs.npmjs.com/cli/v11/configuring-npm/npmrc#auth-related-configuration), possibly referencing an environment variable
- Old way: use `--token <token>` on the command line (not recommended)

### Other options

If you're passing any custom options besides the npm token to `beachball publish`, it's recommended to set them in either the `beachball` config (if they don't interfere with other commands), or a `package.json` script (if specific to `publish`).

For example, the following script could be used for publishing public scoped packages (`access` is also safe to set in the beachball config):
Expand Down Expand Up @@ -152,10 +165,6 @@ This sample assumes the following:
- `REPO_PAT`: A GitHub fine-grained personal access token with write access ([as described above](#github-token))
- `NPM_TOKEN`: An npm token with write access to the package(s) and/or scope(s), such as a [fine-grained token for public npm](#npm-token)
- A repo root `package.json` script `release` which runs `beachball publish`
- A `.npmrc` file with the following content (change the registry if needed):
```txt
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
```

```yml
# Add trigger configuration of your choice (this one is manual only)
Expand Down Expand Up @@ -184,7 +193,7 @@ steps:

- script: npm run release
name: Publish
# This works because .npmrc references NPM_TOKEN
# Beachball will use this environment variable
env:
NPM_TOKEN: $(NPM_TOKEN)
```
Expand Down
1 change: 1 addition & 0 deletions packages/beachball/src/__e2e__/bump.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ describe('bump command', () => {
const parsedOptions = getParsedOptions({
cwd: cwd || repo?.rootPath || '',
argv: [],
env: {},
testRepoOptions: { branch: defaultRemoteBranchName, fetch: false, ...repoOptions },
});
return { options: parsedOptions.options, parsedOptions };
Expand Down
1 change: 1 addition & 0 deletions packages/beachball/src/__e2e__/change.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ describe('change command', () => {
const parsedOptions = getParsedOptions({
cwd: repo!.rootPath,
argv: ['node', 'beachball', 'change', ...(extraArgv ?? [])],
env: {},
testRepoOptions: {
branch: defaultRemoteBranchName,
...repoOptions,
Expand Down
2 changes: 2 additions & 0 deletions packages/beachball/src/__e2e__/getChangedPackages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe('getChangedPackages (basic)', () => {
const parsedOptions = getParsedOptions({
cwd: reusedRepo.rootPath,
argv: [],
env: {},
testRepoOptions: {
fetch: false,
branch: defaultRemoteBranchName,
Expand Down Expand Up @@ -127,6 +128,7 @@ describe('getChangedPackages', () => {
const parsedOptions = getParsedOptions({
cwd,
argv: ['node', 'beachball', 'change', ...extraArgv],
env: {},
testRepoOptions: {
branch: defaultRemoteBranchName,
fetch: false,
Expand Down
1 change: 1 addition & 0 deletions packages/beachball/src/__e2e__/publishE2E.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ describe('publish command (e2e)', () => {
const parsedOptions = getParsedOptions({
cwd: repo!.rootPath,
argv: ['node', 'beachball', 'publish', '--yes', ...(extraArgv || [])],
env: {},
testRepoOptions: {
branch: defaultRemoteBranchName,
registry: 'fake',
Expand Down
1 change: 1 addition & 0 deletions packages/beachball/src/__e2e__/publishGit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe('publish command (git)', () => {
const parsedOptions = getParsedOptions({
cwd,
argv: ['node', 'beachball', 'publish', '--yes'],
env: {},
testRepoOptions: {
branch: defaultRemoteBranchName,
registry: 'http://localhost:99999/',
Expand Down
1 change: 1 addition & 0 deletions packages/beachball/src/__e2e__/publishNpm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe('publish command (npm)', () => {
const parsedOptions = getParsedOptions({
cwd: repo!.rootPath,
argv: ['node', 'beachball', 'publish', '--yes'],
env: {},
testRepoOptions: {
branch: defaultRemoteBranchName,
registry: 'fake',
Expand Down
3 changes: 2 additions & 1 deletion packages/beachball/src/__e2e__/syncE2E.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,15 @@ describe('sync command (e2e)', () => {
const publishOptions: Parameters<typeof packagePublish>[1] = {
registry: 'fake',
retries: 3,
path: undefined,
path: '',
npmReadConcurrency: 2,
};

function getOptionsAndContext(repoOptions?: Partial<RepoOptions>, extraArgv: string[] = []) {
const parsedOptions = getParsedOptions({
cwd: repo!.rootPath,
argv: ['node', 'beachball', 'sync', ...extraArgv],
env: {},
testRepoOptions: {
...publishOptions,
branch: defaultRemoteBranchName,
Expand Down
1 change: 1 addition & 0 deletions packages/beachball/src/__e2e__/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe('validate', () => {
const parsedOptions = getParsedOptions({
cwd: repo!.rootPath,
argv: [],
env: {},
testRepoOptions: {
branch: defaultRemoteBranchName,
},
Expand Down
50 changes: 23 additions & 27 deletions packages/beachball/src/__fixtures__/mockNpm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,50 +134,48 @@ describe('_mockNpmShow', () => {

it("errors if package doesn't exist", async () => {
const emptyData = _makeRegistryData({});
const result = await _mockNpmShow(emptyData, ['foo'], { cwd: undefined });
const result = await _mockNpmShow(emptyData, ['foo'], { cwd: '' });
expect(result).toEqual(getErrorResult('[fake] code E404 - foo - not found'));
});

it('returns requested version plus dist-tags and version list', async () => {
const result = await _mockNpmShow(data, ['foo@1.0.0'], { cwd: undefined });
const result = await _mockNpmShow(data, ['foo@1.0.0'], { cwd: '' });
expect(result).toEqual(getShowResult({ name: 'foo', version: '1.0.0' }));
});

it('returns requested version of scoped package', async () => {
const result = await _mockNpmShow(data, ['@foo/bar@2.0.0'], { cwd: undefined });
const result = await _mockNpmShow(data, ['@foo/bar@2.0.0'], { cwd: '' });
expect(result).toEqual(getShowResult({ name: '@foo/bar', version: '2.0.0' }));
});

it('returns requested tag', async () => {
const result = await _mockNpmShow(data, ['foo@beta'], { cwd: undefined });
const result = await _mockNpmShow(data, ['foo@beta'], { cwd: '' });
expect(result).toEqual(getShowResult({ name: 'foo', version: '1.0.0-beta' }));
});

it('returns requested tag of scoped package', async () => {
const result = await _mockNpmShow(data, ['@foo/bar@beta'], { cwd: undefined });
const result = await _mockNpmShow(data, ['@foo/bar@beta'], { cwd: '' });
expect(result).toEqual(getShowResult({ name: '@foo/bar', version: '2.0.0-beta' }));
});

it('returns latest version if no version requested', async () => {
const result = await _mockNpmShow(data, ['foo'], { cwd: undefined });
const result = await _mockNpmShow(data, ['foo'], { cwd: '' });
expect(result).toEqual(getShowResult({ name: 'foo', version: '1.0.1' }));
});

it('returns latest version of scoped package if no version requested', async () => {
const result = await _mockNpmShow(data, ['@foo/bar'], { cwd: undefined });
const result = await _mockNpmShow(data, ['@foo/bar'], { cwd: '' });
expect(result).toEqual(getShowResult({ name: '@foo/bar', version: '2.0.1' }));
});

it("errors if requested version doesn't exist", async () => {
const result = await _mockNpmShow(data, ['foo@2.0.0'], { cwd: undefined });
const result = await _mockNpmShow(data, ['foo@2.0.0'], { cwd: '' });
expect(result).toEqual(getErrorResult('[fake] code E404 - foo@2.0.0 - not found'));
});

// support for this could be added later
it('currently throws if requested version is a range', async () => {
await expect(() => _mockNpmShow(data, ['foo@^1.0.0'], { cwd: undefined })).rejects.toThrow(
/not currently supported/
);
await expect(() => _mockNpmShow(data, ['foo@^1.0.0'], { cwd: '' })).rejects.toThrow(/not currently supported/);
});
});

Expand Down Expand Up @@ -209,9 +207,7 @@ describe('_mockNpmPublish', () => {
});

it('throws if cwd is not specified', async () => {
await expect(() => _mockNpmPublish({}, [], { cwd: undefined })).rejects.toThrow(
'cwd is required for mock npm publish'
);
await expect(() => _mockNpmPublish({}, [], { cwd: '' })).rejects.toThrow('cwd is required for mock npm publish');
});

it('errors if reading package.json fails', async () => {
Expand Down Expand Up @@ -308,7 +304,7 @@ describe('_mockNpmPack', () => {
});

it('throws if cwd is not specified', async () => {
await expect(() => _mockNpmPack({}, [], { cwd: undefined })).rejects.toThrow('cwd is required for mock npm pack');
await expect(() => _mockNpmPack({}, [], { cwd: '' })).rejects.toThrow('cwd is required for mock npm pack');
});

it('errors if reading package.json fails', async () => {
Expand Down Expand Up @@ -433,7 +429,7 @@ describe('mockNpm', () => {
versions: ['1.0.0'],
'dist-tags': { latest: '1.0.0' },
});
expect(await npm(['show', 'foo'], { cwd: undefined })).toMatchObject({
expect(await npm(['show', 'foo'], { cwd: '' })).toMatchObject({
success: true,
stdout: JSON.stringify({
name: 'foo',
Expand Down Expand Up @@ -485,19 +481,19 @@ describe('mockNpm', () => {
});

it('throws if required cwd is missing', async () => {
await expect(() => npm(['publish'], { cwd: undefined })).rejects.toThrow('cwd is required for mock npm publish');
await expect(() => npm(['pack'], { cwd: undefined })).rejects.toThrow('cwd is required for mock npm pack');
await expect(() => npm(['publish'], { cwd: '' })).rejects.toThrow('cwd is required for mock npm publish');
await expect(() => npm(['pack'], { cwd: '' })).rejects.toThrow('cwd is required for mock npm pack');
});

it('throws on unsupported command', async () => {
await expect(() => npm(['foo'], { cwd: undefined })).rejects.toThrow('Command not supported by mock npm: foo');
await expect(() => npm(['foo'], { cwd: '' })).rejects.toThrow('Command not supported by mock npm: foo');
expect(npmMock.mock).toHaveBeenCalledTimes(1);
expect(npmMock.mock).toHaveBeenCalledWith(['foo'], expect.objectContaining({ cwd: undefined }));
expect(npmMock.mock).toHaveBeenCalledWith(['foo'], expect.objectContaining({ cwd: '' }));
});

it('TEMP mocks npm show command', async () => {
npmMock.setRegistryData({ foo: { versions: ['1.0.0'] } });
const result = await npm(['show', 'foo'], { cwd: undefined });
const result = await npm(['show', 'foo'], { cwd: '' });
expect(result).toMatchObject({
success: true,
stdout: expect.stringContaining('"name":"foo"'),
Expand All @@ -511,24 +507,24 @@ describe('mockNpm', () => {
it('respects mocked command override', async () => {
const mockPublish = jest.fn(() => Promise.resolve(fakePublishResult as unknown as MockNpmResult));
npmMock.setCommandOverride('publish', mockPublish);
const result = await npm(['publish', 'foo'], { cwd: undefined });
const result = await npm(['publish', 'foo'], { cwd: '' });
expect(result).toEqual(fakePublishResult);
expect(mockPublish).toHaveBeenCalledWith(expect.any(Object), ['foo'], { cwd: undefined });
expect(mockPublish).toHaveBeenCalledWith(expect.any(Object), ['foo'], { cwd: '' });
});

it("respects extra mocked command that's not normally supported", async () => {
const mockFoo = jest.fn(() => Promise.resolve('hi' as unknown as MockNpmResult));
npmMock.setCommandOverride('foo', mockFoo);
const result = await npm(['foo'], { cwd: undefined });
const result = await npm(['foo'], { cwd: '' });
expect(result).toEqual('hi');
expect(mockFoo).toHaveBeenCalledWith(expect.any(Object), [], { cwd: undefined });
expect(mockFoo).toHaveBeenCalledWith(expect.any(Object), [], { cwd: '' });
});

it('resets commands after each test', async () => {
// extra command is gone
await expect(() => npm(['foo'], { cwd: undefined })).rejects.toThrow('Command not supported by mock npm: foo');
await expect(() => npm(['foo'], { cwd: '' })).rejects.toThrow('Command not supported by mock npm: foo');
// publish mock is gone
await expect(() => npm(['publish'], { cwd: undefined })).rejects.toThrow('cwd is required for mock npm publish');
await expect(() => npm(['publish'], { cwd: '' })).rejects.toThrow('cwd is required for mock npm publish');
});
});
});
Loading
Loading