Flutter Builds on GitHub Actions
Split a Flutter pipeline across two runner labels: Android artifacts on Linux x64 at $0.016 per minute and iOS artifacts on macOS at $0.08 per minute.
Last verified:
A Flutter pipeline on GitHub Actions splits along the platform line: the Android artifact builds on a Linux runner, and the iOS artifact needs a macOS runner because Xcode, CocoaPods, and the iOS toolchain only exist on macOS. On WarpBuild that split is two runs-on labels in one workflow file, warp-ubuntu-latest-x64-8x at $0.016 per minute for the Android job and warp-macos-15-arm64-6x at $0.08 per minute for the iOS job.
This page covers the two-job workflow with the pub cache and the Gradle caches wired per job, the runner sizes that fit each half, the four bottlenecks that dominate Flutter wall clock time, and a dual-platform cost model against GitHub-hosted list prices.
Overview
A Flutter repository usually settles into three job shapes.
The first is analysis and unit tests: flutter analyze and flutter test against the Dart VM, with no platform toolchain involved. The second is the Android artifact: flutter build apk or flutter build appbundle, which shells out to the Gradle build under android/. The third is the iOS artifact: flutter build ios or flutter build ipa, which runs CocoaPods and then xcodebuild.
Only the third needs macOS. The first two run on Linux, where a minute costs a fraction of a macOS minute, so the platform split is also the cost split.
WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. Two macOS limits shape the layout of a Flutter pipeline. macOS runners do not support nested virtualization. They cannot run Docker. So an Android emulator job for integration tests, or a container that stands up a backend fixture, stays on a Linux label in a separate job, and the two halves exchange work through artifacts rather than through a shared runner.
Linux x64 labels for the Android half
| Runner label | vCPU | Memory | Storage | Price per minute |
|---|---|---|---|---|
warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 |
warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 |
warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 |
macOS labels for the iOS half
| Runner label | macOS | vCPU | Memory | Storage | Price per minute | Alias |
|---|---|---|---|---|---|---|
warp-macos-26-arm64-6x | macOS 26 | 6 | 22GB | 120GB SSD | $0.08 | |
warp-macos-26-arm64-12x | macOS 26 | 12 | 44GB | 270GB SSD | $0.16 | |
warp-macos-15-arm64-6x | macOS 15 | 6 | 22GB | 120GB SSD | $0.08 | warp-macos-latest-arm64-6x |
warp-macos-15-arm64-12x | macOS 15 | 12 | 44GB | 270GB SSD | $0.16 | warp-macos-latest-arm64-12x |
warp-macos-14-arm64-6x | macOS 14 | 6 | 22GB | 120GB SSD | $0.08 |
Both tables come from the cloud runners documentation, verified 2026-08-13. The latest aliases track macOS 15, in sync with GitHub's macos-latest tag. The macOS 26 images ship the Xcode 27.0 SDKs and simulator runtimes while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released, which matters to a Flutter team that needs a newer iOS SDK than the macOS 15 image carries.
Caching behaves differently on each half, and a Flutter pipeline touches both. The WarpBuild cache is enabled by default on Linux runners, where WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4. Entries are scoped to the key, the version, and the branch. The version hash covers the compression tool and the cached paths, so an entry written by the iOS job on macOS never restores in the Android job on Linux. Entries expire after 7 days of last use, and cache storage bills at $0.20 per GB-month with $0.0001 per operation on hosted runners.
One scheduling note for the iOS half. The cloud runners documentation records unlimited concurrency for the generally available Linux and Windows runners, and asks teams that need high macOS concurrency to contact support, so a wide iOS matrix is worth raising before it lands.
Configuration
Point each job at the label its platform needs. Nothing else in the workflow changes, because the runner images carry the same tooling as GitHub-hosted runner images.
name: flutter
on:
pull_request:
push:
branches: [main]
concurrency:
group: flutter-${{ github.ref }}
cancel-in-progress: true
env:
FLUTTER_VERSION: 3.35.4
jobs:
android:
runs-on: warp-ubuntu-latest-x64-8x
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: WarpBuilds/setup-java@v5
with:
distribution: temurin
java-version: '17'
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Cache pub packages
uses: WarpBuilds/cache@v1
with:
path: ~/.pub-cache
key: pub-linux-${{ hashFiles('**/pubspec.lock') }}
restore-keys: pub-linux-
- name: Cache Gradle user home
uses: WarpBuilds/cache@v1
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-linux-${{ hashFiles('android/**/*.gradle*', 'android/**/gradle-wrapper.properties') }}
restore-keys: gradle-linux-
- run: flutter precache --android
- run: flutter pub get
- run: flutter analyze
- run: flutter test
- run: flutter build appbundle --release
- uses: actions/upload-artifact@v4
with:
name: app-release-aab
path: build/app/outputs/bundle/release/app-release.aab
ios:
runs-on: warp-macos-15-arm64-6x
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Cache pub packages
uses: actions/cache@v4
with:
path: ~/.pub-cache
key: pub-macos-${{ hashFiles('**/pubspec.lock') }}
restore-keys: pub-macos-
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: |
ios/Pods
~/Library/Caches/CocoaPods
key: pods-${{ hashFiles('ios/Podfile.lock') }}
restore-keys: pods-
- run: flutter precache --ios
- run: flutter pub get
- run: pod install --project-directory=ios
- run: flutter build ios --release --no-codesign
release-ipa:
needs: [android, ios]
if: github.ref == 'refs/heads/main'
runs-on: warp-macos-26-arm64-12x
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- run: flutter pub get
- run: flutter build ipa --release --export-options-plist=ios/ExportOptions.plist
- uses: actions/upload-artifact@v4
with:
name: ipa
path: build/ios/ipa/*.ipaFive details in that file are worth calling out.
The pub cache is keyed per platform. pub-linux- and pub-macos- are separate key prefixes because the cache version already separates the two, and matching prefixes to reality keeps a restore miss from looking like a bug. Both halves resolve the same pubspec.lock, so both keys roll at the same moment.
The Android job uses WarpBuilds/cache@v1 and WarpBuilds/setup-java@v5. Both use the WarpBuild cache automatically when they run on a WarpBuild runner, with no extra configuration. The iOS job stays on actions/cache@v4 because the WarpBuild cache is enabled on Linux runners.
Gradle caching here is explicit rather than delegated. flutter build appbundle invokes the project's own Gradle wrapper under android/, so caching ~/.gradle/caches and ~/.gradle/wrapper covers the dependency and wrapper downloads for that invocation. A job that calls ./gradlew directly, for a flavor build or a native module test, can use WarpBuilds/gradle-actions/setup-gradle@v5 instead, which caches the whole Gradle User Home including the build cache.
flutter precache --android and flutter precache --ios download only the engine artifacts that half of the pipeline needs. The default flutter precache pulls artifacts for every target platform, which is several hundred megabytes of download that the job never uses.
The release job builds a signed IPA on the 12 vCPU macOS label and stops there. Certificate import, the throwaway keychain, and the provisioning profile sequence are the same on a Flutter project as on a native project, and they are written out step by step on the iOS builds page.
Sizing
The two halves size independently, because they bottleneck on different resources.
| Job | Label | Why |
|---|---|---|
| Analyze and test | warp-ubuntu-latest-x64-4x | The Dart analyzer and flutter test run on the Dart VM. 4 vCPU and 16GB covers most app-sized repositories. |
| Android artifact | warp-ubuntu-latest-x64-8x | Gradle, Kotlin compilation, and the AOT snapshot for the release engine want cores and memory at the same time. |
| Wide Android matrix | warp-ubuntu-latest-x64-16x | Several flavors or ABI splits built in one job, or a module graph wide enough to keep 16 cores busy. |
| iOS artifact | warp-macos-15-arm64-6x | pod install plus an unsigned xcodebuild of the Runner target fits comfortably in 6 vCPU and 22GB. |
| Signed release archive | warp-macos-26-arm64-12x | The archive compiles in release configuration, produces dSYMs, and writes an .xcarchive and an IPA into the same disk. |
Storage decides more of the macOS choice than core count does. The 6 vCPU label carries a 120GB SSD, which holds Xcode, the bundled simulator runtimes, the checkout, the pub cache, the Pods directory, and DerivedData. A release job that also writes an .xcarchive and an exported IPA runs that disk close to full on a large app. The 12 vCPU label carries 270GB, which removes the cleanup steps that a tight disk otherwise forces into the workflow.
On the Linux side, resist jumping to 16 vCPU by default. A Flutter Android build spends much of its time in a single Gradle configuration phase and in the Dart AOT compiler, and neither scales linearly with cores. CI observability reports right-sizing recommendations from measured CPU and memory utilization per repository, workflow, and job, which settles the question faster than guessing from one run.
List prices against GitHub-hosted runners
GitHub publishes its per-minute rates in the GitHub Actions minute multipliers reference and its runner shapes in the hosted runner reference. Both were checked on 2026-08-13.
| Shape | WarpBuild label | WarpBuild rate | GitHub-hosted equivalent | GitHub rate | Difference |
|---|---|---|---|---|---|
| 4 vCPU, 16 GB | warp-ubuntu-latest-x64-4x | $0.008 | 4-core Linux larger runner | $0.012 | 33 percent lower list price |
| 8 vCPU, 32 GB | warp-ubuntu-latest-x64-8x | $0.016 | 8-core Linux larger runner | $0.022 | 27 percent lower list price |
| 16 vCPU, 64 GB | warp-ubuntu-latest-x64-16x | $0.032 | 16-core Linux larger runner | $0.042 | 24 percent lower list price |
| macOS ARM64 | warp-macos-15-arm64-6x (6 vCPU, 22 GB) | $0.08 | largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB) | $0.102 | 22 percent lower list price |
The macOS row is the one that carries a shape difference as well as a price difference: warp-macos-latest-arm64-6x (6 vCPU, 22 GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB), which is 22 percent lower list price on one more vCPU and 8 GB more memory. GitHub list price checked on 2026-08-13. GitHub's standard macOS runner is cheaper per minute at $0.062, on a 3 vCPU, 7 GB machine, so that row compares different amounts of hardware rather than the same machine at two prices.
Worked cost model for a two-platform pipeline
Assumptions, stated so they can be replaced with your own numbers: a team of eight on a 22 weekday month, 15 pull request pushes per weekday for 330 pipeline runs, an Android job of 12 minutes on warp-ubuntu-latest-x64-8x, and an iOS job of 18 minutes on warp-macos-15-arm64-6x. Job durations are held equal on both sides, so the table isolates the rate difference.
| Job | Minutes per month | WarpBuild rate | WarpBuild cost | GitHub-hosted rate | GitHub-hosted cost |
|---|---|---|---|---|---|
| Android (8 vCPU) | 3,960 | $0.016 | $63.36 | $0.022 | $87.12 |
| iOS (6 vCPU macOS) | 5,940 | $0.08 | $475.20 | $0.102 | $605.88 |
| Total | 9,900 | $538.56 | $693.00 |
The monthly difference is $154.44, which is 22 percent of the GitHub-hosted total. Add eight signed release builds of 26 minutes each on warp-macos-26-arm64-12x: 208 minutes at $0.16 is $33.28, for a WarpBuild total of $571.84. That row has no like-for-like GitHub comparison, because GitHub publishes no 12 vCPU Apple Silicon macOS shape.
Every rate above is on the pricing page.
Bottlenecks
Four things dominate Flutter wall clock time on GitHub Actions.
A cold pub cache and cold engine artifacts
Every job starts on a fresh ephemeral VM, so ~/.pub-cache is empty and flutter pub get re-downloads the full dependency set from pub.dev before any code compiles. On top of that, flutter precache fetches the engine artifacts for the target platform, which include gen_snapshot and the platform engine binaries.
Cache ~/.pub-cache keyed on pubspec.lock, cache the Flutter SDK itself through the cache: true input on the setup action, and narrow the precache to one platform per job. The three together turn a multi-minute cold start into a restore.
CocoaPods installs
pod install resolves the Podfile against the local CocoaPods specs repository, and on a cold machine it clones or updates that repository before it installs anything. On plugin-heavy Flutter apps the Pods directory itself is large, and the install compiles nothing but still moves a lot of files.
Cache ios/Pods keyed on ios/Podfile.lock and cache ~/Library/Caches/CocoaPods alongside it. Commit Podfile.lock so the key is stable, and keep pod install as its own step so the timeline shows what it cost.
Gradle daemon cold start
The Android half starts a fresh JVM and a fresh Gradle daemon on every job, then runs configuration across the app module and every plugin module Flutter generates. Configuration time grows with the plugin count, and a Flutter app with 30 plugins configures 30 extra Gradle projects.
Cache ~/.gradle/caches and ~/.gradle/wrapper so dependency resolution and the wrapper distribution download are restores rather than fetches. Keep the daemon settings explicit in android/gradle.properties, and give the job enough memory that the Kotlin compiler and the Dart AOT step are not competing for the same heap headroom.
iOS archive time
The signed release archive is the longest step in a Flutter pipeline. It compiles the Dart code to native AOT, builds the Runner target in release configuration, links every plugin pod, generates dSYMs, and then runs the export step that produces the IPA.
Two things help. Run the archive only on the branch that ships, as the workflow above does with an if on refs/heads/main, so pull request runs stop at the unsigned build. And put that job on the 12 vCPU macOS label, where the extra cores shorten the compile and the 270GB disk holds the archive and the IPA without a cleanup step.
When a job fails on the runner and reproduces nowhere else, the Action Debugger pauses the workflow and opens an SSH session on the runner machine, which is the shortest path to inspecting a Podfile resolution, a Gradle daemon log, or a provisioning profile in place.
FAQ
Which runner labels should a Flutter pipeline use?
Put the Android job on warp-ubuntu-latest-x64-8x (8 vCPU, 32GB, $0.016 per minute) and the iOS job on warp-macos-15-arm64-6x (6 vCPU, 22GB, 120GB SSD, $0.08 per minute). Move the signed release build to warp-macos-26-arm64-12x (12 vCPU, 44GB, 270GB SSD, $0.16 per minute) when the archive runs long or the disk fills.
Can the Android half of a Flutter build run on a macOS runner?
It runs, and it costs $0.08 per minute instead of $0.016. macOS runners also do not support nested virtualization and cannot run Docker, so emulator jobs and container fixtures have no home there. Keep the Android artifact and any containerized step on a Linux label.
Does the WarpBuild cache cover both halves of a Flutter pipeline?
The WarpBuild cache is enabled by default on Linux runners, so the Android job uses WarpBuilds/cache@v1 as a drop-in replacement for actions/cache@v4. The iOS job uses actions/cache@v4 against the GitHub Actions cache backend. Cache entries are scoped to key, version, and branch, and the version covers the compression tool and the cached paths, so an entry saved on macOS never restores on Linux.
What does a two-platform Flutter pipeline cost per month?
For 330 pipeline runs with a 12 minute Android job and an 18 minute iOS job, WarpBuild runner minutes come to $538.56 against $693.00 for the same minutes on GitHub-hosted larger runners (GitHub list prices, checked on 2026-08-13). Pricing is purely usage based, so that is the whole bill.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.