Skip to content
Draft
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
9 changes: 9 additions & 0 deletions .github/workflows/publishReactNativeAndroidArtifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ jobs:
with:
IS_HYBRID_BUILD: ${{ matrix.is_hybrid }}

# 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"

- name: Get Java version
id: get-java-version
uses: ./.github/actions/composite/getJavaVersion
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
11 changes: 9 additions & 2 deletions .github/workflows/publishReactNativeiOSArtifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,9 @@
"healthcheck",
"helpdot",
"helpsite",
"hermestag",
"hermesv",
"hermesversion",
"hexcode",
"hibob",
"hrefs",
Expand Down
2 changes: 0 additions & 2 deletions ios/Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
2 changes: 1 addition & 1 deletion patches/react-native/details.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand All @@ -57,7 +58,7 @@ index 32287a7..5607be3 100644
}
}

@@ -65,8 +75,11 @@ publishing {
@@ -65,8 +76,11 @@ publishing {

repositories {
maven {
Expand Down
10 changes: 8 additions & 2 deletions scripts/artifacts-utils/android/ExpensiUtils.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 28 additions & 0 deletions scripts/artifacts-utils/get-hermes-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/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.
Comment on lines +8 to +13

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd either shorten this part or even move it higher up. This is very specific, and the first part seems to already explain what the script does.


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/hermes-engine/version.properties"

if [[ ! -f "$HERMES_VERSION_FILE" ]]; then
error "Hermes version file not found: $HERMES_VERSION_FILE"
exit 1
fi

sed -n 's/^HERMES_V1_VERSION_NAME=//p' "$HERMES_VERSION_FILE"
5 changes: 4 additions & 1 deletion scripts/artifacts-utils/ios/patched_ios_artifacts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions scripts/artifacts-utils/ios/publish/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -50,6 +51,7 @@ publishing {

properties = [
'patchesHash': patchesHash,
'hermesVersion': hermesVersion,
]

developers {
Expand Down
51 changes: 43 additions & 8 deletions scripts/artifacts-utils/lib/artifactsResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/

Expand All @@ -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 `<properties>`. 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;
Expand Down Expand Up @@ -146,6 +154,24 @@ function computePatchesHash(newDotRoot: string, isHybrid: boolean): string {
return execFileSync('bash', args, {encoding: 'utf8'}).trim();
}

/**
* 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
.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<string[]> {
/* eslint-disable @typescript-eslint/naming-convention -- GitHub REST API params are snake_case */
Expand All @@ -158,22 +184,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<string | null> {
function readPomProperty(pom: string, name: string): string | null {
return pom.match(new RegExp(`<${name}>([^<]+)</${name}>`))?.[1]?.trim() ?? null;
}

async function getRemotePomProperties(packageName: string, artifactId: string, version: string, githubToken: string): Promise<PomProperties> {
const pom = await fetchWithToken(`${getArtifactUrlPrefix(packageName, artifactId, version)}.pom`, githubToken);
return pom.match(/<patchesHash>([^<]+)<\/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<string | null> {
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;
}
Expand Down
12 changes: 10 additions & 2 deletions scripts/artifacts-utils/resolve-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -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
Expand Down
Loading
Loading