diff --git a/package.json b/package.json index c8d07b8db0..0200405014 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "compile-exec": "npm run evergreen-release compile", "compile-all": "npm run compile-compass && npm run compile-exec", "evergreen-release": "cd packages/build && npm run evergreen-release --", + "release": "cd packages/build && npm run release --", "report-missing-help": "lerna run --stream --scope @mongosh/shell-api report-missing-help", "report-supported-api": "lerna run --stream --scope @mongosh/shell-api report-supported-api", "report-coverage": "nyc report --reporter=text --reporter=html && nyc check-coverage --lines=95", diff --git a/packages/build/package.json b/packages/build/package.json index 449e38318e..9a2b4b8db5 100644 --- a/packages/build/package.json +++ b/packages/build/package.json @@ -19,7 +19,8 @@ "test-ci": "mocha -r \"../../scripts/import-expansions.js\" --timeout 30000 -r ts-node/register \"./src/**/*.spec.ts\"", "lint": "eslint \"**/*.{js,ts,tsx}\"", "check": "npm run lint", - "evergreen-release": "ts-node -r ../../scripts/import-expansions.js src/index.ts" + "evergreen-release": "ts-node -r ../../scripts/import-expansions.js src/index.ts", + "release": "ts-node src/index.ts trigger-release" }, "license": "Apache-2.0", "publishConfig": { diff --git a/packages/build/src/evergreen.spec.ts b/packages/build/src/evergreen/artifacts.spec.ts similarity index 88% rename from packages/build/src/evergreen.spec.ts rename to packages/build/src/evergreen/artifacts.spec.ts index cdc50adb7b..06e0cc9530 100644 --- a/packages/build/src/evergreen.spec.ts +++ b/packages/build/src/evergreen/artifacts.spec.ts @@ -3,9 +3,9 @@ import { promises as fs } from 'fs'; import path from 'path'; import rimraf from 'rimraf'; import { promisify } from 'util'; -import { downloadArtifactFromEvergreen } from './evergreen'; +import { downloadArtifactFromEvergreen } from './artifacts'; -describe('evergreen', () => { +describe('evergreen artifacts', () => { describe('downloadArtifactFromEvergreen', () => { let tmpDir: string; diff --git a/packages/build/src/evergreen.ts b/packages/build/src/evergreen/artifacts.ts similarity index 100% rename from packages/build/src/evergreen.ts rename to packages/build/src/evergreen/artifacts.ts diff --git a/packages/build/src/evergreen/index.ts b/packages/build/src/evergreen/index.ts new file mode 100644 index 0000000000..357d919e0a --- /dev/null +++ b/packages/build/src/evergreen/index.ts @@ -0,0 +1,3 @@ + +export * from './artifacts'; +export * from './rest-api'; diff --git a/packages/build/src/evergreen/rest-api.spec.ts b/packages/build/src/evergreen/rest-api.spec.ts new file mode 100644 index 0000000000..2b2b703908 --- /dev/null +++ b/packages/build/src/evergreen/rest-api.spec.ts @@ -0,0 +1,108 @@ +import { expect } from 'chai'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; +import sinon from 'sinon'; +import YAML from 'yaml'; +import { EvergreenApi, EvergreenTask } from './rest-api'; + +describe('evergreen rest-api', () => { + describe('from user configuration', () => { + const configData = { + api_server_host: 'host', + user: 'user', + api_key: 'key' + }; + const writeEvergreenConfiguration = async(content: string): Promise => { + const configFile = path.join(os.tmpdir(), `evergreen-${new Date().getTime()}-${Math.random()}.yaml`); + await fs.writeFile(configFile, content, { encoding: 'utf-8' }); + return configFile; + }; + + it('parses a configuration file correctly', async() => { + const configFile = await writeEvergreenConfiguration(YAML.stringify(configData)); + const api = await EvergreenApi.fromUserConfiguration(configFile); + expect(api.apiBasepath).to.equal('host'); + expect(api.apiUser).to.equal('user'); + expect(api.apiKey).to.equal('key'); + }); + + it('throws an error when the configuration file does not exist', async() => { + try { + await EvergreenApi.fromUserConfiguration('kasldjflasjk dfalsd jfsdfk'); + } catch (e) { + expect(e.message).to.contain('Could not find local evergreen configuration'); + return; + } + expect.fail('Expected error'); + }); + + ['api_server_host', 'user', 'api_key'].forEach(key => { + it(`throws an error if ${key} is missing`, async() => { + const data: Record = { + ...configData + }; + delete data[key]; + const configFile = await writeEvergreenConfiguration(YAML.stringify(data)); + try { + await EvergreenApi.fromUserConfiguration(configFile); + } catch (e) { + expect(e.message).to.contain(key); + } + }); + }); + }); + + describe('getTasks', () => { + let fetch: sinon.SinonStub; + let api: EvergreenApi; + + beforeEach(() => { + fetch = sinon.stub(); + api = new EvergreenApi( + '//basePath/api', 'user', 'key', fetch as any + ); + }); + + it('executes a proper GET', async() => { + const task: EvergreenTask = { + task_id: 'task_id', + version_id: 'version', + status: 'success', + display_name: 'Task', + build_variant: 'variant' + }; + fetch.resolves({ + status: 200, + json: sinon.stub().resolves([task]) + }); + + const tasks = await api.getTasks('mongosh', 'sha'); + expect(tasks).to.deep.equal([task]); + expect(fetch).to.have.been.calledWith( + '//basePath/api/rest/v2/projects/mongosh/revisions/sha/tasks', + { + headers: { + 'Api-User': 'user', + 'Api-Key': 'key' + } + } + ); + }); + + it('fails if there is a non-200 response code', async() => { + fetch.resolves({ + status: 404, + text: sinon.stub().resolves('ERR: Not found') + }); + + try { + await api.getTasks('mongosh', 'sha'); + } catch (e) { + expect(e.message).to.equal('Unexpected response status: 404 - ERR: Not found'); + return; + } + expect.fail('Expected error'); + }); + }); +}); diff --git a/packages/build/src/evergreen/rest-api.ts b/packages/build/src/evergreen/rest-api.ts new file mode 100644 index 0000000000..df4ba5a8d7 --- /dev/null +++ b/packages/build/src/evergreen/rest-api.ts @@ -0,0 +1,76 @@ +/* eslint-disable camelcase */ +import { promises as fs, constants } from 'fs'; +import { default as fetchFn } from 'node-fetch'; +import os from 'os'; +import path from 'path'; +import YAML from 'yaml'; + +export type EvergreenTaskStatus = 'undispatched' | 'scheduled' | 'started' | 'success' | 'failed' | 'aborted'; + +// For full specification of all fields see: https://github.com/evergreen-ci/evergreen/wiki/REST-V2-Usage#objects +export interface EvergreenTask { + task_id: string; + version_id: string; + display_name: string; + build_variant: string; + status: EvergreenTaskStatus; +} + +export class EvergreenApi { + constructor( + public readonly apiBasepath: string, + public readonly apiUser: string, + public readonly apiKey: string, + private readonly fetch: typeof fetchFn = fetchFn + ) {} + + public static async fromUserConfiguration( + pathToConfiguration = path.join(os.homedir(), '.evergreen.yml') + ): Promise { + try { + await fs.access(pathToConfiguration, constants.R_OK); + } catch { + throw new Error(`Could not find local evergreen configuration: ${pathToConfiguration}. Ensure it exists and can be read.`); + } + + const configuration = YAML.parse(await fs.readFile(pathToConfiguration, { encoding: 'utf-8' })); + ['api_server_host', 'user', 'api_key'].forEach(key => { + if (typeof configuration[key] !== 'string') { + throw new Error(`Evergreen configuration ${pathToConfiguration} misses required key ${key}`); + } + }); + return new EvergreenApi( + configuration.api_server_host, + configuration.user, + configuration.api_key, + ); + } + + public async getTasks( + project: string, + commitSha: string + ): Promise { + return await this.apiGET( + `/projects/${project}/revisions/${commitSha}/tasks` + ); + } + + private async apiGET(path: string): Promise { + const response = await this.fetch( + `${this.apiBasepath}/rest/v2${path}`, + { headers: this.getApiHeaders() } + ); + + if (response.status >= 300) { + throw new Error(`Unexpected response status: ${response.status} - ${await response.text()}`); + } + return await response.json(); + } + + private getApiHeaders(): Record { + return { + 'Api-User': this.apiUser, + 'Api-Key': this.apiKey, + }; + } +} diff --git a/packages/build/src/helpers/index.ts b/packages/build/src/helpers/index.ts new file mode 100644 index 0000000000..68a2f353aa --- /dev/null +++ b/packages/build/src/helpers/index.ts @@ -0,0 +1,3 @@ + +export * from './spawn-sync'; +export * from './user-input'; diff --git a/packages/build/src/npm-packages/spawn-sync.spec.ts b/packages/build/src/helpers/spawn-sync.spec.ts similarity index 100% rename from packages/build/src/npm-packages/spawn-sync.spec.ts rename to packages/build/src/helpers/spawn-sync.spec.ts diff --git a/packages/build/src/npm-packages/spawn-sync.ts b/packages/build/src/helpers/spawn-sync.ts similarity index 100% rename from packages/build/src/npm-packages/spawn-sync.ts rename to packages/build/src/helpers/spawn-sync.ts diff --git a/packages/build/src/helpers/user-input.ts b/packages/build/src/helpers/user-input.ts new file mode 100644 index 0000000000..3f16edde5e --- /dev/null +++ b/packages/build/src/helpers/user-input.ts @@ -0,0 +1,33 @@ +/* istanbul ignore file */ +import readline from 'readline'; + +export async function ask(prompt: string): Promise { + return new Promise(resolve => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + rl.question(`${prompt} `, (answer) => { + rl.close(); + resolve(answer); + }); + }); +} + +export async function confirm(prompt: string): Promise { + const answer = await ask(`${prompt} Y/[N]:`); + return !!answer.match(/^[yY]$/); +} + +export async function choose(headline: string, options: string[], prompt: string): Promise { + console.info(headline); + options.forEach(o => console.info(` > ${o}`)); + + let answer: string | undefined; + do { + answer = await ask(prompt); + } while (!options.includes(answer)); + + return answer; +} diff --git a/packages/build/src/index.ts b/packages/build/src/index.ts index 6b2bceab22..418088afca 100644 --- a/packages/build/src/index.ts +++ b/packages/build/src/index.ts @@ -2,31 +2,34 @@ import path from 'path'; import { ALL_BUILD_VARIANTS } from './config'; import { downloadMongoDb } from './download-mongodb'; import { getArtifactUrl } from './evergreen'; +import { triggerRelease } from './local'; import { release, ReleaseCommand } from './release'; export { getArtifactUrl, downloadMongoDb }; if (require.main === module) { (async() => { - const config = require(path.join(__dirname, '..', '..', '..', 'config', 'build.conf.js')); - const command = process.argv[2]; - - if (!['bump', 'compile', 'package', 'upload', 'draft', 'publish'].includes(command)) { - throw new Error('USAGE: npm run evergreen-release '); + if (!['bump', 'compile', 'package', 'upload', 'draft', 'publish', 'trigger-release'].includes(command)) { + throw new Error('USAGE: npm run evergreen-release '); } - const cliBuildVariant = process.argv - .map((arg) => arg.match(/^--build-variant=(.+)$/)) - .filter(Boolean)[0]; - if (cliBuildVariant) { - config.buildVariant = cliBuildVariant[1]; - if (!ALL_BUILD_VARIANTS.includes(config.buildVariant)) { - throw new Error(`Unknown build variant: ${config.buildVariant} - must be one of: ${ALL_BUILD_VARIANTS}`); + if (command === 'trigger-release') { + await triggerRelease(process.argv.slice(3)); + } else { + const config = require(path.join(__dirname, '..', '..', '..', 'config', 'build.conf.js')); + const cliBuildVariant = process.argv + .map((arg) => arg.match(/^--build-variant=(.+)$/)) + .filter(Boolean)[0]; + if (cliBuildVariant) { + config.buildVariant = cliBuildVariant[1]; + if (!ALL_BUILD_VARIANTS.includes(config.buildVariant)) { + throw new Error(`Unknown build variant: ${config.buildVariant} - must be one of: ${ALL_BUILD_VARIANTS}`); + } } - } - await release(command as ReleaseCommand, config); + await release(command as ReleaseCommand, config); + } })().then( () => process.exit(0), (err) => process.nextTick(() => { throw err; }) diff --git a/packages/build/src/local/get-latest-tag.spec.ts b/packages/build/src/local/get-latest-tag.spec.ts new file mode 100644 index 0000000000..291dda5ed8 --- /dev/null +++ b/packages/build/src/local/get-latest-tag.spec.ts @@ -0,0 +1,71 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { getLatestDraftOrReleaseTagFromLog } from './get-latest-tag'; + +describe('local get-latest-tag', () => { + let spawnSync: sinon.SinonStub; + + beforeEach(() => { + spawnSync = sinon.stub(); + }); + + describe('getLatestDraftOrReleaseTagFromLog', () => { + it('extracts the latest draft tag', () => { + spawnSync.onFirstCall().returns({ + stdout: [ + 'v0.7.9', + 'v0.8.0-draft.0', + 'v0.8.0-draft.1', + 'v0.8.0-draft.10', + 'v0.8.0-draft.2' + ].join('\n') + }); + spawnSync.onSecondCall().returns({ + stdout: 'tagHash' + }); + + const result = getLatestDraftOrReleaseTagFromLog( + 'somePath', + spawnSync + ); + expect(result).to.deep.equal({ + commit: 'tagHash', + tag: { + semverName: '0.8.0-draft.10', + releaseVersion: '0.8.0', + draftVersion: 10 + } + }); + }); + + it('extracts the latest release tag', () => { + spawnSync.onFirstCall().returns({ + stdout: [ + 'v0.8.0', + 'v0.8.0-draft.0', + 'v0.8.0-draft.1', + 'v0.8.0-draft.10', + 'v0.8.1', + 'v0.8.0-draft.2', + 'v0.8.1-draft.0', + ].join('\n') + }); + spawnSync.onSecondCall().returns({ + stdout: 'tagHash' + }); + + const result = getLatestDraftOrReleaseTagFromLog( + 'somePath', + spawnSync + ); + expect(result).to.deep.equal({ + commit: 'tagHash', + tag: { + semverName: '0.8.1', + releaseVersion: '0.8.1', + draftVersion: undefined + } + }); + }); + }); +}); diff --git a/packages/build/src/local/get-latest-tag.ts b/packages/build/src/local/get-latest-tag.ts new file mode 100644 index 0000000000..910f05b76c --- /dev/null +++ b/packages/build/src/local/get-latest-tag.ts @@ -0,0 +1,62 @@ +import semver from 'semver'; +import { spawnSync as spawnSyncFn } from '../helpers'; + +export interface TaggedCommit { + commit: string; + tag: TagDetails +} + +export interface TagDetails { + semverName: string; + releaseVersion: string; + draftVersion: number | undefined; +} + +export function getLatestDraftOrReleaseTagFromLog( + repositoryRoot: string, + spawnSync: typeof spawnSyncFn = spawnSyncFn +): TaggedCommit | undefined { + const gitTags = spawnSync('git', ['tag'], { + cwd: repositoryRoot, + encoding: 'utf-8' + }); + + const tagDetails = extractTags(gitTags.stdout.split('\n')); + const sortedTagsWithCommit = tagDetails.sort((t1, t2) => { + return -1 * semver.compare(t1.semverName, t2.semverName); + }); + + if (!sortedTagsWithCommit.length) { + return undefined; + } + + const tag = sortedTagsWithCommit[0]; + const gitLog = spawnSync('git', ['log', '-n1', '--pretty=%H', `v${tag.semverName}`], { + cwd: repositoryRoot, + encoding: 'utf-8' + }); + + return { + commit: gitLog.stdout.trim(), + tag + }; +} + +function extractTags(gitTags: string[]): TagDetails[] { + const validTags = gitTags + .map(tag => semver.valid(tag)) + .filter(v => !!v) as string[]; + + return validTags.map(semverTag => { + const prerelease = semver.prerelease(semverTag); + if (prerelease && prerelease[0] !== 'draft') { + return undefined; + } + + return { + semverName: semverTag, + releaseVersion: `${semver.major(semverTag)}.${semver.minor(semverTag)}.${semver.patch(semverTag)}`, + draftVersion: prerelease ? parseInt(prerelease[1], 10) : undefined + }; + }).filter(t => !!t) as TagDetails[]; +} diff --git a/packages/build/src/local/index.ts b/packages/build/src/local/index.ts new file mode 100644 index 0000000000..6e35de7bb4 --- /dev/null +++ b/packages/build/src/local/index.ts @@ -0,0 +1,2 @@ + +export * from './trigger-release'; diff --git a/packages/build/src/local/repository-status.spec.ts b/packages/build/src/local/repository-status.spec.ts new file mode 100644 index 0000000000..bf8ab23287 --- /dev/null +++ b/packages/build/src/local/repository-status.spec.ts @@ -0,0 +1,270 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { getRepositoryStatus, RepositoryStatus, verifyGitStatus } from './repository-status'; + +describe('local repository-status', () => { + let spawnSync: sinon.SinonStub; + + beforeEach(() => { + spawnSync = sinon.stub(); + }); + + describe('verifyGitStatus', () => { + let getRepositoryStatus: sinon.SinonStub; + + beforeEach(()=> { + getRepositoryStatus = sinon.stub(); + }); + + [ 'master', 'main', 'v0.8.0', 'v0.8.x' ].forEach(branchName => { + it(`accepts a clean repository on ${branchName}`, () => { + const status: RepositoryStatus = { + branch: { + local: branchName, + tracking: `origin/${branchName}`, + diverged: false + }, + clean: true, + hasUnpushedTags: false + }; + getRepositoryStatus.returns(status); + verifyGitStatus('root', getRepositoryStatus); + expect(getRepositoryStatus).to.have.been.calledOnce; + }); + }); + + it('fails if it cannot determine branch', () => { + const status: RepositoryStatus = { + clean: true, + hasUnpushedTags: false + }; + getRepositoryStatus.returns(status); + try { + verifyGitStatus('root', getRepositoryStatus); + } catch (e) { + expect(e.message).to.contain('Could not determine local repository information'); + return; + } + expect.fail('Expected error'); + }); + + it('fails for a forbidden branch', () => { + const status: RepositoryStatus = { + branch: { + local: 'somebranch', + tracking: 'origin/somebranch', + diverged: false + }, + clean: true, + hasUnpushedTags: false + }; + getRepositoryStatus.returns(status); + try { + verifyGitStatus('root', getRepositoryStatus); + } catch (e) { + expect(e.message).to.contain('The current branch does not match'); + return; + } + expect.fail('Expected error'); + }); + + it('fails if tracking branch is missing', () => { + const status: RepositoryStatus = { + branch: { + local: 'main', + tracking: undefined, + diverged: false + }, + clean: true, + hasUnpushedTags: false + }; + getRepositoryStatus.returns(status); + try { + verifyGitStatus('root', getRepositoryStatus); + } catch (e) { + expect(e.message).to.contain('The branch you are on is not tracking any remote branch.'); + return; + } + expect.fail('Expected error'); + }); + + it('fails if repository is not clean', () => { + const status: RepositoryStatus = { + branch: { + local: 'main', + tracking: 'origin/main', + diverged: false + }, + clean: false, + hasUnpushedTags: false + }; + getRepositoryStatus.returns(status); + try { + verifyGitStatus('root', getRepositoryStatus); + } catch (e) { + expect(e.message).to.contain('Your local repository is not clean or diverged from the remote branch'); + return; + } + expect.fail('Expected error'); + }); + + it('fails if repository diverged from remote', () => { + const status: RepositoryStatus = { + branch: { + local: 'main', + tracking: 'origin/main', + diverged: true + }, + clean: true, + hasUnpushedTags: false + }; + getRepositoryStatus.returns(status); + try { + verifyGitStatus('root', getRepositoryStatus); + } catch (e) { + expect(e.message).to.contain('Your local repository is not clean or diverged from the remote branch'); + return; + } + expect.fail('Expected error'); + }); + + it('fails if repository has unpushed tags', () => { + const status: RepositoryStatus = { + branch: { + local: 'main', + tracking: 'origin/main', + diverged: false + }, + clean: true, + hasUnpushedTags: true + }; + getRepositoryStatus.returns(status); + try { + verifyGitStatus('root', getRepositoryStatus); + } catch (e) { + expect(e.message).to.contain('You have local tags that are not pushed to the remote'); + return; + } + expect.fail('Expected error'); + }); + }); + + describe('getRepositoryStatus', () => { + it('parses a clean repository correctly', () => { + spawnSync.returns({ + stdout: '## master...origin/master\n' + }); + spawnSync.onSecondCall().returns({ + stdout: 'Everything up-to-date' + }); + + const status = getRepositoryStatus('somePath', spawnSync); + expect(status).to.deep.equal({ + branch: { + local: 'master', + tracking: 'origin/master', + diverged: false + }, + clean: true, + hasUnpushedTags: false + }); + }); + + it('detectes pending file changes', () => { + spawnSync.onFirstCall().returns({ + stdout: [ + '## master...origin/master', + 'A packages/build/src/helpers/index.ts', + 'A packages/build/src/helpers/spawn-sync.spec.ts', + '?? packages/build/src/helpers/test', + ].join('\n') + }); + spawnSync.onSecondCall().returns({ + stdout: 'Everything up-to-date' + }); + + const status = getRepositoryStatus('somePath', spawnSync); + expect(status).to.deep.equal({ + branch: { + local: 'master', + tracking: 'origin/master', + diverged: false + }, + clean: false, + hasUnpushedTags: false + }); + }); + + it('detectes diverging branches', () => { + spawnSync.returns({ + stdout: [ + '## master...origin/something [ahead 5, behind 3]', + 'A packages/build/src/helpers/index.ts', + 'A packages/build/src/helpers/spawn-sync.spec.ts', + '?? packages/build/src/helpers/test', + ].join('\n') + }); + spawnSync.onSecondCall().returns({ + stdout: 'Everything up-to-date' + }); + + const status = getRepositoryStatus('somePath', spawnSync); + expect(status).to.deep.equal({ + branch: { + local: 'master', + tracking: 'origin/something', + diverged: true + }, + clean: false, + hasUnpushedTags: false + }); + }); + + it('detectes missing origin', () => { + spawnSync.returns({ + stdout: [ + '## master' + ].join('\n') + }); + spawnSync.onSecondCall().returns({ + stdout: 'Everything up-to-date' + }); + + const status = getRepositoryStatus('somePath', spawnSync); + expect(status).to.deep.equal({ + branch: { + local: 'master', + tracking: undefined, + diverged: false + }, + clean: true, + hasUnpushedTags: false + }); + }); + + it('detects unpushed tags', () => { + spawnSync.onFirstCall().returns({ + stdout: [ + '## master...origin/master' + ].join('\n') + }); + spawnSync.onSecondCall().returns({ + stdout: [ + 'To github.com:mongodb-js/mongosh.git', + '* [new tag] vxxx -> vxxx' + ].join('\n') + }); + + const status = getRepositoryStatus('somePath', spawnSync); + expect(status).to.deep.equal({ + branch: { + local: 'master', + tracking: 'origin/master', + diverged: false + }, + clean: true, + hasUnpushedTags: true + }); + }); + }); +}); diff --git a/packages/build/src/local/repository-status.ts b/packages/build/src/local/repository-status.ts new file mode 100644 index 0000000000..e9cfe1f4b4 --- /dev/null +++ b/packages/build/src/local/repository-status.ts @@ -0,0 +1,75 @@ +import { spawnSync as spawnSyncFn } from '../helpers'; + + +export interface RepositoryStatus { + branch?: { + local: string; + tracking: string | undefined; + diverged: boolean; + }, + clean: boolean, + hasUnpushedTags: boolean +} + +export function verifyGitStatus( + repositoryRoot: string, + getRepositoryStatusFn: typeof getRepositoryStatus = getRepositoryStatus +): void { + const repositoryStatus = getRepositoryStatusFn(repositoryRoot); + if (!repositoryStatus.branch?.local) { + throw new Error('Could not determine local repository information - please verify your repository is intact.'); + } + if (!/^(master|main|v[a-z0-9]+\.[a-z0-9]+\.[a-z0-9]+)$/.test(repositoryStatus.branch.local)) { + throw new Error('The current branch does not match: master|main|vX.X.X'); + } + if (!repositoryStatus.branch.tracking) { + throw new Error('The branch you are on is not tracking any remote branch.'); + } + if (repositoryStatus.branch?.diverged || !repositoryStatus.clean) { + throw new Error('Your local repository is not clean or diverged from the remote branch. Commit any uncommited changes and ensure your branch is up to date.'); + } + if (repositoryStatus.hasUnpushedTags) { + throw new Error('You have local tags that are not pushed to the remote. Remove or push those tags to continue.'); + } +} + +export function getRepositoryStatus( + repositoryRoot: string, + spawnSync: typeof spawnSyncFn = spawnSyncFn +): RepositoryStatus { + const gitStatus = spawnSync('git', ['status', '-b', '--porcelain'], { + cwd: repositoryRoot, + encoding: 'utf-8' + }); + const tagStatus = spawnSync('git', ['push', '--tags', '--dry-run'], { + cwd: repositoryRoot, + encoding: 'utf-8' + }); + + const result: RepositoryStatus = { + clean: true, + hasUnpushedTags: tagStatus.stdout.trim() !== 'Everything up-to-date' + }; + + const output = gitStatus.stdout + .split('\n') + .map(l => l.trim()) + .filter(l => !!l); + + const branchOutput = output.find(l => l.match(/^## /)); + const branchInfo = branchOutput?.match(/^## ([^\s.]+)(\.\.\.([^\s]+)( \[[^\]]+])?)?/); + + if (branchInfo) { + result.branch = { + local: branchInfo[1], + tracking: branchInfo[3], + diverged: !!branchInfo[4] + }; + } + + const fileInfo = output.filter(l => !l.match(/^## /)); + result.clean = fileInfo.length === 0; + + return result; +} + diff --git a/packages/build/src/local/trigger-release-draft.spec.ts b/packages/build/src/local/trigger-release-draft.spec.ts new file mode 100644 index 0000000000..4b83e58260 --- /dev/null +++ b/packages/build/src/local/trigger-release-draft.spec.ts @@ -0,0 +1,174 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { TagDetails, TaggedCommit } from './get-latest-tag'; +import { computeNextTagNameFn, triggerReleaseDraft } from './trigger-release-draft'; + +describe('local trigger-release-draft', () => { + describe('triggerReleaseDraft', () => { + let verifyGitStatus: sinon.SinonStub; + let getLatestDraftOrReleaseTagFromLog:sinon.SinonStub; + let choose: sinon.SinonStub; + let confirm: sinon.SinonStub; + let spawnSync: sinon.SinonStub; + + beforeEach(() => { + verifyGitStatus = sinon.stub(); + getLatestDraftOrReleaseTagFromLog = sinon.stub(); + choose = sinon.stub(); + confirm = sinon.stub(); + spawnSync = sinon.stub(); + }); + + it('creates a new draft and pushes when everything is good', async() => { + const latestTag: TaggedCommit = { + commit: 'hash', + tag: { + draftVersion: 7, + releaseVersion: '0.8.0', + semverName: '0.8.0-draft.7' + } + }; + getLatestDraftOrReleaseTagFromLog.returns(latestTag); + confirm.resolves(true); + + await triggerReleaseDraft( + 'root', + verifyGitStatus, + getLatestDraftOrReleaseTagFromLog, + choose, + confirm, + spawnSync + ); + + expect(verifyGitStatus).to.have.been.called; + expect(choose).to.not.have.been.called; + expect(confirm).to.have.been.called; + expect(spawnSync).to.have.been.calledTwice; + expect(spawnSync.getCall(0)).calledWith('git', ['tag', 'v0.8.0-draft.8'], sinon.match.any); + expect(spawnSync.getCall(1)).calledWith('git', ['push', 'origin', 'v0.8.0-draft.8'], sinon.match.any); + }); + + it('asks for the bump type and pushes a new draft if previous tag was a release', async() => { + const latestTag: TaggedCommit = { + commit: 'hash', + tag: { + draftVersion: undefined, + releaseVersion: '0.8.0', + semverName: '0.8.0' + } + }; + getLatestDraftOrReleaseTagFromLog.returns(latestTag); + choose.resolves('minor'); + confirm.resolves(true); + + await triggerReleaseDraft( + 'root', + verifyGitStatus, + getLatestDraftOrReleaseTagFromLog, + choose, + confirm, + spawnSync + ); + + expect(verifyGitStatus).to.have.been.called; + expect(choose).to.have.been.called; + expect(confirm).to.have.been.called; + expect(spawnSync).to.have.been.calledTwice; + expect(spawnSync.getCall(0)).calledWith('git', ['tag', 'v0.9.0-draft.0'], sinon.match.any); + expect(spawnSync.getCall(1)).calledWith('git', ['push', 'origin', 'v0.9.0-draft.0'], sinon.match.any); + }); + + it('fails if no previous tag is found', async() => { + getLatestDraftOrReleaseTagFromLog.returns(undefined); + try { + await triggerReleaseDraft( + 'root', + verifyGitStatus, + getLatestDraftOrReleaseTagFromLog, + choose, + confirm, + spawnSync + ); + } catch (e) { + expect(e.message).to.contain('Could not find a previous draft or release tag.'); + expect(verifyGitStatus).to.have.been.called; + expect(choose).to.not.have.been.called; + expect(confirm).to.not.have.been.called; + expect(spawnSync).to.not.have.been.called; + return; + } + expect.fail('Expected error'); + }); + + it('aborts if user does not confirm', async() => { + const latestTag: TaggedCommit = { + commit: 'hash', + tag: { + draftVersion: 7, + releaseVersion: '0.8.0', + semverName: '0.8.0-draft.7' + } + }; + getLatestDraftOrReleaseTagFromLog.returns(latestTag); + confirm.resolves(false); + + try { + await triggerReleaseDraft( + 'root', + verifyGitStatus, + getLatestDraftOrReleaseTagFromLog, + choose, + confirm, + spawnSync + ); + } catch (e) { + expect(e.message).to.contain('User aborted'); + expect(verifyGitStatus).to.have.been.called; + expect(choose).to.not.have.been.called; + expect(confirm).to.have.been.called; + expect(spawnSync).to.not.have.been.called; + return; + } + expect.fail('Expected error'); + }); + }); + + describe('computeNextTagName', () => { + const draftTag: TagDetails = { + semverName: '0.8.0-draft.8', + draftVersion: 8, + releaseVersion: '0.8.0' + }; + const releaseTag: TagDetails = { + semverName: '0.8.0', + draftVersion: undefined, + releaseVersion: '0.8.0' + }; + + it('computes the next draft bump', () => { + const result = computeNextTagNameFn(draftTag, 'draft'); + expect(result).to.equal('v0.8.0-draft.9'); + }); + it('computes the next patch bump', () => { + const result = computeNextTagNameFn(releaseTag, 'patch'); + expect(result).to.equal('v0.8.1-draft.0'); + }); + it('computes the next minor bump', () => { + const result = computeNextTagNameFn(releaseTag, 'minor'); + expect(result).to.equal('v0.9.0-draft.0'); + }); + it('computes the next major bump', () => { + const result = computeNextTagNameFn(releaseTag, 'major'); + expect(result).to.equal('v1.0.0-draft.0'); + }); + it('fails on unknown bump type', () => { + try { + computeNextTagNameFn(releaseTag, 'what' as any); + } catch (e) { + expect(e.message).to.contain('unexpected bump type'); + return; + } + expect.fail('Expected error'); + }); + }); +}); diff --git a/packages/build/src/local/trigger-release-draft.ts b/packages/build/src/local/trigger-release-draft.ts new file mode 100644 index 0000000000..f7a9d2f8b9 --- /dev/null +++ b/packages/build/src/local/trigger-release-draft.ts @@ -0,0 +1,85 @@ +import assert from 'assert'; +import semver from 'semver'; +import { choose as chooseFn, confirm as confirmFn, spawnSync as spawnSyncFn } from '../helpers'; +import { getLatestDraftOrReleaseTagFromLog as getLatestDraftOrReleaseTagFromLogFn, TagDetails } from './get-latest-tag'; +import { verifyGitStatus as verifyGitStatusFn } from './repository-status'; + +type BumpType = 'draft' | 'patch' | 'minor' | 'major'; + +export async function triggerReleaseDraft( + repositoryRoot: string, + verifyGitStatus: typeof verifyGitStatusFn = verifyGitStatusFn, + getLatestDraftOrReleaseTagFromLog: typeof getLatestDraftOrReleaseTagFromLogFn = getLatestDraftOrReleaseTagFromLogFn, + choose: typeof chooseFn = chooseFn, + confirm: typeof confirmFn = confirmFn, + spawnSync: typeof spawnSyncFn = spawnSyncFn +): Promise { + console.info('Triggering process to create a new release draft...'); + + verifyGitStatus(repositoryRoot); + + const latestDraftOrReleaseTag = getLatestDraftOrReleaseTagFromLog(repositoryRoot); + if (!latestDraftOrReleaseTag) { + throw new Error('Could not find a previous draft or release tag.'); + } + console.info(`-> Most recent tag: v${latestDraftOrReleaseTag.tag.semverName} on commit ${latestDraftOrReleaseTag.commit}`); + + let bumpType: BumpType = 'draft'; + if (latestDraftOrReleaseTag.tag.draftVersion === undefined) { + bumpType = await choose('> Select the type of increment for the new version', [ + 'patch', 'minor', 'major' + ], '... enter your choice:') as BumpType; + } + + const nextTagName = computeNextTagNameFn(latestDraftOrReleaseTag.tag, bumpType); + console.info('-> New draft tag is:'); + console.info(` ${nextTagName}`); + + const confirmed = await confirm('!! Is this correct and should the draft process continue?'); + if (!confirmed) { + throw new Error('User aborted.'); + } + + console.info('... creating and pushing tag ...'); + spawnSync('git', ['tag', nextTagName], { + cwd: repositoryRoot, + encoding: 'utf-8' + }); + spawnSync('git', ['push', 'origin', nextTagName], { + cwd: repositoryRoot, + encoding: 'utf-8' + }); + + console.info('SUCCESS! Your new draft has been tagged and pushed.'); +} + +export function computeNextTagNameFn(latestDraftOrReleaseTag: TagDetails, bumpType: BumpType): string { + if (latestDraftOrReleaseTag.draftVersion !== undefined) { + assert(bumpType === 'draft'); + return `v${latestDraftOrReleaseTag.releaseVersion}-draft.${latestDraftOrReleaseTag.draftVersion + 1}`; + } + assert(bumpType !== 'draft'); + + let major = semver.major(latestDraftOrReleaseTag.releaseVersion); + let minor = semver.minor(latestDraftOrReleaseTag.releaseVersion); + let patch = semver.patch(latestDraftOrReleaseTag.releaseVersion); + + switch (bumpType) { + case 'patch': + patch += 1; + break; + case 'minor': + patch = 0; + minor += 1; + break; + case 'major': + patch = 0; + minor = 0; + major += 1; + break; + default: + throw new Error(`unexpected bump type ${bumpType}`); + } + + return `v${major}.${minor}.${patch}-draft.0`; +} diff --git a/packages/build/src/local/trigger-release-publish.spec.ts b/packages/build/src/local/trigger-release-publish.spec.ts new file mode 100644 index 0000000000..8bb54c8559 --- /dev/null +++ b/packages/build/src/local/trigger-release-publish.spec.ts @@ -0,0 +1,226 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { EvergreenApi, EvergreenTask } from '../evergreen'; +import { TaggedCommit } from './get-latest-tag'; +import { triggerReleasePublish, verifyEvergreenStatusFn } from './trigger-release-publish'; + +describe('local trigger-release-publish', () => { + describe('triggerReleasePublish', () => { + let verifyGitStatus: sinon.SinonStub; + let getLatestDraftOrReleaseTagFromLog:sinon.SinonStub; + let confirm: sinon.SinonStub; + let verifyEvergreenStatus: sinon.SinonStub; + let spawnSync: sinon.SinonStub; + + beforeEach(() => { + verifyGitStatus = sinon.stub(); + getLatestDraftOrReleaseTagFromLog = sinon.stub(); + confirm = sinon.stub(); + verifyEvergreenStatus = sinon.stub(); + spawnSync = sinon.stub(); + }); + + it('creates a new release tag and pushes when everything is good', async() => { + const latestTag: TaggedCommit = { + commit: 'hash', + tag: { + draftVersion: 7, + releaseVersion: '0.8.0', + semverName: '0.8.0-draft.7' + } + }; + getLatestDraftOrReleaseTagFromLog.returns(latestTag); + confirm.resolves(true); + + await triggerReleasePublish( + 'root', + verifyGitStatus, + getLatestDraftOrReleaseTagFromLog, + confirm, + verifyEvergreenStatus, + spawnSync + ); + + expect(verifyGitStatus).to.have.been.called; + expect(confirm).to.have.been.called; + expect(verifyEvergreenStatus).to.have.been.called; + expect(spawnSync).to.have.been.calledTwice; + expect(spawnSync.getCall(0)).calledWith('git', ['tag', 'v0.8.0', 'hash'], sinon.match.any); + expect(spawnSync.getCall(1)).calledWith('git', ['push', 'origin', 'v0.8.0'], sinon.match.any); + }); + + it('fails if no previous tag is found', async() => { + getLatestDraftOrReleaseTagFromLog.returns(undefined); + try { + await triggerReleasePublish( + 'root', + verifyGitStatus, + getLatestDraftOrReleaseTagFromLog, + confirm, + verifyEvergreenStatus, + spawnSync + ); + } catch (e) { + expect(e.message).to.contain('Failed to find a prior tag to release from'); + expect(verifyGitStatus).to.have.been.called; + expect(confirm).to.not.have.been.called; + expect(verifyEvergreenStatus).to.not.have.been.called; + expect(spawnSync).to.not.have.been.called; + return; + } + expect.fail('Expected error'); + }); + + it('fails if the previous tag is not a draft', async() => { + const latestTag: TaggedCommit = { + commit: 'hash', + tag: { + draftVersion: undefined, + releaseVersion: '0.8.0', + semverName: '0.8.0' + } + }; + getLatestDraftOrReleaseTagFromLog.returns(latestTag); + + try { + await triggerReleasePublish( + 'root', + verifyGitStatus, + getLatestDraftOrReleaseTagFromLog, + confirm, + verifyEvergreenStatus, + spawnSync + ); + } catch (e) { + expect(e.message).to.contain('but it\'s not a draft'); + expect(verifyGitStatus).to.have.been.called; + expect(confirm).to.not.have.been.called; + expect(verifyEvergreenStatus).to.not.have.been.called; + expect(spawnSync).to.not.have.been.called; + return; + } + expect.fail('Expected error'); + }); + + it('fails if evergreen check fails', async() => { + const latestTag: TaggedCommit = { + commit: 'hash', + tag: { + draftVersion: 7, + releaseVersion: '0.8.0', + semverName: '0.8.0-draft.7' + } + }; + getLatestDraftOrReleaseTagFromLog.returns(latestTag); + confirm.resolves(true); + const expectedError = new Error('that failed'); + verifyEvergreenStatus.rejects(expectedError); + + try { + await triggerReleasePublish( + 'root', + verifyGitStatus, + getLatestDraftOrReleaseTagFromLog, + confirm, + verifyEvergreenStatus, + spawnSync + ); + } catch (e) { + expect(e).to.equal(expectedError); + expect(verifyGitStatus).to.have.been.called; + expect(confirm).to.have.been.called; + expect(spawnSync).to.not.have.been.called; + return; + } + expect.fail('Expected error'); + }); + + it('aborts if user does not confirm', async() => { + const latestTag: TaggedCommit = { + commit: 'hash', + tag: { + draftVersion: 7, + releaseVersion: '0.8.0', + semverName: '0.8.0-draft.7' + } + }; + getLatestDraftOrReleaseTagFromLog.returns(latestTag); + confirm.resolves(false); + + try { + await triggerReleasePublish( + 'root', + verifyGitStatus, + getLatestDraftOrReleaseTagFromLog, + confirm, + verifyEvergreenStatus, + spawnSync + ); + } catch (e) { + expect(e.message).to.contain('User aborted'); + expect(verifyGitStatus).to.have.been.called; + expect(confirm).to.have.been.called; + expect(spawnSync).to.not.have.been.called; + return; + } + expect.fail('Expected error'); + }); + }); + + describe('verifyEvergreenStatus', () => { + let evergreenProvider: Promise; + let getTasks: sinon.SinonStub; + + const failedTask: EvergreenTask = { + task_id: 'task1', + version_id: 'v1', + build_variant: 'windows', + display_name: 'Task 1', + status: 'failed' + }; + const successTask: EvergreenTask = { + task_id: 'task2', + version_id: 'v2', + build_variant: 'windows', + display_name: 'Task 2', + status: 'success' + }; + + beforeEach(() => { + getTasks = sinon.stub(); + evergreenProvider = Promise.resolve({ + getTasks + } as unknown as EvergreenApi); + }); + + it('works if all tasks are successful', async() => { + getTasks.resolves([successTask]); + await verifyEvergreenStatusFn('sha', evergreenProvider); + expect(getTasks).to.have.been.calledWith('mongosh', 'sha'); + }); + + it('fails if evergreen fails', async() => { + const expectedError = new Error('failed'); + getTasks.rejects(expectedError); + try { + await verifyEvergreenStatusFn('sha', evergreenProvider); + } catch (e) { + expect(e).to.equal(expectedError); + return; + } + expect.fail('Expected error'); + }); + + it('fails if there are failed tasks', async() => { + getTasks.resolves([successTask, failedTask]); + try { + await verifyEvergreenStatusFn('sha', evergreenProvider); + } catch (e) { + expect(e.message).to.contain('Some Evergreen tasks were not successful'); + expect(getTasks).to.have.been.calledWith('mongosh', 'sha'); + return; + } + expect.fail('Expected error'); + }); + }); +}); diff --git a/packages/build/src/local/trigger-release-publish.ts b/packages/build/src/local/trigger-release-publish.ts new file mode 100644 index 0000000000..5a0495af36 --- /dev/null +++ b/packages/build/src/local/trigger-release-publish.ts @@ -0,0 +1,68 @@ +import { EvergreenApi } from '../evergreen'; +import { confirm as confirmFn, spawnSync as spawnSyncFn } from '../helpers'; +import { getLatestDraftOrReleaseTagFromLog as getLatestDraftOrReleaseTagFromLogFn } from './get-latest-tag'; +import { verifyGitStatus as verifyGitStatusFn } from './repository-status'; + +export async function triggerReleasePublish( + repositoryRoot: string, + verifyGitStatus: typeof verifyGitStatusFn = verifyGitStatusFn, + getLatestDraftOrReleaseTagFromLog: typeof getLatestDraftOrReleaseTagFromLogFn = getLatestDraftOrReleaseTagFromLogFn, + confirm: typeof confirmFn = confirmFn, + verifyEvergreenStatus: typeof verifyEvergreenStatusFn = verifyEvergreenStatusFn, + spawnSync: typeof spawnSyncFn = spawnSyncFn +): Promise { + console.info('Triggering process to publish a new release...'); + + verifyGitStatus(repositoryRoot); + + const latestDraftTag = getLatestDraftOrReleaseTagFromLog(repositoryRoot); + if (!latestDraftTag) { + throw new Error('Failed to find a prior tag to release from.'); + } + if (latestDraftTag.tag.draftVersion === undefined) { + throw new Error(`Found prior tag v${latestDraftTag.tag.semverName} - but it's not a draft.`); + } + const releaseTag = `v${latestDraftTag.tag.releaseVersion}`; + + console.info('-> Found most recent draft tag:'); + console.info(` version: v${latestDraftTag.tag.semverName}`); + console.info(` commit: ${latestDraftTag.commit}`); + console.info(` release: ${releaseTag}`); + const confirmed = await confirm(`!! Is this correct and should we tag ${latestDraftTag.commit} as ${releaseTag}?`); + if (!confirmed) { + throw new Error('User aborted.'); + } + + console.info('... verifying evergreen status ...'); + await verifyEvergreenStatus(latestDraftTag.commit); + + console.info('... tagging commit and pushing ...'); + spawnSync('git', ['tag', releaseTag, latestDraftTag.commit], { + cwd: repositoryRoot, + encoding: 'utf-8' + }); + spawnSync('git', ['push', 'origin', releaseTag], { + cwd: repositoryRoot, + encoding: 'utf-8' + }); + + console.info('SUCCESS! Your new release has been tagged and published.'); +} + +export async function verifyEvergreenStatusFn( + commitSha: string, + evergreenApiProvider: Promise = EvergreenApi.fromUserConfiguration() +): Promise { + const evergreenApi = await evergreenApiProvider; + const tasks = await evergreenApi.getTasks('mongosh', commitSha); + const unsuccessfulTasks = tasks.filter(t => t.status !== 'success'); + + if (unsuccessfulTasks.length) { + console.error('!! Detected the following failed tasks on Evergreen:'); + unsuccessfulTasks.forEach(t => { + console.error(` > ${t.display_name} on ${t.build_variant}`); + }); + console.error('!! Please trigger a new draft and ensure all tasks complete successfully.'); + throw new Error('Some Evergreen tasks were not successful.'); + } +} diff --git a/packages/build/src/local/trigger-release.ts b/packages/build/src/local/trigger-release.ts new file mode 100644 index 0000000000..fb6f2c9685 --- /dev/null +++ b/packages/build/src/local/trigger-release.ts @@ -0,0 +1,23 @@ +import path from 'path'; +import { triggerReleaseDraft } from './trigger-release-draft'; +import { triggerReleasePublish } from './trigger-release-publish'; + +export async function triggerRelease(args: string[]): Promise { + if (args.length < 1) { + throw new Error('Missing command to trigger release: draft/publish'); + } + + const repositoryRoot = path.resolve(__dirname, '..', '..', '..', '..'); + + const command = args[0]; + switch (command) { + case 'draft': + await triggerReleaseDraft(repositoryRoot); + break; + case 'publish': + await triggerReleasePublish(repositoryRoot); + break; + default: + throw new Error(`Unknown command ${command} - must be draft or publish`); + } +} diff --git a/packages/build/src/npm-packages/bump.ts b/packages/build/src/npm-packages/bump.ts index 6964425424..d67476ce49 100644 --- a/packages/build/src/npm-packages/bump.ts +++ b/packages/build/src/npm-packages/bump.ts @@ -1,5 +1,5 @@ import { LERNA_BIN, PLACEHOLDER_VERSION, PROJECT_ROOT } from './constants'; -import { spawnSync } from './spawn-sync'; +import { spawnSync } from '../helpers/spawn-sync'; export function bumpNpmPackages( version: string, diff --git a/packages/build/src/npm-packages/list.ts b/packages/build/src/npm-packages/list.ts index 911240e389..62a7d6a931 100644 --- a/packages/build/src/npm-packages/list.ts +++ b/packages/build/src/npm-packages/list.ts @@ -1,5 +1,5 @@ import { LERNA_BIN, PROJECT_ROOT } from './constants'; -import { spawnSync } from './spawn-sync'; +import { spawnSync } from '../helpers/spawn-sync'; export interface LernaPackageDescription { name: string; diff --git a/packages/build/src/npm-packages/publish.ts b/packages/build/src/npm-packages/publish.ts index 2d9fcd0151..c926c70e26 100644 --- a/packages/build/src/npm-packages/publish.ts +++ b/packages/build/src/npm-packages/publish.ts @@ -1,7 +1,7 @@ import path from 'path'; import { LERNA_BIN, PLACEHOLDER_VERSION, PROJECT_ROOT } from './constants'; import { LernaPackageDescription, listNpmPackages as listNpmPackagesFn } from './list'; -import { spawnSync } from './spawn-sync'; +import { spawnSync } from '../helpers/spawn-sync'; export function publishNpmPackages( listNpmPackages: typeof listNpmPackagesFn = listNpmPackagesFn, diff --git a/packages/build/src/release.ts b/packages/build/src/release.ts index c846cabf20..7fadc06e4e 100644 --- a/packages/build/src/release.ts +++ b/packages/build/src/release.ts @@ -28,7 +28,7 @@ export async function release( ): Promise { config = { ...config, - version: await getReleaseVersionFromTag(config.triggeringGitTag) || config.version + version: getReleaseVersionFromTag(config.triggeringGitTag) || config.version }; console.info( diff --git a/packages/build/tsconfig.json b/packages/build/tsconfig.json index eff2d14183..63aa2184d8 100644 --- a/packages/build/tsconfig.json +++ b/packages/build/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../config/tsconfig.base.json", "compilerOptions": { "outDir": "./lib", - "allowJs": true + "allowJs": true, + "removeComments": false }, "include": [ "./src/**/*"