Mobile CI/CD · Store Delivery

    Automate TestFlight & Play Store delivery from GitHub

    Nathan Claiborne
    Getting a signed .ipa or .aab out of GitHub Actions is the easy half. The hard half is the handoff: App Store Connect API keys, Play Console service accounts, release tracks, expedited reviews, and a Fastlane config that nobody on the team wants to own. This guide walks the full automated path — from a green build to a TestFlight tester and a Play Internal Track release — and shows where Corenna removes the parts most teams get stuck on.

    What the handoff actually requires

    • iOS — TestFlight: an App Store Connect API key (.p8), key ID, issuer ID, and a signed .ipa built with a distribution certificate and an App Store provisioning profile.
    • Android — Play Store: a Google Play service-account JSON with the "Release manager" role, the app's package name listed in Play Console, and a signed .aab.
    • Both: a versioning scheme that increments on every upload — TestFlight rejects duplicate CFBundleVersions and Play rejects duplicate versionCodes.

    TestFlight upload from GitHub Actions

    The modern path is the official app-store-connect API via Fastlane's pilot or Apple's altool/notarytool. Both need the same three secrets stored as GitHub Actions secrets: APP_STORE_CONNECT_KEY_ID, APP_STORE_CONNECT_ISSUER_ID, and the base64-encoded .p8 as APP_STORE_CONNECT_KEY_CONTENT.

    - name: Upload to TestFlight
      env:
        APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
        APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
        APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }}
      run: |
        echo "$APP_STORE_CONNECT_KEY_CONTENT" | base64 -d > AuthKey.p8
        xcrun altool --upload-app -f build/App.ipa -t ios \
          --apiKey "$APP_STORE_CONNECT_KEY_ID" \
          --apiIssuer "$APP_STORE_CONNECT_ISSUER_ID"

    The painful part is not the upload — it's the certificate plumbing that produces a valid distribution .ipa in the first place. See iOS code signing explained for the full breakdown.

    Play Store upload from GitHub Actions

    The community-standard action is r0adkll/upload-google-play. It takes the service-account JSON and pushes the .aab to a named track. Internal → Closed → Production is the safe promotion path; internal is the right default for CI.

    - name: Upload to Play Console
      uses: r0adkll/upload-google-play@v1
      with:
        serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
        packageName: com.example.app
        releaseFiles: build/app-release.aab
        track: internal
        status: completed

    The most common failure here is the service account not being invited to the Play Console app. Add the service-account email under Users and permissions in Play Console before the first run.

    Version bumping that won't get rejected

    TestFlight and Play both reject re-uploads of the same version. The simplest reliable scheme is to use the GitHub Actions run number as the build number:

    # iOS
    agvtool new-version -all $GITHUB_RUN_NUMBER
    
    # Android (Gradle)
    def vCode = System.getenv("GITHUB_RUN_NUMBER")?.toInteger() ?: 1
    defaultConfig { versionCode vCode }

    Pair this with a semantic versionName / CFBundleShortVersionString tied to git tags, and you get human-readable release notes plus a strictly monotonic build number for the stores.

    Where Fastlane stops paying off

    Fastlane works, but the typical Fastfile for a mobile team grows to 400–800 lines once you add match, pilot, supply, version bumping, changelog generation, and Slack notifications. That config becomes load-bearing and quietly owned by one person. For most teams, the better trade-off is to keep the signing and store-delivery glue out of your repo entirely and treat it as managed infrastructure — which is exactly the gap Corenna fills.

    How Corenna handles the handoff

    • Encrypted keystore and Apple certificate storage, with one-click rotation.
    • Signed .aab and .ipa straight from a GitHub repo — no Gradle or Xcode locally.
    • Automatic versionCode / CFBundleVersion bumps so uploads never collide.
    • Google Play delivery built in: connect a service account once and send the signed .aab to the internal, closed, open, or production track with release notes. The .ipa is uploaded to TestFlight by you.
    • Self-host bundle export — if you outgrow the hosted flow, you keep the workflows and run the same pipeline on your own infrastructure. See security for the threat model.

    TL;DR

    TestFlight uploads need an App Store Connect API key and a distribution-signed .ipa; Play Store uploads need a service-account JSON and a signed .aab. The plumbing is well-trodden but time-consuming, and version-number collisions are the single most common cause of failed automated releases. If you don't want to own that surface area, let Corenna handle the signing and the Google Play delivery and keep your repo focused on app code.

    Keep reading