From f64468e13a81bde6a68ab6906174ad15040518f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 13 Aug 2026 14:07:26 +0000 Subject: [PATCH 1/2] Add a prebuilt-hermes command and a workflow that publishes its archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building Hermes for Apple platforms is the expensive part of an iOS build, and it only changes when the pinned commit does. Build it once into an archive in the destroot layout hermes-engine.podspec expects from HERMES_ENGINE_TARBALL_PATH, and publish it as a release asset keyed by the pinned commit. Nothing consumes the archive yet — pod install still builds Hermes from source. This lands the command and the publishing workflow first, so the workflow is dispatchable and an asset exists before anything depends on it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UkNbgdyuKgHaFwT27RahGH --- .changeset/prebuilt-hermes-command.md | 18 + .github/workflows/hermes-prebuilt.yml | 78 ++++ docs/CLI.md | 24 +- packages/host/src/node/cli/hermes-prebuilt.ts | 429 ++++++++++++++++++ packages/host/src/node/cli/hermes.ts | 196 ++++---- packages/host/src/node/cli/program.ts | 7 +- 6 files changed, 671 insertions(+), 81 deletions(-) create mode 100644 .changeset/prebuilt-hermes-command.md create mode 100644 .github/workflows/hermes-prebuilt.yml create mode 100644 packages/host/src/node/cli/hermes-prebuilt.ts diff --git a/.changeset/prebuilt-hermes-command.md b/.changeset/prebuilt-hermes-command.md new file mode 100644 index 00000000..5bb4a76d --- /dev/null +++ b/.changeset/prebuilt-hermes-command.md @@ -0,0 +1,18 @@ +--- +"react-native-node-api": minor +--- + +Add a `prebuilt-hermes` command, which resolves an archive of the pinned Hermes +commit prebuilt for Apple platforms and prints its path. The archive holds the +`destroot` layout React Native's `hermes-engine.podspec` expects from a tarball +pointed at by `HERMES_ENGINE_TARBALL_PATH`, so an app that sets that variable +vendors the prebuilt frameworks rather than compiling Hermes as part of its own +build. + +It is resolved from a local cache, then from a release asset published for the +pinned commit, and only built locally if neither has it. Its name covers +everything that changes its contents — the pinned commit, the React Native +version whose `ReactCommon/jsi` it is compiled against, the build type and the +platforms — so a stale archive can never be mistaken for a matching one. + +Nothing consumes this yet: `pod install` still builds Hermes from source. diff --git a/.github/workflows/hermes-prebuilt.yml b/.github/workflows/hermes-prebuilt.yml new file mode 100644 index 00000000..cf619033 --- /dev/null +++ b/.github/workflows/hermes-prebuilt.yml @@ -0,0 +1,78 @@ +name: Hermes prebuilt + +# Builds the pinned Hermes for Apple platforms and publishes it as a release +# asset, so `pod install` downloads it instead of compiling Hermes as part of +# every app build. The asset is keyed by everything that changes its contents, +# so a bumped pin publishes alongside the previous one rather than replacing it. + +env: + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: "url.https://github.com/.insteadOf" + GIT_CONFIG_VALUE_0: "git@github.com:" + +on: + workflow_dispatch: + push: + branches: + - main + - next + paths: + - "packages/host/src/node/cli/hermes.ts" + - "packages/host/src/node/cli/hermes-prebuilt.ts" + - ".github/workflows/hermes-prebuilt.yml" + +# Two runs publishing the same tag would race on creating the release. +concurrency: + group: ${{ github.workflow }} + +jobs: + publish: + name: Publish prebuilt Hermes + runs-on: macos-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: lts/krypton + - uses: pnpm/action-setup@v6 + with: + cache: true + - run: pnpm install + - run: pnpm run build + # Resolved from the test app so the archive is built against the React + # Native version this repository actually pins. + - name: Resolve prebuilt Hermes name + id: hermes + working-directory: apps/test-app + run: | + echo "archive=$(pnpm exec react-native-node-api prebuilt-hermes --print name)" >> "$GITHUB_OUTPUT" + echo "tag=$(pnpm exec react-native-node-api prebuilt-hermes --print tag)" >> "$GITHUB_OUTPUT" + - name: Cache prebuilt Hermes + uses: actions/cache@v6 + with: + path: ~/Library/Caches/react-native-node-api/hermes-prebuilt + key: ${{ steps.hermes.outputs.archive }} + # --no-download so a re-run rebuilds rather than round-tripping the asset + # it is about to publish. + - name: Build prebuilt Hermes + id: build + working-directory: apps/test-app + run: echo "path=$(pnpm exec react-native-node-api prebuilt-hermes --no-download)" >> "$GITHUB_OUTPUT" + # --latest=false keeps these out of the "latest release" slot, which + # belongs to the package releases changesets publishes. + - name: Publish as a release asset + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.hermes.outputs.tag }} + ARCHIVE_PATH: ${{ steps.build.outputs.path }} + run: | + if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --title "Prebuilt Hermes ($TAG)" \ + --notes "Hermes, built for Apple platforms from the commit pinned in \`packages/host/src/node/cli/hermes.ts\`. Downloaded by \`react-native-node-api prebuilt-hermes\` and injected into the app's \`pod install\` through \`HERMES_ENGINE_TARBALL_PATH\`." \ + --latest=false + fi + gh release upload "$TAG" "$ARCHIVE_PATH" --repo "$GITHUB_REPOSITORY" --clobber diff --git a/docs/CLI.md b/docs/CLI.md index 4282f403..58c8ec35 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -9,7 +9,29 @@ npx react-native-node-api [options] Run `npx react-native-node-api help` or `npx react-native-node-api help ` to see this same information from the CLI itself. > [!NOTE] -> This document is hand-written from the [Commander](https://github.com/tj/commander.js) program definition in [`packages/host/src/node/cli/program.ts`](../packages/host/src/node/cli/program.ts) (with the `vendor-hermes` command defined in [`hermes.ts`](../packages/host/src/node/cli/hermes.ts)). It needs to be kept in sync by hand whenever a command or its options change. +> This document is hand-written from the [Commander](https://github.com/tj/commander.js) program definition in [`packages/host/src/node/cli/program.ts`](../packages/host/src/node/cli/program.ts) (with the `vendor-hermes` command defined in [`hermes.ts`](../packages/host/src/node/cli/hermes.ts) and `prebuilt-hermes` in [`hermes-prebuilt.ts`](../packages/host/src/node/cli/hermes-prebuilt.ts)). It needs to be kept in sync by hand whenever a command or its options change. + +## `prebuilt-hermes [from]` + +Resolves an archive of the pinned Hermes, prebuilt for Apple platforms, and prints its path. The archive holds the `destroot` layout React Native's `hermes-engine.podspec` expects from a tarball pointed at by `HERMES_ENGINE_TARBALL_PATH`, so an app that sets that variable vendors the prebuilt frameworks instead of compiling Hermes as part of its own build. + +The archive is looked for in this order, and cached under `~/Library/Caches/react-native-node-api/hermes-prebuilt` (overridable with `REACT_NATIVE_NODE_API_CACHE_PATH`): + +1. The cache, unless `--force` is passed. +2. The [release asset](https://github.com/callstackincubator/react-native-node-api/releases) published for the pinned commit by the `Hermes prebuilt` workflow, unless `--no-download` is passed. +3. A local build from the vendored source, unless `--no-build` is passed. This requires macOS and Xcode, and takes a while — but only once per pinned commit. + +Its name covers everything that changes its contents: the pinned Hermes commit, the React Native version whose `ReactCommon/jsi` it is compiled against, the build type and the platforms. That makes it usable as a CI cache key. + +- `[from]` — Path to a file inside the app package. Defaults to the current working directory. +- `--react-native-package ` — The React Native package to resolve Hermes for. Defaults to `react-native`. +- `--build-type ` — One of `debug` or `release`. `debug` enables Hermes' debugger. Defaults to `debug`. +- `--platform ` — Apple platform to build for, repeatable. Defaults to `iphoneos` and `iphonesimulator`. +- `--silent` — Don't print anything except the final path. Defaults to `false`. +- `--force` — Re-resolve the archive even if it is already cached. Defaults to `false`. +- `--no-download` — Don't download a published archive. +- `--no-build` — Don't build the archive locally when none is published. +- `--print ` — Print `name`, `tag` or `url` of the archive instead of resolving it. ## `vendor-hermes [from]` diff --git a/packages/host/src/node/cli/hermes-prebuilt.ts b/packages/host/src/node/cli/hermes-prebuilt.ts new file mode 100644 index 00000000..125deb16 --- /dev/null +++ b/packages/host/src/node/cli/hermes-prebuilt.ts @@ -0,0 +1,429 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + chalk, + Command, + Option, + oraPromise, + spawn, + UsageError, + wrapAction, + prettyPath, +} from "@react-native-node-api/cli-utils"; +import { readPackage } from "read-pkg"; + +import { + HERMES_GIT_SHA, + ensureHermesCheckout, + reactNativePackageOption, + resolveReactNativePath, + silentOption, +} from "./hermes"; + +const RELEASES_URL = + "https://github.com/callstackincubator/react-native-node-api/releases/download"; + +export const DEFAULT_PLATFORMS = ["iphoneos", "iphonesimulator"]; + +// Passed to Hermes' build-apple-framework.sh, which errors out rather than +// assume one. These match React Native's own podspec declarations. +const DEPLOYMENT_TARGETS = { + IOS_DEPLOYMENT_TARGET: "15.1", + MAC_DEPLOYMENT_TARGET: "10.15", + XROS_DEPLOYMENT_TARGET: "1.0", +}; + +export const BUILD_TYPES = ["debug", "release"] as const; +export type BuildType = (typeof BUILD_TYPES)[number]; + +const PRINTABLE_PROPERTIES = ["name", "tag", "url"] as const; + +/** + * Set to opt out of the prebuilt archive and have the Cocoapods integration + * build Hermes from the vendored source instead — which is what you want while + * iterating on Hermes itself, since Xcode then rebuilds it incrementally. + */ +export const FROM_SOURCE_ENV_VAR = "REACT_NATIVE_NODE_API_HERMES_FROM_SOURCE"; + +export function getCacheDirectory() { + const { REACT_NATIVE_NODE_API_CACHE_PATH, XDG_CACHE_HOME } = process.env; + if (REACT_NATIVE_NODE_API_CACHE_PATH) { + return REACT_NATIVE_NODE_API_CACHE_PATH; + } else if (process.platform === "darwin") { + return path.join( + os.homedir(), + "Library", + "Caches", + "react-native-node-api", + ); + } else { + return path.join( + XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), + "react-native-node-api", + ); + } +} + +export function getPrebuiltDirectory() { + return path.join(getCacheDirectory(), "hermes-prebuilt"); +} + +/** + * Identifies an archive by everything that changes its contents. The React + * Native version is part of it because Hermes is compiled against that + * package's ReactCommon/jsi: a JSI mismatch between the framework and the app + * linking it is an ABI break. + */ +export function getArchiveName({ + reactNativeVersion, + buildType, + platforms, +}: { + reactNativeVersion: string; + buildType: BuildType; + platforms: string[]; +}) { + const shortSha = HERMES_GIT_SHA.slice(0, 12); + // GitHub rewrites every character outside [A-Za-z0-9._-] in a release asset + // name, so the name has to stay within that set to survive a round-trip. + const platformSuffix = [...platforms].sort().join("-"); + return `hermes-${shortSha}-rn${reactNativeVersion}-${buildType}-${platformSuffix}.tar.gz`; +} + +export function getReleaseTag() { + return `hermes-prebuilt-${HERMES_GIT_SHA.slice(0, 12)}`; +} + +export function getDownloadUrl(archiveName: string) { + return `${RELEASES_URL}/${getReleaseTag()}/${encodeURIComponent(archiveName)}`; +} + +/** + * @returns true if the archive was downloaded, false if the release doesn't + * publish one for this combination (yet). + */ +async function downloadArchive(url: string, archivePath: string) { + const response = await fetch(url); + if (response.status === 404) { + return false; + } else if (!response.ok) { + throw new Error( + `Unexpected response downloading ${url}: ${response.status} ${response.statusText}`, + ); + } + const downloadPath = `${archivePath}.download`; + await fs.promises.writeFile( + downloadPath, + Buffer.from(await response.arrayBuffer()), + ); + // Renaming last keeps a half-written download from passing as a cache hit. + await fs.promises.rename(downloadPath, archivePath); + return true; +} + +/** + * Builds the destroot layout React Native's hermes-engine.podspec expects from + * a prebuilt tarball, and archives it. + * + * The per-platform framework builds are delegated to the Hermes checkout's own + * utils/build-apple-framework.sh, which is the script that knows how to build + * that particular source tree. Everything around it — the host compiler, the + * universal XCFramework and the archive — is assembled here. + */ +async function buildArchive({ + reactNativePath, + archivePath, + buildType, + platforms, + silent, +}: { + reactNativePath: string; + archivePath: string; + buildType: BuildType; + platforms: string[]; + silent: boolean; +}) { + const hermesPath = await ensureHermesCheckout({ + reactNativePath, + force: false, + silent, + }); + const hermescPath = path.join(hermesPath, "build_host_hermesc"); + const importHostCompilersPath = path.join( + hermescPath, + "ImportHostCompilers.cmake", + ); + // Hermes is compiled against the app's React Native JSI headers, not its own + // vendored copy: the framework and the app linking it share jsi::Runtime. + const jsiPath = path.join(reactNativePath, "ReactCommon", "jsi"); + + const run = (command: string, args: string[]) => + spawn(command, args, { + cwd: hermesPath, + outputMode: "inherit", + // Keeps the build log off stdout, which callers parse for the final path. + stdout: process.stderr, + env: { + ...DEPLOYMENT_TARGETS, + ...process.env, + JSI_PATH: jsiPath, + BUILD_TYPE: buildType === "debug" ? "Debug" : "Release", + HERMES_OVERRIDE_HERMESC_PATH: importHostCompilersPath, + }, + }); + + // Configured here instead of letting build-apple-framework.sh's + // build_host_hermesc do it: that one takes no architectures, and the hermesc + // we ship has to run on both Apple Silicon and Intel Macs. + if (!fs.existsSync(importHostCompilersPath)) { + await run("cmake", [ + "-S", + ".", + "-B", + hermescPath, + `-DJSI_DIR=${jsiPath}`, + "-DCMAKE_BUILD_TYPE=Release", + "-DCMAKE_OSX_ARCHITECTURES=arm64;x86_64", + ]); + await run("cmake", [ + "--build", + hermescPath, + "--target", + "hermesc", + "-j", + os.availableParallelism().toString(), + ]); + } + + for (const platform of platforms) { + await run("./utils/build-apple-framework.sh", [platform]); + } + + const frameworksPath = path.join( + hermesPath, + "destroot", + "Library", + "Frameworks", + ); + // hermes-engine.podspec vendors macOS as a plain framework and every other + // platform out of the universal XCFramework, so macosx stays where it is. + const xcframeworkPlatforms = platforms.filter( + (platform) => platform !== "macosx", + ); + const xcframeworkPath = path.join( + frameworksPath, + "universal", + "hermesvm.xcframework", + ); + if (xcframeworkPlatforms.length > 0 && !fs.existsSync(xcframeworkPath)) { + await run("xcodebuild", [ + "-create-xcframework", + ...xcframeworkPlatforms.flatMap((platform) => [ + "-framework", + path.join(frameworksPath, platform, "hermesvm.framework"), + "-debug-symbols", + path.join(frameworksPath, platform, "hermesvm.framework.dSYM"), + ]), + "-output", + xcframeworkPath, + ]); + for (const platform of xcframeworkPlatforms) { + await fs.promises.rm(path.join(frameworksPath, platform), { + recursive: true, + force: true, + }); + } + } + + // react-native-xcode.sh falls back to destroot/bin/hermesc when + // HERMES_CLI_PATH is unset, and the podspec deliberately doesn't point that + // at the hermes-compiler npm package for local tarballs: the compiler has to + // emit bytecode this VM can read. + const binPath = path.join(hermesPath, "destroot", "bin"); + await fs.promises.mkdir(binPath, { recursive: true }); + await fs.promises.copyFile( + path.join(hermescPath, "bin", "hermesc"), + path.join(binPath, "hermesc"), + ); + + await fs.promises.mkdir(path.dirname(archivePath), { recursive: true }); + // LICENSE rides along so the archive has more than one top-level entry: + // CocoaPods flattens an archive whose sole entry is a directory, which would + // strip the destroot/ prefix that every path in hermes-engine.podspec assumes. + const partialPath = `${archivePath}.partial`; + await run("tar", ["-czf", partialPath, "destroot", "LICENSE"]); + // Renaming last keeps an interrupted archive from passing as a cache hit. + await fs.promises.rename(partialPath, archivePath); +} + +export async function resolvePrebuiltHermes({ + reactNativePath, + buildType, + platforms, + download, + build, + force, + silent, +}: { + reactNativePath: string; + buildType: BuildType; + platforms: string[]; + download: boolean; + build: boolean; + force: boolean; + silent: boolean; +}) { + const { version: reactNativeVersion } = await readPackage({ + cwd: reactNativePath, + }); + const archiveName = getArchiveName({ + reactNativeVersion, + buildType, + platforms, + }); + const archivePath = path.join(getPrebuiltDirectory(), archiveName); + + if (force) { + await fs.promises.rm(archivePath, { force: true }); + } else if (fs.existsSync(archivePath)) { + return archivePath; + } + + await fs.promises.mkdir(path.dirname(archivePath), { recursive: true }); + + if (download) { + const url = getDownloadUrl(archiveName); + const downloaded = await oraPromise(downloadArchive(url, archivePath), { + text: `Downloading prebuilt Hermes from ${chalk.dim(url)}`, + successText: (published) => + published + ? `Downloaded prebuilt Hermes into ${prettyPath(archivePath)}` + : "No prebuilt Hermes published for this React Native version", + failText: (error) => + `Failed to download prebuilt Hermes: ${error.message}`, + isSilent: silent, + }); + if (downloaded) { + return archivePath; + } + } + + if (!build) { + throw new UsageError(`Found no prebuilt Hermes archive ${archiveName}`, { + fix: { + instructions: `Drop --no-build to build it locally, or set ${chalk.bold(FROM_SOURCE_ENV_VAR)}=1 to build Hermes from source as part of the app build instead.`, + }, + }); + } + + if (process.platform !== "darwin") { + throw new UsageError( + "Building Hermes for Apple platforms requires macOS and Xcode", + ); + } + + if (!silent) { + console.error( + `Building Hermes ${HERMES_GIT_SHA.slice(0, 12)} for ${platforms.join(", ")} — this takes a while, but only once per pinned commit.`, + ); + } + await buildArchive({ + reactNativePath, + archivePath, + buildType, + platforms, + silent, + }); + return archivePath; +} + +function collectPlatform(value: string, previous: string[] | undefined) { + return [...(previous ?? []), value]; +} + +export const command = new Command("prebuilt-hermes") + .description( + "Resolve an archive of the pinned Hermes, prebuilt for Apple platforms, printing its path", + ) + .argument("[from]", "Path to a file inside the app package", process.cwd()) + .addOption(silentOption) + .option( + "--force", + "Re-resolve the archive even if it is already cached", + false, + ) + .addOption(reactNativePackageOption) + .addOption( + new Option("--build-type ", "The Hermes build type") + .choices(BUILD_TYPES) + .default("debug"), + ) + .option( + "--platform ", + `Apple platform to build for, repeatable (default: ${DEFAULT_PLATFORMS.join(", ")})`, + collectPlatform, + ) + .option("--no-download", "Don't download a published archive") + .option( + "--no-build", + "Don't build the archive locally when none is published", + ) + .addOption( + new Option( + "--print ", + "Print a property of the archive instead of resolving it", + ).choices(PRINTABLE_PROPERTIES), + ) + .action( + wrapAction( + async ( + from, + { + silent, + force, + reactNativePackage, + buildType, + platform, + download, + build, + print, + }, + ) => { + const platforms = platform ?? DEFAULT_PLATFORMS; + const reactNativePath = await resolveReactNativePath( + from, + reactNativePackage, + ); + if (print) { + const { version: reactNativeVersion } = await readPackage({ + cwd: reactNativePath, + }); + const archiveName = getArchiveName({ + reactNativeVersion, + buildType, + platforms, + }); + if (print === "name") { + console.log(archiveName); + } else if (print === "tag") { + console.log(getReleaseTag()); + } else { + console.log(getDownloadUrl(archiveName)); + } + return; + } + const archivePath = await resolvePrebuiltHermes({ + reactNativePath, + buildType, + platforms, + download, + build, + force, + silent, + }); + console.log(archivePath); + }, + ), + ); diff --git a/packages/host/src/node/cli/hermes.ts b/packages/host/src/node/cli/hermes.ts index 74f8680b..1c0b4a00 100644 --- a/packages/host/src/node/cli/hermes.ts +++ b/packages/host/src/node/cli/hermes.ts @@ -15,7 +15,7 @@ import { import { packageDirectory } from "pkg-dir"; import { readPackage } from "read-pkg"; -const HERMES_GIT_URL = "https://github.com/facebook/hermes.git"; +export const HERMES_GIT_URL = "https://github.com/facebook/hermes.git"; // Pinned commit on the `static_h` branch, which carries the first-party // Node-API implementation under `API/napi`. Bump deliberately: the JSI @@ -44,100 +44,142 @@ const HERMES_GIT_URL = "https://github.com/facebook/hermes.git"; // cpp/HermesNapiHost.hpp against `API/napi/hermes_napi.h` at the new commit: // the struct is mirrored there (not included) and any change to its member // order or signatures is an ABI break the compiler cannot catch. -const HERMES_GIT_SHA = "5a795c9f880002c862c9254a26b57199819c97f7"; +export const HERMES_GIT_SHA = "5a795c9f880002c862c9254a26b57199819c97f7"; -const platformOption = new Option( +export const reactNativePackageOption = new Option( "--react-native-package ", "The React Native package to vendor Hermes into", ).default("react-native"); +export const silentOption = new Option( + "--silent", + "Don't print anything except the final path", +).default(false); + +/** + * Locate the React Native package the app at `from` actually resolves to. + */ +export async function resolveReactNativePath( + from: string, + reactNativePackage: string, +) { + const appPackageRoot = await packageDirectory({ cwd: from }); + assert(appPackageRoot, "Failed to find package root"); + + const { dependencies = {} } = await readPackage({ cwd: appPackageRoot }); + assert( + Object.keys(dependencies).includes(reactNativePackage), + `Expected app to have a dependency on the '${reactNativePackage}' package`, + ); + + return path.dirname( + require.resolve(reactNativePackage + "/package.json", { + // Ensures we'll be patching the React Native package actually used by the app + paths: [appPackageRoot], + }), + ); +} + +export function getHermesPath(reactNativePath: string) { + return path.join(reactNativePath, "sdks", "node-api-hermes"); +} + +/** + * Clone the pinned Hermes commit into the React Native package, unless it is + * already there. + */ +export async function ensureHermesCheckout({ + reactNativePath, + force, + silent, +}: { + reactNativePath: string; + force: boolean; + silent: boolean; +}) { + const hermesPath = getHermesPath(reactNativePath); + if (force && fs.existsSync(hermesPath)) { + await oraPromise( + fs.promises.rm(hermesPath, { recursive: true, force: true }), + { + text: "Removing existing Hermes clone", + successText: "Removed existing Hermes clone", + failText: (error) => + `Failed to remove existing Hermes clone: ${error.message}`, + isSilent: silent, + }, + ); + } + if (!fs.existsSync(hermesPath)) { + try { + // GitHub allows fetching a reachable commit by SHA, so we can clone + // the pinned commit shallowly without downloading the whole history. + await oraPromise( + (async () => { + await fs.promises.mkdir(hermesPath, { recursive: true }); + const git = (args: string[]) => + spawn("git", args, { + cwd: hermesPath, + outputMode: "buffered", + }); + await git(["init", "--quiet"]); + await git(["remote", "add", "origin", HERMES_GIT_URL]); + await git(["fetch", "--depth", "1", "origin", HERMES_GIT_SHA]); + await git(["checkout", "--quiet", "FETCH_HEAD"]); + await git([ + "submodule", + "update", + "--init", + "--recursive", + "--depth", + "1", + ]); + })(), + { + text: `Cloning Hermes into ${prettyPath(hermesPath)}`, + successText: "Cloned Hermes", + failText: (err) => `Failed to clone Hermes: ${err.message}`, + isSilent: silent, + }, + ); + } catch (error) { + // A failed clone can leave a partial checkout behind, which would + // make the existence check above skip re-cloning on the next run. + await fs.promises.rm(hermesPath, { recursive: true, force: true }); + throw new UsageError("Failed to clone Hermes", { + cause: error, + fix: { + instructions: `Check the network connection and that the pinned Hermes commit ${chalk.bold(HERMES_GIT_SHA)} is still reachable on ${chalk.bold(HERMES_GIT_URL)}.`, + }, + }); + } + } + return hermesPath; +} + export const command = new Command("vendor-hermes") .argument("[from]", "Path to a file inside the app package", process.cwd()) - .option("--silent", "Don't print anything except the final path", false) + .addOption(silentOption) .option( "--force", "Don't check timestamps of input files to skip unnecessary rebuilds", false, ) - .addOption(platformOption) + .addOption(reactNativePackageOption) .action( wrapAction(async (from, { force, silent, reactNativePackage }) => { - const appPackageRoot = await packageDirectory({ cwd: from }); - assert(appPackageRoot, "Failed to find package root"); - - const { dependencies = {} } = await readPackage({ cwd: appPackageRoot }); - assert( - Object.keys(dependencies).includes(reactNativePackage), - `Expected app to have a dependency on the '${reactNativePackage}' package`, - ); - - const reactNativePath = path.dirname( - require.resolve(reactNativePackage + "/package.json", { - // Ensures we'll be patching the React Native package actually used by the app - paths: [appPackageRoot], - }), + const reactNativePath = await resolveReactNativePath( + from, + reactNativePackage, ); if (!silent) { console.log(`Vendoring Hermes at ${HERMES_GIT_SHA}`); } - - const hermesPath = path.join(reactNativePath, "sdks", "node-api-hermes"); - if (force && fs.existsSync(hermesPath)) { - await oraPromise( - fs.promises.rm(hermesPath, { recursive: true, force: true }), - { - text: "Removing existing Hermes clone", - successText: "Removed existing Hermes clone", - failText: (error) => - `Failed to remove existing Hermes clone: ${error.message}`, - isSilent: silent, - }, - ); - } - if (!fs.existsSync(hermesPath)) { - try { - // GitHub allows fetching a reachable commit by SHA, so we can clone - // the pinned commit shallowly without downloading the whole history. - await oraPromise( - (async () => { - await fs.promises.mkdir(hermesPath, { recursive: true }); - const git = (args: string[]) => - spawn("git", args, { - cwd: hermesPath, - outputMode: "buffered", - }); - await git(["init", "--quiet"]); - await git(["remote", "add", "origin", HERMES_GIT_URL]); - await git(["fetch", "--depth", "1", "origin", HERMES_GIT_SHA]); - await git(["checkout", "--quiet", "FETCH_HEAD"]); - await git([ - "submodule", - "update", - "--init", - "--recursive", - "--depth", - "1", - ]); - })(), - { - text: `Cloning Hermes into ${prettyPath(hermesPath)}`, - successText: "Cloned Hermes", - failText: (err) => `Failed to clone Hermes: ${err.message}`, - isSilent: silent, - }, - ); - } catch (error) { - // A failed clone can leave a partial checkout behind, which would - // make the existence check above skip re-cloning on the next run. - await fs.promises.rm(hermesPath, { recursive: true, force: true }); - throw new UsageError("Failed to clone Hermes", { - cause: error, - fix: { - instructions: `Check the network connection and that the pinned Hermes commit ${chalk.bold(HERMES_GIT_SHA)} is still reachable on ${chalk.bold(HERMES_GIT_URL)}.`, - }, - }); - } - } + const hermesPath = await ensureHermesCheckout({ + reactNativePath, + force, + silent, + }); console.log(hermesPath); }), ); diff --git a/packages/host/src/node/cli/program.ts b/packages/host/src/node/cli/program.ts index 77d8b8f4..9078d26f 100644 --- a/packages/host/src/node/cli/program.ts +++ b/packages/host/src/node/cli/program.ts @@ -22,14 +22,15 @@ import { } from "../path-utils"; import { command as vendorHermes } from "./hermes"; +import { command as prebuiltHermes } from "./hermes-prebuilt"; import { packageNameOption, pathSuffixOption } from "./options"; import { linkModules, pruneLinkedModules, ModuleLinker } from "./link-modules"; import { ensureXcodeBuildPhase, createAppleLinker } from "./apple"; import { linkAndroidDir } from "./android"; -export const program = new Command("react-native-node-api").addCommand( - vendorHermes, -); +export const program = new Command("react-native-node-api") + .addCommand(vendorHermes) + .addCommand(prebuiltHermes); async function createLinker(platform: PlatformName): Promise { if (platform === "android") { From 00f4062e11305cb5d16264d66a4bb3262b1db191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 13 Aug 2026 14:10:25 +0000 Subject: [PATCH 2/2] Trigger CI with the labels attached The workflow's pull_request trigger doesn't fire on `labeled`, so the label-gated jobs need a synchronize event to be evaluated against. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UkNbgdyuKgHaFwT27RahGH