From 9b96b409afb3fdff3b9161facdb6e78c81308493 Mon Sep 17 00:00:00 2001 From: mateuuszzzzz Date: Wed, 7 Oct 2026 12:42:12 +0200 Subject: [PATCH 1/7] Implement hermes version check in local build --- .../publishReactNativeAndroidArtifacts.yml | 9 ++ .../publishReactNativeiOSArtifacts.yml | 11 ++- cspell.json | 3 + ios/Podfile | 2 - patches/react-native/details.md | 2 +- ...act-native+0.86.0+017+publish-gradle.patch | 7 +- .../android/ExpensiUtils.gradle | 10 +- .../android/PatchedArtifactsSettings.gradle | 6 +- scripts/artifacts-utils/get-hermes-version.sh | 27 +++++ .../ios/patched_ios_artifacts.rb | 5 +- .../artifacts-utils/ios/publish/build.gradle | 2 + .../artifacts-utils/lib/artifactsResolver.ts | 50 ++++++++-- scripts/artifacts-utils/resolve-artifacts.ts | 12 ++- tests/tooling/artifactsResolver.test.ts | 98 ++++++++++++++++--- 14 files changed, 209 insertions(+), 35 deletions(-) create mode 100644 scripts/artifacts-utils/get-hermes-version.sh diff --git a/.github/workflows/publishReactNativeAndroidArtifacts.yml b/.github/workflows/publishReactNativeAndroidArtifacts.yml index 8048abe07e4d..a89b00533d3e 100644 --- a/.github/workflows/publishReactNativeAndroidArtifacts.yml +++ b/.github/workflows/publishReactNativeAndroidArtifacts.yml @@ -69,6 +69,13 @@ jobs: with: IS_HYBRID_BUILD: ${{ matrix.is_hybrid }} + # react-android is compiled against the Hermes headers that hermes-engine builds from this version (its + # hermesVersionProvider reads the same file). Recording it in the POM lets the resolver reject an artifact + # whose Hermes differs from the one the consuming app links. + - name: Resolve Hermes version + id: hermes + run: echo "VERSION=$(./scripts/artifacts-utils/get-hermes-version.sh)" >> "$GITHUB_OUTPUT" + - name: Get Java version id: get-java-version uses: ./.github/actions/composite/getJavaVersion @@ -97,6 +104,7 @@ jobs: echo "Starting artifacts build for ${{ matrix.is_hybrid == 'true' && 'HybridApp' || 'NewDot Standalone' }}" echo "Version: ${{ env.PATCHED_VERSION }}" echo "Patches hash: ${{ env.PATCHES_HASH }}" + echo "Hermes version: ${{ env.HERMES_VERSION }}" export ORG_GRADLE_PROJECT_reactNativeArchitectures="armeabi-v7a,arm64-v8a,x86,x86_64" # Exclude ktfmt and test tasks from the included react-native build @@ -117,6 +125,7 @@ jobs: IS_HYBRID_BUILD: ${{ matrix.is_hybrid }} PATCHED_VERSION: ${{ steps.getNewPatchedVersion.outputs.NEW_PATCHED_VERSION }} PATCHES_HASH: ${{ steps.computePatchesHash.outputs.PATCHES_HASH }} + HERMES_VERSION: ${{ steps.hermes.outputs.VERSION }} CMAKE_VERSION: 3.31.6 - name: Announce failed workflow in Slack diff --git a/.github/workflows/publishReactNativeiOSArtifacts.yml b/.github/workflows/publishReactNativeiOSArtifacts.yml index 56aebc1d2322..dde474575a3e 100644 --- a/.github/workflows/publishReactNativeiOSArtifacts.yml +++ b/.github/workflows/publishReactNativeiOSArtifacts.yml @@ -64,7 +64,7 @@ jobs: - name: Resolve Hermes version id: hermes - run: echo "VERSION=$(sed 's/^hermes-v\{0,1\}//' node_modules/react-native/sdks/.hermesv1version)" >> "$GITHUB_OUTPUT" + run: echo "VERSION=$(./scripts/artifacts-utils/get-hermes-version.sh)" >> "$GITHUB_OUTPUT" - name: Fetch prebuild files from upstream RN uses: ./.github/actions/composite/fetchReactNativePrebuild @@ -219,6 +219,12 @@ jobs: echo "PATCHES_HASH=$(./scripts/artifacts-utils/compute-patches-hash.sh patches)" >> "$GITHUB_OUTPUT" fi + # The slices were compiled against this Hermes (see the buildSlice job). Recording it in the POM lets the + # resolver reject an artifact whose Hermes differs from the one the consuming app links. + - name: Resolve Hermes version + id: hermes + run: echo "VERSION=$(./scripts/artifacts-utils/get-hermes-version.sh)" >> "$GITHUB_OUTPUT" + - name: Determine new patched RN version id: getNewPatchedVersion run: echo "NEW_PATCHED_VERSION=$(./.github/scripts/getNewPatchedRNVersion.sh)" >> "$GITHUB_OUTPUT" @@ -235,12 +241,13 @@ jobs: merge-multiple: true - name: Publish to GitHub Packages Maven - run: ./android/gradlew -p scripts/artifacts-utils/ios/publish publish -PartifactsDir="/tmp/ios-artifacts" -PpatchedVersion="${PATCHED_VERSION}" -PpatchesHash="${PATCHES_HASH}" -PpackageName="${PACKAGE_NAME}" + run: ./android/gradlew -p scripts/artifacts-utils/ios/publish publish -PartifactsDir="/tmp/ios-artifacts" -PpatchedVersion="${PATCHED_VERSION}" -PpatchesHash="${PATCHES_HASH}" -PhermesVersion="${HERMES_VERSION}" -PpackageName="${PACKAGE_NAME}" env: GH_PUBLISH_ACTOR: ${{ github.actor }} GH_PUBLISH_TOKEN: ${{ github.token }} PATCHED_VERSION: ${{ steps.getNewPatchedVersion.outputs.NEW_PATCHED_VERSION }} PATCHES_HASH: ${{ steps.computePatchesHash.outputs.PATCHES_HASH }} + HERMES_VERSION: ${{ steps.hermes.outputs.VERSION }} PACKAGE_NAME: ${{ matrix.is_hybrid == 'true' && 'react-hybrid' || 'react-standalone' }} - name: Announce failed workflow in Slack diff --git a/cspell.json b/cspell.json index 232fb5466088..fa934b38d135 100644 --- a/cspell.json +++ b/cspell.json @@ -709,6 +709,9 @@ "healthcheck", "helpdot", "helpsite", + "hermestag", + "hermesv", + "hermesversion", "hexcode", "hibob", "hrefs", diff --git a/ios/Podfile b/ios/Podfile index 12d17f06cc44..19483e998210 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -93,8 +93,6 @@ target 'NewExpensify' do # ENV Variable enables/disables TurboModules ENV['RCT_NEW_ARCH_ENABLED'] = '1'; - # ENV Variable enables/disables Hermes V1 - ENV['RCT_HERMES_V1_ENABLED'] = '1'; use_react_native!( diff --git a/patches/react-native/details.md b/patches/react-native/details.md index cf23a98aaba4..cce643e18274 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -128,7 +128,7 @@ ### [react-native+0.86.0+017+publish-gradle.patch](react-native+0.86.0+017+publish-gradle.patch) -- Reason: This patch customizes the Gradle publishing script to allow publishing our custom React Native artifacts to GitHub Packages. +- Reason: This patch customizes the Gradle publishing script to allow publishing our custom React Native artifacts to GitHub Packages. The POM records the patches hash and the Hermes version the artifact was compiled against, which `scripts/artifacts-utils/lib/artifactsResolver.ts` matches before consuming an artifact. - Upstream PR/issue: šŸ›‘ - E/App issue: šŸ›‘ - PR Introducing Patch: https://github.com/Expensify/App/pull/59738 diff --git a/patches/react-native/react-native+0.86.0+017+publish-gradle.patch b/patches/react-native/react-native+0.86.0+017+publish-gradle.patch index fb645f0c446e..ceecc6e4f98d 100644 --- a/patches/react-native/react-native+0.86.0+017+publish-gradle.patch +++ b/patches/react-native/react-native+0.86.0+017+publish-gradle.patch @@ -7,7 +7,7 @@ new file mode 100644 index 0000000..8bfcb73 Binary files /dev/null and b/node_modules/react-native/ReactAndroid/.DS_Store differ diff --git a/node_modules/react-native/ReactAndroid/publish.gradle b/node_modules/react-native/ReactAndroid/publish.gradle -index 32287a7..5607be3 100644 +index 32287a7..77f9452 100644 --- a/node_modules/react-native/ReactAndroid/publish.gradle +++ b/node_modules/react-native/ReactAndroid/publish.gradle @@ -13,7 +13,11 @@ def signingKey = findProperty("SIGNING_KEY") @@ -40,12 +40,13 @@ index 32287a7..5607be3 100644 } pom { -@@ -39,10 +45,14 @@ publishing { +@@ -39,10 +45,15 @@ publishing { description = "A framework for building native apps with React" url = "https://github.com/facebook/react-native" + properties = [ + "patchesHash": System.getenv("PATCHES_HASH"), ++ "hermesVersion": System.getenv("HERMES_VERSION"), + ] + developers { @@ -57,7 +58,7 @@ index 32287a7..5607be3 100644 } } -@@ -65,8 +75,11 @@ publishing { +@@ -65,8 +76,11 @@ publishing { repositories { maven { diff --git a/scripts/artifacts-utils/android/ExpensiUtils.gradle b/scripts/artifacts-utils/android/ExpensiUtils.gradle index 11180e6d7750..6b445b38422a 100644 --- a/scripts/artifacts-utils/android/ExpensiUtils.gradle +++ b/scripts/artifacts-utils/android/ExpensiUtils.gradle @@ -26,11 +26,17 @@ ext.ExpensiUtils = new Object() { return [output: process.in.text?.trim(), exitCode: process.exitValue()] } + // The Hermes tag file react-native builds (and we publish) against for the given flag. Mirrors hermesVersionProvider + // in ReactAndroid/hermes-engine/build.gradle.kts, which is project-phase code the settings phase cannot reach. + String hermesVersionFile(String newDotRootDir, boolean hermesV1Enabled) { + return "${newDotRootDir}/node_modules/react-native/sdks/${hermesV1Enabled ? '.hermesv1version' : '.hermesversion'}" + } + // Returns [buildFromSource, version, githubUsername, githubToken]; any failure -> build from source. - Map resolveArtifacts(String newDotRootDir, boolean isHybrid) { + Map resolveArtifacts(String newDotRootDir, boolean isHybrid, String hermesVersionFile) { def cmd = [ "bun", "${newDotRootDir}/scripts/artifacts-utils/resolve-artifacts.ts", - "--platform=android", "--hybrid=${isHybrid}", "--new-dot-root=${newDotRootDir}" + "--platform=android", "--hybrid=${isHybrid}", "--new-dot-root=${newDotRootDir}", "--hermes-version-file=${hermesVersionFile}" ] try { def result = runCommand(cmd, 120, new File(newDotRootDir)) diff --git a/scripts/artifacts-utils/android/PatchedArtifactsSettings.gradle b/scripts/artifacts-utils/android/PatchedArtifactsSettings.gradle index 0c7987888858..0a8d1a4c7141 100644 --- a/scripts/artifacts-utils/android/PatchedArtifactsSettings.gradle +++ b/scripts/artifacts-utils/android/PatchedArtifactsSettings.gradle @@ -25,7 +25,11 @@ settings.extensions.configure(PatchedArtifactsConfig) { config -> config.packageName = getProperty('patchedArtifacts.packageName') def isHybrid = config.packageName == 'react-hybrid' - def resolution = ExpensiUtils.resolveArtifacts(getNewDotRootDir().toString(), isHybrid) + // Same default as the react-native Gradle plugin: Hermes V1 unless `hermesV1Enabled` (gradle.properties) opts out. + // The resolver only accepts an artifact compiled against the Hermes this flag makes the build link. + def hermesV1Enabled = hasProperty('hermesV1Enabled') ? getProperty('hermesV1Enabled').toString().toBoolean() : true + def newDotRootDir = getNewDotRootDir().toString() + def resolution = ExpensiUtils.resolveArtifacts(newDotRootDir, isHybrid, ExpensiUtils.hermesVersionFile(newDotRootDir, hermesV1Enabled)) config.version = resolution.version config.buildFromSource = resolution.buildFromSource diff --git a/scripts/artifacts-utils/get-hermes-version.sh b/scripts/artifacts-utils/get-hermes-version.sh new file mode 100644 index 000000000000..5e041c1cd4ea --- /dev/null +++ b/scripts/artifacts-utils/get-hermes-version.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Prints the Hermes version our prebuilt artifacts are compiled against, without the `hermes-v` tag prefix +# (e.g. `250829098.0.14`). Both platforms read `sdks/.hermesv1version` when building: `ios-prebuild.js` through +# HERMES_VERSION, and `hermes-engine/build.gradle.kts` through its hermesVersionProvider. +# +# The published POM records this value so that the artifacts resolver can compare it with the Hermes the consuming +# app links. The artifacts do not bundle a Hermes engine, but their React code is compiled against the ABI of one, so +# an artifact built against a different Hermes is unusable. We cannot predict how upcoming upstream versions will +# pick the Hermes version (which file, whether V1 stays opt-in), so this tag is the safeguard against silently +# consuming artifacts built against the wrong ABI: on a mismatch the consumer falls back to a source build and +# names both versions in its log, instead of failing at link time or at runtime. + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)" +readonly SCRIPT_DIR +source "$SCRIPT_DIR/../shellUtils.sh" + +NEW_DOT_ROOT="${1:-$SCRIPT_DIR/../..}" +readonly NEW_DOT_ROOT +readonly HERMES_VERSION_FILE="$NEW_DOT_ROOT/node_modules/react-native/sdks/.hermesv1version" + +if [[ ! -f "$HERMES_VERSION_FILE" ]]; then + error "Hermes version file not found: $HERMES_VERSION_FILE" + exit 1 +fi + +sed 's/^hermes-v\{0,1\}//' "$HERMES_VERSION_FILE" diff --git a/scripts/artifacts-utils/ios/patched_ios_artifacts.rb b/scripts/artifacts-utils/ios/patched_ios_artifacts.rb index 05724431e811..3fcf9649a825 100644 --- a/scripts/artifacts-utils/ios/patched_ios_artifacts.rb +++ b/scripts/artifacts-utils/ios/patched_ios_artifacts.rb @@ -323,9 +323,12 @@ def self.download_and_verify(url, destination, github_token) end def self.resolve(is_hybrid) + react_native_path = File.join(NEW_DOT_ROOT, 'node_modules/react-native') + require_relative File.join(react_native_path, 'sdks/hermes-engine/hermes-utils.rb') + hermes_version_file = hermestag_file(react_native_path) cmd = [ 'bun', File.join(NEW_DOT_ROOT, 'scripts/artifacts-utils/resolve-artifacts.ts'), - '--platform=ios', "--hybrid=#{is_hybrid}", "--new-dot-root=#{NEW_DOT_ROOT}" + '--platform=ios', "--hybrid=#{is_hybrid}", "--new-dot-root=#{NEW_DOT_ROOT}", "--hermes-version-file=#{hermes_version_file}" ] # stdout is pure JSON; the resolver logs to stderr. output = IO.popen(cmd, chdir: NEW_DOT_ROOT, &:read) diff --git a/scripts/artifacts-utils/ios/publish/build.gradle b/scripts/artifacts-utils/ios/publish/build.gradle index 3419b6c11b30..b1a9495fa893 100644 --- a/scripts/artifacts-utils/ios/publish/build.gradle +++ b/scripts/artifacts-utils/ios/publish/build.gradle @@ -3,6 +3,7 @@ apply plugin: 'maven-publish' def artifactsDir = findProperty('artifactsDir') ?: error('artifactsDir property is required') def patchedVersion = findProperty('patchedVersion') ?: error('patchedVersion property is required') def patchesHash = findProperty('patchesHash') ?: error('patchesHash property is required') +def hermesVersion = findProperty('hermesVersion') ?: error('hermesVersion property is required') def packageName = findProperty('packageName') ?: error('packageName property is required') configurations.maybeCreate('iosArtifacts') @@ -50,6 +51,7 @@ publishing { properties = [ 'patchesHash': patchesHash, + 'hermesVersion': hermesVersion, ] developers { diff --git a/scripts/artifacts-utils/lib/artifactsResolver.ts b/scripts/artifacts-utils/lib/artifactsResolver.ts index c78b90c4d3e9..d9f11ec9e6b0 100644 --- a/scripts/artifacts-utils/lib/artifactsResolver.ts +++ b/scripts/artifacts-utils/lib/artifactsResolver.ts @@ -13,8 +13,8 @@ import {getCredentials as getCredentialsFromCLI} from './githubCLI'; /** * Shared resolver for Expensify's patched React Native prebuilt artifacts. - * Resolves which artifact version to use by matching the local patches hash - * against the `patchesHash` recorded in each candidate's Maven POM. Consumed via + * Resolves which artifact version to use by matching the local patches hash and Hermes version + * against the `patchesHash` and `hermesVersion` recorded in each candidate's Maven POM. Consumed via * `resolve-artifacts.ts` by Gradle (Android) and `patched_ios_artifacts.rb` (iOS). */ @@ -30,8 +30,16 @@ type ResolveOptions = { packageName: string; newDotRoot: string; isHybrid: boolean; + /** + * React Native's Hermes tag file for the engine this build links (`sdks/.hermesv1version`, or + * `sdks/.hermesversion` with Hermes V1 disabled). The native caller picks it, since it owns the V1 flag. + */ + hermesVersionFile: string; }; +/** What a published artifact was built from, as recorded in its POM's ``. A property the POM does not carry is null. */ +type PomProperties = {patchesHash: string | null; hermesVersion: string | null}; + /** No prebuilt match (or no credentials): the caller builds react-native from source. Carries no secrets. */ type SourceBuild = { buildFromSource: true; @@ -146,6 +154,23 @@ function computePatchesHash(newDotRoot: string, isHybrid: boolean): string { return execFileSync('bash', args, {encoding: 'utf8'}).trim(); } +/** + * The Hermes version this build links, derived from its tag file the same way the publish workflows derive the + * version they record (`get-hermes-version.sh`), so `hermes-v250829098.0.14` becomes `250829098.0.14`. The artifacts + * are not bundled with a Hermes engine, but their React code is compiled against the headers of one, so an artifact + * is only usable when its recorded Hermes version is the one this build links. + */ +function getLocalHermesVersion(hermesVersionFile: string): string { + const version = fs + .readFileSync(hermesVersionFile, 'utf8') + .trim() + .replace(/^hermes-v?/, ''); + if (!version) { + throw new Error(`Could not read the Hermes version from ${hermesVersionFile}`); + } + return version; +} + /** Published versions of the package that were built from the given react-native version. Requires `initGithubClient`. */ async function getArtifactsCandidates(packageName: string, artifactId: string, rnVersion: string): Promise { /* eslint-disable @typescript-eslint/naming-convention -- GitHub REST API params are snake_case */ @@ -158,22 +183,31 @@ async function getArtifactsCandidates(packageName: string, artifactId: string, r return versions.map((version) => version.name).filter((name) => name.startsWith(rnVersion)); } -async function getRemotePatchesHash(packageName: string, artifactId: string, version: string, githubToken: string): Promise { +function readPomProperty(pom: string, name: string): string | null { + return pom.match(new RegExp(`<${name}>([^<]+)`))?.[1]?.trim() ?? null; +} + +async function getRemotePomProperties(packageName: string, artifactId: string, version: string, githubToken: string): Promise { const pom = await fetchWithToken(`${getArtifactUrlPrefix(packageName, artifactId, version)}.pom`, githubToken); - return pom.match(/([^<]+)<\/patchesHash>/)?.[1]?.trim() ?? null; + return {patchesHash: readPomProperty(pom, 'patchesHash'), hermesVersion: readPomProperty(pom, 'hermesVersion')}; } -/** Returns null when no published artifact was built from the local patches — a legitimate result, not a failure. */ +/** Returns null when no published artifact was built from the local patches and Hermes. That is a legitimate result, not a failure. */ async function findMatchingArtifactsVersion(options: ResolveOptions, artifactId: string, githubToken: string): Promise { - const {packageName, newDotRoot, isHybrid} = options; + const {packageName, newDotRoot, isHybrid, hermesVersionFile} = options; const localPatchesHash = computePatchesHash(newDotRoot, isHybrid); + const localHermesVersion = getLocalHermesVersion(hermesVersionFile); const rnVersion = getReactNativeVersion(newDotRoot); const candidates = await getArtifactsCandidates(packageName, artifactId, rnVersion); for (const candidate of candidates) { - const remoteHash = await getRemotePatchesHash(packageName, artifactId, candidate, githubToken); - if (remoteHash === localPatchesHash) { + const remote = await getRemotePomProperties(packageName, artifactId, candidate, githubToken); + if (remote.patchesHash !== localPatchesHash) { + continue; + } + if (remote.hermesVersion === localHermesVersion) { return candidate; } + logWarn(`${LOG_PREFIX} Skipping ${packageName}:${candidate}: built against Hermes ${remote.hermesVersion ?? 'unknown'}, but this build links Hermes ${localHermesVersion}.`); } return null; } diff --git a/scripts/artifacts-utils/resolve-artifacts.ts b/scripts/artifacts-utils/resolve-artifacts.ts index 028cd044aafe..4d85236850b2 100644 --- a/scripts/artifacts-utils/resolve-artifacts.ts +++ b/scripts/artifacts-utils/resolve-artifacts.ts @@ -7,13 +7,15 @@ import resolveArtifacts from './lib/artifactsResolver'; * * Usage: * bun scripts/artifacts-utils/resolve-artifacts.ts \ - * --platform=ios --hybrid=true --new-dot-root=. + * --platform=ios --hybrid=true --new-dot-root=. \ + * --hermes-version-file=node_modules/react-native/sdks/.hermesv1version * * Prints the result as JSON to stdout (logs go to stderr) and always exits 0. */ const args = parseCommandLineArguments(); const platform = args.platform; const isHybrid = args.hybrid === 'true'; +const hermesVersionFile = args['hermes-version-file']; const packageName = isHybrid ? 'react-hybrid' : 'react-standalone'; if (platform !== 'ios' && platform !== 'android') { @@ -22,7 +24,13 @@ if (platform !== 'ios' && platform !== 'android') { process.exit(0); } -const options = {packageName, newDotRoot: args['new-dot-root'] ?? '.', isHybrid}; +if (!hermesVersionFile) { + process.stderr.write('[PatchedArtifacts] Missing --hermes-version-file (the Hermes tag file this build links); building from source.\n'); + process.stdout.write(JSON.stringify({buildFromSource: true, version: null, packageName, artifactId: ''})); + process.exit(0); +} + +const options = {packageName, newDotRoot: args['new-dot-root'] ?? '.', isHybrid, hermesVersionFile}; const resolution = platform === 'ios' ? resolveArtifacts({...options, platform: 'ios'}) : resolveArtifacts({...options, platform: 'android'}); resolution diff --git a/tests/tooling/artifactsResolver.test.ts b/tests/tooling/artifactsResolver.test.ts index d798ce3290cb..50b790cdd5a8 100644 --- a/tests/tooling/artifactsResolver.test.ts +++ b/tests/tooling/artifactsResolver.test.ts @@ -1,3 +1,4 @@ +import type {Mock} from 'bun:test'; import {afterEach, beforeEach, describe, expect, it, jest, mock} from 'bun:test'; import type GithubUtils from '@github/libs/GithubUtils'; @@ -5,7 +6,7 @@ import type GithubUtils from '@github/libs/GithubUtils'; import type {getCredentials} from '@scripts/artifacts-utils/lib/githubCLI'; import * as childProcess from 'child_process'; -import fs from 'fs'; +import {fs as memfsFs, vol} from 'memfs'; const mockExecFileSync = jest.fn<(command: string) => string>(); const mockGetCredentials = jest.fn(); @@ -17,6 +18,7 @@ const mockInitGithubClient = jest.fn(); // existing import bindings are live, but only if the patch happens before those bindings are first read. // `bun test --isolate` keeps the replacements from reaching the other files in tests/tooling. await mock.module('child_process', () => ({...childProcess, execFileSync: mockExecFileSync})); +await mock.module('fs', () => ({...memfsFs, default: memfsFs})); const realGithubCLI = await import('@scripts/artifacts-utils/lib/githubCLI'); await mock.module('@scripts/artifacts-utils/lib/githubCLI', () => ({...realGithubCLI, getCredentials: mockGetCredentials})); await mock.module('@github/libs/GithubUtils', () => ({ @@ -32,6 +34,10 @@ const {default: resolveArtifacts, ARTIFACT_IDS} = await import('@scripts/artifac const NEW_DOT_ROOT = '/repo'; const LOCAL_HASH = 'abc123hash'; +const HERMES_V1_VERSION = '250829098.0.14'; +const HERMES_LEGACY_VERSION = '0.17.0'; +const HERMES_V1_FILE = `${NEW_DOT_ROOT}/node_modules/react-native/sdks/.hermesv1version`; +const HERMES_LEGACY_FILE = `${NEW_DOT_ROOT}/node_modules/react-native/sdks/.hermesversion`; const TOKEN = 'tok'; const USERNAME = 'me'; @@ -50,15 +56,30 @@ function mockFetchBodies(bodies: string[]) { ); } +/** The `` block of a published POM. Omit `hermesVersion` to mimic a POM that never recorded one. */ +function pomBody(patchesHash: string, hermesVersion?: string) { + const hermesProperty = hermesVersion === undefined ? '' : `${hermesVersion}`; + return `${patchesHash}${hermesProperty}`; +} + /** Makes the package-versions API return the given version names. */ function mockVersions(names: string[]) { mockPaginate.mockResolvedValue(names.map((name) => ({name}))); } -/** Mocks the local patches hash and the react-native version read from package.json. */ +/** Mocks the local patches hash, package.json and react-native's Hermes tag files (tagged `hermes-v…`, no trailing newline, like upstream's). */ function mockLocalRepo() { mockExecFileSync.mockImplementation((command: string) => (command === 'bash' ? LOCAL_HASH : '')); - jest.spyOn(fs, 'readFileSync').mockReturnValue('{"dependencies":{"react-native":"0.85.3"}}'); + vol.fromJSON({ + [`${NEW_DOT_ROOT}/package.json`]: '{"dependencies":{"react-native":"0.85.3"}}', + [HERMES_V1_FILE]: `hermes-v${HERMES_V1_VERSION}`, + [HERMES_LEGACY_FILE]: `hermes-v${HERMES_LEGACY_VERSION}`, + }); +} + +/** Everything the resolver warned about, as one plain-text log. Logger writes warnings to stderr through console.error. */ +function loggedLines(spy: Mock): string { + return spy.mock.calls.map((call) => call.map(String).join(' ')).join('\n'); } describe('artifactsResolver', () => { @@ -66,6 +87,7 @@ describe('artifactsResolver', () => { beforeEach(() => { jest.clearAllMocks(); + vol.reset(); // Force the local (gh CLI) credential path deterministically, regardless of the runner. delete process.env.CI; mockGetCredentials.mockReturnValue({githubToken: TOKEN, githubUsername: USERNAME}); @@ -93,7 +115,7 @@ describe('artifactsResolver', () => { throw new Error('No GitHub CLI found. For setup instructions, refer to: https://example.com'); }); - const result = await resolveArtifacts({platform: 'ios', packageName: 'react-hybrid', newDotRoot: NEW_DOT_ROOT, isHybrid: true}); + const result = await resolveArtifacts({platform: 'ios', packageName: 'react-hybrid', newDotRoot: NEW_DOT_ROOT, isHybrid: true, hermesVersionFile: HERMES_V1_FILE}); expect(result).toStrictEqual({buildFromSource: true, version: null, packageName: 'react-hybrid', artifactId: 'react-native-artifacts'}); expect(mockInitGithubClient).not.toHaveBeenCalled(); @@ -102,9 +124,9 @@ describe('artifactsResolver', () => { it('resolves a matching version and does not build from source', async () => { mockLocalRepo(); mockVersions(['0.85.3-nomatch', '0.85.3-match']); - mockFetchBodies(['differentHash', `${LOCAL_HASH}`]); + mockFetchBodies([pomBody('differentHash', HERMES_V1_VERSION), pomBody(LOCAL_HASH, HERMES_V1_VERSION)]); - const result = await resolveArtifacts({platform: 'ios', packageName: 'react-hybrid', newDotRoot: NEW_DOT_ROOT, isHybrid: true}); + const result = await resolveArtifacts({platform: 'ios', packageName: 'react-hybrid', newDotRoot: NEW_DOT_ROOT, isHybrid: true, hermesVersionFile: HERMES_V1_FILE}); expect(mockInitGithubClient).toHaveBeenCalledWith(TOKEN); expect(result.buildFromSource).toBe(false); @@ -122,9 +144,9 @@ describe('artifactsResolver', () => { it('returns the username alongside the token for a matching Android artifact', async () => { mockLocalRepo(); mockVersions(['0.85.3-match']); - mockFetchBodies([`${LOCAL_HASH}`]); + mockFetchBodies([pomBody(LOCAL_HASH, HERMES_V1_VERSION)]); - const result = await resolveArtifacts({platform: 'android', packageName: 'react-standalone', newDotRoot: NEW_DOT_ROOT, isHybrid: false}); + const result = await resolveArtifacts({platform: 'android', packageName: 'react-standalone', newDotRoot: NEW_DOT_ROOT, isHybrid: false, hermesVersionFile: HERMES_V1_FILE}); expect(result.buildFromSource).toBe(false); if (!result.buildFromSource) { @@ -137,19 +159,69 @@ describe('artifactsResolver', () => { it('falls back to source build when no candidate matches the local patches hash', async () => { mockLocalRepo(); mockVersions(['0.85.3-other']); - mockFetchBodies(['nomatch']); + mockFetchBodies([pomBody('nomatch', HERMES_V1_VERSION)]); - const result = await resolveArtifacts({platform: 'android', packageName: 'react-standalone', newDotRoot: NEW_DOT_ROOT, isHybrid: false}); + const result = await resolveArtifacts({platform: 'android', packageName: 'react-standalone', newDotRoot: NEW_DOT_ROOT, isHybrid: false, hermesVersionFile: HERMES_V1_FILE}); expect(result.buildFromSource).toBe(true); expect(result.version).toBeNull(); }); + it('skips a candidate built from the same patches but against a different Hermes, and says why', async () => { + // Given two artifacts built from the local patches, the newer one against another Hermes than this build links. + // A publish-side Hermes change leaves `patchesHash` untouched, so only the Hermes version can tell them apart. + mockLocalRepo(); + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockVersions(['0.85.3-otherHermes', '0.85.3-match']); + mockFetchBodies([pomBody(LOCAL_HASH, HERMES_LEGACY_VERSION), pomBody(LOCAL_HASH, HERMES_V1_VERSION)]); + + // When resolving for a Hermes V1 build. + const result = await resolveArtifacts({platform: 'ios', packageName: 'react-standalone', newDotRoot: NEW_DOT_ROOT, isHybrid: false, hermesVersionFile: HERMES_V1_FILE}); + + // Then the artifact compiled against this build's Hermes wins, and the skipped one is named with both versions + // so the reason is visible in the build log instead of hiding behind a generic "no match". + expect(result.version).toBe('0.85.3-match'); + expect(loggedLines(consoleError)).toContain( + `Skipping react-standalone:0.85.3-otherHermes: built against Hermes ${HERMES_LEGACY_VERSION}, but this build links Hermes ${HERMES_V1_VERSION}.`, + ); + }); + + it('treats an artifact without a recorded Hermes version as a mismatch', async () => { + // Given an artifact built from the local patches whose POM carries no Hermes version, so the Hermes it was + // compiled against is unknown. + mockLocalRepo(); + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockVersions(['0.85.3-untagged']); + mockFetchBodies([pomBody(LOCAL_HASH)]); + + // When resolving. + const result = await resolveArtifacts({platform: 'android', packageName: 'react-standalone', newDotRoot: NEW_DOT_ROOT, isHybrid: false, hermesVersionFile: HERMES_V1_FILE}); + + // Then it is not trusted, because an unknown Hermes is exactly the mismatch the tag exists to catch, and it is + // logged like any other mismatch rather than through a special case. + expect(result.buildFromSource).toBe(true); + expect(loggedLines(consoleError)).toContain(`Skipping react-standalone:0.85.3-untagged: built against Hermes unknown, but this build links Hermes ${HERMES_V1_VERSION}.`); + }); + + it('derives the Hermes version from whichever tag file the native caller picks', async () => { + // Given one artifact per Hermes flavour, both built from the local patches, and a caller that disabled + // Hermes V1 (so it hands over `.hermesversion` instead of `.hermesv1version`). + mockLocalRepo(); + mockVersions(['0.85.3-v1', '0.85.3-legacy']); + mockFetchBodies([pomBody(LOCAL_HASH, HERMES_V1_VERSION), pomBody(LOCAL_HASH, HERMES_LEGACY_VERSION)]); + + // When resolving. + const result = await resolveArtifacts({platform: 'ios', packageName: 'react-standalone', newDotRoot: NEW_DOT_ROOT, isHybrid: false, hermesVersionFile: HERMES_LEGACY_FILE}); + + // Then the legacy artifact is chosen: the tag file decides, and its `hermes-v` prefix is stripped before comparing. + expect(result.version).toBe('0.85.3-legacy'); + }); + it('falls back to source build when the packages API fails', async () => { mockLocalRepo(); mockPaginate.mockRejectedValue(new Error('403 Forbidden')); - const result = await resolveArtifacts({platform: 'ios', packageName: 'react-hybrid', newDotRoot: NEW_DOT_ROOT, isHybrid: true}); + const result = await resolveArtifacts({platform: 'ios', packageName: 'react-hybrid', newDotRoot: NEW_DOT_ROOT, isHybrid: true, hermesVersionFile: HERMES_V1_FILE}); expect(result.buildFromSource).toBe(true); }); @@ -160,9 +232,9 @@ describe('artifactsResolver', () => { process.env.GITHUB_ACTOR = 'ciActor'; mockLocalRepo(); mockVersions(['0.85.3-match']); - mockFetchBodies([`${LOCAL_HASH}`]); + mockFetchBodies([pomBody(LOCAL_HASH, HERMES_V1_VERSION)]); - const result = await resolveArtifacts({platform: 'android', packageName: 'react-standalone', newDotRoot: NEW_DOT_ROOT, isHybrid: false}); + const result = await resolveArtifacts({platform: 'android', packageName: 'react-standalone', newDotRoot: NEW_DOT_ROOT, isHybrid: false, hermesVersionFile: HERMES_V1_FILE}); expect(mockGetCredentials).not.toHaveBeenCalled(); if (!result.buildFromSource) { From 09d3509406789e93d810ce347e447f055554aeb5 Mon Sep 17 00:00:00 2001 From: mateuuszzzzz Date: Wed, 7 Oct 2026 12:53:22 +0200 Subject: [PATCH 2/7] Use upstream method for reading hermes version --- .../workflows/publishReactNativeAndroidArtifacts.yml | 6 +++--- scripts/artifacts-utils/get-hermes-version.sh | 11 ++++++----- scripts/artifacts-utils/lib/artifactsResolver.ts | 9 +++++---- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/workflows/publishReactNativeAndroidArtifacts.yml b/.github/workflows/publishReactNativeAndroidArtifacts.yml index a89b00533d3e..1e0e0aa0db2e 100644 --- a/.github/workflows/publishReactNativeAndroidArtifacts.yml +++ b/.github/workflows/publishReactNativeAndroidArtifacts.yml @@ -69,9 +69,9 @@ jobs: with: IS_HYBRID_BUILD: ${{ matrix.is_hybrid }} - # react-android is compiled against the Hermes headers that hermes-engine builds from this version (its - # hermesVersionProvider reads the same file). Recording it in the POM lets the resolver reject an artifact - # whose Hermes differs from the one the consuming app links. + # react-android is compiled against the headers of the Hermes that hermes-engine builds for this release. + # Recording its version in the POM lets the resolver reject an artifact whose Hermes differs from the one the + # consuming app links. - name: Resolve Hermes version id: hermes run: echo "VERSION=$(./scripts/artifacts-utils/get-hermes-version.sh)" >> "$GITHUB_OUTPUT" diff --git a/scripts/artifacts-utils/get-hermes-version.sh b/scripts/artifacts-utils/get-hermes-version.sh index 5e041c1cd4ea..81b9557c5b62 100644 --- a/scripts/artifacts-utils/get-hermes-version.sh +++ b/scripts/artifacts-utils/get-hermes-version.sh @@ -1,8 +1,9 @@ #!/bin/bash -# Prints the Hermes version our prebuilt artifacts are compiled against, without the `hermes-v` tag prefix -# (e.g. `250829098.0.14`). Both platforms read `sdks/.hermesv1version` when building: `ios-prebuild.js` through -# HERMES_VERSION, and `hermes-engine/build.gradle.kts` through its hermesVersionProvider. +# Prints the Hermes version our prebuilt artifacts are compiled against (e.g. `250829098.0.14`), read from +# `sdks/hermes-engine/version.properties` exactly as upstream's own prebuild workflow does (see +# `.github/workflows/prebuild-ios-core.yml` in facebook/react-native). It is the version `ios-prebuild.js` receives +# through HERMES_VERSION, and the one the consuming app links through hermes-engine.podspec and the Gradle plugin. # # The published POM records this value so that the artifacts resolver can compare it with the Hermes the consuming # app links. The artifacts do not bundle a Hermes engine, but their React code is compiled against the ABI of one, so @@ -17,11 +18,11 @@ source "$SCRIPT_DIR/../shellUtils.sh" NEW_DOT_ROOT="${1:-$SCRIPT_DIR/../..}" readonly NEW_DOT_ROOT -readonly HERMES_VERSION_FILE="$NEW_DOT_ROOT/node_modules/react-native/sdks/.hermesv1version" +readonly HERMES_VERSION_FILE="$NEW_DOT_ROOT/node_modules/react-native/sdks/hermes-engine/version.properties" if [[ ! -f "$HERMES_VERSION_FILE" ]]; then error "Hermes version file not found: $HERMES_VERSION_FILE" exit 1 fi -sed 's/^hermes-v\{0,1\}//' "$HERMES_VERSION_FILE" +sed -n 's/^HERMES_V1_VERSION_NAME=//p' "$HERMES_VERSION_FILE" diff --git a/scripts/artifacts-utils/lib/artifactsResolver.ts b/scripts/artifacts-utils/lib/artifactsResolver.ts index d9f11ec9e6b0..1bb72d41e5cb 100644 --- a/scripts/artifacts-utils/lib/artifactsResolver.ts +++ b/scripts/artifacts-utils/lib/artifactsResolver.ts @@ -155,10 +155,11 @@ function computePatchesHash(newDotRoot: string, isHybrid: boolean): string { } /** - * The Hermes version this build links, derived from its tag file the same way the publish workflows derive the - * version they record (`get-hermes-version.sh`), so `hermes-v250829098.0.14` becomes `250829098.0.14`. The artifacts - * are not bundled with a Hermes engine, but their React code is compiled against the headers of one, so an artifact - * is only usable when its recorded Hermes version is the one this build links. + * The Hermes version this build links, read from its tag file without the tag prefix, so `hermes-v250829098.0.14` + * becomes `250829098.0.14`. That is the form the publish workflows record (`get-hermes-version.sh`, which reads + * HERMES_V1_VERSION_NAME from `version.properties` like upstream does). The artifacts are not bundled with a Hermes + * engine, but their React code is compiled against the headers of one, so an artifact is only usable when its + * recorded Hermes version is the one this build links. */ function getLocalHermesVersion(hermesVersionFile: string): string { const version = fs From 8a7f8155f4fe441c7d937885010244cfc5fd7f97 Mon Sep 17 00:00:00 2001 From: mateuuszzzzz Date: Tue, 20 Oct 2026 13:09:58 +0200 Subject: [PATCH 3/7] Refactor comment in get-hermes-version.sh --- scripts/artifacts-utils/get-hermes-version.sh | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/scripts/artifacts-utils/get-hermes-version.sh b/scripts/artifacts-utils/get-hermes-version.sh index 81b9557c5b62..86a68e15b334 100644 --- a/scripts/artifacts-utils/get-hermes-version.sh +++ b/scripts/artifacts-utils/get-hermes-version.sh @@ -1,16 +1,9 @@ #!/bin/bash # Prints the Hermes version our prebuilt artifacts are compiled against (e.g. `250829098.0.14`), read from -# `sdks/hermes-engine/version.properties` exactly as upstream's own prebuild workflow does (see -# `.github/workflows/prebuild-ios-core.yml` in facebook/react-native). It is the version `ios-prebuild.js` receives -# through HERMES_VERSION, and the one the consuming app links through hermes-engine.podspec and the Gradle plugin. -# -# The published POM records this value so that the artifacts resolver can compare it with the Hermes the consuming -# app links. The artifacts do not bundle a Hermes engine, but their React code is compiled against the ABI of one, so -# an artifact built against a different Hermes is unusable. We cannot predict how upcoming upstream versions will -# pick the Hermes version (which file, whether V1 stays opt-in), so this tag is the safeguard against silently -# consuming artifacts built against the wrong ABI: on a mismatch the consumer falls back to a source build and -# names both versions in its log, instead of failing at link time or at runtime. +# `sdks/hermes-engine/version.properties` the way upstream's own prebuild workflow does (prebuild-ios-core.yml in +# facebook/react-native). The publish workflows record it in the POM, and the artifacts resolver refuses an artifact +# whose Hermes differs from the one the app links, so an artifact built against the wrong ABI is never used silently. SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)" readonly SCRIPT_DIR From f1f2fa5b139e961a145428ff2844f0507cc1c72e Mon Sep 17 00:00:00 2001 From: mateuuszzzzz Date: Tue, 20 Oct 2026 13:10:26 +0200 Subject: [PATCH 4/7] Pass hermes version file in buildIOS workflow --- .github/workflows/buildIOS.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/buildIOS.yml b/.github/workflows/buildIOS.yml index 7cdb09c2d8a1..153e6588e744 100644 --- a/.github/workflows/buildIOS.yml +++ b/.github/workflows/buildIOS.yml @@ -122,7 +122,10 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} run: | - RESULT=$(./node_modules/.bin/bun scripts/artifacts-utils/resolve-artifacts.ts --platform=ios --hybrid=true --new-dot-root="$GITHUB_WORKSPACE") + # pod install resolves the same artifact through patched_ios_artifacts.rb, which picks the Hermes tag file from + # RCT_HERMES_V1_ENABLED. HybridApp pins that to '1' inside Mobile-Expensify/iOS/Podfile (invisible to this shell), + # so the cache key must assume Hermes V1 to stay in sync with what pod install downloads. + RESULT=$(./node_modules/.bin/bun scripts/artifacts-utils/resolve-artifacts.ts --platform=ios --hybrid=true --new-dot-root="$GITHUB_WORKSPACE" --hermes-version-file="$GITHUB_WORKSPACE/node_modules/react-native/sdks/.hermesv1version") VERSION=$(echo "$RESULT" | jq -r '.version // "source"') if [ -z "$VERSION" ]; then echo "::error::Could not resolve react-native artifact version from resolve-artifacts.ts output: $RESULT" From 81da5cd30c2f1d2b986798435c5096b609f62d3f Mon Sep 17 00:00:00 2001 From: mateuuszzzzz Date: Wed, 21 Oct 2026 15:00:10 +0200 Subject: [PATCH 5/7] Make get-hermes-version.sh executable The publish workflows invoke it directly, so with mode 100644 the step recorded an empty Hermes version. Co-Authored-By: Claude Fable 5.1 --- scripts/artifacts-utils/get-hermes-version.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/artifacts-utils/get-hermes-version.sh diff --git a/scripts/artifacts-utils/get-hermes-version.sh b/scripts/artifacts-utils/get-hermes-version.sh old mode 100644 new mode 100755 From 2a18c8acd6668199b2ac1e92114b682fb368d852 Mon Sep 17 00:00:00 2001 From: mateuuszzzzz Date: Wed, 21 Oct 2026 15:00:11 +0200 Subject: [PATCH 6/7] Republish artifacts when get-hermes-version.sh changes The script decides which Hermes version the POM records, so a change to it must produce new artifacts, like the other publisher inputs. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/publishReactNativeArtifacts.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publishReactNativeArtifacts.yml b/.github/workflows/publishReactNativeArtifacts.yml index fa134645b37e..5f06248459d8 100644 --- a/.github/workflows/publishReactNativeArtifacts.yml +++ b/.github/workflows/publishReactNativeArtifacts.yml @@ -47,6 +47,7 @@ on: - .github/workflows/publishReactNativeAndroidArtifacts.yml - .github/actions/composite/fetchReactNativePrebuild/action.yml - scripts/artifacts-utils/ios/publish/** + - scripts/artifacts-utils/get-hermes-version.sh jobs: verifyPatches: @@ -134,7 +135,8 @@ jobs: .github/workflows/publishReactNativeiOSArtifacts.yml \ .github/workflows/publishReactNativeAndroidArtifacts.yml \ .github/actions/composite/fetchReactNativePrebuild/action.yml \ - scripts/artifacts-utils/ios/publish; then + scripts/artifacts-utils/ios/publish \ + scripts/artifacts-utils/get-hermes-version.sh; then echo "DID_WORKFLOWS_CHANGE=false" >> "$GITHUB_OUTPUT" else echo "DID_WORKFLOWS_CHANGE=true" >> "$GITHUB_OUTPUT" From cd6fba48f1f6e707e3c48b8db4ef0ae2ead19022 Mon Sep 17 00:00:00 2001 From: mateuuszzzzz Date: Wed, 21 Oct 2026 15:00:12 +0200 Subject: [PATCH 7/7] Update Podfile.lock checksum after merging main Co-Authored-By: Claude Fable 5.1 --- ios/Podfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b02b59ac794b..915409c053d4 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -4134,6 +4134,6 @@ SPEC CHECKSUMS: VisionCamera: 30b358b807324c692064f78385e9a732ce1bebfe Yoga: c30c859b7e9b75e547b600b486fe33165f60de00 -PODFILE CHECKSUM: c937d1b7b774ce019c0c848e4ad1e850f08c0e90 +PODFILE CHECKSUM: cdbd46f6c89c7123ca4821dc03f66599047601f8 COCOAPODS: 1.16.2