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 9f9090e388e..0896c130ff5 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 @@ -14,9 +14,12 @@ const { exists, extractXCFramework, findFirst, + firstExistingUrl, formatBytes, formatSpeed, - hermesReleaseUrl, + hermesReleaseUrls, + mavenRepositoryUrls, + reactNativeMavenMirrorEnabled, resolveCacheSlotVersion, resolveHermesArtifact, resolveLatestV1Version, @@ -24,8 +27,8 @@ const { resolveRNCoreArtifact, resolveRNDepsArtifact, resolveSnapshotUrl, - rnCoreReleaseUrl, - rnDepsReleaseUrl, + rnCoreReleaseUrls, + rnDepsReleaseUrls, validateArtifactsCache, } = require('../download-spm-artifacts'); const {execSync} = require('node:child_process'); @@ -33,6 +36,33 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +// Repository-selection env vars are read at call time; clear them per test so +// results don't depend on the host machine, and restore afterwards. +const REPO_ENV_KEYS = [ + 'ENTERPRISE_REPOSITORY', + 'RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED', +]; +let savedRepoEnv = {}; +beforeEach(() => { + savedRepoEnv = {}; + for (const key of REPO_ENV_KEYS) { + savedRepoEnv[key] = process.env[key]; + delete process.env[key]; + } +}); +afterEach(() => { + for (const key of REPO_ENV_KEYS) { + if (savedRepoEnv[key] !== undefined) { + process.env[key] = savedRepoEnv[key]; + } else { + delete process.env[key]; + } + } +}); + +const MIRROR = 'https://repo.reactnative.dev/maven2'; +const CENTRAL = 'https://repo1.maven.org/maven2'; + // Shared fetch router used by the URL-resolution tests below. Each key is a // URL substring; the matched value describes the response. Anything unmatched // returns 404 (the "release not found, fall back to snapshot" path). @@ -193,49 +223,125 @@ describe('resolveHermesArtifact', () => { }); // --------------------------------------------------------------------------- -// Maven URL builders — pure string composition. +// Repository selection — RN Maven mirror first, Maven Central as fallback; +// ENTERPRISE_REPOSITORY replaces both. +// --------------------------------------------------------------------------- + +describe('mavenRepositoryUrls', () => { + it('prefers the RN Maven mirror and keeps Maven Central as fallback by default', () => { + expect(reactNativeMavenMirrorEnabled()).toBe(true); + expect(mavenRepositoryUrls()).toEqual([MIRROR, CENTRAL]); + }); + + it('stays enabled when RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED=true', () => { + process.env.RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED = 'true'; + expect(reactNativeMavenMirrorEnabled()).toBe(true); + expect(mavenRepositoryUrls()).toEqual([MIRROR, CENTRAL]); + }); + + it.each(['false', 'FALSE', '0'])( + 'drops the mirror when RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED=%s', + value => { + process.env.RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED = value; + expect(reactNativeMavenMirrorEnabled()).toBe(false); + expect(mavenRepositoryUrls()).toEqual([CENTRAL]); + }, + ); + + it('ENTERPRISE_REPOSITORY replaces mirror AND Central, stripping trailing slashes', () => { + process.env.ENTERPRISE_REPOSITORY = 'https://maven.internal.example//'; + expect(mavenRepositoryUrls()).toEqual(['https://maven.internal.example']); + }); +}); + +// --------------------------------------------------------------------------- +// Maven URL builders — one candidate per repository, mirror first. // --------------------------------------------------------------------------- describe('release URL builders', () => { - it('rnCoreReleaseUrl points at the reactnative-core classifier on Maven Central', () => { - const url = rnCoreReleaseUrl('0.85.0', 'debug'); - expect(url).toBe( - 'https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts/0.85.0/' + - 'react-native-artifacts-0.85.0-reactnative-core-debug.tar.gz', + it('rnCoreReleaseUrls builds a candidate per repository for the reactnative-core classifier', () => { + const suffix = + '/com/facebook/react/react-native-artifacts/0.85.0/' + + 'react-native-artifacts-0.85.0-reactnative-core-debug.tar.gz'; + expect(rnCoreReleaseUrls('0.85.0', 'debug')).toEqual([ + MIRROR + suffix, + CENTRAL + suffix, + ]); + }); + + it('rnDepsReleaseUrls points at the reactnative-dependencies classifier', () => { + const urls = rnDepsReleaseUrls('0.85.0', 'release'); + expect(urls).toHaveLength(2); + for (const url of urls) { + expect(url).toContain('react-native-artifacts/0.85.0/'); + expect(url).toContain('reactnative-dependencies-release.tar.gz'); + } + }); + + it('hermesReleaseUrls points at the hermes-ios coordinate', () => { + const suffix = + '/com/facebook/hermes/hermes-ios/0.13.0/' + + 'hermes-ios-0.13.0-hermes-ios-debug.tar.gz'; + expect(hermesReleaseUrls('0.13.0', 'debug')).toEqual([ + MIRROR + suffix, + CENTRAL + suffix, + ]); + }); + + it('honors ENTERPRISE_REPOSITORY as the only release base URL', () => { + process.env.ENTERPRISE_REPOSITORY = 'https://maven.internal.example'; + const urls = rnCoreReleaseUrls('0.85.0', 'debug'); + expect(urls).toHaveLength(1); + expect(urls[0]).toContain( + 'https://maven.internal.example/com/facebook/react/', ); }); +}); - it('rnDepsReleaseUrl points at the reactnative-dependencies classifier', () => { - const url = rnDepsReleaseUrl('0.85.0', 'release'); - expect(url).toContain('react-native-artifacts/0.85.0/'); - expect(url).toContain('reactnative-dependencies-release.tar.gz'); +// --------------------------------------------------------------------------- +// firstExistingUrl — ordered candidate probing. +// --------------------------------------------------------------------------- + +describe('firstExistingUrl', () => { + let origFetch; + beforeEach(() => { + origFetch = globalThis.fetch; + }); + afterEach(() => { + globalThis.fetch = origFetch; }); - it('hermesReleaseUrl points at the hermes-ios coordinate', () => { - const url = hermesReleaseUrl('0.13.0', 'debug'); - expect(url).toBe( - 'https://repo1.maven.org/maven2/com/facebook/hermes/hermes-ios/0.13.0/' + - 'hermes-ios-0.13.0-hermes-ios-debug.tar.gz', + it('returns the first candidate when it exists, without probing the rest', async () => { + globalThis.fetch = jest.fn(async () => ({status: 200})); + const url = await firstExistingUrl(['https://a/x', 'https://b/x']); + expect(url).toBe('https://a/x'); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + + it('falls through to the next candidate on a miss', async () => { + globalThis.fetch = jest.fn(async u => + String(u).startsWith('https://b/') ? {status: 200} : {status: 404}, + ); + expect(await firstExistingUrl(['https://a/x', 'https://b/x'])).toBe( + 'https://b/x', ); }); - it('honors ENTERPRISE_REPOSITORY for the release base URL', () => { - jest.isolateModules(() => { - const prev = process.env.ENTERPRISE_REPOSITORY; - process.env.ENTERPRISE_REPOSITORY = 'https://maven.internal.example'; - try { - const mod = require('../download-spm-artifacts'); - expect(mod.rnCoreReleaseUrl('0.85.0', 'debug')).toContain( - 'https://maven.internal.example/com/facebook/react/', - ); - } finally { - if (prev !== undefined) { - process.env.ENTERPRISE_REPOSITORY = prev; - } else { - delete process.env.ENTERPRISE_REPOSITORY; - } + it('falls through to the next candidate when the probe errors (outage)', async () => { + globalThis.fetch = jest.fn(async u => { + if (String(u).startsWith('https://a/')) { + throw new Error('mirror down'); } + return {status: 200}; }); + expect(await firstExistingUrl(['https://a/x', 'https://b/x'])).toBe( + 'https://b/x', + ); + }); + + it('returns null when no candidate exists', async () => { + globalThis.fetch = jest.fn(async () => ({status: 404})); + expect(await firstExistingUrl(['https://a/x', 'https://b/x'])).toBeNull(); }); }); @@ -425,16 +531,61 @@ describe('resolveRNCoreArtifact', () => { fs.rmSync(tempDir, {recursive: true, force: true}); }); - it('uses the stable release URL when it exists', async () => { + it('prefers the RN Maven mirror release when it exists', async () => { globalThis.fetch = routerFetch({ 'reactnative-core-debug.tar.gz': {status: 200}, }); const result = await resolveRNCoreArtifact('0.85.0', 'debug', null); expect(result.version).toBe('0.85.0'); + expect(result.url).toContain('repo.reactnative.dev'); + expect(result.url).toContain('reactnative-core-debug.tar.gz'); + // Maven Central is never probed when the mirror has the artifact. + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + + it('falls back to Maven Central when the mirror is missing the artifact', async () => { + globalThis.fetch = routerFetch({ + // Only Central serves the artifact; the mirror probe hits the 404 + // default. + 'repo1.maven.org': {status: 200}, + }); + const result = await resolveRNCoreArtifact('0.85.0', 'debug', null); + expect(result.version).toBe('0.85.0'); expect(result.url).toContain('repo1.maven.org'); expect(result.url).toContain('reactnative-core-debug.tar.gz'); }); + it('falls back to Maven Central when the mirror errors (outage)', async () => { + globalThis.fetch = jest.fn(async url => { + if (String(url).includes('repo.reactnative.dev')) { + throw new Error('mirror unreachable'); + } + return {status: 200}; + }); + const result = await resolveRNCoreArtifact('0.85.0', 'debug', null); + expect(result.url).toContain('repo1.maven.org'); + }); + + it('goes straight to Maven Central when the mirror is disabled', async () => { + process.env.RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED = 'false'; + globalThis.fetch = routerFetch({ + 'reactnative-core-debug.tar.gz': {status: 200}, + }); + const result = await resolveRNCoreArtifact('0.85.0', 'debug', null); + expect(result.url).toContain('repo1.maven.org'); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + + it('only consults ENTERPRISE_REPOSITORY when it is set', async () => { + process.env.ENTERPRISE_REPOSITORY = 'https://maven.internal.example'; + globalThis.fetch = routerFetch({ + 'reactnative-core-debug.tar.gz': {status: 200}, + }); + const result = await resolveRNCoreArtifact('0.85.0', 'debug', null); + expect(result.url).toContain('maven.internal.example'); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + it('falls back to the snapshot URL when the release is missing', async () => { globalThis.fetch = jest.fn(async (url, opts) => { // HEAD probe of the release URL → 404. diff --git a/packages/react-native/scripts/spm/download-spm-artifacts.js b/packages/react-native/scripts/spm/download-spm-artifacts.js index 4faa4331154..62eaae78237 100644 --- a/packages/react-native/scripts/spm/download-spm-artifacts.js +++ b/packages/react-native/scripts/spm/download-spm-artifacts.js @@ -42,7 +42,17 @@ * Per-artifact version overrides (mirrors existing env vars): * HERMES_VERSION= * RN_DEP_VERSION= - * ENTERPRISE_REPOSITORY= Custom Maven mirror (must match Maven structure) + * + * Repository selection: + * Release tarballs are looked up on the React Native Maven mirror first, + * with Maven Central as the fallback when the mirror doesn't have the + * artifact (or errors). Nightlies/snapshots always come from Sonatype. + * ENTERPRISE_REPOSITORY= Custom Maven repository (must match the + * Maven structure). When set it is the ONLY + * repository consulted (no mirror/Central). + * RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED=false|0 + * Skip the mirror and go straight to Maven + * Central. * * Output: * /React.xcframework/ @@ -128,36 +138,69 @@ function parseArgs(argv /*: Array */) /*: DownloadArgs */ { }; } -const MAVEN_RELEASE = - process.env.ENTERPRISE_REPOSITORY ?? 'https://repo1.maven.org/maven2'; +const MAVEN_CENTRAL_REPOSITORY = 'https://repo1.maven.org/maven2'; +const REACT_NATIVE_MAVEN_MIRROR_REPOSITORY = + 'https://repo.reactnative.dev/maven2'; const MAVEN_SNAPSHOT = 'https://central.sonatype.com/repository/maven-snapshots'; -function rnCoreReleaseUrl( +/** + * The mirror is ON unless RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED is + * explicitly "false"/"0". + */ +function reactNativeMavenMirrorEnabled() /*: boolean */ { + const value = process.env.RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED; + if (value == null || value === '') { + return true; + } + return value.toLowerCase() !== 'false' && value !== '0'; +} + +/** + * Release repositories to try, in order. + * When ENTERPRISE_REPOSITORY is set it is the ONLY repository consulted. + * + * Otherwise the React Native Maven mirror is kept before Maven Central so + * cached artifacts are tried first and Maven Central remains the fallback. + */ +function mavenRepositoryUrls() /*: Array */ { + const enterpriseRepository = process.env.ENTERPRISE_REPOSITORY; + if (enterpriseRepository != null && enterpriseRepository !== '') { + return [enterpriseRepository.replace(/\/+$/, '')]; + } + return reactNativeMavenMirrorEnabled() + ? [REACT_NATIVE_MAVEN_MIRROR_REPOSITORY, MAVEN_CENTRAL_REPOSITORY] + : [MAVEN_CENTRAL_REPOSITORY]; +} + +function rnCoreReleaseUrls( version /*: string */, flavor /*: string */, -) /*: string */ { - return ( - `${MAVEN_RELEASE}/com/facebook/react/react-native-artifacts/${version}/` + - `react-native-artifacts-${version}-reactnative-core-${flavor}.tar.gz` +) /*: Array */ { + return mavenRepositoryUrls().map( + repository => + `${repository}/com/facebook/react/react-native-artifacts/${version}/` + + `react-native-artifacts-${version}-reactnative-core-${flavor}.tar.gz`, ); } -function rnDepsReleaseUrl( +function rnDepsReleaseUrls( version /*: string */, flavor /*: string */, -) /*: string */ { - return ( - `${MAVEN_RELEASE}/com/facebook/react/react-native-artifacts/${version}/` + - `react-native-artifacts-${version}-reactnative-dependencies-${flavor}.tar.gz` +) /*: Array */ { + return mavenRepositoryUrls().map( + repository => + `${repository}/com/facebook/react/react-native-artifacts/${version}/` + + `react-native-artifacts-${version}-reactnative-dependencies-${flavor}.tar.gz`, ); } -function hermesReleaseUrl( +function hermesReleaseUrls( version /*: string */, flavor /*: string */, -) /*: string */ { - return ( - `${MAVEN_RELEASE}/com/facebook/hermes/hermes-ios/${version}/` + - `hermes-ios-${version}-hermes-ios-${flavor}.tar.gz` +) /*: Array */ { + return mavenRepositoryUrls().map( + repository => + `${repository}/com/facebook/hermes/hermes-ios/${version}/` + + `hermes-ios-${version}-hermes-ios-${flavor}.tar.gz`, ); } @@ -319,11 +362,27 @@ async function exists(url /*: string */) /*: Promise */ { } } +/** + * Probes candidate URLs in order and returns the first one that exists + * (HTTP 200 on HEAD), or null when none do. + */ +async function firstExistingUrl( + candidates /*: Array */, +) /*: Promise */ { + for (const url of candidates) { + if (await exists(url)) { + return url; + } + } + return null; +} + /** * Returns {url, version} for the React Native core xcframework tarball. * Resolution order: - * 1. Stable release on Maven Central - * 2. Snapshot build on Sonatype + * 1. Stable release on the RN Maven mirror + * 2. Stable release on Maven Central + * 3. Snapshot build on Sonatype */ async function resolveRNCoreArtifact( version /*: string */, @@ -344,12 +403,12 @@ async function resolveRNCoreArtifact( log(` Using LOCAL core tarball: ${localTarball}`); return {url: localTarball, version: `${version}-local`}; } - const releaseUrl = rnCoreReleaseUrl(version, flavor); - if (await exists(releaseUrl)) { + const releaseUrl = await firstExistingUrl(rnCoreReleaseUrls(version, flavor)); + if (releaseUrl != null) { log(` Using stable release: ${releaseUrl}`); return {url: releaseUrl, version}; } - log(` Release not found, trying snapshot...`); + log(` Release not found in any Maven repository, trying snapshot...`); const snapshotUrl = await rnCoreSnapshotUrl(version, flavor); return {url: snapshotUrl, version}; } @@ -383,12 +442,12 @@ async function resolveRNDepsArtifact( version = await resolveNightlyVersion('react-native'); } - const releaseUrl = rnDepsReleaseUrl(version, flavor); - if (await exists(releaseUrl)) { + const releaseUrl = await firstExistingUrl(rnDepsReleaseUrls(version, flavor)); + if (releaseUrl != null) { log(` Using stable release: ${releaseUrl}`); return {url: releaseUrl, version}; } - log(` Release not found, trying snapshot...`); + log(` Release not found in any Maven repository, trying snapshot...`); const snapshotUrl = await rnDepsSnapshotUrl(version, flavor); return {url: snapshotUrl, version}; } @@ -422,12 +481,12 @@ async function resolveHermesArtifact( version = await resolveLatestV1Version(); } - const releaseUrl = hermesReleaseUrl(version, flavor); - if (await exists(releaseUrl)) { + const releaseUrl = await firstExistingUrl(hermesReleaseUrls(version, flavor)); + if (releaseUrl != null) { log(` Using stable release: ${releaseUrl}`); return {url: releaseUrl, version}; } - log(` Release not found, trying snapshot...`); + log(` Release not found in any Maven repository, trying snapshot...`); const snapshotUrl = await hermesSnapshotUrl(version, flavor); return {url: snapshotUrl, version}; } @@ -1393,9 +1452,12 @@ module.exports = { REQUIRED_ARTIFACTS, validateArtifactsCache, // Exposed for unit tests (pure / fetch-stubbable helpers). - rnCoreReleaseUrl, - rnDepsReleaseUrl, - hermesReleaseUrl, + mavenRepositoryUrls, + reactNativeMavenMirrorEnabled, + rnCoreReleaseUrls, + rnDepsReleaseUrls, + hermesReleaseUrls, + firstExistingUrl, resolveSnapshotUrl, resolveNightlyVersion, resolveLatestV1Version,