DEV Community

Cover image for GitHub Actions CI/CD for Flutter Fastlane (iOS) with possible mistakes
Subash Shrestha
Subash Shrestha

Posted on

GitHub Actions CI/CD for Flutter Fastlane (iOS) with possible mistakes

The premise is:
The topic of setting up Fastlane and using GitHub actions for CI/CD has been covered in a number of articles. Nevertheless, we are making so many mistakes while doing so. Having made so many mistakes, fixing those issues took me around two full working days. In this article, I will share my mistake during CI/CD, and hopefully help them avoid it.

As part of this article, I break down the Fastlane setup and CI/CD different steps and possible errors that you may make during those steps.

  1. A developer account and iOS Appstore Connect
  2. Fastlane: Creating and storing certificates (Matchfile and Appfile Configuration)
  3. Fastlane: Keychain
  4. Fastlane: Installing certificates and Provisioning Profile
  5. Fastlane: Building the app
  6. Code Signing Setup
  7. GitHub Actions and environment secrets Setup
  8. Generating the Build and Incrementing the version code automatically
  9. Deployment of the app These are step-by-step things so make sure to follow them one by one and read the article carefully.

Okay, now take a deep breath 😮‍💨😮‍💨😮‍💨😮‍💨😮‍💨😮‍💨 and dive into the content.

Flutter iOS apps CI using fastlane and GitHub Actions

A developer account and iOS Appstore Connect
First things first, you need a developer account. There are some details you need to gather. Navigate to the link for the membership details from where you note down the team id name and team id which we will use for the app file later on.

After that navigate to the identifier list and add the identifier for your app. You can add the details (capabilities, bundle id, features ) as per your device (we can update it later so there is no chance of error).

Identifier
The whole idea of having a CI/CD is having everything automated and not having to configure it separately for different devices and users. Especially on iOS where we need to manage certificates, provisioning profiles and so on for different users, it’s always a pain.

Instead, I would create a dev account with the necessary access and share it with developers. I’ll refer to it from now on as dev@company.com.

Users and access
After that, the main thing would be getting an App Store Connect API key. After WWDC18, 2FA was enabled and we don't want to get behind. For that, you need to generate an API key with App Manager Access and download it from the keys tab and store it in a safe place. Also, note the key ID, Issuer Id from the same tab.

Keys
By now you need to have all these details to go to the next step:

  • Issuer Id
  • Key Id
  • API key file/content
  • Team ID
  • Team Name
  • App Identifier
  • Apple Username(dev@company.com)

fastlane: Creating and storing certificates (Matchfile and Appfile Configuration)

Fastlane is an open-source tool suite that automates your app’s releases and deployments. It would be a good idea to test the build and deployment processes locally before migrating to a cloud-based system.

Local setup:
While fastlane can be installed directly through ruby, I wouldn’t recommend it since it may cause conflicts with dependent packages. Bundler would be more suitable for me.

gem install bundler

Create a ./Gemfile in the root directory and you can paste the content.

source "https://rubygems.org"

gem "fastlane"
Run bundle update and add both the ./Gemfile and the ./Gemfile.lock to version control
Every time you run fastlane, use bundle exec fastlane [lane]
On your CI, add bundle install as your first build step
To update fastlane, just run bundle update fastlane
Navigate your terminal to your project’s directory and run
bundle exec fastlane init
Now fastlane folder is created with appfile inside it.

Appfile:

The Appfile stores useful information that is used across all Fastlane tools like your Apple ID or the application Bundle Identifier, to deploy your lanes faster and tailored to your project needs. The Appfile has to be inside your ./fastlane directory.

Matchfile:

Here comes the main idea of creating the common dev account (dev@company.com) where we will create the certificates, provisioning profile once, store them in (git/S3) and then teammates, the CD server will just fetch and use them.

No need to create certificates for every developer, no need to install them manually and no more code signing issues, isn’t it great?

match will simply do your complex task to create all required ios certificates and provisioning profiles and store them in a separate repository. Every team member with access to the selected storage can use those credentials for code signing. match also automatically repairs broken and expired credentials and install them in your local keychains. It’s the easiest way to share signing credentials across teams

So everything is done by match, so what’s your task🤔??

Just create a private repository in GitHub. That's all. Even you don’t need to push the code. match will do it for you😅. Copy the repo url you created and it would be your git_url.
Create a matchfile inside the fastlane directory inside your project. For that, you can run the following command inside the Fastlane directory and provide the git_url.
fastlane > fastlane match init

It will create a matchfile inside the fastlane directory. You can go to the documentation for guidance on further setup. But the basic thing would be to provide the storage_mode, username and app_identifier which we collected previously.

Matchfile

This matchfile config will create the certificates and provisioning profile for development, AppStore and ad-hoc and store them in GitHub, just run the following CLI commands. Now, you deserve a cup of coffee ☕️ as you have completed a crucial part of FastLane. So go and get your drink before starting the next step.

fastlane match development
fastlane match appstore
fastlane match development

Possible mistakes:

Matchfile might not have the access to GitHub so make sure to add your SSH to GitHub otherwise it can’t push the certificates. Link
Now we have the basic setup. Now comes the crucial part of the utilization of certificates and where to store the certificates for usage in the lane.

Fastlane: Keychain
As part of the fastlane setup, you might have issues managing the keychain for different stagings. When it comes to local, we can use our system keychain to handle all the overhead. However, when it comes to remote, we must create a keychain and remove it once the session is over.

As of now, you should have a fastfile in your fastlane directory. Fastfile can be created by pasting the given code inside.

`platform :ios do
$keychain_name = SecureRandom.uuid
$keychain_password = SecureRandom.hex(100)
$is_ci = ENV['CI']

after_all do |lane, options|
    remove_keychain
end

error do |lane, exception, options|
    remove_keychain
end

desc "Remove Keychain from CI"
private_lane :remove_keychain do |options|
    if $is_ci
        if File.exist?(File.expand_path("~/Library/Keychains/#{$keychain_name}-db"))
            UI.important "Deleting keychain #{$keychain_name}"
            delete_keychain(name: $keychain_name)
        elsif
            UI.important "No keychain file found to delete"
        end
    end
end

desc "Setup Keychain for match on CI"
private_lane :setup_keychain do |options|
    create_keychain(
        name: $keychain_name,
        password: $keychain_password,
        default_keychain: true,
        unlock: false,
        timeout: 0,
        lock_when_sleeps: true
    )
end
Enter fullscreen mode Exit fullscreen mode

end`

To create the keychain, I have created a script that will generate a random password and keychain, which we will use to create the keychain and remove afterward. Certificates and provisioning profiles are only stored for temporary use during the CI.

Also, you can add up the flavour:

`desc "Configure Flavor for Dart"
private_lane :flavor_config do |options|
if !options[:flavor]
UI.message "No Flavor provided, going with default (develop)"
set_flavor(flavor: "develop")
next
end

if options[:flavor] == "develop" || options[:flavor] == "staging" || options[:flavor] == "production"
    set_flavor(flavor: options[:flavor])
else
    UI.abort_with_message! "No supported flavor provided (#{options[:flavor]}). Supported values are 'develop', 'staging', 'production'."
end
Enter fullscreen mode Exit fullscreen mode

end

def set_flavor(flavor:)
UI.message "Setting flavor to #{flavor}"

ENV[$dart_define_env_key] = "flavor=#{flavor}"
Enter fullscreen mode Exit fullscreen mode

end`

All the fastlane actions are performed as per these flavours.

Fastlane: Installing certificates and Provisioning Profile

Here comes the main task of the fastlane which reduces the tension of the installing the certificates and provisioning profile. Do it once and forget it.

The source code provided is also separated for the CI and local environments. In the case of CI being true, the new keychain will be created, which we have already defined previously. If it’s not CI then the local keychain is used and automatically installs/updates the required certificates if you have provided all the required details that we have already discussed.

desc "Install Certificates and Provisioning Profiles"
    lane :match_sync do |options|
        login
        if $is_ci  
            UI.message "Installing Apple Certificates and Provisioning Profiles for CI"
            setup_keychain
            match(
                readonly: true,
                type: "appstore",
                keychain_name: $keychain_name,
                keychain_password: $keychain_password
            )
        elsif
            UI.message "Installing Apple Certificates and Provisioning Profiles for Local Development"
            UI.message "Installing App Store Certificate and Provisioning Profile"
            match(
                readonly: true,
                type: "appstore"

            )

            UI.message "Installing Development Certificate and Provisioning Profile"
            match(
                readonly: true,
                app_identifier: $app_identifier,
                type: "development",
            )
        end 
    end

    desc "Update Provisioning Profiles"
    lane :match_update do |options|
        if $is_ci
            UI.abort_with_message! "Only run match_update locally!"
        elsif
            UI.message "Updating Provisioning Profiles for New Devices"

            UI.message "Updating App Store Provisioning Profile"
            match(
                readonly: false,
                type: "appstore"
            )

            UI.message "Updating Development Provisioning Profile"
            match(
                readonly: false,
                type: "development"
            )
        end
    end
Enter fullscreen mode Exit fullscreen mode

All of these tasks of downloading and matching the certificates are done by match that we have already set up.

Fastlane: Building the app

After all the certificates and provisioning profiles are installed then the task of building the app remained. If everything has gone fine till now then this should not be a big issue.


desc "Build iOS App"
    lane :build do |options|
        flavor_config options
        gym(
            workspace: "ios/Runner.xcworkspace",
            xcargs: "-allowProvisioningUpdates",
        )
  end
Enter fullscreen mode Exit fullscreen mode

Gym builds and packages iOS apps for you. It takes care of all the heavy lifting and makes it super easy to generate a signed ipa or app file 💪. You just need to provide the workspace location.

Also, you can build your application using build_app.


build_app(
  scheme: "Release",
  export_method: "app-store",
  export_options: {
    provisioningProfiles: {
      "com.example.bundleid" => "Provisioning Profile Name",
      "com.example.bundleid2" => "Provisioning Profile Name 2"
    }
  }
)
Enter fullscreen mode Exit fullscreen mode

Code Signing Setup

Now you need to communicate with the AppStore connect. For that, you need the details that I asked you to get from the app store connect before.

key_id
issuer_id
key_filepath/content
 desc "Get Apple API Token"
    private_lane :login do |options|
        api_key = app_store_connect_api_key(
            key_id: "D83848D23",
            issuer_id: "227b0bbf-ada8-458c-9d62-3d8022b7d07f",
            key_filepath: "D83848D23.p8"
            duration: 1200,
            in_house: false,
            is_key_content_base64: false
        )
        UI.message "this is the api key#{api_key}"

    end

#alternatives:
app_store_connect_api_key(
  key_id: "D83848D23",
  issuer_id: "227b0bbf-ada8-458c-9d62-3d8022b7d07f",
  key_filepath: "D83848D23.p8"
)
app_store_connect_api_key(
  key_id: "D83848D23",
  issuer_id: "227b0bbf-ada8-458c-9d62-3d8022b7d07f",
  key_filepath: "D83848D23.p8",
  duration: 200,
  in_house: true
)
app_store_connect_api_key(
  key_id: "D83848D23",
  issuer_id: "227b0bbf-ada8-458c-9d62-3d8022b7d07f",
  key_content: "-----BEGIN EC PRIVATE KEY-----\nfewfawefawfe\n-----END EC PRIVATE KEY-----"
)
Enter fullscreen mode Exit fullscreen mode

I would suggest keeping the values in the environment secrets in repositories. I will describe that in the next steps. But, by now you should be able to run all the steps locally.

bundle exec fastlane ios build
This command should be able to generate the app.

GitHub Actions and environment secrets Setup

Now comes the CI part where we want to generate the app and push it to the AppStore. This is after the code is pushed to development or any other branch as per your wish. I have added an operation which will run on push and pull requests on any branch. I am sure you are familiar with the notion of creating YAML for the GitHub actions in your project. I won’t explain that process in detail. In the YAML file, you just need to paste the given code to accomplish the same thing that we did in the above steps. However, this process is for CI.

name:Application Build

on: 
  push:
  pull_request:
  workflow_dispatch:
jobs:
  build:
    name: Build Flutter Application
    runs-on: macOS-latest

    steps:
      - uses: actions/checkout@v3

      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.0'

      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.3'
          channel: 'stable'
      - run: flutter --version

      - name: Setup key
        uses: webfactory/ssh-agent@v0.7.0
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY}}

      - name: bundle install
        run: bundle install

      - name: Install dependencies
        run: dart pub get

      - name: Analyze project source
        run: dart analyze

      - name: Run tests
        run: flutter test

      - name: 📱 Build iOS Application
        run: bundle exec fastlane ios build
        env:
          MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
          MATCH_USERNAME: ${{ secrets.MATCH_USERNAME }}
          APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }}
          APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }}
          PROVISIONING_PROFILE_SPECIFIER: ${{ secrets.PROVISIONING_PROFILE_SPECIFIER }}
          CI: ${{ secrets.CI }}
Enter fullscreen mode Exit fullscreen mode

Here I am using some secrets which are stored in the environment. For that you need to go to Settings > Secrets > Actions > New Repository secret.

After that, you can provide them with the env of the actions.

If you define APP_STORE_CONNECT_API_KEY_KEY_ID in the env then you don't need to define it in fastlane as it is automatically recognized by the fastlane. But you need to define the secrets file name in fastlane


before_all do
Dotenv.overload ".env.secrets"
end

Enter fullscreen mode Exit fullscreen mode

This will recognize the environment's secrets. To use this you can simply create two files .env.secrets and .env.secrets.sample and. And never the .env.secrets to git.

Incrementing the version code

If everything is being handled by fastlane then what about the version it is also one of the problems that we face while uploading the applications.

Don't worry my friends, it is also done by the fastlane but you need to set up some things by yourself.

The first task would be to add the lane to increase the build number
increment_build_number # automatically increment by one

increment_build_number(
build_number: "75" # set a specific number
)

increment_build_number(
  build_number: 75, # specify specific build number (optional, omitting it increments by one)
  xcodeproj: "./path/to/MyApp.xcodeproj" # (optional, you must specify the path to your main Xcode project if it is not in the project root directory)
)
Enter fullscreen mode Exit fullscreen mode

Before running the lane, keep in mind to change some config in Xcode.

Info tab of the target
Keep in mind to change the bundle version and bundle version string(short).

Also, the initial current project version should be 1 and the versioning system should be Apple Generic.

If everything is as per the description then, you are all set to deploy the app to AppStore/ Testflight.

Deployment of the app

Now we can upload to TestFlight using the upload_to_testflight method. You just need to provide the location of the IPA file that you created during the build method.


desc "Upload for iOS"
    lane :upload do |options|
        upload_testflight options
        upload_firebase options
    end 

    desc "Upload to Firebase"
    private_lane :upload_firebase do |options|

    end 

    desc "Upload to Testflight"
    private_lane :upload_testflight do |options|
        login
        upload_to_testflight(
            ipa: "Runner.ipa"
            )

    end 
Enter fullscreen mode Exit fullscreen mode

Make sure to provide the current IPA location.

And FYI, upload_to_testflight uses pilot in the background so you can simply use pilot also.

Now you can simply add this lane in the GitHub actions and your file will be in the Testflight if you have done all the steps perfectly. By now, your fastfile should look like this:


fastlane_require "dotenv"

before_all do
   Dotenv.overload ".env.secrets"
end

$dart_define_env_key = "DART_DEFINE"

desc "Configure Flavor for Dart"
private_lane :flavor_config do |options|
    if !options[:flavor]
        UI.message "No Flavor provided, going with default (develop)"
        set_flavor(flavor: "develop")
        next
    end

    if options[:flavor] == "develop" || options[:flavor] == "staging" || options[:flavor] == "production"
        set_flavor(flavor: options[:flavor])
    else
        UI.abort_with_message! "No supported flavor provided (#{options[:flavor]}). Supported values are 'develop', 'staging', 'production'."
    end
end

def set_flavor(flavor:)
    UI.message "Setting flavor to #{flavor}"

    ENV[$dart_define_env_key] = "flavor=#{flavor}"
end

platform :ios do
    $keychain_name = SecureRandom.uuid
    $keychain_password = SecureRandom.hex(100)
    $is_ci = ENV['CI']

    after_all do |lane, options|
        remove_keychain
    end

    error do |lane, exception, options|
        remove_keychain
    end

    desc "Install Certificates and Provisioning Profiles"
    lane :match_sync do |options|
        login
        if $is_ci  
            UI.message "Installing Apple Certificates and Provisioning Profiles for CI"
            setup_keychain
            match(
                readonly: true,
                type: "appstore",
                keychain_name: $keychain_name,
                keychain_password: $keychain_password
            )
        elsif
            UI.message "Installing Apple Certificates and Provisioning Profiles for Local Development"

            UI.message "Installing App Store Certificate and Provisioning Profile"
            match(
                readonly: true,
                type: "appstore"

            )

            UI.message "Installing Development Certificate and Provisioning Profile"
            match(
                readonly: true,
                app_identifier: $app_identifier,
                type: "development",
            )
        end 
    end

    desc "Update Provisioning Profiles"
    lane :match_update do |options|
        if $is_ci
            UI.abort_with_message! "Only run match_update locally!"
        elsif
            UI.message "Updating Provisioning Profiles for New Devices"

            UI.message "Updating App Store Provisioning Profile"
            match(
                readonly: false,
                type: "appstore"
            )

            UI.message "Updating Development Provisioning Profile"
            match(
                readonly: false,
                type: "development"
            )
        end
    end

    desc "Build iOS App"
    lane :build do |options|
        flavor_config options
        gym(
            workspace: "ios/Runner.xcworkspace",
            xcargs: "-allowProvisioningUpdates",
        )
    end

    desc "Test iOS App"
    lane :test do |options|
        UI.abort_with_message! "No iOS tests are currently available or supported"
    end

    desc "Test flutter code"
    lane :test_flutter do |options|
        Dir.chdir("..") do
            sh "flutter test"
        end
    end

    desc "Upload for iOS"
    lane :upload do |options|
        upload_testflight options
        upload_firebase options
    end 

    desc "Upload to Firebase"
    private_lane :upload_firebase do |options|

    end 

    desc "Upload to Testflight"
    private_lane :upload_testflight do |options|
        login
        upload_to_testflight(
            ipa: "Runner.ipa"
            )

    end 

    desc "Get Apple API Token"
    private_lane :login do |options|
        api_key = app_store_connect_api_key(
            duration: 1200,
            in_house: false,
            is_key_content_base64: false
        )
        UI.message "this is the api key#{api_key}"

    end

    desc "Remove Keychain from CI"
    private_lane :remove_keychain do |options|
        if $is_ci
            if File.exist?(File.expand_path("~/Library/Keychains/#{$keychain_name}-db"))
                UI.important "Deleting keychain #{$keychain_name}"
                delete_keychain(name: $keychain_name)
            elsif
                UI.important "No keychain file found to delete"
            end
        end
    end

    desc "Setup Keychain for match on CI"
    private_lane :setup_keychain do |options|
        create_keychain(
            name: $keychain_name,
            password: $keychain_password,
            default_keychain: true,
            unlock: false,
            timeout: 0,
            lock_when_sleeps: true
        )
    end
end
Enter fullscreen mode Exit fullscreen mode

If you have come so far, you have already designed an automated CI for your Flutter iOS apps. There might be some minor issues with the tool, and dependencies that you need to fix. However, I have described most of the issues that you may encounter during the process.

Okay, that’s all for this article. I will see you at the next one. See ya.

Top comments (1)

Collapse
 
onlinemsr profile image
Raja MSR

Your breakdown of the Fastlane setup and the potential errors during CI/CD is incredibly helpful. Thank you for sharing your experiences and guiding fellow developers! 🙌

Now, here’s my question: How to setup MATCH_USERNAME and MATCH_PASSWORD initially? Do we need to use the same password in CI tools?