'use strict'; /* eslint-disable no-for-of-loops/no-for-of-loops */ const fs = require('fs'); const fse = require('fs-extra'); const {spawnSync} = require('child_process'); const path = require('path'); const tmp = require('tmp'); const shell = require('shelljs'); const { ReactVersion, stablePackages, experimentalPackages, canaryChannelLabel, rcNumber, } = require('../../ReactVersions'); const yargs = require('yargs'); const Bundles = require('./bundles'); // Runs the build script for both stable and experimental release channels, // by configuring an environment variable. const sha = String(spawnSync('git', ['rev-parse', 'HEAD']).stdout).slice(0, 8); let dateString = String( spawnSync('git', [ 'show', '-s', '--no-show-signature', '--format=%cd', '--date=format:%Y%m%d', sha, ]).stdout ).trim(); // On CI environment, this string is wrapped with quotes '...'s if (dateString.startsWith("'")) { dateString = dateString.slice(1, 9); } // Build the artifacts using a placeholder React version. We'll then do a string // replace to swap it with the correct version per release channel. // // The placeholder version is the same format that the "next" channel uses const PLACEHOLDER_REACT_VERSION = ReactVersion + '-' + canaryChannelLabel + '-' + sha + '-' + dateString; // TODO: We should inject the React version using a build-time parameter // instead of overwriting the source files. fs.writeFileSync( './packages/shared/ReactVersion.js', `export default '${PLACEHOLDER_REACT_VERSION}';\n` ); const argv = yargs.wrap(yargs.terminalWidth()).options({ releaseChannel: { alias: 'r', describe: 'Build the given release channel.', requiresArg: true, type: 'string', choices: ['experimental', 'stable'], }, index: { alias: 'i', describe: 'Worker id.', requiresArg: true, type: 'number', }, total: { alias: 't', describe: 'Total number of workers.', requiresArg: true, type: 'number', }, ci: { describe: 'Run tests in CI', requiresArg: false, type: 'boolean', default: false, }, type: { describe: `Build the given bundle type. (${Object.values( Bundles.bundleTypes )})`, requiresArg: false, type: 'string', }, pretty: { describe: 'Force pretty output.', requiresArg: false, type: 'boolean', }, 'sync-fbsource': { describe: 'Include to sync build to fbsource.', requiresArg: false, type: 'string', }, 'sync-www': { describe: 'Include to sync build to www.', requiresArg: false, type: 'string', }, 'unsafe-partial': { describe: 'Do not clean ./build first.', requiresArg: false, type: 'boolean', }, }).argv; async function main() { if (argv.ci === true) { buildForChannel(argv.releaseChannel, argv.total, argv.index); switch (argv.releaseChannel) { case 'stable': { processStable('./build'); break; } case 'experimental': { processExperimental('./build'); break; } default: throw new Error(`Unknown release channel ${argv.releaseChannel}`); } } else { // Running locally, no concurrency. Move each channel's build artifacts into // a temporary directory so that they don't conflict. buildForChannel('stable', '', ''); const stableDir = tmp.dirSync().name; crossDeviceRenameSync('./build', stableDir); processStable(stableDir); buildForChannel('experimental', '', ''); const experimentalDir = tmp.dirSync().name; crossDeviceRenameSync('./build', experimentalDir); processExperimental(experimentalDir); // Then merge the experimental folder into the stable one. processExperimental // will have already removed conflicting files. // // In CI, merging is handled by the GitHub Download Artifacts plugin. mergeDirsSync(experimentalDir + '/', stableDir + '/'); // Now restore the combined directory back to its original name crossDeviceRenameSync(stableDir, './build'); } } function buildForChannel(channel, total, index) { const {status} = spawnSync( 'node', ['./scripts/rollup/build.js', ...process.argv.slice(2)], { stdio: ['pipe', process.stdout, process.stderr], env: { ...process.env, RELEASE_CHANNEL: channel, CI_TOTAL: total, CI_INDEX: index, }, } ); if (status !== 0) { // Error of spawned process is already piped to this stderr process.exit(status); } } function processStable(buildDir) { if (fs.existsSync(buildDir + '/node_modules')) { // Identical to `oss-stable` but with real, semver versions. This is what // will get published to @latest. shell.cp('-r', buildDir + '/node_modules', buildDir + '/oss-stable-semver'); if (canaryChannelLabel === 'rc') { // During the RC phase, we also generate an RC build that pins to exact // versions but does not include a SHA, e.g. `19.0.0-rc.0`. This is purely // for signaling purposes — aside from the version, it's no different from // the corresponding canary. shell.cp('-r', buildDir + '/node_modules', buildDir + '/oss-stable-rc'); } const defaultVersionIfNotFound = '0.0.0' + '-' + sha + '-' + dateString; const versionsMap = new Map(); for (const moduleName in stablePackages) { const version = stablePackages[moduleName]; versionsMap.set( moduleName, version + '-' + canaryChannelLabel + '-' + sha + '-' + dateString, defaultVersionIfNotFound ); } updatePackageVersions( buildDir + '/node_modules', versionsMap, defaultVersionIfNotFound, true ); fs.renameSync(buildDir + '/node_modules', buildDir + '/oss-stable'); updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/oss-stable', ReactVersion + '-' + canaryChannelLabel + '-' + sha + '-' + dateString ); if (canaryChannelLabel === 'rc') { const rcVersionsMap = new Map(); for (const moduleName in stablePackages) { const version = stablePackages[moduleName]; rcVersionsMap.set(moduleName, version + `-rc.${rcNumber}`); } updatePackageVersions( buildDir + '/oss-stable-rc', rcVersionsMap, defaultVersionIfNotFound, // For RCs, we pin to exact versions, like we do for canaries. true ); updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/oss-stable-rc', ReactVersion ); } const rnVersionString = ReactVersion + '-native-fb-' + sha + '-' + dateString; if (fs.existsSync(buildDir + '/facebook-react-native')) { updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/facebook-react-native', rnVersionString ); } if (fs.existsSync(buildDir + '/react-native')) { updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/react-native', rnVersionString, filename => filename.endsWith('.fb.js') ); updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/react-native', ReactVersion, filename => !filename.endsWith('.fb.js') && filename.endsWith('.js') ); } // Now do the semver ones const semverVersionsMap = new Map(); for (const moduleName in stablePackages) { const version = stablePackages[moduleName]; semverVersionsMap.set(moduleName, version); } updatePackageVersions( buildDir + '/oss-stable-semver', semverVersionsMap, defaultVersionIfNotFound, // Use ^ only for non-prerelease versions false ); updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/oss-stable-semver', ReactVersion ); } if (fs.existsSync(buildDir + '/facebook-www')) { for (const fileName of fs.readdirSync(buildDir + '/facebook-www')) { const filePath = buildDir + '/facebook-www/' + fileName; const stats = fs.statSync(filePath); if (!stats.isDirectory()) { fs.renameSync(filePath, filePath.replace('.js', '.classic.js')); } } const versionString = ReactVersion + '-www-classic-' + sha + '-' + dateString; updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/facebook-www', versionString ); // Also save a file with the version number fs.writeFileSync(buildDir + '/facebook-www/VERSION_CLASSIC', versionString); } if (fs.existsSync(buildDir + '/sizes')) { fs.renameSync(buildDir + '/sizes', buildDir + '/sizes-stable'); } } function processExperimental(buildDir, version) { if (fs.existsSync(buildDir + '/node_modules')) { const defaultVersionIfNotFound = '0.0.0' + '-experimental-' + sha + '-' + dateString; const versionsMap = new Map(); for (const moduleName in stablePackages) { versionsMap.set(moduleName, defaultVersionIfNotFound); } for (const moduleName of experimentalPackages) { versionsMap.set(moduleName, defaultVersionIfNotFound); } updatePackageVersions( buildDir + '/node_modules', versionsMap, defaultVersionIfNotFound, true ); fs.renameSync(buildDir + '/node_modules', buildDir + '/oss-experimental'); updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/oss-experimental', // TODO: The npm version for experimental releases does not include the // React version, but the runtime version does so that DevTools can do // feature detection. Decide what to do about this later. ReactVersion + '-experimental-' + sha + '-' + dateString ); } if (fs.existsSync(buildDir + '/facebook-www')) { for (const fileName of fs.readdirSync(buildDir + '/facebook-www')) { const filePath = buildDir + '/facebook-www/' + fileName; const stats = fs.statSync(filePath); if (!stats.isDirectory()) { fs.renameSync(filePath, filePath.replace('.js', '.modern.js')); } } const versionString = ReactVersion + '-www-modern-' + sha + '-' + dateString; updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/facebook-www', versionString ); // Also save a file with the version number fs.writeFileSync(buildDir + '/facebook-www/VERSION_MODERN', versionString); } const rnVersionString = ReactVersion + '-native-fb-' + sha + '-' + dateString; if (fs.existsSync(buildDir + '/facebook-react-native')) { updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/facebook-react-native', rnVersionString ); // Also save a file with the version number fs.writeFileSync( buildDir + '/facebook-react-native/VERSION_NATIVE_FB', rnVersionString ); } if (fs.existsSync(buildDir + '/react-native')) { updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/react-native', rnVersionString, filename => filename.endsWith('.fb.js') ); updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/react-native', ReactVersion, filename => !filename.endsWith('.fb.js') && filename.endsWith('.js') ); } if (fs.existsSync(buildDir + '/sizes')) { fs.renameSync(buildDir + '/sizes', buildDir + '/sizes-experimental'); } // Delete all other artifacts that weren't handled above. We assume they are // duplicates of the corresponding artifacts in the stable channel. Ideally, // the underlying build script should not have produced these files in the // first place. for (const pathName of fs.readdirSync(buildDir)) { if ( pathName !== 'oss-experimental' && pathName !== 'facebook-www' && pathName !== 'sizes-experimental' ) { spawnSync('rm', ['-rm', buildDir + '/' + pathName]); } } } function crossDeviceRenameSync(source, destination) { return fse.moveSync(source, destination, {overwrite: true}); } /* * Grabs the built packages in ${tmp_build_dir}/node_modules and updates the * `version` key in their package.json to 0.0.0-${date}-${commitHash} for the commit * you're building. Also updates the dependencies and peerDependencies * to match this version for all of the 'React' packages * (packages available in this repo). */ function updatePackageVersions( modulesDir, versionsMap, defaultVersionIfNotFound, pinToExactVersion ) { for (const moduleName of fs.readdirSync(modulesDir)) { let version = versionsMap.get(moduleName); if (version === undefined) { // TODO: If the module is not in the version map, we should exclude it // from the build artifacts. version = defaultVersionIfNotFound; } const packageJSONPath = path.join(modulesDir, moduleName, 'package.json'); const stats = fs.statSync(packageJSONPath); if (stats.isFile()) { const packageInfo = JSON.parse(fs.readFileSync(packageJSONPath)); // Update version packageInfo.version = version; if (packageInfo.dependencies) { for (const dep of Object.keys(packageInfo.dependencies)) { const depVersion = versionsMap.get(dep); if (depVersion !== undefined) { packageInfo.dependencies[dep] = pinToExactVersion ? depVersion : '^' + depVersion; } } } if (packageInfo.peerDependencies) { if ( !pinToExactVersion && (moduleName === 'use-sync-external-store' || moduleName === 'use-subscription') ) { // use-sync-external-store supports older versions of React, too, so // we don't override to the latest version. We should figure out some // better way to handle this. // TODO: Remove this special case. } else { for (const dep of Object.keys(packageInfo.peerDependencies)) { const depVersion = versionsMap.get(dep); if (depVersion !== undefined) { packageInfo.peerDependencies[dep] = pinToExactVersion ? depVersion : '^' + depVersion; } } } } // Write out updated package.json fs.writeFileSync(packageJSONPath, JSON.stringify(packageInfo, null, 2)); } } } function updatePlaceholderReactVersionInCompiledArtifacts( artifactsDirectory, newVersion, filteringClosure ) { // Update the version of React in the compiled artifacts by searching for // the placeholder string and replacing it with a new one. if (filteringClosure == null) { filteringClosure = filename => filename.endsWith('.js'); } const artifactFilenames = String( spawnSync('grep', [ '-lr', PLACEHOLDER_REACT_VERSION, '--', artifactsDirectory, ]).stdout ) .trim() .split('\n') .filter(filteringClosure); for (const artifactFilename of artifactFilenames) { const originalText = fs.readFileSync(artifactFilename, 'utf8'); const replacedText = originalText.replaceAll( PLACEHOLDER_REACT_VERSION, newVersion ); fs.writeFileSync(artifactFilename, replacedText); } } /** * cross-platform alternative to `rsync -ar` * @param {string} source * @param {string} destination */ function mergeDirsSync(source, destination) { for (const sourceFileBaseName of fs.readdirSync(source)) { const sourceFileName = path.join(source, sourceFileBaseName); const targetFileName = path.join(destination, sourceFileBaseName); const sourceFile = fs.statSync(sourceFileName); if (sourceFile.isDirectory()) { fse.ensureDirSync(targetFileName); mergeDirsSync(sourceFileName, targetFileName); } else { fs.copyFileSync(sourceFileName, targetFileName); } } } main();