diff --git a/.github/actions/composite/determineNewPatchedRNVersion/action.yml b/.github/actions/composite/determineNewPatchedRNVersion/action.yml new file mode 100644 index 000000000000..e804287350d0 --- /dev/null +++ b/.github/actions/composite/determineNewPatchedRNVersion/action.yml @@ -0,0 +1,29 @@ +name: Determine new patched React Native version +description: > + Runs getNewPatchedRNVersion.sh with the required env wiring + (GITHUB_TOKEN, IS_HYBRID_BUILD, ARTIFACT_ID) and exposes the + resulting version string as an output. + +inputs: + artifact_id: + description: Artifact id registered in GitHub Packages Maven (e.g. react-android, react-native-artifacts) + required: true + is_hybrid_build: + description: Whether this is a HybridApp build (true) or Standalone (false) + required: true + +outputs: + NEW_PATCHED_VERSION: + description: The computed next patched React Native version (e.g. 0.83.1-3) + value: ${{ steps.run.outputs.NEW_PATCHED_VERSION }} + +runs: + using: composite + steps: + - id: run + shell: bash + run: echo "NEW_PATCHED_VERSION=$(./.github/scripts/getNewPatchedRNVersion.sh)" >> "$GITHUB_OUTPUT" + env: + GITHUB_TOKEN: ${{ github.token }} + IS_HYBRID_BUILD: ${{ inputs.is_hybrid_build }} + ARTIFACT_ID: ${{ inputs.artifact_id }} diff --git a/.github/actions/composite/fetchReactNativePrebuild/action.yml b/.github/actions/composite/fetchReactNativePrebuild/action.yml new file mode 100644 index 000000000000..096d7cd3547c --- /dev/null +++ b/.github/actions/composite/fetchReactNativePrebuild/action.yml @@ -0,0 +1,23 @@ +name: Fetch React Native prebuild files +description: > + Copies the `ios-prebuild*` scripts and `Package.swift` from the upstream + facebook/react-native repo (matching the version pinned in package.json) + into the local node_modules/react-native/ folder. + +runs: + using: composite + steps: + - name: Fetch prebuild files from upstream React Native + shell: bash + run: | + RN_VERSION="v$(jq -r '.dependencies["react-native"]' package.json)" + COMMIT=$(git ls-remote https://github.com/facebook/react-native.git "${RN_VERSION}^{}" | cut -f1) + git clone --depth=1 --filter=blob:none --sparse --no-checkout \ + https://github.com/facebook/react-native.git /tmp/rn-prebuild + git -C /tmp/rn-prebuild sparse-checkout set \ + packages/react-native/scripts \ + packages/react-native/Package.swift + git -C /tmp/rn-prebuild checkout "${COMMIT}" + cp -r /tmp/rn-prebuild/packages/react-native/scripts/ios-prebuild* node_modules/react-native/scripts/ + cp /tmp/rn-prebuild/packages/react-native/Package.swift node_modules/react-native/ + rm -rf /tmp/rn-prebuild diff --git a/.github/scripts/getNewPatchedRNVersion.sh b/.github/scripts/getNewPatchedRNVersion.sh index caac4124c57d..8a77b98d330c 100755 --- a/.github/scripts/getNewPatchedRNVersion.sh +++ b/.github/scripts/getNewPatchedRNVersion.sh @@ -5,6 +5,11 @@ if [ -z "$GITHUB_TOKEN" ]; then exit 1 fi +if [ -z "$ARTIFACT_ID" ]; then + echo "ARTIFACT_ID env variable is not set" + exit 1 +fi + if [[ "$IS_HYBRID_BUILD" == "true" ]]; then readonly PACKAGE="react-hybrid" else @@ -15,7 +20,7 @@ VERSION="$(jq -r '.dependencies["react-native"]' package.json)" readonly VERSION # List all versions of the package -PACKAGE_VERSIONS="$(gh api "/orgs/Expensify/packages/maven/com.expensify.${PACKAGE}.react-android/versions" --paginate --jq '.[].name')" +PACKAGE_VERSIONS="$(gh api "/orgs/Expensify/packages/maven/com.expensify.${PACKAGE}.${ARTIFACT_ID}/versions" --paginate --jq '.[].name')" # Filter only versions matching the base React Native version PACKAGE_VERSIONS="$(echo "$PACKAGE_VERSIONS" | grep "$VERSION")" diff --git a/.github/workflows/publishReactNativeAndroidArtifacts.yml b/.github/workflows/publishReactNativeAndroidArtifacts.yml index 7e76b4854dee..705214cf86e1 100644 --- a/.github/workflows/publishReactNativeAndroidArtifacts.yml +++ b/.github/workflows/publishReactNativeAndroidArtifacts.yml @@ -1,184 +1,57 @@ name: Publish React Native Android Artifacts on: - workflow_dispatch: + workflow_call: inputs: - build_standalone: - description: 'Build Standalone artifacts' - required: false - type: boolean - build_hybridapp: - description: 'Build HybridApp artifacts' - required: false - type: boolean - app_pull_request_url: - description: 'The Expensify/App pull request URL.' + build_targets: + description: JSON array of `is_hybrid` values to build (e.g. ["true","false"]). + required: true + type: string + app_ref: + description: Expensify/App ref to checkout (empty = triggering ref). required: false + type: string default: '' - mobile_expensify_pull_request_url: - description: 'The Expensify/Mobile-Expensify pull request URL to check out the submodule.' + mobile_expensify_ref: + description: Mobile-Expensify ref to checkout for hybrid builds (empty = pinned submodule). required: false + type: string default: '' - reviewed_code: - description: I reviewed this pull request and verified that it does not contain any malicious code. - type: boolean + secrets: + OS_BOTIFY_TOKEN: + description: Token used to checkout private submodules. + required: true + SLACK_WEBHOOK: + description: Webhook used to announce failures. required: true - default: false - push: - branches: - - main - paths: - - package.json - - patches/react-native+*.patch - - patches/@react-native+*.patch - - patches/react-native/react-native+*.patch - - patches/react-native/@react-native+*.patch - - Mobile-Expensify jobs: - verifyPatches: - name: Verify React Native Patches - runs-on: 'blacksmith-4vcpu-ubuntu-2404' - outputs: - build_targets: ${{ steps.getArtifactBuildTargets.outputs.BUILD_TARGETS }} - steps: - - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - with: - submodules: true - ref: ${{ github.event.before || 'main' }} - token: ${{ secrets.OS_BOTIFY_TOKEN }} - - - name: Get previous patches hash - if: ${{ github.event_name != 'workflow_dispatch' }} - id: getOldPatchesHash - run: | - echo "HYBRID_APP_HASH=$(./scripts/compute-patches-hash.sh patches Mobile-Expensify/patches)" >> "$GITHUB_OUTPUT" - echo "STANDALONE_APP_HASH=$(./scripts/compute-patches-hash.sh patches)" >> "$GITHUB_OUTPUT" - - - name: Get previous react-native version - if: ${{ github.event_name != 'workflow_dispatch' }} - id: getOldVersion - run: echo "VERSION=$(jq -r '.dependencies["react-native"]' package.json)" >> "$GITHUB_OUTPUT" - - - name: Checkout new ref - if: ${{ github.event_name != 'workflow_dispatch' }} - run: | - git fetch origin ${{ github.event.after }} --depth=1 - git checkout ${{ github.event.after }} - git submodule update - - - name: Get new patches hash - id: getNewPatchesHash - run: | - echo "HYBRID_APP_HASH=$(./scripts/compute-patches-hash.sh patches Mobile-Expensify/patches)" >> "$GITHUB_OUTPUT" - echo "STANDALONE_APP_HASH=$(./scripts/compute-patches-hash.sh patches)" >> "$GITHUB_OUTPUT" - - - name: Get new react-native version - if: ${{ github.event_name != 'workflow_dispatch' }} - id: getNewVersion - run: echo "VERSION=$(jq -r '.dependencies["react-native"]' package.json)" >> "$GITHUB_OUTPUT" - - - name: Check if version changed - if: ${{ github.event_name != 'workflow_dispatch' }} - id: didVersionChange - run: | - readonly DID_VERSION_CHANGE=${{ steps.getOldVersion.outputs.VERSION != steps.getNewVersion.outputs.VERSION && 'true' || 'false' }} - echo "DID_VERSION_CHANGE=$DID_VERSION_CHANGE" >> "$GITHUB_OUTPUT" - if [[ "$DID_VERSION_CHANGE" == 'true' ]]; then - echo "::notice::Detected react-native version bump (${{ steps.getOldVersion.outputs.VERSION }} -> ${{ steps.getNewVersion.outputs.VERSION }})" - fi - - - name: Check if patches changed - if: ${{ github.event_name != 'workflow_dispatch' }} - id: didPatchesChange - run: | - readonly DID_HYBRID_APP_PATCHES_CHANGE=${{ steps.getOldPatchesHash.outputs.HYBRID_APP_HASH != steps.getNewPatchesHash.outputs.HYBRID_APP_HASH && 'true' || 'false' }} - readonly DID_STANDALONE_APP_PATCHES_CHANGE=${{ steps.getOldPatchesHash.outputs.STANDALONE_APP_HASH != steps.getNewPatchesHash.outputs.STANDALONE_APP_HASH && 'true' || 'false' }} - echo "DID_HYBRID_APP_PATCHES_CHANGE=$DID_HYBRID_APP_PATCHES_CHANGE" >> "$GITHUB_OUTPUT" - echo "DID_STANDALONE_APP_PATCHES_CHANGE=$DID_STANDALONE_APP_PATCHES_CHANGE" >> "$GITHUB_OUTPUT" - - if [[ "$DID_HYBRID_APP_PATCHES_CHANGE" == 'true' ]]; then - echo "::notice::Detected changes in HybridApp patches (${{ steps.getOldPatchesHash.outputs.HYBRID_APP_HASH }} -> ${{ steps.getNewPatchesHash.outputs.HYBRID_APP_HASH }})" - fi - - if [[ "$DID_STANDALONE_APP_PATCHES_CHANGE" == 'true' ]]; then - echo "::notice::Detected changes in Standalone NewDot patches (${{ steps.getOldPatchesHash.outputs.STANDALONE_APP_HASH }} -> ${{ steps.getNewPatchesHash.outputs.STANDALONE_APP_HASH }})" - fi - - - name: Get artifact build targets - id: getArtifactBuildTargets - run: | - if [[ '${{ github.event_name }}' == 'workflow_dispatch' ]]; then - BUILD_TARGETS=() - - if [[ '${{ inputs.build_standalone }}' == 'true' ]]; then - BUILD_TARGETS+=(false) - fi - - if [[ '${{ inputs.build_hybridapp }}' == 'true' ]]; then - BUILD_TARGETS+=(true) - fi - - if [[ ${#BUILD_TARGETS[@]} -ne 0 ]]; then - echo "BUILD_TARGETS=$(printf '%s\n' "${BUILD_TARGETS[@]}" | jq -R . | jq -c -s .)" >> "$GITHUB_OUTPUT" - fi - exit 0 - fi - - # When there is a version change or standalone app patches change, we need to build for both hybrid and standalone - if [[ '${{ steps.didVersionChange.outputs.DID_VERSION_CHANGE }}' == 'true' || '${{ steps.didPatchesChange.outputs.DID_STANDALONE_APP_PATCHES_CHANGE }}' == 'true' ]]; then - echo "BUILD_TARGETS=[\"true\", \"false\"]" >> "$GITHUB_OUTPUT" - elif [[ '${{ steps.didPatchesChange.outputs.DID_HYBRID_APP_PATCHES_CHANGE }}' == 'true' ]]; then - echo "BUILD_TARGETS=[\"true\"]" >> "$GITHUB_OUTPUT" - fi - - # Validates the actor and that they reviewed the PR before any token is used or untrusted App PR code is checked out. - prep: - uses: ./.github/workflows/validateBuildRequest.yml - with: - REVIEWED_CODE: ${{ inputs.reviewed_code || false }} - secrets: - OS_BOTIFY_COMMIT_TOKEN: ${{ secrets.OS_BOTIFY_COMMIT_TOKEN }} - - resolveRefs: - name: Resolve build refs - needs: [prep] - uses: ./.github/workflows/resolveBuildRefs.yml - with: - APP_PULL_REQUEST_URL: ${{ inputs.app_pull_request_url }} - MOBILE_EXPENSIFY_PULL_REQUEST_URL: ${{ inputs.mobile_expensify_pull_request_url }} - secrets: - OS_BOTIFY_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - buildAndPublishReactNativeArtifacts: name: Build and Publish React Native Artifacts runs-on: ${{ github.repository_owner == 'Expensify' && 'blacksmith-16vcpu-ubuntu-2404' || 'blacksmith-2vcpu-ubuntu-2404' }} - needs: [verifyPatches, prep, resolveRefs] - if: needs.verifyPatches.outputs.build_targets != '' strategy: # Disable fail-fast to prevent cancelling both jobs when only one needs to be stopped due to concurrency limits fail-fast: false matrix: # Dynamically build the matrix based on the build targets - is_hybrid: ${{ fromJSON(needs.verifyPatches.outputs.build_targets) }} + is_hybrid: ${{ fromJSON(inputs.build_targets) }} concurrency: - group: ${{ github.workflow }}-${{ github.job }}-${{ matrix.is_hybrid }} + group: ${{ github.workflow }}-android-build-${{ matrix.is_hybrid }} cancel-in-progress: true steps: - name: Checkout Code uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: - ref: ${{ needs.resolveRefs.outputs.APP_REF }} + ref: ${{ inputs.app_ref }} submodules: ${{ matrix.is_hybrid }} token: ${{ secrets.OS_BOTIFY_TOKEN }} - name: Checkout Mobile-Expensify to specified branch or commit - if: ${{ matrix.is_hybrid == 'true' && needs.resolveRefs.outputs.MOBILE_EXPENSIFY_REF != '' }} + if: ${{ matrix.is_hybrid == 'true' && inputs.mobile_expensify_ref != '' }} run: | cd Mobile-Expensify - git fetch origin ${{ needs.resolveRefs.outputs.MOBILE_EXPENSIFY_REF }} - git checkout ${{ needs.resolveRefs.outputs.MOBILE_EXPENSIFY_REF }} + git fetch origin ${{ inputs.mobile_expensify_ref }} + git checkout ${{ inputs.mobile_expensify_ref }} # Recompute the patches hash from the actually checked-out tree (which may include a custom Mobile-Expensify # ref) so the published artifact is tagged with a hash that consumers will match against. @@ -186,9 +59,9 @@ jobs: id: computePatchesHash run: | if [[ '${{ matrix.is_hybrid }}' == 'true' ]]; then - echo "PATCHES_HASH=$(./scripts/compute-patches-hash.sh patches Mobile-Expensify/patches)" >> "$GITHUB_OUTPUT" + echo "PATCHES_HASH=$(./scripts/artifacts-utils/compute-patches-hash.sh patches Mobile-Expensify/patches)" >> "$GITHUB_OUTPUT" else - echo "PATCHES_HASH=$(./scripts/compute-patches-hash.sh patches)" >> "$GITHUB_OUTPUT" + echo "PATCHES_HASH=$(./scripts/artifacts-utils/compute-patches-hash.sh patches)" >> "$GITHUB_OUTPUT" fi - name: Setup Node @@ -212,10 +85,10 @@ jobs: - name: Determine new patched RN version id: getNewPatchedVersion - run: echo "NEW_PATCHED_VERSION=$(./.github/scripts/getNewPatchedRNVersion.sh)" >> "$GITHUB_OUTPUT" - env: - GITHUB_TOKEN: ${{ github.token }} - IS_HYBRID_BUILD: ${{ matrix.is_hybrid }} + uses: ./.github/actions/composite/determineNewPatchedRNVersion + with: + artifact_id: react-android + is_hybrid_build: ${{ matrix.is_hybrid }} - name: Build and publish React Native artifacts working-directory: ${{ matrix.is_hybrid == 'true' && 'Mobile-Expensify/Android' || 'android' }} diff --git a/.github/workflows/publishReactNativeArtifacts.yml b/.github/workflows/publishReactNativeArtifacts.yml new file mode 100644 index 000000000000..2abbe1b7b7db --- /dev/null +++ b/.github/workflows/publishReactNativeArtifacts.yml @@ -0,0 +1,103 @@ +name: Publish React Native Artifacts + +# Single entry point for building + publishing our prebuilt React Native artifacts. +# Runs the platform-independent phases (verify patches / validate request / resolve refs) ONCE, +# then fans out to the iOS and Android publish workflows in parallel. Always builds both platforms. + +on: + workflow_dispatch: + inputs: + build_standalone: + description: 'Build Standalone artifacts' + required: false + type: boolean + build_hybridapp: + description: 'Build HybridApp artifacts' + required: false + type: boolean + app_pull_request_url: + description: 'The Expensify/App pull request URL.' + required: false + default: '' + mobile_expensify_pull_request_url: + description: 'The Expensify/Mobile-Expensify pull request URL to check out the submodule.' + required: false + default: '' + reviewed_code: + description: I reviewed this pull request and verified that it does not contain any malicious code. + type: boolean + required: true + default: false + push: + branches: + - main + paths: + - package.json + - patches/react-native+*.patch + - patches/@react-native+*.patch + - patches/react-native/react-native+*.patch + - patches/react-native/@react-native+*.patch + - Mobile-Expensify + +jobs: + verifyPatches: + uses: ./.github/workflows/verifyReactNativePatches.yml + with: + ref_before: ${{ github.event.before || '' }} + ref_after: ${{ github.event.after || '' }} + build_standalone: ${{ github.event_name == 'workflow_dispatch' && inputs.build_standalone || false }} + build_hybridapp: ${{ github.event_name == 'workflow_dispatch' && inputs.build_hybridapp || false }} + secrets: + OS_BOTIFY_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + + # Validates the actor and that they reviewed the PR before any token is used or untrusted App PR code is checked out. + prep: + uses: ./.github/workflows/validateBuildRequest.yml + with: + REVIEWED_CODE: ${{ inputs.reviewed_code || false }} + secrets: + OS_BOTIFY_COMMIT_TOKEN: ${{ secrets.OS_BOTIFY_COMMIT_TOKEN }} + + resolveRefs: + name: Resolve build refs + needs: [prep] + uses: ./.github/workflows/resolveBuildRefs.yml + with: + APP_PULL_REQUEST_URL: ${{ inputs.app_pull_request_url }} + MOBILE_EXPENSIFY_PULL_REQUEST_URL: ${{ inputs.mobile_expensify_pull_request_url }} + secrets: + OS_BOTIFY_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + + # Fail-fast: a manual dispatch that ticks neither variant yields no build targets. Without this the run + # would be a confusing green no-op. On push an empty build_targets is a legitimate "nothing changed". + assertHasTargets: + needs: [verifyPatches] + if: github.event_name == 'workflow_dispatch' && needs.verifyPatches.outputs.build_targets == '' + runs-on: ubuntu-latest + steps: + - name: Fail when nothing was selected to build + run: | + echo "::error::No build targets selected — tick build_standalone and/or build_hybridapp." + exit 1 + + ios: + name: iOS + needs: [verifyPatches, prep, resolveRefs] + if: needs.verifyPatches.outputs.build_targets != '' + uses: ./.github/workflows/publishReactNativeiOSArtifacts.yml + with: + build_targets: ${{ needs.verifyPatches.outputs.build_targets }} + app_ref: ${{ needs.resolveRefs.outputs.APP_REF }} + mobile_expensify_ref: ${{ needs.resolveRefs.outputs.MOBILE_EXPENSIFY_REF }} + secrets: inherit + + android: + name: Android + needs: [verifyPatches, prep, resolveRefs] + if: needs.verifyPatches.outputs.build_targets != '' + uses: ./.github/workflows/publishReactNativeAndroidArtifacts.yml + with: + build_targets: ${{ needs.verifyPatches.outputs.build_targets }} + app_ref: ${{ needs.resolveRefs.outputs.APP_REF }} + mobile_expensify_ref: ${{ needs.resolveRefs.outputs.MOBILE_EXPENSIFY_REF }} + secrets: inherit diff --git a/.github/workflows/publishReactNativeiOSArtifacts.yml b/.github/workflows/publishReactNativeiOSArtifacts.yml new file mode 100644 index 000000000000..3d3987303df0 --- /dev/null +++ b/.github/workflows/publishReactNativeiOSArtifacts.yml @@ -0,0 +1,245 @@ +name: Publish React Native iOS Artifacts + +on: + workflow_call: + inputs: + build_targets: + description: JSON array of `is_hybrid` values to build (e.g. ["true","false"]). + required: true + type: string + app_ref: + description: Expensify/App ref to checkout (empty = triggering ref). + required: false + type: string + default: '' + mobile_expensify_ref: + description: Mobile-Expensify ref to checkout for hybrid builds (empty = pinned submodule). + required: false + type: string + default: '' + secrets: + OS_BOTIFY_TOKEN: + description: Token used to checkout private submodules. + required: true + SLACK_WEBHOOK: + description: Webhook used to announce failures. + required: true + +env: + DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer + +jobs: + buildSlice: + name: Build iOS Slice + runs-on: blacksmith-12vcpu-macos-latest + strategy: + fail-fast: false + matrix: + is_hybrid: ${{ fromJSON(inputs.build_targets) }} + flavor: ['Debug', 'Release'] + platform: ['ios', 'ios-simulator'] + concurrency: + group: ${{ github.workflow }}-slice-${{ matrix.is_hybrid }}-${{ matrix.flavor }}-${{ matrix.platform }} + cancel-in-progress: true + steps: + - name: Checkout Code + uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + with: + ref: ${{ inputs.app_ref }} + submodules: ${{ matrix.is_hybrid }} + token: ${{ secrets.OS_BOTIFY_TOKEN }} + + - name: Checkout Mobile-Expensify to specified branch or commit + if: ${{ matrix.is_hybrid == 'true' && inputs.mobile_expensify_ref != '' }} + run: | + cd Mobile-Expensify + git fetch origin ${{ inputs.mobile_expensify_ref }} + git checkout ${{ inputs.mobile_expensify_ref }} + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + with: + IS_HYBRID_BUILD: ${{ matrix.is_hybrid }} + + - name: Resolve Hermes version + id: hermes + run: echo "VERSION=$(sed 's/^hermes-v\{0,1\}//' node_modules/react-native/sdks/.hermesversion)" >> "$GITHUB_OUTPUT" + + - name: Fetch prebuild files from upstream RN + uses: ./.github/actions/composite/fetchReactNativePrebuild + + - name: Setup prebuild workspace + working-directory: node_modules/react-native + run: node scripts/ios-prebuild.js -s -f ${{ matrix.flavor }} + env: + HERMES_VERSION: ${{ steps.hermes.outputs.VERSION }} + + - name: Build slice + working-directory: node_modules/react-native + run: node scripts/ios-prebuild.js -b -f ${{ matrix.flavor }} -p ${{ matrix.platform }} + + - name: Upload slice artifact + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: slice-${{ matrix.is_hybrid }}-${{ matrix.flavor }}-${{ matrix.platform }} + path: node_modules/react-native/.build/output/spm/${{ matrix.flavor }}/Build/Products + retention-days: 1 + + - name: Upload headers + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: headers-${{ matrix.is_hybrid }}-${{ matrix.flavor }}-${{ matrix.platform }} + path: node_modules/react-native/.build/headers + retention-days: 1 + + composeXCFramework: + name: Compose XCFramework + runs-on: blacksmith-12vcpu-macos-latest + needs: [buildSlice] + strategy: + fail-fast: false + matrix: + is_hybrid: ${{ fromJSON(inputs.build_targets) }} + flavor: ['Debug', 'Release'] + concurrency: + group: ${{ github.workflow }}-compose-${{ matrix.is_hybrid }}-${{ matrix.flavor }} + cancel-in-progress: true + steps: + - name: Checkout Code + uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + with: + ref: ${{ inputs.app_ref }} + submodules: ${{ matrix.is_hybrid }} + token: ${{ secrets.OS_BOTIFY_TOKEN }} + + - name: Checkout Mobile-Expensify to specified branch or commit + if: ${{ matrix.is_hybrid == 'true' && inputs.mobile_expensify_ref != '' }} + run: | + cd Mobile-Expensify + git fetch origin ${{ inputs.mobile_expensify_ref }} + git checkout ${{ inputs.mobile_expensify_ref }} + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + with: + IS_HYBRID_BUILD: ${{ matrix.is_hybrid }} + + - name: Fetch prebuild files from upstream RN + uses: ./.github/actions/composite/fetchReactNativePrebuild + + - name: Download slice artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + pattern: slice-${{ matrix.is_hybrid }}-${{ matrix.flavor }}-* + path: node_modules/react-native/.build/output/spm/${{ matrix.flavor }}/Build/Products + merge-multiple: true + + - name: Download headers + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + pattern: headers-${{ matrix.is_hybrid }}-${{ matrix.flavor }}-* + path: node_modules/react-native/.build/headers + merge-multiple: true + + - name: Compose XCFramework + working-directory: node_modules/react-native + run: node scripts/ios-prebuild.js -c -f ${{ matrix.flavor }} + + - name: Compress and rename XCFramework + run: | + cd node_modules/react-native/.build/output/xcframeworks/${{ matrix.flavor }} + tar -czf reactnative-core-${{ matrix.flavor == 'Debug' && 'debug' || 'release' }}.tar.gz React.xcframework + + - name: Compress and rename dSYM + run: | + cd node_modules/react-native/.build/output/xcframeworks/${{ matrix.flavor }}/Symbols + tar -czf ../reactnative-core-dSYM-${{ matrix.flavor == 'Debug' && 'debug' || 'release' }}.tar.gz . + + - name: Upload XCFramework artifact + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: reactnative-core-${{ matrix.flavor == 'Debug' && 'debug' || 'release' }}-${{ matrix.is_hybrid }} + path: node_modules/react-native/.build/output/xcframeworks/${{ matrix.flavor }}/reactnative-core-${{ matrix.flavor == 'Debug' && 'debug' || 'release' }}.tar.gz + retention-days: 1 + + - name: Upload dSYM artifact + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: reactnative-core-dSYM-${{ matrix.flavor == 'Debug' && 'debug' || 'release' }}-${{ matrix.is_hybrid }} + path: node_modules/react-native/.build/output/xcframeworks/${{ matrix.flavor }}/reactnative-core-dSYM-${{ matrix.flavor == 'Debug' && 'debug' || 'release' }}.tar.gz + retention-days: 1 + + publishReactNativeiOSArtifacts: + name: Publish React Native iOS Artifacts + runs-on: blacksmith-4vcpu-ubuntu-2404 + needs: [composeXCFramework] + strategy: + fail-fast: false + matrix: + is_hybrid: ${{ fromJSON(inputs.build_targets) }} + concurrency: + # Publish runs sequentially per is_hybrid. Cancelling an in-flight publish + # can leave Maven metadata out of sync with the uploaded artifact, so we + # let the current run finish and queue the next one instead. + group: ${{ github.workflow }}-publish-${{ matrix.is_hybrid }} + cancel-in-progress: false + steps: + - name: Checkout Code + uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + with: + ref: ${{ inputs.app_ref }} + submodules: ${{ matrix.is_hybrid }} + token: ${{ secrets.OS_BOTIFY_TOKEN }} + + - name: Checkout Mobile-Expensify to specified branch or commit + if: ${{ matrix.is_hybrid == 'true' && inputs.mobile_expensify_ref != '' }} + run: | + cd Mobile-Expensify + git fetch origin ${{ inputs.mobile_expensify_ref }} + git checkout ${{ inputs.mobile_expensify_ref }} + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + with: + IS_HYBRID_BUILD: ${{ matrix.is_hybrid }} + + # Recompute the patches hash from the actually checked-out tree (which may include a custom Mobile-Expensify + # ref) so the published artifact is tagged with a hash that consumers will match against. + - name: Compute patches hash + id: computePatchesHash + run: | + if [[ '${{ matrix.is_hybrid }}' == 'true' ]]; then + echo "PATCHES_HASH=$(./scripts/artifacts-utils/compute-patches-hash.sh patches Mobile-Expensify/patches)" >> "$GITHUB_OUTPUT" + else + echo "PATCHES_HASH=$(./scripts/artifacts-utils/compute-patches-hash.sh patches)" >> "$GITHUB_OUTPUT" + fi + + - name: Determine new patched RN version + id: getNewPatchedVersion + uses: ./.github/actions/composite/determineNewPatchedRNVersion + with: + artifact_id: react-native-artifacts + is_hybrid_build: ${{ matrix.is_hybrid }} + + - name: Download artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + pattern: reactnative-core-*-${{ matrix.is_hybrid }} + path: /tmp/ios-artifacts + 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}" + 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 }} + PACKAGE_NAME: ${{ matrix.is_hybrid == 'true' && 'react-hybrid' || 'react-standalone' }} + + - name: Announce failed workflow in Slack + if: ${{ failure() }} + uses: ./.github/actions/composite/announceFailedWorkflowInSlack + with: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + CHANNEL: '#expensify-open-source' diff --git a/.github/workflows/verifyReactNativePatches.yml b/.github/workflows/verifyReactNativePatches.yml new file mode 100644 index 000000000000..b6d02dfc54db --- /dev/null +++ b/.github/workflows/verifyReactNativePatches.yml @@ -0,0 +1,136 @@ +name: Verify React Native Patches + +on: + workflow_call: + inputs: + ref_before: + description: Git ref to compare against (typically github.event.before); leave empty for workflow_dispatch + required: false + type: string + default: '' + ref_after: + description: Git ref to compare to (typically github.event.after); leave empty for workflow_dispatch + required: false + type: string + default: '' + build_standalone: + description: Force Standalone build (from workflow_dispatch) + required: false + type: boolean + default: false + build_hybridapp: + description: Force HybridApp build (from workflow_dispatch) + required: false + type: boolean + default: false + + outputs: + build_targets: + description: JSON array of `is_hybrid` values to build (empty string when nothing to build) + value: ${{ jobs.verify.outputs.build_targets }} + + secrets: + OS_BOTIFY_TOKEN: + description: Token used to checkout private submodules + required: true + +jobs: + verify: + name: Verify React Native Patches + runs-on: blacksmith-4vcpu-ubuntu-2404 + outputs: + build_targets: ${{ steps.getArtifactBuildTargets.outputs.BUILD_TARGETS }} + steps: + - name: Checkout + # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + submodules: true + ref: ${{ inputs.ref_before || 'main' }} + token: ${{ secrets.OS_BOTIFY_TOKEN }} + + - name: Get previous patches hash + if: ${{ inputs.ref_after != '' }} + id: getOldPatchesHash + run: | + echo "HYBRID_APP_HASH=$(./scripts/artifacts-utils/compute-patches-hash.sh patches Mobile-Expensify/patches)" >> "$GITHUB_OUTPUT" + echo "STANDALONE_APP_HASH=$(./scripts/artifacts-utils/compute-patches-hash.sh patches)" >> "$GITHUB_OUTPUT" + + - name: Get previous react-native version + if: ${{ inputs.ref_after != '' }} + id: getOldVersion + run: echo "VERSION=$(jq -r '.dependencies["react-native"]' package.json)" >> "$GITHUB_OUTPUT" + + - name: Checkout new ref + if: ${{ inputs.ref_after != '' }} + run: | + git fetch origin ${{ inputs.ref_after }} --depth=1 + git checkout ${{ inputs.ref_after }} + git submodule update + + - name: Get new patches hash + id: getNewPatchesHash + run: | + echo "HYBRID_APP_HASH=$(./scripts/artifacts-utils/compute-patches-hash.sh patches Mobile-Expensify/patches)" >> "$GITHUB_OUTPUT" + echo "STANDALONE_APP_HASH=$(./scripts/artifacts-utils/compute-patches-hash.sh patches)" >> "$GITHUB_OUTPUT" + + - name: Get new react-native version + if: ${{ inputs.ref_after != '' }} + id: getNewVersion + run: echo "VERSION=$(jq -r '.dependencies["react-native"]' package.json)" >> "$GITHUB_OUTPUT" + + - name: Check if version changed + if: ${{ inputs.ref_after != '' }} + id: didVersionChange + run: | + readonly DID_VERSION_CHANGE=${{ steps.getOldVersion.outputs.VERSION != steps.getNewVersion.outputs.VERSION && 'true' || 'false' }} + echo "DID_VERSION_CHANGE=$DID_VERSION_CHANGE" >> "$GITHUB_OUTPUT" + if [[ "$DID_VERSION_CHANGE" == 'true' ]]; then + echo "::notice::Detected react-native version bump (${{ steps.getOldVersion.outputs.VERSION }} -> ${{ steps.getNewVersion.outputs.VERSION }})" + fi + + - name: Check if patches changed + if: ${{ inputs.ref_after != '' }} + id: didPatchesChange + run: | + readonly DID_HYBRID_APP_PATCHES_CHANGE=${{ steps.getOldPatchesHash.outputs.HYBRID_APP_HASH != steps.getNewPatchesHash.outputs.HYBRID_APP_HASH && 'true' || 'false' }} + readonly DID_STANDALONE_APP_PATCHES_CHANGE=${{ steps.getOldPatchesHash.outputs.STANDALONE_APP_HASH != steps.getNewPatchesHash.outputs.STANDALONE_APP_HASH && 'true' || 'false' }} + echo "DID_HYBRID_APP_PATCHES_CHANGE=$DID_HYBRID_APP_PATCHES_CHANGE" >> "$GITHUB_OUTPUT" + echo "DID_STANDALONE_APP_PATCHES_CHANGE=$DID_STANDALONE_APP_PATCHES_CHANGE" >> "$GITHUB_OUTPUT" + + if [[ "$DID_HYBRID_APP_PATCHES_CHANGE" == 'true' ]]; then + echo "::notice::Detected changes in HybridApp patches (${{ steps.getOldPatchesHash.outputs.HYBRID_APP_HASH }} -> ${{ steps.getNewPatchesHash.outputs.HYBRID_APP_HASH }})" + fi + + if [[ "$DID_STANDALONE_APP_PATCHES_CHANGE" == 'true' ]]; then + echo "::notice::Detected changes in Standalone NewDot patches (${{ steps.getOldPatchesHash.outputs.STANDALONE_APP_HASH }} -> ${{ steps.getNewPatchesHash.outputs.STANDALONE_APP_HASH }})" + fi + + - name: Get artifact build targets + id: getArtifactBuildTargets + run: | + # workflow_dispatch path: caller leaves ref_after empty and passes explicit toggles. + if [[ '${{ inputs.ref_after }}' == '' ]]; then + BUILD_TARGETS=() + + if [[ '${{ inputs.build_standalone }}' == 'true' ]]; then + BUILD_TARGETS+=(false) + fi + + if [[ '${{ inputs.build_hybridapp }}' == 'true' ]]; then + BUILD_TARGETS+=(true) + fi + + if [[ ${#BUILD_TARGETS[@]} -ne 0 ]]; then + echo "BUILD_TARGETS=$(printf '%s\n' "${BUILD_TARGETS[@]}" | jq -R . | jq -c -s .)" >> "$GITHUB_OUTPUT" + fi + exit 0 + fi + + # push path: derive targets from what actually changed between ref_before and ref_after. + # Version change or standalone patch change forces a full rebuild of both hybrid and standalone. + if [[ '${{ steps.didVersionChange.outputs.DID_VERSION_CHANGE }}' == 'true' || '${{ steps.didPatchesChange.outputs.DID_STANDALONE_APP_PATCHES_CHANGE }}' == 'true' ]]; then + echo "BUILD_TARGETS=[\"true\", \"false\"]" >> "$GITHUB_OUTPUT" + elif [[ '${{ steps.didPatchesChange.outputs.DID_HYBRID_APP_PATCHES_CHANGE }}' == 'true' ]]; then + echo "BUILD_TARGETS=[\"true\"]" >> "$GITHUB_OUTPUT" + fi diff --git a/android/build.gradle b/android/build.gradle index 6a970ae008aa..7ab81991995f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -16,7 +16,7 @@ buildscript { // "mapbox" indicates the usage of the Mapbox SDK. RNMapboxMapsImpl = "mapbox" reactNativeIncludedBuild = gradle.getIncludedBuilds().find { it.name == 'react-native' } - // This is our custom extension that we defined in gradleUtils/PatchedArtifactsSettings.gradle + // This is our custom extension that we defined in scripts/artifacts-utils/android/PatchedArtifactsSettings.gradle // It enables us to use custom artifacts of patched react-native patchedArtifactsConfig = project.gradle.settings.extensions.findByName('patchedArtifacts') } diff --git a/android/settings.gradle b/android/settings.gradle index b41080ec1d7a..e1e1b28c975c 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -6,7 +6,7 @@ plugins { id("com.facebook.react.settings") id("expo-autolinking-settings") } -apply from: "${rootDir}/../gradleUtils/PatchedArtifactsSettings.gradle" +apply from: "${rootDir}/../scripts/artifacts-utils/android/PatchedArtifactsSettings.gradle" extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand(['npx', 'rock', 'config', '-p', 'android']) } rootProject.name = 'NewExpensify' diff --git a/cspell.json b/cspell.json index 1b8055f4a77a..5cef97716ce3 100644 --- a/cspell.json +++ b/cspell.json @@ -827,6 +827,7 @@ "pnrs", "podspec", "podspecs", + "popen", "popen3", "positionMillis", "preauthorization", diff --git a/gradleUtils/ExpensiLog.gradle b/gradleUtils/ExpensiLog.gradle deleted file mode 100644 index fcf15c98dc1a..000000000000 --- a/gradleUtils/ExpensiLog.gradle +++ /dev/null @@ -1,26 +0,0 @@ -ext.ExpensiLog = new Object() { - private final String BLUE = "\u001B[34m" - private final String YELLOW = "\u001B[33m" - private final String RED = "\u001B[31m" - private final String RESET = "\u001B[0m" - - private String prefix = "" - void setPrefix(String newPrefix) { this.prefix = newPrefix } - void clearPrefix() { this.prefix = "" } - - private String format(String color, String message) { - return "${color}[${prefix}] ${message}${this.RESET}" - } - - void lifecycle(String message) { - logger.lifecycle(format(this.BLUE, message)) - } - - void warn(String message) { - logger.warn(format(this.YELLOW, message)) - } - - void error(String message) { - logger.error(format(this.RED, message)) - } -} diff --git a/gradleUtils/PatchedArtifactsSettings.gradle b/gradleUtils/PatchedArtifactsSettings.gradle deleted file mode 100644 index 066a70ef982a..000000000000 --- a/gradleUtils/PatchedArtifactsSettings.gradle +++ /dev/null @@ -1,176 +0,0 @@ -import groovy.xml.XmlSlurper -import groovy.json.JsonSlurper -import java.util.concurrent.TimeUnit - -apply from: "ExpensiLog.gradle" - -class PatchedArtifactsConfig { - String version = null - String packageName = null - String githubUsername = null - String githubToken = null - Boolean buildFromSource = true -} - -def patchedArtifacts = settings.getExtensions().create('patchedArtifacts', PatchedArtifactsConfig) - -def warnIfNotConfigured(reason) { - ExpensiLog.warn("$reason") - ExpensiLog.warn("For setup instructions, refer to: https://github.com/Expensify/App/blob/main/contributingGuides/SETUP_ANDROID.md#enabling-prebuilt-react-native-artifacts") -} - -def getNewDotRootDir() { - return "${rootDir}/${hasProperty('newDotRoot') ? getProperty('newDotRoot') : "/.."}" -} - -def runCommand(command) { - def process = command.execute() - process.waitFor(10, TimeUnit.SECONDS) - return [output: process.in.text?.trim(), exitCode: process.exitValue()] -} - -def hasGithubCLI() { - return runCommand(["which", "gh"]).exitCode == 0 -} - -def isCIEnvironment() { - return System.getenv("CI") != null -} - -def hasRequiredScopes() { - def scopes = runCommand(["gh", "auth", "status"]).output - return ['read:packages', 'write:packages'].any { scope -> scopes.contains(scope) } -} - -def getCIGithubActor() { - return System.getenv("GITHUB_ACTOR") -} - -def getCIGithubToken() { - return System.getenv("GITHUB_TOKEN") -} - -def hasRequiredCIEnvs() { - return getCIGithubActor() != null && getCIGithubToken() != null -} - -def getGithubUsername() { - if (isCIEnvironment()) { - return getCIGithubActor() - } - return new JsonSlurper().parseText(runCommand(["gh", "api", "user"]).output).login -} - -def getGithubToken() { - if (isCIEnvironment()) { - return getCIGithubToken() - } - return runCommand(["gh", "auth", "token"]).output -} - -def validateCredentialsContext() { - if (!hasGithubCLI()) { - return "No Github CLI found." - } - - if (isCIEnvironment() && !hasRequiredCIEnvs()) { - return "Missing required CI environment variables GITHUB_ACTOR and/or GITHUB_TOKEN." - } - - if (!isCIEnvironment() && !hasRequiredScopes()) { - return "Github token does not have required scope read:packages." - } - - return null -} - -def getCredentials() { - try { - def validationErrorMessage = validateCredentialsContext() - if (validationErrorMessage != null) { - warnIfNotConfigured(validationErrorMessage) - return null - } - - return [ - githubUsername: getGithubUsername(), - githubToken: getGithubToken(), - ] - } catch (Exception e) { - warnIfNotConfigured("Failed to get Github credentials. This might be due to an expired token or not being logged in.") - return null - } -} - -def getReactNativeVersion() { - def packageJsonPath = "${getNewDotRootDir()}/package.json" - def packageJson = file(packageJsonPath) - return new JsonSlurper().parse(packageJson).dependencies.'react-native' -} - -def getArtifactsCandidates(packageName) { - def reactNativeVersion = getReactNativeVersion() - def patchedVersions = runCommand(["gh", "api", "/orgs/Expensify/packages/maven/com.expensify.${packageName}.react-android/versions", "--jq", ".[].name"]).output - return patchedVersions.split("\n").findAll { it.startsWith(reactNativeVersion) } -} - -def getPomFile(version, packageName, githubToken) { - return new XmlSlurper().parse( - new URL("https://maven.pkg.github.com/Expensify/App/com/expensify/${packageName}/react-android/${version}/react-android-${version}.pom").openConnection().with { - setRequestProperty("Authorization", "Bearer ${githubToken}") - getInputStream() - } - ) -} - -def getLocalPatchesHash() { - def newDotRootDir = getNewDotRootDir() - def computePatchesScript = "${getNewDotRootDir()}/scripts/compute-patches-hash.sh" - def arguments = hasProperty('newDotRoot') ? ["${newDotRootDir}/patches", "${newDotRootDir}/Mobile-Expensify/patches"] : ["${newDotRootDir}/patches"] - def localPatchesHash = runCommand([computePatchesScript] + arguments).output.trim() - - return localPatchesHash -} - -def findMatchingArtifactsVersion(packageName, githubToken) { - try { - def localPatchesHash = getLocalPatchesHash() - def candidates = getArtifactsCandidates(packageName) - for (candidate in candidates) { - def patchesHashFromPomFile = getPomFile(candidate, packageName, githubToken).properties.patchesHash.text().trim() - if (patchesHashFromPomFile == localPatchesHash) { - return candidate - } - } - } catch (Exception e) { - ExpensiLog.error("Failed to find matching artifacts version for ${packageName}. Reason: $e") - return null - } -} - -settings.extensions.configure(PatchedArtifactsConfig) { config -> - ExpensiLog.setPrefix("PatchedArtifacts") - config.buildFromSource = getProperty('patchedArtifacts.forceBuildFromSource') == 'true' - if(config.buildFromSource) { - ExpensiLog.lifecycle("Forcing build from source.") - return - } - - def credentials = getCredentials() - config.packageName = getProperty('patchedArtifacts.packageName') - - if(credentials != null) { - config.githubUsername = credentials.githubUsername - config.githubToken = credentials.githubToken - config.version = findMatchingArtifactsVersion(config.packageName, config.githubToken) - } - - if(config.version == null || config.packageName == null || config.githubUsername == null || config.githubToken == null) { - config.buildFromSource = true - ExpensiLog.error("No matching artifacts version found for ${config.packageName}. Building react-native from source.") - } - else { - config.buildFromSource = false - ExpensiLog.lifecycle("Using patched react-native artifacts: ${config.packageName}:${config.version}") - } -} diff --git a/rock.config.mjs b/rock.config.mjs index fd354e8f6832..533c0a3203ce 100644 --- a/rock.config.mjs +++ b/rock.config.mjs @@ -22,7 +22,7 @@ export default { android: platformAndroid({sourceDir: isHybrid ? './Mobile-Expensify/Android' : './android'}), }, fingerprint: { - extraSources: ['android/gradle.properties', 'ios/Podfile', 'scripts/compute-patches-hash.sh', 'patches', ...(isHybrid ? ['Mobile-Expensify/patches'] : [])], + extraSources: ['android/gradle.properties', 'ios/Podfile', 'scripts/artifacts-utils/compute-patches-hash.sh', 'patches', ...(isHybrid ? ['Mobile-Expensify/patches'] : [])], env: ['USE_WEB_PROXY', 'PUSHER_DEV_SUFFIX', 'SECURE_NGROK_URL', 'NGROK_URL', 'USE_NGROK', 'FORCE_NATIVE_BUILD'], ignorePaths: ['Mobile-Expensify/Android/assets/app/shared/bundle.js'], }, diff --git a/scripts/artifacts-utils/android/ExpensiUtils.gradle b/scripts/artifacts-utils/android/ExpensiUtils.gradle new file mode 100644 index 000000000000..b76b617d16d2 --- /dev/null +++ b/scripts/artifacts-utils/android/ExpensiUtils.gradle @@ -0,0 +1,47 @@ +import groovy.json.JsonSlurper +import java.util.concurrent.TimeUnit + +ext.ExpensiLog = new Object() { + private final String BLUE = "" + private final String YELLOW = "" + private final String RED = "" + private final String RESET = "" + + private String prefix = "" + void setPrefix(String newPrefix) { this.prefix = newPrefix } + + private String format(String color, String message) { + return "${color}[${prefix}] ${message}${this.RESET}" + } + + void lifecycle(String message) { logger.lifecycle(format(this.BLUE, message)) } + void warn(String message) { logger.warn(format(this.YELLOW, message)) } + void error(String message) { logger.error(format(this.RED, message)) } +} + +ext.ExpensiUtils = new Object() { + Map runCommand(List command, long timeoutSeconds = 10, File workingDir = null) { + def process = workingDir ? command.execute(null, workingDir) : command.execute() + process.waitFor(timeoutSeconds, TimeUnit.SECONDS) + return [output: process.in.text?.trim(), exitCode: process.exitValue()] + } + + // Returns [buildFromSource, version, githubUsername, githubToken]; any failure -> build from source. + Map resolveArtifacts(String packageName, String newDotRootDir, boolean isHybrid) { + def cmd = [ + "bun", "${newDotRootDir}/scripts/artifacts-utils/resolve-artifacts.ts", + "--platform=android", "--package=${packageName}", "--hybrid=${isHybrid}", "--new-dot-root=${newDotRootDir}" + ] + try { + def result = runCommand(cmd, 120, new File(newDotRootDir)) + if (result.exitCode != 0) { + ExpensiLog.error("Artifacts resolver failed (exit ${result.exitCode}). Building react-native from source.") + return [buildFromSource: true, version: null] + } + return new JsonSlurper().parseText(result.output) + } catch (Exception e) { + ExpensiLog.error("Artifacts resolver error: ${e}. Building react-native from source.") + return [buildFromSource: true, version: null] + } + } +} diff --git a/scripts/artifacts-utils/android/PatchedArtifactsSettings.gradle b/scripts/artifacts-utils/android/PatchedArtifactsSettings.gradle new file mode 100644 index 000000000000..5654e57838d6 --- /dev/null +++ b/scripts/artifacts-utils/android/PatchedArtifactsSettings.gradle @@ -0,0 +1,42 @@ +apply from: "ExpensiUtils.gradle" + +class PatchedArtifactsConfig { + String version = null + String packageName = null + String githubUsername = null + String githubToken = null + Boolean buildFromSource = true +} + +settings.getExtensions().create('patchedArtifacts', PatchedArtifactsConfig) + +def getNewDotRootDir() { + return "${rootDir}/${hasProperty('newDotRoot') ? getProperty('newDotRoot') : "/.."}" +} + +settings.extensions.configure(PatchedArtifactsConfig) { config -> + ExpensiLog.setPrefix("PatchedArtifacts") + config.buildFromSource = getProperty('patchedArtifacts.forceBuildFromSource') == 'true' + if (config.buildFromSource) { + ExpensiLog.lifecycle("Forcing build from source.") + return + } + + config.packageName = getProperty('patchedArtifacts.packageName') + + def isHybrid = config.packageName == 'react-hybrid' + def resolution = ExpensiUtils.resolveArtifacts(config.packageName, getNewDotRootDir().toString(), isHybrid) + config.version = resolution.version + config.buildFromSource = resolution.buildFromSource + + if (config.buildFromSource || config.version == null) { + config.buildFromSource = true + ExpensiLog.error("No matching artifacts version found for ${config.packageName}. Building react-native from source.") + return + } + + // The resolver returns the credentials the Maven download needs alongside the version. + config.githubUsername = resolution.githubUsername + config.githubToken = resolution.githubToken + ExpensiLog.lifecycle("Using patched react-native artifacts: ${config.packageName}:${config.version}") +} diff --git a/scripts/artifacts-utils/compute-patches-hash.sh b/scripts/artifacts-utils/compute-patches-hash.sh new file mode 100755 index 000000000000..b75f1b85487e --- /dev/null +++ b/scripts/artifacts-utils/compute-patches-hash.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)" +readonly SCRIPT_DIR +source "$SCRIPT_DIR/../shellUtils.sh" + +PATCH_DIRS=("$@") + +if [ ${#PATCH_DIRS[@]} -eq 0 ]; then + error "At least one patch directory argument is required" + exit 1 +fi + +PATCHES_HASH=$(find "${PATCH_DIRS[@]}" -type f \( -name "react-native+*.patch" -o -name "@react-native+*.patch" \) -exec sha256sum {} \; | awk '{split($2, pathParts, "/"); print pathParts[length(pathParts)], $1 }' | sort | sha256sum | awk '{print $1}') + +echo "$PATCHES_HASH" diff --git a/scripts/artifacts-utils/ios/patched_ios_artifacts.rb b/scripts/artifacts-utils/ios/patched_ios_artifacts.rb new file mode 100644 index 000000000000..fec1448b7eb0 --- /dev/null +++ b/scripts/artifacts-utils/ios/patched_ios_artifacts.rb @@ -0,0 +1,73 @@ +# Consumer for Expensify's patched React Native iOS prebuilt artifacts. +# Reopens ReactNativeCoreUtils (rncore.rb) to point RNCore resolution and +# download at our private GitHub Packages Maven repo (matched by patches hash). +# Must be required after react_native_pods.rb, which defines ReactNativeCoreUtils. + +require 'json' + +module PatchedIOSArtifacts + # scripts/artifacts-utils/ios/ -> repo root is three levels up. + NEW_DOT_ROOT = File.expand_path('../../..', __dir__) + GITHUB_PACKAGES_BASE = 'https://maven.pkg.github.com/Expensify/App' + + def self.setup + is_hybrid = ENV['IS_HYBRID_APP'] == 'true' + package_name = is_hybrid ? 'react-hybrid' : 'react-standalone' + + forced_source = ENV['RCT_USE_PREBUILT_RNCORE'] == '0' + resolution = forced_source ? {'buildFromSource' => true, 'version' => nil} : resolve(package_name, is_hybrid) + + ReactNativeCoreUtils.class_variable_set(:@@patched_version, resolution['version']) + ReactNativeCoreUtils.class_variable_set(:@@patched_package_name, package_name) + ReactNativeCoreUtils.class_variable_set(:@@patched_github_token, resolution['githubToken']) + ReactNativeCoreUtils.class_variable_set(:@@patched_build_from_source, resolution['buildFromSource']) + + ENV['RCT_USE_PREBUILT_RNCORE'] = resolution['buildFromSource'] ? '0' : '1' + end + + def self.resolve(package_name, is_hybrid) + cmd = [ + 'bun', File.join(NEW_DOT_ROOT, 'scripts/artifacts-utils/resolve-artifacts.ts'), + '--platform=ios', "--package=#{package_name}", "--hybrid=#{is_hybrid}", "--new-dot-root=#{NEW_DOT_ROOT}" + ] + # stdout is pure JSON; the resolver logs to stderr. + output = IO.popen(cmd, chdir: NEW_DOT_ROOT, &:read) + raise "resolver exited #{$?.exitstatus}" unless $?.success? + JSON.parse(output) + rescue => e + Pod::UI.warn("[PatchedIOSArtifacts] Resolver failed (#{e.message}); building from source.") if defined?(Pod::UI) + {'buildFromSource' => true, 'version' => nil} + end +end + +class ReactNativeCoreUtils + def self.setup_rncore(react_native_path, react_native_version) + @@react_native_path = react_native_path + # Base RN version (e.g. 0.85.3) — used by RN's install flow as a non-empty guard. The actual + # download URL uses @@patched_version via our stable_tarball_url override, so this stays the plain version. + @@react_native_version = react_native_version + @@build_from_source = @@patched_build_from_source + @@download_dsyms = ENV['RCT_SYMBOLICATE_PREBUILT_FRAMEWORKS'] == '1' + end + + def self.stable_tarball_url(_version, build_type, dsyms = false) + classifier = "reactnative-core-#{dsyms ? 'dSYM-' : ''}#{build_type}" + "#{PatchedIOSArtifacts::GITHUB_PACKAGES_BASE}/com/expensify/#{@@patched_package_name}/react-native-artifacts/#{@@patched_version}/react-native-artifacts-#{@@patched_version}-#{classifier}.tar.gz" + end + + def self.download_rncore_tarball(_react_native_path, tarball_url, version, configuration, dsyms = false) + dir = artifacts_dir + destination = configuration.nil? ? + "#{dir}/reactnative-core-#{version}#{dsyms ? '-dSYM' : ''}.tar.gz" : + "#{dir}/reactnative-core-#{version}#{dsyms ? '-dSYM' : ''}-#{configuration}.tar.gz" + + unless File.exist?(destination) + tmp = "#{dir}/reactnative-core.download" + # curl drops the Authorization header on the cross-host redirect to the object store. + header = @@patched_github_token ? %(-H "Authorization: Bearer #{@@patched_github_token}") : '' + ok = system(%(mkdir -p "#{dir}" && curl --fail --location --proto '=https' #{header} "#{tarball_url}" -o "#{tmp}" && mv "#{tmp}" "#{destination}")) + raise "[PatchedIOSArtifacts] Failed to download #{tarball_url}" unless ok + end + destination + end +end diff --git a/scripts/artifacts-utils/ios/publish/build.gradle b/scripts/artifacts-utils/ios/publish/build.gradle new file mode 100644 index 000000000000..3419b6c11b30 --- /dev/null +++ b/scripts/artifacts-utils/ios/publish/build.gradle @@ -0,0 +1,82 @@ +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 packageName = findProperty('packageName') ?: error('packageName property is required') + +configurations.maybeCreate('iosArtifacts') + +def coreDebugArtifact = artifacts.add('iosArtifacts', file("${artifactsDir}/reactnative-core-debug.tar.gz")) { + type = 'tgz' + extension = 'tar.gz' + classifier = 'reactnative-core-debug' +} + +def coreReleaseArtifact = artifacts.add('iosArtifacts', file("${artifactsDir}/reactnative-core-release.tar.gz")) { + type = 'tgz' + extension = 'tar.gz' + classifier = 'reactnative-core-release' +} + +def coreDsymDebugArtifact = artifacts.add('iosArtifacts', file("${artifactsDir}/reactnative-core-dSYM-debug.tar.gz")) { + type = 'tgz' + extension = 'tar.gz' + classifier = 'reactnative-core-dSYM-debug' +} + +def coreDsymReleaseArtifact = artifacts.add('iosArtifacts', file("${artifactsDir}/reactnative-core-dSYM-release.tar.gz")) { + type = 'tgz' + extension = 'tar.gz' + classifier = 'reactnative-core-dSYM-release' +} + +publishing { + publications { + release(MavenPublication) { + groupId = "com.expensify.${packageName}" + artifactId = 'react-native-artifacts' + version = patchedVersion + + artifact(coreDebugArtifact) + artifact(coreReleaseArtifact) + artifact(coreDsymDebugArtifact) + artifact(coreDsymReleaseArtifact) + + pom { + name = 'react-native-artifacts' + description = 'Patched React Native iOS prebuilt artifacts for Expensify' + url = 'https://github.com/Expensify/App' + + properties = [ + 'patchesHash': patchesHash, + ] + + developers { + developer { + id = 'expensify' + name = 'Expensify' + } + } + + licenses { + license { + name = 'MIT License' + url = 'https://github.com/facebook/react-native/blob/HEAD/LICENSE' + distribution = 'repo' + } + } + } + } + } + + repositories { + maven { + url = 'https://maven.pkg.github.com/Expensify/App' + credentials { + username = System.getenv('GH_PUBLISH_ACTOR') + password = System.getenv('GH_PUBLISH_TOKEN') + } + } + } +} diff --git a/scripts/artifacts-utils/ios/publish/settings.gradle b/scripts/artifacts-utils/ios/publish/settings.gradle new file mode 100644 index 000000000000..82e1f0703c8d --- /dev/null +++ b/scripts/artifacts-utils/ios/publish/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'ios-artifacts-publish' diff --git a/scripts/artifacts-utils/lib/artifactsResolver.ts b/scripts/artifacts-utils/lib/artifactsResolver.ts new file mode 100644 index 000000000000..b58ff7330e5f --- /dev/null +++ b/scripts/artifacts-utils/lib/artifactsResolver.ts @@ -0,0 +1,258 @@ +import {isRecord} from '@libs/ObjectUtils'; + +import {getOctokit} from '@actions/github'; +import {execFileSync} from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +/** + * 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 + * `resolve-artifacts.ts` by Gradle (Android) and `patched_ios_artifacts.rb` (iOS). + */ + +type Platform = 'ios' | 'android'; + +/** Base credentials — a token authenticates every download. */ +type Credentials = {githubToken: string}; +/** iOS's curl Bearer download needs only the token. */ +type IosCredentials = Credentials; +/** Android's Gradle Maven `credentials {}` block additionally requires the username. */ +type AndroidCredentials = Credentials & {githubUsername: string}; + +type ResolveOptions = { + platform: Platform; + packageName: string; + newDotRoot: string; + isHybrid: boolean; +}; + +/** No prebuilt match (or no credentials): the caller builds react-native from source. Carries no secrets. */ +type SourceBuild = { + buildFromSource: true; + version: null; + packageName: string; + artifactId: string; +}; + +/** A matching prebuilt artifact was found; carries the credentials the native download needs. */ +type Prebuilt = { + buildFromSource: false; + version: string; + packageName: string; + artifactId: string; +} & Creds; + +type IosResult = SourceBuild | Prebuilt; +type AndroidResult = SourceBuild | Prebuilt; +type ResolveResult = IosResult | AndroidResult; + +const GITHUB_REPO = 'Expensify/App'; +const GITHUB_OWNER = 'Expensify'; + +const ARTIFACT_IDS = { + android: 'react-android', + ios: 'react-native-artifacts', +} satisfies Record; + +/** Logs go to stderr; stdout is reserved for the JSON result. */ +function logError(message: string) { + process.stderr.write(`[PatchedArtifacts] ${message}\n`); +} + +/** Credentials as read from the source; fields are validated per platform by the callers. */ +type RawCredentials = {githubToken: string | null; githubUsername: string | null}; + +function isCI(): boolean { + return process.env.CI != null; +} + +/** Reads a non-empty environment variable, or null. */ +function getEnvVar(name: string): string | null { + const value: unknown = process.env[name]; + return typeof value === 'string' && value.length > 0 ? value : null; +} + +/** Runs a `gh` command and returns its trimmed output, or null on failure/empty. */ +function getGh(args: string[]): string | null { + try { + const output = execFileSync('gh', args, {encoding: 'utf8'}).trim(); + return output.length > 0 ? output : null; + } catch { + return null; + } +} + +function hasGithubCLI(): boolean { + try { + execFileSync('which', ['gh'], {stdio: 'ignore'}); + return true; + } catch { + return false; + } +} + +function hasRequiredScopes(): boolean { + try { + const status = execFileSync('gh', ['auth', 'status'], {encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']}).toString(); + return status.includes('read:packages'); + } catch { + return false; + } +} + +/** + * In CI credentials come from the environment (no gh CLI). Locally they come from + * the gh CLI, which must be installed and scoped for read:packages. + */ +function readCredentials(): RawCredentials | null { + if (isCI()) { + return {githubToken: getEnvVar('GITHUB_TOKEN'), githubUsername: getEnvVar('GITHUB_ACTOR')}; + } + if (!hasGithubCLI()) { + logError('No GitHub CLI found.'); + return null; + } + if (!hasRequiredScopes()) { + logError('GitHub token does not have required scope read:packages.'); + return null; + } + return {githubToken: getGh(['auth', 'token']), githubUsername: getGh(['api', 'user', '--jq', '.login'])}; +} + +function getIosCredentials(): IosCredentials | null { + const credentials = readCredentials(); + if (credentials == null || credentials.githubToken == null) { + logError('Missing GitHub token.'); + return null; + } + return {githubToken: credentials.githubToken}; +} + +function getAndroidCredentials(): AndroidCredentials | null { + const credentials = readCredentials(); + if (credentials == null || credentials.githubToken == null || credentials.githubUsername == null) { + logError('Missing GitHub credentials (username and/or token).'); + return null; + } + return {githubToken: credentials.githubToken, githubUsername: credentials.githubUsername}; +} + +function mavenPomUrl(packageName: string, artifactId: string, version: string): string { + return `https://maven.pkg.github.com/${GITHUB_REPO}/com/expensify/${packageName}/${artifactId}/${version}/${artifactId}-${version}.pom`; +} + +function buildAuthHeaders(githubToken: string | null): Record { + return githubToken ? {Authorization: `Bearer ${githubToken}`} : {}; +} + +/** + * GitHub Packages 302-redirects to a signed object-store URL on a different host. + * `fetch` follows the redirect and drops the Authorization header on the + * cross-origin hop, so the token reaches only the initial host, never the object store. + */ +async function fetchTokenSafe(url: string, githubToken: string | null): Promise { + const response = await fetch(url, {headers: buildAuthHeaders(githubToken)}); + if (!response.ok) { + throw new Error(`Request to ${url} failed with status ${response.status}`); + } + return response.text(); +} + +function getReactNativeVersion(newDotRoot: string): string { + const parsed: unknown = JSON.parse(fs.readFileSync(path.join(newDotRoot, 'package.json'), 'utf8')); + const dependencies = isRecord(parsed) ? parsed.dependencies : undefined; + const version = isRecord(dependencies) ? dependencies['react-native'] : undefined; + if (typeof version !== 'string') { + throw new Error('Could not read react-native version from package.json'); + } + return version; +} + +function computePatchesHash(newDotRoot: string, isHybrid: boolean): string { + const script = path.join(newDotRoot, 'scripts/artifacts-utils/compute-patches-hash.sh'); + const args = [script]; + if (isHybrid) { + args.push(path.join(newDotRoot, 'patches'), path.join(newDotRoot, 'Mobile-Expensify/patches')); + } else { + args.push(path.join(newDotRoot, 'patches')); + } + return execFileSync('bash', args, {encoding: 'utf8'}).trim(); +} + +async function getArtifactsCandidates(packageName: string, artifactId: string, rnVersion: string, githubToken: string): Promise { + const octokit = getOctokit(githubToken); + /* eslint-disable @typescript-eslint/naming-convention -- GitHub REST API params are snake_case */ + const versions = await octokit.paginate(octokit.rest.packages.getAllPackageVersionsForPackageOwnedByOrg, { + package_type: 'maven', + org: GITHUB_OWNER, + package_name: `com.expensify.${packageName}.${artifactId}`, + per_page: 100, + }); /* eslint-enable @typescript-eslint/naming-convention */ + return versions.map((version) => version.name).filter((name) => name.startsWith(rnVersion)); +} + +async function getRemotePatchesHash(packageName: string, artifactId: string, version: string, githubToken: string): Promise { + const pom = await fetchTokenSafe(mavenPomUrl(packageName, artifactId, version), githubToken); + return pom.match(/([^<]+)<\/patchesHash>/)?.[1]?.trim() ?? null; +} + +async function findMatchingArtifactsVersion(options: ResolveOptions, artifactId: string, githubToken: string): Promise { + const {packageName, newDotRoot, isHybrid} = options; + try { + const localPatchesHash = computePatchesHash(newDotRoot, isHybrid); + const rnVersion = getReactNativeVersion(newDotRoot); + const candidates = await getArtifactsCandidates(packageName, artifactId, rnVersion, githubToken); + for (const candidate of candidates) { + const remoteHash = await getRemotePatchesHash(packageName, artifactId, candidate, githubToken); + if (remoteHash != null && remoteHash === localPatchesHash) { + return candidate; + } + } + return null; + } catch (error) { + logError(`Failed to find matching artifacts version for ${packageName}. Reason: ${String(error)}`); + return null; + } +} + +async function resolveWithCredentials( + options: ResolveOptions, + artifactId: string, + credentials: Creds | null, + sourceBuild: SourceBuild, +): Promise> { + if (!credentials) { + return sourceBuild; + } + const version = await findMatchingArtifactsVersion(options, artifactId, credentials.githubToken); + if (version == null) { + logError(`No matching artifacts version found for ${options.packageName}. Building react-native from source.`); + return sourceBuild; + } + logError(`Using patched react-native artifacts: ${options.packageName}:${version}`); + return {buildFromSource: false, version, packageName: options.packageName, artifactId, ...credentials}; +} + +/** + * Resolves whether a matching prebuilt artifact exists for the current patches. + * Returns a `SourceBuild` (no secrets) when no match is found or credentials are + * unavailable, otherwise a `Prebuilt` carrying the credentials the native + * download needs — token only for iOS, username + token for Android. + */ +function resolveArtifacts(options: ResolveOptions & {platform: 'ios'}): Promise; +function resolveArtifacts(options: ResolveOptions & {platform: 'android'}): Promise; +function resolveArtifacts(options: ResolveOptions): Promise { + const artifactId = ARTIFACT_IDS[options.platform]; + const sourceBuild: SourceBuild = {buildFromSource: true, version: null, packageName: options.packageName, artifactId}; + + if (options.platform === 'ios') { + return resolveWithCredentials(options, artifactId, getIosCredentials(), sourceBuild); + } + return resolveWithCredentials(options, artifactId, getAndroidCredentials(), sourceBuild); +} + +export default resolveArtifacts; +export {ARTIFACT_IDS}; +export type {Platform, ResolveOptions, ResolveResult, IosResult, AndroidResult}; diff --git a/scripts/artifacts-utils/resolve-artifacts.ts b/scripts/artifacts-utils/resolve-artifacts.ts new file mode 100644 index 000000000000..5ae804c87dc7 --- /dev/null +++ b/scripts/artifacts-utils/resolve-artifacts.ts @@ -0,0 +1,31 @@ +import parseCommandLineArguments from '../utils/parseCommandLineArguments'; +import resolveArtifacts from './lib/artifactsResolver'; + +/** + * CLI wrapper around the shared artifacts resolver for the native build systems + * (Gradle and `patched_ios_artifacts.rb`), which cannot import the TS module. + * + * Usage: + * bun scripts/artifacts-utils/resolve-artifacts.ts \ + * --platform=ios --package=react-hybrid --hybrid=true --new-dot-root=. + * + * Prints the result as JSON to stdout (logs go to stderr) and always exits 0. + */ +const args = parseCommandLineArguments(); +const platform = args.platform; +const packageName = args.package ?? ''; + +if (platform !== 'ios' && platform !== 'android') { + process.stderr.write(`[PatchedArtifacts] Invalid or missing --platform "${platform ?? ''}" (expected "ios" or "android"); 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: args.hybrid === 'true'}; +const resolution = platform === 'ios' ? resolveArtifacts({...options, platform: 'ios'}) : resolveArtifacts({...options, platform: 'android'}); + +resolution + .then((result) => process.stdout.write(JSON.stringify(result))) + .catch(() => { + process.stdout.write(JSON.stringify({buildFromSource: true, version: null, packageName, artifactId: ''})); + }); diff --git a/scripts/compute-patches-hash.sh b/scripts/compute-patches-hash.sh deleted file mode 100755 index fc8c23549bd5..000000000000 --- a/scripts/compute-patches-hash.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)" -readonly SCRIPT_DIR -source "$SCRIPT_DIR/shellUtils.sh" - -if [ $# -eq 0 ]; then - error "Please provide at least one path as an argument" - exit 1 -fi - -PATCH_DIRS=("$@") -readonly PATCH_DIRS - -# Find all patches, compute their hash, put filename before hash, sort, compute hash of hashes -PATCHES_HASH=$(find "${PATCH_DIRS[@]}" -type f \( -name "react-native+*.patch" -o -name "@react-native+*.patch" \) -exec sha256sum {} \; | awk '{split($2, pathParts, "/"); print pathParts[length(pathParts)], $1 }' | sort | sha256sum | awk '{print $1}') - -# Include hermesV1Enabled flag in the hash so that enabling Hermes V1 invalidates -# prebuilt artifacts and triggers a build from source. -ANDROID_GRADLE_PROPS="$SCRIPT_DIR/../android/gradle.properties" - -if [ -f "$ANDROID_GRADLE_PROPS" ] && grep -q '^[[:space:]]*hermesV1Enabled[[:space:]]*=[[:space:]]*true' "$ANDROID_GRADLE_PROPS"; then - PATCHES_HASH=$(printf "%s" "${PATCHES_HASH}-hermesV1" | sha256sum | awk '{print $1}') -fi - -echo "$PATCHES_HASH" diff --git a/scripts/pod-install.sh b/scripts/pod-install.sh index b0739b28d4a4..5d529098dad1 100755 --- a/scripts/pod-install.sh +++ b/scripts/pod-install.sh @@ -54,7 +54,7 @@ if [[ "$IS_HYBRID_APP_REPO" == "true" && "$NEW_DOT_FLAG" == "false" ]]; then # Navigate to the OldDot repository, and run bundle install and pod install cd Mobile-Expensify/ios bundle install - RCT_USE_RN_DEP=0 RCT_USE_PREBUILT_RNCORE=0 bundle exec pod install + bundle exec pod install exit 0 fi @@ -110,7 +110,7 @@ if [ -d "$CACHED_PODSPEC_DIR" ]; then fi cd ios || cleanupAndExit 1 -RCT_USE_RN_DEP=0 RCT_USE_PREBUILT_RNCORE=0 bundle exec pod install +bundle exec pod install # Go back to where we started cleanupAndExit 0 diff --git a/scripts/run-build.sh b/scripts/run-build.sh index bd81a64e7657..f4f97d4a92c2 100755 --- a/scripts/run-build.sh +++ b/scripts/run-build.sh @@ -65,13 +65,13 @@ fi # Check if the argument is one of the desired values case "$BUILD" in --ios) - npx rock run:ios --configuration $IOS_MODE --scheme "$SCHEME" --dev-server "${ROCK_FLAGS[@]}" + npx rock run:ios --configuration $IOS_MODE --scheme "$SCHEME" --verbose --dev-server "${ROCK_FLAGS[@]}" ;; --ipad) - npx rock run:ios --simulator "iPad Pro (12.9-inch) (6th generation)" --configuration $IOS_MODE --scheme "$SCHEME" --dev-server "${ROCK_FLAGS[@]}" + npx rock run:ios --simulator "iPad Pro (12.9-inch) (6th generation)" --configuration $IOS_MODE --scheme "$SCHEME" --verbose --dev-server "${ROCK_FLAGS[@]}" ;; --ipad-sm) - npx rock run:ios --simulator "iPad Pro (11-inch) (4th generation)" --configuration $IOS_MODE --scheme "$SCHEME" --dev-server "${ROCK_FLAGS[@]}" + npx rock run:ios --simulator "iPad Pro (11-inch) (4th generation)" --configuration $IOS_MODE --scheme "$SCHEME" --verbose --dev-server "${ROCK_FLAGS[@]}" ;; --android) # Check if this is an Expensify developer with WARP (only they need cert import) diff --git a/tests/unit/ArtifactsResolverTest.ts b/tests/unit/ArtifactsResolverTest.ts new file mode 100644 index 000000000000..a160d8cdb143 --- /dev/null +++ b/tests/unit/ArtifactsResolverTest.ts @@ -0,0 +1,143 @@ +import resolveArtifacts, {ARTIFACT_IDS} from '@scripts/artifacts-utils/lib/artifactsResolver'; + +/** + * @jest-environment node + */ +import {getOctokit} from '@actions/github'; +import {execFileSync} from 'child_process'; +import fs from 'fs'; + +jest.mock('child_process'); +jest.mock('@actions/github'); + +const mockExecFileSync = jest.mocked(execFileSync); +const mockGetOctokit = jest.mocked(getOctokit); +const mockPaginate = jest.fn(); + +const NEW_DOT_ROOT = '/repo'; +const LOCAL_HASH = 'abc123hash'; + +/** A minimal fetch Response stub — only the members the resolver reads. */ +function fakeFetchResponse(body: string) { + return {ok: true, status: 200, text: () => Promise.resolve(body)}; +} + +/** Replaces global fetch with a queue of POM responses (one per candidate lookup). */ +function mockFetchBodies(bodies: string[]) { + let call = 0; + global.fetch = jest.fn().mockImplementation(() => Promise.resolve(fakeFetchResponse(bodies.at(call++) ?? ''))); +} + +/** Makes the Octokit package-versions API return the given version names. */ +function mockVersions(names: string[]) { + mockPaginate.mockResolvedValue(names.map((name) => ({name}))); + // Faking a minimal Octokit surface in a unit test. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + mockGetOctokit.mockReturnValue({ + paginate: mockPaginate, + rest: {packages: {getAllPackageVersionsForPackageOwnedByOrg: jest.fn()}}, + } as unknown as ReturnType); +} + +/** Mocks the gh CLI calls needed for credentials + the local patches hash. */ +function mockResolveExec() { + mockExecFileSync.mockImplementation((cmd: string, args?: readonly string[]) => { + if (cmd === 'bash') { + return LOCAL_HASH; + } + if (cmd === 'gh' && args?.includes('status')) { + return 'Token scopes: read:packages'; + } + if (cmd === 'gh' && args?.includes('user')) { + // `gh api user --jq .login` returns the bare login string. + return 'me'; + } + if (cmd === 'gh' && args?.includes('token')) { + return 'tok'; + } + return ''; + }); + jest.spyOn(fs, 'readFileSync').mockReturnValue('{"dependencies":{"react-native":"0.85.3"}}'); +} + +describe('artifactsResolver', () => { + const ORIGINAL_CI = typeof process.env.CI === 'string' ? process.env.CI : undefined; + + beforeEach(() => { + jest.clearAllMocks(); + // Force the local (gh) credential path deterministically, regardless of the runner. + delete process.env.CI; + }); + + afterEach(() => { + jest.restoreAllMocks(); + if (ORIGINAL_CI === undefined) { + delete process.env.CI; + } else { + process.env.CI = ORIGINAL_CI; + } + }); + + describe('ARTIFACT_IDS', () => { + it('uses the correct Maven artifactId per platform', () => { + expect(ARTIFACT_IDS.android).toBe('react-android'); + expect(ARTIFACT_IDS.ios).toBe('react-native-artifacts'); + }); + }); + + describe('resolveArtifacts', () => { + it('falls back to source build when the GitHub CLI is unavailable', async () => { + mockExecFileSync.mockImplementation((cmd: string) => { + if (cmd === 'which') { + throw new Error('not found'); + } + return ''; + }); + + const result = await resolveArtifacts({platform: 'ios', packageName: 'react-hybrid', newDotRoot: NEW_DOT_ROOT, isHybrid: true}); + + expect(result).toStrictEqual({buildFromSource: true, version: null, packageName: 'react-hybrid', artifactId: 'react-native-artifacts'}); + }); + + it('resolves a matching version and does not build from source', async () => { + mockResolveExec(); + mockVersions(['0.85.3-nomatch', '0.85.3-match']); + mockFetchBodies(['differentHash', `${LOCAL_HASH}`]); + + const result = await resolveArtifacts({platform: 'ios', packageName: 'react-hybrid', newDotRoot: NEW_DOT_ROOT, isHybrid: true}); + + expect(result.buildFromSource).toBe(false); + expect(result.version).toBe('0.85.3-match'); + if (!result.buildFromSource) { + expect(result.githubToken).toBe('tok'); + // iOS carries no username — its result type doesn't even include the field. + expect('githubUsername' in result).toBe(false); + } + }); + + it('returns the username alongside the token for a matching Android artifact', async () => { + mockResolveExec(); + mockVersions(['0.85.3-match']); + mockFetchBodies([`${LOCAL_HASH}`]); + + const result = await resolveArtifacts({platform: 'android', packageName: 'react-standalone', newDotRoot: NEW_DOT_ROOT, isHybrid: false}); + + expect(result.buildFromSource).toBe(false); + if (!result.buildFromSource) { + expect(result.githubToken).toBe('tok'); + expect(result.githubUsername).toBe('me'); + } + }); + + it('falls back to source build when no candidate matches the local patches hash', async () => { + mockResolveExec(); + mockVersions(['0.85.3-other']); + mockFetchBodies(['nomatch']); + + const result = await resolveArtifacts({platform: 'android', packageName: 'react-standalone', newDotRoot: NEW_DOT_ROOT, isHybrid: false}); + + expect(result.buildFromSource).toBe(true); + expect(result.version).toBeNull(); + }); + }); +});