diff --git a/.github/actions/build-android-app/action.yml b/.github/actions/build-android-app/action.yml new file mode 100644 index 0000000000..3afe2e0c5b --- /dev/null +++ b/.github/actions/build-android-app/action.yml @@ -0,0 +1,119 @@ +name: Build Android app +description: Build an Android demo app from this monorepo (with optional Expo prebuild) + +inputs: + app-path: + description: Path to the app workspace, relative to the repo root (e.g. apps/llm) + required: true + expo-prebuild: + description: Whether to run `expo prebuild --platform android` before the Gradle build + required: false + default: "true" + filter-name: + description: dorny/paths-filter filter name for this app+platform (e.g. llm-android). Used as the content-hash cache key so a previously-passing build can be skipped if nothing relevant has changed. + required: true + +runs: + using: composite + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: "yarn" + + - name: Install root dependencies + shell: bash + run: yarn install --immutable + + - name: Compute build hash + id: hash + shell: bash + run: | + h=$(node scripts/compute-app-hash.js "${{ inputs.filter-name }}") + echo "key=build-${{ inputs.filter-name }}-$h" >> "$GITHUB_OUTPUT" + + - name: Lookup pass marker + id: cache + uses: actions/cache/restore@v4 + with: + path: ${{ runner.temp }}/ci-marker + key: ${{ steps.hash.outputs.key }} + lookup-only: true + + - name: Skip notice + if: steps.cache.outputs.cache-hit == 'true' + shell: bash + run: echo "Skipping build — ${{ inputs.filter-name }} already passed at this content hash." + + - name: Free disk space + if: steps.cache.outputs.cache-hit != 'true' + shell: bash + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf /opt/hostedtoolcache/CodeQL + sudo docker system prune -af + + - name: Setup Java 17 + if: steps.cache.outputs.cache-hit != 'true' + uses: actions/setup-java@v5 + with: + distribution: "zulu" + java-version: 17 + cache: "gradle" + + - name: Install Expo CLI + if: steps.cache.outputs.cache-hit != 'true' && inputs.expo-prebuild == 'true' + shell: bash + run: | + npm install -g @expo/cli + echo "$(npm prefix -g)/bin" >> $GITHUB_PATH + + - name: Generate native Android project + if: steps.cache.outputs.cache-hit != 'true' && inputs.expo-prebuild == 'true' + working-directory: ${{ inputs.app-path }} + shell: bash + run: | + rm -rf android + npx expo prebuild --platform android --no-install + + - name: Cache Gradle + if: steps.cache.outputs.cache-hit != 'true' + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + ${{ inputs.app-path }}/android/.gradle + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Build app + if: steps.cache.outputs.cache-hit != 'true' + working-directory: ${{ inputs.app-path }}/android + shell: bash + run: | + ./gradlew assembleDebug \ + --build-cache \ + --parallel \ + --daemon \ + --configure-on-demand \ + -PreactNativeArchitectures=arm64-v8a \ + -Dorg.gradle.jvmargs="-Xmx4g -XX:+HeapDumpOnOutOfMemoryError" \ + -Dorg.gradle.workers.max=4 + + - name: Save pass marker + if: steps.cache.outputs.cache-hit != 'true' && success() + shell: bash + run: | + mkdir -p "${{ runner.temp }}/ci-marker" + touch "${{ runner.temp }}/ci-marker/passed" + + - name: Cache pass marker + if: steps.cache.outputs.cache-hit != 'true' && success() + uses: actions/cache/save@v4 + with: + path: ${{ runner.temp }}/ci-marker + key: ${{ steps.hash.outputs.key }} diff --git a/.github/actions/build-ios-app/action.yml b/.github/actions/build-ios-app/action.yml new file mode 100644 index 0000000000..a09a6af3fa --- /dev/null +++ b/.github/actions/build-ios-app/action.yml @@ -0,0 +1,118 @@ +name: Build iOS app +description: Build an iOS demo app from this monorepo (with optional Expo prebuild) + +inputs: + app-path: + description: Path to the app workspace, relative to the repo root (e.g. apps/llm) + required: true + expo-prebuild: + description: Whether to run `expo prebuild --platform ios` before pod install + required: false + default: "true" + filter-name: + description: dorny/paths-filter filter name for this app+platform (e.g. llm-ios). Used as the content-hash cache key so a previously-passing build can be skipped if nothing relevant has changed. + required: true + +runs: + using: composite + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: "yarn" + + - name: Install root dependencies + shell: bash + run: yarn install --immutable + + - name: Compute build hash + id: hash + shell: bash + run: | + h=$(node scripts/compute-app-hash.js "${{ inputs.filter-name }}") + echo "key=build-${{ inputs.filter-name }}-$h" >> "$GITHUB_OUTPUT" + + - name: Lookup pass marker + id: cache + uses: actions/cache/restore@v4 + with: + path: ${{ runner.temp }}/ci-marker + key: ${{ steps.hash.outputs.key }} + lookup-only: true + + - name: Skip notice + if: steps.cache.outputs.cache-hit == 'true' + shell: bash + run: echo "Skipping build — ${{ inputs.filter-name }} already passed at this content hash." + + - name: Setup Xcode + if: steps.cache.outputs.cache-hit != 'true' + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Install Expo CLI + if: steps.cache.outputs.cache-hit != 'true' && inputs.expo-prebuild == 'true' + shell: bash + run: | + npm install -g @expo/cli + echo "$(npm prefix -g)/bin" >> $GITHUB_PATH + + - name: Generate native iOS project + if: steps.cache.outputs.cache-hit != 'true' && inputs.expo-prebuild == 'true' + working-directory: ${{ inputs.app-path }} + shell: bash + run: | + rm -rf ios + npx expo prebuild --platform ios --no-install + + - name: Cache CocoaPods + if: steps.cache.outputs.cache-hit != 'true' + uses: actions/cache@v4 + with: + path: | + ~/Library/Caches/CocoaPods + ${{ inputs.app-path }}/ios/Pods + key: ${{ runner.os }}-pods-${{ inputs.filter-name }}-${{ hashFiles(format('{0}/ios/Podfile.lock', inputs.app-path)) }} + restore-keys: | + ${{ runner.os }}-pods-${{ inputs.filter-name }}- + + - name: Install CocoaPods dependencies + if: steps.cache.outputs.cache-hit != 'true' + working-directory: ${{ inputs.app-path }}/ios + shell: bash + run: pod install + + - name: Build app + if: steps.cache.outputs.cache-hit != 'true' + working-directory: ${{ inputs.app-path }}/ios + shell: bash + run: | + WORKSPACE=$(ls -d *.xcworkspace | head -n 1) + SCHEME="${WORKSPACE%.xcworkspace}" + set -o pipefail && xcodebuild \ + -workspace "$WORKSPACE" \ + -scheme "$SCHEME" \ + -sdk iphonesimulator \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + build \ + CODE_SIGNING_ALLOWED=NO \ + -jobs $(sysctl -n hw.ncpu) \ + COMPILER_INDEX_STORE_ENABLE=NO \ + ONLY_ACTIVE_ARCH=YES | xcbeautify + + - name: Save pass marker + if: steps.cache.outputs.cache-hit != 'true' && success() + shell: bash + run: | + mkdir -p "${{ runner.temp }}/ci-marker" + touch "${{ runner.temp }}/ci-marker/passed" + + - name: Cache pass marker + if: steps.cache.outputs.cache-hit != 'true' && success() + uses: actions/cache/save@v4 + with: + path: ${{ runner.temp }}/ci-marker + key: ${{ steps.hash.outputs.key }} diff --git a/.github/workflows/build-android-llm-example.yml b/.github/workflows/build-android-llm-example.yml deleted file mode 100644 index 3b4c95bf58..0000000000 --- a/.github/workflows/build-android-llm-example.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: LLM Example app Android build check -on: - pull_request: - paths: - - .github/workflows/build-android-llm-example.yml - - apps/llm/** - - packages/react-native-executorch/** - push: - branches: - - main - paths: - - .github/workflows/build-android-llm-example.yml - - apps/llm/** - - packages/react-native-executorch/** - workflow_dispatch: -jobs: - build: - if: github.repository == 'software-mansion/react-native-executorch' - runs-on: ubuntu-latest - env: - WORKING_DIRECTORY: apps/llm - concurrency: - group: android-${{ github.ref }} - cancel-in-progress: true - steps: - - name: Free Disk Space (Manual) - run: | - sudo rm -rf /usr/share/dotnet - sudo rm -rf /opt/ghc - sudo rm -rf /opt/hostedtoolcache/CodeQL - sudo docker system prune -af - - - name: Check out Git repository - uses: actions/checkout@v6 - with: - submodules: recursive - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: "24" - cache: "yarn" - - name: Setup Java 17 - uses: actions/setup-java@v5 - with: - distribution: "zulu" - java-version: 17 - cache: "gradle" - - name: Install root dependencies - run: yarn install --immutable - - name: Install Expo CLI - run: | - npm install -g @expo/cli - echo "$(npm prefix -g)/bin" >> $GITHUB_PATH - - name: Generate native Android project - working-directory: ${{ env.WORKING_DIRECTORY }} - run: | - rm -rf android - npx expo prebuild --platform android --no-install - - name: Cache Gradle - uses: actions/cache@v5 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - ${{ env.WORKING_DIRECTORY }}/android/.gradle - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - name: Build app - working-directory: ${{ env.WORKING_DIRECTORY }}/android - run: | - ./gradlew assembleDebug \ - --build-cache \ - --parallel \ - --daemon \ - --configure-on-demand \ - -PreactNativeArchitectures=arm64-v8a \ - -Dorg.gradle.jvmargs="-Xmx4g -XX:+HeapDumpOnOutOfMemoryError" \ - -Dorg.gradle.workers.max=4 diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml new file mode 100644 index 0000000000..f59e1af6bd --- /dev/null +++ b/.github/workflows/build-apps.yml @@ -0,0 +1,252 @@ +# Per-app build matrix gated by `dorny/paths-filter`. Adding a new model +# directory, controller, or top-level module under packages/react-native-executorch/ +# probably means updating the filter block below — `core-shared` if it affects every +# app, or one of the per-app `-pkg` anchors otherwise. CI runs +# `scripts/check-ci-filter-coverage.js` (in ci.yml's lint job) to flag uncovered +# files so this stays accurate. +name: Example apps build check +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: + +jobs: + detect-changes: + if: github.repository == 'software-mansion/react-native-executorch' && github.event.pull_request.draft != true + runs-on: ubuntu-latest + outputs: + android_apps: ${{ steps.matrix.outputs.android_apps }} + ios_apps: ${{ steps.matrix.outputs.ios_apps }} + bundle_apps: ${{ steps.matrix.outputs.bundle_apps }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Detect changed apps + id: filter + if: github.event_name != 'workflow_dispatch' + uses: dorny/paths-filter@v3 + with: + filters: | + # Cross-platform shared infrastructure + core-shared: &core-shared + - .github/workflows/build-apps.yml + - packages/react-native-executorch/{scripts,third-party}/** + - packages/react-native-executorch/{package.json,tsconfig.json} + - packages/react-native-executorch/common/{ada,runner}/** + - packages/react-native-executorch/common/rnexecutorch/{Error,ErrorCodes,Log}.h + - packages/react-native-executorch/common/rnexecutorch/RnExecutorchInstaller.{cpp,h} + - packages/react-native-executorch/common/rnexecutorch/{data_processing,host_objects,jsi,metaprogramming,tests,threads,utils}/** + - packages/react-native-executorch/common/rnexecutorch/models/BaseModel.{cpp,h} + - packages/react-native-executorch/src/{common,constants,errors,native,types,utils}/** + - packages/react-native-executorch/src/index.ts + - packages/react-native-executorch/src/modules/{BaseModule.ts,general/**} + - packages/react-native-executorch/src/hooks/{general/**,useModule.ts,useModuleFactory.ts} + - "{package.json,yarn.lock}" + # Platform-specific shared + android-shared: &android-shared + - .github/actions/build-android-app/** + - packages/react-native-executorch/android/** + ios-shared: &ios-shared + - .github/actions/build-ios-app/** + - packages/react-native-executorch/ios/** + - packages/react-native-executorch/react-native-executorch.podspec + # Resource fetchers + expo-fetcher: &expo-fetcher + - packages/expo-resource-fetcher/** + bare-fetcher: &bare-fetcher + - packages/bare-resource-fetcher/** + # Per-app package paths (TS modules + C++ models for the app's domain) + llm-pkg: &llm-pkg + - packages/react-native-executorch/common/rnexecutorch/TokenizerModule.{cpp,h} + - packages/react-native-executorch/common/rnexecutorch/models/llm/** + - packages/react-native-executorch/src/modules/natural_language_processing/{LLMModule,TokenizerModule}.ts + - packages/react-native-executorch/src/hooks/natural_language_processing/{useLLM,useTokenizer}.ts + - packages/react-native-executorch/src/controllers/LLMController.ts + cv-pkg: &cv-pkg + - packages/react-native-executorch/common/rnexecutorch/models/VisionModel.{cpp,h} + - packages/react-native-executorch/common/rnexecutorch/models/{classification,instance_segmentation,object_detection,ocr,semantic_segmentation,style_transfer,text_to_image,vertical_ocr}/** + - packages/react-native-executorch/src/{modules,hooks}/computer_vision/** + - packages/react-native-executorch/src/controllers/{BaseOCRController,OCRController,VerticalOCRController}.ts + speech-pkg: &speech-pkg + - packages/react-native-executorch/common/pfft/** + - packages/react-native-executorch/common/rnexecutorch/TokenizerModule.{cpp,h} + - packages/react-native-executorch/common/rnexecutorch/models/{speech_to_text,text_to_speech,voice_activity_detection}/** + - packages/react-native-executorch/src/modules/natural_language_processing/{SpeechToTextModule,TextToSpeechModule,VADModule,TokenizerModule}.ts + - packages/react-native-executorch/src/hooks/natural_language_processing/{useSpeechToText,useTextToSpeech,useVAD,useTokenizer}.ts + text-embeddings-pkg: &text-embeddings-pkg + - packages/react-native-executorch/common/rnexecutorch/TokenizerModule.{cpp,h} + - packages/react-native-executorch/common/rnexecutorch/models/embeddings/** + - packages/react-native-executorch/src/modules/natural_language_processing/{TextEmbeddingsModule,TokenizerModule}.ts + - packages/react-native-executorch/src/modules/computer_vision/ImageEmbeddingsModule.ts + - packages/react-native-executorch/src/hooks/natural_language_processing/{useTextEmbeddings,useTokenizer}.ts + - packages/react-native-executorch/src/hooks/computer_vision/useImageEmbeddings.ts + # Per-app bundle: core + fetcher + pkg + app dir + llm-app: &llm-app + - *core-shared + - *expo-fetcher + - *llm-pkg + - apps/llm/** + computer-vision-app: &computer-vision-app + - *core-shared + - *expo-fetcher + - *cv-pkg + - apps/computer-vision/** + speech-app: &speech-app + - *core-shared + - *expo-fetcher + - *speech-pkg + - apps/speech/** + text-embeddings-app: &text-embeddings-app + - *core-shared + - *expo-fetcher + - *text-embeddings-pkg + - apps/text-embeddings/** + bare-rn-app: &bare-rn-app + - *core-shared + - *bare-fetcher + - *llm-pkg + - apps/bare-rn/** + # Final per-platform per-app filters (the only ones the matrix consumes) + llm-android: [*llm-app, *android-shared] + llm-ios: [*llm-app, *ios-shared] + computer-vision-android: [*computer-vision-app, *android-shared] + computer-vision-ios: [*computer-vision-app, *ios-shared] + speech-android: [*speech-app, *android-shared] + speech-ios: [*speech-app, *ios-shared] + text-embeddings-android: [*text-embeddings-app, *android-shared] + text-embeddings-ios: [*text-embeddings-app, *ios-shared] + bare-rn-android: [*bare-rn-app, *android-shared] + bare-rn-ios: [*bare-rn-app, *ios-shared] + + - name: Compute matrices + id: matrix + run: | + all='["llm","computer-vision","speech","text-embeddings","bare-rn"]' + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "android_apps=$all" >> "$GITHUB_OUTPUT" + echo "ios_apps=$all" >> "$GITHUB_OUTPUT" + echo "bundle_apps=$all" >> "$GITHUB_OUTPUT" + else + changes='${{ steps.filter.outputs.changes }}' + android_apps=$(echo "$changes" | jq -c '[.[] | select(endswith("-android")) | sub("-android$"; "")]') + ios_apps=$(echo "$changes" | jq -c '[.[] | select(endswith("-ios")) | sub("-ios$"; "")]') + bundle_apps=$(echo "$changes" | jq -c '[.[] | select(endswith("-app")) | sub("-app$"; "")]') + echo "android_apps=$android_apps" >> "$GITHUB_OUTPUT" + echo "ios_apps=$ios_apps" >> "$GITHUB_OUTPUT" + echo "bundle_apps=$bundle_apps" >> "$GITHUB_OUTPUT" + fi + + bundle: + needs: detect-changes + if: needs.detect-changes.outputs.bundle_apps != '[]' && needs.detect-changes.outputs.bundle_apps != '' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + app: ${{ fromJSON(needs.detect-changes.outputs.bundle_apps) }} + platform: [android, ios] + concurrency: + group: bundle-${{ matrix.platform }}-${{ matrix.app }}-${{ github.ref }} + cancel-in-progress: true + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Setup + uses: ./.github/actions/setup + - name: Compute bundle hash + id: hash + run: | + h=$(node scripts/compute-app-hash.js "${{ matrix.app }}-app") + echo "key=bundle-${{ matrix.platform }}-${{ matrix.app }}-$h" >> "$GITHUB_OUTPUT" + - name: Lookup pass marker + id: cache + uses: actions/cache/restore@v4 + with: + path: ${{ runner.temp }}/ci-marker + key: ${{ steps.hash.outputs.key }} + lookup-only: true + - name: Skip notice + if: steps.cache.outputs.cache-hit == 'true' + run: echo "Skipping bundle — bundle-${{ matrix.platform }}-${{ matrix.app }} already passed at this content hash." + - name: Build workspace packages + if: steps.cache.outputs.cache-hit != 'true' && matrix.app == 'bare-rn' + run: yarn workspaces foreach --all --topological-dev run prepare + - name: Build workspace packages + if: steps.cache.outputs.cache-hit != 'true' && matrix.app == 'bare-rn' + run: yarn workspaces foreach --all --topological-dev run prepare + - name: Bundle JS for ${{ matrix.platform }} + if: steps.cache.outputs.cache-hit != 'true' + working-directory: apps/${{ matrix.app }} + run: | + if [ "${{ matrix.app }}" = "bare-rn" ]; then + npx react-native bundle \ + --platform ${{ matrix.platform }} \ + --entry-file index.js \ + --bundle-output /tmp/bundle.js \ + --dev false + else + npx expo export \ + --platform ${{ matrix.platform }} \ + --output-dir /tmp/expo-export + fi + - name: Save pass marker + if: steps.cache.outputs.cache-hit != 'true' && success() + run: | + mkdir -p "${{ runner.temp }}/ci-marker" + touch "${{ runner.temp }}/ci-marker/passed" + - name: Cache pass marker + if: steps.cache.outputs.cache-hit != 'true' && success() + uses: actions/cache/save@v4 + with: + path: ${{ runner.temp }}/ci-marker + key: ${{ steps.hash.outputs.key }} + + build-android: + needs: detect-changes + if: needs.detect-changes.outputs.android_apps != '[]' && needs.detect-changes.outputs.android_apps != '' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + app: ${{ fromJSON(needs.detect-changes.outputs.android_apps) }} + concurrency: + group: android-${{ matrix.app }}-${{ github.ref }} + cancel-in-progress: true + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: recursive + - name: Build Android app + uses: ./.github/actions/build-android-app + with: + app-path: apps/${{ matrix.app }} + expo-prebuild: ${{ matrix.app == 'bare-rn' && 'false' || 'true' }} + filter-name: ${{ matrix.app }}-android + + build-ios: + needs: detect-changes + if: needs.detect-changes.outputs.ios_apps != '[]' && needs.detect-changes.outputs.ios_apps != '' + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + app: ${{ fromJSON(needs.detect-changes.outputs.ios_apps) }} + concurrency: + group: ios-${{ matrix.app }}-${{ github.ref }} + cancel-in-progress: true + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: recursive + - name: Build iOS app + uses: ./.github/actions/build-ios-app + with: + app-path: apps/${{ matrix.app }} + expo-prebuild: ${{ matrix.app == 'bare-rn' && 'false' || 'true' }} + filter-name: ${{ matrix.app }}-ios diff --git a/.github/workflows/build-ios-llm-example.yml b/.github/workflows/build-ios-llm-example.yml deleted file mode 100644 index 4a2b2b9b99..0000000000 --- a/.github/workflows/build-ios-llm-example.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: LLM Example app iOS build check -on: - push: - branches: - - main - paths: - - ".github/workflows/build-ios-llm-example.yml" - - "*.podspec" - - "apps/llm/**" - - "packages/react-native-executorch/**" - pull_request: - paths: - - ".github/workflows/build-ios-llm-example.yml" - - "*.podspec" - - "apps/llm/**" - - "packages/react-native-executorch/**" - workflow_dispatch: -jobs: - build: - if: github.repository == 'software-mansion/react-native-executorch' - runs-on: macos-latest - concurrency: - group: ios-${{ github.ref }} - cancel-in-progress: true - steps: - - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: latest-stable - - name: Check out Git repository - uses: actions/checkout@v6 - with: - submodules: recursive - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: "24" - cache: "yarn" - - name: Install root dependencies - run: yarn install --immutable - - name: Install Expo CLI - run: | - npm install -g @expo/cli - echo "$(npm prefix -g)/bin" >> $GITHUB_PATH - - name: Generate native iOS project - working-directory: apps/llm - run: | - rm -rf ios - npx expo prebuild --platform ios --no-install - - name: Install CocoaPods dependencies - working-directory: apps/llm/ios - run: | - pod install - - name: Build app - working-directory: apps/llm/ios - run: | - set -o pipefail && xcodebuild \ - -workspace llm.xcworkspace \ - -scheme llm \ - -sdk iphonesimulator \ - -configuration Debug \ - -destination 'platform=iOS Simulator,name=iPhone 16 Pro' \ - build \ - CODE_SIGNING_ALLOWED=NO \ - -jobs $(sysctl -n hw.ncpu) \ - COMPILER_INDEX_STORE_ENABLE=NO \ - ONLY_ACTIVE_ARCH=YES | xcbeautify diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96fd27ad65..ab549cbcdd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,15 +3,25 @@ on: push: branches: - main + paths-ignore: + - docs/** + - readmes/** + - "**.md" pull_request: branches: - main + types: [opened, synchronize, reopened, ready_for_review] + paths-ignore: + - docs/** + - readmes/** + - "**.md" merge_group: types: - checks_requested workflow_dispatch: jobs: lint: + if: github.event.pull_request.draft != true runs-on: ubuntu-latest steps: - name: Checkout @@ -23,10 +33,14 @@ jobs: - name: Lint files run: yarn lint + - name: Check CI filter coverage + run: node scripts/check-ci-filter-coverage.js + - name: Typecheck files run: yarn workspaces foreach --all --topological-dev run prepare && yarn typecheck build-library: + if: github.event.pull_request.draft != true runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/docs-build-check.yml b/.github/workflows/docs-build-check.yml index eec9d3a4a3..ed3a2e332e 100644 --- a/.github/workflows/docs-build-check.yml +++ b/.github/workflows/docs-build-check.yml @@ -10,13 +10,14 @@ on: pull_request: branches: - main + types: [opened, synchronize, reopened, ready_for_review] paths: - 'docs/**' - '.github/workflows/docs-build-check.yml' workflow_dispatch: jobs: check: - if: github.repository == 'software-mansion/react-native-executorch' + if: github.repository == 'software-mansion/react-native-executorch' && github.event.pull_request.draft != true runs-on: ubuntu-latest concurrency: group: docs-check-${{ github.ref }} diff --git a/.github/workflows/test-bare-rn.yml b/.github/workflows/test-bare-rn.yml new file mode 100644 index 0000000000..1c0ec656b7 --- /dev/null +++ b/.github/workflows/test-bare-rn.yml @@ -0,0 +1,46 @@ +name: bare-rn Jest tests +on: + push: + branches: + - main + paths: + - .github/workflows/test-bare-rn.yml + - apps/bare-rn/** + - packages/bare-resource-fetcher/** + - packages/react-native-executorch/src/** + - packages/react-native-executorch/package.json + - packages/react-native-executorch/tsconfig.json + - package.json + - yarn.lock + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - .github/workflows/test-bare-rn.yml + - apps/bare-rn/** + - packages/bare-resource-fetcher/** + - packages/react-native-executorch/src/** + - packages/react-native-executorch/package.json + - packages/react-native-executorch/tsconfig.json + - package.json + - yarn.lock + workflow_dispatch: + +jobs: + test: + if: github.repository == 'software-mansion/react-native-executorch' && github.event.pull_request.draft != true + runs-on: ubuntu-latest + concurrency: + group: test-bare-rn-${{ github.ref }} + cancel-in-progress: true + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup + uses: ./.github/actions/setup + + - name: Build workspace packages + run: yarn workspaces foreach --all --topological-dev run prepare + + - name: Run Jest + run: yarn workspace bare-rn test diff --git a/apps/bare-rn/__mocks__/background-downloader.js b/apps/bare-rn/__mocks__/background-downloader.js new file mode 100644 index 0000000000..034ce1e1d7 --- /dev/null +++ b/apps/bare-rn/__mocks__/background-downloader.js @@ -0,0 +1,3 @@ +module.exports = { + setConfig: jest.fn(), +}; diff --git a/apps/bare-rn/__mocks__/react-native-executorch-bare-resource-fetcher.js b/apps/bare-rn/__mocks__/react-native-executorch-bare-resource-fetcher.js new file mode 100644 index 0000000000..a2fc0d0ee1 --- /dev/null +++ b/apps/bare-rn/__mocks__/react-native-executorch-bare-resource-fetcher.js @@ -0,0 +1,3 @@ +module.exports = { + BareResourceFetcher: {}, +}; diff --git a/apps/bare-rn/__mocks__/react-native-executorch.js b/apps/bare-rn/__mocks__/react-native-executorch.js new file mode 100644 index 0000000000..5d7b39a2f7 --- /dev/null +++ b/apps/bare-rn/__mocks__/react-native-executorch.js @@ -0,0 +1,14 @@ +module.exports = { + initExecutorch: jest.fn(), + useLLM: () => ({ + isReady: false, + isGenerating: false, + response: '', + messageHistory: [], + downloadProgress: 0, + error: null, + sendMessage: jest.fn(), + interrupt: jest.fn(), + }), + LLAMA3_2_1B_SPINQUANT: 'LLAMA3_2_1B_SPINQUANT', +}; diff --git a/apps/bare-rn/jest.config.js b/apps/bare-rn/jest.config.js index 8eb675e9bc..ee30550a84 100644 --- a/apps/bare-rn/jest.config.js +++ b/apps/bare-rn/jest.config.js @@ -1,3 +1,11 @@ module.exports = { preset: 'react-native', + moduleNameMapper: { + '^react-native-executorch$': + '/__mocks__/react-native-executorch.js', + '^react-native-executorch-bare-resource-fetcher$': + '/__mocks__/react-native-executorch-bare-resource-fetcher.js', + '^@kesha-antonov/react-native-background-downloader$': + '/__mocks__/background-downloader.js', + }, }; diff --git a/scripts/check-ci-filter-coverage.js b/scripts/check-ci-filter-coverage.js new file mode 100644 index 0000000000..55805ce3df --- /dev/null +++ b/scripts/check-ci-filter-coverage.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// Verifies every source file under packages/react-native-executorch/ is matched by +// at least one filter in .github/workflows/build-apps.yml. Prevents new files (e.g. +// a new model directory or controller) from silently slipping past CI's per-app +// path triggers. + +const fs = require('fs'); +const cp = require('child_process'); +const yaml = require('js-yaml'); +const picomatch = require('picomatch'); + +const WORKFLOW = '.github/workflows/build-apps.yml'; +const PACKAGE_ROOT = 'packages/react-native-executorch/'; + +// Files that legitimately don't belong to any per-app or shared filter. +const ALLOWLIST = new Set([ + 'packages/react-native-executorch/.gitignore', + 'packages/react-native-executorch/.watchmanconfig', + 'packages/react-native-executorch/tsconfig.doc.json', +]); + +const flatten = (x) => (Array.isArray(x) ? x.flatMap(flatten) : [x]); + +const wf = yaml.load(fs.readFileSync(WORKFLOW, 'utf8')); +const filtersStr = wf.jobs['detect-changes'].steps.find( + (s) => s.id === 'filter' +).with.filters; +const filters = yaml.load(filtersStr); + +const patterns = new Set(); +for (const v of Object.values(filters)) { + flatten(v) + .filter((p) => typeof p === 'string') + .forEach((p) => patterns.add(p)); +} +const matchers = [...patterns].map((p) => picomatch(p, { dot: true })); +const matchAny = (file) => matchers.some((m) => m(file)); + +const tracked = cp + .execSync('git ls-files', { encoding: 'utf8' }) + .trim() + .split('\n'); +const orphans = tracked + .filter((f) => f.startsWith(PACKAGE_ROOT)) + .filter((f) => !ALLOWLIST.has(f)) + .filter((f) => !matchAny(f)); + +if (orphans.length > 0) { + console.error( + `\n${WORKFLOW} does not cover ${orphans.length} file(s) under ${PACKAGE_ROOT}:\n` + ); + orphans.forEach((f) => console.error(' ' + f)); + console.error( + `\nAdd them to the appropriate filter (core-shared, llm-pkg, cv-pkg, speech-pkg,\n` + + `text-embeddings-pkg, or one of the platform-shared blocks). If the file is\n` + + `genuinely build-irrelevant, add it to ALLOWLIST in scripts/check-ci-filter-coverage.js.` + ); + process.exit(1); +} + +console.log( + `OK: every file under ${PACKAGE_ROOT} is covered by build-apps.yml.` +); diff --git a/scripts/compute-app-hash.js b/scripts/compute-app-hash.js new file mode 100644 index 0000000000..bfc3df3842 --- /dev/null +++ b/scripts/compute-app-hash.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node +// Computes a stable content hash of every tracked file matched by a given +// filter in .github/workflows/build-apps.yml. Used as a cache key in the +// build-apps matrix so a previously-passing app/platform cell can be skipped +// when nothing relevant has changed since. + +const fs = require('fs'); +const cp = require('child_process'); +const crypto = require('crypto'); +const yaml = require('js-yaml'); +const picomatch = require('picomatch'); + +// Files that the per-app filters reference for trigger purposes but that +// should not invalidate cached build markers. The workflow file itself sits in +// core-shared so editing it triggers CI on every app, but the vast majority of +// edits are orchestration (filter paths, matrix shape, concurrency, triggers) +// and don't change build behavior. Excluding it from the hash means workflow +// edits re-run the matrix but each cell hits its existing marker and skips. +// +// Caveat: workflow edits that DO change build behavior — `with:` inputs to a +// composite, `runs-on:`, an `env:` var, an action's pinned version — won't be +// caught here. Force-clear caches via the GitHub Actions UI when you make one. +const HASH_EXCLUDE = new Set(['.github/workflows/build-apps.yml']); + +const filterName = process.argv[2]; +if (!filterName) { + console.error('Usage: compute-app-hash.js '); + process.exit(1); +} + +const wf = yaml.load( + fs.readFileSync('.github/workflows/build-apps.yml', 'utf8') +); +const filtersStr = wf.jobs['detect-changes'].steps.find( + (s) => s.id === 'filter' +).with.filters; +const filters = yaml.load(filtersStr); + +const flatten = (x) => (Array.isArray(x) ? x.flatMap(flatten) : [x]); +const patterns = flatten(filters[filterName]).filter( + (p) => typeof p === 'string' +); +if (patterns.length === 0) { + console.error(`Unknown filter: ${filterName}`); + process.exit(1); +} +const matchers = patterns.map((p) => picomatch(p, { dot: true })); +const matchAny = (file) => matchers.some((m) => m(file)); + +// `git ls-files -s` outputs: \t +// The hash is a content-addressable git blob hash, so the same content always +// produces the same line — the SHA256 below is stable across machines. +const lines = cp + .execSync('git ls-files -s', { encoding: 'utf8' }) + .trim() + .split('\n') + .filter((line) => { + const tabIdx = line.indexOf('\t'); + if (tabIdx === -1) return false; + const path = line.slice(tabIdx + 1); + return !HASH_EXCLUDE.has(path) && matchAny(path); + }) + .sort(); + +const sha = crypto + .createHash('sha256') + .update(lines.join('\n')) + .digest('hex') + .slice(0, 16); +console.log(sha);