diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 0315bc7fad9..b0fda5f0ded 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -325,9 +325,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.TOKEN_GENERATE_BOT }} PR_NUMBER: ${{ github.event.number }} + HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }} - name: Spread generation to each repository if: ${{ steps.pushGeneratedCode.exitcode == 0 && github.ref == 'refs/heads/main' }} run: yarn workspace scripts spreadGeneration env: GITHUB_TOKEN: ${{ secrets.TOKEN_GENERATE_BOT }} + HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }} diff --git a/.github/workflows/process-release.yml b/.github/workflows/process-release.yml deleted file mode 100644 index b57833f3235..00000000000 --- a/.github/workflows/process-release.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Process release - -on: - issues: - types: - - closed - -jobs: - build: - name: Release - runs-on: ubuntu-20.04 - if: "startsWith(github.event.issue.title, 'chore: release')" - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 0 - token: ${{ secrets.TOKEN_RELEASE_BOT }} - - - name: Setup - id: setup - uses: ./.github/actions/setup - with: - workflow_name: process-release - - - run: yarn workspace scripts processRelease - env: - EVENT_NUMBER: ${{ github.event.issue.number }} - GITHUB_TOKEN: ${{ secrets.TOKEN_RELEASE_BOT }} - VERSION_TAG_ON_RELEASE: $${{ secrets.VERSION_TAG_ON_RELEASE }} diff --git a/scripts/ci/codegen/pushGeneratedCode.ts b/scripts/ci/codegen/pushGeneratedCode.ts index 6f0cae3777c..a143b50f96d 100644 --- a/scripts/ci/codegen/pushGeneratedCode.ts +++ b/scripts/ci/codegen/pushGeneratedCode.ts @@ -6,6 +6,10 @@ import { getNbGitDiff } from '../utils'; import text from './text'; const PR_NUMBER = parseInt(process.env.PR_NUMBER || '0', 10); +const IS_RELEASE_COMMIT = + process.env.HEAD_COMMIT_MESSAGE?.startsWith( + text.commitPrepareReleaseMessage + ) || false; async function isUpToDate(baseBranch: string): Promise { await run('git fetch origin'); @@ -60,16 +64,26 @@ export async function pushGeneratedCode(): Promise { return; } - const commitMessage = await run(`git show -s ${baseBranch} --format="${ - text.commitStartMessage - } %H. ${isMainBranch ? '[skip ci]' : ''} + const skipCi = isMainBranch ? '[skip ci]' : ''; + let message = await run( + `git show -s ${baseBranch} --format="${text.commitStartMessage} %H. ${skipCi}"` + ); + const authors = await run( + `git show -s ${baseBranch} --format=" Co-authored-by: %an <%ae> -%(trailers:key=Co-authored-by)"`); +%(trailers:key=Co-authored-by)"` + ); + + if (IS_RELEASE_COMMIT && isMainBranch) { + message = text.commitReleaseMessage; + } + + message += authors; console.log(`Pushing code to generated branch: '${branchToPush}'`); - await run(`git add .`); - await run(`git commit -m "${commitMessage}"`); + await run('git add .'); + await run(`git commit -m "${message}"`); await run(`git push origin ${branchToPush}`); if (PR_NUMBER) { diff --git a/scripts/ci/codegen/spreadGeneration.ts b/scripts/ci/codegen/spreadGeneration.ts index d3d56be914d..b92e50e5ecf 100644 --- a/scripts/ci/codegen/spreadGeneration.ts +++ b/scripts/ci/codegen/spreadGeneration.ts @@ -1,4 +1,5 @@ /* eslint-disable no-console */ +import execa from 'execa'; import { copy } from 'fs-extra'; import { @@ -10,13 +11,22 @@ import { REPO_URL, ensureGitHubToken, } from '../../common'; -import { getLanguageFolder } from '../../config'; -import { cloneRepository, configureGitHubAuthor } from '../../release/common'; +import { getLanguageFolder, getPackageVersionDefault } from '../../config'; +import { + cloneRepository, + configureGitHubAuthor, + RELEASED_TAG, +} from '../../release/common'; import type { Language } from '../../types'; import { getNbGitDiff } from '../utils'; import text from './text'; +const IS_RELEASE_COMMIT = + process.env.HEAD_COMMIT_MESSAGE?.startsWith( + text.commitPrepareReleaseMessage + ) || false; + export function decideWhereToSpread(commitMessage: string): Language[] { if (commitMessage.startsWith('chore: release')) { return []; @@ -77,6 +87,21 @@ async function spreadGeneration(): Promise { const commitMessage = cleanUpCommitMessage(lastCommitMessage); const langs = decideWhereToSpread(lastCommitMessage); + // At this point, we know the release will happen on every clients + // So we want to set the released tag at the monorepo level too. + if (IS_RELEASE_COMMIT) { + // remove old `released` tag + await run( + `git fetch origin refs/tags/${RELEASED_TAG}:refs/tags/${RELEASED_TAG}` + ); + await run(`git tag -d ${RELEASED_TAG}`); + await run(`git push --delete origin ${RELEASED_TAG}`); + + // create new `released` tag + await run(`git tag released`); + await run(`git push --tags`); + } + for (const lang of langs) { const { tempGitDir } = await cloneRepository({ lang, @@ -100,14 +125,24 @@ async function spreadGeneration(): Promise { continue; } + const version = getPackageVersionDefault(lang); + const message = IS_RELEASE_COMMIT + ? `chore: release ${version}` + : commitMessage; + await configureGitHubAuthor(tempGitDir); await run(`git add .`, { cwd: tempGitDir }); await gitCommit({ - message: commitMessage, + message, coAuthors: [author, ...coAuthors], cwd: tempGitDir, }); - await run(`git push`, { cwd: tempGitDir }); + await execa('git', ['tag', version], { + cwd: tempGitDir, + }); + await run(IS_RELEASE_COMMIT ? 'git push --follow-tags' : 'git push', { + cwd: tempGitDir, + }); console.log(`✅ Spread the generation to ${lang} repository.`); } } diff --git a/scripts/ci/codegen/text.ts b/scripts/ci/codegen/text.ts index 7de7ddb749f..76d73a4435f 100644 --- a/scripts/ci/codegen/text.ts +++ b/scripts/ci/codegen/text.ts @@ -1,7 +1,9 @@ -import { MAIN_BRANCH, REPO_URL } from '../../common'; +import { MAIN_BRANCH, REPO_URL, TODAY } from '../../common'; export default { commitStartMessage: 'chore: generated code for commit', + commitPrepareReleaseMessage: 'chore: prepare-release-', + commitReleaseMessage: `chore: release ${TODAY}`, notification: { header: '### 🔨 The codegen job will run at the end of the CI.', body: (): string => diff --git a/scripts/ci/husky/pre-commit.js b/scripts/ci/husky/pre-commit.js index 47a98d74e38..c53832ce79f 100755 --- a/scripts/ci/husky/pre-commit.js +++ b/scripts/ci/husky/pre-commit.js @@ -64,7 +64,7 @@ async function preCommit() { } console.log( - chalk.bgYellow('[INFO]'), + chalk.black.bgYellow('[INFO]'), `Generated file found, unstaging: ${stagedFile}` ); diff --git a/scripts/common.ts b/scripts/common.ts index 949ea8577a0..45d4c209e9a 100644 --- a/scripts/common.ts +++ b/scripts/common.ts @@ -23,6 +23,7 @@ export const MAIN_BRANCH = releaseConfig.mainBranch; export const OWNER = releaseConfig.owner; export const REPO = releaseConfig.repo; export const REPO_URL = `https://github.com/${OWNER}/${REPO}`; +export const TODAY = new Date().toISOString().split('T')[0]; export const CI = Boolean(process.env.CI); export const DOCKER = Boolean(process.env.DOCKER); diff --git a/scripts/package.json b/scripts/package.json index c76b869bb01..9eb37afa2cf 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -6,7 +6,6 @@ "createMatrix": "ts-node ci/githubActions/createMatrix.ts", "createReleaseIssue": "ts-node release/create-release-issue.ts", "pre-commit": "./ci/husky/pre-commit.js", - "processRelease": "ts-node release/process-release.ts", "pushGeneratedCode": "ts-node ci/codegen/pushGeneratedCode.ts", "renovateWeeklyPR": "ts-node ci/githubActions/renovateWeeklyPR.ts", "setRunVariables": "ts-node ci/githubActions/setRunVariables.ts", diff --git a/scripts/release/__tests__/common.test.ts b/scripts/release/__tests__/common.test.ts deleted file mode 100644 index fa261277f45..00000000000 --- a/scripts/release/__tests__/common.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { getMarkdownSection } from '../common'; - -describe('getMarkdownSection', () => { - it('gets the section correctly', () => { - const text = ` - # hello - - hi - - # world - - hey - `; - expect(getMarkdownSection(text, '# hello')).toMatchInlineSnapshot(` - "# hello - - hi - " - `); - }); - - it('gets the sub headings', () => { - const text = ` - # hi - - # hello - - ## sub-heading - - hello - - # this shouldn't be included - - right? - `; - - expect(getMarkdownSection(text, '# hello')).toMatchInlineSnapshot(` - "# hello - - ## sub-heading - - hello - " - `); - }); - - it('gets the whole text till the end', () => { - const text = ` - # hi - - # hello - - this is a test - `; - - expect(getMarkdownSection(text, '# hello')).toMatchInlineSnapshot(` - "# hello - - this is a test - " - `); - }); -}); diff --git a/scripts/release/__tests__/create-release-issue.test.ts b/scripts/release/__tests__/create-release-issue.test.ts index 5a36ab73911..1b2fc4c68cb 100644 --- a/scripts/release/__tests__/create-release-issue.test.ts +++ b/scripts/release/__tests__/create-release-issue.test.ts @@ -8,7 +8,7 @@ import { } from '../create-release-issue'; describe('create release issue', () => { - it('reads versions openapitools.json', () => { + it('reads versions of the current language', () => { expect(readVersions()).toEqual({ java: { current: expect.any(String), @@ -53,7 +53,19 @@ describe('create release issue', () => { it('returns error when it is a generated commit', () => { expect( - parseCommit(`${generationCommitText.commitStartMessage} ABCDEF`) + parseCommit( + `49662518 ${generationCommitText.commitStartMessage} ABCDEF` + ) + ).toEqual({ + error: 'generation-commit', + }); + }); + + it('returns error when it is a generated commit, even with other casing', () => { + expect( + parseCommit( + `49662518 ${generationCommitText.commitStartMessage.toLocaleUpperCase()} ABCDEF` + ) ).toEqual({ error: 'generation-commit', }); @@ -80,9 +92,9 @@ describe('create release issue', () => { }, }) ).toMatchInlineSnapshot(` - "- [x] javascript: 0.0.1 -> \`patch\` _(e.g. 0.0.2)_ - - [x] java: 0.0.1 -> \`patch\` _(e.g. 0.0.2)_ - - [x] php: 0.0.1 -> \`patch\` _(e.g. 0.0.2)_" + "- javascript: 0.0.1 -> **\`patch\` _(e.g. 0.0.2)_** + - java: 0.0.1 -> **\`patch\` _(e.g. 0.0.2)_** + - php: 0.0.1 -> **\`patch\` _(e.g. 0.0.2)_**" `); }); @@ -106,8 +118,8 @@ describe('create release issue', () => { }, }) ).toMatchInlineSnapshot(` - "- [x] javascript: 0.0.1 -> \`patch\` _(e.g. 0.0.2)_ - - [x] java: 0.0.1 -> \`patch\` _(e.g. 0.0.2)_ + "- javascript: 0.0.1 -> **\`patch\` _(e.g. 0.0.2)_** + - java: 0.0.1 -> **\`patch\` _(e.g. 0.0.2)_** - ~php: 0.0.1 (no commit)~" `); }); @@ -132,10 +144,10 @@ describe('create release issue', () => { }, }) ).toMatchInlineSnapshot(` - "- [x] javascript: 0.0.1 -> \`patch\` _(e.g. 0.0.2)_ - - [ ] java: 0.0.1 -> \`patch\` _(e.g. 0.0.2)_ + "- javascript: 0.0.1 -> **\`patch\` _(e.g. 0.0.2)_** + - ~java: 0.0.1 -> **\`patch\` _(e.g. 0.0.2)_**~ - No \`feat\` or \`fix\` commit, thus unchecked by default. - - [x] php: 0.0.1 -> \`minor\` _(e.g. 0.1.0)_" + - php: 0.0.1 -> **\`minor\` _(e.g. 0.1.0)_**" `); }); }); diff --git a/scripts/release/__tests__/process-release.test.ts b/scripts/release/__tests__/process-release.test.ts index 9b5dd78d553..379201916eb 100644 --- a/scripts/release/__tests__/process-release.test.ts +++ b/scripts/release/__tests__/process-release.test.ts @@ -7,9 +7,10 @@ describe('process release', () => { const versions = getVersionsToRelease(` ## Version Changes - - [x] javascript: 1.0.0 -> \`minor\` (e.g. 1.1.0) - - [x] php: 2.0.0 -> \`patch\` (e.g. 2.0.1) - - [ ] java: 3.0.0 -> \`patch\` (e.g. 3.0.1) + - javascript: 1.0.0 -> **\`minor\` _(e.g. 1.1.0)_** + - ~java: 3.0.0 -> **\`patch\` _(e.g. 3.0.1)_**~ + - No \`feat\` or \`fix\` commit, thus unchecked by default. + - php: 2.0.0 -> **\`patch\` _(e.g. 2.0.1)_** `); expect(Object.keys(versions)).toEqual(['javascript', 'php']); diff --git a/scripts/release/common.ts b/scripts/release/common.ts index 1d0c0d6d9b4..f8beb194f21 100644 --- a/scripts/release/common.ts +++ b/scripts/release/common.ts @@ -16,22 +16,6 @@ export function getGitAuthor(): { name: string; email: string } { return config.gitAuthor; } -export function getMarkdownSection(markdown: string, title: string): string { - const levelIndicator = title.split(' ')[0]; // e.g. `##` - const lines = markdown - .slice(markdown.indexOf(title)) - .split('\n') - .map((line) => line.trim()); - let endIndex = lines.length; - for (let i = 1; i < lines.length; i++) { - if (lines[i].startsWith(`${levelIndicator} `)) { - endIndex = i; - break; - } - } - return lines.slice(0, endIndex).join('\n'); -} - export async function configureGitHubAuthor(cwd?: string): Promise { const { name, email } = getGitAuthor(); diff --git a/scripts/release/create-release-issue.ts b/scripts/release/create-release-issue.ts index d9802f60ae5..6624ee59219 100755 --- a/scripts/release/create-release-issue.ts +++ b/scripts/release/create-release-issue.ts @@ -4,6 +4,7 @@ import dotenv from 'dotenv'; import semver from 'semver'; import generationCommitText from '../ci/codegen/text'; +import { getNbGitDiff } from '../ci/utils'; import { LANGUAGES, ROOT_ENV_PATH, @@ -13,10 +14,12 @@ import { REPO, getOctokit, ensureGitHubToken, + TODAY, } from '../common'; import { getPackageVersionDefault } from '../config'; import { RELEASED_TAG } from './common'; +import { processRelease } from './process-release'; import TEXT from './text'; import type { Versions, @@ -24,6 +27,7 @@ import type { PassedCommit, Commit, Scope, + Changelog, } from './types'; dotenv.config({ path: ROOT_ENV_PATH }); @@ -49,13 +53,15 @@ export function getVersionChangesText(versions: Versions): string { } const next = semver.inc(current, releaseType!); - const checked = skipRelease ? ' ' : 'x'; - return [ - `- [${checked}] ${lang}: ${current} -> \`${releaseType}\` _(e.g. ${next})_`, - skipRelease && TEXT.descriptionForSkippedLang, - ] - .filter(Boolean) - .join('\n'); + + if (skipRelease) { + return [ + `- ~${lang}: ${current} -> **\`${releaseType}\` _(e.g. ${next})_**~`, + TEXT.descriptionForSkippedLang, + ].join('\n'); + } + + return `- ${lang}: ${current} -> **\`${releaseType}\` _(e.g. ${next})_**`; }).join('\n'); } @@ -99,13 +105,18 @@ export function parseCommit(commit: string): Commit { let message = commit.slice(LENGTH_SHA1 + 1); let type = message.slice(0, message.indexOf(':')); const matchResult = type.match(/(.+)\((.+)\)/); - if (!matchResult) { - if (commit.startsWith(generationCommitText.commitStartMessage)) { - return { - error: 'generation-commit', - }; - } + if ( + message + .toLocaleLowerCase() + .startsWith(generationCommitText.commitStartMessage) + ) { + return { + error: 'generation-commit', + }; + } + + if (!matchResult) { return { error: 'missing-language-scope', }; @@ -196,33 +207,15 @@ export function decideReleaseStrategy({ } /* eslint-enable no-param-reassign */ -async function createReleaseIssue(): Promise { - ensureGitHubToken(); - - if ((await run('git rev-parse --abbrev-ref HEAD')) !== MAIN_BRANCH) { - throw new Error( - `You can run this script only from \`${MAIN_BRANCH}\` branch.` - ); - } - - await run(`git rev-parse --verify refs/tags/${RELEASED_TAG}`, { - errorMessage: '`released` tag is missing in this repository.', - }); - - console.log('Pulling from origin...'); - await run('git fetch origin'); - await run('git pull'); - - const commitsWithUnknownLanguageScope: string[] = []; - const commitsWithoutLanguageScope: string[] = []; - - // Remove the local tag, and fetch it from the remote. - // We move the `released` tag as we release, so we need to make it up-to-date. - await run(`git tag -d ${RELEASED_TAG}`); - await run( - `git fetch origin refs/tags/${RELEASED_TAG}:refs/tags/${RELEASED_TAG}` - ); - +/** + * Returns commits separated in categories used to compute the next release version. + * + * Gracefully exits if there is none. + */ +async function getCommits(): Promise<{ + validCommits: PassedCommit[]; + skippedCommits: string; +}> { // Reading commits since last release const latestCommits = ( await run(`git log --oneline --abbrev=8 ${RELEASED_TAG}..${MAIN_BRANCH}`) @@ -230,14 +223,8 @@ async function createReleaseIssue(): Promise { .split('\n') .filter(Boolean); - if (latestCommits.length === 0) { - console.log( - chalk.bgYellow('[INFO]'), - `Skipping release because no commit has been added since \`releated\` tag.` - ); - // eslint-disable-next-line no-process-exit - process.exit(0); - } + const commitsWithoutLanguageScope: string[] = []; + const commitsWithUnknownLanguageScope: string[] = []; const validCommits = latestCommits .map((commitMessage) => { @@ -265,76 +252,119 @@ async function createReleaseIssue(): Promise { }) .filter(Boolean) as PassedCommit[]; + if (validCommits.length === 0) { + console.log( + chalk.black.bgYellow('[INFO]'), + `Skipping release because no valid commit has been added since \`released\` tag.` + ); + // eslint-disable-next-line no-process-exit + process.exit(0); + } + + return { + validCommits, + skippedCommits: getSkippedCommitsText({ + commitsWithoutLanguageScope, + commitsWithUnknownLanguageScope, + }), + }; +} + +async function createReleaseIssue(): Promise { + ensureGitHubToken(); + + if (!process.env.LOCAL_TEST_DEV) { + if ((await run('git rev-parse --abbrev-ref HEAD')) !== MAIN_BRANCH) { + throw new Error( + `You can run this script only from \`${MAIN_BRANCH}\` branch.` + ); + } + + if ( + (await getNbGitDiff({ + head: null, + })) !== 0 + ) { + throw new Error( + 'Working directory is not clean. Commit all the changes first.' + ); + } + } + + await run(`git rev-parse --verify refs/tags/${RELEASED_TAG}`, { + errorMessage: '`released` tag is missing in this repository.', + }); + + console.log('Pulling from origin...'); + await run('git fetch origin'); + await run('git pull'); + + // Remove the local tag, and fetch it from the remote. + // We move the `released` tag as we release, so we need to make it up-to-date. + await run(`git tag -d ${RELEASED_TAG}`); + await run( + `git fetch origin refs/tags/${RELEASED_TAG}:refs/tags/${RELEASED_TAG}` + ); + + console.log('Search for commits since last release...'); + const { validCommits, skippedCommits } = await getCommits(); + const versions = decideReleaseStrategy({ versions: readVersions(), commits: validCommits, }); - const versionChanges = getVersionChangesText(versions); - const skippedCommits = getSkippedCommitsText({ - commitsWithoutLanguageScope, - commitsWithUnknownLanguageScope, - }); + console.log('Creating changelogs for all languages...'); + const changelog: Changelog = LANGUAGES.reduce((newChangelog, lang) => { + if (versions[lang].noCommit) { + return newChangelog; + } - const changelogs = LANGUAGES.filter( - (lang) => !versions[lang].noCommit && versions[lang].current - ) - .flatMap((lang) => { - if (versions[lang].noCommit) { - return []; - } + return { + ...newChangelog, + [lang]: validCommits + .filter( + (commit) => + commit.scope === lang || COMMON_SCOPES.includes(commit.scope) + ) + .map((commit) => `- ${commit.raw}`) + .join('\n'), + }; + }, {} as Changelog); - return [ - `### ${lang}`, - ...validCommits - .filter( - (commit) => - commit.scope === lang || COMMON_SCOPES.includes(commit.scope) - ) - .map((commit) => `- ${commit.raw}`), - ]; - }) - .join('\n'); - - const body = [ - TEXT.header, - TEXT.versionChangeHeader, - versionChanges, - TEXT.descriptionVersionChanges, - TEXT.indenpendentVersioning, - TEXT.changelogHeader, - TEXT.changelogDescription, - changelogs, - TEXT.skippedCommitsHeader, - skippedCommits, - TEXT.approvalHeader, - TEXT.approval, - ].join('\n\n'); + const headBranch = `chore/prepare-release-${TODAY}`; + console.log('Updating config files...'); + await processRelease(versionChanges, changelog, headBranch); + + console.log('Creating pull request...'); const octokit = getOctokit(); - octokit.rest.issues - .create({ + try { + const { + data: { number, html_url: url }, + } = await octokit.rest.pulls.create({ owner: OWNER, repo: REPO, - title: `chore: release ${new Date().toISOString().split('T')[0]}`, - body, - }) - .then((result) => { - const { - data: { number, html_url: url }, - } = result; - - console.log(''); - console.log(`Release issue #${number} is ready for review.`); - console.log(` > ${url}`); - }) - .catch((error) => { - console.log('Unable to create the release issue'); - - throw new Error(error); + title: `chore: prepare release ${TODAY}`, + body: [ + TEXT.header, + TEXT.summary, + TEXT.versionChangeHeader, + versionChanges, + TEXT.skippedCommitsHeader, + skippedCommits, + ].join('\n\n'), + base: 'main', + head: headBranch, }); + + console.log(`Release PR #${number} is ready for review.`); + console.log(` > ${url}`); + } catch (e) { + throw new Error(`Unable to create the release PR: ${e}`); + } } // JS version of `if __name__ == '__main__'` diff --git a/scripts/release/process-release.ts b/scripts/release/process-release.ts index 347674d1e1a..d639996a735 100755 --- a/scripts/release/process-release.ts +++ b/scripts/release/process-release.ts @@ -2,8 +2,6 @@ import fsp from 'fs/promises'; import dotenv from 'dotenv'; -import execa from 'execa'; -import { copy } from 'fs-extra'; import semver from 'semver'; import clientsConfig from '../../config/clients.config.json'; @@ -13,14 +11,10 @@ import { toAbsolutePath, run, exists, - gitCommit, - OWNER, - REPO, - emptyDirExceptForDotGit, GENERATORS, LANGUAGES, - getOctokit, - ensureGitHubToken, + MAIN_BRANCH, + gitBranchExists, } from '../common'; import { getClientsConfigField, @@ -30,85 +24,13 @@ import { } from '../config'; import type { Language } from '../types'; -import { - RELEASED_TAG, - TEAM_SLUG, - getMarkdownSection, - configureGitHubAuthor, - cloneRepository, -} from './common'; -import TEXT from './text'; -import type { - VersionsToRelease, - BeforeClientGenerationCommand, - BeforeClientCommitCommand, -} from './types'; +import type { Changelog, VersionsToRelease } from './types'; dotenv.config({ path: ROOT_ENV_PATH }); -const BEFORE_CLIENT_GENERATION: { - [lang in Language]?: BeforeClientGenerationCommand; -} = { - javascript: async ({ releaseType, dir }) => { - await run(`yarn release:bump ${releaseType}`, { cwd: dir }); - }, -}; - -const BEFORE_CLIENT_COMMIT: { [lang: string]: BeforeClientCommitCommand } = { - javascript: async ({ dir }) => { - // https://github.com/yarnpkg/berry/issues/2948 - await run(`YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn`, { cwd: dir }); // generate `yarn.lock` file - }, -}; - -async function getIssueBody(): Promise { - const octokit = getOctokit(); - const { - data: { body }, - } = await octokit.rest.issues.get({ - owner: OWNER, - repo: REPO, - issue_number: Number(process.env.EVENT_NUMBER), - }); - - if (!body) { - throw new Error( - `Unexpected \`body\` of the release issue: ${JSON.stringify(body)}` - ); - } - return body; -} - -function getDateStamp(): string { - return new Date().toISOString().split('T')[0]; -} - -export function getVersionsToRelease(issueBody: string): VersionsToRelease { - const versionsToRelease: VersionsToRelease = {}; - - getMarkdownSection(issueBody, TEXT.versionChangeHeader) - .split('\n') - .forEach((line) => { - const result = line.match(/- \[x\] (.+): (.+) -> `(.+)`/); - if (!result) { - return; - } - const [, lang, current, releaseType] = result; - if (!['major', 'minor', 'patch', 'prerelease'].includes(releaseType)) { - throw new Error( - `\`${releaseType}\` is unknown release type. Allowed: major, minor, patch, prerelease` - ); - } - versionsToRelease[lang] = { - current, - releaseType, - }; - }); - - return versionsToRelease; -} - -// Bump each client version of the JavaScript client in openapitools.json +/** + * Bump each client version of the JavaScript client in openapitools.json. + */ async function updateVersionForJavascript( versionsToRelease: VersionsToRelease ): Promise { @@ -192,12 +114,12 @@ async function updateConfigFiles( async function updateChangelog({ lang, - issueBody, + changelog, current, next, }: { lang: Language; - issueBody: string; + changelog: Changelog; current: string; next: string; }): Promise { @@ -210,61 +132,67 @@ async function updateChangelog({ const changelogHeader = `## [${next}](${getGitHubUrl( lang )}/compare/${current}...${next})`; - const newChangelog = getMarkdownSection( - getMarkdownSection(issueBody, TEXT.changelogHeader), - `### ${lang}` - ); await fsp.writeFile( changelogPath, - [changelogHeader, newChangelog, existingContent].join('\n\n') + [changelogHeader, changelog[lang], existingContent].join('\n\n') ); } -function formatGitTag({ - lang, - version, -}: { - lang: string; - version: string; -}): string { - return lang === 'go' ? `v${version}` : version; -} +export function getVersionsToRelease( + versionChanges: string +): VersionsToRelease { + const versionsToRelease: VersionsToRelease = {}; -async function isAuthorizedRelease(): Promise { - const octokit = getOctokit(); - const { data: members } = await octokit.rest.teams.listMembersInOrg({ - org: OWNER, - team_slug: TEAM_SLUG, - }); + versionChanges.split('\n').forEach((line) => { + // This character means we've skipped the release of this language + if (line.includes('~')) { + return; + } - const { data: comments } = await octokit.rest.issues.listComments({ - owner: OWNER, - repo: REPO, - issue_number: Number(process.env.EVENT_NUMBER), + // example of string to match: + // - javascript: 0.0.1 -> **`patch` _(e.g. 0.0.2)_** + // ^ ^ ^ + const result = line.match(/- (.+): (.+) -> \*\*`(.+)`/); + + if (!result) { + return; + } + + const [, lang, current, releaseType] = result; + if (!['major', 'minor', 'patch', 'prerelease'].includes(releaseType)) { + throw new Error( + `\`${releaseType}\` is unknown release type. Allowed: major, minor, patch, prerelease` + ); + } + versionsToRelease[lang] = { + current, + releaseType, + }; }); - return comments.some( - (comment) => - comment.body?.toLowerCase().trim() === 'approved' && - members.find((member) => member.login === comment.user?.login) - ); + return versionsToRelease; } -async function processRelease(): Promise { - const githubToken = ensureGitHubToken(); - - if (!process.env.EVENT_NUMBER) { - throw new Error('Environment variable `EVENT_NUMBER` does not exist.'); +/** + * Updates the changelogs and the config files containing versions of the API clients. + * + * @param versionChanges - A summary of the version changes, with their new version. + * @param changelog - The changelog of all the languages, which is generated by `create-release-issue`. + * @param headBranch - The branch to push the changes to. + */ +export async function processRelease( + versionChanges: string, + changelog: Changelog, + headBranch: string +): Promise { + if (await gitBranchExists(headBranch)) { + await run(`git fetch origin ${headBranch}`); + await run(`git push -d origin ${headBranch}`); } - if (!(await isAuthorizedRelease())) { - throw new Error( - 'The issue was not approved.\nA team member must leave a comment "approved" in the release issue.' - ); - } + await run(`git checkout -b ${headBranch}`); - const issueBody = await getIssueBody(); - const versionsToRelease = getVersionsToRelease(issueBody); + const versionsToRelease = getVersionsToRelease(versionChanges); await updateConfigFiles(versionsToRelease); @@ -272,89 +200,42 @@ async function processRelease(): Promise { versionsToRelease )) { /* - About bumping versions of JS clients: - - There are generated clients in JS repo, and non-generated clients like `algoliasearch`, `client-common`, etc. - Now that the versions of generated clients are updated in `openapitools.json`, - the generation output will have correct new versions. - - However, we need to manually update versions of the non-generated (a.k.a. manually written) clients. - In order to do that, we run `yarn release:bump ` in this monorepo first. - It will update the versions of the non-generated clients which exists in this monorepo. - After that, we generate clients with new versions. And then, we copy all of them over to JS repository. - */ - await BEFORE_CLIENT_GENERATION[lang]?.({ - releaseType, - dir: toAbsolutePath(getLanguageFolder(lang as Language)), - }); - - console.log(`Generating ${lang} client(s)...`); - console.log(await run(`yarn cli generate ${lang}`)); + About bumping versions of JS clients: + + There are generated clients in JS repo, and non-generated clients like `algoliasearch`, `client-common`, etc. + Now that the versions of generated clients are updated in `openapitools.json`, + the generation output will have correct new versions. + + However, we need to manually update versions of the non-generated (a.k.a. manually written) clients. + In order to do that, we run `yarn release:bump ` in this monorepo first. + It will update the versions of the non-generated clients which exists in this monorepo. + After that, we generate clients with new versions. And then, we copy all of them over to JS repository. + */ + if (lang === 'javascript') { + await run( + `yarn workspace algoliasearch-client-javascript release:bump ${releaseType}`, + { + verbose: true, + } + ); + } const next = semver.inc(current, releaseType); + await updateChangelog({ lang: lang as Language, - issueBody, + changelog, current, next: next!, }); } - // We push commits to each repository AFTER all the generations are done. - // Otherwise, we will end up having broken release. - for (const [lang, { current, releaseType }] of Object.entries( - versionsToRelease - )) { - const { tempGitDir } = await cloneRepository({ - lang: lang as Language, - githubToken, - tempDir: process.env.RUNNER_TEMP!, - }); - - const clientPath = toAbsolutePath(getLanguageFolder(lang as Language)); - await emptyDirExceptForDotGit(tempGitDir); - await copy(clientPath, tempGitDir, { preserveTimestamps: true }); - - await configureGitHubAuthor(tempGitDir); - await BEFORE_CLIENT_COMMIT[lang]?.({ - dir: tempGitDir, - }); - await run(`git add .`, { cwd: tempGitDir }); - - const next = semver.inc(current, releaseType); - const tag = formatGitTag({ lang, version: next! }); - await gitCommit({ - message: `chore: release ${tag}`, - cwd: tempGitDir, - }); - await execa('git', ['tag', tag], { - cwd: tempGitDir, - }); - await run(`git push --follow-tags`, { cwd: tempGitDir }); - } - - // Commit and push from the monorepo level. - await configureGitHubAuthor(); - await run(`git add .`); - const dateStamp = getDateStamp(); - await gitCommit({ - message: `chore: release ${dateStamp}`, - }); - await run(`git push`); - - // remove old `released` tag + console.log(`Pushing updated configs to ${headBranch}`); + await run(`git add .`, { verbose: true }); await run( - `git fetch origin refs/tags/${RELEASED_TAG}:refs/tags/${RELEASED_TAG}` + `CI=true git commit -m "${headBranch.replace('chore/', 'chore: ')}"`, + { verbose: true } ); - await run(`git tag -d ${RELEASED_TAG}`); - await run(`git push --delete origin ${RELEASED_TAG}`); - - // create new `released` tag - await run(`git tag released`); - await run(`git push --tags`); -} - -// JS version of `if __name__ == '__main__'` -if (require.main === module) { - processRelease(); + await run(`git push origin ${headBranch}`, { verbose: true }); + await run(`git checkout ${MAIN_BRANCH}`, { verbose: true }); } diff --git a/scripts/release/text.ts b/scripts/release/text.ts index 1249468b63f..29363ca595f 100644 --- a/scripts/release/text.ts +++ b/scripts/release/text.ts @@ -1,15 +1,13 @@ export default { header: `## Summary`, + summary: + 'This PR has been created using the `yarn release` script. Once merged, the clients will try to release their new version if their version has changed.', versionChangeHeader: `## Version Changes`, skippedCommitsHeader: `### Skipped Commits`, skippedCommitsDesc: `It doesn't mean these commits are being excluded from the release. It means they're not taken into account when the release process figured out the next version number, and updated the changelog.`, noCommit: `no commit`, currentVersionNotFound: `current version not found`, - descriptionVersionChanges: [ - `**Checked** → Update version, update repository, and release the library.`, - `**Un-checked** → Do nothing`, - ].join('\n'), indenpendentVersioning: `
@@ -31,10 +29,4 @@ export default { changelogHeader: `## CHANGELOG`, changelogDescription: `Update the following lines. Once merged, it will be reflected to \`changelogs/*.\``, - - approvalHeader: `## Approval`, - approval: [ - `To proceed this release, a team member must leave a comment "approved" in this issue.`, - `To skip this release, just close it.`, - ].join('\n'), }; diff --git a/scripts/release/types.ts b/scripts/release/types.ts index 054141b69e6..6d124c9d1eb 100644 --- a/scripts/release/types.ts +++ b/scripts/release/types.ts @@ -43,10 +43,9 @@ export type VersionsToRelease = { }; }; -export type BeforeClientGenerationCommand = (params: { - releaseType: ReleaseType; - dir: string; -}) => Promise; +export type Changelog = { + [lang in Language]?: string; +}; export type BeforeClientCommitCommand = (params: { dir: string;