okaryo.log

Building a Workflow with GitHub Actions to Upload Flutter iOS App to Firebase App Distribution (Non-AutoSigning) | okaryo.log

Building a Workflow with GitHub Actions to Upload Flutter iOS App to Firebase App Distribution (Non-AutoSigning)

    #Flutter#Firebase#AppDistribution#GitHub#GitHubActions#CI/CD

Introduction

Recently, I added iOS support to the Flutter app I’ve been developing. To enable device installation and testing, I used Firebase’s App Distribution to distribute the app. I’ve built a workflow using GitHub Actions to automate this process, which I will document here.

By the way, here’s the Android version. It’s from two years ago, so it might be a bit outdated.

Prerequisites

This article does not cover the following topics:

  • How to create iOS app certificates
  • Firebase setup
  • GitHub Actions syntax

Also, for this iOS build, manual signing is used with certificates and provisioning profiles prepared, rather than AutoSigning. I plan to write an article about AutoSigning in the future.

The Workflow

Here is the final workflow I came up with. I’ll explain each step in detail later.

# ./.github/workflows/deploy-to-app-distribution.yml
name: Deploy to App Distribution

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Environment'
        required: true
        default: 'staging'
        type: choice
        options:
          - staging
          - production

jobs:
  staging:
    if: inputs.environment == 'staging'
    uses: ./.github/workflows/_deploy-to-app-distribution.yml
    with:
      environment: staging
      ios_bundle_id: com.example.ios.staging
      ios_provisioning_profile_name: profile-staging-adhoc
    secrets:
      FIREBASE_IOS_APP_ID: ${{ secrets.FIREBASE_IOS_APP_ID_STAGING }}
      APP_DISTRIBUTION_SERVICE_ACCOUNT_JSON: ${{ secrets.APP_DISTRIBUTION_SERVICE_ACCOUNT_JSON_STAGING }}
      IOS_CERTIFICATE_P12_BASE64: ${{ secrets.IOS_CERTIFICATE_P12_BASE64 }}
      IOS_P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }}
      IOS_ADHOC_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_ADHOC_PROVISION_PROFILE_BASE64_STAGING }}
      RUNNER_KEYCHAIN_PASSWORD: ${{ secrets.RUNNER_KEYCHAIN_PASSWORD_STAGING }}

  production:
    if: inputs.environment == 'production'
    uses: ./.github/workflows/_deploy-to-app-distribution.yml
    with:
      environment: production
      ios_bundle_id: com.example.ios
      ios_provisioning_profile_name: profile-production-adhoc
    secrets:
      FIREBASE_IOS_APP_ID: ${{ secrets.FIREBASE_IOS_APP_ID_PRODUCTION }}
      APP_DISTRIBUTION_SERVICE_ACCOUNT_JSON: ${{ secrets.APP_DISTRIBUTION_SERVICE_ACCOUNT_JSON_PRODUCTION }}
      IOS_CERTIFICATE_P12_BASE64: ${{ secrets.IOS_CERTIFICATE_P12_BASE64 }}
      IOS_P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }}
      IOS_ADHOC_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_ADHOC_PROVISION_PROFILE_BASE64_PRODUCTION }}
      RUNNER_KEYCHAIN_PASSWORD: ${{ secrets.RUNNER_KEYCHAIN_PASSWORD_PRODUCTION }}

Workflow Details

Workflow Trigger

The workflow is triggered manually using workflow_dispatch, but you can modify it to trigger upon branch merges or other events according to your needs.

Here, the destination (staging/production) can be selected, and the variables and secrets used in each environment are passed to the reusable workflow. For more details on how to handle multiple environments in a single workflow, please refer to the following:

Execution Environment

Now, let’s dive into the jobs within the reusable workflow.

The iOS build environment runs on macos-latest, which consumes 10 times more GitHub Actions credits compared to Linux environments. If a build takes 10 minutes, it will use up 100 minutes of credits. Consider reducing the number of executions or opting for a self-hosted runner to manage your credits wisely.

actions/checkout@v4

This is the usual checkout action, but here, I’ve set fetch-depth: 0. This allows all commit history to be fetched because I use the total number of commits as the build number in a later step.

Setting Up Flutter and Build Numbers

Flutter is set up, and I’m using a package called cider to manage the version and build number.

- name: Set up Flutter
  uses: subosito/flutter-action@v2
  with:
    channel: stable
    flutter-version-file: pubspec.yaml
    cache: true
- name: Set up cider
  run: flutter pub global activate cider
- name: Set up build number
  run: |
    VERSION=$(cider version)
    BUILD_NUMBER=$(git rev-list HEAD --count)
    cider version $VERSION+$BUILD_NUMBER

The build number is determined by the number of commits using git rev-list HEAD --count. You can find more details about this in the article below:

Installing the Certificate and Provisioning Profile

This step installs the Apple certificate and provisioning profile on the GitHub Actions runner.

- name: Install the Apple certificate and provisioning profile
  env:
    IOS_CERTIFICATE_P12_BASE64: ${{ secrets.IOS_CERTIFICATE_P12_BASE64 }}
    IOS_P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }}
    IOS_ADHOC_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_ADHOC_PROVISION_PROFILE_BASE64 }}
    RUNNER_KEYCHAIN_PASSWORD: ${{ secrets.RUNNER_KEYCHAIN_PASSWORD }}
  run: |
    # create variables
    CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
    PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
    KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db

    # import certificate and provisioning profile from secrets
    echo -n "$IOS_CERTIFICATE_P12_BASE64" | base64 --decode -o $CERTIFICATE_PATH
    echo -n "$IOS_ADHOC_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH

    # create temporary keychain
    security create-keychain -p "$RUNNER_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
    security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
    security unlock-keychain -p "$RUNNER_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH

    # import certificate to keychain
    security import $CERTIFICATE_PATH -P "$IOS_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
    security set-key-partition-list -S apple-tool:,apple: -k "$RUNNER_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
    security list-keychain -d user -s $KEYCHAIN_PATH

    # apply provisioning profile
    mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
    cp $PP_PATH ~/LIBRARY/MOBILEDEVICE/PROVISIONING\ PROFILES

The certificate and provisioning profile are both encoded in Base64 and stored as secrets. $RUNNER_KEYCHAIN_PASSWORD is the password used for the keychain on the runner and can be set freely.

For more details, refer to GitHub Actions’ official documentation:

Generating ExportOptions.plist

The ExportOptions.plist file contains settings for exporting iOS apps. A template ExportOptions-Template.plist is pre-created, and envsubst is used within the workflow to set up environment and distribution-specific settings.

- name: Generate ExportOptions.plist
  run: |
    export EXPORT_METHOD=release-testing
    export EXPORT_DESTINATION=export
    export BUNDLE_ID=${{ inputs.ios_bundle_id }}
    export PROVISIONING_PROFILE=${{ inputs.ios_provisioning_profile }}
    envsubst < ios/Runner/ExportOptions-Template.plist > ios/Runner/ExportOptions.plist

Here is the ExportOptions-Template.plist. You can check available settings using xcodebuild --help.

<?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>compileBitcode</key>
	<false/>
	<key>destination</key>
	<string>${EXPORT_DESTINATION}</string>
	<key>method</key>
	<string>${EXPORT_METHOD}</string>
	<key>provisioningProfiles</key>
	<dict>
		<key>${BUNDLE_ID}</key>
		<string>${PROVISIONING_PROFILE}</string>
	</dict>
	<key>signingCertificate</key>
	<string>iOS Distribution</string>
	<key>signingStyle</key>
	<string>manual</string>
	<key>stripSwiftSymbols</key>
	<true/>
	<key>teamID</key>
	<string>YOUR_TEAM_ID</string>
	<key>thinning</key>
	<string>&lt;none&gt;</string>
</dict>
</plist>

Building the IPA

Finally, we build the iOS app. Note that this command uses flutter build ipa, not flutter build ios.

- name: Build ipa
  run: |
    flutter build ipa
      --release \
      --flavor ${{ inputs.environment }} \
      --dart-define=FLAVOR=${{ inputs.environment }} \
      --export-options-plist=ios/Runner/ExportOptions.plist

Uploading the Artifact

The built IPA file is uploaded as an artifact to be passed to the next job using actions/upload-artifact. Ideally, I would upload the IPA to App Distribution within the same macOS job, but since the wzieba/Firebase-Distribution-Github-Action only supports Linux, attempting to run it directly on macOS results in the error Container action is only supported on Linux.

As for me, I have set the CFBundleName in the Info.plist so that the IPA is created with the name app-${{ environment }}-release.ipa.

- uses: actions/upload-artifact@v2
  with:
    name: app-${{ inputs.environment }}-release.ipa
    path: build/ios/ipa/app-${{ inputs.environment }}-release.ipa

Cleaning Up the Keychain and Provisioning Profile

If you’re using a self-hosted runner, the keychain and provisioning profile might persist after the workflow finishes, so it’s essential to clean them up.

- name: Clean up keychain and provisioning profile
  if: ${{ always() }}
  run: |
    security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
    rm ~/Library/MobileDevice/Provisioning\ Profiles/build_pp.mobileprovision

Uploading the IPA to Firebase App Distribution

Finally, in the last job, after the build-ios job completes, the artifact is downloaded, and the IPA is uploaded to Firebase App Distribution. As mentioned earlier, wzieba/Firebase-Distribution-Github-Action only supports Linux, so this job runs on ubuntu-latest.

distribute-ios:
  needs: build-ios
  runs-on: ubuntu-latest
  steps:
    - uses: actions/download-artifact@v2
      with:
        name: app-${{ inputs.environment }}-release.ipa
    - name: Upload artifact to Firebase App Distribution
      uses: wzieba/Firebase-Distribution-Github-Action@v1
      with:
        appId: ${{ secrets.FIREBASE_IOS_APP_ID }}
        serviceCredentialsFileContent: ${{ secrets.APP_DISTRIBUTION_SERVICE_ACCOUNT_JSON }}
        groups: ios_testers
        file: app-${{ inputs.environment }}-release.ipa

Conclusion

Although I expected iOS certificate management to be a challenge, GitHub Actions and the well-documented resources made building this workflow relatively straightforward.

That said, the macOS execution environment consumes GitHub Actions credits much faster than a Linux environment. If the build takes 10 minutes, it will use up 100 minutes of credit. To manage this, I reduced the number of workflow executions by using workflow_dispatch for manual triggers, but even with that, credits run out quickly. I’m currently considering migrating to a self-hosted runner.

This time, I used manual signing, but AutoSigning, which frees you from managing certificates and provisioning profiles, has become more mainstream recently. I’d like to switch to that soon (though I’m stuck on resolving a CFBundleIdentifier Collision error).


Related Posts
Related Posts
Promotion

This site uses Google Analytics.