From 853dd99746d1d919abc067f099fe422a41ca0b75 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Thu, 10 Mar 2022 16:10:43 +0100 Subject: [PATCH] test(ci): add tests for release process (#241) * test(ci): add tests for release process * chore: update test case * chore: update tests --- .../__tests__/create-release-issue.test.ts | 239 +++++++++++++++++ .../release/__tests__/process-release.test.ts | 31 +++ scripts/release/create-release-issue.ts | 240 +++++++++++------- scripts/release/process-release.ts | 24 +- 4 files changed, 426 insertions(+), 108 deletions(-) create mode 100644 scripts/release/__tests__/create-release-issue.test.ts create mode 100644 scripts/release/__tests__/process-release.test.ts diff --git a/scripts/release/__tests__/create-release-issue.test.ts b/scripts/release/__tests__/create-release-issue.test.ts new file mode 100644 index 0000000000..604c711f17 --- /dev/null +++ b/scripts/release/__tests__/create-release-issue.test.ts @@ -0,0 +1,239 @@ +import { + parseCommit, + getVersionChangesText, + decideReleaseStrategy, +} from '../create-release-issue'; + +describe('create release issue', () => { + it('parses commit', () => { + expect(parseCommit(`abcdefg fix(javascript): fix the thing`)).toEqual({ + hash: 'abcdefg', + lang: 'javascript', + message: 'fix the thing', + raw: 'abcdefg fix(javascript): fix the thing', + type: 'fix', + }); + }); + + it('returns error when language scope is missing', () => { + expect(parseCommit(`abcdefg fix: fix the thing`)).toEqual({ + error: 'missing-language-scope', + }); + }); + + it('returns error when language scope is unknown', () => { + expect(parseCommit(`abcdefg fix(basic): fix the thing`)).toEqual({ + error: 'unknown-language-scope', + }); + }); + + it('generates text for version changes', () => { + expect( + getVersionChangesText({ + javascript: { + current: '0.0.1', + next: '0.0.2', + }, + php: { + current: '0.0.1', + next: '0.0.2', + }, + java: { + current: '0.0.1', + next: '0.0.2', + }, + }) + ).toMatchInlineSnapshot(` + "- [x] javascript: v0.0.1 -> v0.0.2 + - [x] java: v0.0.1 -> v0.0.2 + - [x] php: v0.0.1 -> v0.0.2" + `); + }); + + it('generates text for version changes with a language with no commit', () => { + expect( + getVersionChangesText({ + javascript: { + current: '0.0.1', + next: '0.0.2', + }, + php: { + current: '0.0.1', + next: '0.0.1', + noCommit: true, + }, + java: { + current: '0.0.1', + next: '0.0.2', + }, + }) + ).toMatchInlineSnapshot(` + "- [x] javascript: v0.0.1 -> v0.0.2 + - [x] java: v0.0.1 -> v0.0.2 + - ~php: v0.0.1 (no commit)~" + `); + }); + + it('generates text for version changes with a language to skip', () => { + expect( + getVersionChangesText({ + javascript: { + current: '0.0.1', + next: '0.0.2', + }, + php: { + current: '0.0.1', + next: '0.0.1', + }, + java: { + current: '0.0.1', + next: '0.0.2', + skipRelease: true, + }, + }) + ).toMatchInlineSnapshot(` + "- [x] javascript: v0.0.1 -> v0.0.2 + - [ ] java: v0.0.1 -> v0.0.2 + - No \`feat\` or \`fix\` commit, thus unchecked by default. + - [x] php: v0.0.1 -> v0.0.1" + `); + }); + + it('bumps major version for BREAKING CHANGE', () => { + const versions = decideReleaseStrategy({ + versions: { + javascript: { + current: '0.0.1', + }, + java: { + current: '0.0.1', + }, + php: { + current: '0.0.1', + }, + }, + commits: [ + { + hash: 'abcdefg', + type: 'feat', + lang: 'javascript', + message: 'update the API (BREAKING CHANGE)', + raw: 'abcdefg feat(javascript): update the API (BREAKING CHANGE)', + }, + ], + }); + + expect(versions.javascript.next).toEqual('1.0.0'); + }); + + it('bumps minor version for feat', () => { + const versions = decideReleaseStrategy({ + versions: { + javascript: { + current: '0.0.1', + }, + java: { + current: '0.0.1', + }, + php: { + current: '0.0.1', + }, + }, + commits: [ + { + hash: 'abcdefg', + type: 'feat', + lang: 'php', + message: 'update the API', + raw: 'abcdefg feat(php): update the API', + }, + ], + }); + + expect(versions.php.next).toEqual('0.1.0'); + }); + + it('bumps patch version for fix', () => { + const versions = decideReleaseStrategy({ + versions: { + javascript: { + current: '0.0.1', + }, + java: { + current: '0.0.1', + }, + php: { + current: '0.0.1', + }, + }, + commits: [ + { + hash: 'abcdefg', + type: 'fix', + lang: 'java', + message: 'fix some bug', + raw: 'abcdefg fix(java): fix some bug', + }, + ], + }); + + expect(versions.java.next).toEqual('0.0.2'); + }); + + it('marks noCommit for languages without any commit', () => { + const versions = decideReleaseStrategy({ + versions: { + javascript: { + current: '0.0.1', + }, + java: { + current: '0.0.1', + }, + php: { + current: '0.0.1', + }, + }, + commits: [ + { + hash: 'abcdefg', + type: 'fix', + lang: 'java', + message: 'fix some bug', + raw: 'abcdefg fix(java): fix some bug', + }, + ], + }); + + expect(versions.javascript.noCommit).toEqual(true); + expect(versions.php.noCommit).toEqual(true); + expect(versions.java.noCommit).toBeUndefined(); + }); + + it('marks skipRelease for patch upgrade without fix commit', () => { + const versions = decideReleaseStrategy({ + versions: { + javascript: { + current: '0.0.1', + }, + java: { + current: '0.0.1', + }, + php: { + current: '0.0.1', + }, + }, + commits: [ + { + hash: 'abcdefg', + type: 'chore', + lang: 'javascript', + message: 'update devDevpendencies', + raw: 'abcdefg chore(javascript): update devDevpendencies', + }, + ], + }); + expect(versions.javascript.skipRelease).toEqual(true); + expect(versions.java.skipRelease).toBeUndefined(); + expect(versions.php.skipRelease).toBeUndefined(); + }); +}); diff --git a/scripts/release/__tests__/process-release.test.ts b/scripts/release/__tests__/process-release.test.ts new file mode 100644 index 0000000000..2f174f234d --- /dev/null +++ b/scripts/release/__tests__/process-release.test.ts @@ -0,0 +1,31 @@ +import { getVersionsToRelease, getLangsToUpdateRepo } from '../process-release'; + +describe('process release', () => { + it('gets versions to release', () => { + const versions = getVersionsToRelease(` + ## Version Changes + + - [x] javascript: v1.0.0 -> v1.1.0 + - [x] php: v2.0.0 -> v2.0.1 + - [ ] java: v3.0.0 -> v3.0.1 + `); + + expect(Object.keys(versions)).toEqual(['javascript', 'php']); + expect(versions.javascript.current).toEqual('1.0.0'); + expect(versions.javascript.next).toEqual('1.1.0'); + expect(versions.php.current).toEqual('2.0.0'); + expect(versions.php.next).toEqual('2.0.1'); + }); + + it('gets langs to update', () => { + expect( + getLangsToUpdateRepo(` +## Version Changes + +- [ ] javascript: v1.0.0 -> v1.1.0 +- [x] php: v2.0.0 -> v2.0.1 +- [ ] java: v3.0.0 -> v3.0.1 +`) + ).toEqual(['javascript', 'java']); + }); +}); diff --git a/scripts/release/create-release-issue.ts b/scripts/release/create-release-issue.ts index be1a01f7c5..e50b4b2eba 100755 --- a/scripts/release/create-release-issue.ts +++ b/scripts/release/create-release-issue.ts @@ -12,7 +12,6 @@ dotenv.config({ path: ROOT_ENV_PATH }); type Version = { current: string; - langName: string; next?: string | null; noCommit?: boolean; skipRelease?: boolean; @@ -29,7 +28,6 @@ function readVersions(): Versions { if (!versions[gen.language]) { versions[gen.language] = { current: gen.additionalProperties?.packageVersion, - langName: gen.language, next: undefined, }; } @@ -37,11 +35,122 @@ function readVersions(): Versions { return versions; } -if (!process.env.GITHUB_TOKEN) { - throw new Error('Environment variable `GITHUB_TOKEN` does not exist.'); +export function getVersionChangesText(versions: Versions): string { + return LANGUAGES.map((lang) => { + const { current, next, noCommit, skipRelease } = versions[lang]; + + if (noCommit) { + return `- ~${lang}: v${current} (${TEXT.noCommit})~`; + } + + if (!current) { + return `- ~${lang}: (${TEXT.currentVersionNotFound})~`; + } + + const checked = skipRelease ? ' ' : 'x'; + return [ + `- [${checked}] ${lang}: v${current} -> v${next}`, + skipRelease && TEXT.descriptionForSkippedLang, + ] + .filter(Boolean) + .join('\n'); + }).join('\n'); +} + +type PassedCommit = { + hash: string; + type: string; + lang: string; + message: string; + raw: string; +}; + +type Commit = + | PassedCommit + | { error: 'missing-language-scope' } + | { error: 'unknown-language-scope' }; + +export function parseCommit(commit: string): Commit { + const hash = commit.slice(0, 7); + let message = commit.slice(8); + let type = message.slice(0, message.indexOf(':')); + const matchResult = type.match(/(.+)\((.+)\)/); + if (!matchResult) { + return { + error: 'missing-language-scope', + }; + } + message = message.slice(message.indexOf(':') + 1).trim(); + type = matchResult[1]; + const lang = matchResult[2]; + + if (!LANGUAGES.includes(lang)) { + return { error: 'unknown-language-scope' }; + } + + return { + hash, + type, // `fix` | `feat` | `chore` | ... + lang, // `javascript` | `php` | `java` | ... + message, + raw: commit, + }; +} + +export function decideReleaseStrategy({ + versions, + commits, +}: { + versions: Versions; + commits: PassedCommit[]; +}): Versions { + const ret: Versions = { ...versions }; + + LANGUAGES.forEach((lang) => { + const commitsPerLang = commits.filter((commit) => commit.lang === lang); + const currentVersion = versions[lang].current; + + if (commitsPerLang.length === 0) { + ret[lang].next = currentVersion; + ret[lang].noCommit = true; + return; + } + + if (semver.prerelease(currentVersion)) { + // if version is like 0.1.2-beta.1, it increases to 0.1.2-beta.2, even if there's a breaking change. + ret[lang].next = semver.inc(currentVersion, 'prerelease'); + return; + } + + if ( + commitsPerLang.some((commit) => + commit.message.includes('BREAKING CHANGE') + ) + ) { + ret[lang].next = semver.inc(currentVersion, 'major'); + return; + } + + const commitTypes = new Set(commitsPerLang.map(({ type }) => type)); + if (commitTypes.has('feat')) { + ret[lang].next = semver.inc(currentVersion, 'minor'); + return; + } + + ret[lang].next = semver.inc(currentVersion, 'patch'); + if (!commitTypes.has('fix')) { + ret[lang].skipRelease = true; + } + }); + + return ret; } async function createReleaseIssue(): Promise { + if (!process.env.GITHUB_TOKEN) { + throw new Error('Environment variable `GITHUB_TOKEN` does not exist.'); + } + if ((await run('git rev-parse --abbrev-ref HEAD')) !== MAIN_BRANCH) { throw new Error( `You can run this script only from \`${MAIN_BRANCH}\` branch.` @@ -58,122 +167,57 @@ async function createReleaseIssue(): Promise { errorMessage: '`released` tag is missing in this repository.', }); - // Reading versions from `openapitools.json` - const versions = readVersions(); - console.log('Pulling from origin...'); run(`git pull`); console.log('Pushing to origin...'); run(`git push`); - const commitsWithoutScope: string[] = []; - const commitsWithNonLanguageScope: string[] = []; + const commitsWithUnknownLanguageScope: string[] = []; + const commitsWithoutLanguageScope: string[] = []; // Reading commits since last release - type LatestCommit = { - hash: string; - type: string; - lang: string; - message: string; - raw: string; - }; const latestCommits = ( await run(`git log --oneline ${RELEASED_TAG}..${MAIN_BRANCH}`) ) .split('\n') .filter(Boolean) - .map((commit) => { - const hash = commit.slice(0, 7); - let message = commit.slice(8); - let type = message.slice(0, message.indexOf(':')); - const matchResult = type.match(/(.+)\((.+)\)/); - if (!matchResult) { - commitsWithoutScope.push(commit); - return undefined; - } - message = message.slice(message.indexOf(':') + 1).trim(); - type = matchResult[1]; - const lang = matchResult[2]; - - if (!LANGUAGES.includes(lang)) { - commitsWithNonLanguageScope.push(commit); - return undefined; + .map((commitMessage) => { + const commit = parseCommit(commitMessage); + + if ('error' in commit) { + if (commit.error === 'missing-language-scope') { + commitsWithoutLanguageScope.push(commitMessage); + return undefined; + } + + if (commit.error === 'unknown-language-scope') { + commitsWithUnknownLanguageScope.push(commitMessage); + return undefined; + } } - return { - hash, - type, // `fix` | `feat` | `chore` | ... - lang, // `javascript` | `php` | `java` | ... - message, - raw: commit, - }; + return commit; }) - .filter(Boolean) as LatestCommit[]; + .filter(Boolean) as PassedCommit[]; console.log('[INFO] Skipping these commits due to lack of language scope:'); - console.log(commitsWithoutScope.map((commit) => ` ${commit}`).join('\n')); + console.log( + commitsWithoutLanguageScope.map((commit) => ` ${commit}`).join('\n') + ); console.log(''); - console.log('[INFO] Skipping these commits due to wrong scopes:'); + console.log('[INFO] Skipping these commits due to unknown language scope:'); console.log( - commitsWithNonLanguageScope.map((commit) => ` ${commit}`).join('\n') + commitsWithUnknownLanguageScope.map((commit) => ` ${commit}`).join('\n') ); - LANGUAGES.forEach((lang) => { - const commits = latestCommits.filter( - (lastestCommit) => lastestCommit.lang === lang - ); - const currentVersion = versions[lang].current; - - if (commits.length === 0) { - versions[lang].next = currentVersion; - versions[lang].noCommit = true; - return; - } - - if (semver.prerelease(currentVersion)) { - // if version is like 0.1.2-beta.1, it increases to 0.1.2-beta.2, even if there's a breaking change. - versions[lang].next = semver.inc(currentVersion, 'prerelease'); - return; - } - - if (commits.some((commit) => commit.message.includes('BREAKING CHANGE'))) { - versions[lang].next = semver.inc(currentVersion, 'major'); - return; - } - - const commitTypes = new Set(commits.map(({ type }) => type)); - if (commitTypes.has('feat')) { - versions[lang].next = semver.inc(currentVersion, 'minor'); - return; - } - - versions[lang].next = semver.inc(currentVersion, 'patch'); - if (!commitTypes.has('fix')) { - versions[lang].skipRelease = true; - } + const versions = decideReleaseStrategy({ + versions: readVersions(), + commits: latestCommits, }); - const versionChanges = LANGUAGES.map((lang) => { - const { current, next, noCommit, skipRelease, langName } = versions[lang]; - - if (noCommit) { - return `- ~${langName}: v${current} (${TEXT.noCommit})~`; - } - - if (!current) { - return `- ~${langName}: (${TEXT.currentVersionNotFound})~`; - } - - const checked = skipRelease ? ' ' : 'x'; - return [ - `- [${checked}] ${langName}: v${current} -> v${next}`, - skipRelease && TEXT.descriptionForSkippedLang, - ] - .filter(Boolean) - .join('\n'); - }).join('\n'); + const versionChanges = getVersionChangesText(versions); const changelogs = LANGUAGES.filter( (lang) => !versions[lang].noCommit && versions[lang].current @@ -184,7 +228,7 @@ async function createReleaseIssue(): Promise { } return [ - `### ${versions[lang].langName}`, + `### ${lang}`, ...latestCommits .filter((commit) => commit.lang === lang) .map((commit) => `- ${commit.raw}`), @@ -226,4 +270,6 @@ async function createReleaseIssue(): Promise { }); } -createReleaseIssue(); +if (require.main === module) { + createReleaseIssue(); +} diff --git a/scripts/release/process-release.ts b/scripts/release/process-release.ts index 6a8559eb91..b0e3a4a1e2 100755 --- a/scripts/release/process-release.ts +++ b/scripts/release/process-release.ts @@ -26,14 +26,6 @@ import TEXT from './text'; dotenv.config({ path: ROOT_ENV_PATH }); -if (!process.env.GITHUB_TOKEN) { - throw new Error('Environment variable `GITHUB_TOKEN` does not exist.'); -} - -if (!process.env.EVENT_NUMBER) { - throw new Error('Environment variable `EVENT_NUMBER` does not exist.'); -} - function getIssueBody(): string { return JSON.parse( execa.sync('curl', [ @@ -56,7 +48,7 @@ function getDateStamp(): string { return new Date().toISOString().split('T')[0]; } -function getVersionsToRelease(issueBody: string): VersionsToRelease { +export function getVersionsToRelease(issueBody: string): VersionsToRelease { const versionsToRelease: VersionsToRelease = {}; const dateStamp = getDateStamp(); @@ -78,7 +70,7 @@ function getVersionsToRelease(issueBody: string): VersionsToRelease { return versionsToRelease; } -function getLangsToUpdateRepo(issueBody: string): string[] { +export function getLangsToUpdateRepo(issueBody: string): string[] { return getMarkdownSection(issueBody, TEXT.versionChangeHeader) .split('\n') .map((line) => { @@ -113,6 +105,14 @@ async function configureGitHubAuthor(cwd?: string): Promise { } async function processRelease(): Promise { + if (!process.env.GITHUB_TOKEN) { + throw new Error('Environment variable `GITHUB_TOKEN` does not exist.'); + } + + if (!process.env.EVENT_NUMBER) { + throw new Error('Environment variable `EVENT_NUMBER` does not exist.'); + } + const issueBody = getIssueBody(); if ( @@ -219,4 +219,6 @@ async function processRelease(): Promise { await run(`git push --tags`); } -processRelease(); +if (require.main === module) { + processRelease(); +}