Building a Workflow with GitHub Actions to Upload Flutter iOS App to Firebase App Distribution (Non-AutoSigning)
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><none></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).