diff --git a/.github/workflow-scripts/__tests__/generateChangelog-test.js b/.github/workflow-scripts/__tests__/generateChangelog-test.js new file mode 100644 index 00000000000000..346aa06cc708ac --- /dev/null +++ b/.github/workflow-scripts/__tests__/generateChangelog-test.js @@ -0,0 +1,251 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +const { + generateChangelog, + _computePreviousVersionFrom, + _generateChangelog, + _pushCommit, + _createPR, +} = require('../generateChangelog'); + +const silence = () => {}; +const mockGetNpmPackageInfo = jest.fn(); +const mockExecSync = jest.fn(); +const mockRun = jest.fn(); +const mockFetch = jest.fn(); +const mockExit = jest.fn(); + +jest.mock('../utils.js', () => ({ + log: silence, + run: mockRun, + getNpmPackageInfo: mockGetNpmPackageInfo, +})); + +process.exit = mockExit; +global.fetch = mockFetch; + +describe('Generate Changelog', () => { + beforeEach(jest.clearAllMocks); + + describe('_computePreviousVersionFrom', () => { + it('returns rc.0 when rc is 1', async () => { + const currentVersion = '0.78.0-rc.1'; + const expectedVersion = '0.78.0-rc.0'; + + const receivedVersion = await _computePreviousVersionFrom(currentVersion); + + expect(receivedVersion).toEqual(expectedVersion); + }); + + it('returns previous rc version when rc is > 1', async () => { + const currentVersion = '0.78.0-rc.5'; + const expectedVersion = '0.78.0-rc.4'; + + const receivedVersion = await _computePreviousVersionFrom(currentVersion); + + expect(receivedVersion).toEqual(expectedVersion); + }); + + it('returns previous patch version when rc is 0', async () => { + const currentVersion = '0.78.0-rc.0'; + const expectedVersion = '0.77.1'; + + mockGetNpmPackageInfo.mockReturnValueOnce( + Promise.resolve({version: '0.77.1'}), + ); + + const receivedVersion = await _computePreviousVersionFrom(currentVersion); + + expect(receivedVersion).toEqual(expectedVersion); + }); + + it('returns patch 0 when patch is 1', async () => { + const currentVersion = '0.78.1'; + const expectedVersion = '0.78.0'; + + const receivedVersion = await _computePreviousVersionFrom(currentVersion); + + expect(receivedVersion).toEqual(expectedVersion); + }); + + it('returns previous patch when patch is > 1', async () => { + const currentVersion = '0.78.5'; + const expectedVersion = '0.78.4'; + + const receivedVersion = await _computePreviousVersionFrom(currentVersion); + + expect(receivedVersion).toEqual(expectedVersion); + }); + + it('returns null when patch is 0', async () => { + const currentVersion = '0.78.0'; + + const receivedVersion = await _computePreviousVersionFrom(currentVersion); + + expect(receivedVersion).toBeNull(); + }); + + it("throws an error when the version can't be parsed", async () => { + const currentVersion = '0.78.0-rc0'; + + await expect( + _computePreviousVersionFrom(currentVersion), + ).rejects.toThrow(); + }); + }); + + describe('_generateChangelog', () => { + it('calls git in the right order', async () => { + const currentVersion = '0.79.0-rc5'; + const previousVersion = '0.79.0-rc4'; + const token = 'token'; + + expectedCommandArgs = [ + '@rnx-kit/rn-changelog-generator', + '--base', + `v${previousVersion}`, + '--compare', + `v${currentVersion}`, + '--repo', + '.', + '--changelog', + './CHANGELOG.md', + '--token', + `${token}`, + ]; + + _generateChangelog(previousVersion, currentVersion, token); + + expect(mockRun).toHaveBeenCalledTimes(4); + expect(mockRun).toHaveBeenNthCalledWith(1, 'git checkout main'); + expect(mockRun).toHaveBeenNthCalledWith(2, 'git fetch'); + expect(mockRun).toHaveBeenNthCalledWith(3, 'git pull origin main'); + expect(mockRun).toHaveBeenNthCalledWith( + 4, + `npx ${expectedCommandArgs.join(' ')}`, + ); + }); + }); + + describe('_pushCommit', () => { + it('calls git in the right order', async () => { + const currentVersion = '0.79.0-rc5'; + + _pushCommit(currentVersion); + + expect(mockRun).toHaveBeenCalledTimes(4); + expect(mockRun).toHaveBeenNthCalledWith( + 1, + `git checkout -b changelog/v${currentVersion}`, + ); + expect(mockRun).toHaveBeenNthCalledWith(2, 'git add CHANGELOG.md'); + expect(mockRun).toHaveBeenNthCalledWith( + 3, + `git commit -m "[RN][Changelog] Add changelog for v${currentVersion}"`, + ); + expect(mockRun).toHaveBeenNthCalledWith( + 4, + `git push origin changelog/v${currentVersion}`, + ); + }); + }); + + describe('_createPR', () => { + it('throws error when status is not 201', async () => { + const currentVersion = '0.79.0-rc5'; + const token = 'token'; + + mockFetch.mockReturnValueOnce(Promise.resolve({status: 401})); + + const headers = { + Accept: 'Accept: application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + Authorization: `Bearer ${token}`, + }; + + const content = ` +## Summary +Add Changelog for ${currentVersion} + +## Changelog: +[Internal] - Add Changelog for ${currentVersion} + +## Test Plan: +N/A`; + + const body = { + title: `[RN][Changelog] Add changelog for v${currentVersion}`, + head: `changelog/v${currentVersion}`, + base: 'main', + body: content, + }; + + await expect(_createPR(currentVersion, token)).rejects.toThrow(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.github.com/repos/facebook/react-native/pulls', + { + method: 'POST', + headers: headers, + body: JSON.stringify(body), + }, + ); + }); + it('Returns the pr url', async () => { + const currentVersion = '0.79.0-rc5'; + const token = 'token'; + const expectedPrURL = + 'https://github.com/facebook/react-native/pulls/1234'; + + const returnedObject = { + status: 201, + json: () => Promise.resolve({html_url: expectedPrURL}), + }; + mockFetch.mockReturnValueOnce(Promise.resolve(returnedObject)); + + const headers = { + Accept: 'Accept: application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + Authorization: `Bearer ${token}`, + }; + + const content = ` +## Summary +Add Changelog for ${currentVersion} + +## Changelog: +[Internal] - Add Changelog for ${currentVersion} + +## Test Plan: +N/A`; + + const body = { + title: `[RN][Changelog] Add changelog for v${currentVersion}`, + head: `changelog/v${currentVersion}`, + base: 'main', + body: content, + }; + + const receivedPrURL = await _createPR(currentVersion, token); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.github.com/repos/facebook/react-native/pulls', + { + method: 'POST', + headers: headers, + body: JSON.stringify(body), + }, + ); + expect(receivedPrURL).toEqual(expectedPrURL); + }); + }); +}); diff --git a/.github/workflow-scripts/generateChangelog.js b/.github/workflow-scripts/generateChangelog.js new file mode 100644 index 00000000000000..fc7a9c1794de2a --- /dev/null +++ b/.github/workflow-scripts/generateChangelog.js @@ -0,0 +1,120 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +const {log, getNpmPackageInfo, run} = require('./utils'); + +async function _computePreviousVersionFrom(version) { + log(`Computing previous version from: ${version}`); + const regex = /^0\.(\d+)\.(\d+)(-rc\.(\d+))?$/; + const match = version.match(regex); + if (!match) { + throw new Error(`Invalid version format: ${version}`); + } + + const minor = match[1]; + const patch = match[2]; + const rc = match[4]; + + if (rc) { + if (Number(rc) > 0) { + return `0.${minor}.${patch}-rc.${Number(rc) - 1}`; + } + //fetch latest version on NPM + const latestPkg = await getNpmPackageInfo('react-native', 'latest'); + return latestPkg.version; + } else { + if (Number(patch) === 0) { + // No need to generate the changelog for 0.X.0 as we already generated it from RCs + log( + `Skipping changelog generation for ${version} as we already have it from the RCs`, + ); + return null; + } + return `0.${minor}.${Number(patch) - 1}`; + } +} + +function _generateChangelog(previousVersion, version, token) { + log(`Generating changelog for ${version} from ${previousVersion}`); + run('git checkout main'); + run('git fetch'); + run('git pull origin main'); + const generateChangelogComand = `npx @rnx-kit/rn-changelog-generator --base v${previousVersion} --compare v${version} --repo . --changelog ./CHANGELOG.md --token ${token}`; + run(generateChangelogComand); +} + +function _pushCommit(version) { + log(`Pushing commit to changelog/v${version}`); + run(`git checkout -b changelog/v${version}`); + run('git add CHANGELOG.md'); + run(`git commit -m "[RN][Changelog] Add changelog for v${version}"`); + run(`git push origin changelog/v${version}`); +} + +async function _createPR(version, token) { + log('Creating changelog pr'); + const url = 'https://api.github.com/repos/facebook/react-native/pulls'; + const body = ` +## Summary +Add Changelog for ${version} + +## Changelog: +[Internal] - Add Changelog for ${version} + +## Test Plan: +N/A`; + + const response = await fetch(url, { + method: 'POST', + headers: { + Accept: 'Accept: application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + title: `[RN][Changelog] Add changelog for v${version}`, + head: `changelog/v${version}`, + base: 'main', + body: body, + }), + }); + + if (response.status !== 201) { + throw new Error( + `Failed to create PR: ${response.status} ${response.statusText}`, + ); + } + + const data = await response.json(); + return data.html_url; +} + +async function generateChangelog(version, token) { + if (version.startsWith('v')) { + version = version.substring(1); + } + + const previousVersion = await _computePreviousVersionFrom(version); + if (previousVersion) { + log(`Previous version is ${previousVersion}`); + _generateChangelog(previousVersion, version, token); + _pushCommit(version); + const prURL = await _createPR(version, token); + log(`Created PR: ${prURL}`); + } +} + +module.exports = { + generateChangelog, + // Exported only for testing purposes: + _computePreviousVersionFrom, + _generateChangelog, + _pushCommit, + _createPR, +}; diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml new file mode 100644 index 00000000000000..9734ebd6417307 --- /dev/null +++ b/.github/workflows/generate-changelog.yml @@ -0,0 +1,28 @@ +name: Generate Changelog + +on: + workflow_call: + +jobs: + generate-changelog: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + - name: Install dependencies + uses: ./.github/actions/yarn-install + - name: Configure Git + shell: bash + run: | + git config --local user.email "bot@reactnative.dev" + git config --local user.name "React Native Bot" + - name: Generate Changelog + uses: actions/github-script@v6 + with: + script: | + const {generateChangelog} = require('./.github/workflow-scripts/generateChangelog'); + const version = '${{ github.ref_name }}'; + await generateChangelog(version, '${{secrets.REACT_NATIVE_BOT_GITHUB_TOKEN}}'); diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 3ad9cb32b48e4f..905c183b0dc062 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -237,3 +237,8 @@ jobs: const {verifyArtifactsAreOnMaven} = require('./.github/workflow-scripts/verifyArtifactsAreOnMaven.js'); const version = "${{ github.ref_name }}"; await verifyArtifactsAreOnMaven(version); + + create_changelog: + needs: build_npm_package + uses: ./.github/workflows/generate-changelog.yml + secrets: inherit