+1 (415) 843-4662

CI/CD for Capacitor apps with GitHub Actions + fastlane (post-Appflow)

Appflow's native builds and store deployments end on December 31, 2027. The good news is that the replacement — GitHub Actions running fastlane — is the same setup most native iOS and Android teams have used for years, it costs less, and nothing about it has a sunset date. This is the pipeline we set up for clients, trimmed to the essentials.

What the pipeline does

  1. On every pull request: build the web app, run tests, and produce an Android debug APK as a downloadable artifact (a "preview build")
  2. On merge to main: build signed iOS and Android release binaries and push them to TestFlight and the Google Play internal track
  3. On a version tag: promote the same binaries to production review

1. Signing secrets with fastlane match

Appflow stored your certificates and profiles. The equivalent is fastlane match, which keeps encrypted signing assets in a private git repository (or S3 / Google Cloud Storage) and installs them on any machine — your laptop or a CI runner — with one command.

Install fastlane and initialise match in ios/App:

cd ios/App
fastlane match init          # choose git, point it at a private repo
fastlane match appstore      # creates/fetches the distribution cert + profile

match needs an App Store Connect API key rather than an Apple ID password in CI. Create one in App Store Connect → Users and Access → Integrations, and store three values as GitHub secrets: ASC_KEY_ID, ASC_ISSUER_ID, and the .p8 contents as ASC_KEY_CONTENT. Add MATCH_PASSWORD (the passphrase match used to encrypt the repo) and a deploy key or token for the match repo (MATCH_GIT_BASIC_AUTHORIZATION).

For Android, the keystore is a file: base64-encode it and store it as ANDROID_KEYSTORE_BASE64, with ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD, and a Google Play service-account JSON as PLAY_SERVICE_ACCOUNT_JSON (created in Google Cloud, granted access in Play Console → Users and permissions).

2. Fastfile for iOS

ios/App/fastlane/Fastfile:

default_platform(:ios)

platform :ios do
  desc "Build and upload to TestFlight"
  lane :beta do
    setup_ci                                  # temporary keychain on CI runners
    app_store_connect_api_key(
      key_id: ENV["ASC_KEY_ID"],
      issuer_id: ENV["ASC_ISSUER_ID"],
      key_content: ENV["ASC_KEY_CONTENT"],
      is_key_content_base64: false
    )
    match(type: "appstore", readonly: true)
    increment_build_number(build_number: ENV["BUILD_NUMBER"])
    build_app(
      workspace: "App.xcworkspace",
      scheme: "App",
      export_method: "app-store"
    )
    upload_to_testflight(skip_waiting_for_build_processing: true)
  end
end

If Capacitor 8 created your iOS project with Swift Package Manager (its default) there is no Pods directory and App.xcworkspace still exists; if you are on CocoaPods, add cocoapods before match.

3. Fastfile for Android

android/fastlane/Fastfile:

default_platform(:android)

platform :android do
  desc "Build AAB and upload to the internal track"
  lane :internal do
    gradle(
      task: "bundle",
      build_type: "Release",
      properties: {
        "android.injected.signing.store.file" => ENV["KEYSTORE_PATH"],
        "android.injected.signing.store.password" => ENV["ANDROID_KEYSTORE_PASSWORD"],
        "android.injected.signing.key.alias" => ENV["ANDROID_KEY_ALIAS"],
        "android.injected.signing.key.password" => ENV["ANDROID_KEY_PASSWORD"],
      }
    )
    upload_to_play_store(
      track: "internal",
      json_key: ENV["PLAY_JSON_PATH"],
      aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH],
      skip_upload_metadata: true,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )
  end
end

4. The GitHub Actions workflow

.github/workflows/mobile.yml:

name: mobile
on:
  pull_request:
  push:
    branches: [main]
    tags: ['v*']

env:
  NODE_VERSION: 22

jobs:
  web:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: ${{ env.NODE_VERSION }}, cache: npm }
      - run: npm ci
      - run: npm test -- --run
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with: { name: www, path: www }

  android:
    needs: web
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: ${{ env.NODE_VERSION }}, cache: npm }
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: 21 }
      - uses: actions/download-artifact@v4
        with: { name: www, path: www }
      - run: npm ci
      - run: npx cap sync android
      # PR: unsigned debug APK as a preview build
      - if: github.event_name == 'pull_request'
        run: cd android && ./gradlew assembleDebug
      - if: github.event_name == 'pull_request'
        uses: actions/upload-artifact@v4
        with: { name: preview-apk, path: android/app/build/outputs/apk/debug/*.apk }
      # main: signed AAB to the internal track
      - if: github.ref == 'refs/heads/main'
        run: |
          echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > $RUNNER_TEMP/release.keystore
          echo "$PLAY_SERVICE_ACCOUNT_JSON" > $RUNNER_TEMP/play.json
          cd android && bundle install && bundle exec fastlane internal
        env:
          ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
          KEYSTORE_PATH: ${{ runner.temp }}/release.keystore
          ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
          ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
          ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
          PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
          PLAY_JSON_PATH: ${{ runner.temp }}/play.json

  ios:
    needs: web
    if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
    runs-on: macos-15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: ${{ env.NODE_VERSION }}, cache: npm }
      - uses: actions/download-artifact@v4
        with: { name: www, path: www }
      - run: npm ci
      - run: npx cap sync ios
      - run: cd ios/App && bundle install && bundle exec fastlane beta
        env:
          ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
          BUILD_NUMBER: ${{ github.run_number }}

A Gemfile in each native folder pinning fastlane makes bundle exec reproducible; commit Gemfile.lock.

Note the web build happens once and is shared by both native jobs — the same www directory goes into both binaries, which is what you want when a bug report says "Android only".

5. PR preview builds

The preview-apk artifact above is enough for most teams: QA downloads the APK from the workflow run and sideloads it. For iOS previews you need signed ad-hoc builds; a match(type: "adhoc") lane plus a distribution service (or TestFlight's external testers) covers it, but keep that on main rather than every PR — macOS runner minutes are the expensive part of this pipeline.

6. Promotion on tags

When v1.8.0 is tagged, the iOS job above runs again and TestFlight has the build. Promotion to App Store review and the Play production track is a deliberate, human step in our client pipelines: a release lane that calls deliver/upload_to_app_store with submit_for_review: true and upload_to_play_store(track: "production"), triggered manually with workflow_dispatch. Automating the build is the win; automating the submit is where teams get surprised by a rejected binary at 5pm on a Friday.

Operational notes

  • Pin Xcode on the macOS runner with maxim-lobanov/setup-xcode once your project depends on a specific version; Capacitor 8 needs Xcode 26
  • Cache ~/.gradle and node_modules; do not cache ios/App/build
  • Put the version number in one place (package.json) and propagate it to Info.plist and build.gradle in the lanes, so the three never disagree
  • Rotate the App Store Connect key and the Play service account on the same calendar as your other production credentials

Codemagic and Bitrise both run essentially the same Fastfiles with less YAML and pre-configured macOS images, and are a good choice if nobody on the team wants to own runner configuration. The fastlane part — the part that actually replaces Appflow — is identical.

If you want this stood up and handed over with documentation, it is the third step of our Appflow migration service.