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:
- Speed – A new build can be generated and shipped to testers in minutes, not hours.
- Reliability – Scripts run the same way every time, eliminating human slip‑ups like forgetting to increment a version code.
- 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/andios/. - 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@v3with the appropriate key. - Keep secrets out of logs. Never echo a key or password; GitHub masks secrets automatically, but a stray
echo $MY_KEYcan 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.gradleorInfo.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.