diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 6b007d52f27..229f4d69f1f 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -351,7 +351,16 @@ jobs: job: codegen - name: Push generated code + id: pushGeneratedCode run: yarn workspace scripts pushGeneratedCode env: GITHUB_TOKEN: ${{ secrets.TOKEN_GENERATE_BOT }} PR_NUMBER: ${{ github.event.number }} + + - 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_RELEASE_BOT }} diff --git a/scripts/__tests__/common.test.ts b/scripts/__tests__/common.test.ts new file mode 100644 index 00000000000..d953c3d48d5 --- /dev/null +++ b/scripts/__tests__/common.test.ts @@ -0,0 +1,38 @@ +import execa from 'execa'; + +import { gitCommit } from '../common'; + +jest.mock('execa'); + +describe('gitCommit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('commits with message', () => { + gitCommit({ message: 'chore: does something' }); + expect(execa).toHaveBeenCalledTimes(1); + expect(execa).toHaveBeenCalledWith( + 'git', + ['commit', '-m', 'chore: does something'], + { cwd: expect.any(String) } + ); + }); + + it('commits with co-author', () => { + gitCommit({ + message: 'chore: does something', + coauthor: { name: 'some', email: 'random@person.com' }, + }); + expect(execa).toHaveBeenCalledTimes(1); + expect(execa).toHaveBeenCalledWith( + 'git', + [ + 'commit', + '-m', + 'chore: does something\n\n\nCo-authored-by: some ', + ], + { cwd: expect.any(String) } + ); + }); +}); diff --git a/scripts/ci/codegen/__tests__/spreadGeneration.test.ts b/scripts/ci/codegen/__tests__/spreadGeneration.test.ts new file mode 100644 index 00000000000..454322cd2d0 --- /dev/null +++ b/scripts/ci/codegen/__tests__/spreadGeneration.test.ts @@ -0,0 +1,48 @@ +import { LANGUAGES } from '../../../common'; +import { decideWhereToSpread, cleanUpCommitMessage } from '../spreadGeneration'; + +describe('spread generation', () => { + it('skips in case of release commit', () => { + expect(decideWhereToSpread('chore: release 2022-03-15')).toEqual([]); + }); + + it('spreads to all if scope is missing', () => { + expect(decideWhereToSpread('chore: do something')).toEqual(LANGUAGES); + }); + + it('spreads to javascript if the scope is javascript', () => { + expect(decideWhereToSpread('fix(javascript): fix something')).toEqual([ + 'javascript', + ]); + }); + + it('spreads to all if scope is not specific language', () => { + ['cts', 'spec', 'script', 'ci'].forEach((scope) => { + expect(decideWhereToSpread(`fix(${scope}): fix something`)).toEqual( + LANGUAGES + ); + }); + }); + + it('removes pull-request number from commit message', () => { + expect( + cleanUpCommitMessage(`feat(ci): make ci push generated code (#244)`) + ).toEqual( + `feat(ci): make ci push generated code\n\nhttps://github.com/algolia/api-clients-automation/pull/244` + ); + }); + + it('keeps the commit message even if it does not have PR number', () => { + const commitMessage = `feat(ci): make ci push generated code`; + expect(cleanUpCommitMessage(commitMessage)).toEqual(commitMessage); + }); + + it('cleans up correctly even if the title contains a url', () => { + const commitMessage = `fix(java): solve oneOf using a custom generator https://algolia.atlassian.net/browse/APIC-123 (#200)`; + expect(cleanUpCommitMessage(commitMessage)).toMatchInlineSnapshot(` + "fix(java): solve oneOf using a custom generator https://algolia.atlassian.net/browse/APIC-123 + + https://github.com/algolia/api-clients-automation/pull/200" + `); + }); +}); diff --git a/scripts/ci/codegen/spreadGeneration.ts b/scripts/ci/codegen/spreadGeneration.ts new file mode 100644 index 00000000000..7f6999d2bc8 --- /dev/null +++ b/scripts/ci/codegen/spreadGeneration.ts @@ -0,0 +1,75 @@ +import { gitCommit, LANGUAGES, run, toAbsolutePath } from '../../common'; +import { getLanguageFolder } from '../../config'; +import { + cloneRepository, + configureGitHubAuthor, + OWNER, + REPO, +} from '../../release/common'; + +const GENERATED_MAIN_BRANCH = `generated/main`; + +export function decideWhereToSpread(commitMessage: string): string[] { + if (commitMessage.startsWith('chore: release')) { + return []; + } + + const result = commitMessage.match(/(.+)\((.+)\):/); + if (!result) { + // no scope + return LANGUAGES; + } + + const scope = result[2]; + return LANGUAGES.includes(scope) ? [scope] : LANGUAGES; +} + +export function cleanUpCommitMessage(commitMessage: string): string { + const result = commitMessage.match(/(.+)\s\(#(\d+)\)$/); + if (!result) { + return commitMessage; + } + + return [ + result[1], + `https://github.com/${OWNER}/${REPO}/pull/${result[2]}`, + ].join('\n\n'); +} + +async function spreadGeneration(): Promise { + if (!process.env.GITHUB_TOKEN) { + throw new Error('Environment variable `GITHUB_TOKEN` does not exist.'); + } + + const lastCommitMessage = await run(`git log -1 --format="%s"`); + const name = (await run(`git log -1 --format="%an"`)).trim(); + const email = (await run(`git log -1 --format="%ae"`)).trim(); + const commitMessage = cleanUpCommitMessage(lastCommitMessage); + const langs = decideWhereToSpread(lastCommitMessage); + + await run(`git checkout ${GENERATED_MAIN_BRANCH}`); + + for (const lang of langs) { + const { tempGitDir } = await cloneRepository({ + lang, + githubToken: process.env.GITHUB_TOKEN, + tempDir: process.env.RUNNER_TEMP!, + }); + + const clientPath = toAbsolutePath(getLanguageFolder(lang)); + await run(`cp -r ${clientPath}/ ${tempGitDir}`); + + await configureGitHubAuthor(tempGitDir); + await run(`git add .`, { cwd: tempGitDir }); + await gitCommit({ + message: commitMessage, + coauthor: { name, email }, + cwd: tempGitDir, + }); + await run(`git push`, { cwd: tempGitDir }); + } +} + +if (require.main === module) { + spreadGeneration(); +} diff --git a/scripts/common.ts b/scripts/common.ts index d4eeff468f7..b58f6d344a4 100644 --- a/scripts/common.ts +++ b/scripts/common.ts @@ -154,6 +154,34 @@ export async function runIfExists( return ''; } +export async function gitCommit({ + message, + coauthor, + cwd = ROOT_DIR, +}: { + message: string; + coauthor?: { + name: string; + email: string; + }; + cwd?: string; +}): Promise { + await execa( + 'git', + [ + 'commit', + '-m', + message + + (coauthor + ? `\n\n\nCo-authored-by: ${coauthor.name} <${coauthor.email}>` + : ''), + ], + { + cwd, + } + ); +} + export async function buildCustomGenerators(verbose: boolean): Promise { const spinner = createSpinner('building custom generators', verbose).start(); await run('./gradle/gradlew --no-daemon -p generators assemble', { diff --git a/scripts/package.json b/scripts/package.json index cbd9ab784a6..eb28bce0160 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -6,6 +6,7 @@ "processRelease": "ts-node release/process-release.ts", "pushGeneratedCode": "ts-node ci/codegen/pushGeneratedCode.ts", "cleanGeneratedBranch": "ts-node ci/codegen/cleanGeneratedBranch.ts", + "spreadGeneration": "ts-node ci/codegen/spreadGeneration.ts", "upsertGenerationComment": "ts-node ci/codegen/upsertGenerationComment.ts", "test": "jest" }, diff --git a/scripts/release/common.ts b/scripts/release/common.ts index e51dc0eedaa..30b09087966 100644 --- a/scripts/release/common.ts +++ b/scripts/release/common.ts @@ -1,5 +1,5 @@ import config from '../../config/release.config.json'; -import { run } from '../common'; +import { getGitHubUrl, run } from '../common'; export const RELEASED_TAG = config.releasedTag; export const MAIN_BRANCH = config.mainBranch; @@ -36,3 +36,26 @@ export async function configureGitHubAuthor(cwd?: string): Promise { await run(`git config user.name "${name}"`, { cwd }); await run(`git config user.email "${email}"`, { cwd }); } + +export async function cloneRepository({ + lang, + githubToken, + tempDir, +}: { + lang: string; + githubToken: string; + tempDir: string; +}): Promise<{ tempGitDir: string }> { + const targetBranch = getTargetBranch(lang); + + const gitHubUrl = getGitHubUrl(lang, { token: githubToken }); + const tempGitDir = `${tempDir}/${lang}`; + await run(`rm -rf ${tempGitDir}`); + await run( + `git clone --depth 1 --branch ${targetBranch} ${gitHubUrl} ${tempGitDir}` + ); + + return { + tempGitDir, + }; +} diff --git a/scripts/release/process-release.ts b/scripts/release/process-release.ts index 239d703fb15..ec7ead84fdd 100755 --- a/scripts/release/process-release.ts +++ b/scripts/release/process-release.ts @@ -11,6 +11,7 @@ import { run, exists, getGitHubUrl, + gitCommit, } from '../common'; import { getLanguageFolder } from '../config'; @@ -19,8 +20,8 @@ import { OWNER, REPO, getMarkdownSection, - getTargetBranch, configureGitHubAuthor, + cloneRepository, } from './common'; import TEXT from './text'; @@ -163,27 +164,26 @@ async function processRelease(): Promise { await run(`git add ${changelogPath}`); } - // We push commits from submodules AFTER all the generations are done. + // We push commits to each repository AFTER all the generations are done. // Otherwise, we will end up having broken release. for (const lang of langsToReleaseOrUpdate) { - const clientPath = toAbsolutePath(getLanguageFolder(lang)); - const targetBranch = getTargetBranch(lang); - - const gitHubUrl = getGitHubUrl(lang, { token: process.env.GITHUB_TOKEN }); - const tempGitDir = `${process.env.RUNNER_TEMP}/${lang}`; - await run(`rm -rf ${tempGitDir}`); - await run( - `git clone --depth 1 --branch ${targetBranch} ${gitHubUrl} ${tempGitDir}` - ); + const { tempGitDir } = await cloneRepository({ + lang, + githubToken: process.env.GITHUB_TOKEN, + tempDir: process.env.RUNNER_TEMP!, + }); + const clientPath = toAbsolutePath(getLanguageFolder(lang)); await run(`cp -r ${clientPath}/ ${tempGitDir}`); + await configureGitHubAuthor(tempGitDir); await run(`git add .`, { cwd: tempGitDir }); const { next, dateStamp } = versionsToRelease[lang]; if (willReleaseLibrary(lang)) { - await execa('git', ['commit', '-m', `chore: release ${next}`], { + await gitCommit({ + message: `chore: release ${next}`, cwd: tempGitDir, }); if (process.env.VERSION_TAG_ON_RELEASE === 'true') { @@ -191,7 +191,8 @@ async function processRelease(): Promise { await run(`git push --tags`, { cwd: tempGitDir }); } } else { - await execa('git', ['commit', '-m', `chore: update repo ${dateStamp}`], { + await gitCommit({ + message: `chore: update repo ${dateStamp}`, cwd: tempGitDir, }); } @@ -199,7 +200,9 @@ async function processRelease(): Promise { } // Commit and push from the monorepo level. - await execa('git', ['commit', '-m', `chore: release ${getDateStamp()}`]); + await gitCommit({ + message: `chore: release ${getDateStamp()}`, + }); await run(`git push`); // remove old `released` tag