Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const {
hermesReleaseUrls,
mavenRepositoryUrls,
reactNativeMavenMirrorEnabled,
readPinnedHermesVersion,
resolveCacheSlotVersion,
resolveHermesArtifact,
resolveLatestV1Version,
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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
Expand All @@ -121,58 +153,85 @@ 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=<literal-version> uses it verbatim', async () => {
it('HERMES_VERSION=<literal-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},
});
const result = await 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},
Expand All @@ -181,6 +240,7 @@ describe('resolveHermesArtifact', () => {
'0.87.0-nightly-anything',
'debug',
null,
tempDir,
);
expect(result.version).toBe('0.13.0');
});
Expand All @@ -197,6 +257,7 @@ describe('resolveHermesArtifact', () => {
'0.87.0-nightly-anything',
'debug',
null,
tempDir,
);
expect(result.version).toBe('0.14.0-nightly-abc');
});
Expand All @@ -215,7 +276,12 @@ describe('resolveHermesArtifact', () => {
'<buildNumber>2</buildNumber></metadata>',
};
});
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');
});
Expand Down
64 changes: 52 additions & 12 deletions packages/react-native/scripts/spm/download-spm-artifacts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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=<version>).
* 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<string> */ {
log(' Resolving latest-v1 Hermes from npm...');
// $FlowFixMe[incompatible-call] global fetch not in Flow stubs
Expand Down Expand Up @@ -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=<literal> → 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<ResolvedArtifact> */ {
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');
Expand Down Expand Up @@ -1178,7 +1217,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
label: 'hermes',
name: 'hermes-engine',
resolve: () =>
resolveHermesArtifact(resolvedRnVersion, flavor, rawVersion),
resolveHermesArtifact(resolvedRnVersion, flavor, rawVersion, rnRoot),
sharedName: (v /*: string */) => `hermes-ios-${v}-${flavor}.tar.gz`,
},
];
Expand Down Expand Up @@ -1461,6 +1500,7 @@ module.exports = {
resolveSnapshotUrl,
resolveNightlyVersion,
resolveLatestV1Version,
readPinnedHermesVersion,
resolveRNCoreArtifact,
resolveRNDepsArtifact,
exists,
Expand Down
Loading