Paul Gschwendtner f96dcc5ce0 feat(dev-infra): tool for staging and publishing releases (#38656)
Creates a tool for staging and publishing releases as per the
new branching and versioning that has been outlined in the following
document. The tool is intended to be used across the organization to
ensure consistent branching/versioning and labeling:

https://docs.google.com/document/d/197kVillDwx-RZtSVOBtPb4BBIAw0E9RT3q3v6DZkykU/edit#heading=h.s3qlps8f4zq7dd

The tool implements the actions as outlined in the following
initial plan: https://hackmd.io/2Le8leq0S6G_R5VEVTNK9A.

The implementation slightly diverged in so far that it performs
staging and publishing together so that releasing is a single
convenient command. In case of errors for which re-running the
full command is not sufficient, we want to consider adding
recover functionality. e.g. when the staging completed, but the
actual NPM publishing aborted unexpectedly due to build errors.

PR Close #38656
2020-09-28 16:11:42 -04:00

212 lines
8.7 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {readFileSync} from 'fs';
import {join} from 'path';
import * as semver from 'semver';
import {getBranchPushMatcher} from '../../../utils/testing';
import {ActiveReleaseTrains} from '../../versioning/active-release-trains';
import * as npm from '../../versioning/npm-publish';
import {ReleaseTrain} from '../../versioning/release-trains';
import {ReleaseAction} from '../actions';
import {actions} from '../actions/index';
import {changelogPath} from '../constants';
import {getChangelogForVersion, getTestingMocksForReleaseAction, parse, setupReleaseActionForTesting, testTmpDir} from './test-utils';
describe('common release action logic', () => {
const baseReleaseTrains: ActiveReleaseTrains = {
releaseCandidate: null,
next: new ReleaseTrain('master', parse('10.1.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.1')),
};
describe('version computation', async () => {
const testReleaseTrain: ActiveReleaseTrains = {
releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.3')),
next: new ReleaseTrain('master', parse('10.2.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.1')),
};
it('should not modify release train versions and cause invalid other actions', async () => {
const {releaseConfig, gitClient} = getTestingMocksForReleaseAction();
const descriptions: string[] = [];
for (const actionCtor of actions) {
if (await actionCtor.isActive(testReleaseTrain)) {
const action = new actionCtor(testReleaseTrain, gitClient, releaseConfig, testTmpDir);
descriptions.push(await action.getDescription());
}
}
expect(descriptions).toEqual([
`Cut a first release-candidate for the feature-freeze branch (v10.1.0-rc.0).`,
`Cut a new patch release for the "10.0.x" branch (v10.0.2).`,
`Cut a new next pre-release for the "10.1.x" branch (v10.1.0-next.4).`,
`Cut a new release for an active LTS branch (0 active).`
]);
});
});
describe('build and publishing', () => {
it('should support a custom NPM registry', async () => {
const {repo, instance, releaseConfig} =
setupReleaseActionForTesting(TestAction, baseReleaseTrains);
const {version, branchName} = baseReleaseTrains.next;
const tagName = version.format();
const customRegistryUrl = 'https://custom-npm-registry.google.com';
repo.expectBranchRequest(branchName, 'STAGING_SHA')
.expectCommitRequest('STAGING_SHA', `release: cut the v${version} release`)
.expectTagToBeCreated(tagName, 'STAGING_SHA')
.expectReleaseToBeCreated(`v${version}`, tagName);
// Set up a custom NPM registry.
releaseConfig.publishRegistry = customRegistryUrl;
await instance.testBuildAndPublish(version, branchName, 'latest');
expect(npm.runNpmPublish).toHaveBeenCalledTimes(2);
expect(npm.runNpmPublish)
.toHaveBeenCalledWith(`${testTmpDir}/dist/pkg1`, 'latest', customRegistryUrl);
expect(npm.runNpmPublish)
.toHaveBeenCalledWith(`${testTmpDir}/dist/pkg2`, 'latest', customRegistryUrl);
});
});
describe('changelog cherry-picking', () => {
const {version, branchName} = baseReleaseTrains.latest;
const fakeReleaseNotes = getChangelogForVersion(version.format());
const forkBranchName = `changelog-cherry-pick-${version}`;
it('should prepend fetched changelog', async () => {
const {repo, fork, instance, testTmpDir} =
setupReleaseActionForTesting(TestAction, baseReleaseTrains);
// Expect the changelog to be fetched and return a fake changelog to test that
// it is properly appended. Also expect a pull request to be created in the fork.
repo.expectChangelogFetch(branchName, fakeReleaseNotes)
.expectFindForkRequest(fork)
.expectPullRequestToBeCreated('master', fork, forkBranchName, 200);
// Simulate that the fork branch name is available.
fork.expectBranchRequest(forkBranchName, null);
await instance.testCherryPickWithPullRequest(version, branchName);
const changelogContent = readFileSync(join(testTmpDir, changelogPath), 'utf8');
expect(changelogContent).toEqual(`${fakeReleaseNotes}Existing changelog`);
});
it('should respect a custom release note extraction pattern', async () => {
const {repo, fork, instance, testTmpDir, releaseConfig} =
setupReleaseActionForTesting(TestAction, baseReleaseTrains);
// Custom pattern matching changelog output sections grouped through
// basic level-1 markdown headers (compared to the default anchor pattern).
releaseConfig.extractReleaseNotesPattern = version =>
new RegExp(`(# v${version} \\("[^"]+"\\).*?)(?:# v|$)`, 's');
const customReleaseNotes = `# v${version} ("newton-kepler")\n\nNew Content!`;
// Expect the changelog to be fetched and return a fake changelog to test that
// it is properly appended. Also expect a pull request to be created in the fork.
repo.expectChangelogFetch(branchName, customReleaseNotes)
.expectFindForkRequest(fork)
.expectPullRequestToBeCreated('master', fork, forkBranchName, 200);
// Simulate that the fork branch name is available.
fork.expectBranchRequest(forkBranchName, null);
await instance.testCherryPickWithPullRequest(version, branchName);
const changelogContent = readFileSync(join(testTmpDir, changelogPath), 'utf8');
expect(changelogContent).toEqual(`${customReleaseNotes}\n\nExisting changelog`);
});
it('should print an error if release notes cannot be extracted', async () => {
const {repo, fork, instance, testTmpDir, releaseConfig} =
setupReleaseActionForTesting(TestAction, baseReleaseTrains);
// Expect the changelog to be fetched and return a fake changelog to test that
// it is properly appended. Also expect a pull request to be created in the fork.
repo.expectChangelogFetch(branchName, `non analyzable changelog`)
.expectFindForkRequest(fork)
.expectPullRequestToBeCreated('master', fork, forkBranchName, 200);
// Simulate that the fork branch name is available.
fork.expectBranchRequest(forkBranchName, null);
spyOn(console, 'error');
await instance.testCherryPickWithPullRequest(version, branchName);
expect(console.error)
.toHaveBeenCalledWith(
jasmine.stringMatching(`Could not cherry-pick release notes for v${version}`));
expect(console.error)
.toHaveBeenCalledWith(jasmine.stringMatching(
`Please copy the release notes manually into the "master" branch.`));
const changelogContent = readFileSync(join(testTmpDir, changelogPath), 'utf8');
expect(changelogContent).toEqual(`Existing changelog`);
});
it('should push changes to a fork for creating a pull request', async () => {
const {repo, fork, instance, gitClient} =
setupReleaseActionForTesting(TestAction, baseReleaseTrains);
// Expect the changelog to be fetched and return a fake changelog to test that
// it is properly appended. Also expect a pull request to be created in the fork.
repo.expectChangelogFetch(branchName, fakeReleaseNotes)
.expectFindForkRequest(fork)
.expectPullRequestToBeCreated('master', fork, forkBranchName, 200);
// Simulate that the fork branch name is available.
fork.expectBranchRequest(forkBranchName, null);
await instance.testCherryPickWithPullRequest(version, branchName);
expect(gitClient.pushed.length).toBe(1);
expect(gitClient.pushed[0]).toEqual(getBranchPushMatcher({
targetBranch: forkBranchName,
targetRepo: fork,
baseBranch: 'master',
baseRepo: repo,
expectedCommits: [{
message: `docs: release notes for the v${version} release`,
files: ['CHANGELOG.md'],
}],
}));
});
});
});
/**
* Test release action that exposes protected units of the base
* release action class. This allows us to add unit tests.
*/
class TestAction extends ReleaseAction {
async getDescription() {
return 'Test action';
}
async perform() {
throw Error('Not implemented.');
}
async testBuildAndPublish(newVersion: semver.SemVer, publishBranch: string, distTag: string) {
await this.buildAndPublish(newVersion, publishBranch, distTag);
}
async testCherryPickWithPullRequest(version: semver.SemVer, branch: string) {
await this.cherryPickChangelogIntoNextBranch(version, branch);
}
}