From 1ab7dfc5443197303f1fecc7ebfe0be4a6180ae6 Mon Sep 17 00:00:00 2001 From: Michael Rose Date: Fri, 19 Feb 2021 14:37:54 +0100 Subject: [PATCH 1/2] chore(build): allow support releases MONGOSH-535 --- .../build/src/local/get-latest-tag.spec.ts | 94 +++++++++---------- packages/build/src/local/get-latest-tag.ts | 31 +++++- .../build/src/local/repository-status.spec.ts | 73 ++++++++++---- packages/build/src/local/repository-status.ts | 37 ++++++-- .../src/local/trigger-release-draft.spec.ts | 57 ++++++++++- .../build/src/local/trigger-release-draft.ts | 16 +++- .../src/local/trigger-release-publish.ts | 2 +- 7 files changed, 223 insertions(+), 87 deletions(-) diff --git a/packages/build/src/local/get-latest-tag.spec.ts b/packages/build/src/local/get-latest-tag.spec.ts index 291dda5ed8..e794562a60 100644 --- a/packages/build/src/local/get-latest-tag.spec.ts +++ b/packages/build/src/local/get-latest-tag.spec.ts @@ -10,61 +10,57 @@ describe('local get-latest-tag', () => { }); 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', + [ + { + restriction: undefined, + expected: { + semverName: '0.8.1', + releaseVersion: '0.8.1', + draftVersion: undefined + } + }, + { + restriction: { major: 0, minor: 8, patch: 0 }, + expected: { + semverName: '0.8.0', releaseVersion: '0.8.0', - draftVersion: 10 + draftVersion: undefined } - }); - }); - - 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: { + }, + { + restriction: { major: 0, minor: 8, patch: undefined }, + expected: { semverName: '0.8.1', releaseVersion: '0.8.1', draftVersion: undefined } + } + ].forEach(({ restriction, expected }) => { + it(`extracts the latest tag when restricted to ${JSON.stringify(restriction)}`, () => { + 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', + restriction, + spawnSync + ); + expect(result).to.deep.equal({ + commit: 'tagHash', + tag: expected + }); }); }); }); diff --git a/packages/build/src/local/get-latest-tag.ts b/packages/build/src/local/get-latest-tag.ts index 910f05b76c..fa34b2ab9d 100644 --- a/packages/build/src/local/get-latest-tag.ts +++ b/packages/build/src/local/get-latest-tag.ts @@ -12,8 +12,15 @@ export interface TagDetails { draftVersion: number | undefined; } +export interface ReleaseVersion { + major: number | undefined; + minor: number | undefined; + patch: number | undefined; +} + export function getLatestDraftOrReleaseTagFromLog( repositoryRoot: string, + versionRestriction: ReleaseVersion | undefined, spawnSync: typeof spawnSyncFn = spawnSyncFn ): TaggedCommit | undefined { const gitTags = spawnSync('git', ['tag'], { @@ -21,7 +28,7 @@ export function getLatestDraftOrReleaseTagFromLog( encoding: 'utf-8' }); - const tagDetails = extractTags(gitTags.stdout.split('\n')); + const tagDetails = extractTags(gitTags.stdout.split('\n'), versionRestriction); const sortedTagsWithCommit = tagDetails.sort((t1, t2) => { return -1 * semver.compare(t1.semverName, t2.semverName); }); @@ -42,7 +49,7 @@ export function getLatestDraftOrReleaseTagFromLog( }; } -function extractTags(gitTags: string[]): TagDetails[] { +function extractTags(gitTags: string[], versionRestriction: ReleaseVersion | undefined): TagDetails[] { const validTags = gitTags .map(tag => semver.valid(tag)) .filter(v => !!v) as string[]; @@ -53,9 +60,27 @@ function extractTags(gitTags: string[]): TagDetails[] { return undefined; } + const major = semver.major(semverTag); + const minor = semver.minor(semverTag); + const patch = semver.patch(semverTag); + + if (versionRestriction?.major !== undefined) { + if (major !== versionRestriction.major) { + return undefined; + } + if (versionRestriction.minor !== undefined) { + if (minor !== versionRestriction.minor) { + return undefined; + } + if (versionRestriction.patch !== undefined && versionRestriction.patch !== patch) { + return undefined; + } + } + } + return { semverName: semverTag, - releaseVersion: `${semver.major(semverTag)}.${semver.minor(semverTag)}.${semver.patch(semverTag)}`, + releaseVersion: `${major}.${minor}.${patch}`, draftVersion: prerelease ? parseInt(prerelease[1], 10) : undefined }; }).filter(t => !!t) as TagDetails[]; diff --git a/packages/build/src/local/repository-status.spec.ts b/packages/build/src/local/repository-status.spec.ts index bf8ab23287..0dc1007a3e 100644 --- a/packages/build/src/local/repository-status.spec.ts +++ b/packages/build/src/local/repository-status.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import sinon from 'sinon'; -import { getRepositoryStatus, RepositoryStatus, verifyGitStatus } from './repository-status'; +import { getReleaseVersionFromBranch, getRepositoryStatus, RepositoryStatus, verifyGitStatus } from './repository-status'; describe('local repository-status', () => { let spawnSync: sinon.SinonStub; @@ -16,7 +16,7 @@ describe('local repository-status', () => { getRepositoryStatus = sinon.stub(); }); - [ 'master', 'main', 'v0.8.0', 'v0.8.x' ].forEach(branchName => { + [ 'master', 'main', 'release/v0.8.0', 'release/v0.8.x' ].forEach(branchName => { it(`accepts a clean repository on ${branchName}`, () => { const status: RepositoryStatus = { branch: { @@ -28,8 +28,8 @@ describe('local repository-status', () => { hasUnpushedTags: false }; getRepositoryStatus.returns(status); - verifyGitStatus('root', getRepositoryStatus); - expect(getRepositoryStatus).to.have.been.calledOnce; + const returnedStatus = verifyGitStatus('root', getRepositoryStatus); + expect(returnedStatus).to.equal(status); }); }); @@ -150,23 +150,31 @@ describe('local repository-status', () => { }); describe('getRepositoryStatus', () => { - it('parses a clean repository correctly', () => { - spawnSync.returns({ - stdout: '## master...origin/master\n' - }); - spawnSync.onSecondCall().returns({ - stdout: 'Everything up-to-date' - }); + [ + 'master', + 'main', + 'release/v0.7.x', + 'release/another-branch', + 'release/v0.7.9' + ].forEach(branch => { + it('parses a clean repository correctly', () => { + spawnSync.returns({ + stdout: `## ${branch}...origin/${branch}\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 + const status = getRepositoryStatus('somePath', spawnSync); + expect(status).to.deep.equal({ + branch: { + local: branch, + tracking: `origin/${branch}`, + diverged: false + }, + clean: true, + hasUnpushedTags: false + }); }); }); @@ -267,4 +275,29 @@ describe('local repository-status', () => { }); }); }); + + describe('getReleaseVersionFromDraft', () => { + it('parses the release branch properly', () => { + const version = getReleaseVersionFromBranch('release/v0.8.3'); + expect(version).to.deep.equal({ + major: 0, + minor: 8, + patch: 3 + }); + }); + + it('handles a release branch that is not fully numbered', () => { + const version = getReleaseVersionFromBranch('release/v0.8.x'); + expect(version).to.deep.equal({ + major: 0, + minor: 8, + patch: undefined + }); + }); + + it('returns undefined for non-release branches', () => { + const version = getReleaseVersionFromBranch('master'); + expect(version).to.be.undefined; + }); + }); }); diff --git a/packages/build/src/local/repository-status.ts b/packages/build/src/local/repository-status.ts index e9cfe1f4b4..2156277664 100644 --- a/packages/build/src/local/repository-status.ts +++ b/packages/build/src/local/repository-status.ts @@ -11,16 +11,19 @@ export interface RepositoryStatus { hasUnpushedTags: boolean } +const MAIN_OR_MASTER_BRANCH = /^(main|master)$/; +const RELEASE_BRANCH = /^release\/v([a-z0-9]+\.[a-z0-9]+\.[a-z0-9]+)$/; + export function verifyGitStatus( repositoryRoot: string, getRepositoryStatusFn: typeof getRepositoryStatus = getRepositoryStatus -): void { +): RepositoryStatus { 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 (!MAIN_OR_MASTER_BRANCH.test(repositoryStatus.branch.local) && !RELEASE_BRANCH.test(repositoryStatus.branch.local)) { + throw new Error('The current branch does not match: master|main|release/vX.X.X'); } if (!repositoryStatus.branch.tracking) { throw new Error('The branch you are on is not tracking any remote branch.'); @@ -31,6 +34,7 @@ export function verifyGitStatus( if (repositoryStatus.hasUnpushedTags) { throw new Error('You have local tags that are not pushed to the remote. Remove or push those tags to continue.'); } + return repositoryStatus; } export function getRepositoryStatus( @@ -48,7 +52,7 @@ export function getRepositoryStatus( const result: RepositoryStatus = { clean: true, - hasUnpushedTags: tagStatus.stdout.trim() !== 'Everything up-to-date' + hasUnpushedTags: (tagStatus.stdout.trim() + tagStatus.stderr.trim()) !== 'Everything up-to-date' }; const output = gitStatus.stdout @@ -57,13 +61,13 @@ export function getRepositoryStatus( .filter(l => !!l); const branchOutput = output.find(l => l.match(/^## /)); - const branchInfo = branchOutput?.match(/^## ([^\s.]+)(\.\.\.([^\s]+)( \[[^\]]+])?)?/); + const branchInfo = branchOutput?.match(/^## ([^\s]+?((?=\.\.\.)|$))(\.\.\.([^\s]+)( \[[^\]]+])?)?/); if (branchInfo) { result.branch = { local: branchInfo[1], - tracking: branchInfo[3], - diverged: !!branchInfo[4] + tracking: branchInfo[4], + diverged: !!branchInfo[5] }; } @@ -73,3 +77,22 @@ export function getRepositoryStatus( return result; } +export function getReleaseVersionFromBranch(branchName: string | undefined): { major: number | undefined, minor: number | undefined, patch: number | undefined} | undefined { + const match = branchName?.match(RELEASE_BRANCH); + if (!match) { + return undefined; + } + + const versionParts = match[1].split('.'); + const numOrUndefiend = (num: string): number | undefined => { + const value = parseInt(num, 10); + return isNaN(value) ? undefined : value; + }; + + return { + major: numOrUndefiend(versionParts[0]), + minor: numOrUndefiend(versionParts[1]), + patch: numOrUndefiend(versionParts[2]), + }; +} + diff --git a/packages/build/src/local/trigger-release-draft.spec.ts b/packages/build/src/local/trigger-release-draft.spec.ts index 4b83e58260..66b63f2395 100644 --- a/packages/build/src/local/trigger-release-draft.spec.ts +++ b/packages/build/src/local/trigger-release-draft.spec.ts @@ -1,6 +1,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { TagDetails, TaggedCommit } from './get-latest-tag'; +import { RepositoryStatus } from './repository-status'; import { computeNextTagNameFn, triggerReleaseDraft } from './trigger-release-draft'; describe('local trigger-release-draft', () => { @@ -11,8 +12,18 @@ describe('local trigger-release-draft', () => { let confirm: sinon.SinonStub; let spawnSync: sinon.SinonStub; + const cleanRepoStatus: RepositoryStatus = { + branch: { + local: 'master', + tracking: 'origin/master', + diverged: false + }, + clean: true, + hasUnpushedTags: false + }; + beforeEach(() => { - verifyGitStatus = sinon.stub(); + verifyGitStatus = sinon.stub().returns(cleanRepoStatus); getLatestDraftOrReleaseTagFromLog = sinon.stub(); choose = sinon.stub(); confirm = sinon.stub(); @@ -28,6 +39,7 @@ describe('local trigger-release-draft', () => { semverName: '0.8.0-draft.7' } }; + getLatestDraftOrReleaseTagFromLog.returns(latestTag); confirm.resolves(true); @@ -48,7 +60,7 @@ describe('local trigger-release-draft', () => { 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() => { + it('asks for the bump type and pushes a new draft if previous tag was a release on master', async() => { const latestTag: TaggedCommit = { commit: 'hash', tag: { @@ -57,6 +69,7 @@ describe('local trigger-release-draft', () => { semverName: '0.8.0' } }; + getLatestDraftOrReleaseTagFromLog.returns(latestTag); choose.resolves('minor'); confirm.resolves(true); @@ -78,6 +91,46 @@ describe('local trigger-release-draft', () => { expect(spawnSync.getCall(1)).calledWith('git', ['push', 'origin', 'v0.9.0-draft.0'], sinon.match.any); }); + it('automatically does a patch when on a release branch (for a support release)', async() => { + const repoStatus: RepositoryStatus = { + branch: { + local: 'release/v0.8.2', + tracking: 'origin/release/v0.8.2', + diverged: false + }, + clean: true, + hasUnpushedTags: false + }; + + const latestTag: TaggedCommit = { + commit: 'hash', + tag: { + draftVersion: undefined, + releaseVersion: '0.8.2', + semverName: '0.8.2' + } + }; + + verifyGitStatus.returns(repoStatus); + getLatestDraftOrReleaseTagFromLog.returns(latestTag); + confirm.resolves(true); + + await triggerReleaseDraft( + 'root', + verifyGitStatus, + getLatestDraftOrReleaseTagFromLog, + choose, + confirm, + spawnSync + ); + + expect(verifyGitStatus).to.have.been.called; + expect(confirm).to.have.been.called; + expect(spawnSync).to.have.been.calledTwice; + expect(spawnSync.getCall(0)).calledWith('git', ['tag', 'v0.8.3-draft.0'], sinon.match.any); + expect(spawnSync.getCall(1)).calledWith('git', ['push', 'origin', 'v0.8.3-draft.0'], sinon.match.any); + }); + it('fails if no previous tag is found', async() => { getLatestDraftOrReleaseTagFromLog.returns(undefined); try { diff --git a/packages/build/src/local/trigger-release-draft.ts b/packages/build/src/local/trigger-release-draft.ts index f7a9d2f8b9..d623e3fb40 100644 --- a/packages/build/src/local/trigger-release-draft.ts +++ b/packages/build/src/local/trigger-release-draft.ts @@ -2,7 +2,7 @@ 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'; +import { getReleaseVersionFromBranch, verifyGitStatus as verifyGitStatusFn } from './repository-status'; type BumpType = 'draft' | 'patch' | 'minor' | 'major'; @@ -16,19 +16,25 @@ export async function triggerReleaseDraft( ): Promise { console.info('Triggering process to create a new release draft...'); - verifyGitStatus(repositoryRoot); + const repositoryStatus = verifyGitStatus(repositoryRoot); + const branchReleaseVersion = getReleaseVersionFromBranch(repositoryStatus.branch?.local); - const latestDraftOrReleaseTag = getLatestDraftOrReleaseTagFromLog(repositoryRoot); + const latestDraftOrReleaseTag = getLatestDraftOrReleaseTagFromLog(repositoryRoot, branchReleaseVersion); 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) { + let bumpType: BumpType; + if (branchReleaseVersion && latestDraftOrReleaseTag.tag.draftVersion === undefined) { + console.info('-> You are on a release branch, last tag was a release - assuming patch...'); + bumpType = 'patch'; + } else 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; + } else { + bumpType = 'draft'; } const nextTagName = computeNextTagNameFn(latestDraftOrReleaseTag.tag, bumpType); diff --git a/packages/build/src/local/trigger-release-publish.ts b/packages/build/src/local/trigger-release-publish.ts index 5a0495af36..9a422ddd82 100644 --- a/packages/build/src/local/trigger-release-publish.ts +++ b/packages/build/src/local/trigger-release-publish.ts @@ -15,7 +15,7 @@ export async function triggerReleasePublish( verifyGitStatus(repositoryRoot); - const latestDraftTag = getLatestDraftOrReleaseTagFromLog(repositoryRoot); + const latestDraftTag = getLatestDraftOrReleaseTagFromLog(repositoryRoot, undefined); if (!latestDraftTag) { throw new Error('Failed to find a prior tag to release from.'); } From fc72f6db7cf77aae892bd0f077d8f6cd610f4886 Mon Sep 17 00:00:00 2001 From: Michael Rose Date: Mon, 22 Feb 2021 08:31:23 +0100 Subject: [PATCH 2/2] fixup --- packages/build/src/local/repository-status.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/build/src/local/repository-status.ts b/packages/build/src/local/repository-status.ts index 2156277664..b5a64d6191 100644 --- a/packages/build/src/local/repository-status.ts +++ b/packages/build/src/local/repository-status.ts @@ -52,7 +52,7 @@ export function getRepositoryStatus( const result: RepositoryStatus = { clean: true, - hasUnpushedTags: (tagStatus.stdout.trim() + tagStatus.stderr.trim()) !== 'Everything up-to-date' + hasUnpushedTags: (tagStatus.stdout?.trim() ?? '' + tagStatus.stderr?.trim() ?? '') !== 'Everything up-to-date' }; const output = gitStatus.stdout