01The Mobile CI/CD Challenge

Mobile CI/CD is fundamentally different from web deployment. You can't just push to production, you need to:

  • Build platform-specific binaries
  • Sign with certificates and provisioning profiles
  • Run tests on real devices and simulators
  • Navigate app store review processes
  • Manage multiple environments and configurations

This guide covers the patterns that scale from solo developer to enterprise team.

02Pipeline Architecture

The Four Stages

Branching Strategy

BranchTriggerActions
feature/*PushBuild, Unit Tests, Lint
developMergeBuild, All Tests, Deploy to Dev
release/*CreateBuild, All Tests, Deploy to Staging
mainMergeBuild, All Tests, Deploy to Production

03iOS Pipeline

Fastlane Configuration

# Fastfile
default_platform(:ios)

platform :ios do
  before_all do
    setup_ci if ENV['CI']
  end

  desc "Run unit tests"
  lane :test do
    run_tests(
      scheme: "MyApp",
      devices: ["iPhone 15 Pro"],
      code_coverage: true,
      output_directory: "./test_results"
    )
  end

  desc "Build and upload to TestFlight"
  lane :beta do
    sync_code_signing(type: "appstore")

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

    build_app(
      scheme: "MyApp",
      export_method: "app-store",
      output_directory: "./build"
    )

    upload_to_testflight(
      skip_waiting_for_build_processing: true
    )
  end

  desc "Deploy to App Store"
  lane :release do
    sync_code_signing(type: "appstore")

    build_app(
      scheme: "MyApp-Release",
      export_method: "app-store"
    )

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

Code Signing

Use match for team-based code signing:

# Matchfile
git_url("git@github.com:company/certificates.git")

storage_mode("git")

type("appstore")

app_identifier(["com.company.app", "com.company.app.widget"])

username("ci@company.com")

GitHub Actions Workflow

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

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

jobs:
  test:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4

      - name: Select Xcode
        run: sudo xcode-select -s /Applications/Xcode_15.2.app

      - name: Install dependencies
        run: bundle install

      - name: Run tests
        run: bundle exec fastlane test

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./test_results/coverage.lcov

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: bundle install

      - name: Setup certificates
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
        run: bundle exec fastlane match appstore --readonly

      - name: Deploy to TestFlight
        env:
          APP_STORE_CONNECT_API_KEY: ${{ secrets.ASC_API_KEY }}
        run: bundle exec fastlane beta

04Android Pipeline

Gradle Configuration

// build.gradle
android {
    buildTypes {
        debug {
            applicationIdSuffix ".debug"
            versionNameSuffix "-debug"
        }

        staging {
            initWith debug
            applicationIdSuffix ".staging"
            versionNameSuffix "-staging"
            signingConfig signingConfigs.staging
        }

        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            signingConfig signingConfigs.release
        }
    }

    flavorDimensions "brand"
    productFlavors {
        brandA {
            dimension "brand"
            applicationId "com.company.branda"
        }
        brandB {
            dimension "brand"
            applicationId "com.company.brandb"
        }
    }
}

Fastlane for Android

# Fastfile
default_platform(:android)

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

  desc "Build and upload to Play Store internal track"
  lane :internal do
    gradle(
      task: "bundle",
      build_type: "Release",
      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]
    )
  end

  desc "Promote internal to production"
  lane :promote_to_production do
    upload_to_play_store(
      track: "internal",
      track_promote_to: "production",
      skip_upload_aab: true,
      skip_upload_metadata: true,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )
  end
end

05Testing in CI

Test Pyramid

Test Layer Guidelines:

  • E2E Tests (10%), Real devices, critical paths only
  • Integration Tests (20%), API contracts, database operations
  • Unit Tests (70%), Business logic, fast and numerous

Device Testing

Use cloud device farms for real device testing:

# Firebase Test Lab
- name: Run instrumented tests
  run: |
    gcloud firebase test android run \
      --type instrumentation \
      --app app/build/outputs/apk/debug/app-debug.apk \
      --test app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
      --device model=Pixel6,version=33 \
      --device model=Pixel4,version=30

Visual Regression Testing

Catch UI regressions before they ship:

// Snapshot testing
func testLoginScreen() {
    let vc = LoginViewController()
    assertSnapshot(matching: vc, as: .image(on: .iPhone13Pro))
}

06Environment Management

Configuration per Environment

# config/environments.yml
development:
  api_base_url: "https://dev-api.example.com"
  analytics_enabled: false
  debug_logging: true

staging:
  api_base_url: "https://staging-api.example.com"
  analytics_enabled: true
  debug_logging: true

production:
  api_base_url: "https://api.example.com"
  analytics_enabled: true
  debug_logging: false

Secrets Management

Never commit secrets. Use CI/CD secret management:

# GitHub Actions
env:
  API_KEY: ${{ secrets.API_KEY }}
  SIGNING_KEY: ${{ secrets.SIGNING_KEY }}

# GitLab CI
variables:
  API_KEY: $API_KEY  # From CI/CD settings

07Release Automation

Versioning Strategy

Semantic versioning with build numbers:

Version: MAJOR.MINOR.PATCH (1.2.3)
Build:   Incrementing integer (456)

Full: 1.2.3 (456)

Automate version bumping:

# Fastlane
lane :bump_version do |options|
  type = options[:type] || "patch"

  increment_version_number(
    bump_type: type
  )

  commit_version_bump(
    message: "Bump version: #{type}"
  )

  add_git_tag
  push_git_tags
end

Changelog Generation

Generate changelogs from commits:

# .github/workflows/release.yml
- name: Generate changelog
  uses: TriPSs/conventional-changelog-action@v4
  with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    output-file: "CHANGELOG.md"

Phased Rollouts

Reduce risk with gradual releases:

# iOS phased release
upload_to_app_store(
  phased_release: true  # 7-day rollout
)

# Android staged rollout
upload_to_play_store(
  track: "production",
  rollout: "0.1"  # 10% of users
)

08Pipeline Optimization

Build Caching

Cache dependencies and derived data:

# GitHub Actions
- name: Cache CocoaPods
  uses: actions/cache@v3
  with:
    path: Pods
    key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}

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

Parallel Execution

Run independent jobs concurrently:

jobs:
  test-ios:
    runs-on: macos-14
    # iOS tests...

  test-android:
    runs-on: ubuntu-latest
    # Android tests...

  lint:
    runs-on: ubuntu-latest
    # Lint checks...

  deploy:
    needs: [test-ios, test-android, lint]
    # Only after all pass...

Build Time Monitoring

Track and improve build times:

# Fastlane build time tracking
before_all do
  @start_time = Time.now
end

after_all do
  duration = Time.now - @start_time

  # Send to metrics service
  post_build_metrics(
    duration: duration,
    lane: lane_context[SharedValues::LANE_NAME]
  )
end

09Monitoring & Alerting

Build Status Dashboard

Visibility into pipeline health:

  • Build success rate
  • Average build duration
  • Test flakiness
  • Deployment frequency

Failure Notifications

# Slack notification on failure
- 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 }}

10Checklist

CI Pipeline

  • Automated builds on every commit
  • Unit tests with coverage thresholds
  • Linting and static analysis
  • Dependency vulnerability scanning
  • Build caching configured

CD Pipeline

  • Automated deployment to test environments
  • Code signing automated
  • Environment configuration managed
  • Secrets properly secured
  • Rollback procedures documented

Release Process

  • Versioning automated
  • Changelog generation
  • Phased rollouts configured
  • Store metadata automation
  • Post-release monitoring

11Conclusion

A well-designed mobile CI/CD pipeline is the foundation of shipping quality software quickly. The investment in automation pays dividends in:

  • Developer velocity, Focus on features, not deployments
  • Quality, Catch issues before users do
  • Confidence, Know exactly what's in each release
  • Recovery, Roll back quickly when needed

Start simple, automate incrementally, and continuously improve.


Pipeline patterns refined through years of enterprise mobile development with dozens of releases per week.