Mobile CI CD Pipelines

Production engineering guide for mobile ci cd pipelines covering patterns, implementation strategies, and operational best practices.

Mobile CI/CD pipelines automate the build, test, and deployment of mobile applications across iOS and Android, ensuring consistent, reliable releases from code commit to production. They matter most when teams ship frequently—multiple times per day—and need to validate changes across real devices, simulators, and configurations, catching regressions early and enabling feature flags, canary releases, and rollbacks with confidence.

Building Mobile Artifacts

A mobile CI/CD pipeline begins with artifact generation: compiling source code into installable binaries—APKs for Android, IPA files for iOS. The build step must handle versioning, code signing, asset bundling, and configuration per environment.

For Android, the build is orchestrated via Gradle. A build.gradle file defines the android block with buildTypes, flavorDimensions, and productFlavors. A typical build.gradle snippet:

android {
    compileSdk 34

    defaultConfig {
        applicationId "com.example.app"
        minSdk 21
        targetSdk 34
        versionCode 100
        versionName "1.2.0"
    }

    buildTypes {
        release {
            minifyEnabled true
            shrinkResources true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
            signingConfig signingConfigs.release
        }
        debug {
            applicationIdSuffix ".debug"
            versionNameSuffix "-debug"
            debuggable true
        }
    }

    flavorDimensions "environment"
    productFlavors {
        staging {
            dimension "environment"
            applicationIdSuffix ".staging"
        }
        production {
            dimension "environment"
            applicationIdSuffix ".prod"
        }
    }

    signingConfigs {
        release {
            keyAlias 'app-key'
            storeFile file('keystore.jks')
            storePassword 'password123'
            keyPassword 'password123'
        }
    }
}

Running the build in CI:

./gradlew assembleStagingRelease assembleProductionRelease

This generates app-staging-release.apk and app-production-release.apk in app/build/outputs/apk/.

For iOS, Xcode projects use xcodebuild. A pipeline command to build a release IPA:

xcodebuild -workspace MyApp.xcworkspace -scheme Production -configuration Release -archivePath build/MyApp.xcarchive

The archive contains all assets and metadata. To export the IPA:

xcodebuild -exportArchive -archivePath build/MyApp.xcarchive -exportPath build/exports -exportOptionsPlist ExportOptions.plist

The ExportOptions.plist file defines export behavior:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>method</key>
    <string>app-store</string>
    <key>provisioningProfiles</key>
    <dict>
        <key>com.example.app</key>
        <string>MyApp-Production.mobileprovision</string>
    </dict>
    <key>uploadBitcode</key>
    <true/>
    <key>compileBitcode</key>
    <true/>
</dict>
</plist>

Build Artifacts and Versioning

Artifacts must be versioned and tagged. The build process should compute a version string from Git metadata: git describe --tags --long --dirty outputs v1.2.0-12-gabc1234. This version is embedded in the app and stored in a build manifest.

A build.manifest.json file is generated per build:

{
  "app": {
    "name": "MyApp",
    "platform": "android",
    "version": "1.2.0-12-gabc1234",
    "buildId": "20231027-081532",
    "commit": "abc1234567890abcdef1234567890abcdef12345678",
    "branch": "main",
    "timestamp": "2023-10-27T08:15:32Z"
  },
  "artifacts": [
    {
      "type": "apk",
      "path": "app/build/outputs/apk/staging/release/app-staging-release.apk",
      "sha256": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
    }
  ]
}

This manifest is uploaded to a blob storage endpoint via curl:

curl -X PUT \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ARTIFACT_TOKEN" \
  -d @build.manifest.json \
  "https://artifacts.example.com/builds/20231027-081532"

Testing Mobile Applications

Testing must be fast, deterministic, and scalable. The pipeline runs unit, integration, and UI tests across multiple configurations.

Unit and Integration Tests

Unit tests are run via the test runner provided by the platform. In Android, ./gradlew test executes JUnit and Espresso tests in src/test/ and src/androidTest/.

For iOS, xctest is driven by xcodebuild:

xcodebuild -workspace MyApp.xcworkspace -scheme Test -destination 'platform=iOS Simulator,name=iPhone 14,OS=17.0' test

The destination string selects a simulator by name and OS version. The output includes a .xctestrun file specifying test configuration:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>testables</key>
    <array>
        <string>MyAppTests</string>
    </array>
    <key>environment</key>
    <dict>
        <key>TEST_ENV</key>
        <string>ci</string>
    </dict>
</dict>
</plist>

UI and End-to-End Tests

UI tests are executed using tools like Appium or Detox, both of which run on a server that communicates with simulators or real devices.

Appium requires a appium server running on port 4723. A test script in JavaScript:

const wd = require('wd');
const { Builder } = wd;

async function runUITest() {
  const driver = await new Builder()
    .withCapabilities({
      platformName: 'iOS',
      deviceName: 'iPhone 14',
      app: '/path/to/MyApp.ipa',
      automationName: 'XCUITest'
    })
    .usingRemote('http://localhost:4723/wd/hub')
    .build();

  try {
    await driver.elementByAccessibilityId('loginButton').click();
    await driver.elementByXPath('//XCUIElementTypeTextField[@name="username"]').sendKeys('alice');
    await driver.elementByXPath('//XCUIElementTypeButton[@name="submit"]').click();
    const success = await driver.elementByXPath('//XCUIElementTypeStaticText[@name="Welcome, alice"]').text();
    console.log('Test passed:', success);
  } catch (err) {
    console.error('Test failed:', err.message);
  } finally {
    await driver.quit();
  }
}

runUITest();

The pipeline invokes this script via node test/ui.js. The Appium server logs test execution, including screenshots and video on failure.

Test Artifacts and Failure Modes

Test results are collected as XML and JSON. JUnit XML is expected by CI systems like Jenkins or GitHub Actions:

<testsuite name="LoginTest" tests="3" failures="1" errors="0" time="4.2">
  <testcase name="user_can_login" classname="LoginTest" time="1.5">
    <failure message="Expected welcome message to be visible">Expected element to be present</failure>
  </testcase>
  <testcase name="user_cannot_login_with_invalid_email" classname="LoginTest" time="0.8"/>
</testsuite>

The pipeline uploads these reports and attaches them to the build. A common failure: test execution time exceeds timeout, causing the pipeline to fail silently. The xcodebuild command may not fail on test failures, requiring explicit validation:

if ! xcodebuild -scheme Test -destination 'platform=iOS Simulator,name=iPhone 14,OS=17.0' test; then
  echo "Test suite failed" >&2
  exit 1
fi

Deploying to Production

Deployment involves uploading binaries to app stores and pushing updates to internal testing channels.

iOS: App Store Connect

To submit a build to Apple’s App Store, the pipeline uses altool or transporter:

/usr/bin/altool --uploadApp -f MyApp.ipa -u [email protected] -p password123

altool returns a submissionId. The pipeline waits for the upload to complete via polling:

while true; do
  result=$(curl -s -H "Authorization: Bearer $TOKEN" \
    "https://api.appstoreconnect.apple.com/v1/submissions/$submissionId")
  status=$(echo "$result" | jq -r '.data.attributes.status')
  if [ "$status" = "processing" ]; then
    sleep 30
  elif [ "$status" = "finished" ]; then
    echo "Upload succeeded"
    break
  else
    echo "Upload failed: $status"
    exit 1
  fi
done

Android: Google Play Console

For Android, the pipeline uses gplaycli or google-play-deploy:

gplaycli --json --service-account-key-file key.json --track=beta --release-notes-file=release-notes.txt --apk=app-staging-release.apk

The gplaycli tool authenticates via a service account key file in JSON format:

{
  "type": "service_account",
  "project_id": "myapp-123456",
  "private_key_id": "abc123def456",
  "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...",
  "client_email": "[email protected]",
  "client_id": "123456789012345678901",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token"
}

The pipeline monitors the rollout via gplaycli polling until the track is fully promoted.

Internal Testing and Canary Releases

For internal testing, the pipeline uploads builds to a staging environment:

curl -X POST \
  -H "Authorization: Bearer $INTERNAL_TOKEN" \
  -F "[email protected]" \
  -F "track=internal" \
  -F "notes=Fix login flow and dark mode" \
  https://internal-api.example.com/apps/upload

The internal-api returns a buildId and downloadUrl. The pipeline sends notifications via Slack or email.

Configuration and State Management

All pipelines are driven by a pipeline.yaml file at the repository root:

name: Mobile CI/CD
on:
  push:
    branches:
      - main
      - develop
  pull_request:
    branches:
      - main

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Setup Java
        uses: actions/setup-java@v3
        with:
          java-version: '17'
      - name: Build Android
        run: |
          ./gradlew assembleStagingRelease assembleProductionRelease
      - name: Build iOS
        run: |
          xcodebuild -workspace MyApp.xcworkspace -scheme Production -configuration Release -archivePath build/MyApp.xcarchive
          xcodebuild -exportArchive -archivePath build/MyApp.xcarchive -exportPath build/exports -exportOptionsPlist ExportOptions.plist
      - name: Upload Artifacts
        run: |
          curl -X PUT -H "Content-Type: application/json" -H "Authorization: Bearer $ARTIFACT_TOKEN" -d @build.manifest.json https://artifacts.example.com/builds/$(date +%Y%m%d-%H%M%S)

  test:
    needs: build
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install Appium
        run: brew install appium
      - name: Start Appium Server
        run: appium &> appium.log &
      - name: Run UI Tests
        run: node test/ui.js
      - name: Upload Test Reports
        run: |
          curl -X POST -H "Authorization: Bearer $TEST_TOKEN" -F "[email protected]" https://test-reports.example.com

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Deploy to Google Play
        run: |
          gplaycli --json --service-account-key-file key.json --track=beta --release-notes-file=release-notes.txt --apk=app-production-release.apk
      - name: Deploy to App Store
        run: |
          /usr/bin/altool --uploadApp -f MyApp.ipa -u [email protected] -p password123

Common Failure Modes

Idioms and Patterns

The pipeline is the heartbeat of mobile engineering—its logs, artifacts, and configuration form a shared contract across teams, ensuring every release is repeatable, observable, and accountable.

This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.