Back to all articles

CI/CD for Mobile Apps: Automated Build and Deployment Pipeline 2025

Manual deployments waste 4-8 hours per release. CI/CD automates building, testing, and deploying, reducing time to 15 minutes and eliminating human error. This guide covers complete setup.

What is CI/CD?

CI (Continuous Integration):
- Automatically build code on every commit
- Run tests automatically
- Catch issues early
- Ensure code quality

CD (Continuous Deployment/Delivery):
- Automatically deploy to TestFlight/Play Console
- Release to production automatically
- Zero-downtime deployments
- Rollback capability

Benefits:
- 80% faster deployments
- 90% fewer deployment errors
- More frequent releases
- Better code quality

CI/CD Pipeline Overview

Typical pipeline:

1. Code Push
   ↓
2. Trigger CI/CD (GitHub Actions, CircleCI, etc.)
   ↓
3. Install Dependencies
   ↓
4. Run Linter (code quality)
   ↓
5. Run Unit Tests
   ↓
6. Run Integration Tests
   ↓
7. Build App (Debug/Release)
   ↓
8. Run UI Tests (optional)
   ↓
9. Upload to TestFlight/Play Console (Beta)
   ↓
10. Deploy to Production (after approval)

Fastlane Setup

What is Fastlane?

Fastlane automates mobile app deployment tasks:

  • Build and sign apps
  • Screenshot generation
  • Beta deployment
  • App Store submission
  • Code signing management

Installation

# Install Fastlane
sudo gem install fastlane

# Initialize in project
cd ios
fastlane init

cd ../android
fastlane init

iOS Fastfile

# ios/fastlane/Fastfile
default_platform(:ios)

platform :ios do
  desc "Run tests"
  lane :test do
    run_tests(
      scheme: "MyApp",
      devices: ["iPhone 14"]
    )
  end

  desc "Build app"
  lane :build do
    gym(
      scheme: "MyApp",
      export_method: "app-store"
    )
  end

  desc "Deploy to TestFlight"
  lane :beta do
    # Increment build number
    increment_build_number(
      xcodeproj: "MyApp.xcodeproj"
    )

    # Build
    gym(
      scheme: "MyApp",
      export_method: "app-store"
    )

    # Upload to TestFlight
    upload_to_testflight(
      skip_waiting_for_build_processing: true
    )
  end

  desc "Deploy to App Store"
  lane :release do
    # Increment version
    increment_version_number(
      bump_type: "patch"
    )

    # Build
    gym(
      scheme: "MyApp",
      export_method: "app-store"
    )

    # Upload to App Store
    upload_to_app_store(
      submit_for_review: false,
      automatic_release: false
    )
  end
end

Android Fastfile

# android/fastlane/Fastfile
default_platform(:android)

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

  desc "Build app"
  lane :build do
    gradle(
      task: "bundle",
      build_type: "Release"
    )
  end

  desc "Deploy to Play Console (Internal)"
  lane :beta do
    # Increment version code
    increment_version_code(
      gradle_file_path: "app/build.gradle"
    )

    # Build AAB
    gradle(
      task: "bundle",
      build_type: "Release"
    )

    # Upload to Play Console
    upload_to_play_store(
      track: "internal",
      aab: "app/build/outputs/bundle/release/app-release.aab"
    )
  end

  desc "Deploy to Play Store"
  lane :release do
    # Build
    gradle(
      task: "bundle",
      build_type: "Release"
    )

    # Upload to production
    upload_to_play_store(
      track: "production",
      aab: "app/build/outputs/bundle/release/app-release.aab"
    )
  end
end

GitHub Actions CI/CD

iOS Workflow

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

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

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

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

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

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

      - name: Install Fastlane
        run: sudo gem install fastlane

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

      - name: Setup signing
        env:
          CERTIFICATES_P12: ${{ secrets.CERTIFICATES_P12 }}
          CERTIFICATES_PASSWORD: ${{ secrets.CERTIFICATES_PASSWORD }}
          PROVISIONING_PROFILE: ${{ secrets.PROVISIONING_PROFILE }}
        run: |
          # Import certificate
          echo $CERTIFICATES_P12 | base64 --decode > certificate.p12
          security create-keychain -p "" build.keychain
          security import certificate.p12 -k build.keychain -P $CERTIFICATES_PASSWORD -T /usr/bin/codesign
          security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain

          # Install provisioning profile
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          echo $PROVISIONING_PROFILE | base64 --decode > ~/Library/MobileDevice/Provisioning\ Profiles/profile.mobileprovision

      - name: Deploy to TestFlight
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
        run: |
          cd ios
          fastlane beta

  deploy-production:
    needs: test
    runs-on: macos-latest
    if: github.ref == 'refs/heads/main'
    steps:
      # Similar to beta but uses `fastlane release`
      - uses: actions/checkout@v3
      - name: Deploy to App Store
        run: |
          cd ios
          fastlane release

Android Workflow

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

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

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

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

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

      - name: Run lint
        run: |
          cd android
          ./gradlew lint

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

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

      - name: Setup signing
        env:
          KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
        run: |
          echo $KEYSTORE_BASE64 | base64 --decode > android/app/keystore.jks
          echo "storeFile=keystore.jks" >> android/key.properties
          echo "storePassword=$KEYSTORE_PASSWORD" >> android/key.properties
          echo "keyAlias=$KEY_ALIAS" >> android/key.properties
          echo "keyPassword=$KEY_PASSWORD" >> android/key.properties

      - name: Install Fastlane
        run: sudo gem install fastlane

      - name: Deploy to Play Console
        env:
          GOOGLE_PLAY_JSON_KEY: ${{ secrets.GOOGLE_PLAY_JSON_KEY }}
        run: |
          echo $GOOGLE_PLAY_JSON_KEY | base64 --decode > android/google-play-key.json
          cd android
          fastlane beta

  deploy-production:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      # Similar to beta but uses `fastlane release`
      - uses: actions/checkout@v3
      - name: Deploy to Play Store
        run: |
          cd android
          fastlane release

Code Signing

iOS Code Signing

Options:

1. Match (Fastlane):
# Setup
fastlane match init

# Create certificates
fastlane match appstore
fastlane match development

# Use in Fastlane
match(type: "appstore")

2. Manual:
- Export certificates from Keychain
- Convert to base64
- Store in GitHub Secrets
- Import in CI

# Export
security export -t identities -f p12 -k ~/Library/Keychains/login.keychain-db -P "password" > cert.p12
base64 cert.p12 | pbcopy

# In CI
echo $CERT_BASE64 | base64 --decode > cert.p12
security import cert.p12 -k build.keychain -P $PASSWORD

Android Code Signing

# Create keystore
keytool -genkey -v -keystore my-release-key.keystore -alias my-key-alias -keyalg RSA -keysize 2048 -validity 10000

# Convert to base64
base64 my-release-key.keystore | pbcopy

# Store in GitHub Secrets:
KEYSTORE_BASE64: [base64 string]
KEYSTORE_PASSWORD: [password]
KEY_ALIAS: [alias]
KEY_PASSWORD: [key password]

# In CI
echo $KEYSTORE_BASE64 | base64 --decode > app/keystore.jks

# build.gradle
signingConfigs {
  release {
    storeFile file('keystore.jks')
    storePassword System.getenv("KEYSTORE_PASSWORD")
    keyAlias System.getenv("KEY_ALIAS")
    keyPassword System.getenv("KEY_PASSWORD")
  }
}

Automated Testing

Unit Tests

iOS (XCTest):
- name: Run unit tests
  run: xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 14'

Android (JUnit):
- name: Run unit tests
  run: ./gradlew testDebugUnitTest

UI Tests

iOS (XCUITest):
- name: Run UI tests
  run: xcodebuild test -scheme MyAppUITests -destination 'platform=iOS Simulator,name=iPhone 14'

Android (Espresso):
- name: Run UI tests
  uses: reactivecircus/android-emulator-runner@v2
  with:
    api-level: 29
    script: ./gradlew connectedAndroidTest

Code Coverage

iOS:
- name: Generate coverage
  run: |
    xcodebuild test -scheme MyApp -enableCodeCoverage YES
    xcrun llvm-cov export -format="lcov" > coverage.lcov

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

Android:
- name: Generate coverage
  run: ./gradlew jacocoTestReport

- name: Upload coverage
  uses: codecov/codecov-action@v3
  with:
    file: ./app/build/reports/jacoco/jacoco.xml

Environment Variables

Managing Secrets

GitHub Secrets (Settings → Secrets):
- APP_STORE_CONNECT_API_KEY_ID
- APP_STORE_CONNECT_API_ISSUER_ID
- APP_STORE_CONNECT_API_KEY_CONTENT
- CERTIFICATES_P12
- CERTIFICATES_PASSWORD
- PROVISIONING_PROFILE
- KEYSTORE_BASE64
- KEYSTORE_PASSWORD
- KEY_ALIAS
- KEY_PASSWORD
- GOOGLE_PLAY_JSON_KEY

Access in workflow:
env:
  API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}

Environment-Specific Builds

iOS (Schemes):
- Development
- Staging
- Production

Build specific:
gym(scheme: "MyApp-Production")

Android (Build Variants):
android {
  flavorDimensions "environment"
  productFlavors {
    dev {
      applicationIdSuffix ".dev"
    }
    staging {
      applicationIdSuffix ".staging"
    }
    prod {}
  }
}

Build specific:
./gradlew assembleProdRelease

Versioning Strategy

Semantic Versioning

Format: MAJOR.MINOR.PATCH
Example: 2.5.3

MAJOR: Breaking changes (2.0.0)
MINOR: New features (2.1.0)
PATCH: Bug fixes (2.0.1)

iOS:
- Version (CFBundleShortVersionString): 2.5.3
- Build number (CFBundleVersion): 145

Android:
- versionName: 2.5.3
- versionCode: 145

Auto-increment:
iOS: increment_build_number
Android: increment_version_code

Git Tagging

# Tag release
git tag -a v2.5.3 -m "Release version 2.5.3"
git push origin v2.5.3

# Auto-tag in CI
- name: Create tag
  run: |
    VERSION=$(cat version.txt)
    git tag -a v$VERSION -m "Release $VERSION"
    git push origin v$VERSION

Notifications

Slack Integration

- name: Notify Slack
  uses: 8398a7/action-slack@v3
  with:
    status: ${{ job.status }}
    text: 'iOS build deployed to TestFlight'
    webhook_url: ${{ secrets.SLACK_WEBHOOK }}
  if: always()

Email Notifications

GitHub Actions sends emails by default on failure.

Custom:
- name: Send email
  uses: dawidd6/action-send-mail@v3
  with:
    server_address: smtp.gmail.com
    server_port: 465
    username: ${{ secrets.EMAIL_USERNAME }}
    password: ${{ secrets.EMAIL_PASSWORD }}
    subject: Build ${{ job.status }}
    body: Build completed with status ${{ job.status }}

Rollback Strategy

TestFlight Rollback

# Remove build from TestFlight
fastlane pilot remove -b [build_number]

# Or mark build as expired in App Store Connect

Play Console Rollback

# Halt release
- Go to Play Console
- Production → Releases
- Stop rollout or update percentage

# Via Fastlane
upload_to_play_store(
  track: "production",
  rollout: "0.1"  # 10% rollout
)

Alternative CI/CD Platforms

CircleCI

# .circleci/config.yml
version: 2.1
jobs:
  test:
    macos:
      xcode: "14.2.0"
    steps:
      - checkout
      - run: cd ios && pod install
      - run: cd ios && fastlane test
  deploy:
    macos:
      xcode: "14.2.0"
    steps:
      - checkout
      - run: cd ios && fastlane beta
workflows:
  test-and-deploy:
    jobs:
      - test
      - deploy:
          requires:
            - test
          filters:
            branches:
              only: main

Bitrise

  • Mobile-focused CI/CD
  • Visual workflow builder
  • Pre-built steps for mobile
  • Easy setup for iOS/Android

Codemagic

  • Flutter/React Native focused
  • Simple configuration
  • Generous free tier
  • Fast builds

Best Practices

Pipeline Optimization

Speed improvements:

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

2. Parallel jobs:
jobs:
  test-ios:
    runs-on: macos-latest
  test-android:
    runs-on: ubuntu-latest

3. Fail fast:
strategy:
  fail-fast: true

4. Skip unnecessary steps:
if: github.event_name == 'push'

Security

  • Never commit secrets to git
  • Use GitHub Secrets or environment variables
  • Rotate API keys regularly
  • Use least-privilege permissions
  • Enable branch protection

Testing Strategy

Test pyramid:
- Unit tests (fast, many): 70%
- Integration tests (medium): 20%
- UI tests (slow, few): 10%

Run on every commit:
- Linting
- Unit tests

Run on develop branch:
- Integration tests
- Beta deployment

Run on main branch:
- Full test suite
- Production deployment

Monitoring

Build Metrics

  • Build success rate
  • Build duration
  • Test pass rate
  • Deployment frequency
  • Time to production

Crash Monitoring

Integrate crash reporting:
- Firebase Crashlytics
- Sentry
- Bugsnag

Track deployment:
# Send release to Sentry
- name: Create Sentry release
  run: |
    sentry-cli releases new $VERSION
    sentry-cli releases set-commits $VERSION --auto
    sentry-cli releases finalize $VERSION

Conclusion

CI/CD transforms mobile app development by automating builds, testing, and deployments. Start with Fastlane for deployment automation, add GitHub Actions for CI, and gradually expand testing coverage. The time invested pays off in faster releases and fewer bugs.

Need a Support URL for Your App?

Generate a compliant, professional support page in under a minute. Our easy-to-use generator creates everything you need for App Store and Google Play submissions.