Mobile App Deployment: Automated CI/CD Pipeline Setup with GitHub Actions

Manual builds and mobile app deployment are the enemy of fast iteration. Every minute spent building locally, running tests manually, or uploading to TestFlight for mobile app deployment is a minute not spent improving your app. Mobile CI/CD (Continuous Integration and Continuous Deployment) automates these workflows, giving your team faster feedback and more reliable app deployment automation releases.

GitHub Actions has emerged as a compelling CI/CD platform for mobile development. It offers macOS runners (essential for iOS builds), generous free tier minutes, and native integration with GitHub repositories. This guide walks through setting up a complete mobile CI/CD pipeline.

Pipeline Architecture

Pipeline Architecture Infographic

A production-ready mobile CI/CD pipeline has four stages:

1. Build       -> Compile the app, verify it builds cleanly
2. Test        -> Run unit tests, integration tests, linting
3. Distribute  -> Deploy to beta testers (TestFlight / Firebase App Distribution)
4. Release     -> Submit to App Store / Google Play (triggered manually)

Each pull request triggers stages 1-2. Merges to the main branch trigger stages 1-3. Releases are triggered manually or by git tags.

GitHub Actions Basics

GitHub Actions workflows are defined in YAML files in .github/workflows/. Each workflow contains jobs, and each job contains steps.

Key concepts:

  • Triggers: Events that start a workflow (push, pull_request, schedule, manual)
  • Runners: Virtual machines that execute jobs (ubuntu-latest, macos-latest)
  • Actions: Reusable steps from the marketplace or your own repository
  • Secrets: Encrypted environment variables for sensitive data (signing certificates, API keys)

iOS CI/CD Pipeline

iOS CI/CD Pipeline Infographic

Build and Test Workflow

# .github/workflows/ios.yml
name: iOS Build and Test

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  build-and-test:
    runs-on: macos-11
    timeout-minutes: 30

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Select Xcode version
        run: sudo xcode-select -s /Applications/Xcode_13.0.app

      - name: Cache CocoaPods
        uses: actions/cache@v2
        with:
          path: Pods
          key: pods-${{ hashFiles('Podfile.lock') }}
          restore-keys: pods-

      - name: Install dependencies
        run: pod install --repo-update

      - name: Build
        run: |
          xcodebuild build \
            -workspace MyApp.xcworkspace \
            -scheme MyApp \
            -destination 'platform=iOS Simulator,name=iPhone 13,OS=15.0' \
            -configuration Debug \
            CODE_SIGNING_ALLOWED=NO

      - name: Run unit tests
        run: |
          xcodebuild test \
            -workspace MyApp.xcworkspace \
            -scheme MyApp \
            -destination 'platform=iOS Simulator,name=iPhone 13,OS=15.0' \
            -configuration Debug \
            CODE_SIGNING_ALLOWED=NO \
            | xcpretty --report junit

      - name: Upload test results
        uses: actions/upload-artifact@v2
        if: always()
        with:
          name: test-results
          path: build/reports/

iOS Distribution to TestFlight

# .github/workflows/ios-deploy.yml
name: iOS Deploy to TestFlight

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: macos-11
    timeout-minutes: 45

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Select Xcode
        run: sudo xcode-select -s /Applications/Xcode_13.0.app

      - name: Install dependencies
        run: pod install --repo-update

      - name: Install Apple certificate and provisioning profile
        env:
          BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
          P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
          PROVISION_PROFILE_BASE64: ${{ secrets.PROVISION_PROFILE_BASE64 }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: |
          # Create variables
          CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
          PROFILE_PATH=$RUNNER_TEMP/build_profile.mobileprovision
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db

          # Decode from base64
          echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
          echo -n "$PROVISION_PROFILE_BASE64" | base64 --decode -o $PROFILE_PATH

          # Create keychain
          security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH

          # Import certificate
          security import $CERTIFICATE_PATH -P "$P12_PASSWORD" \
            -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          security list-keychain -d user -s $KEYCHAIN_PATH

          # Install provisioning profile
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          cp $PROFILE_PATH ~/Library/MobileDevice/Provisioning\ Profiles

      - name: Increment build number
        run: |
          BUILD_NUMBER=$(date +%Y%m%d%H%M)
          agvtool new-version -all $BUILD_NUMBER

      - name: Archive
        run: |
          xcodebuild archive \
            -workspace MyApp.xcworkspace \
            -scheme MyApp \
            -archivePath $RUNNER_TEMP/MyApp.xcarchive \
            -configuration Release

      - name: Export IPA
        run: |
          xcodebuild -exportArchive \
            -archivePath $RUNNER_TEMP/MyApp.xcarchive \
            -exportPath $RUNNER_TEMP/export \
            -exportOptionsPlist ExportOptions.plist

      - name: Upload to TestFlight
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY: ${{ secrets.ASC_API_KEY }}
        run: |
          xcrun altool --upload-app \
            --type ios \
            --file $RUNNER_TEMP/export/MyApp.ipa \
            --apiKey "$APP_STORE_CONNECT_API_KEY_ID" \
            --apiIssuer "$APP_STORE_CONNECT_API_ISSUER_ID"

      - name: Clean up keychain
        if: always()
        run: security delete-keychain $RUNNER_TEMP/app-signing.keychain-db

Setting Up iOS Secrets

Encode your certificate and provisioning profile as base64:

# Encode certificate
base64 -i Certificates.p12 | pbcopy
# Paste into GitHub secret: BUILD_CERTIFICATE_BASE64

# Encode provisioning profile
base64 -i MyApp.mobileprovision | pbcopy
# Paste into GitHub secret: PROVISION_PROFILE_BASE64

Store these in your repository’s Settings, then Secrets, then Actions.

Android CI/CD Pipeline

Build and Test Workflow

# .github/workflows/android.yml
name: Android Build and Test

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    timeout-minutes: 20

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

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

      - name: Cache Gradle
        uses: actions/cache@v2
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: gradle-

      - name: Grant execute permission for gradlew
        run: chmod +x gradlew

      - name: Run lint
        run: ./gradlew lint

      - name: Run unit tests
        run: ./gradlew testDebugUnitTest

      - name: Build debug APK
        run: ./gradlew assembleDebug

      - name: Upload APK artifact
        uses: actions/upload-artifact@v2
        with:
          name: debug-apk
          path: app/build/outputs/apk/debug/app-debug.apk

      - name: Upload test results
        uses: actions/upload-artifact@v2
        if: always()
        with:
          name: test-results
          path: app/build/reports/tests/

Android Distribution

# .github/workflows/android-deploy.yml
name: Android Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

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

      - name: Cache Gradle
        uses: actions/cache@v2
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: gradle-${{ hashFiles('**/*.gradle*') }}

      - name: Decode keystore
        env:
          ENCODED_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
        run: echo $ENCODED_KEYSTORE | base64 --decode > app/keystore.jks

      - name: Build release AAB
        env:
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
        run: |
          ./gradlew bundleRelease \
            -Pandroid.injected.signing.store.file=keystore.jks \
            -Pandroid.injected.signing.store.password=$KEYSTORE_PASSWORD \
            -Pandroid.injected.signing.key.alias=$KEY_ALIAS \
            -Pandroid.injected.signing.key.password=$KEY_PASSWORD

      - name: Upload to Firebase App Distribution
        uses: wzieba/Firebase-Distribution-Github-Action@v1
        with:
          appId: ${{ secrets.FIREBASE_APP_ID }}
          token: ${{ secrets.FIREBASE_TOKEN }}
          groups: beta-testers
          file: app/build/outputs/bundle/release/app-release.aab

      - name: Upload to Google Play (internal track)
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }}
          packageName: au.com.yourapp
          releaseFiles: app/build/outputs/bundle/release/app-release.aab
          track: internal

React Native Pipeline

React Native apps need both iOS and Android builds. Use a matrix strategy or separate jobs:

# .github/workflows/react-native.yml
name: React Native CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  javascript-tests:
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '16'
          cache: 'npm'

      - run: npm ci
      - run: npm run lint
      - run: npm test -- --coverage

      - name: Upload coverage
        uses: actions/upload-artifact@v2
        with:
          name: coverage
          path: coverage/

  android-build:
    needs: javascript-tests
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '16'
          cache: 'npm'
      - uses: actions/setup-java@v2
        with:
          java-version: '11'
          distribution: 'adopt'

      - run: npm ci
      - run: cd android && ./gradlew assembleRelease

  ios-build:
    needs: javascript-tests
    runs-on: macos-11
    timeout-minutes: 40

    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '16'
          cache: 'npm'

      - run: npm ci
      - run: cd ios && pod install
      - name: Build iOS
        run: |
          xcodebuild build \
            -workspace ios/MyApp.xcworkspace \
            -scheme MyApp \
            -destination 'platform=iOS Simulator,name=iPhone 13' \
            CODE_SIGNING_ALLOWED=NO

Best Practices

Cache Everything

Cache dependencies (CocoaPods, Gradle, npm) to reduce build times dramatically. A cached iOS build can be 5 to 10 minutes faster.

Use Build Numbers Derived from CI

Auto-increment build numbers based on the CI run number or timestamp:

- name: Set build number
  run: |
    echo "BUILD_NUMBER=${{ github.run_number }}" >> $GITHUB_ENV

Parallelise Where Possible

Run iOS and Android builds in parallel. Run unit tests while builds are in progress (if they do not depend on the build output).

Keep Workflows Fast

Target build times:

  • Lint and unit tests: under 5 minutes
  • Full build: under 15 minutes
  • Build and deploy: under 25 minutes

If builds take longer, investigate caching, parallelisation, and removing unnecessary steps.

Branch Protection

Configure GitHub branch protection rules:

  • Require status checks to pass before merging
  • Require pull request reviews
  • Prevent direct pushes to main

This ensures every change passes CI before reaching your main branch.

Notifications

Notify your team when builds fail:

- name: Notify Slack on failure
  if: failure()
  uses: 8398a7/action-slack@v3
  with:
    status: failure
    fields: repo,message,commit,author
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

Cost Considerations

GitHub Actions pricing (as of October 2021):

  • Public repos: Free
  • Private repos: 2,000 minutes/month free, then USD 0.008/minute (Linux), USD 0.08/minute (macOS)

macOS runners are 10 times the cost of Linux runners. Optimise your iOS builds:

  • Run JavaScript tests on Linux runners
  • Only run iOS builds when iOS code changes
  • Cache aggressively to reduce build time

For most Australian startups, the free tier covers development needs. Active projects with frequent merges may need paid minutes, typically AUD 20 to 100/month.

Getting Started with Mobile App Deployment Automation

  1. Start with a simple build-and-test workflow for pull requests in your mobile CI/CD
  2. Add deployment to beta testing (TestFlight / Firebase App Distribution) on merge to main for mobile app deployment
  3. Gradually add linting, code coverage, and more test types to your app deployment automation
  4. Automate App Store and Google Play submission for release branches with mobile CI/CD

A good mobile CI/CD pipeline saves hours every week and catches bugs before they reach users through app deployment automation. At eawesome, mobile app deployment automation is set up from the first day of every project, because automated quality gates are non-negotiable for professional mobile development.

Expand your DevOps knowledge with our guides on mobile CI/CD with Fastlane and mobile DevOps best practices.

Frequently Asked Questions About Mobile App Deployment and CI/CD

What is the difference between CI and CD in mobile app deployment?

CI (Continuous Integration) automatically builds and tests code on every commit for mobile app deployment. CD (Continuous Deployment) automatically deploys passing builds to test environments or stores. Together, mobile CI/CD provides automated app deployment automation from code commit to app store.

Why use GitHub Actions instead of other CI/CD tools?

GitHub Actions offers native GitHub integration, macOS runners essential for iOS mobile app deployment, generous free tier (2,000 Linux minutes, 200 macOS minutes monthly), extensive marketplace actions, and secrets management. It’s ideal for mobile CI/CD with app deployment automation for iOS and Android.

How do I set up iOS code signing in GitHub Actions?

Base64-encode your .p12 certificate and provisioning profile, store as GitHub secrets for mobile app deployment, create temporary keychain in workflow, import certificate with password, install provisioning profile, and clean up keychain after build. This ensures secure mobile CI/CD code signing automation.

Can I run iOS and Android mobile app deployment in parallel?

Yes, use separate jobs with different runners - macOS for iOS mobile app deployment, Linux for Android mobile CI/CD. Both can run simultaneously after JavaScript tests pass, dramatically reducing total pipeline time for app deployment automation.

How do I optimize mobile CI/CD costs?

Cache dependencies aggressively (CocoaPods, Gradle, npm), run JavaScript tests on cheap Linux runners, only trigger builds when relevant files change, use shorter timeouts to prevent hung builds, and reserve macOS runners exclusively for iOS builds to optimize mobile app deployment costs.

Essential Mobile App Deployment Insights

GitHub Actions mobile CI/CD workflows with proper caching can reduce iOS build times by 60% - from 15 minutes to 6 minutes - through CocoaPods and derived data caching.

macOS runners cost 10x more than Linux ($0.08/min vs $0.008/min) - running JavaScript tests on Linux before mobile app deployment saves 90% on compute costs.

Automated mobile app deployment with GitHub Actions typically catches 30-40% more bugs than manual testing through consistent automated test execution in mobile CI/CD pipelines.