This article will guide you on how you can automate the CI/CD workflow of your Flutter’s iOS app.

If you are not familiar with the concept behind CI/CD, Github Actions, or Fastlane, you can read this article I wrote to help you get started.

Prerequisites

Before continuing reading this,

  • You should have at least tried building your Flutter app locally, and released it manually to TestFlight.
  • Make sure you have Fastlane installed on your development machine.

Workflow Setup

ci/cd

Setup Fastlane

a. Initialize Fastlane for iOS

cd ios
fastlane init

Select option number #2 (Distribution to TestFlight).

[01:00:00]: What would you like to use fastlane for?
1. 📸  Automate screenshots
2. 👩‍✈️  Automate beta distribution to TestFlight
3. 🚀  Automate App Store distribution
4. 🛠  Manual setup - manually setup your project to automate your tasks

Next, enter your Apple Developer account credentials.

[13:03:04]: Please enter your Apple ID developer credentials
[13:03:04]: Apple ID Username:
<YOUR_EMAIL>
[13:04:36]: Logging in...

Finally, select the team that manages the app.

b. Install fastlane plugin to retrieve version code from Flutter

In ios/Gemfile, add the following plugin.

...
gem "fastlane"

# Add this plugin
gem "fastlane-plugin-flutter_version", git: "https://github.com/tianhaoz95/fastlane-plugin-flutter-version"

Install the plugin.

cd ios/fastlane
fastlane install_plugins

c. Configure fastlane match

Fastlane match is a new approach to iOS’s codesigning. Fastlane match makes it easy for teams to manage the required certificates and provisioning profiles for your iOS apps.

Initialize fastlane match for your iOS app.

cd ios
fastlane match init

Create a new private repository named certificates on your Github personal account or organization, then select option #1 (Git Storage).

[01:00:00]: fastlane match supports multiple storage modes, please select the one you want to use:
1. git
2. google_cloud
3. s3
?  

Assign the URL of the newly created repository.

[01:00:00]: Please create a new, private git repository to store the certificates and profiles there
[01:00:00]: URL of the Git Repo: <YOUR_CERTIFICATES_REPO_URL>

git_url should be set to the https URL of the certificates repository. Optionally, you can also use SSH, but it requires a different steps to run.

# ios/Matchfile
git_url("https://github.com/gitusername/certificates")
storage_mode("git")
type("appstore")

Next, generate the certficates and enter your credentials when asked.

You will be prompted to enter a passphrase. Remember it correctly because it will be used later by Github Actions to decrypt your certificates repository.

cd ios
fastlane match appstore

ci/cd

Generated certificates and provisioning profiles are uploaded to the certificates repository

Lastly, open ios/Runner.xcworkspace in XCode, and update the provisioning profile for the release configuration of your app.

ci/cd

d. Configure Fastfile

In ios/fastlane/Fastfile, replace everything with the following.

default_platform(:ios)

DEVELOPER_APP_ID = ENV["DEVELOPER_APP_ID"]
DEVELOPER_APP_IDENTIFIER = ENV["DEVELOPER_APP_IDENTIFIER"]
PROVISIONING_PROFILE_SPECIFIER = ENV["PROVISIONING_PROFILE_SPECIFIER"]
TEMP_KEYCHAIN_USER = ENV["TEMP_KEYCHAIN_USER"]
TEMP_KEYCHAIN_PASSWORD = ENV["TEMP_KEYCHAIN_PASSWORD"]

def delete_temp_keychain(name)
  delete_keychain(
    name: name
  ) if File.exist? File.expand_path("~/Library/Keychains/#{name}-db")
end

def create_temp_keychain(name, password)
  create_keychain(
    name: name,
    password: password,
    unlock: false,
    timeout: false
  )
end

def ensure_temp_keychain(name, password)
  delete_temp_keychain(name)
  create_temp_keychain(name, password)
end

platform :ios do
  lane :closed_beta do
    keychain_name = TEMP_KEYCHAIN_USER
    keychain_password = TEMP_KEYCHAIN_PASSWORD
    ensure_temp_keychain(keychain_name, keychain_password)

    match(
      type: 'appstore',
      app_identifier: "#{DEVELOPER_APP_IDENTIFIER}",
      git_basic_authorization: Base64.strict_encode64(ENV["GIT_AUTHORIZATION"]),
      readonly: true,
      keychain_name: keychain_name,
      keychain_password: keychain_password 
    )

    gym(
      configuration: "Release",
      workspace: "Runner.xcworkspace",
      scheme: "Runner",
      export_method: "app-store",
      export_options: {
        provisioningProfiles: { 
            DEVELOPER_APP_ID => PROVISIONING_PROFILE_SPECIFIER
        }
      }
    )

    pilot(
      apple_id: "#{DEVELOPER_APP_ID}",
      app_identifier: "#{DEVELOPER_APP_IDENTIFIER}",
      skip_waiting_for_build_processing: true,
      skip_submission: true,
      distribute_external: false,
      notify_external_testers: false,
      ipa: "./Runner.ipa"
    )

    delete_temp_keychain(keychain_name)
  end
end

Few things to note 💡

Match

For the CI/CD to import the certificates and provisioning profiles, it needs to have access to the certificates repository. You can do this by generating a personal access token that has the scope to access or read private repositories.

In Github, go to Settings -> Developer Settings -> Personal access tokens -> click Generate New Token -> tick the repo scope -> then click Generate token.

ci/cd

match(
  ...
  git_basic_authorization: Base64.strict_encode64(ENV["GIT_AUTHORIZATION"]),
  ...
)

Have a copy of the personal access token generated. You will use it later for the environment variable GIT_AUTHORIZATION.

Keychains

Since you’ll be importing the certificates and provisioning profiles to the CI/CD’s macOS virtual machine, you need to create a keychain to store it.

def delete_temp_keychain(name)
  delete_keychain(
    name: name
  ) if File.exist? File.expand_path("~/Library/Keychains/#{name}-db")
end

def create_temp_keychain(name, password)
  create_keychain(
    name: name,
    password: password,
    unlock: false,
    timeout: false
  )
end

def ensure_temp_keychain(name, password)
  delete_temp_keychain(name)
  create_temp_keychain(name, password)
end

You might be stuck on waiting for this step if you haven’t created a keychain because there will be a prompt that requires your input to create one for the CI/CD.

I got this strategy from this gist.

Build Processing

In Github Actions, you are billed based on the minutes you have used for running your CI/CD workflow. From experience, it takes about 15-30 minutes before a build can be processed in App Store Connect.

For private projects, the estimated cost per build can go up to $0.08/min x 30 mins = $2.4, or more, depending on the configuration or dependencies of your project.

If you share the same concerns for the pricing as I do for private projects, you can set the skip_waiting_for_build_processing to false.

pilot(
  ...
  skip_waiting_for_build_processing: true,
  ..
)

What’s the catch? You have to manually update the compliance of your app in App Store Connect after the build has been processed, in order for you to distribute the build to your users.

This is just an optional parameter to update if you want to save on the build minutes for private projects. For free projects, this shouldn’t be a problem at all. See pricing.

e. Configure Appfile

In ios/fastlane/Appfile, add the following.

app_identifier(ENV["DEVELOPER_APP_IDENTIFIER"])
apple_id(ENV["FASTLANE_APPLE_ID"])

itc_team_id(ENV["APP_STORE_CONNECT_TEAM_ID"])
team_id(ENV["DEVELOPER_PORTAL_TEAM_ID"])

Setup Github Actions

a. Configure Github secrets

Ever wonder where the values of the ENV are coming from? Well, it’s not a secret anymore - it’s your project’s secret. 🤦

ci/cd

1 . APP_STORE_CONNECT_TEAM_ID - the ID of your App Store Connect team in you’re in multiple teams

2 . DEVELOPER_APP_ID - in App Store Connect, go to the app -> App Information -> Scroll down to the General Information section of your app and look for Apple ID.

3 . DEVELOPER_APP_IDENTIFIER - your app’s bundle identifier

4 . DEVELOPER_PORTAL_TEAM_ID - the ID of your Developer Portal team if you’re in multiple teams

5 . FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD - this workflow won’t require a 2FA setup, so you have to generate a new application specific password in order for Fastlane to upload your builds. More on this.

6 . FASTLANE_APPLE_ID - the Apple ID or developer email you use to manage the app

7 . GIT_AUTHORIZATION - <YOUR_GITUSERNAME>:<YOUR_PERSONAL_ACCESS_TOKEN>, eg. joshuadeguzman:mysecretkeyyoudontwanttoknow

8 . MATCH PASSWORD - the passphrase that you assigned when initializing match will be used for decrypting the certificates and provisioning profiles

9 . PROVISIONING_PROFILE_SPECIFIER - match AppStore <YOUR_APP_BUNDLE_IDENTIFIER>, eg. match AppStore io.github.joshuadeguzman.demoFlutterActions.

10 . TEMP_KEYCHAIN_USER & TEMP_KEYCHAIN_PASSWORD - assign a temp keychain user and password for your workflow

b. Configure Github workflow file

Create a Github workflow directory.

md .github/workflow

Inside the workflow folder, create a file named cd.yml, and add the following.

# cd.yml
name: CD

on:
  push:
    branches:
      - 'v*'
      
jobs:
 deploy_ios:
    name: Deploy beta build to TestFlight
    runs-on: macOS-latest
    steps:
      - name: Checkout code from ref
        uses: actions/checkout@v2
        with:
          ref: ${{ github.ref }}
      - name: Run Flutter tasks
        uses: subosito/flutter-action@v1
        with:
          flutter-version: '1.17.5'
      - run: flutter pub get
      - run: flutter build ios --release --no-codesign
      - name: Deploy iOS Beta to TestFlight via Fastlane
        uses: maierj/fastlane-action@v1.4.0
        with:
          lane: closed_beta
          subdirectory: ios
        env:
          APP_STORE_CONNECT_TEAM_ID: '${{ secrets.APP_STORE_CONNECT_TEAM_ID }}'
          DEVELOPER_APP_ID: '${{ secrets.DEVELOPER_APP_ID }}'
          DEVELOPER_APP_IDENTIFIER: '${{ secrets.DEVELOPER_APP_IDENTIFIER }}'
          DEVELOPER_PORTAL_TEAM_ID: '${{ secrets.DEVELOPER_PORTAL_TEAM_ID }}'
          FASTLANE_APPLE_ID: '${{ secrets.FASTLANE_APPLE_ID }}'
          FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: '${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}'
          MATCH_PASSWORD: '${{ secrets.MATCH_PASSWORD }}'
          GIT_AUTHORIZATION: '${{ secrets.GIT_AUTHORIZATION }}'
          PROVISIONING_PROFILE_SPECIFIER: '${{ secrets.PROVISIONING_PROFILE_SPECIFIER }}'
          TEMP_KEYCHAIN_PASSWORD: '${{ secrets.TEMP_KEYCHAIN_PASSWORD }}'
          TEMP_KEYCHAIN_USER: '${{ secrets.TEMP_KEYCHAIN_USER }}'

Few things to note 💡

Running Flutter

subosito/flutter-action is a Github Action that helps you set up your Flutter app by running commands that are required by your project, eg. building an app or running code generation tools.

An example command is flutter build --release. This creates a release build (.app) of your iOS app in preparation for archiving it for release (in Fastlane).

flutter build ios --release --no-codesign

Environment variables and secrets

In the example, the environment variables are retrieved from your project’s secrets.

Ideally, this should always be the case, but no one’s stopping you to set environment variables directly on the workflow file or in the code itself.

That’s it! Now for the moment of truth, test the workflow on Github Actions.

Test Workflow

1. Create a branch

Create a branch, eg. v1.0.2.

git checkout -b v1.0.2

2. Trigger the workflow

Push the new commits to the newly created branch to trigger the workflow.

git push origin v1.0.2

ci/cd

After a few minutes, the build should be available in your App Store Connect dashboard.

ci/cd

Awesome!

That’s it for your Flutter’s iOS app.

Take note, that the setup of jobs may vary depending on the tools or dependencies your project requires. Feel free to comment on any issues you encountered while setting up the CI/CD workflow for your project.

Resources