Automating Deployment Pipelines for Mobile Apps with GitHub Actions

You know that feeling when you spend a whole afternoon tweaking a UI, push the commit, and then spend another hour manually uploading the build to TestFlight or Google Play? It’s the kind of repetitive grind that makes you wonder if you’re still a developer or just a glorified button‑pusher. The good news is you don’t have to live like that. With GitHub Actions you can let the CI/CD machine do the heavy lifting while you focus on the fun part—building features.

Why automation matters now

Mobile ecosystems move fast. Apple drops a new iOS version every fall, Google pushes Android updates quarterly, and both stores tighten their review guidelines. If your release process is manual, you’ll always be a step behind. Automation gives you three big wins:

  1. Speed – A new build can be generated and shipped to testers in minutes, not hours.
  2. Reliability – Scripts run the same way every time, eliminating human slip‑ups like forgetting to increment a version code.
  3. Confidence – When the pipeline passes, you know the exact same code that built on your laptop is what’s in the store.

Getting started: the repo layout

Before we dive into YAML, let’s make sure our repository is organized in a way that the pipeline can understand.

my‑app/
├─ android/
├─ ios/
├─ src/
├─ .github/
│  └─ workflows/
│     └─ deploy.yml
├─ fastlane/
│  ├─ Fastfile
│  └─ Appfile
└─ README.md
  • Keep platform‑specific code in android/ and ios/.
  • Put shared business logic in src/.
  • Store all CI/CD definitions under .github/workflows.
  • If you use Fastlane for store uploads (highly recommended), keep its config in a fastlane/ folder.

This layout isn’t mandatory, but it keeps things tidy and makes the workflow file easier to read.

Step‑by‑step: building a GitHub Action workflow

1. Define the trigger

We want the pipeline to run on every push to the main branch and when a tag that looks like a version number is created.

on:
  push:
    branches: [ main ]
    tags:
      - 'v*.*.*'

The v*.*.* pattern matches tags like v1.2.3. Tagging a commit is the signal that you’re ready to ship.

2. Set up the job matrix

Our app has two platforms, so we’ll run two parallel jobs: one for Android, one for iOS.

jobs:
  build-android:
    runs-on: ubuntu‑latest
    steps: [...]
  build-ios:
    runs-on: macos‑latest
    steps: [...]

GitHub provides ubuntu‑latest for Linux builds and macos‑latest for anything that needs Xcode.

3. Checkout the code

Both jobs start with the same step: pull the repository.

- name: Checkout repository
  uses: actions/checkout@v3

4. Set up the environment

Android

- name: Set up JDK 11
  uses: actions/setup-java@v3
  with:
    java-version: '11'
    distribution: 'temurin'

- name: Install Android SDK
  uses: android-actions/setup-android@v2
  with:
    api-level: 33
    ndk-version: 25.2.9519653

iOS

- name: Set up Xcode
  uses: maxim-lobanov/setup-xcode@v1
  with:
    xcode-version: '15.0'

These actions install the compilers and tools we need without us having to manage them manually.

5. Build the binaries

Android

- name: Build APK
  run: ./gradlew assembleRelease

iOS

- name: Build IPA
  run: |
    cd ios
    fastlane gym --scheme MyApp --export_method app-store

I like to keep the iOS build inside Fastlane because it already knows how to sign the app with the right provisioning profile.

6. Run unit and UI tests

Testing is non‑negotiable. If a test fails, the pipeline aborts and you get a clear red flag.

- name: Run Android unit tests
  run: ./gradlew testReleaseUnitTest

- name: Run iOS unit tests
  run: fastlane test

You can add UI tests with Firebase Test Lab for Android or XCUITest for iOS later; the pattern stays the same.

7. Upload artifacts

We want the built files available for later steps (or for a human to download).

- name: Upload APK
  uses: actions/upload-artifact@v3
  with:
    name: android-apk
    path: app/build/outputs/apk/release/app-release.apk

- name: Upload IPA
  uses: actions/upload-artifact@v3
  with:
    name: ios-ipa
    path: ios/MyApp.ipa

8. Deploy to stores (optional)

If the tag indicates a release, we push the binaries to the respective stores using Fastlane.

- name: Deploy to Google Play
  if: startsWith(github.ref, 'refs/tags/v')
  env:
    PLAY_JSON_KEY: ${{ secrets.PLAY_JSON_KEY }}
  run: |
    cd android
    fastlane supply --apk app-release.apk --track internal

- name: Deploy to App Store Connect
  if: startsWith(github.ref, 'refs/tags/v')
  env:
    APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
  run: |
    cd ios
    fastlane deliver --ipa MyApp.ipa --skip_screenshots --skip_metadata

Notice the if: condition – it prevents accidental uploads on every push. The secrets (PLAY_JSON_KEY, APP_STORE_CONNECT_API_KEY) are stored securely in the repository settings.

Testing on real devices

CI runners are great for unit tests, but nothing beats a real device for catching UI glitches. I like to add a step that triggers a Firebase Test Lab run for Android and a Bitrise or Azure pipeline for iOS. The YAML gets a bit longer, but the payoff is worth it: you catch layout breaks before they reach a tester’s phone.

Tips & gotchas

  • Cache your dependencies. Both Gradle and CocoaPods can be cached between runs, shaving minutes off the build time. Use actions/cache@v3 with the appropriate key.
  • Keep secrets out of logs. Never echo a key or password; GitHub masks secrets automatically, but a stray echo $MY_KEY can still appear in plain text if you’re not careful.
  • Version bump automation. You can add a small script that reads the latest tag, increments the patch number, and writes it back to build.gradle or Info.plist. This way the version always matches the tag that triggered the build.
  • Parallelism limits. macOS runners are more expensive and have stricter concurrency limits on free accounts. If you hit the limit, consider running iOS builds only on tagged releases.

Wrapping up

Setting up a GitHub Actions pipeline for mobile apps might feel like a lot of YAML at first, but once it’s in place you’ll wonder how you ever survived without it. The key is to start small—maybe just automate the Android build—then layer in tests, artifact uploads, and finally store deployments. By the time you’re done, you’ll have a repeatable, auditable process that lets you ship faster, with fewer mistakes, and with more confidence that the code you wrote is exactly the code your users get.

Reactions