Sparehand

Docs › Mobile builds: Xcode, Gradle, Flutter and React Native

Mobile builds: Xcode, Gradle, Flutter and React Native

Xcode and Gradle builds are the heaviest jobs a mobile developer runs all day. sparehand run sends them to a worker that has the toolchain, keeps the build folders there so the second build is incremental, and brings the app, the APK and the test reports back:

sparehand run xcodebuild -scheme MyApp -sdk iphonesimulator build     # on a Mac worker with Xcode
sparehand run ./gradlew assembleDebug testDebugUnitTest               # on a worker with a JDK and the Android SDK
sparehand emulator test                                               # instrumented tests on an emulator, on a worker

Nothing is added to the command. Sparehand reads from it, and from the checkout, what the job needs.

Which worker gets the job

The command hasThe job needsSo it goes to
xcodebuild, xcrun, xcodegenXcodea Mac whose Xcode has finished its first launch
swift build, swift test, swift run, swift packageSwifta machine with Swift (a Mac with Xcode, or Linux with a Swift toolchain)
./gradlew, gradlea JDK, and the Android SDK when the project has an AndroidManifest.xmla machine with both
flutter …Fluttera machine with Flutter
flutter build apk, appbundle, aarFlutter, a JDK, the Android SDK
flutter build ios, ipa, macosFlutter, Xcodea Mac
react-native run-android, build-android, expo run:androida JDK, the Android SDK
react-native run-ios, build-ios, expo run:iosXcodea Mac
pod install, pod updateXcode, CocoaPodsa Mac with CocoaPods

The command is read as text, so sh -c 'xcodebuild … | xcbeautify' counts too. A script of your own (npm run ios, make release, fastlane …) can't be read: say what it needs with --needs:

sparehand run --needs xcode npm run build:ios
sparehand run --needs jdk,android ./scripts/build-release.sh
sparehand run --needs - ./gradlew help        # "-": place it like any other job

The words are xcode, swift, jdk, android, flutter and pods. SPAREHAND_NEEDS does the same for every run of a shell.

A worker that lacks what the job needs is left out, and Sparehand says why:

[sparehand] → MacBook (Xcode 27.2; CPU 8% busy of 12 cores, 78% memory free, 0 other jobs)
[sparehand] not chosen: Laptop has no Xcode (it isn't a Mac)

When no worker has it, the answer is the usual "run it locally" (exit 255, or the command runs on the hub at a terminal), with each worker's reason.

The Xcode version. A project that needs one says so in a .xcode-version file at its top (27.2), or you set SPAREHAND_XCODE=27. A Mac with another version is left out. 27 is met by any 27.x, 27.2 by 27.2 and 27.2.1. Without either, any Xcode will do.

What each worker has is in sparehand doctor:

MacBook: ok, …; for mobile builds: Xcode 27.2, Swift 6.4, JDK 21, the Android SDK (API 37); no signing identity: simulator and unsigned builds only; …

Sparehand asks a worker once and keeps the answer for ten minutes (SPAREHAND_TOOLCHAIN_TTL). A "no" is asked again after a minute, and doctor always asks.

Setting a worker up

Anything else a project needs on the worker goes in its hook (~/.config/sparehand/hooks/REPO.sh, see Configuration); the hook runs after all of this and can override it.

Build caches

A second build is fast only when the first one's work is still there. On the worker it is:

sparehand clean says how much they take, and removes a checkout's caches with its run folder (DerivedData included) once the checkout is gone or hasn't been used for SPAREHAND_KEEP_DAYS:

MacBook: build caches of 9 checkouts take 2.8 GB (the largest: my-app-1a2b3c/my-app 563 MB, …); each goes with its run folder
MacBook: caches every checkout shares: Gradle 730 MB (~/.gradle), SwiftPM 63 MB (~/Library/Caches/org.swift.swiftpm)

Sparehand never removes the shared ones; they are the tools' own.

What comes back

After a mobile job, whatever its exit code, the standard outputs come back by themselves:

JobWhatWhere on the hub
Gradle (Android, Flutter, React Native)build/outputs/apk, bundle, aar, androidTest-results; build/reports (tests, lint); build/test-results (JUnit XML), of every modulethe same paths
Flutterbuild/app/outputs/flutter-apk, apk, bundle; build/ios/ipathe same paths
Xcodethe .app bundles and .xcresult test results the job madebuild/sparehand/, where the command ran: build/sparehand/Debug-iphonesimulator/MyApp.app, build/sparehand/Test-MyApp-….xcresult
[sparehand] got app/build/outputs/apk, app/build/reports, app/build/test-results from Laptop

Only these folders are taken, each replaced whole. Anything else (an .xcarchive, a -exportPath, a coverage folder) comes back with --get, as for any job:

sparehand run --get build/MyApp.xcarchive xcodebuild -scheme MyApp -archivePath build/MyApp.xcarchive archive CODE_SIGNING_ALLOWED=NO

SPAREHAND_OUT_DIR moves build/sparehand; add it to .gitignore if build/ isn't there already. SPAREHAND_OUTPUTS=0 switches the whole thing off.

Instrumented tests on an emulator

sparehand emulator test                                    # ./gradlew connectedDebugAndroidTest
sparehand emulator test Pixel_9 --api 37 -- ./gradlew :app:connectedDebugAndroidTest
sparehand emulator test -- flutter test integration_test

One command boots an emulator on a free worker (the rules and options of sparehand emulator start: AVD, --on, --ram, --api), runs the test command on that same worker with ANDROID_SERIAL set to that emulator, brings the reports back (app/build/reports/androidTests, app/build/outputs/androidTest-results) and stops the emulator, also when the tests fail or you press Ctrl-C. Its exit code is the tests' own; 255 means no worker could boot the emulator or take the job.

The tests talk to the emulator on the worker itself, so nothing travels through the hub. To drive an emulator from the hub instead (your own adb, an agent that takes screenshots), use sparehand emulator start.

Limits

Settings

SettingDefaultMeaning
SPAREHAND_NEEDSautoWhat a job needs: read from the command, a list (xcode,jdk), or - for nothing. run --needs sets it for one run.
SPAREHAND_XCODEThe Xcode version an Xcode job needs (27, 27.2). Empty: the checkout's .xcode-version, else any.
SPAREHAND_OUTPUTS10: nothing comes back by itself.
SPAREHAND_OUT_DIRbuild/sparehandWhere an Xcode job's apps and test results land, relative to where the command ran.
SPAREHAND_GRADLE_CACHE10: Gradle's build cache isn't switched on for jobs.
SPAREHAND_TOOLCHAIN_TTL600Seconds a worker's answer about its toolchains is kept.

For a coding agent, one line in its instructions does it: "Builds and tests go through sparehand run (xcodebuild, ./gradlew, flutter); instrumented Android tests through sparehand emulator test; on exit 255 run the same command here."