From 5cb96cfd434113737cf44c8695d585bb969b01aa Mon Sep 17 00:00:00 2001 From: Oscar Franco Date: Wed, 12 Aug 2026 19:16:20 -0400 Subject: [PATCH] Fix hermes rolling tag mismatch --- .../__tests__/download-spm-artifacts-test.js | 104 ++++++++++++++---- .../scripts/spm/download-spm-artifacts.js | 64 +++++++++-- 2 files changed, 137 insertions(+), 31 deletions(-) diff --git a/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js b/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js index 97c56c26e5b..4e204f3c05d 100644 --- a/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js +++ b/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js @@ -20,6 +20,7 @@ const { hermesReleaseUrls, mavenRepositoryUrls, reactNativeMavenMirrorEnabled, + readPinnedHermesVersion, resolveCacheSlotVersion, resolveHermesArtifact, resolveLatestV1Version, @@ -88,21 +89,51 @@ function routerFetch(routes /*: {[string]: any} */) { } // --------------------------------------------------------------------------- -// resolveHermesArtifact — hermes uses its own version space, decoupled from -// React Native's nightly cadence. The default behavior mirrors RN's -// CocoaPods prebuild (HERMES_VERSION='latest-v1'): resolve via the -// hermes-compiler npm dist-tag instead of trying to download a hermes-ios -// artifact at the RN nightly version (which won't exist on Maven). +// readPinnedHermesVersion / resolveHermesArtifact — hermes uses its own +// version space, decoupled from React Native's nightly cadence. By default, +// resolution reads the version pinned in sdks/hermes-engine/version.properties +// (the same file CocoaPods' hermes-engine.podspec reads via +// sdks/hermes-engine/hermes-utils.rb), so SPM and CocoaPods land on the same +// Hermes build for a given react-native release. It falls back to the +// hermes-compiler `latest-v1` npm dist-tag only when that file is missing. // --------------------------------------------------------------------------- +describe('readPinnedHermesVersion', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-hermes-pin-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('reads HERMES_VERSION_NAME from sdks/hermes-engine/version.properties', () => { + const propsDir = path.join(tempDir, 'sdks', 'hermes-engine'); + fs.mkdirSync(propsDir, {recursive: true}); + fs.writeFileSync( + path.join(propsDir, 'version.properties'), + 'HERMES_VERSION_NAME=260318099.0.1\n', + ); + expect(readPinnedHermesVersion(tempDir)).toBe('260318099.0.1'); + }); + + it('returns null when version.properties does not exist', () => { + expect(readPinnedHermesVersion(tempDir)).toBe(null); + }); +}); + describe('resolveHermesArtifact', () => { let origFetch; let origHermesEnv; + let tempDir; beforeEach(() => { origFetch = globalThis.fetch; origHermesEnv = process.env.HERMES_VERSION; delete process.env.HERMES_VERSION; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-hermes-resolve-')); }); afterEach(() => { @@ -112,6 +143,7 @@ describe('resolveHermesArtifact', () => { } else { delete process.env.HERMES_VERSION; } + fs.rmSync(tempDir, {recursive: true, force: true}); }); // Mock fetch with a router: each entry's key is a URL substring; the value @@ -121,44 +153,69 @@ describe('resolveHermesArtifact', () => { globalThis.fetch = routerFetch(routes); } + function writePinnedVersion(version /*: string */) { + const propsDir = path.join(tempDir, 'sdks', 'hermes-engine'); + fs.mkdirSync(propsDir, {recursive: true}); + fs.writeFileSync( + path.join(propsDir, 'version.properties'), + `HERMES_VERSION_NAME=${version}\n`, + ); + } + describe('default behavior (no HERMES_VERSION set)', () => { - it('resolves to the latest-v1 hermes-compiler dist-tag, NOT the RN version', async () => { + it('resolves to the version pinned in version.properties, NOT the RN version', async () => { + writePinnedVersion('260318099.0.1'); mockFetch({ - 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, - // Pretend the release URL exists once we ask for 0.13.0. - 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, + 'hermes-ios/260318099.0.1/hermes-ios-260318099.0.1': {ok: true}, }); const result = await resolveHermesArtifact( '0.87.0-nightly-20260519-58cd1bf58', 'debug', null, + tempDir, ); - expect(result.version).toBe('0.13.0'); - expect(result.url).toContain('/0.13.0/'); + expect(result.version).toBe('260318099.0.1'); + expect(result.url).toContain('/260318099.0.1/'); // The RN nightly hash MUST NOT leak into the hermes URL. expect(result.url).not.toContain('20260519'); }); - it('ignores rawVersion (the RN --version arg) when HERMES_VERSION is unset', async () => { + it('ignores rawVersion (the RN --version arg)', async () => { + writePinnedVersion('260318099.0.1'); mockFetch({ - 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, - 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, + 'hermes-ios/260318099.0.1/hermes-ios-260318099.0.1': {ok: true}, }); // Caller passes the original RN --version verbatim; hermes should - // still default to latest-v1 instead of using this. + // still resolve from version.properties instead of using this. const result = await resolveHermesArtifact( '0.87.0-nightly-20260519-58cd1bf58', 'debug', '0.87.0-nightly-20260519-58cd1bf58', + tempDir, ); - expect(result.version).toBe('0.13.0'); + expect(result.version).toBe('260318099.0.1'); expect(result.url).not.toContain('20260519'); }); + + it('falls back to the latest-v1 hermes-compiler dist-tag when version.properties is missing', async () => { + mockFetch({ + 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, + 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, + }); + const result = await resolveHermesArtifact( + '0.87.0-nightly-20260519-58cd1bf58', + 'debug', + null, + tempDir, + ); + expect(result.version).toBe('0.13.0'); + }); }); describe('HERMES_VERSION escape hatches', () => { - it('HERMES_VERSION= uses it verbatim', async () => { + it('HERMES_VERSION= uses it verbatim, even when version.properties is pinned', async () => { process.env.HERMES_VERSION = '0.13.5'; + writePinnedVersion('260318099.0.1'); mockFetch({ 'hermes-ios/0.13.5/hermes-ios-0.13.5': {ok: true}, }); @@ -166,13 +223,15 @@ describe('resolveHermesArtifact', () => { '0.87.0-nightly-anything', 'debug', null, + tempDir, ); expect(result.version).toBe('0.13.5'); expect(result.url).toContain('/0.13.5/'); }); - it('HERMES_VERSION=latest-v1 resolves via npm dist-tag', async () => { + it('HERMES_VERSION=latest-v1 resolves via npm dist-tag, even when version.properties is pinned', async () => { process.env.HERMES_VERSION = 'latest-v1'; + writePinnedVersion('260318099.0.1'); mockFetch({ 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, @@ -181,6 +240,7 @@ describe('resolveHermesArtifact', () => { '0.87.0-nightly-anything', 'debug', null, + tempDir, ); expect(result.version).toBe('0.13.0'); }); @@ -197,6 +257,7 @@ describe('resolveHermesArtifact', () => { '0.87.0-nightly-anything', 'debug', null, + tempDir, ); expect(result.version).toBe('0.14.0-nightly-abc'); }); @@ -215,7 +276,12 @@ describe('resolveHermesArtifact', () => { '2', }; }); - const result = await resolveHermesArtifact('0.87.0', 'debug', null); + const result = await resolveHermesArtifact( + '0.87.0', + 'debug', + null, + tempDir, + ); expect(result.url).toContain('maven-snapshots'); expect(result.url).toContain('hermes-ios-debug.tar.gz'); }); diff --git a/packages/react-native/scripts/spm/download-spm-artifacts.js b/packages/react-native/scripts/spm/download-spm-artifacts.js index 5a786f090da..a89038e83e6 100644 --- a/packages/react-native/scripts/spm/download-spm-artifacts.js +++ b/packages/react-native/scripts/spm/download-spm-artifacts.js @@ -337,6 +337,35 @@ async function resolveCacheSlotVersion( } } +const DEFAULT_RN_ROOT = path.resolve(__dirname, '../..'); + +/** + * Reads the Hermes version pinned for this react-native install from + * sdks/hermes-engine/version.properties (HERMES_VERSION_NAME=). + * CocoaPods' hermes-engine.podspec reads this same file (via + * sdks/hermes-engine/hermes-utils.rb) to pick the Hermes build it downloads, + * so defaulting to it here keeps SPM on the same Hermes build as CocoaPods + * for a given react-native release, instead of drifting to whatever is + * newest on npm's `latest-v1` dist-tag at install time. + */ +function readPinnedHermesVersion( + rnRoot /*: string */ = DEFAULT_RN_ROOT, +) /*: string | null */ { + const propsPath = path.join( + rnRoot, + 'sdks', + 'hermes-engine', + 'version.properties', + ); + if (!fs.existsSync(propsPath)) { + return null; + } + const match = fs + .readFileSync(propsPath, 'utf8') + .match(/^HERMES_VERSION_NAME=(.+)$/m); + return match != null ? match[1].trim() : null; +} + async function resolveLatestV1Version() /*: Promise */ { log(' Resolving latest-v1 Hermes from npm...'); // $FlowFixMe[incompatible-call] global fetch not in Flow stubs @@ -454,26 +483,36 @@ async function resolveRNDepsArtifact( /** * Returns {url, version} for Hermes. Hermes uses its own version space - * decoupled from React Native's nightly cadence — RN's `hermes-compiler` - * npm package publishes a `latest-v1` dist-tag that always resolves to a - * binary that's been built and uploaded to Maven. Our default mirrors RN's - * CocoaPods prebuild path (see scripts/ios-prebuild/hermes.js): + * decoupled from React Native's nightly cadence, so rnVersion / rawVersion + * are intentionally not consulted for the RN-nightly-hash case: there is no + * guarantee a hermes-ios artifact exists for any given RN nightly hash — + * tying them together produces 404s like #(repro case from spikes/MyApp). * - * HERMES_VERSION unset → 'latest-v1' dist-tag - * HERMES_VERSION=latest-v1 → same (explicit) + * Default resolution order (when HERMES_VERSION is unset): + * 1. sdks/hermes-engine/version.properties (HERMES_VERSION_NAME) — the + * exact Hermes build this react-native install was pinned to and + * tested against. This mirrors what CocoaPods' hermes-engine.podspec + * reads (see sdks/hermes-engine/hermes-utils.rb), so SPM and CocoaPods + * resolve to the same Hermes build for a given react-native release + * instead of SPM drifting to whatever is newest upstream. + * 2. If that file is missing, fall back to the hermes-compiler + * `latest-v1` npm dist-tag. + * + * HERMES_VERSION=latest-v1 → force the npm dist-tag lookup * HERMES_VERSION=nightly → hermes-compiler@nightly dist-tag * HERMES_VERSION= → use that version verbatim - * - * Note: rnVersion / rawVersion are intentionally not consulted. There is no - * guarantee a hermes-ios artifact exists for any given RN nightly hash — - * tying them together produces 404s like #(repro case from spikes/MyApp). */ async function resolveHermesArtifact( rnVersion /*: string */, flavor /*: string */, rawVersion /*: string | null */, + rnRoot /*: string */ = DEFAULT_RN_ROOT, ) /*: Promise */ { - let version = process.env.HERMES_VERSION ?? 'latest-v1'; + let version = process.env.HERMES_VERSION; + + if (version == null) { + version = readPinnedHermesVersion(rnRoot) ?? 'latest-v1'; + } if (version === 'nightly') { version = await resolveNightlyVersion('hermes-compiler'); @@ -1178,7 +1217,7 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { label: 'hermes', name: 'hermes-engine', resolve: () => - resolveHermesArtifact(resolvedRnVersion, flavor, rawVersion), + resolveHermesArtifact(resolvedRnVersion, flavor, rawVersion, rnRoot), sharedName: (v /*: string */) => `hermes-ios-${v}-${flavor}.tar.gz`, }, ]; @@ -1461,6 +1500,7 @@ module.exports = { resolveSnapshotUrl, resolveNightlyVersion, resolveLatestV1Version, + readPinnedHermesVersion, resolveRNCoreArtifact, resolveRNDepsArtifact, exists,