Back to Blog

CI/CD for Mobile App Development: Complete Guide 2026

Complete guide to CI/CD for mobile apps. Learn to automate builds, testing, and deployment for iOS, Android, React Native, and Flutter apps.

Hevcode Team
January 24, 2026

Continuous Integration and Continuous Deployment (CI/CD) automates building, testing, and deploying mobile apps. This guide covers setting up CI/CD pipelines for iOS, Android, React Native, and Flutter applications.

Why CI/CD for Mobile?

  • Faster releases - Automate repetitive tasks
  • Fewer bugs - Automated testing catches issues early
  • Consistent builds - Same process every time
  • Team efficiency - Developers focus on code, not deployment

CI/CD Pipeline Overview

┌─────────┐    ┌─────────┐    ┌─────────┐    ┌─────────┐    ┌─────────┐
│  Code   │───▶│  Build  │───▶│  Test   │───▶│ Deploy  │───▶│ Release │
│  Push   │    │         │    │         │    │ (Beta)  │    │ (Store) │
└─────────┘    └─────────┘    └─────────┘    └─────────┘    └─────────┘
     │              │              │              │              │
     ▼              ▼              ▼              ▼              ▼
  Trigger       Compile       Unit Tests    TestFlight     App Store
  Pipeline      & Sign        UI Tests      Play Console   Play Store

Popular CI/CD Platforms

Platform Best For iOS Android Price
GitHub Actions GitHub users Yes Yes Free tier
Bitrise Mobile-first Yes Yes Free tier
Codemagic Flutter Yes Yes Free tier
CircleCI Large teams Yes Yes Free tier
Fastlane Local/any CI Yes Yes Free

GitHub Actions Setup

React Native CI/CD

# .github/workflows/ci.yml
name: CI

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

jobs:
  lint-and-test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run TypeScript check
        run: npm run typecheck

      - name: Run unit tests
        run: npm test -- --coverage

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

  build-android:
    needs: lint-and-test
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Setup Java
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '17'

      - name: Setup Android SDK
        uses: android-actions/setup-android@v3

      - name: Install dependencies
        run: npm ci

      - name: Build Android Release
        working-directory: android
        run: ./gradlew assembleRelease

      - name: Upload APK
        uses: actions/upload-artifact@v4
        with:
          name: app-release.apk
          path: android/app/build/outputs/apk/release/app-release.apk

  build-ios:
    needs: lint-and-test
    runs-on: macos-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install CocoaPods
        working-directory: ios
        run: pod install

      - name: Build iOS
        working-directory: ios
        run: |
          xcodebuild -workspace MyApp.xcworkspace \
            -scheme MyApp \
            -configuration Release \
            -sdk iphonesimulator \
            -destination 'platform=iOS Simulator,name=iPhone 15' \
            build

Android Deployment to Play Store

# .github/workflows/android-release.yml
name: Android Release

on:
  push:
    tags:
      - 'v*'

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Setup Java
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '17'

      - name: Install dependencies
        run: npm ci

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

      - name: Build Release Bundle
        working-directory: android
        env:
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
        run: ./gradlew bundleRelease

      - name: Upload to Play Store
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT }}
          packageName: com.myapp
          releaseFiles: android/app/build/outputs/bundle/release/app-release.aab
          track: internal
          status: completed

iOS Deployment to TestFlight

# .github/workflows/ios-release.yml
name: iOS Release

on:
  push:
    tags:
      - 'v*'

jobs:
  deploy:
    runs-on: macos-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install CocoaPods
        working-directory: ios
        run: pod install

      - name: Install Apple Certificate
        uses: apple-actions/import-codesign-certs@v2
        with:
          p12-file-base64: ${{ secrets.CERTIFICATES_P12 }}
          p12-password: ${{ secrets.CERTIFICATES_PASSWORD }}

      - name: Install Provisioning Profile
        uses: apple-actions/download-provisioning-profiles@v1
        with:
          bundle-id: com.myapp
          issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
          api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
          api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}

      - name: Build and Archive
        working-directory: ios
        run: |
          xcodebuild -workspace MyApp.xcworkspace \
            -scheme MyApp \
            -configuration Release \
            -archivePath build/MyApp.xcarchive \
            archive

      - name: Export IPA
        working-directory: ios
        run: |
          xcodebuild -exportArchive \
            -archivePath build/MyApp.xcarchive \
            -exportPath build \
            -exportOptionsPlist ExportOptions.plist

      - name: Upload to TestFlight
        uses: apple-actions/upload-testflight-build@v1
        with:
          app-path: ios/build/MyApp.ipa
          issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
          api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
          api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}

Flutter CI/CD

# .github/workflows/flutter-ci.yml
name: Flutter CI

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

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.16.0'
          channel: 'stable'
          cache: true

      - name: Install dependencies
        run: flutter pub get

      - name: Analyze code
        run: flutter analyze

      - name: Run tests
        run: flutter test --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v3

  build-android:
    needs: test
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.16.0'
          channel: 'stable'

      - name: Setup Java
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '17'

      - name: Build APK
        run: flutter build apk --release

      - name: Build App Bundle
        run: flutter build appbundle --release

      - name: Upload APK
        uses: actions/upload-artifact@v4
        with:
          name: app-release.apk
          path: build/app/outputs/flutter-apk/app-release.apk

  build-ios:
    needs: test
    runs-on: macos-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.16.0'
          channel: 'stable'

      - name: Install dependencies
        run: flutter pub get

      - name: Build iOS (no signing)
        run: flutter build ios --release --no-codesign

Fastlane Configuration

Fastlane automates iOS and Android deployment locally or in CI.

iOS Fastfile

# ios/fastlane/Fastfile
default_platform(:ios)

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

  desc "Build and upload to TestFlight"
  lane :beta do
    # Increment build number
    increment_build_number(
      build_number: ENV["BUILD_NUMBER"] || latest_testflight_build_number + 1
    )

    # Build app
    build_app(
      workspace: "MyApp.xcworkspace",
      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
    # Ensure we're on main branch
    ensure_git_branch(branch: "main")

    # Build app
    build_app(
      workspace: "MyApp.xcworkspace",
      scheme: "MyApp",
      export_method: "app-store"
    )

    # Upload to App Store
    upload_to_app_store(
      submit_for_review: true,
      automatic_release: true,
      force: true
    )
  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 and upload to Play Store Internal"
  lane :beta do
    # Increment version code
    increment_version_code(
      gradle_file_path: "app/build.gradle"
    )

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

    # Upload to Play Store
    upload_to_play_store(
      track: "internal",
      aab: "app/build/outputs/bundle/release/app-release.aab",
      skip_upload_metadata: true,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )
  end

  desc "Promote Internal to Production"
  lane :release do
    upload_to_play_store(
      track: "internal",
      track_promote_to: "production",
      skip_upload_aab: true,
      skip_upload_metadata: true
    )
  end
end

Environment Management

Using Environment Variables

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

# Create .env file during build
- name: Create .env file
  run: |
    echo "API_URL=${{ secrets.API_URL }}" >> .env
    echo "API_KEY=${{ secrets.API_KEY }}" >> .env

React Native Environment Config

// react-native-config
// .env.production
API_URL=https://api.production.com
API_KEY=prod_key_xxx

// .env.staging
API_URL=https://api.staging.com
API_KEY=staging_key_xxx

// Usage
import Config from 'react-native-config';
const apiUrl = Config.API_URL;

Flutter Environment Config

// Using --dart-define
// flutter build apk --dart-define=API_URL=https://api.production.com

class Environment {
  static const String apiUrl = String.fromEnvironment(
    'API_URL',
    defaultValue: 'https://api.dev.com',
  );
}

Code Signing

iOS Code Signing with Match

# Matchfile
git_url("https://github.com/company/certificates")
storage_mode("git")
type("appstore") # or "development", "adhoc"
app_identifier(["com.myapp"])

# In Fastfile
lane :setup_signing do
  match(
    type: "appstore",
    readonly: is_ci
  )
end

Android Signing

// android/app/build.gradle
android {
    signingConfigs {
        release {
            storeFile file(System.getenv("KEYSTORE_FILE") ?: "release.keystore")
            storePassword System.getenv("KEYSTORE_PASSWORD")
            keyAlias System.getenv("KEY_ALIAS")
            keyPassword System.getenv("KEY_PASSWORD")
        }
    }

    buildTypes {
        release {
            signingConfig signingConfigs.release
        }
    }
}

Automated Version Management

Semantic Versioning

# .github/workflows/version.yml
name: Version Bump

on:
  push:
    branches: [main]

jobs:
  version:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Bump version and push tag
        id: tag_version
        uses: mathieudutour/github-tag-action@v6.1
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          default_bump: patch

      - name: Create Release
        uses: ncipollo/release-action@v1
        with:
          tag: ${{ steps.tag_version.outputs.new_tag }}
          name: Release ${{ steps.tag_version.outputs.new_tag }}
          body: ${{ steps.tag_version.outputs.changelog }}

Auto-increment Build Number

# iOS - Fastlane
increment_build_number(
  build_number: ENV["GITHUB_RUN_NUMBER"]
)

# Android - Gradle
def getBuildNumber() {
    return System.getenv("GITHUB_RUN_NUMBER") ?: "1"
}

android {
    defaultConfig {
        versionCode getBuildNumber().toInteger()
    }
}

Best Practices

1. Cache Dependencies

# Node modules
- uses: actions/cache@v3
  with:
    path: node_modules
    key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}

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

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

2. Parallel Jobs

jobs:
  lint:
    runs-on: ubuntu-latest
    # ...

  test:
    runs-on: ubuntu-latest
    # ...

  build-android:
    needs: [lint, test]  # Run after lint and test
    runs-on: ubuntu-latest
    # ...

  build-ios:
    needs: [lint, test]  # Runs in parallel with build-android
    runs-on: macos-latest
    # ...

3. Fail Fast

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: true  # Stop all jobs if one fails
      matrix:
        node: [18, 20]

4. Branch Protection

Configure branch protection rules:

  • Require status checks before merging
  • Require branches to be up to date
  • Require code review approval

Conclusion

CI/CD is essential for modern mobile development. Start with basic automated testing, then add automated deployment. Use Fastlane for complex workflows and caching for faster builds.

Need help setting up CI/CD? Contact Hevcode for professional mobile app development with automated deployment pipelines.

Related Articles

Tags:CI/CDDevOpsMobile DevelopmentAutomationGitHub Actions

Need help with your project?

We've helped 534+ clients build successful apps. Let's discuss yours.

Ready to Build Your App?

534+ projects delivered • 4.9★ rating • 6+ years experience

Let's discuss your project — no obligations, just a straightforward conversation.