diff --git a/.circleci/config.yml b/.circleci/config.yml index 8464e162fb2fa7..ef511ea4b6baee 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -34,6 +34,18 @@ references: attach_workspace: at: *hermes_workspace_root + main_only: &main_only + filters: + branches: + only: main + main_or_stable_only: &main_or_stable_only + filters: + branches: + only: + - main + - /0\.[0-9]+[\.[0-9]+]?-stable/ + + # ------------------------- # Dependency Anchors # ------------------------- @@ -808,10 +820,7 @@ jobs: command: | REPO_ROOT=$(pwd) node ./scripts/set-rn-template-version.js "file:$REPO_ROOT/build/$(cat build/react-native-package-version)" - node cli.js init $PROJECT_NAME --directory "/tmp/$PROJECT_NAME" --template $REPO_ROOT --verbose --skip-install - cd /tmp/$PROJECT_NAME - yarn - + node ./scripts/template/initialize.js --reactNativeRootPath $REPO_ROOT --templateName $PROJECT_NAME --templateConfigPath $REPO_ROOT --directory "/tmp/$PROJECT_NAME" - run: name: Build the template application for << parameters.flavor >> with Architecture set to << parameters.architecture >>, and using the << parameters.jsengine>> JS engine. command: | @@ -914,13 +923,11 @@ jobs: PACKAGE=$(cat build/react-native-package-version) PATH_TO_PACKAGE="$REPO_ROOT/build/$PACKAGE" node ./scripts/set-rn-template-version.js "file:$PATH_TO_PACKAGE" - node cli.js init $PROJECT_NAME --directory "/tmp/$PROJECT_NAME" --template $REPO_ROOT --verbose --skip-install + node ./scripts/template/initialize.js --reactNativeRootPath $REPO_ROOT --templateName $PROJECT_NAME --templateConfigPath $REPO_ROOT --directory "/tmp/$PROJECT_NAME" - run: name: Install iOS dependencies - Configuration << parameters.flavor >>; New Architecture << parameters.architecture >>; JS Engine << parameters.jsengine>>; Flipper << parameters.flipper >> command: | - cd /tmp/$PROJECT_NAME - yarn install - cd ios + cd /tmp/$PROJECT_NAME/ios bundle install @@ -1561,6 +1568,18 @@ jobs: command: | echo "Nightly build run" + find_and_publish_bumped_packages: + executor: reactnativeandroid + steps: + - checkout + - run_yarn + - run: + name: Set NPM auth token + command: echo "//registry.npmjs.org/:_authToken=${CIRCLE_NPM_TOKEN}" > ~/.npmrc + - run: + name: Find and publish all bumped packages + command: node ./scripts/monorepo/find-and-publish-all-bumped-packages.js + # ------------------------- # PIPELINE PARAMETERS @@ -1749,11 +1768,8 @@ workflows: unless: << pipeline.parameters.run_package_release_workflow_only >> triggers: - schedule: + <<: *main_only cron: "0 20 * * *" - filters: - branches: - only: - - main jobs: - nightly_job @@ -1776,3 +1792,8 @@ workflows: - build_hermesc_linux - build_hermes_macos - build_hermesc_windows + + publish_bumped_packages: + jobs: + - find_and_publish_bumped_packages: + <<: *main_or_stable_only diff --git a/scripts/__tests__/find-and-publish-all-bumped-packages-test.js b/scripts/__tests__/find-and-publish-all-bumped-packages-test.js new file mode 100644 index 00000000000000..ee44b354b1e4c9 --- /dev/null +++ b/scripts/__tests__/find-and-publish-all-bumped-packages-test.js @@ -0,0 +1,35 @@ +/** + * 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 {exec} = require('shelljs'); + +const forEachPackage = require('../monorepo/for-each-package'); +const findAndPublishAllBumpedPackages = require('../monorepo/find-and-publish-all-bumped-packages'); + +jest.mock('shelljs', () => ({exec: jest.fn()})); +jest.mock('../monorepo/for-each-package', () => jest.fn()); + +describe('findAndPublishAllBumpedPackages', () => { + it('throws an error if updated version is not 0.x.y', () => { + const mockedPackageNewVersion = '1.0.0'; + + forEachPackage.mockImplementationOnce(callback => { + callback('absolute/path/to/package', 'to/package', { + version: mockedPackageNewVersion, + }); + }); + exec.mockImplementationOnce(() => ({ + stdout: `- "version": "0.72.0"\n+ "version": "${mockedPackageNewVersion}"\n`, + })); + + expect(() => findAndPublishAllBumpedPackages()).toThrow( + `Package version expected to be 0.x.y, but received ${mockedPackageNewVersion}`, + ); + }); +}); diff --git a/scripts/__tests__/for-each-package-test.js b/scripts/__tests__/for-each-package-test.js new file mode 100644 index 00000000000000..23bbb0925772bb --- /dev/null +++ b/scripts/__tests__/for-each-package-test.js @@ -0,0 +1,51 @@ +/** + * 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 path = require('path'); +const {readdirSync, readFileSync} = require('fs'); + +const forEachPackage = require('../monorepo/for-each-package'); + +jest.mock('fs', () => ({ + readdirSync: jest.fn(), + readFileSync: jest.fn(), +})); + +describe('forEachPackage', () => { + it('executes callback call with parameters', () => { + const callback = jest.fn(); + const mockedPackageManifest = '{"name": "my-new-package"}'; + const mockedParsedPackageManifest = JSON.parse(mockedPackageManifest); + const mockedPackageName = 'my-new-package'; + + readdirSync.mockImplementationOnce(() => [ + {name: mockedPackageName, isDirectory: () => true}, + ]); + readFileSync.mockImplementationOnce(() => mockedPackageManifest); + + forEachPackage(callback); + + expect(callback).toHaveBeenCalledWith( + path.join(__dirname, '..', '..', 'packages', mockedPackageName), + path.join('packages', mockedPackageName), + mockedParsedPackageManifest, + ); + }); + + it('filters react-native folder', () => { + const callback = jest.fn(); + readdirSync.mockImplementationOnce(() => [ + {name: 'react-native', isDirectory: () => true}, + ]); + + forEachPackage(callback); + + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/scripts/monorepo/find-and-publish-all-bumped-packages.js b/scripts/monorepo/find-and-publish-all-bumped-packages.js new file mode 100644 index 00000000000000..b2a75ab0225be5 --- /dev/null +++ b/scripts/monorepo/find-and-publish-all-bumped-packages.js @@ -0,0 +1,96 @@ +/** + * 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 path = require('path'); +const chalk = require('chalk'); +const {exec} = require('shelljs'); + +const forEachPackage = require('./for-each-package'); + +const ROOT_LOCATION = path.join(__dirname, '..', '..'); +const NPM_CONFIG_OTP = process.env.NPM_CONFIG_OTP; + +const findAndPublishAllBumpedPackages = () => { + console.log('Traversing all packages inside /packages...'); + + forEachPackage( + (packageAbsolutePath, packageRelativePathFromRoot, packageManifest) => { + if (packageManifest.private) { + console.log( + `\u23ED Skipping private package ${chalk.dim(packageManifest.name)}`, + ); + + return; + } + + const diff = exec( + `git log -p --format="" HEAD~1..HEAD ${packageRelativePathFromRoot}/package.json`, + {cwd: ROOT_LOCATION, silent: true}, + ).stdout; + + const previousVersionPatternMatches = diff.match( + /- {2}"version": "([0-9]+.[0-9]+.[0-9]+)"/, + ); + + if (!previousVersionPatternMatches) { + console.log( + `\uD83D\uDD0E No version bump for ${chalk.green( + packageManifest.name, + )}`, + ); + + return; + } + + const [, previousVersion] = previousVersionPatternMatches; + const nextVersion = packageManifest.version; + + console.log( + `\uD83D\uDCA1 ${chalk.yellow( + packageManifest.name, + )} was updated: ${chalk.red(previousVersion)} -> ${chalk.green( + nextVersion, + )}`, + ); + + if (!nextVersion.startsWith('0.')) { + throw new Error( + `Package version expected to be 0.x.y, but received ${nextVersion}`, + ); + } + + const npmOTPFlag = NPM_CONFIG_OTP ? `--otp ${NPM_CONFIG_OTP}` : ''; + + const {code, stderr} = exec(`npm publish ${npmOTPFlag}`, { + cwd: packageAbsolutePath, + silent: true, + }); + if (code) { + console.log( + chalk.red( + `\u274c Failed to publish version ${nextVersion} of ${packageManifest.name}. Stderr:`, + ), + ); + console.log(stderr); + + process.exit(1); + } else { + console.log( + `\u2705 Successfully published new version of ${chalk.green( + packageManifest.name, + )}`, + ); + } + }, + ); + + process.exit(0); +}; + +findAndPublishAllBumpedPackages(); diff --git a/scripts/monorepo/for-each-package.js b/scripts/monorepo/for-each-package.js new file mode 100644 index 00000000000000..10991b5936da1d --- /dev/null +++ b/scripts/monorepo/for-each-package.js @@ -0,0 +1,59 @@ +/** + * 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 path = require('path'); +const {readdirSync, readFileSync} = require('fs'); + +const ROOT_LOCATION = path.join(__dirname, '..', '..'); +const PACKAGES_LOCATION = path.join(ROOT_LOCATION, 'packages'); + +const PACKAGES_BLOCK_LIST = ['react-native']; + +/** + * Function, which returns an array of all directories inside specified location + * + * @param {string} source Path to directory, where this should be executed + * @returns {string[]} List of directories names + */ +const getDirectories = source => + readdirSync(source, {withFileTypes: true}) + .filter(file => file.isDirectory()) + .map(directory => directory.name); + +/** + * @callback forEachPackageCallback + * @param {string} packageAbsolutePath + * @param {string} packageRelativePathFromRoot + * @param {Object} packageManifest + */ + +/** + * Iterate through every package inside /packages (ignoring react-native) and call provided callback for each of them + * + * @param {forEachPackageCallback} callback The callback which will be called for each package + */ +const forEachPackage = callback => { + // We filter react-native package on purpose, so that no CI's script will be executed for this package in future + const packagesDirectories = getDirectories(PACKAGES_LOCATION).filter( + directoryName => !PACKAGES_BLOCK_LIST.includes(directoryName), + ); + + packagesDirectories.forEach(packageDirectory => { + const packageAbsolutePath = path.join(PACKAGES_LOCATION, packageDirectory); + const packageRelativePathFromRoot = path.join('packages', packageDirectory); + + const packageManifest = JSON.parse( + readFileSync(path.join(packageAbsolutePath, 'package.json')), + ); + + callback(packageAbsolutePath, packageRelativePathFromRoot, packageManifest); + }); +}; + +module.exports = forEachPackage; diff --git a/scripts/run-ci-e2e-tests.js b/scripts/run-ci-e2e-tests.js index 9a928be712e59f..55f8c29bd8904c 100644 --- a/scripts/run-ci-e2e-tests.js +++ b/scripts/run-ci-e2e-tests.js @@ -23,7 +23,9 @@ const {cd, cp, echo, exec, exit, mv, rm} = require('shelljs'); const spawn = require('child_process').spawn; const argv = require('yargs').argv; const path = require('path'); -const {setupVerdaccio} = require('./setup-verdaccio'); + +const forEachPackage = require('./monorepo/for-each-package'); +const setupVerdaccio = require('./setup-verdaccio'); const SCRIPTS = __dirname; const ROOT = path.normalize(path.join(__dirname, '..')); @@ -34,6 +36,9 @@ const REACT_NATIVE_TEMP_DIR = exec( ).stdout.trim(); const REACT_NATIVE_APP_DIR = `${REACT_NATIVE_TEMP_DIR}/template`; const numberOfRetries = argv.retries || 1; + +const VERDACCIO_CONFIG_PATH = path.join(ROOT, '.circleci/verdaccio.yml'); + let SERVER_PID; let APPIUM_PID; let VERDACCIO_PID; @@ -73,17 +78,21 @@ try { const REACT_NATIVE_PACKAGE = path.join(ROOT, 'react-native-*.tgz'); describe('Set up Verdaccio'); - VERDACCIO_PID = setupVerdaccio(); + VERDACCIO_PID = setupVerdaccio(ROOT, VERDACCIO_CONFIG_PATH); describe('Publish packages'); - const packages = JSON.parse( - JSON.parse(exec('yarn --json workspaces info').stdout).data, + forEachPackage( + (packageAbsolutePath, packageRelativePathFromRoot, packageManifest) => { + if (packageManifest.private) { + return; + } + + exec( + 'npm publish --registry http://localhost:4873 --yes --access public', + {cwd: packageAbsolutePath}, + ); + }, ); - Object.keys(packages).forEach(packageName => { - exec( - `cd ${packages[packageName].location} && npm publish --registry http://localhost:4873 --yes --access public`, - ); - }); describe('Scaffold a basic React Native app from template'); exec(`rsync -a ${ROOT}/template ${REACT_NATIVE_TEMP_DIR}`); diff --git a/scripts/setup-verdaccio.js b/scripts/setup-verdaccio.js index e62e7918540d2a..3dc32a261dbe60 100644 --- a/scripts/setup-verdaccio.js +++ b/scripts/setup-verdaccio.js @@ -9,22 +9,41 @@ 'use strict'; -const {exec} = require('shelljs'); -const spawn = require('child_process').spawn; - -function setupVerdaccio() { - const verdaccioProcess = spawn('npx', [ - 'verdaccio@5.15.3', - '--config', - '.circleci/verdaccio.yml', - ]); +const {execSync, spawn} = require('child_process'); + +function setupVerdaccio( + reactNativeRootPath, // Path to React Native root folder + verdaccioConfigPath, // Path to Verdaccio config file, which you want to use for bootstrapping Verdaccio + verdaccioStoragePath, // Path to Verdaccio storage, where it should keep packages. Optional. Default value will be decided by your Verdaccio config +) { + if (!reactNativeRootPath) { + throw new Error( + 'Path to React Native repo root is not specified. You should provide it as a first argument', + ); + } + + if (!verdaccioConfigPath) { + throw new Error( + 'Path to Verdaccio config is not specified. You should provide it as a second argument', + ); + } + + const verdaccioProcess = spawn( + 'npx', + ['verdaccio@5.16.3', '--config', verdaccioConfigPath], + {env: {...process.env, VERDACCIO_STORAGE_PATH: verdaccioStoragePath}}, + ); + const VERDACCIO_PID = verdaccioProcess.pid; - exec('npx wait-on@6.0.1 http://localhost:4873'); - exec('npm set registry http://localhost:4873'); - exec('echo "//localhost:4873/:_authToken=secretToken" > .npmrc'); + + execSync('npx wait-on@6.0.1 http://localhost:4873'); + + execSync('npm set registry http://localhost:4873'); + execSync('echo "//localhost:4873/:_authToken=secretToken" > .npmrc', { + cwd: reactNativeRootPath, + }); + return VERDACCIO_PID; } -module.exports = { - setupVerdaccio: setupVerdaccio, -}; +module.exports = setupVerdaccio; diff --git a/scripts/template/initialize.js b/scripts/template/initialize.js new file mode 100644 index 00000000000000..eaba0e4e900373 --- /dev/null +++ b/scripts/template/initialize.js @@ -0,0 +1,95 @@ +/** + * 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 + */ + +'use strict'; + +const yargs = require('yargs'); +const {execSync, spawnSync} = require('child_process'); + +const forEachPackage = require('../monorepo/for-each-package'); +const setupVerdaccio = require('../setup-verdaccio'); + +const {argv} = yargs + .option('r', { + alias: 'reactNativeRootPath', + describe: 'Path to root folder of react-native', + required: true, + }) + .option('n', { + alias: 'templateName', + describe: 'Template App name', + required: true, + }) + .option('tcp', { + alias: 'templateConfigPath', + describe: 'Path to folder containing template config', + required: true, + }) + .option('d', { + alias: 'directory', + describe: 'Path to template application folder', + required: true, + }) + .strict(); + +const {reactNativeRootPath, templateName, templateConfigPath, directory} = argv; + +const VERDACCIO_CONFIG_PATH = `${reactNativeRootPath}/.circleci/verdaccio.yml`; + +function install() { + const VERDACCIO_PID = setupVerdaccio( + reactNativeRootPath, + VERDACCIO_CONFIG_PATH, + ); + process.stdout.write('Bootstrapped Verdaccio \u2705\n'); + + process.stdout.write('Starting to publish every package...\n'); + forEachPackage( + (packageAbsolutePath, packageRelativePathFromRoot, packageManifest) => { + if (packageManifest.private) { + return; + } + + execSync('npm publish --registry http://localhost:4873 --access public', { + cwd: packageAbsolutePath, + stdio: [process.stdin, process.stdout, process.stderr], + }); + + process.stdout.write( + `Published ${packageManifest.name} to proxy \u2705\n`, + ); + }, + ); + + process.stdout.write('Published every package \u2705\n'); + + execSync( + `node cli.js init ${templateName} --directory ${directory} --template ${templateConfigPath} --verbose --skip-install`, + { + cwd: reactNativeRootPath, + stdio: [process.stdin, process.stdout, process.stderr], + }, + ); + process.stdout.write('Completed initialization of template app \u2705\n'); + + process.stdout.write('Installing dependencies in template app folder...\n'); + spawnSync('yarn', ['install'], { + cwd: directory, + stdio: [process.stdin, process.stdout, process.stderr], + }); + process.stdout.write('Installed dependencies via Yarn \u2705\n'); + + process.stdout.write(`Killing verdaccio. PID — ${VERDACCIO_PID}...\n`); + execSync(`kill -9 ${VERDACCIO_PID}`); + process.stdout.write('Killed Verdaccio process \u2705\n'); + + process.exit(); +} + +install();