From 50e02726e6fb900446c69610881084a4aff66338 Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Thu, 15 Oct 2026 07:09:42 +0400 Subject: [PATCH] ci: pre-install the ndk and skip native builds on js-only changes - install ndk, platform and build-tools with sdkmanager before gradle, with retries that clear partial downloads (the 'zipfile unknown archive' flake) - build an app natively only when its android/, ios/, gemfile or package.json change, per platform; lockfile and ci changes still build everything - move the app-picking logic to .github/scripts/changed-apps.sh with a test - skip the native roll-up when the whole run is cancelled by a newer push - build ios for arm64 only and skip the index store --- .github/scripts/changed-apps.sh | 52 +++++++++++++++++ .github/scripts/changed-apps.test.sh | 45 +++++++++++++++ .github/workflows/ci.yml | 85 +++++++++++++++++----------- 3 files changed, 148 insertions(+), 34 deletions(-) create mode 100755 .github/scripts/changed-apps.sh create mode 100755 .github/scripts/changed-apps.test.sh diff --git a/.github/scripts/changed-apps.sh b/.github/scripts/changed-apps.sh new file mode 100755 index 0000000..a80b420 --- /dev/null +++ b/.github/scripts/changed-apps.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Picks which apps each CI job runs for. Prints GITHUB_OUTPUT lines: +# js=[...] apps for `check` (typecheck, lint, test, JS bundle) +# android=[...] apps for the `android` build matrix +# ios=[...] apps for the `ios` build matrix +# filter=... turbo --filter args for `check`, empty when every app runs +# +# changed-apps.sh all every app, every job (push to main) +# git diff --name-only | changed-apps.sh only what the changed files need +set -euo pipefail +cd "$(dirname "$0")/../.." + +all=$(ls apps | jq -Rsc 'split("\n") | map(select(length > 0))') + +if [ "${1:-}" = all ]; then + js=$all android=$all ios=$all +else + files=$(grep -vE '^docs/|\.(md|gif|png|jpe?g)$' || true) + + # Apps with a changed file under apps// matching the pattern. + apps_matching() { + { grep -E "^apps/[^/]+/($1)" <<<"$files" || true; } | cut -d/ -f2 \ + | jq -Rsc --argjson all "$all" 'split("\n") | unique | map(select(IN($all[])))' + } + + # JS: a change outside apps/ (packages/, lockfile, root config) can break any app. + if [ -n "$files" ] && grep -qv '^apps/' <<<"$files"; then + js=$all + else + js=$(apps_matching '') + fi + + # Native: Debug builds don't bundle JS, so only native project files, native + # dependencies (package.json, lockfile) and CI itself can break them. + if grep -qE '^(pnpm-lock\.yaml|package\.json|pnpm-workspace\.yaml|\.npmrc|\.github/)' <<<"$files"; then + android=$all ios=$all + else + android=$(apps_matching 'android/|package\.json$|react-native\.config\.js$') + ios=$(apps_matching 'ios/|Gemfile|package\.json$|react-native\.config\.js$') + fi +fi + +# No turbo filter when every app runs, so packages/ is checked too. +filter= +if [ "$js" != "$all" ]; then + filter=$(jq -r 'map("--filter=@offline-app-examples/" + .) | join(" ")' <<<"$js") +fi + +echo "js=$js" +echo "android=$android" +echo "ios=$ios" +echo "filter=$filter" diff --git a/.github/scripts/changed-apps.test.sh b/.github/scripts/changed-apps.test.sh new file mode 100755 index 0000000..41c136b --- /dev/null +++ b/.github/scripts/changed-apps.test.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Checks changed-apps.sh against sample diffs. Run: bash .github/scripts/changed-apps.test.sh +set -euo pipefail +script="$(dirname "$0")/changed-apps.sh" +all=$(ls "$(dirname "$0")/../../apps" | jq -Rsc 'split("\n") | map(select(length > 0))') +fail=0 + +# expect +expect() { + got=$(printf '%s' "$2" | bash "$script" | grep -v '^filter=' | tr '\n' ' ') + want="js=$3 android=$4 ios=$5 " + if [ "$got" = "$want" ]; then echo "ok $1"; else echo "FAIL $1"$'\n'" got: $got"$'\n'" want: $want"; fail=1; fi +} + +expect "js-only app change skips native" \ + $'apps/cowrite/App.tsx\napps/cowrite/src/x.ts' '["cowrite"]' '[]' '[]' +expect "ios change builds only ios" \ + $'apps/cowrite/ios/Podfile' '["cowrite"]' '[]' '["cowrite"]' +expect "gemfile change builds only ios" \ + $'apps/orderup/Gemfile.lock' '["orderup"]' '[]' '["orderup"]' +expect "android change builds only android" \ + $'apps/orderup/android/app/build.gradle' '["orderup"]' '["orderup"]' '[]' +expect "app package.json builds both" \ + $'apps/cowrite/package.json\napps/orderup/App.tsx' '["cowrite","orderup"]' '["cowrite"]' '["cowrite"]' +expect "shared js package checks every app, no native" \ + $'packages/mesh/src/index.ts\napps/cowrite/ios/Podfile' "$all" '[]' '["cowrite"]' +expect "lockfile runs everything" \ + $'pnpm-lock.yaml' "$all" "$all" "$all" +expect "ci change runs everything" \ + $'.github/workflows/ci.yml' "$all" "$all" "$all" +expect "docs only runs nothing" \ + $'README.md\napps/cowrite/demo.gif\ndocs/plan.md' '[]' '[]' '[]' +expect "removed app is ignored" \ + $'apps/removed/ios/Podfile' '[]' '[]' '[]' +expect "no changes runs nothing" '' '[]' '[]' '[]' + +got=$(bash "$script" all | tr '\n' ' ') +want="js=$all android=$all ios=$all filter= " +[ "$got" = "$want" ] && echo "ok push to main runs everything" || { echo "FAIL push to main: $got"; fail=1; } + +got=$(printf 'apps/cowrite/App.tsx\napps/orderup/App.tsx' | bash "$script" | grep '^filter=') +[ "$got" = "filter=--filter=@offline-app-examples/cowrite --filter=@offline-app-examples/orderup" ] \ + && echo "ok turbo filter" || { echo "FAIL turbo filter: $got"; fail=1; } + +exit $fail diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60ce341..15eece2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,15 +1,23 @@ # CI for every app under apps/. # -# Don't list apps in this file. The `changes` job reads apps/ and picks which -# apps to run: -# - PR touching only apps// -> just those apps -# - PR touching anything else (lockfile, packages/, this file, root config) -# -> every app -# - PR touching only docs (docs/, *.md, images) -> nothing -# - push to main -> every app +# Don't list apps in this file. The `changes` job runs +# .github/scripts/changed-apps.sh, which reads apps/ and picks the apps for +# each job from the PR's changed files: # -# `check` (typecheck, lint, test, JS bundle), `android` and `ios` all run for -# that list. A new app is picked up automatically, as long as: +# `check` (typecheck, lint, test, JS bundle) +# - any file in apps// -> that app +# - anything outside apps/ (packages/, lockfile, root config) -> every app +# `android` / `ios` (native builds; Debug builds don't bundle JS) +# - apps//android/, ios/, Gemfile*, package.json -> that app, that +# platform only +# - lockfile, root package.json, workspace config, .github/ -> every app +# - JS-only changes (src/, App.tsx, packages/) -> no native builds +# +# Docs-only PRs (docs/, *.md, images) run nothing. Pushes to main run +# everything. To change these rules, edit the script and its test, +# changed-apps.test.sh, which runs in the `changes` job. +# +# A new app is picked up automatically, as long as: # - apps//package.json is named @offline-app-examples/ # - apps//ios has exactly one .xcworkspace whose scheme has # the same name @@ -39,40 +47,31 @@ jobs: changes: runs-on: ubuntu-latest outputs: - apps: ${{ steps.apps.outputs.apps }} + js: ${{ steps.apps.outputs.js }} + android: ${{ steps.apps.outputs.android }} + ios: ${{ steps.apps.outputs.ios }} filter: ${{ steps.apps.outputs.filter }} steps: - uses: actions/checkout@v7 with: # The PR merge commit plus its parents, so HEAD^1..HEAD is the PR's diff. fetch-depth: 2 + - run: bash .github/scripts/changed-apps.test.sh - id: apps run: | - all=$(ls apps | jq -Rsc 'split("\n") | map(select(length > 0))') - apps=$all if [ "${{ github.event_name }}" = pull_request ]; then - files=$(git diff --name-only HEAD^1 HEAD | grep -vE '^docs/|\.(md|gif|png|jpe?g)$' || true) - if [ -z "$files" ]; then - apps='[]' - elif ! grep -qv '^apps/' <<<"$files"; then - apps=$(grep '^apps/' <<<"$files" | cut -d/ -f2 \ - | jq -Rsc --argjson all "$all" 'split("\n") | unique | map(select(IN($all[])))') - fi - fi - echo "apps=$apps" | tee -a "$GITHUB_OUTPUT" - # No turbo filter when every app runs, so packages/ is checked too. - if [ "$apps" != "$all" ]; then - filter=$(jq -r 'map("--filter=@offline-app-examples/" + .) | join(" ")' <<<"$apps") - fi - echo "filter=${filter:-}" | tee -a "$GITHUB_OUTPUT" + git diff --name-only HEAD^1 HEAD | bash .github/scripts/changed-apps.sh + else + bash .github/scripts/changed-apps.sh all + fi | tee -a "$GITHUB_OUTPUT" check: needs: changes - if: needs.changes.outputs.apps != '[]' + if: needs.changes.outputs.js != '[]' runs-on: ubuntu-latest timeout-minutes: 20 env: - APPS: ${{ needs.changes.outputs.apps }} + APPS: ${{ needs.changes.outputs.js }} FILTER: ${{ needs.changes.outputs.filter }} steps: - uses: actions/checkout@v7 @@ -99,13 +98,13 @@ jobs: android: needs: [check, changes] - if: needs.changes.outputs.apps != '[]' + if: needs.changes.outputs.android != '[]' runs-on: ubuntu-latest timeout-minutes: 45 strategy: fail-fast: false matrix: - app: ${{ fromJSON(needs.changes.outputs.apps) }} + app: ${{ fromJSON(needs.changes.outputs.android) }} steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 @@ -124,6 +123,21 @@ jobs: apps/${{ matrix.app }}/android/**/*.gradle* apps/${{ matrix.app }}/android/gradle/wrapper/gradle-wrapper.properties - run: pnpm install --frozen-lockfile + # Install the SDK packages up front. Left to Gradle, the NDK downloads + # mid-build, and a corrupt download ("Error on ZipFile unknown archive") + # fails every Gradle retry. Versions match apps/*/android/build.gradle. + - name: Install Android SDK packages + run: | + for attempt in 1 2 3; do + if yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install \ + "ndk;27.1.12297006" "platforms;android-36" "build-tools;36.0.0" > /dev/null; then + exit 0 + fi + echo "::warning::sdkmanager failed (attempt $attempt), clearing partial downloads" + rm -rf "$ANDROID_HOME/ndk/27.1.12297006" "$ANDROID_HOME/.temp" "$ANDROID_HOME/.downloadIntermediates" + sleep $((attempt * 10)) + done + exit 1 - name: Build debug APK working-directory: apps/${{ matrix.app }}/android run: | @@ -145,13 +159,13 @@ jobs: ios: needs: [check, changes] - if: needs.changes.outputs.apps != '[]' + if: needs.changes.outputs.ios != '[]' runs-on: macos-15 timeout-minutes: 60 strategy: fail-fast: false matrix: - app: ${{ fromJSON(needs.changes.outputs.apps) }} + app: ${{ fromJSON(needs.changes.outputs.ios) }} steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 @@ -191,11 +205,14 @@ jobs: scheme=$(basename *.xcworkspace .xcworkspace) xcodebuild -workspace "$scheme.xcworkspace" -scheme "$scheme" \ -configuration Debug -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' \ - -derivedDataPath "$RUNNER_TEMP/dd" CODE_SIGNING_ALLOWED=NO -quiet build + -derivedDataPath "$RUNNER_TEMP/dd" CODE_SIGNING_ALLOWED=NO \ + ONLY_ACTIVE_ARCH=YES ARCHS=arm64 COMPILER_INDEX_STORE_ENABLE=NO -quiet build # Single required check for the native builds, since the matrix varies per PR. + # Skipped when the run itself is cancelled (a newer push superseded it), but a + # single job that is cancelled, such as by its timeout, still fails it. native: - if: always() + if: ${{ !cancelled() }} needs: [check, changes, android, ios] runs-on: ubuntu-latest steps: