CI/CD for Capacitor Apps: GitHub Actions, Ionic Appflow, and Automated Device Testing

CI/CD for Capacitor Apps: GitHub Actions, Ionic Appflow, and Automated Device Testing

A Capacitor app CI/CD pipeline has more moving pieces than a typical web application. The web layer needs a standard JavaScript build, but then Capacitor must sync that output to native iOS and Android projects, each of which needs its own build toolchain, signing configuration, and deployment target. Add Jest unit tests and Cypress integration tests to the front of that pipeline, and you have a workflow that spans four or five distinct environments.

This guide builds a complete, production-grade CI/CD pipeline step by step — from running tests on the first push to delivering builds to Firebase Test Lab and BrowserStack for device testing.

The Capacitor CI/CD Pipeline Shape

Before writing YAML, understand the dependency chain:

1. Install dependencies (npm)
2. Run unit tests (Jest)          ← fails fast, cheap
3. Build web layer (npm run build)
4. Run Cypress tests              ← validates web layer
5. npx cap sync [ios|android]     ← copies web build to native dirs
6. Build native apps              ← needs platform SDKs
7. Run device tests               ← Firebase Test Lab / BrowserStack
8. Deploy to app stores           ← Fastlane / Appflow

Steps 1–4 run on any Linux runner. Steps 5–6 require macos-latest for iOS (Xcode) and can stay on ubuntu-latest for Android. Keep them in separate jobs so iOS build time doesn't block Android.

Step 1: Running Jest Unit Tests

# .github/workflows/test-and-build.yml
name: Test and Build

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

jobs:
  unit-tests:
    name: Jest Unit Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run unit tests with coverage
        run: npx jest --coverage --ci --runInBand
        env:
          CI: true

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage/lcov.info
          fail_ci_if_error: false

--runInBand disables Jest's parallel worker pool. In CI containers with 2 vCPUs, parallelism often hurts more than it helps due to memory pressure from multiple Node.js processes. Benchmark both on your test suite.

Step 2: Cypress Integration Tests

  cypress-tests:
    name: Cypress Integration Tests
    runs-on: ubuntu-latest
    needs: unit-tests    # only run if unit tests pass

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build web layer
        run: npm run build

      - name: Run Cypress tests
        uses: cypress-io/github-action@v6
        with:
          start: npx serve dist --listen 3000
          wait-on: 'http://localhost:3000'
          wait-on-timeout: 60
          browser: chrome
          headed: false
          record: true
        env:
          CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Upload Cypress screenshots on failure
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: cypress-screenshots
          path: cypress/screenshots
          retention-days: 7

Step 3: Capacitor Sync and Caching

npx cap sync copies the web build output and updates CocoaPods and Gradle dependencies. Cache both to avoid downloading gigabytes on every run.

  cap-sync:
    name: Capacitor Sync
    runs-on: ubuntu-latest
    needs: [unit-tests, cypress-tests]

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install Capacitor CLI
        run: npm ci

      - name: Build web layer
        run: npm run build

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

      - name: Sync Capacitor (Android)
        run: npx cap sync android

      - name: Upload Android native project
        uses: actions/upload-artifact@v4
        with:
          name: android-native
          path: android/
          retention-days: 1

Step 4: Android Build

  android-build:
    name: Android Build
    runs-on: ubuntu-latest
    needs: cap-sync

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

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

      - name: Download Android native project
        uses: actions/download-artifact@v4
        with:
          name: android-native
          path: android/

      - name: Build web layer (needed for cap sync artifacts)
        run: npm ci && npm run build

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

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

      - name: Build release APK
        run: |
          cd android
          ./gradlew assembleRelease \
            -Pandroid.injected.signing.store.file=${{ github.workspace }}/android/app/keystore.jks \
            -Pandroid.injected.signing.store.password=${{ secrets.KEYSTORE_PASSWORD }} \
            -Pandroid.injected.signing.key.alias=${{ secrets.KEY_ALIAS }} \
            -Pandroid.injected.signing.key.password=${{ secrets.KEY_PASSWORD }}

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

Step 5: iOS Build with Fastlane

iOS builds require macos-latest and Xcode. Fastlane handles the complexity of code signing, provisioning profiles, and App Store Connect uploads.

# ios/fastlane/Fastfile
default_platform(:ios)

platform :ios do
  desc "Run tests and build for TestFlight"
  lane :beta do
    # Sync code signing using Match
    match(
      type: "appstore",
      readonly: is_ci,
      git_url: ENV["MATCH_GIT_URL"],
    )

    # Build the app
    gym(
      workspace: "App.xcworkspace",
      scheme: "App",
      export_method: "app-store",
      export_options: {
        uploadBitcode: false,
        compileBitcode: false,
      },
    )

    # Upload to TestFlight
    pilot(
      skip_waiting_for_build_processing: true,
      apple_id: ENV["APPLE_APP_ID"],
    )
  end

  desc "Build for device testing only (no upload)"
  lane :build_test do
    match(type: "development", readonly: is_ci)
    gym(
      workspace: "App.xcworkspace",
      scheme: "App",
      export_method: "development",
      configuration: "Debug",
    )
  end
end
  ios-build:
    name: iOS Build
    runs-on: macos-latest
    needs: cap-sync

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build web layer
        run: npm run build

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

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

      - name: Sync Capacitor to iOS
        run: npx cap sync ios

      - name: Set up Ruby and Fastlane
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true
          working-directory: ios

      - name: Build iOS app via Fastlane
        run: cd ios && bundle exec fastlane beta
        env:
          MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_APP_ID: ${{ secrets.APPLE_APP_ID }}
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.ASC_API_KEY }}

Firebase Test Lab for Android Device Testing

Firebase Test Lab runs your APK on a matrix of real Android devices in Google's data centers. For Capacitor apps, run instrumentation tests (Espresso) or robo tests (crawling the app automatically).

  firebase-test-lab:
    name: Firebase Test Lab
    runs-on: ubuntu-latest
    needs: android-build

    steps:
      - uses: actions/checkout@v4

      - name: Download APK
        uses: actions/download-artifact@v4
        with:
          name: app-release
          path: ./apk

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          credentials_json: ${{ secrets.GCP_SA_KEY }}

      - name: Set up Cloud SDK
        uses: google-github-actions/setup-gcloud@v2

      - name: Run Robo test on Firebase Test Lab
        run: |
          gcloud firebase test android run \
            --type robo \
            --app ./apk/app-release.apk \
            --device model=Pixel6,version=33,locale=en,orientation=portrait \
            --device model=Pixel4,version=30,locale=en,orientation=portrait \
            --device model=SamsungGalaxyS21,version=31,locale=en,orientation=portrait \
            --robo-max-depth=10 \
            --timeout=5m \
            --results-bucket=gs://${{ secrets.GCS_BUCKET }}/test-results \
            --results-dir="${GITHUB_RUN_ID}" \
            --project=${{ secrets.GCP_PROJECT_ID }}

      - name: Download test results
        if: always()
        run: |
          gsutil -m cp -r \
            gs://${{ secrets.GCS_BUCKET }}/test-results/${GITHUB_RUN_ID} \
            ./firebase-results || true

      - name: Upload Firebase results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: firebase-test-results
          path: ./firebase-results

For instrumentation tests with Espresso (testing the WebView interaction layer):

gcloud firebase test android run \
  --<span class="hljs-built_in">type instrumentation \
  --app ./apk/app-debug.apk \
  --<span class="hljs-built_in">test ./apk/app-debug-androidTest.apk \
  --device model=Pixel6,version=33 \
  --environment-variables clearPackageData=<span class="hljs-literal">true \
  --<span class="hljs-built_in">timeout=10m

BrowserStack App Automate

BrowserStack App Automate integrates with Appium to run your hybrid app tests on real devices. It handles WebView context switching on actual hardware — valuable for catching rendering and interaction bugs that simulators miss.

  browserstack-tests:
    name: BrowserStack Device Tests
    runs-on: ubuntu-latest
    needs: android-build

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Download APK
        uses: actions/download-artifact@v4
        with:
          name: app-release
          path: ./apk

      - name: Upload app to BrowserStack
        id: upload_app
        run: |
          RESPONSE=$(curl -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \
            -X POST "https://api-cloud.browserstack.com/app-automate/upload" \
            -F "file=@./apk/app-release.apk")
          APP_URL=$(echo $RESPONSE | jq -r '.app_url')
          echo "app_url=${APP_URL}" >> $GITHUB_OUTPUT

      - name: Run BrowserStack Appium tests
        run: npx wdio wdio.browserstack.conf.js
        env:
          BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
          BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
          BROWSERSTACK_APP_URL: ${{ steps.upload_app.outputs.app_url }}
// wdio.browserstack.conf.js
exports.config = {
  user: process.env.BROWSERSTACK_USERNAME,
  key: process.env.BROWSERSTACK_ACCESS_KEY,
  hostname: 'hub.browserstack.com',

  capabilities: [
    {
      platformName: 'Android',
      'appium:deviceName': 'Samsung Galaxy S23',
      'appium:platformVersion': '13.0',
      'appium:app': process.env.BROWSERSTACK_APP_URL,
      'appium:automationName': 'UiAutomator2',
      'bstack:options': {
        projectName: 'MyCapacitorApp',
        buildName: `Build ${process.env.GITHUB_RUN_NUMBER}`,
        sessionName: 'Android Device Tests',
        debug: true,
        networkLogs: true,
        deviceLogs: true,
      },
    },
    {
      platformName: 'Android',
      'appium:deviceName': 'Google Pixel 7',
      'appium:platformVersion': '13.0',
      'appium:app': process.env.BROWSERSTACK_APP_URL,
      'appium:automationName': 'UiAutomator2',
      'bstack:options': {
        projectName: 'MyCapacitorApp',
        buildName: `Build ${process.env.GITHUB_RUN_NUMBER}`,
        sessionName: 'Pixel 7 Tests',
      },
    },
  ],

  framework: 'mocha',
  mochaOpts: { timeout: 120000 },
  specs: ['./e2e/**/*.test.js'],
};

Ionic Appflow as an Alternative

Ionic Appflow is a managed CI/CD service specifically for Capacitor and Ionic apps. It removes the need to maintain iOS/Android build environments in GitHub Actions.

# Trigger Appflow build via webhook from GitHub Actions
  appflow-trigger:
    name: Trigger Appflow Build
    runs-on: ubuntu-latest
    needs: [unit-tests, cypress-tests]
    if: github.ref == 'refs/heads/main'

    steps:
      - name: Trigger Appflow iOS build
        run: |
          curl -X POST \
            -H "Authorization: Bearer ${{ secrets.APPFLOW_TOKEN }}" \
            -H "Content-Type: application/json" \
            -d '{
              "ref": "${{ github.sha }}",
              "build_stack_id": "com.apple.ios-16",
              "environment_id": ${{ secrets.APPFLOW_ENV_ID }},
              "native_config_id": ${{ secrets.APPFLOW_NATIVE_CONFIG_ID }}
            }' \
            "https://api.ionicjs.com/apps/${{ secrets.APPFLOW_APP_ID }}/builds"

Appflow's advantages over self-managed GitHub Actions:

  • Managed Xcode environments — no waiting for Apple's SDK updates
  • Live Deploy for instant over-the-air web layer updates without app store review
  • Built-in code signing management
  • Simple webhook triggers that integrate with GitHub, GitLab, and Bitbucket

Caching Strategy Summary

Effective caching is the difference between a 45-minute pipeline and a 12-minute one:

# npm — always cache
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ hashFiles('**/package-lock.json') }}

# Gradle — Android builds
- uses: actions/cache@v4
  with:
    path: |
      ~/.gradle/caches
      ~/.gradle/wrapper
    key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}

# CocoaPods — iOS builds
- uses: actions/cache@v4
  with:
    path: ios/Pods
    key: pods-${{ hashFiles('ios/Podfile.lock') }}

# Bundler (Fastlane) — iOS builds
- uses: ruby/setup-ruby@v1
  with:
    bundler-cache: true
    working-directory: ios

# Cypress binary — web tests
- uses: actions/cache@v4
  with:
    path: ~/.cache/Cypress
    key: cypress-${{ hashFiles('**/package-lock.json') }}

Complete Pipeline Status Check

Protect your main branch with required status checks:

# In your repository Settings → Branches → Branch protection rules:
# Required status checks:
# - Jest Unit Tests
# - Cypress Integration Tests
# - Android Build
# - iOS Build

With this pipeline, every pull request to main runs the complete test suite and builds both native apps before merge. Device testing on Firebase Test Lab and BrowserStack runs on merge to main, giving you confidence before each release that the app works on real hardware.


HelpMeTest complements your CI/CD pipeline by running end-to-end monitoring tests against your deployed Capacitor app continuously — catching regressions that only appear in production, not in CI.

Read more