Mobile DevOps: Automating App Builds and Deployments

Manual mobile app builds and deployments are slow, error-prone, and unsustainable. If your team is still building release candidates on a developer’s laptop and manually uploading to the App Store or Google Play, you are losing hours every release cycle and introducing unnecessary risk.

Mobile DevOps brings the same automation discipline that backend teams have enjoyed for years to the mobile development workflow. This guide covers how to set up automated builds, testing, and deployment for iOS and Android apps.

The Mobile CI/CD Pipeline

A mature mobile CI/CD pipeline handles five stages:

  1. Code quality: Linting, static analysis, and formatting checks
  2. Build: Compile the app for all target platforms
  3. Test: Run unit tests, UI tests, and integration tests
  4. Distribution: Deploy to internal testers (TestFlight, Firebase App Distribution)
  5. Release: Submit to the App Store and Google Play

Pipeline Tools

The tools we use and recommend:

  • CI/CD platform: GitHub Actions, Bitrise, or CircleCI
  • Build automation: Fastlane
  • Code signing: Match (iOS) and Fastlane supply (Android)
  • Testing: XCTest (iOS), Espresso (Android), Detox (React Native)
  • Distribution: TestFlight, Firebase App Distribution
  • Monitoring: Firebase Crashlytics, Sentry

Fastlane: The Foundat

ion

Fastlane is the industry standard for mobile build automation. It handles code signing, building, testing, and deployment through a Ruby-based DSL.

iOS Fastfile

# ios/fastlane/Fastfile

default_platform(:ios)

platform :ios do
  desc "Run unit tests"
  lane :test do
    scan(
      workspace: "MyApp.xcworkspace",
      scheme: "MyApp",
      devices: ["iPhone 13"],
      clean: true,
      code_coverage: true
    )
  end

  desc "Build and upload to TestFlight"
  lane :beta do
    setup_ci if ENV['CI']

    match(
      type: "appstore",
      readonly: is_ci
    )

    increment_build_number(
      build_number: ENV['BUILD_NUMBER'] || latest_testflight_build_number + 1
    )

    build_app(
      workspace: "MyApp.xcworkspace",
      scheme: "MyApp",
      export_method: "app-store",
      clean: true
    )

    upload_to_testflight(
      skip_waiting_for_build_processing: true,
      changelog: changelog_from_git_commits(
        commits_count: 10,
        merge_commit_filtering: "exclude_merges"
      )
    )

    slack(
      message: "New iOS beta uploaded to TestFlight!",
      channel: "#releases"
    ) if ENV['SLACK_URL']
  end

  desc "Submit to App Store"
  lane :release do
    build_app(
      workspace: "MyApp.xcworkspace",
      scheme: "MyApp",
      export_method: "app-store"
    )

    upload_to_app_store(
      submit_for_review: true,
      automatic_release: false,
      force: true,
      precheck_include_in_app_purchases: false
    )
  end
end

Android Fastfile

# android/fastlane/Fastfile

default_platform(:android)

platform :android do
  desc "Run unit tests"
  lane :test do
    gradle(
      task: "test",
      build_type: "Debug"
    )
  end

  desc "Build and upload to Firebase App Distribution"
  lane :beta do
    gradle(
      task: "clean assembleRelease",
      properties: {
        "android.injected.signing.store.file" => ENV['KEYSTORE_PATH'],
        "android.injected.signing.store.password" => ENV['KEYSTORE_PASSWORD'],
        "android.injected.signing.key.alias" => ENV['KEY_ALIAS'],
        "android.injected.signing.key.password" => ENV['KEY_PASSWORD'],
      }
    )

    firebase_app_distribution(
      app: ENV['FIREBASE_APP_ID'],
      groups: "internal-testers",
      release_notes: changelog_from_git_commits(
        commits_count: 10,
        merge_commit_filtering: "exclude_merges"
      )
    )
  end

  desc "Build and upload to Google Play (internal track)"
  lane :release do
    gradle(
      task: "clean bundleRelease",
      properties: {
        "android.injected.signing.store.file" => ENV['KEYSTORE_PATH'],
        "android.injected.signing.store.password" => ENV['KEYSTORE_PASSWORD'],
        "android.injected.signing.key.alias" => ENV['KEY_ALIAS'],
        "android.injected.signing.key.password" => ENV['KEY_PASSWORD'],
      }
    )

    upload_to_play_store(
      track: "internal",
      aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH],
      skip_upload_metadata: true,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )
  end
end

GitHub Actions Workfl

GitHub Actions Workflows Infographic ows

iOS Build and Test

# .github/workflows/ios.yml
name: iOS CI

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

jobs:
  test:
    runs-on: macos-12
    steps:
      - uses: actions/checkout@v3

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

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

      - name: Install dependencies
        run: cd ios && pod install

      - name: Run tests
        run: |
          cd ios
          bundle exec fastlane test

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: test-results
          path: ios/fastlane/test_output/

  beta:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: macos-12
    steps:
      - uses: actions/checkout@v3

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

      - name: Install dependencies
        run: cd ios && pod install

      - name: Deploy to TestFlight
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
          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 }}
          BUILD_NUMBER: ${{ github.run_number }}
        run: |
          cd ios
          bundle exec fastlane beta

Android Build and Test

# .github/workflows/android.yml
name: Android CI

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

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

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

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

      - name: Run tests
        run: cd android && ./gradlew test

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

  beta:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

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

      - name: Decode keystore
        run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks

      - name: Build and deploy
        env:
          KEYSTORE_PATH: keystore.jks
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
          FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }}
        run: |
          cd android
          bundle exec fastlane beta

iOS Code Signing with Ma

tch

Code signing is the most painful part of iOS CI/CD. Match solves this by storing certificates and provisioning profiles in a Git repository:

# ios/fastlane/Matchfile
git_url("https://github.com/your-org/certificates.git")
storage_mode("git")
type("appstore")
app_identifier("com.example.myapp")
username("[email protected]")

Initial setup (run once):

bundle exec fastlane match appstore
bundle exec fastlane match development

In CI, Match fetches certificates automatically. The MATCH_PASSWORD environment variable decrypts the repository.

Version Management

Automate version numbering to avoid conflicts and manual errors:

# Semantic versioning from Git tags
lane :bump_version do |options|
  type = options[:type] || "patch"

  case type
  when "major"
    increment_version_number(bump_type: "major")
  when "minor"
    increment_version_number(bump_type: "minor")
  when "patch"
    increment_version_number(bump_type: "patch")
  end

  version = get_version_number
  commit_version_bump(message: "Bump version to #{version}")
  add_git_tag(tag: "v#{version}")
  push_to_git_remote
end

For build numbers, use the CI build number:

increment_build_number(
  build_number: ENV['GITHUB_RUN_NUMBER'] || Time.now.strftime("%Y%m%d%H%M")
)

Environment Management

Separate Configurations

Maintain separate configurations for development, staging, and production:

# .env.development
API_BASE_URL=https://dev-api.example.com
ANALYTICS_ENABLED=false
LOG_LEVEL=debug

# .env.staging
API_BASE_URL=https://staging-api.example.com
ANALYTICS_ENABLED=true
LOG_LEVEL=info

# .env.production
API_BASE_URL=https://api.example.com
ANALYTICS_ENABLED=true
LOG_LEVEL=error

Secret Management

Never commit secrets to your repository. Use your CI platform’s secret management:

  • GitHub Actions: Repository Secrets
  • Bitrise: Secret Environment Variables
  • CircleCI: Context and Environment Variables

Required secrets for a typical mobile CI/CD setup:

  • App Store Connect API key
  • Google Play service account JSON
  • Code signing certificates and passwords
  • Firebase configuration
  • Slack webhook URL

Automated Testing in CI

Test Pyramid

Structure your test suite for CI efficiency:

Unit Tests (~70%)        - Fast, run on every commit
Integration Tests (~20%) - Medium speed, run on PR merge
UI/E2E Tests (~10%)      - Slow, run nightly or pre-release

Parallel Testing

Run tests in parallel to reduce pipeline duration:

# iOS parallel testing
- name: Run tests
  run: |
    xcodebuild test \
      -workspace MyApp.xcworkspace \
      -scheme MyApp \
      -parallel-testing-enabled YES \
      -maximum-parallel-testing-workers 4

Monitoring and Notifications

Slack Notifications

# In your Fastfile
after_all do |lane|
  slack(
    message: "#{lane} completed successfully!",
    success: true,
    channel: "#mobile-releases",
    payload: {
      "Build Number" => get_build_number,
      "Version" => get_version_number,
      "Git Branch" => git_branch,
    }
  )
end

error do |lane, exception|
  slack(
    message: "#{lane} failed: #{exception.message}",
    success: false,
    channel: "#mobile-releases"
  )
end

Practical Tips

  1. Start with tests: Automate testing before automating deployment. A CI pipeline that only runs tests still saves significant time.

  2. Cache aggressively: Cache CocoaPods, Gradle dependencies, and derived data. This can cut build times by 50 percent or more.

  3. Use self-hosted runners for iOS: GitHub Actions macOS runners are expensive. A Mac Mini running as a self-hosted runner pays for itself in a few months.

  4. Version your Fastfile: Treat your build automation code with the same care as application code. Review changes, write comments, and test in a branch before merging.

  5. Monitor build times: Track how long each pipeline stage takes. When builds exceed 20 minutes, investigate and optimise.

Conclusion

Mobile DevOps transforms your release process from a stressful manual procedure into a reliable automated workflow. The upfront investment in setting up CI/CD pays dividends with every release: faster iteration, fewer mistakes, and the confidence to ship frequently.

Start with Fastlane for build automation, add GitHub Actions for CI/CD, and progressively automate more of your workflow. Within a few sprints, you will wonder how you ever shipped without it.

For help setting up mobile CI/CD for your team, contact eawesome. We build automated mobile delivery pipelines for Australian development teams.