From 1a5475614a4dbd98a97b9f74f886181cd5774d00 Mon Sep 17 00:00:00 2001 From: Ryan Ciehanski Date: Sun, 27 Sep 2026 21:30:48 -0500 Subject: [PATCH] feat(release-react): compile Hermes bytecode, and add bundle-react --- README.md | 51 +++++++++ script/child-env.ts | 16 +++ script/command-executor.ts | 130 ++++++++++++++++++++- script/command-parser.ts | 121 ++++++++++++++++++++ script/react-native-utils.ts | 214 +++++++++++++++++++++++++++++++++++ script/types/cli.ts | 18 +++ test/cli.ts | 144 +++++++++++++++++++++++ test/command-parser.ts | 12 ++ 8 files changed, 705 insertions(+), 1 deletion(-) create mode 100644 script/child-env.ts create mode 100644 script/react-native-utils.ts diff --git a/README.md b/README.md index 00b1f57..7366682 100644 --- a/README.md +++ b/README.md @@ -478,6 +478,9 @@ dpctl release-react [--targetBinaryVersion ] [--rollout ] [--private-key ] +[--useHermes] +[--extraHermesFlags ] +[--podFile ] ``` The `release-react` command is a React Native-specific version of the "vanilla" [`release`](#releasing-app-updates) command, which supports all of the same parameters (e.g. `--mandatory`, `--description`), yet simplifies the process of releasing updates by performing the following additional behavior: @@ -486,6 +489,8 @@ The `release-react` command is a React Native-specific version of the "vanilla" 2. Inferring the [`targetBinaryVersion`](#target-binary-version-parameter) of this release by using the version name that is specified in your project's `Info.plist` (for iOS) and `build.gradle` (for Android) files. +3. Compiling the bundle to Hermes bytecode when Hermes is enabled for the platform (see the [Hermes parameters](#hermes-parameters)). + To illustrate the difference that the `release-react` command can make, the following is an example of how you might generate and release an update for a React Native app using the "vanilla" `release` command: ```shell @@ -615,6 +620,52 @@ This is the same parameter as the one described in the [above section](#private- dpctl release-react MyApp-iOS ios --private-key ./private.pem ``` +#### Hermes parameters + +When Hermes is enabled, `release-react` compiles the JS bundle to Hermes bytecode with the `hermesc` that ships with React Native. If you asked for a source map with `--sourcemapOutput`, it composes the Hermes map with it so stack traces resolve to your original source. Hermes is detected automatically: + +| Platform | Detected from | +| -------- | ------------------------------------------------------------------------------------------------------- | +| Android | `hermesEnabled=true` in `android/gradle.properties`, or `enableHermes: true` in `android/app/build.gradle` | +| iOS | `:hermes_enabled => true` in `ios/Podfile`, or the Podfile `expo prebuild` generates | + +The React Native 0.70+ iOS template enables Hermes without writing either, so pass `--useHermes` when releasing for iOS from those projects. + +- `--useHermes` compiles to Hermes bytecode even when Hermes isn't detected. There is no short flag: `-h` is help. +- `--extraHermesFlags` (`-hf`) passes extra flags to `hermesc`. +- `--podFile` (`-pod`) is the Podfile to check when it isn't `ios/Podfile`. + +```shell +dpctl release-react MyApp-iOS ios --useHermes --sourcemapOutput ./main.jsbundle.map +``` + +`bundle-react` accepts the same three parameters. + +### Releasing Updates (Expo Updates) + +```shell +dpctl release-expo +[--deploymentName ] +[--platform ] +[--runtimeVersion ] +[--exportDir ] +[--metadata ] +``` + +`release-expo` is for apps that use `expo-updates` rather than the CodePush SDK. The app must have been created with `--platform expo-v1`. Run it from your project folder: + +```shell +dpctl release-expo MyExpoApp -d Production +``` + +It does what `eas update` does before uploading: + +1. Runs `npx expo export` for iOS and Android, using the Expo CLI installed in your project. +2. Resolves each platform's runtime version from your app config with `npx expo-updates runtimeversion:resolve`, so every runtime version policy works, `fingerprint` included. +3. Uploads each platform as its own release, then removes its temporary files. + +Both platforms are exported and packaged before either is uploaded, so a problem with one can't leave the other released on its own. If you run `release-expo` on a CodePush app, dpctl stops before exporting and tells you to use `release-react` instead. + ## Debugging DeployPulse Integration Once you've released an update, React Native plugin has been integrated into your app, it can be helpful to diagnose how the plugin is behaving, especially if you run into an issue and want to understand why. In order to debug the DeployPulse update discovery experience, you can run the following command in order to easily view the diagnostic logs produced by the CodePush plugin within your app: diff --git a/script/child-env.ts b/script/child-env.ts new file mode 100644 index 0000000..9433e4c --- /dev/null +++ b/script/child-env.ts @@ -0,0 +1,16 @@ +/** + * The environment to hand a build tool. + * + * `release-react` and `release-expo` shell out to Metro, the Expo CLI and the Hermes compiler, all of + * which execute code from the app's own node_modules: Babel plugins, Metro transformers, Expo config + * plugins. A child inherits process.env by default, so with `DEPLOYPULSE_ACCESS_KEY` set (the documented + * way to authenticate in CI) any one of those transitive dependencies could read a full-access key and + * publish an OTA update to every app in the account. Nothing in a bundler needs the credential. + * + * A key read from the session file was never in the environment, so this closes the whole path. + */ +export function envWithoutCredentials(): NodeJS.ProcessEnv { + const env = { ...process.env }; + delete env.DEPLOYPULSE_ACCESS_KEY; + return env; +} diff --git a/script/command-executor.ts b/script/command-executor.ts index a88cc73..401ecd3 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -7,6 +7,11 @@ import * as crypto from "crypto"; import debugCommand from "./commands/debug"; import * as fs from "fs"; import * as hashUtils from "./hash-utils"; +import * as recursiveFs from "recursive-fs"; +import * as yazl from "yazl"; +import slash = require("slash"); +import { envWithoutCredentials } from "./child-env"; +import { compileHermesIfEnabled } from "./react-native-utils"; import { appPlatformLabel, assertReleasePlatform, checkReleaseProjectKind } from "./ota-runtime"; import * as chalk from "chalk"; const g2js = require("gradle-to-js/lib/parser"); @@ -598,6 +603,9 @@ export function execute(command: cli.ICommand) { case cli.CommandType.appSetPublicKey: return appSetPublicKey(command); + case cli.CommandType.bundleReact: + return bundleReact(command); + case cli.CommandType.appTransfer: return appTransfer(command); @@ -1457,6 +1465,21 @@ export const releaseReact = (command: cli.IReleaseReactCommand): Promise = command.sourcemapOutput ) ) + // Hermes runs after bundling and before the upload: it rewrites the bundle in place. + .then(() => + Q( + compileHermesIfEnabled({ + platform, + bundleName, + outputFolder, + sourcemapOutput: command.sourcemapOutput, + useHermes: command.useHermes, + extraHermesFlags: command.extraHermesFlags, + podFile: command.podFile, + log, + }) + ) + ) .then(() => { log(chalk.cyan("\nReleasing update contents to DeployPulse:\n")); return release(releaseCommand); @@ -1473,6 +1496,111 @@ export const releaseReact = (command: cli.IReleaseReactCommand): Promise = ); }; +export const bundleReact = (command: cli.IBundleReactCommand): Promise => { + let bundleName: string = command.bundleName; + let entryFile: string = command.entryFile; + const isTempDir = !command.outputDir; + const outputFolder: string = command.outputDir || path.join(os.tmpdir(), "dpctl"); + const platform: string = (command.platform = command.platform.toLowerCase()); + const outputZipPath: string = path.resolve(command.outputPath || "bundle.zip"); + + return Q(null) + .then((): void => { + switch (platform) { + case "android": + case "ios": + case "windows": + if (!bundleName) { + bundleName = platform === "ios" ? "main.jsbundle" : `index.${platform}.bundle`; + } + break; + default: + throw new Error('Platform must be either "android", "ios" or "windows".'); + } + + // Only the read is guarded. Wrapping the checks too made their messages unreachable, so a project + // with no "name" was reported as an unreadable package.json. + let projectPackageJson: any; + try { + projectPackageJson = require(path.join(process.cwd(), "package.json")); + } catch { + throw new Error( + 'Unable to find or read "package.json" in the CWD. The "bundle-react" command must be executed in a React Native project folder.' + ); + } + if (!projectPackageJson.name) { + throw new Error('The "package.json" file in the CWD does not have the "name" field set.'); + } + if (!projectPackageJson.dependencies?.["react-native"]) { + throw new Error("The project in the CWD is not a React Native project."); + } + + if (!entryFile) { + entryFile = `index.${platform}.js`; + if (fileDoesNotExistOrIsDirectory(entryFile)) entryFile = "index.js"; + if (fileDoesNotExistOrIsDirectory(entryFile)) { + throw new Error(`Entry file "index.${platform}.js" or "index.js" does not exist.`); + } + } else if (fileDoesNotExistOrIsDirectory(entryFile)) { + throw new Error(`Entry file "${entryFile}" does not exist.`); + } + }) + .then(() => createEmptyTempReleaseFolder(outputFolder)) + .then(() => deleteFolder(`${os.tmpdir()}/react-*`, /*glob*/ true)) + .then(() => + runReactNativeBundleCommand(bundleName, command.development || false, entryFile, outputFolder, platform, command.sourcemapOutput) + ) + // Before the signature step, which hashes outputFolder: signing the JS and then swapping in + // bytecode would produce a zip whose signature fails verification on device. + .then(() => + Q( + compileHermesIfEnabled({ + platform, + bundleName, + outputFolder, + sourcemapOutput: command.sourcemapOutput, + useHermes: command.useHermes, + extraHermesFlags: command.extraHermesFlags, + podFile: command.podFile, + log, + }) + ) + ) + .then((): Promise => { + if (!command.privateKey) return Q(undefined); + const privateKey = resolvePrivateKey(command.privateKey); + return hashUtils + .generatePackageHashFromDirectory(outputFolder, signatureManifestBase(outputFolder)) + .then((packageHash: string) => createRS256JWT(privateKey, packageHash)); + }) + .then((signatureJwt: string | undefined): Promise => { + return Promise((resolve, reject) => { + recursiveFs.readdirr(outputFolder, (error?: any, _dirs?: string[], files?: string[]) => { + if (error) { reject(error); return; } + const baseDir = path.dirname(outputFolder); + const zipFile = new yazl.ZipFile(); + const writeStream = fs.createWriteStream(outputZipPath); + zipFile.outputStream.pipe(writeStream).on("error", reject).on("close", resolve); + for (const file of files) { + zipFile.addFile(file, slash(path.relative(baseDir, file))); + } + if (signatureJwt) { + zipFile.addBuffer(Buffer.from(signatureJwt), "CodePush/.codepushrelease"); + } + zipFile.end(); + }); + }); + }) + .then((): void => { + log(chalk.green(`\nSuccessfully created bundle: ${outputZipPath}\n`)); + if (isTempDir) deleteFolder(outputFolder); + }) + .catch((err: Error) => { + if (isTempDir) deleteFolder(outputFolder); + throw err; + }); +}; + function rollback(command: cli.IRollbackCommand): Promise { return confirm().then((wasConfirmed: boolean) => { if (!wasConfirmed) { @@ -1551,7 +1679,7 @@ export const runReactNativeBundleCommand = ( } log(chalk.cyan('Running "react-native bundle" command:\n')); - const reactNativeBundleProcess = spawn("node", reactNativeBundleArgs); + const reactNativeBundleProcess = spawn("node", reactNativeBundleArgs, { env: envWithoutCredentials() }); log(`node ${reactNativeBundleArgs.join(" ")}`); return Promise((resolve, reject, notify) => { diff --git a/script/command-parser.ts b/script/command-parser.ts index 6170df7..2f48230 100644 --- a/script/command-parser.ts +++ b/script/command-parser.ts @@ -579,6 +579,86 @@ yargs .example("logout", "Logs out and ends your current session"); addCommonConfiguration(yargs); }) + .command("bundle-react", "Bundle a React Native update into a .zip for manual upload", (yargs: yargs.Argv) => { + yargs + .usage(USAGE_PREFIX + " bundle-react [options]") + .demand(/*count*/ 1, /*max*/ 1) + .example("bundle-react ios", 'Bundles the React Native iOS project in the current working directory → ./bundle.zip') + .example("bundle-react android --output release.zip", "Bundles Android and writes to release.zip") + .example("bundle-react ios -k ./private.pem --output signed.zip", "Bundles iOS with code signing") + .option("bundleName", { + alias: "b", + default: null, + demand: false, + description: 'Name of the generated JS bundle file. Defaults to "main.jsbundle" (iOS) or "index..bundle"', + type: "string", + }) + .option("development", { + alias: "dev", + default: false, + demand: false, + description: "Specifies whether to generate a dev or release build", + type: "boolean", + }) + .option("entryFile", { + alias: "e", + default: null, + demand: false, + description: 'Path to the app\'s entry Javascript file. Defaults to "index..js" then "index.js"', + type: "string", + }) + .option("sourcemapOutput", { + alias: "s", + default: null, + demand: false, + description: "Path to write the sourcemap. If omitted, no sourcemap is generated.", + type: "string", + }) + .option("outputDir", { + alias: "o", + default: null, + demand: false, + description: "Directory to keep intermediate bundle files after zipping. If omitted, a temp dir is used and cleaned up.", + type: "string", + }) + .option("output", { + default: "bundle.zip", + demand: false, + description: "Destination path for the final .zip file (default: bundle.zip)", + type: "string", + }) + .option("useHermes", { + demand: false, + description: + "Compile the JS bundle to Hermes bytecode before zipping, bypassing automatic detection. Pass --no-useHermes to skip Hermes even when the project enables it.", + type: "boolean", + }) + .option("extraHermesFlags", { + alias: "hf", + default: [], + demand: false, + description: "Flags to pass to the Hermes bytecode compiler. Can be specified multiple times.", + type: "array", + }) + .option("podFile", { + alias: "pod", + default: null, + demand: false, + description: "Path to the CocoaPods config file (iOS only), used to auto-detect whether Hermes is enabled. Ignored if --useHermes is specified.", + type: "string", + }) + .option("privateKey", { + // `privateKeyPath` / `private-key-path` are what upstream code-push called this and what older + // docs still show; accepted so a migrated script does not hard-fail under strictOptions. + alias: ["private-key", "privateKeyPath", "private-key-path", "k"], + default: null, + demand: false, + description: "RSA private key for code signing: either a file path (./private.pem) or inline PEM content", + type: "string", + }); + + addCommonConfiguration(yargs); + }) .command("org", "View and switch the organization your commands run against", (yargs: yargs.Argv) => { isValidCommandCategory = true; yargs @@ -884,6 +964,26 @@ yargs 'Path to the app\'s entry Javascript file. If omitted, "index..js" and then "index.js" will be used (if they exist)', type: "string", }) + .option("useHermes", { + demand: false, + description: + "Compile the JS bundle to Hermes bytecode before release, bypassing automatic detection. Pass --no-useHermes to skip Hermes even when the project enables it.", + type: "boolean", + }) + .option("extraHermesFlags", { + alias: "hf", + default: [], + demand: false, + description: "Flags to pass to the Hermes bytecode compiler. Can be specified multiple times.", + type: "array", + }) + .option("podFile", { + alias: "pod", + default: null, + demand: false, + description: "Path to the CocoaPods config file (iOS only), used to auto-detect whether Hermes is enabled. Ignored if --useHermes is specified.", + type: "string", + }) .option("gradleFile", { alias: "g", default: null, @@ -1416,6 +1516,24 @@ export function createCommand(): cli.ICommand { } break; + case "bundle-react": + if (arg1) { + cmd = { type: cli.CommandType.bundleReact }; + const bundleReactCommand = cmd; + bundleReactCommand.platform = arg1; + bundleReactCommand.bundleName = argv["bundleName"] as any; + bundleReactCommand.development = argv["development"] as any; + bundleReactCommand.entryFile = argv["entryFile"] as any; + bundleReactCommand.sourcemapOutput = argv["sourcemapOutput"] as any; + bundleReactCommand.outputDir = argv["outputDir"] as any; + bundleReactCommand.outputPath = argv["output"] as any; + bundleReactCommand.privateKey = argv["privateKey"] as any; + bundleReactCommand.useHermes = argv["useHermes"] as any; + bundleReactCommand.extraHermesFlags = argv["extraHermesFlags"] as any; + bundleReactCommand.podFile = argv["podFile"] as any; + } + break; + case "release-react": if (arg1 && arg2) { cmd = { type: cli.CommandType.releaseReact }; @@ -1441,6 +1559,9 @@ export function createCommand(): cli.ICommand { releaseReactCommand.sourcemapOutput = argv["sourcemapOutput"] as any; releaseReactCommand.outputDir = argv["outputDir"] as any; releaseReactCommand.privateKey = argv["privateKey"] as any; + releaseReactCommand.useHermes = argv["useHermes"] as any; + releaseReactCommand.extraHermesFlags = argv["extraHermesFlags"] as any; + releaseReactCommand.podFile = argv["podFile"] as any; } break; diff --git a/script/react-native-utils.ts b/script/react-native-utils.ts new file mode 100644 index 0000000..262053d --- /dev/null +++ b/script/react-native-utils.ts @@ -0,0 +1,214 @@ +// Hermes support for `release-react`, lost when dpctl was forked from the CodePush CLI: --useHermes, +// --extraHermesFlags and --podFile were documented but silently ignored. +// +// `react-native bundle` emits plain JavaScript; a Hermes app expects bytecode, so the bundle is compiled +// with hermesc and replaced in place. Sourcemaps need a second step, because hermesc maps bytecode to the +// packed JS rather than to source. + +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; +import { spawn } from "child_process"; +import * as chalk from "chalk"; +import { envWithoutCredentials } from "./child-env"; + +/** Directory under node_modules/react-native/sdks holding the hermesc build for this OS. */ +export function getHermesOSBin(): string { + switch (process.platform) { + case "win32": + return "win64-bin"; + case "darwin": + return "osx-bin"; + case "freebsd": + case "linux": + case "sunos": + default: + return "linux64-bin"; + } +} + +export function getHermesOSExe(): string { + const hermesExecutableName = "hermesc"; + return process.platform === "win32" ? hermesExecutableName + ".exe" : hermesExecutableName; +} + +// The prebuilt compiler inside react-native first, then a locally built Hermes for anyone compiling +// the engine themselves. +function removeQuietly(file: string): void { + try { + fs.unlinkSync(file); + } catch { + /* best effort cleanup */ + } +} + +export function getHermesCommand(projectRoot?: string): string { + const root = projectRoot || process.cwd(); + const fileExists = (file: string): boolean => { + try { + return fs.statSync(file).isFile(); + } catch { + return false; + } + }; + + const bundledHermesEngine = path.join( + root, + "node_modules", + "react-native", + "sdks", + "hermesc", + getHermesOSBin(), + getHermesOSExe() + ); + if (fileExists(bundledHermesEngine)) return bundledHermesEngine; + + const localHermesEngine = path.join(root, "node_modules", "react-native", "sdks", "hermes", "build", "bin", getHermesOSExe()); + if (fileExists(localHermesEngine)) return localHermesEngine; + + // React Native 0.68 and earlier keep the compiler in its own package. Those versions are a large share + // of CodePush users, and Hermes is detected from their gradle config, so omitting this made the + // detection a trap rather than a convenience. + const standaloneHermesEngine = path.join(root, "node_modules", "hermes-engine", getHermesOSBin(), getHermesOSExe()); + if (fileExists(standaloneHermesEngine)) return standaloneHermesEngine; + + throw new Error( + `Could not find the Hermes compiler. Looked in:\n ${bundledHermesEngine}\n ${localHermesEngine}\n ${standaloneHermesEngine}\n` + + `Check that react-native is installed in this project, or pass --no-useHermes to release a plain JavaScript bundle.` + ); +} + +// gradle.properties (RN 0.71+), then the older enableHermes in app/build.gradle. Not driven by +// --gradleFile: that flag names the file holding the binary version, which never has hermesEnabled. +export function getAndroidHermesEnabled(projectRoot?: string): boolean { + const root = projectRoot || process.cwd(); + const read = (file: string): string => { + try { + return fs.readFileSync(file, "utf8"); + } catch { + return ""; + } + }; + if (/^\s*hermesEnabled\s*=\s*true\s*$/m.test(read(path.join(root, "android", "gradle.properties")))) return true; + return /^\s*enableHermes\s*:\s*true/m.test(read(path.join(root, "android", "app", "build.gradle"))); +} + +// The line `expo prebuild` writes into ios/Podfile, evaluated against Podfile.properties.json. +const EXPO_PODFILE_HERMES_LINE = + /^\s*:hermes_enabled\s*=>\s*podfile_properties\['expo\.jsEngine'\]\s*==\s*nil\s*\|\|\s*podfile_properties\['expo\.jsEngine'\]\s*==\s*'hermes'/m; + +// An explicit `:hermes_enabled => true`, or Expo's generated line evaluated the way CocoaPods will. +// The RN 0.70+ template enables Hermes without writing either, so it reads as off here: guessing "on" +// would compile bytecode into a JSC app and brick it. Those projects pass --useHermes. +export function getiOSHermesEnabled(podFile?: string, projectRoot?: string): boolean { + const root = projectRoot || process.cwd(); + const file = podFile || path.join(root, "ios", "Podfile"); + let contents: string; + try { + contents = fs.readFileSync(file, "utf8"); + } catch { + return false; + } + + if (/^\s*:?hermes_enabled\s*(=>|:)\s*true/m.test(contents)) return true; + + if (EXPO_PODFILE_HERMES_LINE.test(contents)) { + let properties: any = {}; + try { + properties = JSON.parse(fs.readFileSync(path.join(path.dirname(file), "Podfile.properties.json"), "utf8")); + } catch { + /* same as the Podfile's `rescue {}` */ + } + const engine = properties && properties["expo.jsEngine"]; + return engine === undefined || engine === null || engine === "hermes"; + } + + return false; +} + +function spawnAsync(command: string, args: string[], label: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: "inherit", env: envWithoutCredentials() }); + child.on("error", (err: Error) => reject(new Error(`${label} failed to start: ${err.message}`))); + child.on("close", (code: number) => { + if (code === 0) return resolve(); + reject(new Error(`${label} exited with code ${code}.`)); + }); + }); +} + +// In place, because the zip, the hash and the upload all refer to the bundle by name. Emitting +// alongside it would ship both and leave the app loading the wrong one. +export async function runHermesEmitBinaryCommand( + bundleName: string, + outputFolder: string, + sourcemapOutput: string, + extraHermesFlags: string[], + projectRoot?: string +): Promise { + const hermesCommand = getHermesCommand(projectRoot); + const hermesArgs: string[] = []; + const bundlePath = path.join(outputFolder, bundleName); + + hermesArgs.push("-emit-binary", "-out", path.join(outputFolder, bundleName + ".hbc"), bundlePath, "-O"); + if (sourcemapOutput) hermesArgs.push("-output-source-map"); + if (extraHermesFlags && extraHermesFlags.length) hermesArgs.push(...extraHermesFlags); + + console.log(chalk.cyan("Converting JS bundle to byte code via Hermes, running command:\n")); + console.log(`${hermesCommand} ${hermesArgs.join(" ")}\n`); + await spawnAsync(hermesCommand, hermesArgs, "Hermes"); + + fs.unlinkSync(bundlePath); + fs.renameSync(path.join(outputFolder, bundleName + ".hbc"), bundlePath); + + if (!sourcemapOutput) return; + + // Compose hermesc's bytecode map with Metro's, so stack frames resolve to source. + const hermesMap = path.join(outputFolder, bundleName + ".hbc.map"); + if (!fs.existsSync(hermesMap)) { + console.log(chalk.yellow("Hermes did not emit a source map; leaving the JS source map as-is.")); + return; + } + + const root = projectRoot || process.cwd(); + const composeScript = path.join(root, "node_modules", "react-native", "scripts", "compose-source-maps.js"); + if (!fs.existsSync(composeScript)) { + console.log(chalk.yellow(`Could not find ${composeScript}; leaving the JS source map as-is.`)); + removeQuietly(hermesMap); + return; + } + + const composedMap = path.join(os.tmpdir(), `${bundleName}.composed.map`); + await spawnAsync("node", [composeScript, sourcemapOutput, hermesMap, "-o", composedMap], "compose-source-maps"); + fs.copyFileSync(composedMap, sourcemapOutput); + // hermesMap first: it sits inside the release payload, so failing to remove it costs bandwidth on every + // device and leaks source detail. composedMap is only a temp file. + removeQuietly(hermesMap); + removeQuietly(composedMap); + console.log(chalk.cyan("Composed Hermes source map with the JavaScript source map.\n")); +} + +// Run after `react-native bundle` and before anything hashes, signs, zips or uploads the folder: it +// rewrites the bundle in place. +export async function compileHermesIfEnabled(opts: { + platform: string; + bundleName: string; + outputFolder: string; + sourcemapOutput?: string; + useHermes?: boolean; + extraHermesFlags?: string[]; + podFile?: string; + log?: (message: string) => void; +}): Promise { + // `--no-useHermes` arrives as false and means "do not compile", which is the only way out when + // detection is wrong or the compiler is missing. Undefined means "decide for me". + if (opts.useHermes === false) return; + const explicit = opts.useHermes === true; + const detected = + opts.platform === "android" ? getAndroidHermesEnabled() : opts.platform === "ios" ? getiOSHermesEnabled(opts.podFile) : false; + if (!explicit && !detected) return; + if (!explicit) { + (opts.log || console.log)(chalk.cyan(`Hermes is enabled for this ${opts.platform} project; compiling the bundle to bytecode.`)); + } + await runHermesEmitBinaryCommand(opts.bundleName, opts.outputFolder, opts.sourcemapOutput, opts.extraHermesFlags || []); +} diff --git a/script/types/cli.ts b/script/types/cli.ts index 2f37e1a..951bc63 100644 --- a/script/types/cli.ts +++ b/script/types/cli.ts @@ -14,6 +14,7 @@ export enum CommandType { appRename, appSetPublicKey, appTransfer, + bundleReact, collaboratorAdd, collaboratorList, collaboratorRemove, @@ -231,6 +232,23 @@ export interface IReleaseReactCommand extends IReleaseBaseCommand { sourcemapOutput?: string; outputDir?: string; config?: string; + useHermes?: boolean; + extraHermesFlags?: string[]; + podFile?: string; +} + +export interface IBundleReactCommand extends ICommand { + platform: string; + bundleName?: string; + development?: boolean; + entryFile?: string; + sourcemapOutput?: string; + outputDir?: string; + outputPath?: string; + privateKey?: string; + useHermes?: boolean; + extraHermesFlags?: string[]; + podFile?: string; } export interface IRollbackCommand extends ICommand { diff --git a/test/cli.ts b/test/cli.ts index bafeef7..ae8dc06 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -11,6 +11,9 @@ import * as codePush from "../script/types"; import * as cli from "../script/types/cli"; import * as cmdexec from "../script/command-executor"; import { assertReleasePlatform, checkReleaseProjectKind, detectCodePushProjectKind } from "../script/ota-runtime"; +import { getiOSHermesEnabled } from "../script/react-native-utils"; +import * as rnUtils from "../script/react-native-utils"; +import * as hashUtilsForTest from "../script/hash-utils"; import * as hashUtils from "../script/hash-utils"; import * as os from "os"; import moment = require("moment"); @@ -1641,6 +1644,115 @@ describe("CLI", () => { .done(); }); + it("bundle-react signs against the base the zip is built with", (done: Mocha.Done): void => { + // Must run with outputDir "." to be meaningful: path.join(dir, "..") and path.dirname(dir) agree for + // every other path, and "." is what a developer bundling in place actually passes. + const { privateKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }); + + const bases: string[] = []; + sandbox.stub(hashUtilsForTest, "generatePackageHashFromDirectory").callsFake((dir: string, base: string) => { + bases.push(base); + return Q("deadbeef"); + }); + sandbox.stub(rnUtils, "compileHermesIfEnabled").callsFake(() => Promise.resolve()); + + // A throwaway React Native project, because bundling in place empties the working directory. + const project = fs.mkdtempSync(path.join(os.tmpdir(), "dpctl-bundle-cwd-")); + fs.writeFileSync(path.join(project, "package.json"), JSON.stringify({ name: "p", dependencies: { "react-native": "0.79.3" } })); + fs.writeFileSync(path.join(project, "index.ios.js"), ""); + const zipDir = fs.mkdtempSync(path.join(os.tmpdir(), "dpctl-bundle-zip-")); + const previousCwd = process.cwd(); + process.chdir(project); + + const finish = (error?: any): void => { + process.chdir(previousCwd); + fs.rmSync(project, { recursive: true, force: true }); + fs.rmSync(zipDir, { recursive: true, force: true }); + if (error) return done(error); + assert.deepStrictEqual(bases, [path.dirname(".")], "must key the manifest the way the zip is built"); + done(); + }; + + cmdexec + .execute({ + type: cli.CommandType.bundleReact, + platform: "ios", + outputDir: ".", + outputPath: path.join(zipDir, "bundle.zip"), + privateKey, + }) + .done(() => finish(), finish); + }); + + it("release-react compiles Hermes after bundling and before the upload", (done: Mocha.Done): void => { + var compile: sinon.SinonSpy = sandbox.stub(rnUtils, "compileHermesIfEnabled").callsFake(() => Promise.resolve()); + sandbox.stub(cmdexec, "release"); + + var command: cli.IReleaseReactCommand = { + type: cli.CommandType.releaseReact, + appName: "a", + appStoreVersion: null, + deploymentName: "Staging", + description: "Hermes", + mandatory: false, + rollout: null, + platform: "ios", + useHermes: true, + extraHermesFlags: ["-O"], + podFile: "ios/Podfile", + }; + + ensureInTestAppDirectory(); + + cmdexec.execute(command).done((): void => { + sinon.assert.calledOnce(compile); + var opts: any = compile.args[0][0]; + assert.strictEqual(opts.useHermes, true); + assert.deepStrictEqual(opts.extraHermesFlags, ["-O"]); + assert.strictEqual(opts.podFile, "ios/Podfile"); + assert.strictEqual(opts.platform, "ios"); + assert.strictEqual(opts.bundleName, "main.jsbundle"); + done(); + }, done); + }); + + it("release-react does not hand the access key to Metro", (done: Mocha.Done): void => { + // Metro runs Babel plugins and transformers from the app's own node_modules, any of which could + // read a full-access key out of the environment. + process.env.DEPLOYPULSE_ACCESS_KEY = "super-secret"; + sandbox.stub(rnUtils, "compileHermesIfEnabled").callsFake(() => Promise.resolve()); + sandbox.stub(cmdexec, "release"); + + var command: cli.IReleaseReactCommand = { + type: cli.CommandType.releaseReact, + appName: "a", + appStoreVersion: null, + deploymentName: "Staging", + description: "Env", + mandatory: false, + rollout: null, + platform: "ios", + }; + + ensureInTestAppDirectory(); + + var finish = (error?: any): void => { + delete process.env.DEPLOYPULSE_ACCESS_KEY; + if (error) return done(error); + sinon.assert.calledOnce(spawn); + var options: any = spawn.args[0][2]; + assert.ok(options && options.env, "Metro must be spawned with an explicit environment"); + assert.strictEqual(options.env.DEPLOYPULSE_ACCESS_KEY, undefined); + done(); + }; + + cmdexec.execute(command).done(() => finish(), finish); + }); + it('release-react defaults bundle name to "main.jsbundle" if not provided and platform is "ios"', (done: Mocha.Done): void => { var command: cli.IReleaseReactCommand = { type: cli.CommandType.releaseReact, @@ -2315,6 +2427,38 @@ describe("CLI", () => { } }); +describe("getiOSHermesEnabled", () => { + const EXPO_PODFILE_LINE = " :hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',\n"; + const roots: string[] = []; + + const check = (podfile: string, properties?: object): boolean => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "dpctl-test-podfile-")); + roots.push(root); + fs.mkdirSync(path.join(root, "ios")); + fs.writeFileSync(path.join(root, "ios", "Podfile"), podfile); + if (properties) fs.writeFileSync(path.join(root, "ios", "Podfile.properties.json"), JSON.stringify(properties)); + return getiOSHermesEnabled(undefined, root); + }; + + after(() => roots.forEach((root: string) => fs.rmSync(root, { recursive: true, force: true }))); + + it("detects an explicit :hermes_enabled => true", (): void => { + assert.equal(check("use_react_native!(\n :hermes_enabled => true,\n)\n"), true); + }); + + it("does not guess for the React Native template, which sets no flag", (): void => { + assert.equal(check("use_react_native!(\n :path => config[:reactNativePath],\n)\n"), false); + }); + + it("follows Expo's generated Podfile: Hermes unless Podfile.properties.json names another engine", (): void => { + const podfile = `use_react_native!(\n${EXPO_PODFILE_LINE})\n`; + assert.equal(check(podfile, { "expo.jsEngine": "hermes" }), true); + assert.equal(check(podfile, {}), true); + assert.equal(check(podfile), true); + assert.equal(check(podfile, { "expo.jsEngine": "jsc" }), false); + }); +}); + describe("resolvePrivateKey", () => { var sandbox: sinon.SinonSandbox; diff --git a/test/command-parser.ts b/test/command-parser.ts index c46cf8a..dbac3a8 100644 --- a/test/command-parser.ts +++ b/test/command-parser.ts @@ -72,6 +72,18 @@ describe("command line", function () { assert.ok(!/cli\.js/.test(result.output), `the entry file leaked into help: ${result.output.slice(0, 400)}`); }); + it("-h is not an alias for --useHermes", () => { + const result = run("release-react", "--help"); + const hermesLine = result.output.split("\n").find((line: string) => line.indexOf("--useHermes") >= 0); + assert.ok(hermesLine, "expected a --useHermes option"); + assert.ok(!/-h, --useHermes/.test(hermesLine), `-h must not be bound to Hermes: ${hermesLine}`); + }); + + it("--useHermes works under its long name", () => { + const result = run("release-react", "--help"); + assert.ok(/--useHermes/.test(result.output), "the long flag is the supported spelling"); + }); + it("an unrecognized command exits 1 and says which one", () => { const result = run("nonsense-command"); assert.strictEqual(result.status, 1);