From 3b5e2ca71f8789df904f394425a4113711c94245 Mon Sep 17 00:00:00 2001 From: Revopush Date: Fri, 9 Oct 2026 01:05:11 +0300 Subject: [PATCH 1/8] Pick hermesc the same way React Native does The CLI could compile a release with a different hermesc than the store binary, producing bytecode the app can't load. Mirror React Native's lookup order: - Android (gradle-plugin detectOSAwareHermesCommand): literal `hermesCommand` from the `react { }` block or legacy project.ext.react, REACT_NATIVE_OVERRIDE_HERMES_DIR / hermesc built from source, then hermes-compiler when hermesV1Enabled=true, else sdks/hermesc, else hermes-compiler. - iOS (react-native-xcode.sh): HERMES_CLI_PATH, the CocoaPods hermes-engine hermesc, then hermes-compiler. - Resolve hermes-compiler through react-native so non-hoisted installs are found. Previously sdks/hermesc always won, so RN 0.82 apps with Hermes V1 and iOS apps whose CocoaPods Hermes differs from npm got the wrong compiler. Co-Authored-By: Claude Opus 5.5 --- script/react-native-utils.ts | 109 +++++++++++++++++++++++------------ test/hermes-command.ts | 99 +++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 37 deletions(-) create mode 100644 test/hermes-command.ts diff --git a/script/react-native-utils.ts b/script/react-native-utils.ts index ceeb652..642dd7e 100644 --- a/script/react-native-utils.ts +++ b/script/react-native-utils.ts @@ -107,7 +107,7 @@ export async function runHermesEmitBinaryCommand( } console.log(chalk.cyan("Converting JS bundle to byte code via Hermes, running command:\n")); - const hermesCommand = await getHermesCommand(gradleFile); + const hermesCommand = await getHermesCommand(command.platform, gradleFile, command.podFile); const hermesProcess = childProcess.spawn(hermesCommand, hermesArgs); console.log(`${hermesCommand} ${hermesArgs.join(" ")}`); @@ -312,16 +312,30 @@ function parseGradlePropertiesFile(gradleFile: string): Record { return props; } +// Reads a literal `hermesCommand` from the `react { }` block or legacy `project.ext.react`. +// Groovy expressions (e.g. Expo's computed path) can't be evaluated and are ignored. async function getHermesCommandFromGradle(gradleFile: string): Promise { const buildGradle: any = await parseBuildGradleFile(gradleFile); - const hermesCommandProperty: any = Array.from(buildGradle["project.ext.react"] || []).find((prop: string) => + const legacyProperty: string = Array.from(buildGradle["project.ext.react"] || []).find((prop: string) => prop.trim().startsWith("hermesCommand:") - ); - if (hermesCommandProperty) { - return hermesCommandProperty.replace("hermesCommand:", "").trim().slice(1, -1); - } else { + ) as string; + const hermesCommand: string = + buildGradle.react?.hermesCommand || legacyProperty?.replace("hermesCommand:", "").trim().slice(1, -1) || ""; + if (!hermesCommand || /["'$()+]/.test(hermesCommand)) { return ""; } + + const gradleDir = + gradleFile && !directoryExistsSync(gradleFile) ? path.dirname(gradleFile) : gradleFile || path.join("android", "app"); + return path.resolve(gradleDir, hermesCommand.replace("%OS-BIN%", getHermesOSBin())); +} + +function isHermesV1EnabledInGradle(gradleFile: string): boolean { + try { + return parseGradlePropertiesFile(gradleFile).hermesV1Enabled?.toLowerCase() === "true"; + } catch { + return false; + } } async function getAndroidHermesEnabled(gradleFile: string): Promise { @@ -409,45 +423,66 @@ function getHermesOSExe(): string { } } -async function getHermesCommand(gradleFile: string): Promise { - const fileExists = (file: string): boolean => { +// Mirrors how React Native picks hermesc, so releases are compiled like the store binary: +// Android: gradle-plugin PathUtils.detectOSAwareHermesCommand, iOS: scripts/react-native-xcode.sh +export async function getHermesCommand(platform: string, gradleFile: string, podFile: string): Promise { + const reactNativePath = getReactNativePackagePath(); + const bundledHermesc = path.join(reactNativePath, "sdks", "hermesc", getHermesOSBin(), getHermesOSExe()); + const hermesCompiler = getHermesCompilerPath(reactNativePath); + const candidates: string[] = []; + + if (platform === "ios") { + if (process.env.HERMES_CLI_PATH) { + return process.env.HERMES_CLI_PATH; + } + const podsRoot = path.join(path.dirname(podFile || path.join("ios", "Podfile")), "Pods"); + candidates.push(path.join(podsRoot, "hermes-engine", "destroot", "bin", "hermesc"), hermesCompiler, bundledHermesc); + } else { + let gradleHermesCommand = ""; try { - return fs.statSync(file).isFile(); - } catch (e) { - return false; + gradleHermesCommand = await getHermesCommandFromGradle(gradleFile); + } catch { + // Gradle files not present; fall back to the default locations } - }; - // Hermes is bundled with react-native since 0.69 - const reactNativePath = getReactNativePackagePath(); - const bundledHermesEngine = path.join(reactNativePath, "sdks", "hermesc", getHermesOSBin(), getHermesOSExe()); - if (fileExists(bundledHermesEngine)) { - return bundledHermesEngine; + if (gradleHermesCommand) { + return gradleHermesCommand; + } + + const overrideHermesDir = process.env.REACT_NATIVE_OVERRIDE_HERMES_DIR; + candidates.push( + overrideHermesDir + ? path.join(overrideHermesDir, "build", "bin", getHermesOSExe()) + : path.join(reactNativePath, "ReactAndroid", "hermes-engine", "build", "hermes", "bin", getHermesOSExe()) + ); + // RN 0.82 uses hermes-compiler only with hermesV1Enabled; RN <= 0.82 ships sdks/hermesc, RN 0.83+ only hermes-compiler + candidates.push(...(isHermesV1EnabledInGradle(gradleFile) ? [hermesCompiler, bundledHermesc] : [bundledHermesc, hermesCompiler])); } - let gradleHermesCommand = ""; + // RN < 0.69 shipped hermesc in the hermes-engine / hermesvm packages + const nodeModulesPath = getNodeModulesPath(reactNativePath); + candidates.push(path.join(nodeModulesPath, "hermes-engine", getHermesOSBin(), getHermesOSExe())); + + return ( + candidates.find((candidate) => candidate && fileExistsSync(candidate)) || + path.join(nodeModulesPath, "hermesvm", getHermesOSBin(), "hermes") + ); +} + +// hermes-compiler is a dependency of react-native, so resolve it from there (it may not be hoisted) +function getHermesCompilerPath(reactNativePath: string): string | null { try { - gradleHermesCommand = await getHermesCommandFromGradle(gradleFile); + const packageJson = require.resolve("hermes-compiler/package.json", { paths: [reactNativePath] }); + return path.join(path.dirname(packageJson), "hermesc", getHermesOSBin(), getHermesOSExe()); } catch { - // Gradle files not present (e.g. iOS-only project); skip to node_modules fallback + return null; } - if (gradleHermesCommand) { - return path.join("android", "app", gradleHermesCommand.replace("%OS-BIN%", getHermesOSBin())); - } else { - const nodeModulesPath = getNodeModulesPath(reactNativePath); - - // assume if hermes-engine exists it should be used instead of hermesvm - const hermesEngine = path.join(nodeModulesPath, "hermes-engine", getHermesOSBin(), getHermesOSExe()); - if (fileExists(hermesEngine)) { - return hermesEngine; - } - - // RN 0.83 hermes-compiler - const hermesCompiler = path.join(nodeModulesPath, "hermes-compiler", "hermesc", getHermesOSBin(), getHermesOSExe()); - if (fileExists(hermesCompiler)) { - return hermesCompiler; - } +} - return path.join(nodeModulesPath, "hermesvm", getHermesOSBin(), "hermes"); +function fileExistsSync(file: string): boolean { + try { + return fs.statSync(file).isFile(); + } catch { + return false; } } diff --git a/test/hermes-command.ts b/test/hermes-command.ts new file mode 100644 index 0000000..803b3be --- /dev/null +++ b/test/hermes-command.ts @@ -0,0 +1,99 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { getHermesCommand } from "../script/react-native-utils"; + +const OS_BIN = process.platform === "darwin" ? "osx-bin" : process.platform === "win32" ? "win64-bin" : "linux64-bin"; +const HERMESC = process.platform === "win32" ? "hermesc.exe" : "hermesc"; + +describe("Hermes command resolution", () => { + let originalCwd: string; + let originalEnv: NodeJS.ProcessEnv; + let projectDirectory: string; + + function createFile(relativePath: string, contents = ""): string { + const file = path.join(projectDirectory, relativePath); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); + return file; + } + + const bundledHermesc = () => createFile(`node_modules/react-native/sdks/hermesc/${OS_BIN}/${HERMESC}`); + const hermesCompiler = (root = "node_modules") => { + createFile(`${root}/hermes-compiler/package.json`, JSON.stringify({ name: "hermes-compiler", version: "1.0.0" })); + return createFile(`${root}/hermes-compiler/hermesc/${OS_BIN}/${HERMESC}`); + }; + + beforeEach(() => { + originalCwd = process.cwd(); + originalEnv = { ...process.env }; + delete process.env.HERMES_CLI_PATH; + delete process.env.REACT_NATIVE_OVERRIDE_HERMES_DIR; + projectDirectory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "revopush-hermesc-"))); + createFile("node_modules/react-native/package.json", JSON.stringify({ name: "react-native", version: "0.83.0" })); + createFile("android/app/build.gradle", "react {\n}\n"); + createFile("android/gradle.properties", "hermesEnabled=true\n"); + process.chdir(projectDirectory); + }); + + afterEach(() => { + process.chdir(originalCwd); + process.env = originalEnv; + fs.rmSync(projectDirectory, { recursive: true, force: true }); + }); + + it("uses a literal hermesCommand from the react block, resolved against the app module", async () => { + bundledHermesc(); + createFile("android/app/build.gradle", 'react {\n hermesCommand = "../../custom/%OS-BIN%/hermesc"\n}\n'); + assert.strictEqual( + await getHermesCommand("android", undefined, undefined), + path.join(projectDirectory, "custom", OS_BIN, "hermesc") + ); + }); + + it("ignores a computed hermesCommand expression such as Expo's", async () => { + const expected = bundledHermesc(); + createFile( + "android/app/build.gradle", + 'react {\n hermesCommand = new File(["node", "--print", "require.resolve(\'react-native/package.json\')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"\n}\n' + ); + assert.strictEqual(fs.realpathSync(await getHermesCommand("android", undefined, undefined)), expected); + }); + + it("prefers the bundled hermesc unless Hermes V1 is enabled", async () => { + const bundled = bundledHermesc(); + const compiler = hermesCompiler(); + assert.strictEqual(fs.realpathSync(await getHermesCommand("android", undefined, undefined)), bundled); + + createFile("android/gradle.properties", "hermesEnabled=true\nhermesV1Enabled=true\n"); + assert.strictEqual(fs.realpathSync(await getHermesCommand("android", undefined, undefined)), compiler); + }); + + it("resolves hermes-compiler through react-native when it is not hoisted", async () => { + const compiler = hermesCompiler("node_modules/react-native/node_modules"); + assert.strictEqual(fs.realpathSync(await getHermesCommand("android", undefined, undefined)), compiler); + }); + + it("uses hermesc built from source via REACT_NATIVE_OVERRIDE_HERMES_DIR", async () => { + bundledHermesc(); + const built = createFile(`hermes/build/bin/${HERMESC}`); + process.env.REACT_NATIVE_OVERRIDE_HERMES_DIR = path.join(projectDirectory, "hermes"); + assert.strictEqual(await getHermesCommand("android", undefined, undefined), built); + }); + + it("uses HERMES_CLI_PATH, then the CocoaPods hermesc, on iOS", async () => { + hermesCompiler(); + const podHermesc = createFile("ios/Pods/hermes-engine/destroot/bin/hermesc"); + assert.strictEqual(fs.realpathSync(await getHermesCommand("ios", undefined, undefined)), podHermesc); + + process.env.HERMES_CLI_PATH = "/custom/hermesc"; + assert.strictEqual(await getHermesCommand("ios", undefined, undefined), "/custom/hermesc"); + }); + + it("falls back to hermes-compiler on iOS without CocoaPods", async () => { + bundledHermesc(); + const compiler = hermesCompiler(); + assert.strictEqual(fs.realpathSync(await getHermesCommand("ios", undefined, undefined)), compiler); + }); +}); From 09a6e42149a9dc5537c5e5783ada7d1c63c501a3 Mon Sep 17 00:00:00 2001 From: Revopush Date: Fri, 9 Oct 2026 14:43:23 +0300 Subject: [PATCH 2/8] Resolve hermesCommand like the Gradle plugin, including Kotlin DSL - Resolve a relative `react { hermesCommand }` against react.root (the project root), as BundleHermesCTask runs hermesc from there; legacy project.ext.react stays relative to the app module. Expand $rootDir and $projectDir. - Kotlin DSL build scripts that set hermesCommand are evaluated with the Gradle wrapper (reusing the version-detection init script, now generalized to print any JSON); on failure, warn and fall back to the default hermesc. - iOS: read HERMES_CLI_PATH from ios/.xcode.env(.local) too (and load .xcode.env, which was never read), and without Pods prefer sdks/hermesc unless RCT_HERMES_V1_ENABLED=1. - Reuse file-utils fileExists and simplify the lookup code. Co-Authored-By: Claude Opus 5.5 --- script/react-native-utils.ts | 130 ++++++++++++++++++++++------------- script/utils/gradle-utils.ts | 56 ++++++++++----- test/hermes-command.ts | 89 ++++++++++++++++++++++-- 3 files changed, 206 insertions(+), 69 deletions(-) diff --git a/script/react-native-utils.ts b/script/react-native-utils.ts index ed887e9..27b1ed5 100644 --- a/script/react-native-utils.ts +++ b/script/react-native-utils.ts @@ -3,7 +3,8 @@ import * as chalk from "chalk"; import * as path from "path"; import * as childProcess from "child_process"; import { coerce, compare, gte, valid } from "semver"; -import { downloadBlob, extractArchive, fileDoesNotExistOrIsDirectory } from "./utils/file-utils"; +import { downloadBlob, extractArchive, fileDoesNotExistOrIsDirectory, fileExists } from "./utils/file-utils"; +import { evaluateKotlinDslReactConfig, resolveGradleBuildFile } from "./utils/gradle-utils"; import * as dotenv from "dotenv"; import { DotenvParseOutput } from "dotenv"; import * as cli from "../script/types/cli"; @@ -146,10 +147,12 @@ export async function runHermesEmitBinaryCommand( const hermesCommand = await getHermesCommand(command.platform, gradleFile, command.podFile); - const resolvedBaseBytecode = - baseBytecode && resolveHermesBaseBytecode(baseBytecode, getHermesCompilerBytecodeVersion(hermesCommand)); - if (resolvedBaseBytecode) { - hermesArgs.push("-base-bytecode", resolvedBaseBytecode); + if (baseBytecode) { + const compilerBytecodeVersion = getHermesCompilerBytecodeVersion(hermesCommand); + const resolvedBaseBytecode = resolveHermesBaseBytecode(baseBytecode, compilerBytecodeVersion); + if (resolvedBaseBytecode) { + hermesArgs.push("-base-bytecode", resolvedBaseBytecode); + } } console.log(chalk.cyan("Converting JS bundle to byte code via Hermes, running command:\n")); @@ -271,7 +274,8 @@ export async function runHermesEmitBinaryCommand( } export function getXcodeDotEnvValue(key: string): string | undefined { - const xcodeEnvs = loadEnvAsMap([path.join("ios", ".xcode.env.local"), path.join("ios", ".xcode.env.local")]); + // Xcode sources .xcode.env, then .xcode.env.local + const xcodeEnvs = loadEnvAsMap([path.join("ios", ".xcode.env"), path.join("ios", ".xcode.env.local")]); return xcodeEnvs.get(key); } @@ -364,22 +368,54 @@ function parseGradlePropertiesFile(gradleFile: string): Record { return props; } -// Reads a literal `hermesCommand` from the `react { }` block or legacy `project.ext.react`. -// Groovy expressions (e.g. Expo's computed path) can't be evaluated and are ignored. +// Only literal paths (plus $rootDir / $projectDir) can be resolved; other Groovy expressions, like Expo's, are ignored async function getHermesCommandFromGradle(gradleFile: string): Promise { + const buildFile = resolveGradleBuildFile(gradleFile || path.join("android", "app")); + if (buildFile.endsWith(".kts")) { + return getHermesCommandFromKotlinDsl(buildFile); + } + const buildGradle: any = await parseBuildGradleFile(gradleFile); - const legacyProperty: string = Array.from(buildGradle["project.ext.react"] || []).find((prop: string) => - prop.trim().startsWith("hermesCommand:") - ) as string; - const hermesCommand: string = - buildGradle.react?.hermesCommand || legacyProperty?.replace("hermesCommand:", "").trim().slice(1, -1) || ""; - if (!hermesCommand || /["'$()+]/.test(hermesCommand)) { + const gradlePath = gradleFile || path.join("android", "app"); + const appDir = path.resolve(directoryExistsSync(gradlePath) ? gradlePath : path.dirname(gradlePath)); + const rootDir = path.dirname(appDir); + + // The gradle plugin runs hermesc from react.root (default: $rootDir/..); legacy react.gradle from the app module + let hermesCommand: string = buildGradle.react?.hermesCommand; + let workingDir = path.dirname(rootDir); + if (!hermesCommand) { + const legacyProperties: string[] = Array.from(buildGradle["project.ext.react"] || []); + const legacyProperty = legacyProperties.find((prop) => prop.trim().startsWith("hermesCommand:")); + hermesCommand = legacyProperty?.replace("hermesCommand:", "").trim().slice(1, -1); + workingDir = appDir; + } + if (!hermesCommand) { + return ""; + } + + hermesCommand = hermesCommand + .replace(/\$(\{rootDir\}|rootDir\b)/g, () => rootDir) + .replace(/\$(\{projectDir\}|projectDir\b)/g, () => appDir); + if (/["'$()+]/.test(hermesCommand)) { return ""; } - const gradleDir = - gradleFile && !directoryExistsSync(gradleFile) ? path.dirname(gradleFile) : gradleFile || path.join("android", "app"); - return path.resolve(gradleDir, hermesCommand.replace("%OS-BIN%", getHermesOSBin())); + return path.resolve(workingDir, hermesCommand.replace("%OS-BIN%", getHermesOSBin())); +} + +// Kotlin DSL can't be parsed statically, so evaluate it with Gradle, but only if it sets hermesCommand +async function getHermesCommandFromKotlinDsl(buildFile: string): Promise { + if (!/^\s*(react\.)?hermesCommand\b/m.test(fs.readFileSync(buildFile, "utf8"))) { + return ""; + } + + try { + const { hermesCommand, root } = await evaluateKotlinDslReactConfig(buildFile); + return hermesCommand && root ? path.resolve(root, hermesCommand.replace("%OS-BIN%", getHermesOSBin())) : ""; + } catch (error) { + log(chalk.yellow(`\nWarning: ${error.message}\nUsing the default hermesc instead.\n`)); + return ""; + } } function isHermesV1EnabledInGradle(gradleFile: string): boolean { @@ -475,49 +511,55 @@ function getHermesOSExe(): string { } } -// Mirrors how React Native picks hermesc, so releases are compiled like the store binary: -// Android: gradle-plugin PathUtils.detectOSAwareHermesCommand, iOS: scripts/react-native-xcode.sh +// Mirrors how React Native picks hermesc, so updates are compiled like the store binary. +// Android: gradle-plugin PathUtils.detectOSAwareHermesCommand; iOS: scripts/react-native-xcode.sh export async function getHermesCommand(platform: string, gradleFile: string, podFile: string): Promise { const reactNativePath = getReactNativePackagePath(); const bundledHermesc = path.join(reactNativePath, "sdks", "hermesc", getHermesOSBin(), getHermesOSExe()); const hermesCompiler = getHermesCompilerPath(reactNativePath); - const candidates: string[] = []; + let candidates: string[]; if (platform === "ios") { - if (process.env.HERMES_CLI_PATH) { - return process.env.HERMES_CLI_PATH; + // react-native-xcode.sh honors HERMES_CLI_PATH from the environment or ios/.xcode.env(.local) + const hermesCliPath = getXcodeDotEnvValue("HERMES_CLI_PATH"); + if (hermesCliPath) { + return hermesCliPath; } - const podsRoot = path.join(path.dirname(podFile || path.join("ios", "Podfile")), "Pods"); - candidates.push(path.join(podsRoot, "hermes-engine", "destroot", "bin", "hermesc"), hermesCompiler, bundledHermesc); - } else { - let gradleHermesCommand = ""; - try { - gradleHermesCommand = await getHermesCommandFromGradle(gradleFile); - } catch { - // Gradle files not present; fall back to the default locations + const podsDir = path.join(path.dirname(podFile || path.join("ios", "Podfile")), "Pods"); + const podsHermesc = path.join(podsDir, "hermes-engine", "destroot", "bin", "hermesc"); + + // Without Pods (e.g. CI), pick the compiler like Android: RN 0.82 uses hermes-compiler only for Hermes V1 + if (process.env.RCT_HERMES_V1_ENABLED === "1") { + candidates = [podsHermesc, hermesCompiler, bundledHermesc]; + } else { + candidates = [podsHermesc, bundledHermesc, hermesCompiler]; } + } else { + // A missing or unparsable gradle file just means there is no hermesCommand override + const gradleHermesCommand = await getHermesCommandFromGradle(gradleFile).catch(() => ""); if (gradleHermesCommand) { return gradleHermesCommand; } const overrideHermesDir = process.env.REACT_NATIVE_OVERRIDE_HERMES_DIR; - candidates.push( - overrideHermesDir - ? path.join(overrideHermesDir, "build", "bin", getHermesOSExe()) - : path.join(reactNativePath, "ReactAndroid", "hermes-engine", "build", "hermes", "bin", getHermesOSExe()) - ); + const sourceBuiltHermesc = overrideHermesDir + ? path.join(overrideHermesDir, "build", "bin", getHermesOSExe()) + : path.join(reactNativePath, "ReactAndroid", "hermes-engine", "build", "hermes", "bin", getHermesOSExe()); + // RN 0.82 uses hermes-compiler only with hermesV1Enabled; RN <= 0.82 ships sdks/hermesc, RN 0.83+ only hermes-compiler - candidates.push(...(isHermesV1EnabledInGradle(gradleFile) ? [hermesCompiler, bundledHermesc] : [bundledHermesc, hermesCompiler])); + if (isHermesV1EnabledInGradle(gradleFile)) { + candidates = [sourceBuiltHermesc, hermesCompiler, bundledHermesc]; + } else { + candidates = [sourceBuiltHermesc, bundledHermesc, hermesCompiler]; + } } // RN < 0.69 shipped hermesc in the hermes-engine / hermesvm packages const nodeModulesPath = getNodeModulesPath(reactNativePath); candidates.push(path.join(nodeModulesPath, "hermes-engine", getHermesOSBin(), getHermesOSExe())); - return ( - candidates.find((candidate) => candidate && fileExistsSync(candidate)) || - path.join(nodeModulesPath, "hermesvm", getHermesOSBin(), "hermes") - ); + const hermesc = candidates.find((candidate) => candidate && fileExists(candidate)); + return hermesc || path.join(nodeModulesPath, "hermesvm", getHermesOSBin(), "hermes"); } // hermes-compiler is a dependency of react-native, so resolve it from there (it may not be hoisted) @@ -530,14 +572,6 @@ function getHermesCompilerPath(reactNativePath: string): string | null { } } -function fileExistsSync(file: string): boolean { - try { - return fs.statSync(file).isFile(); - } catch { - return false; - } -} - function getComposeSourceMapsPath(): string { // detect if compose-source-maps.js script exists const composeSourceMaps = path.join(getReactNativePackagePath(), "scripts", "compose-source-maps.js"); diff --git a/script/utils/gradle-utils.ts b/script/utils/gradle-utils.ts index bab4690..c15789c 100644 --- a/script/utils/gradle-utils.ts +++ b/script/utils/gradle-utils.ts @@ -33,21 +33,14 @@ const FALLBACK_HINT = "Pass the version explicitly with --targetBinaryVersion to // "revopush"-prefixed to avoid collisions with project tasks; written in Groovy, // which Gradle accepts as an init script for projects of either DSL. -const PRINT_VERSION_TASK = "_revopushPrintVersion"; -const printVersionInitScript = (moduleName: string) => ` +const PRINT_TASK = "_revopushPrint"; +const printInitScript = (moduleName: string, printJson: string) => ` allprojects { afterEvaluate { proj -> if (proj.name == '${moduleName}') { - task ${PRINT_VERSION_TASK} { + task ${PRINT_TASK} { doLast { - def android = proj.extensions.findByName('android') - if (android == null) { - throw new GradleException("Project ':${moduleName}' does not apply the Android Gradle plugin (no 'android' extension found).") - } - println groovy.json.JsonOutput.toJson([ - versionName: android.defaultConfig.versionName, - versionCode: android.defaultConfig.versionCode?.toString() - ]) + ${printJson} } } } @@ -55,6 +48,23 @@ allprojects { } `.trim(); +const PRINT_VERSION = ` + def android = proj.extensions.findByName('android') + if (android == null) { + throw new GradleException("Project ':\${proj.name}' does not apply the Android Gradle plugin (no 'android' extension found).") + } + println groovy.json.JsonOutput.toJson([ + versionName: android.defaultConfig.versionName, + versionCode: android.defaultConfig.versionCode?.toString() + ])`; + +const PRINT_REACT_CONFIG = ` + def react = proj.extensions.findByName('react') + println groovy.json.JsonOutput.toJson([ + hermesCommand: react?.hermesCommand?.getOrElse(''), + root: react?.root?.get()?.asFile?.absolutePath + ])`; + /** @param gradleFile build script path or its directory; defaults to "android/app". */ export async function getAndroidVersionInfo(gradleFile?: string | null): Promise { const buildFile = resolveGradleBuildFile(gradleFile ?? path.join("android", "app")); @@ -74,7 +84,7 @@ export async function getAndroidVersionInfo(gradleFile?: string | null): Promise } /** Locates the build script: the given file itself, or inside the given directory (Kotlin DSL preferred). */ -function resolveGradleBuildFile(gradleFile: string): string { +export function resolveGradleBuildFile(gradleFile: string): string { const candidates = [gradleFile, path.join(gradleFile, "build.gradle.kts"), path.join(gradleFile, "build.gradle")]; const buildFile = candidates.find(fileExists); if (!buildFile) { @@ -104,27 +114,41 @@ async function parseGroovyDslBuildFile(buildFile: string): Promise { + try { + return await runGradlePrintTask(buildFile, PRINT_VERSION); + } catch (error) { + throw new Error(`${error.message}\n${FALLBACK_HINT}`); + } +} + +/** Evaluates the `react { }` extension of a Kotlin DSL build script. */ +export function evaluateKotlinDslReactConfig(buildFile: string): Promise<{ hermesCommand?: string; root?: string }> { + return runGradlePrintTask(buildFile, PRINT_REACT_CONFIG); +} + +/** Runs the project's Gradle wrapper with an injected task and returns the JSON it prints. */ +async function runGradlePrintTask(buildFile: string, printJson: string): Promise { // The build file lives in the application module folder (typically android/app); // its parent is the Gradle project root, and the folder name is the module name. const moduleName = path.basename(path.dirname(path.resolve(buildFile))); const androidDir = path.resolve(buildFile, "..", ".."); const gradlew = path.join(androidDir, process.platform === "win32" ? "gradlew.bat" : "gradlew"); if (!fileExists(gradlew)) { - throw new Error(`No Gradle wrapper found at "${gradlew}", required to evaluate "${buildFile}". ${FALLBACK_HINT}`); + throw new Error(`No Gradle wrapper found at "${gradlew}", required to evaluate "${buildFile}".`); } const initScript = path.join(os.tmpdir(), `revopush-init-${process.pid}.gradle`); - fs.writeFileSync(initScript, printVersionInitScript(moduleName), "utf8"); + fs.writeFileSync(initScript, printInitScript(moduleName, printJson), "utf8"); try { const { stdout } = await exec( - `"${gradlew}" --project-dir "${androidDir}" --init-script "${initScript}" -q :${moduleName}:${PRINT_VERSION_TASK}`, + `"${gradlew}" --project-dir "${androidDir}" --init-script "${initScript}" -q :${moduleName}:${PRINT_TASK}`, { timeout: 120000 } ); // The task's JSON is the last line; configuration-phase output (plugin notices, printlns) may precede it. return JSON.parse(stdout.trim().split(/\r?\n/).pop() ?? ""); } catch (error) { - throw new Error(`Gradle failed while reading the version from "${buildFile}": ${error.message}\n${FALLBACK_HINT}`); + throw new Error(`Gradle failed while evaluating "${buildFile}": ${error.message}`); } finally { fs.rmSync(initScript, { force: true }); } diff --git a/test/hermes-command.ts b/test/hermes-command.ts index 803b3be..6a2fe48 100644 --- a/test/hermes-command.ts +++ b/test/hermes-command.ts @@ -2,6 +2,8 @@ import * as assert from "assert"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; +import * as sinon from "sinon"; +import * as cmdexec from "../script/command-executor"; import { getHermesCommand } from "../script/react-native-utils"; const OS_BIN = process.platform === "darwin" ? "osx-bin" : process.platform === "win32" ? "win64-bin" : "linux64-bin"; @@ -30,6 +32,7 @@ describe("Hermes command resolution", () => { originalEnv = { ...process.env }; delete process.env.HERMES_CLI_PATH; delete process.env.REACT_NATIVE_OVERRIDE_HERMES_DIR; + delete process.env.RCT_HERMES_V1_ENABLED; projectDirectory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "revopush-hermesc-"))); createFile("node_modules/react-native/package.json", JSON.stringify({ name: "react-native", version: "0.83.0" })); createFile("android/app/build.gradle", "react {\n}\n"); @@ -39,19 +42,82 @@ describe("Hermes command resolution", () => { afterEach(() => { process.chdir(originalCwd); - process.env = originalEnv; + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + Object.assign(process.env, originalEnv); fs.rmSync(projectDirectory, { recursive: true, force: true }); }); - it("uses a literal hermesCommand from the react block, resolved against the app module", async () => { + it("resolves a literal hermesCommand from the react block against the project root", async () => { bundledHermesc(); - createFile("android/app/build.gradle", 'react {\n hermesCommand = "../../custom/%OS-BIN%/hermesc"\n}\n'); + createFile("android/app/build.gradle", 'react {\n hermesCommand = "custom/%OS-BIN%/hermesc"\n}\n'); assert.strictEqual( await getHermesCommand("android", undefined, undefined), path.join(projectDirectory, "custom", OS_BIN, "hermesc") ); }); + it("expands $rootDir in hermesCommand", async () => { + bundledHermesc(); + createFile("android/app/build.gradle", 'react {\n hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"\n}\n'); + assert.strictEqual( + await getHermesCommand("android", undefined, undefined), + path.join(projectDirectory, "android", "my-custom-hermesc", "bin", "hermesc") + ); + }); + + it("resolves a legacy project.ext.react hermesCommand against the app module", async () => { + bundledHermesc(); + createFile("android/app/build.gradle", 'project.ext.react = [\n hermesCommand: "../../custom/%OS-BIN%/hermesc"\n]\n'); + assert.strictEqual( + await getHermesCommand("android", undefined, undefined), + path.join(projectDirectory, "custom", OS_BIN, "hermesc") + ); + }); + + describe("Kotlin DSL", () => { + const gradleRun = () => path.join(projectDirectory, "gradle-ran"); + + // Stands in for the Gradle wrapper: records that it ran and prints the evaluated react { } config + function createGradlew(script: string) { + fs.rmSync(path.join(projectDirectory, "android", "app", "build.gradle")); + createFile("android/gradlew", `#!/bin/sh\ntouch "${gradleRun()}"\n${script}\n`); + fs.chmodSync(path.join(projectDirectory, "android", "gradlew"), 0o755); + } + + it("evaluates hermesCommand with Gradle and resolves it against react.root", async () => { + bundledHermesc(); + createGradlew(`echo '{"hermesCommand":"custom/%OS-BIN%/hermesc","root":"${projectDirectory}"}'`); + createFile("android/app/build.gradle.kts", 'react {\n hermesCommand = "custom/%OS-BIN%/hermesc"\n}\n'); + assert.strictEqual( + await getHermesCommand("android", undefined, undefined), + path.join(projectDirectory, "custom", OS_BIN, "hermesc") + ); + }); + + it("does not run Gradle when the build script doesn't set hermesCommand", async () => { + const bundled = bundledHermesc(); + createGradlew("exit 1"); + createFile("android/app/build.gradle.kts", 'react {\n // hermesCommand = "custom/hermesc"\n}\n'); + assert.strictEqual(fs.realpathSync(await getHermesCommand("android", undefined, undefined)), bundled); + assert.strictEqual(fs.existsSync(gradleRun()), false); + }); + + it("warns and falls back to the default hermesc when Gradle fails", async () => { + const bundled = bundledHermesc(); + const log = sinon.stub(cmdexec, "log"); + try { + createGradlew("exit 1"); + createFile("android/app/build.gradle.kts", 'react {\n hermesCommand = "custom/hermesc"\n}\n'); + assert.strictEqual(fs.realpathSync(await getHermesCommand("android", undefined, undefined)), bundled); + sinon.assert.calledWithMatch(log, sinon.match(/Gradle failed/)); + } finally { + log.restore(); + } + }); + }); + it("ignores a computed hermesCommand expression such as Expo's", async () => { const expected = bundledHermesc(); createFile( @@ -91,9 +157,22 @@ describe("Hermes command resolution", () => { assert.strictEqual(await getHermesCommand("ios", undefined, undefined), "/custom/hermesc"); }); - it("falls back to hermes-compiler on iOS without CocoaPods", async () => { - bundledHermesc(); + it("prefers the bundled hermesc on iOS without CocoaPods unless Hermes V1 is enabled", async () => { + const bundled = bundledHermesc(); const compiler = hermesCompiler(); + assert.strictEqual(fs.realpathSync(await getHermesCommand("ios", undefined, undefined)), bundled); + + process.env.RCT_HERMES_V1_ENABLED = "1"; assert.strictEqual(fs.realpathSync(await getHermesCommand("ios", undefined, undefined)), compiler); }); + + it("falls back to hermes-compiler on iOS when react-native has no bundled hermesc", async () => { + const compiler = hermesCompiler(); + assert.strictEqual(fs.realpathSync(await getHermesCommand("ios", undefined, undefined)), compiler); + }); + + it("reads HERMES_CLI_PATH from ios/.xcode.env.local", async () => { + createFile("ios/.xcode.env.local", "export HERMES_CLI_PATH=/xcode/hermesc\n"); + assert.strictEqual(await getHermesCommand("ios", undefined, undefined), "/xcode/hermesc"); + }); }); From 193be61115372d84e90b039525c47b0ed73f080c Mon Sep 17 00:00:00 2001 From: Revopush Date: Sat, 10 Oct 2026 22:05:29 +0300 Subject: [PATCH 3/8] Move Hermes detection into hermes-utils and align it with React Native 0.76+ - Split react-native-utils: hermes-utils (Hermes enabled, hermesc lookup, bytecode version), gradle-utils (all Gradle reading), react-native-project-utils (RN paths, .xcode.env) - iOS: read HERMES_CLI_PATH from the Pods xcconfig written by pod install and USE_HERMES from the Xcode project, like react-native-xcode.sh - Android: read react.hermesEnabled, honour react.root, ignore Expo's hermesCommand expressions without a warning - Prefer hermes-compiler on iOS instead of reading RCT_HERMES_V1_ENABLED - Compute "is Hermes enabled" once per release - Read only the base bundle's bytecode header Co-Authored-By: Claude Opus 5.5 --- script/command-executor.ts | 32 +- script/react-native-utils.ts | 618 +++------------------ script/utils/gradle-utils.ts | 155 ++++-- script/utils/hermes-utils.ts | 205 +++++++ script/utils/react-native-project-utils.ts | 48 ++ test/hermes-bytecode.ts | 33 +- test/hermes-command.ts | 167 ++++-- 7 files changed, 612 insertions(+), 646 deletions(-) create mode 100644 script/utils/hermes-utils.ts create mode 100644 script/utils/react-native-project-utils.ts diff --git a/script/command-executor.ts b/script/command-executor.ts index d670878..0cdb331 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -33,12 +33,12 @@ import { import { getBundleSourceMapOutput, getMinifyParams, - getReactNativePackagePath, - isHermesEnabled, isValidVersion, runHermesEmitBinaryCommand, takeHermesBaseBytecode, } from "./react-native-utils"; +import { isHermesEnabled } from "./utils/hermes-utils"; +import { getReactNativePackagePath } from "./utils/react-native-project-utils"; import { fileDoesNotExistOrIsDirectory, fileExists, isBinaryOrZip, extractArchive } from "./utils/file-utils"; import { getAndroidVersionInfo } from "./utils/gradle-utils"; import { @@ -1139,7 +1139,8 @@ export const runExpoExportEmbedCommand = async ( outputFolder: string, sourcemapOutputFolder: string, platform: string, - extraBundlerOptions: string[] + extraBundlerOptions: string[], + isHermes: boolean ) => { const expoBundleArgs: string[] = []; const envNodeArgs: string = process.env.CODE_PUSH_NODE_ARGS; @@ -1169,8 +1170,7 @@ export const runExpoExportEmbedCommand = async ( if (sourcemapOutputFolder) { let bundleSourceMapOutput = sourcemapOutputFolder; if (!sourcemapOutputFolder.endsWith(".map")) { - // user defined directory, нужно вычислить полный путь - bundleSourceMapOutput = await getBundleSourceMapOutput(command, bundleName, sourcemapOutputFolder); + bundleSourceMapOutput = getBundleSourceMapOutput(platform, bundleName, sourcemapOutputFolder, isHermes); } expoBundleArgs.push("--sourcemap-output", bundleSourceMapOutput); @@ -1359,6 +1359,7 @@ export const releaseExpo = (command: cli.IReleaseReactCommand): Promise => }) .then(() => deleteFolder(`${os.tmpdir()}/react-*`)) .then(async () => { + const isHermes = await isHermesEnabled(command, platform); await runExpoExportEmbedCommand( command, bundleName, @@ -1367,11 +1368,9 @@ export const releaseExpo = (command: cli.IReleaseReactCommand): Promise => outputFolder, sourcemapOutputFolder, platform, - command.extraBundlerOptions + command.extraBundlerOptions, + isHermes ); - }) - .then(async () => { - const isHermes = await isHermesEnabled(command, platform); if (isHermes) { await createEmptyTempReleaseFolder(baseReleaseTmpFolder); @@ -1472,6 +1471,7 @@ export const releaseReact = (command: cli.IReleaseReactCommand): Promise = // https://github.com/facebook/react-native/issues/4289 .then(() => deleteFolder(`${os.tmpdir()}/react-*`)) .then(async () => { + const isHermes = await isHermesEnabled(command, platform); await runReactNativeBundleCommand( command, bundleName, @@ -1480,11 +1480,9 @@ export const releaseReact = (command: cli.IReleaseReactCommand): Promise = outputFolder, sourcemapOutputFolder, platform, - command.extraBundlerOptions + command.extraBundlerOptions, + isHermes ); - }) - .then(async () => { - const isHermes = await isHermesEnabled(command, platform); if (isHermes) { await createEmptyTempReleaseFolder(baseReleaseTmpFolder); @@ -1822,7 +1820,8 @@ export const runReactNativeBundleCommand = async ( outputFolder: string, sourcemapOutputFolder: string, platform: string, - extraBundlerOptions: string[] + extraBundlerOptions: string[], + isHermes: boolean ) => { const reactNativeBundleArgs: string[] = []; const envNodeArgs: string = process.env.CODE_PUSH_NODE_ARGS; @@ -1854,14 +1853,13 @@ export const runReactNativeBundleCommand = async ( if (sourcemapOutputFolder) { let bundleSourceMapOutput = sourcemapOutputFolder; if (!sourcemapOutputFolder.endsWith(".map")) { - // user defined full path to source map. let's use that instead - bundleSourceMapOutput = await getBundleSourceMapOutput(command, bundleName, sourcemapOutputFolder); + bundleSourceMapOutput = getBundleSourceMapOutput(platform, bundleName, sourcemapOutputFolder, isHermes); } reactNativeBundleArgs.push("--sourcemap-output", bundleSourceMapOutput); } - const minifyValue = await getMinifyParams(command); + const minifyValue = getMinifyParams(platform, isHermes); Array.prototype.push.apply(reactNativeBundleArgs, minifyValue); if (extraBundlerOptions.length > 0) { diff --git a/script/react-native-utils.ts b/script/react-native-utils.ts index 27b1ed5..45625b1 100644 --- a/script/react-native-utils.ts +++ b/script/react-native-utils.ts @@ -2,51 +2,36 @@ import * as fs from "fs"; import * as chalk from "chalk"; import * as path from "path"; import * as childProcess from "child_process"; -import { coerce, compare, gte, valid } from "semver"; -import { downloadBlob, extractArchive, fileDoesNotExistOrIsDirectory, fileExists } from "./utils/file-utils"; -import { evaluateKotlinDslReactConfig, resolveGradleBuildFile } from "./utils/gradle-utils"; -import * as dotenv from "dotenv"; -import { DotenvParseOutput } from "dotenv"; -import * as cli from "../script/types/cli"; +import { valid } from "semver"; +import { downloadBlob, extractArchive } from "./utils/file-utils"; +import { findHermesc, isBaseBytecodeCompatible } from "./utils/hermes-utils"; +import { getReactNativePackagePath, getXcodeDotEnvValue } from "./utils/react-native-project-utils"; +import * as cli from "./types/cli"; import { log, sdk } from "./command-executor"; -const g2js = require("gradle-to-js/lib/parser"); - export function isValidVersion(version: string): boolean { return !!valid(version) || /^\d+\.\d+$/.test(version); } -export async function getBundleSourceMapOutput(command: cli.IReleaseReactCommand, bundleName: string, sourcemapOutputFolder: string) { - let bundleSourceMapOutput: string | undefined; - switch (command.platform) { +export function getBundleSourceMapOutput( + platform: string, + bundleName: string, + sourcemapOutputFolder: string, + isHermes: boolean +): string { + switch (platform) { case "android": { - // see BundleHermesCTask -> resolvePackagerSourceMapFile - // for Hermes targeted bundles there are 2 source maps: "packager" (metro) and "compiler" (Hermes) - // Metro bundles use .packager.map notation - const isHermes = await isHermesEnabled(command, command.platform); - if (isHermes) { - bundleSourceMapOutput = path.join(sourcemapOutputFolder, bundleName + ".packager.map"); - } else { - bundleSourceMapOutput = path.join(sourcemapOutputFolder, bundleName + ".map"); - } - - break; + // With Hermes, Metro's map is the "packager" one (BundleHermesCTask.resolvePackagerSourceMapFile) + return path.join(sourcemapOutputFolder, bundleName + (isHermes ? ".packager.map" : ".map")); } case "ios": { - // see react-native-xcode.sh - // to match js bundle generated by Xcode and by Revopush cli we must respect SOURCEMAP_FILE value - // because it appears as //# sourceMappingURL value in a js bundle - const xcodeDotEnvValue = getXcodeDotEnvValue("SOURCEMAP_FILE"); - const sourceMapFilename = xcodeDotEnvValue ? path.basename(xcodeDotEnvValue) : bundleName + ".map"; - - bundleSourceMapOutput = path.join(sourcemapOutputFolder, sourceMapFilename); - - break; + // Named like react-native-xcode.sh does, since the name ends up in the bundle's sourceMappingURL + const sourceMapFile = getXcodeDotEnvValue("SOURCEMAP_FILE"); + return path.join(sourcemapOutputFolder, sourceMapFile ? path.basename(sourceMapFile) : bundleName + ".map"); } default: throw new Error('Platform must be either "android" or "ios".'); } - return bundleSourceMapOutput; } export async function takeHermesBaseBytecode( @@ -55,7 +40,12 @@ export async function takeHermesBaseBytecode( outputFolder: string, bundleName: string ): Promise { - const { bundleBlobUrl } = await sdk.getBaseRelease(command.appName, command.deploymentName, command.appStoreVersion, command.buildNumber); + const { bundleBlobUrl } = await sdk.getBaseRelease( + command.appName, + command.deploymentName, + command.appStoreVersion, + command.buildNumber + ); if (!bundleBlobUrl) { return null; } @@ -72,48 +62,6 @@ export async function takeHermesBaseBytecode( return baseReleaseBundle; } -// Hermes BytecodeFileHeader: uint64 magic, uint32 version (little-endian), stable since Hermes v0.1 -const HERMES_BYTECODE_MAGIC = BigInt("0x1F1903C103BC1FC6"); - -function getHermesBytecodeVersion(bytecode: Buffer): number | null { - if (bytecode.length < 12 || bytecode.readBigUInt64LE(0) !== HERMES_BYTECODE_MAGIC) { - return null; - } - return bytecode.readUInt32LE(8); -} - -// Compile an empty input to stdout and read the version from the emitted header -export function getHermesCompilerBytecodeVersion(hermesCommand: string): number | null { - const result = childProcess.spawnSync(hermesCommand, ["-emit-binary", "-"], { input: "", timeout: 30000 }); - return result.status === 0 ? getHermesBytecodeVersion(result.stdout) : null; -} - -// hermesc -base-bytecode aborts on a base of another bytecode version; the base is only a diff optimization -export function resolveHermesBaseBytecode(baseBytecode: string, compilerBytecodeVersion: number | null): string | null { - const baseBytecodeVersion = getHermesBytecodeVersion(fs.readFileSync(baseBytecode)); - if (baseBytecodeVersion === null) { - log(chalk.yellow("\nBase release bundle is not Hermes bytecode, skipping -base-bytecode.\n")); - return null; - } - - if (compilerBytecodeVersion === null) { - log(chalk.yellow("\nCould not detect the Hermes compiler bytecode version, skipping -base-bytecode.\n")); - return null; - } - - if (baseBytecodeVersion !== compilerBytecodeVersion) { - log( - chalk.yellow( - `\nWarning: base release is Hermes bytecode v${baseBytecodeVersion}, local compiler produces v${compilerBytecodeVersion}. ` + - `Skipping -base-bytecode. Binaries on v${baseBytecodeVersion} can't run this update.\n` - ) - ); - return null; - } - - return baseBytecode; -} - export async function runHermesEmitBinaryCommand( command: cli.IReleaseReactCommand, bundleName: string, @@ -123,501 +71,95 @@ export async function runHermesEmitBinaryCommand( gradleFile: string, baseBytecode?: string ): Promise { - const hermesArgs: string[] = []; - const envNodeArgs: string = process.env.CODE_PUSH_NODE_ARGS; + const bundle = path.join(outputFolder, bundleName); + const bytecode = bundle + ".hbc"; + const hermesc = await findHermesc(command.platform, gradleFile, command.podFile); - if (typeof envNodeArgs !== "undefined") { - Array.prototype.push.apply(hermesArgs, envNodeArgs.trim().split(/\s+/)); - } - - Array.prototype.push.apply(hermesArgs, [ + const hermesArgs = [ + ...(process.env.CODE_PUSH_NODE_ARGS?.trim().split(/\s+/).filter(Boolean) ?? []), "-emit-binary", "-O", "-out", - path.join(outputFolder, bundleName + ".hbc"), - path.join(outputFolder, bundleName), + bytecode, + bundle, "-w", "-max-diagnostic-width=80", ...extraHermesFlags, - ]); - + ]; if (sourcemapOutputFolder) { hermesArgs.push("-output-source-map"); } - - const hermesCommand = await getHermesCommand(command.platform, gradleFile, command.podFile); - - if (baseBytecode) { - const compilerBytecodeVersion = getHermesCompilerBytecodeVersion(hermesCommand); - const resolvedBaseBytecode = resolveHermesBaseBytecode(baseBytecode, compilerBytecodeVersion); - if (resolvedBaseBytecode) { - hermesArgs.push("-base-bytecode", resolvedBaseBytecode); - } + if (baseBytecode && isBaseBytecodeCompatible(baseBytecode, hermesc)) { + hermesArgs.push("-base-bytecode", baseBytecode); } console.log(chalk.cyan("Converting JS bundle to byte code via Hermes, running command:\n")); - const hermesProcess = childProcess.spawn(hermesCommand, hermesArgs); - console.log(`${hermesCommand} ${hermesArgs.join(" ")}`); + await runCommand("hermes", hermesc, hermesArgs); + await fs.promises.rename(bytecode, bundle); - return new Promise((resolve, reject) => { - hermesProcess.on("error", reject); - - hermesProcess.stdout.on("data", (data: Buffer) => { - console.log(data.toString().trim()); - }); + if (sourcemapOutputFolder) { + await composeHermesSourceMaps(command, bundleName, outputFolder, sourcemapOutputFolder); + } +} - hermesProcess.stderr.on("data", (data: Buffer) => { - console.error(data.toString().trim()); - }); +// Packager + Hermes source maps = final source map, as react-native-xcode.sh and the RN Gradle plugin do +async function composeHermesSourceMaps( + command: cli.IReleaseReactCommand, + bundleName: string, + outputFolder: string, + sourcemapOutputFolder: string +): Promise { + const composeSourceMaps = path.join(getReactNativePackagePath(), "scripts", "compose-source-maps.js"); + if (!fs.existsSync(composeSourceMaps)) { + throw new Error("react-native compose-source-maps.js script is not found"); + } - hermesProcess.on("close", (exitCode: number, signal: string) => { - if (exitCode !== 0) { - reject(new Error(`"hermes" command failed (exitCode=${exitCode}, signal=${signal}).`)); - return; - } - // Copy HBC bundle to overwrite JS bundle - const source = path.join(outputFolder, bundleName + ".hbc"); - const destination = path.join(outputFolder, bundleName); - fs.copyFile(source, destination, (err) => { - if (err) { - console.error(err); - reject(new Error(`Copying file ${source} to ${destination} failed. "hermes" previously exited with code ${exitCode}.`)); - return; - } - fs.unlink(source, (err) => { - if (err) { - console.error(err); - reject(err); - return; - } - resolve(null as void); - }); - }); - }); - }).then(async () => { - if (!sourcemapOutputFolder) { - // skip source map compose if source map is not enabled - return; - } + const hermesSourceMap = path.join(outputFolder, bundleName + ".hbc.map"); + if (!fs.existsSync(hermesSourceMap)) { + throw new Error(`sourcemap file ${hermesSourceMap} is not found`); + } - const composeSourceMapsPath = getComposeSourceMapsPath(); - if (!composeSourceMapsPath) { - throw new Error("react-native compose-source-maps.js scripts is not found"); - } + let packagerSourceMap = sourcemapOutputFolder; + let combinedSourceMap = sourcemapOutputFolder; + if (!sourcemapOutputFolder.endsWith(".map")) { + packagerSourceMap = getBundleSourceMapOutput(command.platform, bundleName, sourcemapOutputFolder, true); + combinedSourceMap = command.platform === "android" ? path.join(sourcemapOutputFolder, bundleName + ".map") : packagerSourceMap; + } - const jsCompilerSourceMapFile = path.join(outputFolder, bundleName + ".hbc" + ".map"); - if (!fs.existsSync(jsCompilerSourceMapFile)) { - throw new Error(`sourcemap file ${jsCompilerSourceMapFile} is not found`); - } + await runCommand("compose-source-maps", "node", [composeSourceMaps, packagerSourceMap, hermesSourceMap, "-o", combinedSourceMap]); + // Otherwise it would be released too + await fs.promises.unlink(hermesSourceMap); +} - const platformSourceMapOutput = await getBundleSourceMapOutput(command, bundleName, sourcemapOutputFolder); - return new Promise((resolve, reject) => { - let bundleSourceMapOutput = sourcemapOutputFolder; - let combinedSourceMapOutput = sourcemapOutputFolder; +function runCommand(name: string, command: string, args: string[]): Promise { + console.log(`${command} ${args.join(" ")}`); + const child = childProcess.spawn(command, args); + child.stdout.on("data", (data: Buffer) => console.log(data.toString().trim())); + child.stderr.on("data", (data: Buffer) => console.error(data.toString().trim())); - if (!sourcemapOutputFolder.endsWith(".map")) { - bundleSourceMapOutput = platformSourceMapOutput; - switch (command.platform) { - case "android": { - combinedSourceMapOutput = path.join(sourcemapOutputFolder, bundleName + ".map"); - break; - } - case "ios": { - combinedSourceMapOutput = bundleSourceMapOutput; - break; - } - default: - throw new Error('Platform must be either "android" or "ios".'); - } + return new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", (exitCode: number, signal: string) => { + if (exitCode === 0) { + resolve(); + } else { + reject(new Error(`"${name}" command failed (exitCode=${exitCode}, signal=${signal}).`)); } - const composeSourceMapsArgs = [ - composeSourceMapsPath, - bundleSourceMapOutput, - jsCompilerSourceMapFile, - "-o", - combinedSourceMapOutput, - ]; - - // https://github.com/facebook/react-native/blob/master/react.gradle#L211 - // https://github.com/facebook/react-native/blob/master/scripts/react-native-xcode.sh#L178 - // packager.sourcemap.map + hbc.sourcemap.map = sourcemap.map - const composeSourceMapsProcess = childProcess.spawn("node", composeSourceMapsArgs); - console.log(`${composeSourceMapsPath} ${composeSourceMapsArgs.join(" ")}`); - - composeSourceMapsProcess.stdout.on("data", (data: Buffer) => { - console.log(data.toString().trim()); - }); - - composeSourceMapsProcess.stderr.on("data", (data: Buffer) => { - console.error(data.toString().trim()); - }); - - composeSourceMapsProcess.on("close", (exitCode: number, signal: string) => { - if (exitCode !== 0) { - reject(new Error(`"compose-source-maps" command failed (exitCode=${exitCode}, signal=${signal}).`)); - return; - } - - // Delete the HBC sourceMap, otherwise it will be included in 'code-push' bundle as well - fs.unlink(jsCompilerSourceMapFile, (err) => { - if (err) { - console.error(err); - reject(err); - return; - } - - resolve(null); - }); - }); }); }); } -export function getXcodeDotEnvValue(key: string): string | undefined { - // Xcode sources .xcode.env, then .xcode.env.local - const xcodeEnvs = loadEnvAsMap([path.join("ios", ".xcode.env"), path.join("ios", ".xcode.env.local")]); - return xcodeEnvs.get(key); -} - -export async function getMinifyParams(command: cli.IReleaseReactCommand) { - const isHermes = await isHermesEnabled(command); - - switch (command.platform) { +export function getMinifyParams(platform: string, isHermes: boolean): (string | boolean)[] { + switch (platform) { case "android": { - // android always explicitly pass --minify true/false - // TaskConfiguration it.minifyEnabled.set(!isHermesEnabledInThisVariant) + // The RN Gradle plugin minifies unless Hermes is enabled return ["--minify", !isHermes]; } case "ios": { - //if [[ $USE_HERMES != false && $DEV == false ]]; then - // EXTRA_ARGS+=("--minify" "false") - // fi - // ios does pass --minify false only if Hermes enables and does pass anything otherwise + // react-native-xcode.sh passes "--minify false" only with Hermes return isHermes ? ["--minify", false] : []; } default: throw new Error('Platform must be either "android" or "ios".'); } } - -export async function isHermesEnabled(command: cli.IReleaseReactCommand, platform: string = command.platform.toLowerCase()) { - if (command.useHermes) return true; - if (platform === "android") return getAndroidHermesEnabled(command.gradleFile); - if (platform === "ios") return getiOSHermesEnabled(command.podFile); - return false; -} - -function parseBuildGradleFile(gradleFile: string) { - let buildGradlePath: string = path.join("android", "app"); - if (gradleFile) { - buildGradlePath = gradleFile; - } - - try { - if (fs.lstatSync(buildGradlePath).isDirectory()) { - buildGradlePath = path.join(buildGradlePath, "build.gradle"); - fs.accessSync(buildGradlePath); - } - } catch { - throw new Error(`Unable to find gradle file "${buildGradlePath}".`); - } - - return g2js.parseFile(buildGradlePath).catch(() => { - throw new Error(`Unable to parse the "${buildGradlePath}" file. Please ensure it is a well-formed Gradle file.`); - }); -} - -function parseGradlePropertiesFile(gradleFile: string): Record { - let gradlePropsPath: string = path.join("android", "gradle.properties"); - - try { - if (gradleFile) { - const base = gradleFile; - const stat = fs.lstatSync(base); - - if (stat.isDirectory()) { - if (path.basename(base) === "app") { - gradlePropsPath = path.join(base, "..", "gradle.properties"); - } else { - gradlePropsPath = path.join(base, "gradle.properties"); - } - } else { - gradlePropsPath = path.join(path.dirname(base), "..", "gradle.properties"); - } - } - } catch {} - - gradlePropsPath = path.normalize(gradlePropsPath); - - if (fileDoesNotExistOrIsDirectory(gradlePropsPath)) { - throw new Error(`Unable to find gradle.properties file "${gradlePropsPath}".`); - } - - const text = fs.readFileSync(gradlePropsPath, "utf8"); - const props: Record = {}; - for (const rawLine of text.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) continue; - const m = line.match(/^([^=\s]+)\s*=\s*(.*)$/); - if (m) { - const key = m[1].trim(); - const val = m[2].trim(); - props[key] = val; - } - } - return props; -} - -// Only literal paths (plus $rootDir / $projectDir) can be resolved; other Groovy expressions, like Expo's, are ignored -async function getHermesCommandFromGradle(gradleFile: string): Promise { - const buildFile = resolveGradleBuildFile(gradleFile || path.join("android", "app")); - if (buildFile.endsWith(".kts")) { - return getHermesCommandFromKotlinDsl(buildFile); - } - - const buildGradle: any = await parseBuildGradleFile(gradleFile); - const gradlePath = gradleFile || path.join("android", "app"); - const appDir = path.resolve(directoryExistsSync(gradlePath) ? gradlePath : path.dirname(gradlePath)); - const rootDir = path.dirname(appDir); - - // The gradle plugin runs hermesc from react.root (default: $rootDir/..); legacy react.gradle from the app module - let hermesCommand: string = buildGradle.react?.hermesCommand; - let workingDir = path.dirname(rootDir); - if (!hermesCommand) { - const legacyProperties: string[] = Array.from(buildGradle["project.ext.react"] || []); - const legacyProperty = legacyProperties.find((prop) => prop.trim().startsWith("hermesCommand:")); - hermesCommand = legacyProperty?.replace("hermesCommand:", "").trim().slice(1, -1); - workingDir = appDir; - } - if (!hermesCommand) { - return ""; - } - - hermesCommand = hermesCommand - .replace(/\$(\{rootDir\}|rootDir\b)/g, () => rootDir) - .replace(/\$(\{projectDir\}|projectDir\b)/g, () => appDir); - if (/["'$()+]/.test(hermesCommand)) { - return ""; - } - - return path.resolve(workingDir, hermesCommand.replace("%OS-BIN%", getHermesOSBin())); -} - -// Kotlin DSL can't be parsed statically, so evaluate it with Gradle, but only if it sets hermesCommand -async function getHermesCommandFromKotlinDsl(buildFile: string): Promise { - if (!/^\s*(react\.)?hermesCommand\b/m.test(fs.readFileSync(buildFile, "utf8"))) { - return ""; - } - - try { - const { hermesCommand, root } = await evaluateKotlinDslReactConfig(buildFile); - return hermesCommand && root ? path.resolve(root, hermesCommand.replace("%OS-BIN%", getHermesOSBin())) : ""; - } catch (error) { - log(chalk.yellow(`\nWarning: ${error.message}\nUsing the default hermesc instead.\n`)); - return ""; - } -} - -function isHermesV1EnabledInGradle(gradleFile: string): boolean { - try { - return parseGradlePropertiesFile(gradleFile).hermesV1Enabled?.toLowerCase() === "true"; - } catch { - return false; - } -} - -async function getAndroidHermesEnabled(gradleFile: string): Promise { - try { - const props = parseGradlePropertiesFile(gradleFile); - if (typeof props.hermesEnabled !== "undefined") { - const v = String(props.hermesEnabled).trim().toLowerCase(); - if (v === "true") return true; - if (v === "false") return false; - } - } catch {} - - try { - const buildGradle: any = await parseBuildGradleFile(gradleFile); - const lines: string[] = Array.from(buildGradle["project.ext.react"] || []); - if (lines.some((l) => /\benableHermes\s*:\s*true\b/.test(l))) return true; - if (lines.some((l) => /\benableHermes\s*:\s*false\b/.test(l))) return false; - } catch {} - - const rnVersion = coerce(getReactNativeVersion())?.version; - return rnVersion && compare(rnVersion, "0.70.0") >= 0; -} - -function getiOSHermesEnabled(podFile: string): boolean { - const podPath = podFile || path.join("ios", "Podfile"); - if (podFile && fileDoesNotExistOrIsDirectory(podPath)) { - throw new Error(`Unable to find Podfile file "${podPath}".`); - } else if (!podFile && fileDoesNotExistOrIsDirectory(podPath)) { - // No Podfile at default path (e.g. Android-only project); fall back to RN version heuristic - const rnVersion = coerce(getReactNativeVersion())?.version; - return !!(rnVersion && compare(rnVersion, "0.70.0") >= 0); - } - - try { - const podFileContents = fs.readFileSync(podPath).toString(); - - const hasTrue = /([^#\n]*:?hermes_enabled(\s+|\n+)?(=>|:)(\s+|\n+)?true)/.test(podFileContents); - if (hasTrue) return true; - - const hasFalse = /([^#\n]*:?hermes_enabled(\s+|\n+)?(=>|:)(\s+|\n+)?false)/.test(podFileContents); - if (hasFalse) return false; - - const rnVersion = coerce(getReactNativeVersion())?.version; - return rnVersion && compare(rnVersion, "0.70.0") >= 0; - } catch (error) { - throw error; - } -} - -function loadEnvAsMap(envPaths = []): Map { - const merged: DotenvParseOutput = {}; - - for (const envPath of envPaths) { - if (fs.existsSync(envPath)) { - Object.assign(merged, dotenv.parse(fs.readFileSync(envPath))); // later files override earlier ones - } - } - - // fallback to process.env for anything missing - return new Map([...Object.entries(process.env), ...Object.entries(merged)]); -} - -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"; - } -} - -function getHermesOSExe(): string { - const react63orAbove = compare(coerce(getReactNativeVersion())?.version, "0.63.0") !== -1; - const hermesExecutableName = react63orAbove ? "hermesc" : "hermes"; - switch (process.platform) { - case "win32": - return hermesExecutableName + ".exe"; - default: - return hermesExecutableName; - } -} - -// Mirrors how React Native picks hermesc, so updates are compiled like the store binary. -// Android: gradle-plugin PathUtils.detectOSAwareHermesCommand; iOS: scripts/react-native-xcode.sh -export async function getHermesCommand(platform: string, gradleFile: string, podFile: string): Promise { - const reactNativePath = getReactNativePackagePath(); - const bundledHermesc = path.join(reactNativePath, "sdks", "hermesc", getHermesOSBin(), getHermesOSExe()); - const hermesCompiler = getHermesCompilerPath(reactNativePath); - let candidates: string[]; - - if (platform === "ios") { - // react-native-xcode.sh honors HERMES_CLI_PATH from the environment or ios/.xcode.env(.local) - const hermesCliPath = getXcodeDotEnvValue("HERMES_CLI_PATH"); - if (hermesCliPath) { - return hermesCliPath; - } - const podsDir = path.join(path.dirname(podFile || path.join("ios", "Podfile")), "Pods"); - const podsHermesc = path.join(podsDir, "hermes-engine", "destroot", "bin", "hermesc"); - - // Without Pods (e.g. CI), pick the compiler like Android: RN 0.82 uses hermes-compiler only for Hermes V1 - if (process.env.RCT_HERMES_V1_ENABLED === "1") { - candidates = [podsHermesc, hermesCompiler, bundledHermesc]; - } else { - candidates = [podsHermesc, bundledHermesc, hermesCompiler]; - } - } else { - // A missing or unparsable gradle file just means there is no hermesCommand override - const gradleHermesCommand = await getHermesCommandFromGradle(gradleFile).catch(() => ""); - if (gradleHermesCommand) { - return gradleHermesCommand; - } - - const overrideHermesDir = process.env.REACT_NATIVE_OVERRIDE_HERMES_DIR; - const sourceBuiltHermesc = overrideHermesDir - ? path.join(overrideHermesDir, "build", "bin", getHermesOSExe()) - : path.join(reactNativePath, "ReactAndroid", "hermes-engine", "build", "hermes", "bin", getHermesOSExe()); - - // RN 0.82 uses hermes-compiler only with hermesV1Enabled; RN <= 0.82 ships sdks/hermesc, RN 0.83+ only hermes-compiler - if (isHermesV1EnabledInGradle(gradleFile)) { - candidates = [sourceBuiltHermesc, hermesCompiler, bundledHermesc]; - } else { - candidates = [sourceBuiltHermesc, bundledHermesc, hermesCompiler]; - } - } - - // RN < 0.69 shipped hermesc in the hermes-engine / hermesvm packages - const nodeModulesPath = getNodeModulesPath(reactNativePath); - candidates.push(path.join(nodeModulesPath, "hermes-engine", getHermesOSBin(), getHermesOSExe())); - - const hermesc = candidates.find((candidate) => candidate && fileExists(candidate)); - return hermesc || path.join(nodeModulesPath, "hermesvm", getHermesOSBin(), "hermes"); -} - -// hermes-compiler is a dependency of react-native, so resolve it from there (it may not be hoisted) -function getHermesCompilerPath(reactNativePath: string): string | null { - try { - const packageJson = require.resolve("hermes-compiler/package.json", { paths: [reactNativePath] }); - return path.join(path.dirname(packageJson), "hermesc", getHermesOSBin(), getHermesOSExe()); - } catch { - return null; - } -} - -function getComposeSourceMapsPath(): string { - // detect if compose-source-maps.js script exists - const composeSourceMaps = path.join(getReactNativePackagePath(), "scripts", "compose-source-maps.js"); - if (fs.existsSync(composeSourceMaps)) { - return composeSourceMaps; - } - return null; -} - -function getNodeModulesPath(reactNativePath: string): string { - const nodeModulesPath = path.dirname(reactNativePath); - if (directoryExistsSync(nodeModulesPath)) { - return nodeModulesPath; - } - - return path.join("node_modules"); -} - -export function getReactNativePackagePath(): string { - const result = childProcess.spawnSync("node", ["--print", "require.resolve('react-native/package.json')"]); - const packagePath = path.dirname(result.stdout.toString()); - if (result.status === 0 && directoryExistsSync(packagePath)) { - return packagePath; - } - - return path.join("node_modules", "react-native"); -} - -export function directoryExistsSync(dirname: string): boolean { - try { - return fs.statSync(dirname).isDirectory(); - } catch (err) { - if (err.code !== "ENOENT") { - throw err; - } - } - return false; -} - -export function getReactNativeVersion(): string { - try { - const result = childProcess.spawnSync("node", ["--print", "require('react-native/package.json').version"]); - return result.stdout.toString().trim(); - } catch (error) { - throw new Error( - 'Unable to resolve "react-native". Please make sure it is installed in your project (e.g. "npm install react-native").' - ); - } -} diff --git a/script/utils/gradle-utils.ts b/script/utils/gradle-utils.ts index c15789c..88bfcba 100644 --- a/script/utils/gradle-utils.ts +++ b/script/utils/gradle-utils.ts @@ -1,7 +1,5 @@ -// Detects the Android versionName/versionCode a CodePush release should target. -// Groovy DSL (build.gradle) is parsed statically; Kotlin DSL (build.gradle.kts) may -// compute values from arbitrary expressions, so it is evaluated by running the -// project's Gradle wrapper with an injected task that prints the resolved values. +// Reads the Android app's version and Hermes settings. Groovy DSL is parsed statically; Kotlin DSL is evaluated +// by running the Gradle wrapper with an injected task that prints the values. import * as childProcess from "child_process"; import * as fs from "fs"; @@ -29,10 +27,10 @@ interface GradleVersionFields { versionCode: string | number | null; } +const DEFAULT_APP_DIR = path.join("android", "app"); const FALLBACK_HINT = "Pass the version explicitly with --targetBinaryVersion to skip Gradle detection."; -// "revopush"-prefixed to avoid collisions with project tasks; written in Groovy, -// which Gradle accepts as an init script for projects of either DSL. +// Groovy init scripts work for projects of either DSL const PRINT_TASK = "_revopushPrint"; const printInitScript = (moduleName: string, printJson: string) => ` allprojects { @@ -65,15 +63,19 @@ const PRINT_REACT_CONFIG = ` root: react?.root?.get()?.asFile?.absolutePath ])`; -/** @param gradleFile build script path or its directory; defaults to "android/app". */ +// Every `gradleFile` parameter below is the app's build script or its directory; defaults to "android/app". + export async function getAndroidVersionInfo(gradleFile?: string | null): Promise { - const buildFile = resolveGradleBuildFile(gradleFile ?? path.join("android", "app")); - const { versionName, versionCode } = buildFile.endsWith(".kts") - ? await evaluateKotlinDslBuildFile(buildFile) - : await parseGroovyDslBuildFile(buildFile); + const buildFile = findGradleBuildFile(gradleFile); + if (!buildFile) { + throw new Error(`Unable to find gradle file "${gradleFile ?? DEFAULT_APP_DIR}".`); + } + const { versionName, versionCode } = isKotlinDsl(buildFile) + ? await evaluateKotlinDslVersion(buildFile) + : await parseGroovyDslVersion(buildFile); const appVersion = resolveAppVersion(versionName, buildFile); - const buildNumber = resolveBuildNumber(versionCode); + const buildNumber = resolveBuildNumber(versionCode, buildFile); if (!buildNumber) { console.log(chalk.yellow( `Warning: Unable to read "android.defaultConfig.versionCode" from "${buildFile}". ` + @@ -83,20 +85,103 @@ export async function getAndroidVersionInfo(gradleFile?: string | null): Promise return { appVersion, buildNumber }; } -/** Locates the build script: the given file itself, or inside the given directory (Kotlin DSL preferred). */ -export function resolveGradleBuildFile(gradleFile: string): string { - const candidates = [gradleFile, path.join(gradleFile, "build.gradle.kts"), path.join(gradleFile, "build.gradle")]; - const buildFile = candidates.find(fileExists); - if (!buildFile) { - throw new Error(`Unable to find gradle file "${gradleFile}".`); +/** `(react.)hermesEnabled` from gradle.properties, or `enableHermes` from RN < 0.71's `project.ext.react`; undefined if unset. */ +export async function getGradleHermesEnabled(gradleFile?: string | null): Promise { + let enabled = readGradleProperty("hermesEnabled", gradleFile) ?? readGradleProperty("react.hermesEnabled", gradleFile); + const buildFile = findGradleBuildFile(gradleFile); + if (enabled === undefined && buildFile && !isKotlinDsl(buildFile)) { + const parsed = await parseGroovyDslBuildFile(buildFile).catch((): any => ({})); + enabled = getLegacyReactProperty(parsed, "enableHermes"); } - return buildFile; + return enabled === undefined ? undefined : enabled.toLowerCase() === "true"; +} + +/** + * The app's `react { hermesCommand }` (or RN < 0.71's `project.ext.react`) as an absolute path, resolved from the + * directory the RN Gradle plugin runs hermesc in. It may still contain the `%OS-BIN%` placeholder. + */ +export async function getGradleHermesCommand(gradleFile?: string | null): Promise { + const buildFile = findGradleBuildFile(gradleFile); + if (!buildFile) return undefined; + return isKotlinDsl(buildFile) ? evaluateKotlinDslHermesCommand(buildFile) : parseGroovyDslHermesCommand(buildFile); } -async function parseGroovyDslBuildFile(buildFile: string): Promise { - const parsed: any = await g2js.parseFile(buildFile).catch(() => { +/** A property from the app module's or the Android project's gradle.properties. */ +export function readGradleProperty(key: string, gradleFile?: string | null): string | undefined { + const appDir = getAppDir(gradleFile); + for (const file of [path.join(appDir, "gradle.properties"), path.join(appDir, "..", "gradle.properties")]) { + if (!fileExists(file)) continue; + // The properties parser type-converts values (e.g. "2" becomes a number) — normalize back to string. + const value = properties.parse(fs.readFileSync(file, "utf8"))?.[key]?.toString(); + if (value) return value; + } +} + +function getAppDir(gradleFile?: string | null): string { + const location = gradleFile || DEFAULT_APP_DIR; + return fileExists(location) ? path.dirname(location) : location; +} + +/** The build script: the given file itself, or inside the app directory (Kotlin DSL preferred). */ +function findGradleBuildFile(gradleFile?: string | null): string | undefined { + const appDir = getAppDir(gradleFile); + return [gradleFile, path.join(appDir, "build.gradle.kts"), path.join(appDir, "build.gradle")].find( + (file) => file && fileExists(file) + ); +} + +function isKotlinDsl(buildFile: string): boolean { + return buildFile.endsWith(".kts"); +} + +function parseGroovyDslBuildFile(buildFile: string): Promise { + return g2js.parseFile(buildFile).catch(() => { throw new Error(`Unable to parse the "${buildFile}" file. Please ensure it is a well-formed Gradle file.`); }); +} + +// RN < 0.71 configured the app with `project.ext.react = [key: value, ...]`. g2js keeps a trailing `// comment` +// at the start of the next entry (RN's template has `enableHermes: false, // ...`), so strip comments first. +function getLegacyReactProperty(parsed: any, key: string): string | undefined { + const entries: string[] = parsed["project.ext.react"] || []; + const entry = entries.map((prop) => prop.replace(/\/\/.*$/gm, "").trim()).find((prop) => prop.startsWith(`${key}:`)); + return entry?.slice(key.length + 1).trim().replace(/^["']|["']$/g, ""); +} + +// The RN Gradle plugin runs hermesc from react.root (default: the project root); RN < 0.71's react.gradle from the app module +async function parseGroovyDslHermesCommand(buildFile: string): Promise { + const gradle = await parseGroovyDslBuildFile(buildFile); + const appDir = path.resolve(path.dirname(buildFile)); + const variables = { rootDir: path.dirname(appDir), projectDir: appDir }; + + if (gradle.react?.hermesCommand) { + const hermesCommand = toLiteralPath(gradle.react.hermesCommand, variables); + const root = toLiteralPath(/^file\(["'](.+)["']\)$/.exec(gradle.react.root ?? "")?.[1], variables) ?? "../.."; + return hermesCommand && path.resolve(appDir, root, hermesCommand); + } + + const legacyHermesCommand = toLiteralPath(getLegacyReactProperty(gradle, "hermesCommand"), variables); + return legacyHermesCommand && path.resolve(appDir, legacyHermesCommand); +} + +// A plain Groovy string with $rootDir / $projectDir expanded; undefined for any other expression, like Expo's +function toLiteralPath(value: unknown, variables: Record): string | undefined { + if (typeof value !== "string") return undefined; + const expanded = value.replace(/\$(?:\{(\w+)\}|(\w+))/g, (match, braced, bare) => variables[braced ?? bare] ?? match); + return /["'$()+]/.test(expanded) ? undefined : expanded; +} + +// Running Gradle is slow, so only evaluate Kotlin DSL scripts that actually set hermesCommand +async function evaluateKotlinDslHermesCommand(buildFile: string): Promise { + if (!/^[^/\n]*\bhermesCommand\b/m.test(fs.readFileSync(buildFile, "utf8"))) { + return undefined; + } + const { hermesCommand, root } = await runGradlePrintTask(buildFile, PRINT_REACT_CONFIG); + return hermesCommand && root ? path.resolve(root, hermesCommand) : undefined; +} + +async function parseGroovyDslVersion(buildFile: string): Promise { + const parsed = await parseGroovyDslBuildFile(buildFile); // g2js yields an array when the file contains multiple 'android' blocks. const androidBlocks: any[] = Array.isArray(parsed.android) ? parsed.android : [parsed.android]; const defaultConfig = androidBlocks.find((block) => block?.defaultConfig)?.defaultConfig; @@ -113,7 +198,7 @@ async function parseGroovyDslBuildFile(buildFile: string): Promise { +async function evaluateKotlinDslVersion(buildFile: string): Promise { try { return await runGradlePrintTask(buildFile, PRINT_VERSION); } catch (error) { @@ -121,15 +206,9 @@ async function evaluateKotlinDslBuildFile(buildFile: string): Promise { - return runGradlePrintTask(buildFile, PRINT_REACT_CONFIG); -} - /** Runs the project's Gradle wrapper with an injected task and returns the JSON it prints. */ async function runGradlePrintTask(buildFile: string, printJson: string): Promise { - // The build file lives in the application module folder (typically android/app); - // its parent is the Gradle project root, and the folder name is the module name. + // android//build.gradle.kts const moduleName = path.basename(path.dirname(path.resolve(buildFile))); const androidDir = path.resolve(buildFile, "..", ".."); const gradlew = path.join(androidDir, process.platform === "win32" ? "gradlew.bat" : "gradlew"); @@ -145,7 +224,7 @@ async function runGradlePrintTask(buildFile: string, printJson: string): Promise `"${gradlew}" --project-dir "${androidDir}" --init-script "${initScript}" -q :${moduleName}:${PRINT_TASK}`, { timeout: 120000 } ); - // The task's JSON is the last line; configuration-phase output (plugin notices, printlns) may precede it. + // Configuration output may precede the JSON line return JSON.parse(stdout.trim().split(/\r?\n/).pop() ?? ""); } catch (error) { throw new Error(`Gradle failed while evaluating "${buildFile}": ${error.message}`); @@ -159,7 +238,7 @@ function resolveAppVersion(versionName: string | null, buildFile: string): strin // A value that isn't valid semver and doesn't start with a digit is a property reference. const isPropertyRef = !isValidVersion(versionName) && !/^\d/.test(versionName); - const appVersion = isPropertyRef ? lookupGradleProperty(versionName.replace("project.", "")) : versionName; + const appVersion = isPropertyRef ? readGradleProperty(versionName.replace("project.", ""), buildFile) : versionName; if (!appVersion || !isValidVersion(appVersion)) { throw new Error( @@ -170,19 +249,9 @@ function resolveAppVersion(versionName: string | null, buildFile: string): strin return appVersion; } -function resolveBuildNumber(versionCode: string | number | null): string | undefined { +function resolveBuildNumber(versionCode: string | number | null, buildFile: string): string | undefined { const text = versionCode?.toString(); if (!text) return undefined; // A non-numeric value is a reference to a Gradle property (e.g. "project.versionCode"). - return (/^\d+$/.test(text) ? text : lookupGradleProperty(text.replace("project.", ""))) || undefined; -} - -function lookupGradleProperty(key: string): string | undefined { - const files = [path.join("android", "app", "gradle.properties"), path.join("android", "gradle.properties")]; - for (const file of files) { - if (!fileExists(file)) continue; - // The properties parser type-converts values (e.g. "2" becomes a number) — normalize back to string. - const value = properties.parse(fs.readFileSync(file, "utf8"))?.[key]?.toString(); - if (value) return value; - } + return (/^\d+$/.test(text) ? text : readGradleProperty(text.replace("project.", ""), buildFile)) || undefined; } diff --git a/script/utils/hermes-utils.ts b/script/utils/hermes-utils.ts new file mode 100644 index 0000000..4494fc2 --- /dev/null +++ b/script/utils/hermes-utils.ts @@ -0,0 +1,205 @@ +// Whether Hermes is enabled, which hermesc React Native's own build uses, and its bytecode version. + +import * as childProcess from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import * as chalk from "chalk"; +import { coerce, gte } from "semver"; + +import * as cli from "../types/cli"; +import { log } from "../command-executor"; +import { fileDoesNotExistOrIsDirectory, fileExists } from "./file-utils"; +import { getGradleHermesCommand, getGradleHermesEnabled, readGradleProperty } from "./gradle-utils"; +import { getReactNativePackagePath, getReactNativeVersion, getXcodeDotEnvValue } from "./react-native-project-utils"; + +const OS_BIN = ({ win32: "win64-bin", darwin: "osx-bin" } as Record)[process.platform] ?? "linux64-bin"; + +function exe(name: string): string { + return process.platform === "win32" ? `${name}.exe` : name; +} + +export async function isHermesEnabled(command: cli.IReleaseReactCommand, platform = command.platform.toLowerCase()): Promise { + if (command.useHermes) return true; + if (platform === "android") return (await getGradleHermesEnabled(command.gradleFile)) ?? isHermesDefaultEngine(); + if (platform === "ios") { + return getXcodeProjectHermesEnabled(command.podFile) ?? getPodfileHermesEnabled(command.podFile) ?? isHermesDefaultEngine(); + } + return false; +} + +// Hermes is the default engine since React Native 0.70 +function isHermesDefaultEngine(): boolean { + const reactNativeVersion = coerce(getReactNativeVersion()); + return !!reactNativeVersion && gte(reactNativeVersion, "0.70.0"); +} + +// pod install writes the USE_HERMES build setting that react-native-xcode.sh checks into the app's Xcode project +function getXcodeProjectHermesEnabled(podFile?: string): boolean | undefined { + const iosDir = path.dirname(podFile || path.join("ios", "Podfile")); + const projects = fs.existsSync(iosDir) ? fs.readdirSync(iosDir).filter((file) => file.endsWith(".xcodeproj")) : []; + for (const project of projects) { + const pbxproj = path.join(iosDir, project, "project.pbxproj"); + const match = fileExists(pbxproj) && /\bUSE_HERMES = "?(true|false)"?;/.exec(fs.readFileSync(pbxproj, "utf8")); + if (match) return match[1] === "true"; + } + return undefined; +} + +// `:hermes_enabled => true/false` in the Podfile; undefined if unset +function getPodfileHermesEnabled(podFile?: string): boolean | undefined { + const podfilePath = podFile || path.join("ios", "Podfile"); + if (fileDoesNotExistOrIsDirectory(podfilePath)) { + if (podFile) throw new Error(`Unable to find Podfile file "${podfilePath}".`); + return undefined; + } + const match = /^[^#\n]*:?hermes_enabled\s*(?:=>|:)\s*(true|false)/m.exec(fs.readFileSync(podfilePath, "utf8")); + return match ? match[1] === "true" : undefined; +} + +/** + * Finds the hermesc React Native's own build uses, in the same order: + * Android: gradle-plugin PathUtils.detectOSAwareHermesCommand; iOS: scripts/react-native-xcode.sh. + */ +export async function findHermesc(platform: string, gradleFile?: string, podFile?: string): Promise { + const configured = platform === "ios" ? getConfiguredIosHermesc(podFile) : await getConfiguredAndroidHermesc(gradleFile); + if (configured) return configured; + + const reactNativePath = getReactNativePackagePath(); + // RN <= 0.82 ships sdks/hermesc, RN 0.83+ only hermes-compiler. RN 0.82's hermes-compiler is an empty placeholder + // unless the app opts into Hermes V1, which Android also has to enable with hermesV1Enabled. + const bundledHermesc = path.join(reactNativePath, "sdks", "hermesc", OS_BIN, exe("hermesc")); + const hermesCompiler = resolvePackageFile("hermes-compiler", reactNativePath, "hermesc", OS_BIN, exe("hermesc")); + const preferBundled = platform === "android" && readGradleProperty("hermesV1Enabled", gradleFile) !== "true"; + const packagedHermesc = preferBundled ? [bundledHermesc, hermesCompiler] : [hermesCompiler, bundledHermesc]; + + const candidates = [ + platform === "ios" ? getCocoaPodsHermesc(podFile) : getSourceBuiltAndroidHermesc(reactNativePath), + ...packagedHermesc, + // RN < 0.69 shipped hermesc in the hermes-engine package (named "hermes" before RN 0.64) or in hermesvm + resolvePackageFile("hermes-engine", reactNativePath, OS_BIN, exe("hermesc")), + resolvePackageFile("hermes-engine", reactNativePath, OS_BIN, exe("hermes")), + resolvePackageFile("hermesvm", reactNativePath, OS_BIN, exe("hermes")), + ]; + + const hermesc = candidates.find((candidate) => candidate && fileExists(candidate)); + if (hermesc) return hermesc; + + const setting = platform === "ios" ? "HERMES_CLI_PATH" : "react.hermesCommand"; + throw new Error(`Unable to find hermesc. Set ${setting} to the path of the hermesc binary.`); +} + +async function getConfiguredAndroidHermesc(gradleFile?: string): Promise { + try { + return (await getGradleHermesCommand(gradleFile))?.replace("%OS-BIN%", OS_BIN); + } catch (error) { + log(chalk.yellow(`\nWarning: Unable to read hermesCommand from Gradle, using the default hermesc. ${error.message}\n`)); + return undefined; + } +} + +// react-native-xcode.sh gets HERMES_CLI_PATH from .xcode.env(.local), the environment, or the Pods xcconfig, +// where the hermes-engine pod writes it at pod install. Values may reference $PODS_ROOT or other variables. +function getConfiguredIosHermesc(podFile?: string): string | undefined { + const hermesCliPath = getXcodeDotEnvValue("HERMES_CLI_PATH") ?? getPodsXcconfigValue("HERMES_CLI_PATH", podFile); + const variables: Record = { PODS_ROOT: getPodsDir(podFile), ...process.env }; + const expanded = hermesCliPath?.replace( + /\$(?:\{(\w+)\}|\((\w+)\)|(\w+))/g, + (match, braced, parenthesized, bare) => variables[braced ?? parenthesized ?? bare] ?? match + ); + if (expanded?.includes("$")) { + log(chalk.yellow(`\nWarning: Unable to resolve HERMES_CLI_PATH "${hermesCliPath}", using the default hermesc.\n`)); + return undefined; + } + return expanded; +} + +function getPodsXcconfigValue(key: string, podFile?: string): string | undefined { + const supportFilesDir = path.join(getPodsDir(podFile), "Target Support Files"); + const targetDirs = fs.existsSync(supportFilesDir) ? fs.readdirSync(supportFilesDir).filter((dir) => dir.startsWith("Pods-")) : []; + for (const targetDir of targetDirs) { + const xcconfigs = fs.readdirSync(path.join(supportFilesDir, targetDir)).filter((file) => file.endsWith(".xcconfig")); + for (const xcconfig of xcconfigs) { + const contents = fs.readFileSync(path.join(supportFilesDir, targetDir, xcconfig), "utf8"); + const value = new RegExp(`^${key}\\s*=\\s*(.+)$`, "m").exec(contents)?.[1].trim(); + if (value) return value; + } + } + return undefined; +} + +function getPodsDir(podFile?: string): string { + return path.join(path.dirname(podFile || path.join("ios", "Podfile")), "Pods"); +} + +function getCocoaPodsHermesc(podFile?: string): string { + return path.join(getPodsDir(podFile), "hermes-engine", "destroot", "bin", "hermesc"); +} + +function getSourceBuiltAndroidHermesc(reactNativePath: string): string { + const overrideHermesDir = process.env.REACT_NATIVE_OVERRIDE_HERMES_DIR; + return overrideHermesDir + ? path.join(overrideHermesDir, "build", "bin", exe("hermesc")) + : path.join(reactNativePath, "ReactAndroid", "hermes-engine", "build", "hermes", "bin", exe("hermesc")); +} + +// Resolved from react-native, as they may not be hoisted +function resolvePackageFile(packageName: string, reactNativePath: string, ...segments: string[]): string | undefined { + try { + const packageJson = require.resolve(`${packageName}/package.json`, { paths: [reactNativePath] }); + return path.join(path.dirname(packageJson), ...segments); + } catch { + return undefined; + } +} + +// Hermes bytecode header (BytecodeFileFormat.h): uint64 magic, uint32 version, little-endian; stable since Hermes v0.1 +const BYTECODE_MAGIC = BigInt("0x1F1903C103BC1FC6"); +const BYTECODE_HEADER_SIZE = 12; + +function readBytecodeVersion(bytecode: Buffer): number | undefined { + const isHermesBytecode = bytecode.length >= BYTECODE_HEADER_SIZE && bytecode.readBigUInt64LE(0) === BYTECODE_MAGIC; + return isHermesBytecode ? bytecode.readUInt32LE(8) : undefined; +} + +function readFileHeader(file: string, size: number): Buffer { + const header = Buffer.alloc(size); + const fd = fs.openSync(file, "r"); + try { + const bytesRead = fs.readSync(fd, header, 0, size, 0); + return header.subarray(0, bytesRead); + } finally { + fs.closeSync(fd); + } +} + +/** Compiles an empty input to stdout and reads the bytecode version from the emitted header. */ +export function getHermescBytecodeVersion(hermesc: string): number | undefined { + const result = childProcess.spawnSync(hermesc, ["-emit-binary", "-"], { input: "", timeout: 30000 }); + return result.status === 0 ? readBytecodeVersion(result.stdout) : undefined; +} + +/** hermesc -base-bytecode aborts on a base of another bytecode version. The base only optimizes diffs, so it can be skipped. */ +export function isBaseBytecodeCompatible(baseBytecode: string, hermesc: string): boolean { + const baseVersion = readBytecodeVersion(readFileHeader(baseBytecode, BYTECODE_HEADER_SIZE)); + if (baseVersion === undefined) { + log(chalk.yellow("\nBase release bundle is not Hermes bytecode, skipping -base-bytecode.\n")); + return false; + } + + const hermescVersion = getHermescBytecodeVersion(hermesc); + if (hermescVersion === undefined) { + log(chalk.yellow("\nCould not detect the Hermes compiler bytecode version, skipping -base-bytecode.\n")); + return false; + } + + if (baseVersion !== hermescVersion) { + log( + chalk.yellow( + `\nWarning: base release is Hermes bytecode v${baseVersion}, local compiler produces v${hermescVersion}. ` + + `Skipping -base-bytecode. Binaries on v${baseVersion} can't run this update.\n` + ) + ); + return false; + } + return true; +} diff --git a/script/utils/react-native-project-utils.ts b/script/utils/react-native-project-utils.ts new file mode 100644 index 0000000..d04f3fe --- /dev/null +++ b/script/utils/react-native-project-utils.ts @@ -0,0 +1,48 @@ +import * as childProcess from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import * as dotenv from "dotenv"; + +export function getReactNativePackagePath(): string { + const result = childProcess.spawnSync("node", ["--print", "require.resolve('react-native/package.json')"]); + const packagePath = path.dirname(result.stdout.toString()); + if (result.status === 0 && directoryExistsSync(packagePath)) { + return packagePath; + } + + return path.join("node_modules", "react-native"); +} + +function directoryExistsSync(dirname: string): boolean { + try { + return fs.statSync(dirname).isDirectory(); + } catch (err) { + if (err.code !== "ENOENT") { + throw err; + } + } + return false; +} + +export function getReactNativeVersion(): string { + try { + const result = childProcess.spawnSync("node", ["--print", "require('react-native/package.json').version"]); + return result.stdout.toString().trim(); + } catch (error) { + throw new Error( + 'Unable to resolve "react-native". Please make sure it is installed in your project (e.g. "npm install react-native").' + ); + } +} + +/** A value react-native-xcode.sh sees: ios/.xcode.env.local overrides ios/.xcode.env, which overrides the environment. */ +export function getXcodeDotEnvValue(key: string): string | undefined { + for (const envFile of [".xcode.env.local", ".xcode.env"]) { + const envPath = path.join("ios", envFile); + const value = fs.existsSync(envPath) ? dotenv.parse(fs.readFileSync(envPath))[key] : undefined; + if (value !== undefined) { + return value; + } + } + return process.env[key]; +} diff --git a/test/hermes-bytecode.ts b/test/hermes-bytecode.ts index 6d89758..d0c83d2 100644 --- a/test/hermes-bytecode.ts +++ b/test/hermes-bytecode.ts @@ -4,7 +4,7 @@ import * as os from "os"; import * as path from "path"; import * as sinon from "sinon"; import * as cmdexec from "../script/command-executor"; -import { getHermesCompilerBytecodeVersion, resolveHermesBaseBytecode } from "../script/react-native-utils"; +import { getHermescBytecodeVersion, isBaseBytecodeCompatible } from "../script/utils/hermes-utils"; function hermesHeader(version: number): Buffer { const header = Buffer.alloc(128); @@ -24,12 +24,17 @@ describe("Hermes base bytecode", () => { return bundle; } + // A stand-in hermesc: a node script whose stdout is what `hermesc -emit-binary -` would print function createCompiler(script: string): string { const compiler = path.join(testDirectory, "hermesc"); fs.writeFileSync(compiler, `#!/usr/bin/env node\n${script}\n`, { mode: 0o755 }); return compiler; } + function compilerEmitting(version: number): string { + return createCompiler(`process.stdout.write(Buffer.from("${hermesHeader(version).toString("hex")}", "hex"));`); + } + beforeEach(() => { testDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "revopush-hermes-")); sandbox = sinon.createSandbox(); @@ -42,34 +47,32 @@ describe("Hermes base bytecode", () => { }); it("reads the compiler bytecode version from the bytecode it emits", () => { - const compiler = createCompiler(`process.stdout.write(Buffer.from("${hermesHeader(98).toString("hex")}", "hex"));`); - assert.strictEqual(getHermesCompilerBytecodeVersion(compiler), 98); + assert.strictEqual(getHermescBytecodeVersion(compilerEmitting(98)), 98); }); - it("returns null when the compiler fails or emits something other than Hermes bytecode", () => { - assert.strictEqual(getHermesCompilerBytecodeVersion(createCompiler("process.exit(1);")), null); - assert.strictEqual(getHermesCompilerBytecodeVersion(createCompiler(`process.stdout.write("var a;");`)), null); - assert.strictEqual(getHermesCompilerBytecodeVersion(path.join(testDirectory, "missing-hermesc")), null); + it("returns undefined when the compiler fails or emits something other than Hermes bytecode", () => { + assert.strictEqual(getHermescBytecodeVersion(createCompiler("process.exit(1);")), undefined); + assert.strictEqual(getHermescBytecodeVersion(createCompiler(`process.stdout.write("var a;");`)), undefined); + assert.strictEqual(getHermescBytecodeVersion(path.join(testDirectory, "missing-hermesc")), undefined); }); - it("keeps the base bytecode when versions match", () => { - const bundle = createBundle(hermesHeader(96)); - assert.strictEqual(resolveHermesBaseBytecode(bundle, 96), bundle); + it("uses the base bytecode when versions match", () => { + assert.strictEqual(isBaseBytecodeCompatible(createBundle(hermesHeader(96)), compilerEmitting(96)), true); sinon.assert.notCalled(log); }); it("skips the base bytecode and warns when versions differ", () => { - const bundle = createBundle(hermesHeader(96)); - assert.strictEqual(resolveHermesBaseBytecode(bundle, 98), null); + assert.strictEqual(isBaseBytecodeCompatible(createBundle(hermesHeader(96)), compilerEmitting(98)), false); sinon.assert.calledWithMatch(log, sinon.match(/v96.*v98/s)); }); it("skips the base bytecode when the compiler version is unknown", () => { - assert.strictEqual(resolveHermesBaseBytecode(createBundle(hermesHeader(96)), null), null); + assert.strictEqual(isBaseBytecodeCompatible(createBundle(hermesHeader(96)), createCompiler("process.exit(1);")), false); }); it("skips the base bytecode when the base is not Hermes bytecode", () => { - assert.strictEqual(resolveHermesBaseBytecode(createBundle("__d(function(){});"), 96), null); - assert.strictEqual(resolveHermesBaseBytecode(createBundle(Buffer.from([0xc6, 0x1f])), 96), null); + const compiler = compilerEmitting(96); + assert.strictEqual(isBaseBytecodeCompatible(createBundle("__d(function(){});"), compiler), false); + assert.strictEqual(isBaseBytecodeCompatible(createBundle(Buffer.from([0xc6, 0x1f])), compiler), false); }); }); diff --git a/test/hermes-command.ts b/test/hermes-command.ts index 6a2fe48..9cfdeab 100644 --- a/test/hermes-command.ts +++ b/test/hermes-command.ts @@ -4,7 +4,8 @@ import * as os from "os"; import * as path from "path"; import * as sinon from "sinon"; import * as cmdexec from "../script/command-executor"; -import { getHermesCommand } from "../script/react-native-utils"; +import { findHermesc, isHermesEnabled } from "../script/utils/hermes-utils"; +import { getAndroidVersionInfo } from "../script/utils/gradle-utils"; const OS_BIN = process.platform === "darwin" ? "osx-bin" : process.platform === "win32" ? "win64-bin" : "linux64-bin"; const HERMESC = process.platform === "win32" ? "hermesc.exe" : "hermesc"; @@ -32,7 +33,6 @@ describe("Hermes command resolution", () => { originalEnv = { ...process.env }; delete process.env.HERMES_CLI_PATH; delete process.env.REACT_NATIVE_OVERRIDE_HERMES_DIR; - delete process.env.RCT_HERMES_V1_ENABLED; projectDirectory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "revopush-hermesc-"))); createFile("node_modules/react-native/package.json", JSON.stringify({ name: "react-native", version: "0.83.0" })); createFile("android/app/build.gradle", "react {\n}\n"); @@ -52,28 +52,34 @@ describe("Hermes command resolution", () => { it("resolves a literal hermesCommand from the react block against the project root", async () => { bundledHermesc(); createFile("android/app/build.gradle", 'react {\n hermesCommand = "custom/%OS-BIN%/hermesc"\n}\n'); - assert.strictEqual( - await getHermesCommand("android", undefined, undefined), - path.join(projectDirectory, "custom", OS_BIN, "hermesc") - ); + assert.strictEqual(await findHermesc("android"), path.join(projectDirectory, "custom", OS_BIN, "hermesc")); }); it("expands $rootDir in hermesCommand", async () => { bundledHermesc(); createFile("android/app/build.gradle", 'react {\n hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"\n}\n'); - assert.strictEqual( - await getHermesCommand("android", undefined, undefined), - path.join(projectDirectory, "android", "my-custom-hermesc", "bin", "hermesc") - ); + assert.strictEqual(await findHermesc("android"), path.join(projectDirectory, "android", "my-custom-hermesc", "bin", "hermesc")); }); it("resolves a legacy project.ext.react hermesCommand against the app module", async () => { bundledHermesc(); createFile("android/app/build.gradle", 'project.ext.react = [\n hermesCommand: "../../custom/%OS-BIN%/hermesc"\n]\n'); - assert.strictEqual( - await getHermesCommand("android", undefined, undefined), - path.join(projectDirectory, "custom", OS_BIN, "hermesc") + assert.strictEqual(await findHermesc("android"), path.join(projectDirectory, "custom", OS_BIN, "hermesc")); + }); + + it("reads legacy project.ext.react entries that follow a trailing comment", async () => { + bundledHermesc(); + createFile( + "android/app/build.gradle", + 'project.ext.react = [\n enableHermes: true, // clean and rebuild if changing\n hermesCommand: "../../custom/%OS-BIN%/hermesc"\n]\n' ); + assert.strictEqual(await findHermesc("android"), path.join(projectDirectory, "custom", OS_BIN, "hermesc")); + }); + + it("resolves hermesCommand against a literal react.root", async () => { + bundledHermesc(); + createFile("android/app/build.gradle", 'react {\n root = file("../../app-root")\n hermesCommand = "custom/hermesc"\n}\n'); + assert.strictEqual(await findHermesc("android"), path.join(projectDirectory, "app-root", "custom", "hermesc")); }); describe("Kotlin DSL", () => { @@ -90,17 +96,14 @@ describe("Hermes command resolution", () => { bundledHermesc(); createGradlew(`echo '{"hermesCommand":"custom/%OS-BIN%/hermesc","root":"${projectDirectory}"}'`); createFile("android/app/build.gradle.kts", 'react {\n hermesCommand = "custom/%OS-BIN%/hermesc"\n}\n'); - assert.strictEqual( - await getHermesCommand("android", undefined, undefined), - path.join(projectDirectory, "custom", OS_BIN, "hermesc") - ); + assert.strictEqual(await findHermesc("android"), path.join(projectDirectory, "custom", OS_BIN, "hermesc")); }); it("does not run Gradle when the build script doesn't set hermesCommand", async () => { const bundled = bundledHermesc(); createGradlew("exit 1"); createFile("android/app/build.gradle.kts", 'react {\n // hermesCommand = "custom/hermesc"\n}\n'); - assert.strictEqual(fs.realpathSync(await getHermesCommand("android", undefined, undefined)), bundled); + assert.strictEqual(fs.realpathSync(await findHermesc("android")), bundled); assert.strictEqual(fs.existsSync(gradleRun()), false); }); @@ -110,7 +113,7 @@ describe("Hermes command resolution", () => { try { createGradlew("exit 1"); createFile("android/app/build.gradle.kts", 'react {\n hermesCommand = "custom/hermesc"\n}\n'); - assert.strictEqual(fs.realpathSync(await getHermesCommand("android", undefined, undefined)), bundled); + assert.strictEqual(fs.realpathSync(await findHermesc("android")), bundled); sinon.assert.calledWithMatch(log, sinon.match(/Gradle failed/)); } finally { log.restore(); @@ -124,55 +127,153 @@ describe("Hermes command resolution", () => { "android/app/build.gradle", 'react {\n hermesCommand = new File(["node", "--print", "require.resolve(\'react-native/package.json\')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"\n}\n' ); - assert.strictEqual(fs.realpathSync(await getHermesCommand("android", undefined, undefined)), expected); + assert.strictEqual(fs.realpathSync(await findHermesc("android")), expected); + }); + + it("ignores Expo SDK 55's hermesCommand expression without a warning", async () => { + const expected = hermesCompiler(); + createFile( + "android/app/build.gradle", + 'react {\n hermesCommand = new File(["node", "--print", "require.resolve(\'hermes-compiler/package.json\', { paths: [require.resolve(\'react-native/package.json\')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/hermesc/%OS-BIN%/hermesc"\n}\n' + ); + const log = sinon.stub(cmdexec, "log"); + try { + assert.strictEqual(fs.realpathSync(await findHermesc("android")), expected); + sinon.assert.notCalled(log); + } finally { + log.restore(); + } }); it("prefers the bundled hermesc unless Hermes V1 is enabled", async () => { const bundled = bundledHermesc(); const compiler = hermesCompiler(); - assert.strictEqual(fs.realpathSync(await getHermesCommand("android", undefined, undefined)), bundled); + assert.strictEqual(fs.realpathSync(await findHermesc("android")), bundled); createFile("android/gradle.properties", "hermesEnabled=true\nhermesV1Enabled=true\n"); - assert.strictEqual(fs.realpathSync(await getHermesCommand("android", undefined, undefined)), compiler); + assert.strictEqual(fs.realpathSync(await findHermesc("android")), compiler); }); it("resolves hermes-compiler through react-native when it is not hoisted", async () => { const compiler = hermesCompiler("node_modules/react-native/node_modules"); - assert.strictEqual(fs.realpathSync(await getHermesCommand("android", undefined, undefined)), compiler); + assert.strictEqual(fs.realpathSync(await findHermesc("android")), compiler); }); it("uses hermesc built from source via REACT_NATIVE_OVERRIDE_HERMES_DIR", async () => { bundledHermesc(); const built = createFile(`hermes/build/bin/${HERMESC}`); process.env.REACT_NATIVE_OVERRIDE_HERMES_DIR = path.join(projectDirectory, "hermes"); - assert.strictEqual(await getHermesCommand("android", undefined, undefined), built); + assert.strictEqual(await findHermesc("android"), built); }); it("uses HERMES_CLI_PATH, then the CocoaPods hermesc, on iOS", async () => { hermesCompiler(); const podHermesc = createFile("ios/Pods/hermes-engine/destroot/bin/hermesc"); - assert.strictEqual(fs.realpathSync(await getHermesCommand("ios", undefined, undefined)), podHermesc); + assert.strictEqual(fs.realpathSync(await findHermesc("ios")), podHermesc); process.env.HERMES_CLI_PATH = "/custom/hermesc"; - assert.strictEqual(await getHermesCommand("ios", undefined, undefined), "/custom/hermesc"); + assert.strictEqual(await findHermesc("ios"), "/custom/hermesc"); }); - it("prefers the bundled hermesc on iOS without CocoaPods unless Hermes V1 is enabled", async () => { + it("prefers hermes-compiler on iOS without CocoaPods, skipping RN 0.82's empty placeholder package", async () => { const bundled = bundledHermesc(); - const compiler = hermesCompiler(); - assert.strictEqual(fs.realpathSync(await getHermesCommand("ios", undefined, undefined)), bundled); + createFile("node_modules/hermes-compiler/package.json", JSON.stringify({ name: "hermes-compiler", version: "0.0.0" })); + assert.strictEqual(fs.realpathSync(await findHermesc("ios")), bundled); - process.env.RCT_HERMES_V1_ENABLED = "1"; - assert.strictEqual(fs.realpathSync(await getHermesCommand("ios", undefined, undefined)), compiler); + const compiler = hermesCompiler(); + assert.strictEqual(fs.realpathSync(await findHermesc("ios")), compiler); }); it("falls back to hermes-compiler on iOS when react-native has no bundled hermesc", async () => { const compiler = hermesCompiler(); - assert.strictEqual(fs.realpathSync(await getHermesCommand("ios", undefined, undefined)), compiler); + assert.strictEqual(fs.realpathSync(await findHermesc("ios")), compiler); }); it("reads HERMES_CLI_PATH from ios/.xcode.env.local", async () => { createFile("ios/.xcode.env.local", "export HERMES_CLI_PATH=/xcode/hermesc\n"); - assert.strictEqual(await getHermesCommand("ios", undefined, undefined), "/xcode/hermesc"); + assert.strictEqual(await findHermesc("ios"), "/xcode/hermesc"); + }); + + it("uses HERMES_CLI_PATH written into the Pods xcconfig by pod install", async () => { + createFile("ios/Pods/hermes-engine/destroot/bin/hermesc"); + createFile( + "ios/Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig", + "PODS_ROOT = ${SRCROOT}/Pods\nHERMES_CLI_PATH = $(PODS_ROOT)/../../node_modules/hermes-compiler/hermesc/osx-bin/hermesc\n" + ); + assert.strictEqual(path.resolve(await findHermesc("ios")), path.resolve("node_modules/hermes-compiler/hermesc/osx-bin/hermesc")); + + createFile("ios/.xcode.env", "export HERMES_CLI_PATH=/xcode/hermesc\n"); + assert.strictEqual(await findHermesc("ios"), "/xcode/hermesc"); + }); + + it("expands $PODS_ROOT in HERMES_CLI_PATH", async () => { + createFile("ios/.xcode.env", 'export HERMES_CLI_PATH="$PODS_ROOT/custom/hermesc"\n'); + assert.strictEqual(await findHermesc("ios"), path.join("ios", "Pods", "custom", "hermesc")); + }); + + it("finds the hermes binary of RN < 0.64's hermes-engine package", async () => { + createFile("node_modules/hermes-engine/package.json", JSON.stringify({ name: "hermes-engine", version: "0.4.0" })); + const hermes = createFile(`node_modules/hermes-engine/${OS_BIN}/${process.platform === "win32" ? "hermes.exe" : "hermes"}`); + assert.strictEqual(fs.realpathSync(await findHermesc("android")), hermes); + }); + + it("fails with the setting to use when no hermesc is found", async () => { + await assert.rejects(findHermesc("android"), /Set react\.hermesCommand/); + await assert.rejects(findHermesc("ios"), /Set HERMES_CLI_PATH/); + }); + + describe("Hermes enabled detection", () => { + const command = (platform: string): any => ({ platform }); + + it("reads hermesEnabled from gradle.properties", async () => { + createFile("android/gradle.properties", "hermesEnabled=false\n"); + assert.strictEqual(await isHermesEnabled(command("android")), false); + }); + + it("reads enableHermes after a commented project.ext.react entry", async () => { + createFile("android/gradle.properties", ""); + createFile("node_modules/react-native/package.json", JSON.stringify({ name: "react-native", version: "0.68.0" })); + createFile( + "android/app/build.gradle", + 'project.ext.react = [\n entryFile: "index.js", // entry\n enableHermes: true\n]\n' + ); + assert.strictEqual(await isHermesEnabled(command("android")), true); + }); + + it("reads enableHermes from RN < 0.71's project.ext.react", async () => { + createFile("android/gradle.properties", ""); + createFile("android/app/build.gradle", "project.ext.react = [\n enableHermes: false\n]\n"); + assert.strictEqual(await isHermesEnabled(command("android")), false); + }); + + it("reads hermes_enabled from the Podfile, ignoring comments", async () => { + createFile("ios/Podfile", "# :hermes_enabled => true\nuse_react_native!(\n :hermes_enabled => false\n)\n"); + assert.strictEqual(await isHermesEnabled(command("ios")), false); + }); + + it("reads the scoped react.hermesEnabled property", async () => { + createFile("android/gradle.properties", "react.hermesEnabled=false\n"); + assert.strictEqual(await isHermesEnabled(command("android")), false); + }); + + it("reads the USE_HERMES build setting pod install writes into the Xcode project", async () => { + createFile("ios/Podfile", "use_react_native!(\n :hermes_enabled => true\n)\n"); + createFile("ios/App.xcodeproj/project.pbxproj", "buildSettings = {\n\t\t\t\tUSE_HERMES = false;\n};\n"); + assert.strictEqual(await isHermesEnabled(command("ios")), false); + }); + + it("defaults to Hermes on RN 0.70+", async () => { + createFile("android/gradle.properties", ""); + assert.strictEqual(await isHermesEnabled(command("android")), true); + }); + }); + + it("resolves a versionName that references a Gradle property", async () => { + createFile("android/gradle.properties", "appVersion=1.2.3\n"); + createFile( + "android/app/build.gradle", + "android {\n defaultConfig {\n versionName project.appVersion\n versionCode 7\n }\n}\n" + ); + assert.deepStrictEqual(await getAndroidVersionInfo(undefined), { appVersion: "1.2.3", buildNumber: "7" }); }); }); From 61cdda4992caf68bc64001c7f62a2d1966398c11 Mon Sep 17 00:00:00 2001 From: Revopush Date: Sat, 10 Oct 2026 22:26:10 +0300 Subject: [PATCH 4/8] Read hermesCommand with a single literal match Read a literal hermesCommand from the build script text for both Groovy and Kotlin DSL instead of parsing it with gradle-to-js or running Gradle. Computed values, a custom react.root and RN < 0.71's project.ext.react hermesCommand now fall back to the default hermesc lookup. Kotlin version detection is back to main's implementation. Shorten comments. Co-Authored-By: Claude Opus 5.5 --- script/react-native-utils.ts | 8 +- script/utils/gradle-utils.ts | 114 ++++++--------------- script/utils/hermes-utils.ts | 40 +++----- script/utils/react-native-project-utils.ts | 2 +- test/hermes-command.ts | 61 ++--------- 5 files changed, 59 insertions(+), 166 deletions(-) diff --git a/script/react-native-utils.ts b/script/react-native-utils.ts index 45625b1..eee87dc 100644 --- a/script/react-native-utils.ts +++ b/script/react-native-utils.ts @@ -21,11 +21,11 @@ export function getBundleSourceMapOutput( ): string { switch (platform) { case "android": { - // With Hermes, Metro's map is the "packager" one (BundleHermesCTask.resolvePackagerSourceMapFile) + // With Hermes, Metro's map is the "packager" one (BundleHermesCTask) return path.join(sourcemapOutputFolder, bundleName + (isHermes ? ".packager.map" : ".map")); } case "ios": { - // Named like react-native-xcode.sh does, since the name ends up in the bundle's sourceMappingURL + // Same name as react-native-xcode.sh, since it ends up in the bundle's sourceMappingURL const sourceMapFile = getXcodeDotEnvValue("SOURCEMAP_FILE"); return path.join(sourcemapOutputFolder, sourceMapFile ? path.basename(sourceMapFile) : bundleName + ".map"); } @@ -102,7 +102,7 @@ export async function runHermesEmitBinaryCommand( } } -// Packager + Hermes source maps = final source map, as react-native-xcode.sh and the RN Gradle plugin do +// Packager + Hermes source maps = final source map, as RN's build does async function composeHermesSourceMaps( command: cli.IReleaseReactCommand, bundleName: string, @@ -127,7 +127,7 @@ async function composeHermesSourceMaps( } await runCommand("compose-source-maps", "node", [composeSourceMaps, packagerSourceMap, hermesSourceMap, "-o", combinedSourceMap]); - // Otherwise it would be released too + // Not part of the release await fs.promises.unlink(hermesSourceMap); } diff --git a/script/utils/gradle-utils.ts b/script/utils/gradle-utils.ts index 88bfcba..a3661ef 100644 --- a/script/utils/gradle-utils.ts +++ b/script/utils/gradle-utils.ts @@ -1,5 +1,5 @@ -// Reads the Android app's version and Hermes settings. Groovy DSL is parsed statically; Kotlin DSL is evaluated -// by running the Gradle wrapper with an injected task that prints the values. +// Reads the Android app's version and Hermes settings. Groovy DSL is parsed statically; Kotlin DSL versions are +// evaluated by running the Gradle wrapper with an injected task that prints them. import * as childProcess from "child_process"; import * as fs from "fs"; @@ -30,38 +30,29 @@ interface GradleVersionFields { const DEFAULT_APP_DIR = path.join("android", "app"); const FALLBACK_HINT = "Pass the version explicitly with --targetBinaryVersion to skip Gradle detection."; -// Groovy init scripts work for projects of either DSL -const PRINT_TASK = "_revopushPrint"; -const printInitScript = (moduleName: string, printJson: string) => ` +// "revopush"-prefixed to avoid collisions with project tasks; written in Groovy, +// which Gradle accepts as an init script for projects of either DSL. +const PRINT_VERSION_TASK = "_revopushPrintVersion"; +const printVersionInitScript = (moduleName: string) => ` allprojects { afterEvaluate { proj -> if (proj.name == '${moduleName}') { - task ${PRINT_TASK} { + task ${PRINT_VERSION_TASK} { doLast { - ${printJson} - } - } - } - } -} -`.trim(); - -const PRINT_VERSION = ` def android = proj.extensions.findByName('android') if (android == null) { - throw new GradleException("Project ':\${proj.name}' does not apply the Android Gradle plugin (no 'android' extension found).") + throw new GradleException("Project ':${moduleName}' does not apply the Android Gradle plugin (no 'android' extension found).") } println groovy.json.JsonOutput.toJson([ versionName: android.defaultConfig.versionName, versionCode: android.defaultConfig.versionCode?.toString() - ])`; - -const PRINT_REACT_CONFIG = ` - def react = proj.extensions.findByName('react') - println groovy.json.JsonOutput.toJson([ - hermesCommand: react?.hermesCommand?.getOrElse(''), - root: react?.root?.get()?.asFile?.absolutePath - ])`; + ]) + } + } + } + } +} +`.trim(); // Every `gradleFile` parameter below is the app's build script or its directory; defaults to "android/app". @@ -85,7 +76,7 @@ export async function getAndroidVersionInfo(gradleFile?: string | null): Promise return { appVersion, buildNumber }; } -/** `(react.)hermesEnabled` from gradle.properties, or `enableHermes` from RN < 0.71's `project.ext.react`; undefined if unset. */ +/** `hermesEnabled` / `react.hermesEnabled`, or RN < 0.71's `enableHermes`; undefined if unset */ export async function getGradleHermesEnabled(gradleFile?: string | null): Promise { let enabled = readGradleProperty("hermesEnabled", gradleFile) ?? readGradleProperty("react.hermesEnabled", gradleFile); const buildFile = findGradleBuildFile(gradleFile); @@ -96,14 +87,16 @@ export async function getGradleHermesEnabled(gradleFile?: string | null): Promis return enabled === undefined ? undefined : enabled.toLowerCase() === "true"; } -/** - * The app's `react { hermesCommand }` (or RN < 0.71's `project.ext.react`) as an absolute path, resolved from the - * directory the RN Gradle plugin runs hermesc in. It may still contain the `%OS-BIN%` placeholder. - */ -export async function getGradleHermesCommand(gradleFile?: string | null): Promise { +/** A literal `hermesCommand`, resolved from the project root like the RN Gradle plugin; expressions are ignored */ +export function getGradleHermesCommand(gradleFile?: string | null): string | undefined { const buildFile = findGradleBuildFile(gradleFile); - if (!buildFile) return undefined; - return isKotlinDsl(buildFile) ? evaluateKotlinDslHermesCommand(buildFile) : parseGroovyDslHermesCommand(buildFile); + const buildScript = buildFile ? fs.readFileSync(buildFile, "utf8") : ""; + const hermesCommand = /^\s*hermesCommand\s*(?:=|\.set\()\s*["']([^"']+)["']\s*\)?\s*$/m.exec(buildScript)?.[1]; + if (!hermesCommand) return undefined; + + const androidDir = path.resolve(path.dirname(buildFile), ".."); + const expanded = hermesCommand.replace(/^\$\{?rootDir\}?/, androidDir); + return expanded.includes("$") ? undefined : path.resolve(androidDir, "..", expanded); } /** A property from the app module's or the Android project's gradle.properties. */ @@ -140,46 +133,13 @@ function parseGroovyDslBuildFile(buildFile: string): Promise { }); } -// RN < 0.71 configured the app with `project.ext.react = [key: value, ...]`. g2js keeps a trailing `// comment` -// at the start of the next entry (RN's template has `enableHermes: false, // ...`), so strip comments first. +// RN < 0.71's `project.ext.react = [key: value]`. g2js prepends a trailing `// comment` to the next entry, so strip it function getLegacyReactProperty(parsed: any, key: string): string | undefined { const entries: string[] = parsed["project.ext.react"] || []; const entry = entries.map((prop) => prop.replace(/\/\/.*$/gm, "").trim()).find((prop) => prop.startsWith(`${key}:`)); return entry?.slice(key.length + 1).trim().replace(/^["']|["']$/g, ""); } -// The RN Gradle plugin runs hermesc from react.root (default: the project root); RN < 0.71's react.gradle from the app module -async function parseGroovyDslHermesCommand(buildFile: string): Promise { - const gradle = await parseGroovyDslBuildFile(buildFile); - const appDir = path.resolve(path.dirname(buildFile)); - const variables = { rootDir: path.dirname(appDir), projectDir: appDir }; - - if (gradle.react?.hermesCommand) { - const hermesCommand = toLiteralPath(gradle.react.hermesCommand, variables); - const root = toLiteralPath(/^file\(["'](.+)["']\)$/.exec(gradle.react.root ?? "")?.[1], variables) ?? "../.."; - return hermesCommand && path.resolve(appDir, root, hermesCommand); - } - - const legacyHermesCommand = toLiteralPath(getLegacyReactProperty(gradle, "hermesCommand"), variables); - return legacyHermesCommand && path.resolve(appDir, legacyHermesCommand); -} - -// A plain Groovy string with $rootDir / $projectDir expanded; undefined for any other expression, like Expo's -function toLiteralPath(value: unknown, variables: Record): string | undefined { - if (typeof value !== "string") return undefined; - const expanded = value.replace(/\$(?:\{(\w+)\}|(\w+))/g, (match, braced, bare) => variables[braced ?? bare] ?? match); - return /["'$()+]/.test(expanded) ? undefined : expanded; -} - -// Running Gradle is slow, so only evaluate Kotlin DSL scripts that actually set hermesCommand -async function evaluateKotlinDslHermesCommand(buildFile: string): Promise { - if (!/^[^/\n]*\bhermesCommand\b/m.test(fs.readFileSync(buildFile, "utf8"))) { - return undefined; - } - const { hermesCommand, root } = await runGradlePrintTask(buildFile, PRINT_REACT_CONFIG); - return hermesCommand && root ? path.resolve(root, hermesCommand) : undefined; -} - async function parseGroovyDslVersion(buildFile: string): Promise { const parsed = await parseGroovyDslBuildFile(buildFile); // g2js yields an array when the file contains multiple 'android' blocks. @@ -199,35 +159,27 @@ async function parseGroovyDslVersion(buildFile: string): Promise { - try { - return await runGradlePrintTask(buildFile, PRINT_VERSION); - } catch (error) { - throw new Error(`${error.message}\n${FALLBACK_HINT}`); - } -} - -/** Runs the project's Gradle wrapper with an injected task and returns the JSON it prints. */ -async function runGradlePrintTask(buildFile: string, printJson: string): Promise { - // android//build.gradle.kts + // The build file lives in the application module folder (typically android/app); + // its parent is the Gradle project root, and the folder name is the module name. const moduleName = path.basename(path.dirname(path.resolve(buildFile))); const androidDir = path.resolve(buildFile, "..", ".."); const gradlew = path.join(androidDir, process.platform === "win32" ? "gradlew.bat" : "gradlew"); if (!fileExists(gradlew)) { - throw new Error(`No Gradle wrapper found at "${gradlew}", required to evaluate "${buildFile}".`); + throw new Error(`No Gradle wrapper found at "${gradlew}", required to evaluate "${buildFile}". ${FALLBACK_HINT}`); } const initScript = path.join(os.tmpdir(), `revopush-init-${process.pid}.gradle`); - fs.writeFileSync(initScript, printInitScript(moduleName, printJson), "utf8"); + fs.writeFileSync(initScript, printVersionInitScript(moduleName), "utf8"); try { const { stdout } = await exec( - `"${gradlew}" --project-dir "${androidDir}" --init-script "${initScript}" -q :${moduleName}:${PRINT_TASK}`, + `"${gradlew}" --project-dir "${androidDir}" --init-script "${initScript}" -q :${moduleName}:${PRINT_VERSION_TASK}`, { timeout: 120000 } ); - // Configuration output may precede the JSON line + // The task's JSON is the last line; configuration-phase output (plugin notices, printlns) may precede it. return JSON.parse(stdout.trim().split(/\r?\n/).pop() ?? ""); } catch (error) { - throw new Error(`Gradle failed while evaluating "${buildFile}": ${error.message}`); + throw new Error(`Gradle failed while reading the version from "${buildFile}": ${error.message}\n${FALLBACK_HINT}`); } finally { fs.rmSync(initScript, { force: true }); } diff --git a/script/utils/hermes-utils.ts b/script/utils/hermes-utils.ts index 4494fc2..ee4d537 100644 --- a/script/utils/hermes-utils.ts +++ b/script/utils/hermes-utils.ts @@ -1,4 +1,4 @@ -// Whether Hermes is enabled, which hermesc React Native's own build uses, and its bytecode version. +// Hermes detection, hermesc lookup and bytecode version checks import * as childProcess from "child_process"; import * as fs from "fs"; @@ -33,7 +33,7 @@ function isHermesDefaultEngine(): boolean { return !!reactNativeVersion && gte(reactNativeVersion, "0.70.0"); } -// pod install writes the USE_HERMES build setting that react-native-xcode.sh checks into the app's Xcode project +// USE_HERMES build setting written by pod install, checked by react-native-xcode.sh function getXcodeProjectHermesEnabled(podFile?: string): boolean | undefined { const iosDir = path.dirname(podFile || path.join("ios", "Podfile")); const projects = fs.existsSync(iosDir) ? fs.readdirSync(iosDir).filter((file) => file.endsWith(".xcodeproj")) : []; @@ -45,7 +45,7 @@ function getXcodeProjectHermesEnabled(podFile?: string): boolean | undefined { return undefined; } -// `:hermes_enabled => true/false` in the Podfile; undefined if unset +// `:hermes_enabled => true/false` in the Podfile function getPodfileHermesEnabled(podFile?: string): boolean | undefined { const podfilePath = podFile || path.join("ios", "Podfile"); if (fileDoesNotExistOrIsDirectory(podfilePath)) { @@ -56,17 +56,15 @@ function getPodfileHermesEnabled(podFile?: string): boolean | undefined { return match ? match[1] === "true" : undefined; } -/** - * Finds the hermesc React Native's own build uses, in the same order: - * Android: gradle-plugin PathUtils.detectOSAwareHermesCommand; iOS: scripts/react-native-xcode.sh. - */ +/** The hermesc RN's build uses: Gradle plugin PathUtils.detectOSAwareHermesCommand, iOS react-native-xcode.sh */ export async function findHermesc(platform: string, gradleFile?: string, podFile?: string): Promise { - const configured = platform === "ios" ? getConfiguredIosHermesc(podFile) : await getConfiguredAndroidHermesc(gradleFile); + const configured = + platform === "ios" ? getConfiguredIosHermesc(podFile) : getGradleHermesCommand(gradleFile)?.replace("%OS-BIN%", OS_BIN); if (configured) return configured; const reactNativePath = getReactNativePackagePath(); - // RN <= 0.82 ships sdks/hermesc, RN 0.83+ only hermes-compiler. RN 0.82's hermes-compiler is an empty placeholder - // unless the app opts into Hermes V1, which Android also has to enable with hermesV1Enabled. + // RN <= 0.82 ships sdks/hermesc, 0.83+ hermes-compiler. On 0.82, hermes-compiler is an empty placeholder unless + // the app opts into Hermes V1 (Android also needs hermesV1Enabled) const bundledHermesc = path.join(reactNativePath, "sdks", "hermesc", OS_BIN, exe("hermesc")); const hermesCompiler = resolvePackageFile("hermes-compiler", reactNativePath, "hermesc", OS_BIN, exe("hermesc")); const preferBundled = platform === "android" && readGradleProperty("hermesV1Enabled", gradleFile) !== "true"; @@ -75,7 +73,7 @@ export async function findHermesc(platform: string, gradleFile?: string, podFile const candidates = [ platform === "ios" ? getCocoaPodsHermesc(podFile) : getSourceBuiltAndroidHermesc(reactNativePath), ...packagedHermesc, - // RN < 0.69 shipped hermesc in the hermes-engine package (named "hermes" before RN 0.64) or in hermesvm + // RN < 0.69: hermes-engine (binary named "hermes" before 0.64) or hermesvm resolvePackageFile("hermes-engine", reactNativePath, OS_BIN, exe("hermesc")), resolvePackageFile("hermes-engine", reactNativePath, OS_BIN, exe("hermes")), resolvePackageFile("hermesvm", reactNativePath, OS_BIN, exe("hermes")), @@ -88,17 +86,7 @@ export async function findHermesc(platform: string, gradleFile?: string, podFile throw new Error(`Unable to find hermesc. Set ${setting} to the path of the hermesc binary.`); } -async function getConfiguredAndroidHermesc(gradleFile?: string): Promise { - try { - return (await getGradleHermesCommand(gradleFile))?.replace("%OS-BIN%", OS_BIN); - } catch (error) { - log(chalk.yellow(`\nWarning: Unable to read hermesCommand from Gradle, using the default hermesc. ${error.message}\n`)); - return undefined; - } -} - -// react-native-xcode.sh gets HERMES_CLI_PATH from .xcode.env(.local), the environment, or the Pods xcconfig, -// where the hermes-engine pod writes it at pod install. Values may reference $PODS_ROOT or other variables. +// As react-native-xcode.sh sees it: .xcode.env(.local), the environment, or the Pods xcconfig written by pod install function getConfiguredIosHermesc(podFile?: string): string | undefined { const hermesCliPath = getXcodeDotEnvValue("HERMES_CLI_PATH") ?? getPodsXcconfigValue("HERMES_CLI_PATH", podFile); const variables: Record = { PODS_ROOT: getPodsDir(podFile), ...process.env }; @@ -142,7 +130,7 @@ function getSourceBuiltAndroidHermesc(reactNativePath: string): string { : path.join(reactNativePath, "ReactAndroid", "hermes-engine", "build", "hermes", "bin", exe("hermesc")); } -// Resolved from react-native, as they may not be hoisted +// Resolved from react-native, since they may not be hoisted function resolvePackageFile(packageName: string, reactNativePath: string, ...segments: string[]): string | undefined { try { const packageJson = require.resolve(`${packageName}/package.json`, { paths: [reactNativePath] }); @@ -152,7 +140,7 @@ function resolvePackageFile(packageName: string, reactNativePath: string, ...seg } } -// Hermes bytecode header (BytecodeFileFormat.h): uint64 magic, uint32 version, little-endian; stable since Hermes v0.1 +// Hermes bytecode header (BytecodeFileFormat.h): uint64 magic, uint32 version, little-endian const BYTECODE_MAGIC = BigInt("0x1F1903C103BC1FC6"); const BYTECODE_HEADER_SIZE = 12; @@ -172,13 +160,13 @@ function readFileHeader(file: string, size: number): Buffer { } } -/** Compiles an empty input to stdout and reads the bytecode version from the emitted header. */ +/** Compiles empty input and reads the version from the emitted header */ export function getHermescBytecodeVersion(hermesc: string): number | undefined { const result = childProcess.spawnSync(hermesc, ["-emit-binary", "-"], { input: "", timeout: 30000 }); return result.status === 0 ? readBytecodeVersion(result.stdout) : undefined; } -/** hermesc -base-bytecode aborts on a base of another bytecode version. The base only optimizes diffs, so it can be skipped. */ +/** -base-bytecode fails on another bytecode version; the base only shrinks diffs, so it's safe to skip */ export function isBaseBytecodeCompatible(baseBytecode: string, hermesc: string): boolean { const baseVersion = readBytecodeVersion(readFileHeader(baseBytecode, BYTECODE_HEADER_SIZE)); if (baseVersion === undefined) { diff --git a/script/utils/react-native-project-utils.ts b/script/utils/react-native-project-utils.ts index d04f3fe..ee29d8d 100644 --- a/script/utils/react-native-project-utils.ts +++ b/script/utils/react-native-project-utils.ts @@ -35,7 +35,7 @@ export function getReactNativeVersion(): string { } } -/** A value react-native-xcode.sh sees: ios/.xcode.env.local overrides ios/.xcode.env, which overrides the environment. */ +/** As react-native-xcode.sh sees it: ios/.xcode.env.local, then ios/.xcode.env, then the environment */ export function getXcodeDotEnvValue(key: string): string | undefined { for (const envFile of [".xcode.env.local", ".xcode.env"]) { const envPath = path.join("ios", envFile); diff --git a/test/hermes-command.ts b/test/hermes-command.ts index 9cfdeab..a2a89ff 100644 --- a/test/hermes-command.ts +++ b/test/hermes-command.ts @@ -61,64 +61,17 @@ describe("Hermes command resolution", () => { assert.strictEqual(await findHermesc("android"), path.join(projectDirectory, "android", "my-custom-hermesc", "bin", "hermesc")); }); - it("resolves a legacy project.ext.react hermesCommand against the app module", async () => { + it("reads a literal hermesCommand from a Kotlin DSL build script", async () => { bundledHermesc(); - createFile("android/app/build.gradle", 'project.ext.react = [\n hermesCommand: "../../custom/%OS-BIN%/hermesc"\n]\n'); + fs.rmSync(path.join(projectDirectory, "android", "app", "build.gradle")); + createFile("android/app/build.gradle.kts", 'react {\n hermesCommand.set("custom/%OS-BIN%/hermesc")\n}\n'); assert.strictEqual(await findHermesc("android"), path.join(projectDirectory, "custom", OS_BIN, "hermesc")); }); - it("reads legacy project.ext.react entries that follow a trailing comment", async () => { - bundledHermesc(); - createFile( - "android/app/build.gradle", - 'project.ext.react = [\n enableHermes: true, // clean and rebuild if changing\n hermesCommand: "../../custom/%OS-BIN%/hermesc"\n]\n' - ); - assert.strictEqual(await findHermesc("android"), path.join(projectDirectory, "custom", OS_BIN, "hermesc")); - }); - - it("resolves hermesCommand against a literal react.root", async () => { - bundledHermesc(); - createFile("android/app/build.gradle", 'react {\n root = file("../../app-root")\n hermesCommand = "custom/hermesc"\n}\n'); - assert.strictEqual(await findHermesc("android"), path.join(projectDirectory, "app-root", "custom", "hermesc")); - }); - - describe("Kotlin DSL", () => { - const gradleRun = () => path.join(projectDirectory, "gradle-ran"); - - // Stands in for the Gradle wrapper: records that it ran and prints the evaluated react { } config - function createGradlew(script: string) { - fs.rmSync(path.join(projectDirectory, "android", "app", "build.gradle")); - createFile("android/gradlew", `#!/bin/sh\ntouch "${gradleRun()}"\n${script}\n`); - fs.chmodSync(path.join(projectDirectory, "android", "gradlew"), 0o755); - } - - it("evaluates hermesCommand with Gradle and resolves it against react.root", async () => { - bundledHermesc(); - createGradlew(`echo '{"hermesCommand":"custom/%OS-BIN%/hermesc","root":"${projectDirectory}"}'`); - createFile("android/app/build.gradle.kts", 'react {\n hermesCommand = "custom/%OS-BIN%/hermesc"\n}\n'); - assert.strictEqual(await findHermesc("android"), path.join(projectDirectory, "custom", OS_BIN, "hermesc")); - }); - - it("does not run Gradle when the build script doesn't set hermesCommand", async () => { - const bundled = bundledHermesc(); - createGradlew("exit 1"); - createFile("android/app/build.gradle.kts", 'react {\n // hermesCommand = "custom/hermesc"\n}\n'); - assert.strictEqual(fs.realpathSync(await findHermesc("android")), bundled); - assert.strictEqual(fs.existsSync(gradleRun()), false); - }); - - it("warns and falls back to the default hermesc when Gradle fails", async () => { - const bundled = bundledHermesc(); - const log = sinon.stub(cmdexec, "log"); - try { - createGradlew("exit 1"); - createFile("android/app/build.gradle.kts", 'react {\n hermesCommand = "custom/hermesc"\n}\n'); - assert.strictEqual(fs.realpathSync(await findHermesc("android")), bundled); - sinon.assert.calledWithMatch(log, sinon.match(/Gradle failed/)); - } finally { - log.restore(); - } - }); + it("ignores a commented-out hermesCommand", async () => { + const bundled = bundledHermesc(); + createFile("android/app/build.gradle", 'react {\n // hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"\n}\n'); + assert.strictEqual(fs.realpathSync(await findHermesc("android")), bundled); }); it("ignores a computed hermesCommand expression such as Expo's", async () => { From 5bde00d4a81179f27c575dc09a5f74b19a84d93d Mon Sep 17 00:00:00 2001 From: Revopush Date: Sat, 10 Oct 2026 23:12:21 +0300 Subject: [PATCH 5/8] Simplify hermes-utils Look up hermesc as HERMES_CLI_PATH / hermesCommand, then hermes-compiler or react-native's sdks/hermesc, then RN < 0.69 packages. Drop the CocoaPods hermesc, Pods xcconfig lookup, Xcode variable expansion and from-source Hermes builds: default setups resolve to a compiler with the same bytecode version. Merge iOS Hermes detection into one function and the base bytecode warnings into two. Co-Authored-By: Claude Opus 5.5 --- script/react-native-utils.ts | 2 +- script/utils/hermes-utils.ts | 155 ++++++++--------------------------- test/hermes-command.ts | 52 +++--------- 3 files changed, 50 insertions(+), 159 deletions(-) diff --git a/script/react-native-utils.ts b/script/react-native-utils.ts index eee87dc..b6fa2c1 100644 --- a/script/react-native-utils.ts +++ b/script/react-native-utils.ts @@ -73,7 +73,7 @@ export async function runHermesEmitBinaryCommand( ): Promise { const bundle = path.join(outputFolder, bundleName); const bytecode = bundle + ".hbc"; - const hermesc = await findHermesc(command.platform, gradleFile, command.podFile); + const hermesc = await findHermesc(command.platform, gradleFile); const hermesArgs = [ ...(process.env.CODE_PUSH_NODE_ARGS?.trim().split(/\s+/).filter(Boolean) ?? []), diff --git a/script/utils/hermes-utils.ts b/script/utils/hermes-utils.ts index ee4d537..1e25aa8 100644 --- a/script/utils/hermes-utils.ts +++ b/script/utils/hermes-utils.ts @@ -8,7 +8,7 @@ import { coerce, gte } from "semver"; import * as cli from "../types/cli"; import { log } from "../command-executor"; -import { fileDoesNotExistOrIsDirectory, fileExists } from "./file-utils"; +import { fileExists } from "./file-utils"; import { getGradleHermesCommand, getGradleHermesEnabled, readGradleProperty } from "./gradle-utils"; import { getReactNativePackagePath, getReactNativeVersion, getXcodeDotEnvValue } from "./react-native-project-utils"; @@ -20,65 +20,50 @@ function exe(name: string): string { export async function isHermesEnabled(command: cli.IReleaseReactCommand, platform = command.platform.toLowerCase()): Promise { if (command.useHermes) return true; - if (platform === "android") return (await getGradleHermesEnabled(command.gradleFile)) ?? isHermesDefaultEngine(); - if (platform === "ios") { - return getXcodeProjectHermesEnabled(command.podFile) ?? getPodfileHermesEnabled(command.podFile) ?? isHermesDefaultEngine(); - } - return false; -} - -// Hermes is the default engine since React Native 0.70 -function isHermesDefaultEngine(): boolean { - const reactNativeVersion = coerce(getReactNativeVersion()); - return !!reactNativeVersion && gte(reactNativeVersion, "0.70.0"); -} - -// USE_HERMES build setting written by pod install, checked by react-native-xcode.sh -function getXcodeProjectHermesEnabled(podFile?: string): boolean | undefined { - const iosDir = path.dirname(podFile || path.join("ios", "Podfile")); - const projects = fs.existsSync(iosDir) ? fs.readdirSync(iosDir).filter((file) => file.endsWith(".xcodeproj")) : []; - for (const project of projects) { - const pbxproj = path.join(iosDir, project, "project.pbxproj"); - const match = fileExists(pbxproj) && /\bUSE_HERMES = "?(true|false)"?;/.exec(fs.readFileSync(pbxproj, "utf8")); + const enabled = platform === "android" ? await getGradleHermesEnabled(command.gradleFile) : getIosHermesEnabled(command.podFile); + // Hermes is the default engine since React Native 0.70 + return enabled ?? gte(coerce(getReactNativeVersion()) ?? "0.0.0", "0.70.0"); +} + +// pod install writes USE_HERMES into the Xcode project since RN 0.75; older apps set :hermes_enabled in the Podfile +function getIosHermesEnabled(podFile = path.join("ios", "Podfile")): boolean | undefined { + const iosDir = path.dirname(podFile); + const xcodeProjects = fs.existsSync(iosDir) ? fs.readdirSync(iosDir).filter((file) => file.endsWith(".xcodeproj")) : []; + const sources: [string, RegExp][] = [ + ...xcodeProjects.map((project): [string, RegExp] => [ + path.join(iosDir, project, "project.pbxproj"), + /\bUSE_HERMES = "?(true|false)"?;/, + ]), + [podFile, /^[^#\n]*:?hermes_enabled\s*(?:=>|:)\s*(true|false)/m], + ]; + for (const [file, pattern] of sources) { + const match = fileExists(file) && pattern.exec(fs.readFileSync(file, "utf8")); if (match) return match[1] === "true"; } return undefined; } -// `:hermes_enabled => true/false` in the Podfile -function getPodfileHermesEnabled(podFile?: string): boolean | undefined { - const podfilePath = podFile || path.join("ios", "Podfile"); - if (fileDoesNotExistOrIsDirectory(podfilePath)) { - if (podFile) throw new Error(`Unable to find Podfile file "${podfilePath}".`); - return undefined; - } - const match = /^[^#\n]*:?hermes_enabled\s*(?:=>|:)\s*(true|false)/m.exec(fs.readFileSync(podfilePath, "utf8")); - return match ? match[1] === "true" : undefined; -} - /** The hermesc RN's build uses: Gradle plugin PathUtils.detectOSAwareHermesCommand, iOS react-native-xcode.sh */ -export async function findHermesc(platform: string, gradleFile?: string, podFile?: string): Promise { +export async function findHermesc(platform: string, gradleFile?: string): Promise { const configured = - platform === "ios" ? getConfiguredIosHermesc(podFile) : getGradleHermesCommand(gradleFile)?.replace("%OS-BIN%", OS_BIN); - if (configured) return configured; + platform === "ios" ? getXcodeDotEnvValue("HERMES_CLI_PATH") : getGradleHermesCommand(gradleFile)?.replace("%OS-BIN%", OS_BIN); + // Values referencing Xcode variables like $PODS_ROOT can't be resolved here + if (configured && !configured.includes("$")) return configured; - const reactNativePath = getReactNativePackagePath(); // RN <= 0.82 ships sdks/hermesc, 0.83+ hermes-compiler. On 0.82, hermes-compiler is an empty placeholder unless // the app opts into Hermes V1 (Android also needs hermesV1Enabled) + const reactNativePath = getReactNativePackagePath(); const bundledHermesc = path.join(reactNativePath, "sdks", "hermesc", OS_BIN, exe("hermesc")); const hermesCompiler = resolvePackageFile("hermes-compiler", reactNativePath, "hermesc", OS_BIN, exe("hermesc")); const preferBundled = platform === "android" && readGradleProperty("hermesV1Enabled", gradleFile) !== "true"; - const packagedHermesc = preferBundled ? [bundledHermesc, hermesCompiler] : [hermesCompiler, bundledHermesc]; const candidates = [ - platform === "ios" ? getCocoaPodsHermesc(podFile) : getSourceBuiltAndroidHermesc(reactNativePath), - ...packagedHermesc, + ...(preferBundled ? [bundledHermesc, hermesCompiler] : [hermesCompiler, bundledHermesc]), // RN < 0.69: hermes-engine (binary named "hermes" before 0.64) or hermesvm resolvePackageFile("hermes-engine", reactNativePath, OS_BIN, exe("hermesc")), resolvePackageFile("hermes-engine", reactNativePath, OS_BIN, exe("hermes")), resolvePackageFile("hermesvm", reactNativePath, OS_BIN, exe("hermes")), ]; - const hermesc = candidates.find((candidate) => candidate && fileExists(candidate)); if (hermesc) return hermesc; @@ -86,50 +71,6 @@ export async function findHermesc(platform: string, gradleFile?: string, podFile throw new Error(`Unable to find hermesc. Set ${setting} to the path of the hermesc binary.`); } -// As react-native-xcode.sh sees it: .xcode.env(.local), the environment, or the Pods xcconfig written by pod install -function getConfiguredIosHermesc(podFile?: string): string | undefined { - const hermesCliPath = getXcodeDotEnvValue("HERMES_CLI_PATH") ?? getPodsXcconfigValue("HERMES_CLI_PATH", podFile); - const variables: Record = { PODS_ROOT: getPodsDir(podFile), ...process.env }; - const expanded = hermesCliPath?.replace( - /\$(?:\{(\w+)\}|\((\w+)\)|(\w+))/g, - (match, braced, parenthesized, bare) => variables[braced ?? parenthesized ?? bare] ?? match - ); - if (expanded?.includes("$")) { - log(chalk.yellow(`\nWarning: Unable to resolve HERMES_CLI_PATH "${hermesCliPath}", using the default hermesc.\n`)); - return undefined; - } - return expanded; -} - -function getPodsXcconfigValue(key: string, podFile?: string): string | undefined { - const supportFilesDir = path.join(getPodsDir(podFile), "Target Support Files"); - const targetDirs = fs.existsSync(supportFilesDir) ? fs.readdirSync(supportFilesDir).filter((dir) => dir.startsWith("Pods-")) : []; - for (const targetDir of targetDirs) { - const xcconfigs = fs.readdirSync(path.join(supportFilesDir, targetDir)).filter((file) => file.endsWith(".xcconfig")); - for (const xcconfig of xcconfigs) { - const contents = fs.readFileSync(path.join(supportFilesDir, targetDir, xcconfig), "utf8"); - const value = new RegExp(`^${key}\\s*=\\s*(.+)$`, "m").exec(contents)?.[1].trim(); - if (value) return value; - } - } - return undefined; -} - -function getPodsDir(podFile?: string): string { - return path.join(path.dirname(podFile || path.join("ios", "Podfile")), "Pods"); -} - -function getCocoaPodsHermesc(podFile?: string): string { - return path.join(getPodsDir(podFile), "hermes-engine", "destroot", "bin", "hermesc"); -} - -function getSourceBuiltAndroidHermesc(reactNativePath: string): string { - const overrideHermesDir = process.env.REACT_NATIVE_OVERRIDE_HERMES_DIR; - return overrideHermesDir - ? path.join(overrideHermesDir, "build", "bin", exe("hermesc")) - : path.join(reactNativePath, "ReactAndroid", "hermes-engine", "build", "hermes", "bin", exe("hermesc")); -} - // Resolved from react-native, since they may not be hoisted function resolvePackageFile(packageName: string, reactNativePath: string, ...segments: string[]): string | undefined { try { @@ -142,22 +83,9 @@ function resolvePackageFile(packageName: string, reactNativePath: string, ...seg // Hermes bytecode header (BytecodeFileFormat.h): uint64 magic, uint32 version, little-endian const BYTECODE_MAGIC = BigInt("0x1F1903C103BC1FC6"); -const BYTECODE_HEADER_SIZE = 12; function readBytecodeVersion(bytecode: Buffer): number | undefined { - const isHermesBytecode = bytecode.length >= BYTECODE_HEADER_SIZE && bytecode.readBigUInt64LE(0) === BYTECODE_MAGIC; - return isHermesBytecode ? bytecode.readUInt32LE(8) : undefined; -} - -function readFileHeader(file: string, size: number): Buffer { - const header = Buffer.alloc(size); - const fd = fs.openSync(file, "r"); - try { - const bytesRead = fs.readSync(fd, header, 0, size, 0); - return header.subarray(0, bytesRead); - } finally { - fs.closeSync(fd); - } + return bytecode.length >= 12 && bytecode.readBigUInt64LE(0) === BYTECODE_MAGIC ? bytecode.readUInt32LE(8) : undefined; } /** Compiles empty input and reads the version from the emitted header */ @@ -168,26 +96,15 @@ export function getHermescBytecodeVersion(hermesc: string): number | undefined { /** -base-bytecode fails on another bytecode version; the base only shrinks diffs, so it's safe to skip */ export function isBaseBytecodeCompatible(baseBytecode: string, hermesc: string): boolean { - const baseVersion = readBytecodeVersion(readFileHeader(baseBytecode, BYTECODE_HEADER_SIZE)); - if (baseVersion === undefined) { - log(chalk.yellow("\nBase release bundle is not Hermes bytecode, skipping -base-bytecode.\n")); - return false; - } - + const baseVersion = readBytecodeVersion(fs.readFileSync(baseBytecode)); const hermescVersion = getHermescBytecodeVersion(hermesc); - if (hermescVersion === undefined) { - log(chalk.yellow("\nCould not detect the Hermes compiler bytecode version, skipping -base-bytecode.\n")); - return false; - } - - if (baseVersion !== hermescVersion) { - log( - chalk.yellow( - `\nWarning: base release is Hermes bytecode v${baseVersion}, local compiler produces v${hermescVersion}. ` + - `Skipping -base-bytecode. Binaries on v${baseVersion} can't run this update.\n` - ) - ); - return false; - } - return true; + if (baseVersion !== undefined && baseVersion === hermescVersion) return true; + + const warning = + baseVersion === undefined || hermescVersion === undefined + ? "Unable to compare Hermes bytecode versions, skipping -base-bytecode." + : `Base release is Hermes bytecode v${baseVersion}, local compiler produces v${hermescVersion}. ` + + `Skipping -base-bytecode. Binaries on v${baseVersion} can't run this update.`; + log(chalk.yellow(`\nWarning: ${warning}\n`)); + return false; } diff --git a/test/hermes-command.ts b/test/hermes-command.ts index a2a89ff..103d054 100644 --- a/test/hermes-command.ts +++ b/test/hermes-command.ts @@ -32,7 +32,6 @@ describe("Hermes command resolution", () => { originalCwd = process.cwd(); originalEnv = { ...process.env }; delete process.env.HERMES_CLI_PATH; - delete process.env.REACT_NATIVE_OVERRIDE_HERMES_DIR; projectDirectory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "revopush-hermesc-"))); createFile("node_modules/react-native/package.json", JSON.stringify({ name: "react-native", version: "0.83.0" })); createFile("android/app/build.gradle", "react {\n}\n"); @@ -112,56 +111,31 @@ describe("Hermes command resolution", () => { assert.strictEqual(fs.realpathSync(await findHermesc("android")), compiler); }); - it("uses hermesc built from source via REACT_NATIVE_OVERRIDE_HERMES_DIR", async () => { - bundledHermesc(); - const built = createFile(`hermes/build/bin/${HERMESC}`); - process.env.REACT_NATIVE_OVERRIDE_HERMES_DIR = path.join(projectDirectory, "hermes"); - assert.strictEqual(await findHermesc("android"), built); - }); - - it("uses HERMES_CLI_PATH, then the CocoaPods hermesc, on iOS", async () => { + it("uses HERMES_CLI_PATH on iOS", async () => { hermesCompiler(); - const podHermesc = createFile("ios/Pods/hermes-engine/destroot/bin/hermesc"); - assert.strictEqual(fs.realpathSync(await findHermesc("ios")), podHermesc); - process.env.HERMES_CLI_PATH = "/custom/hermesc"; assert.strictEqual(await findHermesc("ios"), "/custom/hermesc"); }); - it("prefers hermes-compiler on iOS without CocoaPods, skipping RN 0.82's empty placeholder package", async () => { - const bundled = bundledHermesc(); - createFile("node_modules/hermes-compiler/package.json", JSON.stringify({ name: "hermes-compiler", version: "0.0.0" })); - assert.strictEqual(fs.realpathSync(await findHermesc("ios")), bundled); - - const compiler = hermesCompiler(); - assert.strictEqual(fs.realpathSync(await findHermesc("ios")), compiler); - }); - - it("falls back to hermes-compiler on iOS when react-native has no bundled hermesc", async () => { - const compiler = hermesCompiler(); - assert.strictEqual(fs.realpathSync(await findHermesc("ios")), compiler); - }); - it("reads HERMES_CLI_PATH from ios/.xcode.env.local", async () => { + createFile("ios/.xcode.env", "export HERMES_CLI_PATH=/env/hermesc\n"); createFile("ios/.xcode.env.local", "export HERMES_CLI_PATH=/xcode/hermesc\n"); assert.strictEqual(await findHermesc("ios"), "/xcode/hermesc"); }); - it("uses HERMES_CLI_PATH written into the Pods xcconfig by pod install", async () => { - createFile("ios/Pods/hermes-engine/destroot/bin/hermesc"); - createFile( - "ios/Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig", - "PODS_ROOT = ${SRCROOT}/Pods\nHERMES_CLI_PATH = $(PODS_ROOT)/../../node_modules/hermes-compiler/hermesc/osx-bin/hermesc\n" - ); - assert.strictEqual(path.resolve(await findHermesc("ios")), path.resolve("node_modules/hermes-compiler/hermesc/osx-bin/hermesc")); - - createFile("ios/.xcode.env", "export HERMES_CLI_PATH=/xcode/hermesc\n"); - assert.strictEqual(await findHermesc("ios"), "/xcode/hermesc"); + it("ignores a HERMES_CLI_PATH that references Xcode variables", async () => { + const compiler = hermesCompiler(); + createFile("ios/.xcode.env", 'export HERMES_CLI_PATH="$PODS_ROOT/custom/hermesc"\n'); + assert.strictEqual(fs.realpathSync(await findHermesc("ios")), compiler); }); - it("expands $PODS_ROOT in HERMES_CLI_PATH", async () => { - createFile("ios/.xcode.env", 'export HERMES_CLI_PATH="$PODS_ROOT/custom/hermesc"\n'); - assert.strictEqual(await findHermesc("ios"), path.join("ios", "Pods", "custom", "hermesc")); + it("prefers hermes-compiler on iOS, skipping RN 0.82's empty placeholder package", async () => { + const bundled = bundledHermesc(); + createFile("node_modules/hermes-compiler/package.json", JSON.stringify({ name: "hermes-compiler", version: "0.0.0" })); + assert.strictEqual(fs.realpathSync(await findHermesc("ios")), bundled); + + const compiler = hermesCompiler(); + assert.strictEqual(fs.realpathSync(await findHermesc("ios")), compiler); }); it("finds the hermes binary of RN < 0.64's hermes-engine package", async () => { From 5733de95320c55320aef5f824b598e2c45a86f91 Mon Sep 17 00:00:00 2001 From: Revopush Date: Sat, 10 Oct 2026 23:32:06 +0300 Subject: [PATCH 6/8] Drop redundant directory check in getReactNativePackagePath require.resolve only succeeds when package.json exists, so its directory always exists. Co-Authored-By: Claude Opus 5.5 --- script/utils/react-native-project-utils.ts | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/script/utils/react-native-project-utils.ts b/script/utils/react-native-project-utils.ts index ee29d8d..62a1b51 100644 --- a/script/utils/react-native-project-utils.ts +++ b/script/utils/react-native-project-utils.ts @@ -5,23 +5,7 @@ import * as dotenv from "dotenv"; export function getReactNativePackagePath(): string { const result = childProcess.spawnSync("node", ["--print", "require.resolve('react-native/package.json')"]); - const packagePath = path.dirname(result.stdout.toString()); - if (result.status === 0 && directoryExistsSync(packagePath)) { - return packagePath; - } - - return path.join("node_modules", "react-native"); -} - -function directoryExistsSync(dirname: string): boolean { - try { - return fs.statSync(dirname).isDirectory(); - } catch (err) { - if (err.code !== "ENOENT") { - throw err; - } - } - return false; + return result.status === 0 ? path.dirname(result.stdout.toString().trim()) : path.join("node_modules", "react-native"); } export function getReactNativeVersion(): string { From ab68765837f289b4972be8ffa9bbd3f3f84fe0d1 Mon Sep 17 00:00:00 2001 From: Revopush Date: Sat, 10 Oct 2026 23:35:13 +0300 Subject: [PATCH 7/8] Move React Native project helpers back to react-native-utils getReactNativePackagePath, getReactNativeVersion and getXcodeDotEnvValue lived in react-native-utils on main; drop react-native-project-utils. Revert cosmetic renames in gradle-utils. Co-Authored-By: Claude Opus 5.5 --- script/command-executor.ts | 2 +- script/react-native-utils.ts | 30 +++++++++++++++++++- script/utils/gradle-utils.ts | 12 +++----- script/utils/hermes-utils.ts | 2 +- script/utils/react-native-project-utils.ts | 32 ---------------------- 5 files changed, 35 insertions(+), 43 deletions(-) delete mode 100644 script/utils/react-native-project-utils.ts diff --git a/script/command-executor.ts b/script/command-executor.ts index 0cdb331..50dc466 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -33,12 +33,12 @@ import { import { getBundleSourceMapOutput, getMinifyParams, + getReactNativePackagePath, isValidVersion, runHermesEmitBinaryCommand, takeHermesBaseBytecode, } from "./react-native-utils"; import { isHermesEnabled } from "./utils/hermes-utils"; -import { getReactNativePackagePath } from "./utils/react-native-project-utils"; import { fileDoesNotExistOrIsDirectory, fileExists, isBinaryOrZip, extractArchive } from "./utils/file-utils"; import { getAndroidVersionInfo } from "./utils/gradle-utils"; import { diff --git a/script/react-native-utils.ts b/script/react-native-utils.ts index b6fa2c1..3ef8559 100644 --- a/script/react-native-utils.ts +++ b/script/react-native-utils.ts @@ -5,7 +5,7 @@ import * as childProcess from "child_process"; import { valid } from "semver"; import { downloadBlob, extractArchive } from "./utils/file-utils"; import { findHermesc, isBaseBytecodeCompatible } from "./utils/hermes-utils"; -import { getReactNativePackagePath, getXcodeDotEnvValue } from "./utils/react-native-project-utils"; +import * as dotenv from "dotenv"; import * as cli from "./types/cli"; import { log, sdk } from "./command-executor"; @@ -163,3 +163,31 @@ export function getMinifyParams(platform: string, isHermes: boolean): (string | throw new Error('Platform must be either "android" or "ios".'); } } + +export function getReactNativePackagePath(): string { + const result = childProcess.spawnSync("node", ["--print", "require.resolve('react-native/package.json')"]); + return result.status === 0 ? path.dirname(result.stdout.toString().trim()) : path.join("node_modules", "react-native"); +} + +export function getReactNativeVersion(): string { + try { + const result = childProcess.spawnSync("node", ["--print", "require('react-native/package.json').version"]); + return result.stdout.toString().trim(); + } catch (error) { + throw new Error( + 'Unable to resolve "react-native". Please make sure it is installed in your project (e.g. "npm install react-native").' + ); + } +} + +/** As react-native-xcode.sh sees it: ios/.xcode.env.local, then ios/.xcode.env, then the environment */ +export function getXcodeDotEnvValue(key: string): string | undefined { + for (const envFile of [".xcode.env.local", ".xcode.env"]) { + const envPath = path.join("ios", envFile); + const value = fs.existsSync(envPath) ? dotenv.parse(fs.readFileSync(envPath))[key] : undefined; + if (value !== undefined) { + return value; + } + } + return process.env[key]; +} diff --git a/script/utils/gradle-utils.ts b/script/utils/gradle-utils.ts index a3661ef..cdc53d4 100644 --- a/script/utils/gradle-utils.ts +++ b/script/utils/gradle-utils.ts @@ -61,8 +61,8 @@ export async function getAndroidVersionInfo(gradleFile?: string | null): Promise if (!buildFile) { throw new Error(`Unable to find gradle file "${gradleFile ?? DEFAULT_APP_DIR}".`); } - const { versionName, versionCode } = isKotlinDsl(buildFile) - ? await evaluateKotlinDslVersion(buildFile) + const { versionName, versionCode } = buildFile.endsWith(".kts") + ? await evaluateKotlinDslBuildFile(buildFile) : await parseGroovyDslVersion(buildFile); const appVersion = resolveAppVersion(versionName, buildFile); @@ -80,7 +80,7 @@ export async function getAndroidVersionInfo(gradleFile?: string | null): Promise export async function getGradleHermesEnabled(gradleFile?: string | null): Promise { let enabled = readGradleProperty("hermesEnabled", gradleFile) ?? readGradleProperty("react.hermesEnabled", gradleFile); const buildFile = findGradleBuildFile(gradleFile); - if (enabled === undefined && buildFile && !isKotlinDsl(buildFile)) { + if (enabled === undefined && buildFile && !buildFile.endsWith(".kts")) { const parsed = await parseGroovyDslBuildFile(buildFile).catch((): any => ({})); enabled = getLegacyReactProperty(parsed, "enableHermes"); } @@ -123,10 +123,6 @@ function findGradleBuildFile(gradleFile?: string | null): string | undefined { ); } -function isKotlinDsl(buildFile: string): boolean { - return buildFile.endsWith(".kts"); -} - function parseGroovyDslBuildFile(buildFile: string): Promise { return g2js.parseFile(buildFile).catch(() => { throw new Error(`Unable to parse the "${buildFile}" file. Please ensure it is a well-formed Gradle file.`); @@ -158,7 +154,7 @@ async function parseGroovyDslVersion(buildFile: string): Promise { +async function evaluateKotlinDslBuildFile(buildFile: string): Promise { // The build file lives in the application module folder (typically android/app); // its parent is the Gradle project root, and the folder name is the module name. const moduleName = path.basename(path.dirname(path.resolve(buildFile))); diff --git a/script/utils/hermes-utils.ts b/script/utils/hermes-utils.ts index 1e25aa8..f97d56e 100644 --- a/script/utils/hermes-utils.ts +++ b/script/utils/hermes-utils.ts @@ -10,7 +10,7 @@ import * as cli from "../types/cli"; import { log } from "../command-executor"; import { fileExists } from "./file-utils"; import { getGradleHermesCommand, getGradleHermesEnabled, readGradleProperty } from "./gradle-utils"; -import { getReactNativePackagePath, getReactNativeVersion, getXcodeDotEnvValue } from "./react-native-project-utils"; +import { getReactNativePackagePath, getReactNativeVersion, getXcodeDotEnvValue } from "../react-native-utils"; const OS_BIN = ({ win32: "win64-bin", darwin: "osx-bin" } as Record)[process.platform] ?? "linux64-bin"; diff --git a/script/utils/react-native-project-utils.ts b/script/utils/react-native-project-utils.ts deleted file mode 100644 index 62a1b51..0000000 --- a/script/utils/react-native-project-utils.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as childProcess from "child_process"; -import * as fs from "fs"; -import * as path from "path"; -import * as dotenv from "dotenv"; - -export function getReactNativePackagePath(): string { - const result = childProcess.spawnSync("node", ["--print", "require.resolve('react-native/package.json')"]); - return result.status === 0 ? path.dirname(result.stdout.toString().trim()) : path.join("node_modules", "react-native"); -} - -export function getReactNativeVersion(): string { - try { - const result = childProcess.spawnSync("node", ["--print", "require('react-native/package.json').version"]); - return result.stdout.toString().trim(); - } catch (error) { - throw new Error( - 'Unable to resolve "react-native". Please make sure it is installed in your project (e.g. "npm install react-native").' - ); - } -} - -/** As react-native-xcode.sh sees it: ios/.xcode.env.local, then ios/.xcode.env, then the environment */ -export function getXcodeDotEnvValue(key: string): string | undefined { - for (const envFile of [".xcode.env.local", ".xcode.env"]) { - const envPath = path.join("ios", envFile); - const value = fs.existsSync(envPath) ? dotenv.parse(fs.readFileSync(envPath))[key] : undefined; - if (value !== undefined) { - return value; - } - } - return process.env[key]; -} From e3005f1e9b630366591ead8be67f055450ce40a4 Mon Sep 17 00:00:00 2001 From: Revopush Date: Sat, 10 Oct 2026 23:46:15 +0300 Subject: [PATCH 8/8] Tidy iOS Hermes detection and read the RN version from package.json - getIosHermesEnabled: check the Xcode project, then the Podfile, through a small matchInFiles helper - getReactNativeVersion: read the version next to the resolved react-native package instead of spawning node; drop the catch that never ran Co-Authored-By: Claude Opus 5.5 --- script/react-native-utils.ts | 12 +++--------- script/utils/hermes-utils.ts | 19 ++++++++++--------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/script/react-native-utils.ts b/script/react-native-utils.ts index 3ef8559..eca3e43 100644 --- a/script/react-native-utils.ts +++ b/script/react-native-utils.ts @@ -169,15 +169,9 @@ export function getReactNativePackagePath(): string { return result.status === 0 ? path.dirname(result.stdout.toString().trim()) : path.join("node_modules", "react-native"); } -export function getReactNativeVersion(): string { - try { - const result = childProcess.spawnSync("node", ["--print", "require('react-native/package.json').version"]); - return result.stdout.toString().trim(); - } catch (error) { - throw new Error( - 'Unable to resolve "react-native". Please make sure it is installed in your project (e.g. "npm install react-native").' - ); - } +export function getReactNativeVersion(): string | undefined { + const packageJson = path.join(getReactNativePackagePath(), "package.json"); + return fs.existsSync(packageJson) ? JSON.parse(fs.readFileSync(packageJson, "utf8")).version : undefined; } /** As react-native-xcode.sh sees it: ios/.xcode.env.local, then ios/.xcode.env, then the environment */ diff --git a/script/utils/hermes-utils.ts b/script/utils/hermes-utils.ts index f97d56e..4bf1e57 100644 --- a/script/utils/hermes-utils.ts +++ b/script/utils/hermes-utils.ts @@ -29,16 +29,17 @@ export async function isHermesEnabled(command: cli.IReleaseReactCommand, platfor function getIosHermesEnabled(podFile = path.join("ios", "Podfile")): boolean | undefined { const iosDir = path.dirname(podFile); const xcodeProjects = fs.existsSync(iosDir) ? fs.readdirSync(iosDir).filter((file) => file.endsWith(".xcodeproj")) : []; - const sources: [string, RegExp][] = [ - ...xcodeProjects.map((project): [string, RegExp] => [ - path.join(iosDir, project, "project.pbxproj"), - /\bUSE_HERMES = "?(true|false)"?;/, - ]), - [podFile, /^[^#\n]*:?hermes_enabled\s*(?:=>|:)\s*(true|false)/m], - ]; - for (const [file, pattern] of sources) { + const pbxprojs = xcodeProjects.map((project) => path.join(iosDir, project, "project.pbxproj")); + const value = + matchInFiles(pbxprojs, /\bUSE_HERMES = "?(true|false)"?;/) ?? + matchInFiles([podFile], /^[^#\n]*:?hermes_enabled\s*(?:=>|:)\s*(true|false)/m); + return value === undefined ? undefined : value === "true"; +} + +function matchInFiles(files: string[], pattern: RegExp): string | undefined { + for (const file of files) { const match = fileExists(file) && pattern.exec(fs.readFileSync(file, "utf8")); - if (match) return match[1] === "true"; + if (match) return match[1]; } return undefined; }