diff --git a/.github/workflow-scripts/__tests__/publishTemplate-test.js b/.github/workflow-scripts/__tests__/publishTemplate-test.js new file mode 100644 index 00000000000..c917dad568d --- /dev/null +++ b/.github/workflow-scripts/__tests__/publishTemplate-test.js @@ -0,0 +1,158 @@ +/** + * 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 { + publishTemplate, + verifyPublishedTemplate, +} = require('../publishTemplate'); + +const mockRun = jest.fn(); +const mockSleep = jest.fn(); +const mockGetNpmPackageInfo = jest.fn(); +const silence = () => {}; + +jest.mock('../utils.js', () => ({ + log: silence, + run: mockRun, + sleep: mockSleep, + getNpmPackageInfo: mockGetNpmPackageInfo, +})); + +const getMockGithub = () => ({ + rest: { + actions: { + createWorkflowDispatch: jest.fn(), + }, + }, +}); + +describe('#publishTemplate', () => { + beforeEach(jest.clearAllMocks); + + it('checks commits for magic #publish-package-to-npm&latest string and sets latest', async () => { + mockRun.mockReturnValueOnce(` + The commit message + + #publish-packages-to-npm&latest`); + + const github = getMockGithub(); + await publishTemplate(github, '0.76.0', true); + expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith({ + owner: 'react-native-community', + repo: 'template', + workflow_id: 'release.yml', + ref: '0.76-stable', + inputs: { + dry_run: true, + is_latest_on_npm: true, + version: '0.76.0', + }, + }); + }); + + it('pubished as is_latest_on_npm = false if missing magic string', async () => { + mockRun.mockReturnValueOnce(` + The commit message without magic + `); + + const github = getMockGithub(); + await publishTemplate(github, '0.76.0', false); + expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith({ + owner: 'react-native-community', + repo: 'template', + workflow_id: 'release.yml', + ref: '0.76-stable', + inputs: { + dry_run: false, + is_latest_on_npm: false, + version: '0.76.0', + }, + }); + }); +}); + +describe('#verifyPublishedTemplate', () => { + beforeEach(jest.clearAllMocks); + + it("waits on npm updating for version and not 'latest'", async () => { + const NOT_LATEST = false; + mockGetNpmPackageInfo + // template@ + .mockReturnValueOnce(Promise.reject('mock http/404')) + .mockReturnValueOnce(Promise.resolve()); + mockSleep.mockReturnValueOnce(Promise.resolve()).mockImplementation(() => { + throw new Error('Should not be called again!'); + }); + + const version = '0.77.0'; + await verifyPublishedTemplate(version, NOT_LATEST); + + expect(mockGetNpmPackageInfo).toHaveBeenLastCalledWith( + '@react-native-community/template', + version, + ); + }); + + it('waits on npm updating version and latest tag', async () => { + const IS_LATEST = true; + const version = '0.77.0'; + mockGetNpmPackageInfo + // template@latest → unknown tag + .mockReturnValueOnce(Promise.reject('mock http/404')) + // template@latest != version → old tag + .mockReturnValueOnce(Promise.resolve({version: '0.76.5'})) + // template@latest == version → correct tag + .mockReturnValueOnce(Promise.resolve({version})); + mockSleep + .mockReturnValueOnce(Promise.resolve()) + .mockReturnValueOnce(Promise.resolve()) + .mockImplementation(() => { + throw new Error('Should not be called again!'); + }); + + await verifyPublishedTemplate(version, IS_LATEST); + + expect(mockGetNpmPackageInfo).toHaveBeenCalledWith( + '@react-native-community/template', + 'latest', + ); + }); + + describe('timeouts', () => { + let mockProcess; + beforeEach(() => { + mockProcess = jest.spyOn(process, 'exit').mockImplementation(code => { + throw new Error(`process.exit(${code}) called!`); + }); + }); + afterEach(() => mockProcess.mockRestore()); + it('will timeout if npm does not update package version after a set number of retries', async () => { + const RETRIES = 2; + mockGetNpmPackageInfo.mockReturnValue(Promise.reject('mock http/404')); + mockSleep.mockReturnValue(Promise.resolve()); + await expect(() => + verifyPublishedTemplate('0.77.0', true, RETRIES), + ).rejects.toThrowError('process.exit(1) called!'); + expect(mockGetNpmPackageInfo).toHaveBeenCalledTimes(RETRIES); + }); + + it('will timeout if npm does not update latest tag after a set number of retries', async () => { + const RETRIES = 7; + const IS_LATEST = true; + mockGetNpmPackageInfo.mockReturnValue( + Promise.resolve({version: '0.76.5'}), + ); + mockSleep.mockReturnValue(Promise.resolve()); + await expect(async () => { + await verifyPublishedTemplate('0.77.0', IS_LATEST, RETRIES); + }).rejects.toThrowError('process.exit(1) called!'); + expect(mockGetNpmPackageInfo).toHaveBeenCalledTimes(RETRIES); + }); + }); +}); diff --git a/.github/workflow-scripts/publishTemplate.js b/.github/workflow-scripts/publishTemplate.js new file mode 100644 index 00000000000..12221c1ae75 --- /dev/null +++ b/.github/workflow-scripts/publishTemplate.js @@ -0,0 +1,103 @@ +/** + * 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 {run, sleep, getNpmPackageInfo, log} = require('./utils.js'); + +const TAG_AS_LATEST_REGEX = /#publish-packages-to-npm&latest/; + +/** + * Should this commit be `latest` on npm? + */ +function isLatest() { + const commitMessage = run('git log -n1 --pretty=%B'); + return TAG_AS_LATEST_REGEX.test(commitMessage); +} +module.exports.isLatest = isLatest; + +/** + * Create a Github Action to publish the community template matching the released version + * of React Native. + */ +module.exports.publishTemplate = async (github, version, dryRun = true) => { + log(`📤 Get the ${TEMPLATE_NPM_PKG} repo to publish ${version}`); + + const is_latest_on_npm = isLatest(); + + const majorMinor = /^v?(\d+\.\d+)/.exec(version); + + if (!majorMinor) { + log(`🔥 can't capture MAJOR.MINOR from '${version}', giving up.`); + process.exit(1); + } + + // MAJOR.MINOR-stable + const ref = `${majorMinor[1]}-stable`; + + await github.rest.actions.createWorkflowDispatch({ + owner: 'react-native-community', + repo: 'template', + workflow_id: 'release.yml', + ref, + inputs: { + dry_run: dryRun, + is_latest_on_npm, + // 0.75.0-rc.0, note no 'v' prefix + version: version.replace(/^v/, ''), + }, + }); +}; + +const SLEEP_S = 10; +const MAX_RETRIES = 3 * 6; // 3 minutes +const TEMPLATE_NPM_PKG = '@react-native-community/template'; + +/** + * Will verify that @latest and the @ have been published. + * + * NOTE: This will infinitely query each step until successful, make sure the + * calling job has a timeout. + */ +module.exports.verifyPublishedTemplate = async ( + version, + latest = false, + retries = MAX_RETRIES, +) => { + log(`🔍 Is ${TEMPLATE_NPM_PKG}@${version} on npm?`); + + let count = retries; + while (count-- > 0) { + try { + const json = await getNpmPackageInfo( + TEMPLATE_NPM_PKG, + latest ? 'latest' : version, + ); + log(`🎉 Found ${TEMPLATE_NPM_PKG}@${version} on npm`); + if (!latest) { + return; + } + if (json.version === version) { + log(`🎉 ${TEMPLATE_NPM_PKG}@latest → ${version} on npm`); + return; + } + log( + `🐌 ${TEMPLATE_NPM_PKG}@latest → ${pkg.version} on npm and not ${version} as expected, retrying...`, + ); + } catch (e) { + log(`Nope, fetch failed: ${e.message}`); + } + await sleep(SLEEP_S); + } + + let msg = `🚨 Timed out when trying to verify ${TEMPLATE_NPM_PKG}@${version} on npm`; + if (latest) { + msg += ' and latest tag points to this version.'; + } + log(msg); + process.exit(1); +}; diff --git a/.github/workflow-scripts/utils.js b/.github/workflow-scripts/utils.js new file mode 100644 index 00000000000..1c2b8a5d1f8 --- /dev/null +++ b/.github/workflow-scripts/utils.js @@ -0,0 +1,29 @@ +/** + * 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 {execSync} = require('child_process'); + +function run(...cmd) { + return execSync(cmd, 'utf8').toString().trim(); +} +module.exports.run = run; + +async function sleep(seconds) { + return new Promise(resolve => setTimeout(resolve, seconds * 1000)); +} +module.exports.sleep = sleep; + +async function getNpmPackageInfo(pkg, versionOrTag) { + return fetch(`https://registry.npmjs.org/${pkg}/${versionOrTag}`).then(resp => + res.json(), + ); +} +module.exports.getNpmPackageInfo = getNpmPackageInfo; + +module.exports.log = (...args) => console.log(...args); diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 296a246f93e..34925e23cf9 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -191,46 +191,23 @@ jobs: gha-npm-token: ${{ env.GHA_NPM_TOKEN }} - name: Publish @react-native-community/template id: publish-template-to-npm - shell: bash - run: | - COMMIT_MSG=$(git log -n1 --pretty=%B); - if grep -q '#publish-packages-to-npm&latest' <<< "$COMMIT_MSG"; then - echo "TAG=latest" >> $GITHUB_OUTPUT - IS_LATEST=true - else - IS_LATEST=false - fi - # Go from v0.75.0-rc.4 -> 0.75-stable, which is the template's branching scheme - VERSION=$(grep -oE '\d+\.\d+' <<< "${{ github.ref_name }}" | { read version; echo "$version-stable"; }) - echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - - curl -L https://api.github.com/repos/react-native-community/template/actions/workflows/release.yaml/dispatches - -H "Accept: application/vnd.github.v3+json" \ - -H "Authorization: Bearer $REACT_NATIVE_BOT_GITHUB_TOKEN" \ - -d "{\"ref\":\"$VERSION\",\"inputs\":{\"version\":\"${{ github.ref_name }}\",\"is_latest_on_npm\":\"$IS_LATEST\"}}" + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.REACT_NATIVE_BOT_GITHUB_TOKEN }} + script: | + const {publishTemplate} = require('./.github/workflow-scripts/publishTemplate.js') + const version = "${{ github.ref_name }}" + const isDryRun = false + await publishTemplate(github, version, isDryRun); - name: Wait for template to be published timeout-minutes: 3 - env: - VERSION: ${{ steps.publish-template-to-npm.outputs.VERSION }} - TAG: ${{ steps.publish-template-to-npm.outputs.TAG }} - shell: bash - run: | - echo "Waiting until @react-native-community/template is published to npm" - while true; do - if curl -o /dev/null -s -f "https://registry.npmjs.org/@react-native-community/template/$VERSION"; then - echo "Confirm that @react-native-community/template@$VERSION is published on npm" - break - fi - sleep 10 - done - while [ "$TAG" == "latest" ]; do - CURRENT=$(curl -s "https://registry.npmjs.org/react-native/latest" | jq -r '.version'); - if [ "$CURRENT" == "$VERSION" ]; then - echo "Confirm that @react-native-community/template@latest == $VERSION on npm" - break - fi - sleep 10 - done + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.REACT_NATIVE_BOT_GITHUB_TOKEN }} + script: | + const {verifyPublished, isLatest} = require('./.github/workflow-scripts/publishTemplate.js') + const version = "${{ github.ref_name }}" + await verifyPublished(version, isLatest()); - name: Update rn-diff-purge to generate upgrade-support diff run: | curl -X POST https://api.github.com/repos/react-native-community/rn-diff-purge/dispatches \