Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/scripts/changed-apps.sh
Original file line number Diff line number Diff line change
@@ -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/<name>/ 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"
45 changes: 45 additions & 0 deletions .github/scripts/changed-apps.test.sh
Original file line number Diff line number Diff line change
@@ -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 <description> <changed files> <js> <android> <ios>
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
85 changes: 51 additions & 34 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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/<name>/ -> 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/<name>/ -> that app
# - anything outside apps/ (packages/, lockfile, root config) -> every app
# `android` / `ios` (native builds; Debug builds don't bundle JS)
# - apps/<name>/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/<name>/package.json is named @offline-app-examples/<name>
# - apps/<name>/ios has exactly one <Scheme>.xcworkspace whose scheme has
# the same name
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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: |
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading