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 labelvCPUMemoryStoragePrice per minute
warp-ubuntu-latest-x64-4x416GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664GB150GB SSD$0.032

macOS labels for the iOS half

Runner labelmacOSvCPUMemoryStoragePrice per minuteAlias
warp-macos-26-arm64-6xmacOS 26622GB120GB SSD$0.08
warp-macos-26-arm64-12xmacOS 261244GB270GB SSD$0.16
warp-macos-15-arm64-6xmacOS 15622GB120GB SSD$0.08warp-macos-latest-arm64-6x
warp-macos-15-arm64-12xmacOS 151244GB270GB SSD$0.16warp-macos-latest-arm64-12x
warp-macos-14-arm64-6xmacOS 14622GB120GB 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/*.ipa

Five 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.

JobLabelWhy
Analyze and testwarp-ubuntu-latest-x64-4xThe Dart analyzer and flutter test run on the Dart VM. 4 vCPU and 16GB covers most app-sized repositories.
Android artifactwarp-ubuntu-latest-x64-8xGradle, Kotlin compilation, and the AOT snapshot for the release engine want cores and memory at the same time.
Wide Android matrixwarp-ubuntu-latest-x64-16xSeveral flavors or ABI splits built in one job, or a module graph wide enough to keep 16 cores busy.
iOS artifactwarp-macos-15-arm64-6xpod install plus an unsigned xcodebuild of the Runner target fits comfortably in 6 vCPU and 22GB.
Signed release archivewarp-macos-26-arm64-12xThe 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.

ShapeWarpBuild labelWarpBuild rateGitHub-hosted equivalentGitHub rateDifference
4 vCPU, 16 GBwarp-ubuntu-latest-x64-4x$0.0084-core Linux larger runner$0.01233 percent lower list price
8 vCPU, 32 GBwarp-ubuntu-latest-x64-8x$0.0168-core Linux larger runner$0.02227 percent lower list price
16 vCPU, 64 GBwarp-ubuntu-latest-x64-16x$0.03216-core Linux larger runner$0.04224 percent lower list price
macOS ARM64warp-macos-15-arm64-6x (6 vCPU, 22 GB)$0.08largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB)$0.10222 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.

JobMinutes per monthWarpBuild rateWarpBuild costGitHub-hosted rateGitHub-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
Total9,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.