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 / AppflowSteps 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: 7Step 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: 1Step 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.apkStep 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-resultsFor 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=10mBrowserStack 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 BuildWith 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.