From 6b54417a459ea9ff4635bf820fd70c419ff75a8c Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Wed, 9 Mar 2022 10:49:37 +0100 Subject: [PATCH 1/3] test(ci): add tests for release process --- .../__tests__/create-release-issue.test.ts | 239 +++++++++++++++++ .../release/__tests__/process-release.test.ts | 28 ++ scripts/release/create-release-issue.ts | 240 +++++++++++------- scripts/release/process-release.ts | 24 +- 4 files changed, 423 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 00000000000..604c711f17d --- /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 00000000000..a08d9f313f7 --- /dev/null +++ b/scripts/release/__tests__/process-release.test.ts @@ -0,0 +1,28 @@ +import { getVersionsToRelease, getLangsToUpdateRepo } from '../process-release'; + +describe('process release', () => { + it('gets versions to release', () => { + expect( + getVersionsToRelease(` +## Version Changes + +- [x] javascript: v1.0.0 -> v1.1.0 +`) + ).toEqual({ + javascript: { + current: '1.0.0', + next: '1.1.0', + }, + }); + }); + + it('gets langs to update', () => { + expect( + getLangsToUpdateRepo(` +## Version Changes + +- [ ] javascript: v1.0.0 -> v1.1.0 +`) + ).toEqual(['javascript']); + }); +}); diff --git a/scripts/release/create-release-issue.ts b/scripts/release/create-release-issue.ts index c2dc26288bd..fc6b7580191 100755 --- a/scripts/release/create-release-issue.ts +++ b/scripts/release/create-release-issue.ts @@ -12,7 +12,6 @@ dotenv.config(); 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 b8e4dd819c1..7e058a24165 100755 --- a/scripts/release/process-release.ts +++ b/scripts/release/process-release.ts @@ -20,14 +20,6 @@ import TEXT from './text'; dotenv.config(); -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', [ @@ -45,7 +37,7 @@ type VersionsToRelease = { }; }; -function getVersionsToRelease(issueBody: string): VersionsToRelease { +export function getVersionsToRelease(issueBody: string): VersionsToRelease { const versionsToRelease: VersionsToRelease = {}; getMarkdownSection(issueBody, TEXT.versionChangeHeader) .split('\n') @@ -64,7 +56,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) => { @@ -99,6 +91,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 ( @@ -206,4 +206,6 @@ async function processRelease(): Promise { await run(`git push --tags`); } -processRelease(); +if (require.main === module) { + processRelease(); +} From ffa8a447457bf307ebbe67016fe83fd826f41cae Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Thu, 10 Mar 2022 11:41:34 +0100 Subject: [PATCH 2/3] chore: update test case --- .../release/__tests__/process-release.test.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/scripts/release/__tests__/process-release.test.ts b/scripts/release/__tests__/process-release.test.ts index a08d9f313f7..a75cceced59 100644 --- a/scripts/release/__tests__/process-release.test.ts +++ b/scripts/release/__tests__/process-release.test.ts @@ -2,18 +2,15 @@ import { getVersionsToRelease, getLangsToUpdateRepo } from '../process-release'; describe('process release', () => { it('gets versions to release', () => { - expect( - getVersionsToRelease(` -## Version Changes + const versions = getVersionsToRelease(` + ## Version Changes + + - [x] javascript: v1.0.0 -> v1.1.0 + `); -- [x] javascript: v1.0.0 -> v1.1.0 -`) - ).toEqual({ - javascript: { - current: '1.0.0', - next: '1.1.0', - }, - }); + expect(Object.keys(versions)).toEqual(['javascript']); + expect(versions.javascript.current).toEqual('1.0.0'); + expect(versions.javascript.next).toEqual('1.1.0'); }); it('gets langs to update', () => { From d68ccee283af203e0e562c1cfb6ed0064b80b2d9 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Thu, 10 Mar 2022 15:46:13 +0100 Subject: [PATCH 3/3] chore: update tests --- scripts/release/__tests__/process-release.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/release/__tests__/process-release.test.ts b/scripts/release/__tests__/process-release.test.ts index a75cceced59..2f174f234d8 100644 --- a/scripts/release/__tests__/process-release.test.ts +++ b/scripts/release/__tests__/process-release.test.ts @@ -6,11 +6,15 @@ describe('process release', () => { ## 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']); + 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', () => { @@ -19,7 +23,9 @@ describe('process release', () => { ## 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']); + ).toEqual(['javascript', 'java']); }); });