Can Android and iOS Builds Share One Workflow?

Yes. runs-on is a per-job setting, so an Android job on a Linux label and an iOS job on a macOS label live in the same GitHub Actions workflow file.

Last verified:

Yes. runs-on is declared on each job rather than on the workflow, so one file can hold an Android job on a Linux runner and an iOS job on a macOS runner (workflow syntax reference for runs-on, checked on 2026-08-13). WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so both legs select a warp- label and the pair runs in parallel from the same trigger (WarpBuild cloud runners, checked on 2026-08-13).

Answer

The shape that works is one small job that computes the release metadata, two platform jobs that consume it, and a final job that collects the outputs. Jobs run in parallel by default, and needs is what creates the ordering (workflow syntax reference for needs). Values move down the graph through job outputs (workflow syntax reference for job outputs).

name: mobile-release

on:
  pull_request:
  push:
    branches: [main]

jobs:
  version:
    runs-on: warp-ubuntu-latest-x64-2x
    outputs:
      build_number: ${{ steps.compute.outputs.build_number }}
      semver: ${{ steps.compute.outputs.semver }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - id: compute
        run: |
          echo "build_number=$(git rev-list --count HEAD)" >> "$GITHUB_OUTPUT"
          echo "semver=$(cat VERSION)" >> "$GITHUB_OUTPUT"

  android:
    needs: version
    runs-on: warp-ubuntu-latest-x64-8x
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "21"

      - uses: WarpBuilds/cache@v1
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', 'gradle/libs.versions.toml') }}

      - name: Assemble the release bundle
        run: |
          ./gradlew bundleRelease \
            -PversionCode=${{ needs.version.outputs.build_number }} \
            -PversionName=${{ needs.version.outputs.semver }}

      - uses: actions/upload-artifact@v4
        with:
          name: android-aab
          path: app/build/outputs/bundle/release/app-release.aab

  ios:
    needs: version
    runs-on: warp-macos-latest-arm64-6x
    steps:
      - uses: actions/checkout@v4

      - uses: actions/cache@v4
        with:
          path: |
            ~/Library/Developer/Xcode/DerivedData
            .build
          key: spm-${{ runner.os }}-${{ hashFiles('**/Package.resolved') }}

      - name: Stamp the build number
        run: agvtool new-version -all ${{ needs.version.outputs.build_number }}

      - name: Archive
        run: |
          xcodebuild archive \
            -scheme App \
            -destination "generic/platform=iOS" \
            -archivePath build/App.xcarchive \
            MARKETING_VERSION=${{ needs.version.outputs.semver }}

      - uses: actions/upload-artifact@v4
        with:
          name: ios-archive
          path: build/App.xcarchive

  release:
    needs: [android, ios]
    runs-on: warp-ubuntu-latest-x64-2x
    steps:
      - uses: actions/download-artifact@v4
        with:
          path: dist

      - run: ls -R dist

The version job runs once, both platform jobs read needs.version.outputs.build_number, and the two binaries carry the same version string without a second computation. Per-minute rates for every label are on the pricing page.

Detail

The two legs bill at different rates

The Android leg and the iOS leg pay different per-minute rates, so a mixed workflow has one job that carries most of the bill. Every WarpBuild rate below comes from the cloud runners documentation, checked on 2026-08-13. Job minutes are a modeling assumption, so substitute your own.

JobLabelShapePer minuteMinutesCost per run
versionwarp-ubuntu-latest-x64-2x2 vCPU, 8 GB$0.0041$0.004
androidwarp-ubuntu-latest-x64-8x8 vCPU, 32 GB$0.0169$0.144
ioswarp-macos-latest-arm64-6x6 vCPU, 22 GB$0.0814$1.12
releasewarp-ubuntu-latest-x64-2x2 vCPU, 8 GB$0.0041$0.004

That is $1.272 per run, of which the macOS job is $1.12, or 88 percent. At 300 runs a month the workflow costs $381.60, split $336.00 on macOS minutes and $45.60 on Linux minutes. Tuning the Android leg moves cents; tuning the iOS leg moves dollars.

The same 14 macOS minutes on the largest GitHub-hosted macOS ARM64 runner, a 5 vCPU and 14 GB machine at $0.102 per minute, is $1.428 per run and $428.40 at 300 runs (GitHub Actions minute multipliers, checked on 2026-08-13). warp-macos-latest-arm64-6x is $0.08 per minute on a machine with one more vCPU and 8 GB more memory, which is about 22 percent lower list price and $92.40 a month at this volume.

Sizing up the iOS leg is arithmetic against that number. warp-macos-26-arm64-12x is 12 vCPU with 44 GB at $0.16 per minute, so it costs less per run than the 6 vCPU label only once the archive finishes under 7 minutes, since $1.12 divided by $0.16 is 7. Measure the current job before you switch. Per-job durations and queue times are visible in CI observability, which sits alongside snapshot runners, remote Docker builders, an MCP server, and the Action Debugger in the WarpBuild product surface.

What the two jobs can share

Shared thingMechanismConstraint
Version numbers and release metadataoutputs on the upstream job, read through needsStrings only, so structured data has to be serialized
Build products such as an AAB and an xcarchiveactions/upload-artifact then actions/download-artifactFile permissions are not maintained during upload (upload-artifact), so re-apply an executable bit after download
Secrets, variables, and concurrency groupsRepository or environment scope, declared onceApplies to the workflow, no per-platform copy needed
Source codeactions/checkout@v4 in each jobEach job checks out separately; runners share no filesystem

The two platform jobs never share a machine. A job gets its own ephemeral runner, and everything that crosses the gap between jobs crosses it as an artifact or an output.

What the two jobs cannot share

Caches. Cache version is a hash over the compression tool the runner OS selects and the paths being cached, which means a cache created on warp-macos-14-arm64-6x cannot be restored on warp-ubuntu-latest-x64-4x (WarpBuild caching documentation). Even a shared key resolves to a different entry on the other platform.

The paths diverge as well. The Android leg caches ~/.gradle/caches and ~/.gradle/wrapper; the iOS leg caches ~/Library/Developer/Xcode/DerivedData and the SwiftPM .build directory. A single key covering both restores nothing useful on either. Put runner.os in every cache key and keep the two entries independent.

Cache backends differ too. The WarpBuild cache is available on all Linux runners and enabled by default, and it is not supported on Windows runners (cloud runners documentation), so the Android leg can use WarpBuilds/cache@v1 as a drop-in for actions/cache@v4 while the iOS leg stays on actions/cache@v4.

One more split is worth planning for. macOS runners do not support nested virtualization and cannot run Docker, and Android instrumented tests that need /dev/kvm require the nested-virtualization.enabled=true dynamic label on an x86-64 Linux runner (cloud runners documentation). Container fixtures and emulator jobs therefore belong on the Linux side of the file, which is where they already are in this shape.

Can one GitHub Actions workflow file target two operating systems?

Yes. runs-on is declared per job, so the Android job carries warp-ubuntu-latest-x64-8x and the iOS job carries warp-macos-latest-arm64-6x in the same file. Both start in parallel unless a needs edge orders them. The label catalog with sizes and rates is on the macOS runner page.

Should I use a matrix instead of two separate jobs?

No. A matrix repeats one list of steps across values, and the two legs run different toolchains, different caches, and different signing paths. Use a matrix inside a leg instead, such as several simulator destinations in the iOS job or several product flavors in the Android job.

Can the Android job restore a cache the iOS job saved?

No. Cache version differs by runner OS and cached paths, so entries do not cross platforms (caching documentation). Key each leg with runner.os and treat the two caches as unrelated.

Does this change for React Native or Flutter?

The job graph stays the same and the shared step grows. Both frameworks add a JavaScript or Dart dependency install that each platform leg repeats, so the version job often also produces a lockfile hash the two legs key their caches on. The React Native workflow page and the Flutter workflow page cover the framework-specific steps.

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.