Skip to content

Commit 74b6d90

Browse files
chrfalchclaude
andcommitted
fix(iOS): stage Hermes headers in prebuild compose job so ReactNativeHeaders resolves <hermes/...>
The iOS prebuild workflow splits into two jobs on separate runners. `build-rn-slice` stages the hermes-ios headers during setup (`.build/artifacts/hermes/destroot/include/hermes`) but never hands them to the `compose-xcframework` job. Compose (`-c` -> `buildXCFrameworks`) computed the hermes include path from a directory that never exists on the compose runner, silently got `null`, and skipped the hermes-header fold in `headers-compose.js`. The published ReactNativeHeaders.xcframework therefore shipped without `hermes/`, so consumers couldn't resolve `<hermes/...>`. No warning fired, so every nightly regressed silently. Fix (build-time, matches the artifact's self-contained design): - compose-xcframework now re-stages the hermes-ios headers before composing ("Set Hermes version" + "Stage Hermes headers"), guarded by the same cache-hit condition as its sibling steps. - Extract the hermes-header resolution into an exported `resolveHermesHeaders(buildFolder, required)` with a `findFirst` fallback, and fail closed (throw) when a version-stamped CI cut can't find the headers instead of shipping without them. Gated behind a new `--require-hermes` flag, passed by the workflow only when `version-type` is set (local dev keeps the previous no-fold behavior). Adds unit tests for the resolver (found / findFirst fallback / absent-not-required -> null / absent-required -> throws). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 07dc003 commit 74b6d90

6 files changed

Lines changed: 162 additions & 16 deletions

File tree

.github/workflows/prebuild-ios-core.yml

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,27 @@ jobs:
175175
tar -xzf /tmp/third-party/ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz -C /tmp/third-party/
176176
mkdir -p packages/react-native/third-party/
177177
mv /tmp/third-party/packages/react-native/third-party/ReactNativeDependencies.xcframework packages/react-native/third-party/ReactNativeDependencies.xcframework
178+
- name: Set Hermes version
179+
if: steps.restore-ios-xcframework.outputs.cache-hit != 'true'
180+
shell: bash
181+
run: |
182+
# Non-stable RN builds resolve Hermes from npm's latest-v1 dist-tag.
183+
# TODO: rename 'latest-v1' to 'latest' once V1 is the only Hermes on npm.
184+
# Stable builds use the version pinned in version.properties.
185+
if [ "${{ inputs.use-hermes-prebuilt }}" == "true" ]; then
186+
HERMES_VERSION="latest-v1"
187+
else
188+
HERMES_VERSION=$(sed -n 's/^HERMES_VERSION_NAME=//p' packages/react-native/sdks/hermes-engine/version.properties)
189+
fi
190+
echo "Using Hermes version: $HERMES_VERSION"
191+
echo "HERMES_VERSION=$HERMES_VERSION" >> $GITHUB_ENV
192+
- name: Stage Hermes headers
193+
if: steps.restore-ios-xcframework.outputs.cache-hit != 'true'
194+
working-directory: packages/react-native
195+
env:
196+
FLAVOR: ${{ matrix.flavor }}
197+
run: |
198+
node -e "require('./scripts/ios-prebuild/hermes').prepareHermesArtifactsAsync(require('./package.json').version, process.env.FLAVOR).then(()=>process.exit(0)).catch(e=>{console.error(e);process.exit(1)})"
178199
- name: Setup Keychain
179200
if: ${{ steps.restore-ios-xcframework.outputs.cache-hit != 'true' && env.REACT_ORG_CODE_SIGNING_P12_CERT != '' }}
180201
uses: apple-actions/import-codesign-certs@v3 # https://github.com/marketplace/actions/import-code-signing-certificates
@@ -185,12 +206,12 @@ jobs:
185206
if: ${{ steps.restore-ios-xcframework.outputs.cache-hit != 'true' && env.REACT_ORG_CODE_SIGNING_P12_CERT == '' }}
186207
run: |
187208
cd packages/react-native
188-
node scripts/ios-prebuild -c -f "${{ matrix.flavor }}"
209+
node scripts/ios-prebuild -c -f "${{ matrix.flavor }}" ${{ inputs.version-type != '' && '--require-hermes' || '' }}
189210
- name: Create and Sign XCFramework
190211
if: ${{ steps.restore-ios-xcframework.outputs.cache-hit != 'true' && env.REACT_ORG_CODE_SIGNING_P12_CERT != '' }}
191212
run: |
192213
cd packages/react-native
193-
node scripts/ios-prebuild -c -f "${{ matrix.flavor }}" -i "React Org"
214+
node scripts/ios-prebuild -c -f "${{ matrix.flavor }}" -i "React Org" ${{ inputs.version-type != '' && '--require-hermes' || '' }}
194215
- name: Verify composed headers
195216
if: steps.restore-ios-xcframework.outputs.cache-hit != 'true'
196217
run: |

packages/react-native/scripts/ios-prebuild.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ async function main() {
7777
frameworkPaths,
7878
buildType,
7979
cli.identity,
80+
cli.requireHermes,
8081
);
8182
}
8283

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
'use strict';
12+
13+
const {resolveHermesHeaders} = require('../xcframework');
14+
const fs = require('node:fs');
15+
const os = require('node:os');
16+
const path = require('node:path');
17+
18+
describe('resolveHermesHeaders', () => {
19+
let tmp /*: string */ = '';
20+
let buildFolder /*: string */ = '';
21+
22+
beforeEach(() => {
23+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'xcframework-test-'));
24+
buildFolder = path.join(tmp, '.build');
25+
});
26+
27+
afterEach(() => {
28+
fs.rmSync(tmp, {recursive: true, force: true});
29+
});
30+
31+
test('returns the base include directory when hermes/hermes.h exists', () => {
32+
const includeDir = path.join(
33+
buildFolder,
34+
'artifacts',
35+
'hermes',
36+
'destroot',
37+
'include',
38+
);
39+
fs.mkdirSync(path.join(includeDir, 'hermes'), {recursive: true});
40+
fs.writeFileSync(path.join(includeDir, 'hermes', 'hermes.h'), '');
41+
42+
expect(resolveHermesHeaders(buildFolder, true)).toBe(includeDir);
43+
});
44+
45+
test('finds a non-standard nested include directory', () => {
46+
const includeDir = path.join(
47+
buildFolder,
48+
'artifacts',
49+
'hermes',
50+
'nested',
51+
'archive',
52+
'destroot',
53+
'include',
54+
);
55+
fs.mkdirSync(path.join(includeDir, 'hermes'), {recursive: true});
56+
fs.writeFileSync(path.join(includeDir, 'hermes', 'hermes.h'), '');
57+
58+
expect(resolveHermesHeaders(buildFolder, true)).toBe(includeDir);
59+
});
60+
61+
test('returns null when Hermes headers are absent and not required', () => {
62+
expect(resolveHermesHeaders(buildFolder, false)).toBeNull();
63+
});
64+
65+
test('throws when Hermes headers are absent and required', () => {
66+
expect(() => resolveHermesHeaders(buildFolder, true)).toThrow(
67+
/ReactNativeHeaders[\s\S]*<hermes\/\.\.\.>[\s\S]*destroot\/include/,
68+
);
69+
});
70+
});

packages/react-native/scripts/ios-prebuild/cli.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ const cli = yargs
6666
describe:
6767
'Specify the code signing identity to use for signing the frameworks.',
6868
})
69+
.option('require-hermes', {
70+
type: 'boolean',
71+
describe:
72+
'Require Hermes headers when composing the ReactNativeHeaders XCFramework.',
73+
})
6974
.help();
7075

7176
/**
@@ -80,6 +85,7 @@ async function getCLIConfiguration() /*: Promise<?{|
8085
flavor: BuildFlavor,
8186
destinations: ReadonlyArray<Destination>,
8287
identity: ?string,
88+
requireHermes: boolean,
8389
|}> */ {
8490
// Run input parsing
8591
const argv = await cli.argv;
@@ -124,6 +130,7 @@ async function getCLIConfiguration() /*: Promise<?{|
124130
flavor: flavor,
125131
destinations: resolvedPlatforms,
126132
identity: argv.identity,
133+
requireHermes: argv.requireHermes ?? false,
127134
};
128135
}
129136

packages/react-native/scripts/ios-prebuild/utils.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
const {execSync} = require('node:child_process');
1414
const fs = require('node:fs');
15+
const path = require('node:path');
1516

1617
/**
1718
* Creates a folder if it does not exist
@@ -28,6 +29,31 @@ function createFolderIfNotExists(folderPath /*:string*/) /*: string */ {
2829
return folderPath;
2930
}
3031

32+
function findFirst(
33+
dir /*: string */,
34+
predicate /*: (name: string) => boolean */,
35+
depth /*: number */,
36+
) /*: string | null */ {
37+
if (depth <= 0 || !fs.existsSync(dir)) {
38+
return null;
39+
}
40+
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
41+
// $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow but always string here
42+
const full /*: string */ = path.join(dir, entry.name);
43+
// $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow but always string here
44+
if (predicate(entry.name)) {
45+
return full;
46+
}
47+
if (entry.isDirectory()) {
48+
const hit = findFirst(full, predicate, depth - 1);
49+
if (hit != null) {
50+
return hit;
51+
}
52+
}
53+
}
54+
return null;
55+
}
56+
3157
function throwIfOnEden() {
3258
try {
3359
execSync('eden info', {stdio: 'ignore'});
@@ -104,6 +130,7 @@ async function computeNightlyTarballURL(
104130

105131
module.exports = {
106132
createFolderIfNotExists,
133+
findFirst,
107134
throwIfOnEden,
108135
createLogger,
109136
computeNightlyTarballURL,

packages/react-native/scripts/ios-prebuild/xcframework.js

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,45 @@ const fs = require('node:fs');
1919
const path = require('node:path');
2020

2121
const {execSync, execFileSync} = childProcess;
22-
const {createLogger} = utils;
22+
const {createLogger, findFirst} = utils;
2323

2424
const frameworkLog = createLogger('XCFramework');
2525

26+
function resolveHermesHeaders(
27+
buildFolder /*: string */,
28+
required /*: boolean */,
29+
) /*: ?string */ {
30+
const hermesArtifacts = path.join(buildFolder, 'artifacts', 'hermes');
31+
const baseInclude = path.join(hermesArtifacts, 'destroot', 'include');
32+
if (fs.existsSync(path.join(baseInclude, 'hermes', 'hermes.h'))) {
33+
return baseInclude;
34+
}
35+
36+
const includeDir = findFirst(hermesArtifacts, name => name === 'include', 8);
37+
if (
38+
includeDir != null &&
39+
fs.existsSync(path.join(includeDir, 'hermes', 'hermes.h'))
40+
) {
41+
return includeDir;
42+
}
43+
44+
if (required) {
45+
throw new Error(
46+
'Cannot compose ReactNativeHeaders: <hermes/...> will not resolve because ' +
47+
"hermes/hermes.h is missing. Stage the hermes-ios tarball's " +
48+
'destroot/include into .build/artifacts/hermes before composing.',
49+
);
50+
}
51+
return null;
52+
}
53+
2654
function buildXCFrameworks(
2755
rootFolder /*: string */,
2856
buildFolder /*: string */,
2957
frameworkFolders /*: Array<string> */,
3058
buildType /*: BuildFlavor */,
3159
identity /*: ?string */,
60+
requireHermes /*: boolean */ = false,
3261
) {
3362
// Let's run codegen for FBReactNativeSpec otherwise some headers will be missing
3463
generateFBReactNativeSpecIOS('.');
@@ -103,19 +132,9 @@ function buildXCFrameworks(
103132
// ReactNativeHeaders so consumers resolve `<hermes/hermes.h>` out of the box
104133
// (same fold ensureHeadersLayout does consumer-side). The hermes-ios tarball
105134
// is staged at .build/artifacts/hermes/destroot/include by the hermes prebuild
106-
// step; pass it when its `hermes/` namespace is present, else null (then
107-
// `<hermes/...>` stays consumer-composed, as before).
108-
const hermesInclude = path.resolve(
109-
process.cwd(),
110-
'.build',
111-
'artifacts',
112-
'hermes',
113-
'destroot',
114-
'include',
115-
);
116-
const hermesHeaders = fs.existsSync(path.join(hermesInclude, 'hermes'))
117-
? hermesInclude
118-
: null;
135+
// step; pass it when its `hermes/` namespace is present, else null for local
136+
// development. CI release composition requires it and fails closed.
137+
const hermesHeaders = resolveHermesHeaders(buildFolder, requireHermes);
119138
const headersXcfw = buildReactNativeHeadersXcframework(
120139
path.dirname(outputPath),
121140
plan,
@@ -271,4 +290,5 @@ function signXCFramework(
271290

272291
module.exports = {
273292
buildXCFrameworks,
293+
resolveHermesHeaders,
274294
};

0 commit comments

Comments
 (0)