Post

CI/CD Practical Guide (3): Implementing iOS App CI and CD Workflows Using GitHub Actions

A complete tutorial on automating the build, test, and deployment of iOS Apps using GitHub Actions

CI/CD Practical Guide (3): Implementing iOS App CI and CD Workflows Using GitHub Actions

ℹ️ℹ️ℹ️ The following content is translated by OpenAI.

Click here to view the original Chinese version. | 點此查看本文中文版


CI/CD Practical Guide (3): Implementing iOS App CI and CD Workflows Using GitHub Actions

A complete tutorial on automating the build, test, and deployment of iOS Apps using GitHub Actions

Photo by [Robs](https://unsplash.com/@robinne?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash){:target="_blank"}

Photo by Robs

Introduction

In the previous article “CI/CD Practical Guide (2): Comprehensive Guide to Using GitHub Actions and Self-hosted Runners”, we introduced the basics of GitHub Actions, its workflow, and how to use your own machine as a Runner. We implemented three simple automation Actions together. This article will focus on the practical use of GitHub Actions to build an iOS App CI/CD workflow, guiding you step by step while supplementing your knowledge of GitHub Actions.

App CI/CD Workflow Diagram

This article will focus on the CI/CD section built with GitHub Actions. The next article “CI/CD Practical Guide (4): Using Google Apps Script Web App to Connect GitHub Actions for a Free and Easy Packaging Tool Platform” will introduce the right half of the diagram, which involves using Google Apps Script Web App to build a cross-team collaboration packaging platform.

Workflow Process:

  1. GitHub Actions triggers via Pull Request, form submission, or scheduled event
  2. Executes corresponding Workflow Jobs/Steps
  3. Steps execute corresponding Fastlane (iOS) or (Android Gradle) scripts
  4. Fastlane executes corresponding xcodebuild (iOS) commands
  5. Obtains execution results
  6. Subsequent Workflow Jobs/Steps process results
  7. Completion

GitHub Actions Results

Let’s start with the final results to give you some motivation for implementation!

[CI Testing](https://github.com/ZhgChgLi/github-actions-ci-cd-demo/pull/11){:target="_blank"}

CI Testing

[CI Nightly Build, CD Deploy](https://github.com/ZhgChgLi/github-actions-ci-cd-demo/actions/runs/16119750747){:target="_blank"}

CI Nightly Build, CD Deploy

Basic Knowledge of GitHub Actions x Self-hosted Runner

If you are not familiar with GitHub Actions and setting up a Self-hosted Runner, I strongly recommend reading the previous article “CI/CD Practical Guide (2): Comprehensive Guide to Using GitHub Actions and Self-hosted Runners” or implementing it alongside the knowledge from the previous article.

Let’s get started with the implementation!

Infrastructure Architecture of the iOS Demo Project

The iOS project content used in this article includes test items generated by AI, so there is no need to focus on the iOS programming details; we will only discuss the Infra & CI/CD aspects.

The following tools are based on past experience; if it’s a new project, consider using the updated mise and tuist.

Mint

The Mint tool helps us manage the versions of dependency tools uniformly (Gemfile can only manage Ruby Gems), such as XCodeGen, SwiftFormat, SwiftLint, Periphery, etc.

Mintfile:

1
2
3
yonaskolb/Mint@0.17.5
yonaskolb/XcodeGen@2.35.0
nicklockwood/SwiftFormat@0.51.13

Here we only use three.

If you find it too complicated, you can skip it and directly install the required tools using brew install in the Action Workflow Step.

Bundle

Gemfile:

1
2
3
4
5
6
source 'https://rubygems.org'
gem 'cocoapods', '~>1.16.0'
gem 'fastlane', '~>2.228.0'

plugins_path = File.join(File.dirname(__FILE__), 'Product', 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)

This manages Ruby (Gems) related dependencies. The two most commonly used in iOS projects are cocoapods and fastlane.

Cocoapods

Product/podfile:

1
2
3
4
5
6
platform :ios, '13.0'
use_frameworks!

target 'app-ci-cd-github-actions-demo' do
  pod 'SnapKit'
end 

Although it has been declared to be deprecated, Cocoapods is still very common in older iOS projects. Here we simply add SnapKit as a demo.

XCodeGen

To avoid conflicts caused by changes to .xcodeproj / .xcworkspace during multi-person development, we use a Project.yaml to define the XCode Project content and generate the project files locally (not pushed to Git).

Product/project.yaml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
name: app-ci-cd-github-actions-demo
options:
  bundleIdPrefix: com.example
  deploymentTarget:
    iOS: '13.0'
  usesTabs: false
  indentWidth: 2
  tabWidth: 2

configs:
  Debug: debug
  Release: release

targets:
  app-ci-cd-github-actions-demo:
    type: application
    platform: iOS
    sources:
      - app-ci-cd-github-actions-demo
    resources:
      - app-ci-cd-github-actions-demo/Assets.xcassets
      - app-ci-cd-github-actions-demo/Base.lproj
    info:
      path: app-ci-cd-github-actions-demo/Info.plist
      properties:
        CFBundleIdentifier: $(PRODUCT_BUNDLE_IDENTIFIER)
    settings:
      base:
        PRODUCT_BUNDLE_IDENTIFIER: com.test.appcicdgithubactionsdemo
    cocoapods: true

  app-ci-cd-github-actions-demoTests:
    type: bundle.unit-test
    platform: iOS
    sources:
      - app-ci-cd-github-actions-demoTests
    dependencies:
      - target: app-ci-cd-github-actions-demo
    info:
      path: app-ci-cd-github-actions-demoTests/Info.plist
    settings:
      base:
        PRODUCT_BUNDLE_IDENTIFIER: com.test.appcicdgithubactionsdemo.tests

  app-ci-cd-github-actions-demoUITests:
    type: bundle.ui-testing
    platform: iOS
    sources:
      - app-ci-cd-github-actions-demoUITests
    dependencies:
      - target: app-ci-cd-github-actions-demo
    info:
      path: app-ci-cd-github-actions-demoUITests/Info.plist
    settings:
      base:
        PRODUCT_BUNDLE_IDENTIFIER: com.test.appcicdgithubactionsdemo.uitests

  app-ci-cd-github-actions-demoSnapshotTests:
    type: bundle.unit-test
    platform: iOS
    sources:
      - path: app-ci-cd-github-actions-demoSnapshotTests
        excludes:
          - "**/__Snapshots__/**"
    dependencies:
      - target: app-ci-cd-github-actions-demo
      - product: SnapshotTesting
        package: SnapshotTesting
    info:
      path: app-ci-cd-github-actions-demoSnapshotTests/Info.plist
      settings:
        base:
          PRODUCT_BUNDLE_IDENTIFIER: com.test.appcicdgithubactionsdemo.snapshottests

packages:
  SnapshotTesting:
    url: https://github.com/pointfreeco/swift-snapshot-testing
    from: 1.18.4

SnapshotTesting: Managed using Swift Package Manager.

Fastlane

Encapsulates the xcodebuild command, as well as the complex steps for connecting to App Store Connect API, Firebase API, and other services.

Product/fastlane/Fastfile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
default_platform(:ios)

platform :ios do
  desc "Run all tests (Unit Tests + UI Tests)"
  lane :run_all_tests do |options|
    device = options[:device]
    scan(
      scheme: "app-ci-cd-github-actions-demo",
      device: device,
      clean: true,
      output_directory: "fastlane/test_output",
      output_types: "junit"
    )
  end

  desc "Run only Unit Tests"
  lane :run_unit_tests do |options|
    device = options[:device]
    scan(
      scheme: "app-ci-cd-github-actions-demo",
      device: device,
      clean: true,
      only_testing: [
        "app-ci-cd-github-actions-demoTests"
      ],
      output_directory: "fastlane/test_output",
      output_types: "junit"
    )
  end

  desc "Build and upload to Firebase App Distribution"
  lane :beta do |options|
    
    if options[:version_number] && options[:version_number].to_s.strip != ""
      increment_version_number(version_number: options[:version_number])
    end

    if options[:build_number] && options[:build_number].to_s.strip != ""
      increment_build_number(build_number: options[:build_number])
    end

    update_code_signing_settings(
      use_automatic_signing: false,
      path: "app-ci-cd-github-actions-demo.xcodeproj",
      team_id: ENV['TEAM_ID'],
      code_sign_identity: "iPhone Developer",
      sdk: "iphoneos*",
      profile_name: "cicd"
    )

    gym(
      scheme: "app-ci-cd-github-actions-demo",
      clean: true,
      export_method: "development",
      output_directory: "fastlane/build",
      output_name: "app-ci-cd-github-actions-demo.ipa",
      export_options: {
          provisioningProfiles: {
            "com.test.appcicdgithubactionsdemo" => "cicd",
          },
      }
    )

    firebase_app_distribution(
      app: "1:127683058219:ios:98896929fa131c7a80686e",
      firebase_cli_token: ENV["FIREBASE_CLI_TOKEN"],
      release_notes: options[:release_notes] || "New beta build"
    )
  end
end

Note: The provisioningProfiles and profile_name correspond to the Profiles certificate names in App Developer. (If using match, these specifications are not necessary.)

Fastlane is an indispensable part of iOS CI/CD, allowing you to quickly develop the actual execution steps of CI/CD using its encapsulated methods; we only need to focus on the overall script design without dealing with complex API connections or command writing.

For example: Fastlane only requires writing “scan(xxx)” to execute tests, whereas writing it as xcodebuild would require “ xcodebuild -workspace ./xxx.xcworkspace -scheme xxx -derivedDataPath xxx ‘platform=iOS Simulator,id=xxx’ clean build test”, and packaging and deployment would be much more troublesome, requiring manual connections to App Store Connect/Firebase API, with key verification needing over 10 lines of code.

In the demo project, we only have three lanes:

  • run_all_tests: Run all types of tests (Snapshot + Unit)
  • run_unit_tests: Run only unit tests (Unit)
  • beta: Package and deploy to Firebase App Distribution

Fastlane — Match

Due to limitations in the demo project, Match is not used to manage team development and deployment certificates here, but it is still worth mentioning. It is recommended to use Match to manage all development and deployment certificates for the team, making it easier to control and update uniformly.

Using Match allows you to directly use commands like match all during project setup to install all the necessary certificates for development in one go.

  • Fastlane Match will use another Private Repo to manage certificate keys, and in GitHub Actions, you need to set up the SSH Agent to clone another Private Repo. (Please refer to the supplementary section at the end.)

— — —

Makefile

Makefile

Makefile

This allows both the development side and CI/CD to use the Makefile to execute commands, making it easier to encapsulate the same environment, paths, and operations.

A classic case is that some people use the locally installed pod install, while others use the Bundler-managed bundle exec pod install. If the versions differ, it may lead to discrepancies.

If you find it too complicated, you can skip it and directly write the commands to be executed in the Action Workflow Step.

Makefile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#!make
PRODUCT_FOLDER = ./Product/
SHELL         := /bin/zsh
.DEFAULT_GOAL := install
MINT_DIRECTORY := ./mint/

export MINT_PATH=$(MINT_DIRECTORY)

## 👇 Help function
.PHONY: help
help:
 @echo ""
 @echo "📖 Available commands:"
 @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
  awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-20s\033[0m %s\n", $$1, $$2}'
 @echo ""

## Setup
.PHONY: setup
setup: check-mint ## Install Ruby and Mint dependencies
 @echo "🔨 Installing Ruby dependencies..."
 bundle config set path 'vendor/bundle'
 bundle install
 @echo "🔨 Installing Mint dependencies..."
 mint bootstrap

## Install
.PHONY: install
install: XcodeGen PodInstall ## Execute XcodeGen and CocoaPods installation

.PHONY: XcodeGen
XcodeGen: check-mint ## Generate .xcodeproj using XcodeGen
 @echo "🔨 Execute XcodeGen"
 cd $(PRODUCT_FOLDER) && \
 mint run yonaskolb/XcodeGen --quiet

.PHONY: PodInstall
PodInstall: ## Install CocoaPods dependencies
 @echo "📦 Installing CocoaPods dependencies..."
 cd $(PRODUCT_FOLDER) && \
 bundle exec pod install

### Mint
check-mint: check-brew ## Check if Mint is installed; if not, install it automatically
 @if ! command -v mint &> /dev/null; then \
  echo "🔨 Installing mint..."; \
  brew install mint; \
 fi

### Brew
check-brew: ## Check if Homebrew is installed; if not, install it automatically
 @if ! command -v brew &> /dev/null; then \
  echo "🔨 Installing Homebrew..."; \
  /bin/bash -c "$$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"; \
 fi

## Format only git swift files
.PHONY: format
format: check-mint ## Format all Swift files under Product/
 mint run swiftformat $(PRODUCT_FOLDER)

To avoid polluting the entire system or other projects, we try to specify the paths of dependency packages (e.g., mint, bundle, etc.) within the project directory (and use .gitignore to exclude them).

1
2
3
4
5
6
7
8
9
10
11
12
13
├── mint (Mint dependencies)
   └── packages
├── Product
   ├── app-ci-cd-github-actions-demo
   ├── app-ci-cd-github-actions-demo.xcodeproj
   ├── app-ci-cd-github-actions-demo.xcworkspace
   ├── app-ci-cd-github-actions-demoSnapshotTests
   ├── app-ci-cd-github-actions-demoTests
   ├── app-ci-cd-github-actions-demoUITests
   ├── fastlane
   └── Pods (Cocoapods dependencies)
└── vendor (Bundle dependencies)
    └── bundle

make help

make help

Using Makefile for unified project setup steps:

  1. git clone repo
  2. cd ./repo
  3. make setup Install necessary tool dependencies ( brew, mint, bundle, xcodegen, swiftformat,…)
  4. make install Generate the project (execute pod install, xcodegen)
  5. Done
  6. Open and run the project

Whether for CI/CD or onboarding new team members, follow the above steps to set up the project.

GitHub Actions CI/CD Case Studies in This Article

This article will introduce three GitHub Actions CI/CD workflow case studies. You can refer to the steps to build a CI/CD that fits your team’s workflow.

  1. CI — Execute unit tests upon Pull Request
  2. CD — Package + deploy to Firebase App Distribution
  3. CI + CD — Nightly Build executing snapshot + unit tests + packaging + deploying to Firebase App Distribution

Due to demo limitations, this article will only connect to package and deploy to Firebase App Distribution. The steps for packaging to TestFlight or the App Store are the same, with the only difference being the scripts in Fastlane; feel free to explore.

CI — Execute Unit Tests Upon Pull Request

Process

The Develop branch cannot be pushed directly; a Pull Request must be made to update it. All Pull Requests must pass Review and unit tests before they can be merged, and any new Commit Push will trigger retesting.

CI-Testing.yml

Repo → Actions → New workflow → set up a workflow yourself.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# Workflow (Action) name
name: CI-Testing

# Title name in Actions Log
run-name: "[CI-Testing] ${{ github.event.pull_request.title || github.ref }}"

# If there are new Jobs in the same Concurrency Group, the running ones will be canceled
# For example, if a task triggered by a Push Commit has not executed yet and another Push Commit occurs, the previous task will be canceled
concurrency:
  group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true
# Trigger Events
on:
  # PR Events
  pull_request:
    # PR - When opened, reopened, or when a new Push Commit occurs
    types: [opened, synchronize, reopened]
  # Manual form trigger
  workflow_dispatch:
    # Form Inputs fields
    inputs:
      # Test Fastlane Lane to execute
      TEST_LANE:
        description: 'Test Lane'
        default: 'run_unit_tests'
        type: choice
        options:
          - run_unit_tests
          - run_all_tests
  # Other Workflows can trigger this Workflow
  # Nightly Build will call this
  workflow_call:
    # Form Inputs fields
    inputs:
      # Test Fastlane Lane to execute
      TEST_LANE:
        description: 'Test Lane'
        default: 'run_unit_tests'
        # workflow_call inputs do not support choice
        type: string
      BRANCH:
        description: 'Branch'
        type: string
  
# Job Items
# Jobs will run concurrently
jobs:
  # Job ID
  testing:
    # Job name (optional, but it's better for readability in logs)
    name: Testing
    
    # Runner Label - Use GitHub Hosted Runner macos-15 to execute the job
    # Note: Since this project is a Public Repo, it can be used for free indefinitely
    # Note: Since this project is a Public Repo, it can be used for free indefinitely
    # Note: Since this project is a Public Repo, it can be used for free indefinitely
    # If it's a Private Repo, charges apply based on usage, and macOS machines are the most expensive (10 times), which could reach the 2,000 minutes free limit after running 10 times
    # It is recommended to use a self-hosted Runner
    runs-on: macos-15

    # Set the maximum timeout duration to prevent endless waiting in case of anomalies
    timeout-minutes: 30

    # use zsh
    # Optional, I just prefer using zsh; the default is bash
    defaults:
      run:
        shell: zsh {0}
          
    # Job Steps
    # Steps will be executed in order  
    steps:
      # git clone the current project & checkout to the executing branch
      - name: Checkout repository
        uses: actions/checkout@v3
        with:
          # Git Large File Storage, not needed for our testing environment
          # default: false
          lfs: false
          
          # If specified, checkout the specified branch; otherwise, use the default (current branch)
          # Since the on: schedule event can only run on the main branch, if we want to do Nightly Build or similar tasks, we need to specify the branch
          # e.g. on: schedule -> main branch, Nightly Build master branch
          ref: ${{ github.event.inputs.BRANCH || '' }}

      # ========== Env Setup Steps ==========
      
      # Read the specified XCode version for the project
      # In the following steps, we manually specify the XCode_x.x.x.app to use
      # instead of using xcversion, as xcversion has been sunset and is unstable. 
      - name: Read .xcode-version
        id: read_xcode_version
        run: |
          XCODE_VERSION=$(cat .xcode-version)
          echo "XCODE_VERSION: ${XCODE_VERSION}"
          echo "xcode_version=${XCODE_VERSION}" >> $GITHUB_OUTPUT

          # You can also specify the global XCode version here, so you don't need to specify DEVELOPER_DIR in subsequent steps
          # However, this command requires sudoer privileges, so if it's a self-hosted runner, ensure the runner environment has sudo privileges
          # sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer"

      # Read the specified Ruby version for the project
      - name: Read .ruby-version
        id: read_ruby_version
        run: |
          RUBY_VERSION=$(cat .ruby-version)
          echo "RUBY_VERSION: ${RUBY_VERSION}"
          echo "ruby_version=${RUBY_VERSION}" >> $GITHUB_OUTPUT

      # Install or set the Runner Ruby version to the specified version
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: "${{ steps.read_ruby_version.outputs.ruby_version }}"

      # Optional, because previously when running multiple runners on self-hosted CI/CD, cocoapods repos are in a shared directory
      # The problem solved is: there is a very small chance of conflicts when pulling cocoapods repos during simultaneous pod installs (since the default is $HOME/.cocoapods/)
      # GitHub Hosted Runner does not require this setting
      # - name: Change Cocoapods Repos Folder
      #   if: contains(runner.labels, 'self-hosted')
      #   run: |
      #     # Each Runner uses its own .cocoapods folder to prevent resource conflicts
      #     mkdir -p "$HOME/.cocoapods-${{ env.RUNNER_NAME }}/"
      #     export CP_HOME_DIR="$HOME/.cocoapods-${{ env.RUNNER_NAME }}"
      #     rm -f "$HOME/.cocoapods-${{ env.RUNNER_NAME }}/repos/cocoapods/.git/index.lock"

      # ========== Cache Setting Steps ==========
      # Note that even for self-hosted, Cache is currently Cloud Cache and will count towards usage
      # Rules: Automatically deleted after 7 days of no hits, single Cache limit is 10 GB, Cache only on successful Action
      # Public Repo: Free and unlimited
      # Private Repo: Starting from 5 GB
      # Self-hosted can write Cache & Restore strategies using shell scripts or use other tools for assistance
      
      # Bundle Cache (Gemfile)
      # Corresponds to the installation path specified in the Makefile, ./vendor
      - name: Cache Bundle
        uses: actions/cache@v3
        with:
          path: |
            ./vendor
          key: ${{ runner.os }}-bundle-${{ hashFiles('Gemfile.lock') }}
          restore-keys: |
            ${{ runner.os }}-bundle-

      # CocoaPods Cache (Podfile)
      # Default is in the project/Pods directory
      - name: Cache CocoaPods
        uses: actions/cache@v3
        with:
          path: |
            ./Product/Pods
          key: ${{ runner.os }}-cocoapods-${{ hashFiles('Product/Podfile.lock') }}
          restore-keys: |
            ${{ runner.os }}-cocoapods-

      # Mint cache
      # Corresponds to the installation path specified in the Makefile, ./mint
      - name: Cache Mint
        uses: actions/cache@v3
        with:
          path: ./mint
          key: ${{ runner.os }}-mint-${{ hashFiles('Mintfile') }}
          restore-keys: |
            ${{ runner.os }}-mint-

      # ====================

      # Project Setup & Dependency Installation
      - name: Setup & Install Dependency
        run: |
          # Execute the Setup command encapsulated in the Makefile, corresponding to commands like:
          # brew install mint
          # bundle config set path 'vendor/bundle'
          # bundle install
          # mint bootstrap
          # ...
          # Other setup commands
          make setup

          # Execute the Install command encapsulated in the Makefile, corresponding to commands like:
          # mint run yonaskolb/XcodeGen --quiet
          # bundle exec pod install
          # ...
          # Other install commands
          make install

      # Execute Fastlane Unit Test Lane
      - name: Run Tests
        id: testing
        # Specify the working directory, so subsequent commands don't need to cd ./Product/
        working-directory: ./Product/
        env:
          # Test plan, run all or just unit tests
          # If triggered by a PR, use run_unit_tests; otherwise, check the value of inputs.TEST_LANE, default is run_all_tests
          TEST_LANE: ${{ github.event_name == 'pull_request' && 'run_unit_tests' || github.event.inputs.TEST_LANE || 'run_all_tests' }}
          
          # Specify the XCode_x.x.x version to use for this Job
          DEVELOPER_DIR: "/Applications/Xcode_${{ steps.read_xcode_version.outputs.xcode_version }}.app/Contents/Developer"
          
          # Repo -> Settings -> Actions secrets and variables -> variables
          # Name of the simulator being used
          SIMULATOR_NAME: ${{ vars.SIMULATOR_NAME }}
          # iOS version of the simulator
          SIMULATOR_IOS_VERSION: ${{ vars.SIMULATOR_IOS_VERSION }}

          # Current Runner name
          RUNNER_NAME: ${{ runner.name }}
          
          # Increase XCodebuild command timeout duration and retry count
          # Because when the machine is under heavy load, it may fail after 3 attempts
          FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 60
          FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 10
        run: |

          # If self-hosted and running multiple Runners on the same machine, there may be issues with simulator contention (discussed later)
          # To avoid this problem, it is recommended to name the simulator after the Runner name, so each Runner has its own simulator, preventing conflicts that could lead to test failures
          # e.g. bundle exec fastlane run_unit_tests device:"${RUNNER_NAME} (${SIMULATOR_IOS_VERSION})"
          # Here we are using GitHub Hosted Runner, so there is no such issue, and we can directly use device:"${SIMULATOR_NAME} (${SIMULATOR_IOS_VERSION})"

          # If an error occurs, do not exit immediately and write all output to temp/testing_output.txt
          # We will analyze the file content later to distinguish between Build Failed and Test Failed, commenting different messages to the PR
          set +e
          
          # EXIT_CODE stores the exit code of the execution.
          # 0 = OK
          # 1 = exit
          EXIT_CODE=0
          
          # Write all output to a file
          bundle exec fastlane ${TEST_LANE} device:"${SIMULATOR_NAME} (${SIMULATOR_IOS_VERSION})" | tee "$RUNNER_TEMP/testing_output.txt"
          # If the current EXIT_CODE is 0, assign ${pipestatus[1]} to EXIT_CODE
          [[ $EXIT_CODE -eq 0 ]] && EXIT_CODE=${pipestatus[1]}

          # Restore error handling
          set -e

          # Check Testing Output
          # If Testing Output contains "Error building", set is_build_error=true for Actions environment variable, indicating Build failure
          # If Testing Output contains "Tests have failed", set is_test_error=true for Actions environment variable, indicating Test failure
          
          if grep -q "Error building" "$RUNNER_TEMP/testing_output.txt"; then
            echo "is_build_error=true" >> $GITHUB_OUTPUT
            echo "❌ Detected Build Error"
          elif grep -q "Tests have failed" "$RUNNER_TEMP/testing_output.txt"; then
            echo "is_test_error=true" >> $GITHUB_OUTPUT
            echo "❌ Detected Test Error"
          fi

          # Restore Exit Code Output
          exit $EXIT_CODE
          
      # ========== Handle Result Steps ==========
      
      # Parse *.junit test reports, and mark results, Comment (if it's a PR)
      - name: Publish Test Report
        # Directly reuse a pre-written .junit Parser Actions: https://github.com/mikepenz/action-junit-report
        uses: mikepenz/action-junit-report@v5
        # if:
        # Previous step (Testing) success or
        # Previous step (Testing) failed and is_test_error (do not execute this step if build failed)
        if: ${{ (failure() && steps.testing.outputs.is_test_error == 'true') || success() }}
        with:
          check_name: "Testing Report"
          comment: true
          updateComment: false
          require_tests: true
          detailed_summary: true
          report_paths: "./Product/fastlane/test_output/*.junit"

      # Comment on Build Failure
      - name: Build Failure Comment
        # if:
        # Previous step (Testing) failed and is_build_error and has PR Number
        # 
        if: ${{ failure() && steps.testing.outputs.is_build_error == 'true' && github.event.pull_request.number }}
        uses: actions/github-script@v6
        env:
          action_url: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}"
        with:
            script: |
              const action_url = process.env.action_url
              const pullRequest = context.payload.pull_request || {}
              const commitSha = pullRequest.head?.sha || context.sha
              const creator = pullRequest.user?.login || context.actor
        
              const commentBody = [
                `# Project or Test Build Failed ❌`,
                `Please check if your Pull Request can compile and run tests correctly.`,
                ``,
                `🔗 **Action**: [View Workflow Run](${action_url})`,
                `📝 **Commit**: ${commitSha}`,
                `👤 **Author**: @${creator}`
              ].join('\n')
        
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.payload.pull_request.number,
                body: commentBody
              })

Technical Highlights:

  • runs-on: It is recommended to use a self-hosted Runner, GitHub Hosted Runner macOS is very expensive.
  • Manually read the .xcode-version file to obtain the specified XCode version and set the DEVELOPER_DIR env in steps that require specifying XCode, allowing easy switching without Sudo.
  • Cache: Can speed up dependency installation, but note that even for self-hosted Runners, GitHub Cloud Cache is still used and subject to billing limits.
  • Using set +e allows commands to fail without exiting immediately + outputs everything to a file + reads the file to determine if it was Build Failed or Test Failed; otherwise, the message will uniformly indicate Test Failed. It can also be extended to check for other errors, such as: Underlying Error: Unable to boot the Simulator. indicating simulator startup failure, please try again.
  • Checkout Code can accept specified branches: since the on: schedule event can only trigger on the main (Default Branch), if we want to schedule operations on another branch, we need to specify the branch.
  • Specifying the .cocoapods Repo path is optional; previously, there were issues with two Runners on the same self-hosted machine getting stuck during pod install due to both operating on the .cocoapods Repo, causing a git lock. (But the probability is very low.)
  • If you have a Private Pods Repo, you need to set up SSH Agent to have permission to clone. (Please refer to the additional notes at the end.)
  • Remember to add to Repo -> Settings -> Actions secrets and variables -> variables: SIMULATOR_IOS_VERSION for the simulator iOS version SIMULATOR_NAME for the simulator name

Commit the file to the Repo main branch and manually trigger it once to verify correctness:

Once confirmed, continue with the setup.

GitHub Workflow Settings

Repo → Settings → Rules → Rulesets.

Ruleset Configuration
  • Ruleset Name: Ruleset Name
  • Enforcement status: Enable/Disable this rule restriction
  • Target branches: Target Base branches; setting Default Branch means all branches intended to merge into main or develop are subject to this rule restriction.
  • Bypass list: Specify special identities or Teams that can bypass this restriction.
  • Branch rules:

  • Restrict deletions: Prohibit branch deletion
  • Require a pull request before merging: Can only merge through PR
  • Required approvals: Limit the number of approvals needed
  • Require status checks to pass: Restrict which checks must pass before merging Click + Add checks, enter Testing, and select the one with the GitHub Actions logo. There is a small issue here; if Suggestions cannot find Testing, you need to return to Actions and trigger it (try opening a PR) successfully once for it to appear here.

  • Block force pushes: Prohibit force pushes

After saving and confirming that the Enforcement status is Active, the rules will take effect.

Once everything is set up, open a PR to test it out:

  • Checks show CI-Testing (Required), Merging is blocked, and At least X approving review is required by reviewers with write access. This indicates successful setup.

If the project build fails (Build Failed), it will comment:

If the project build succeeds but test cases fail (Test Failed), it will comment:

If the project build and tests succeed (Test Success), it will comment:

After completing the Review Approve + Check tests successfully:

[Demo PR](https://github.com/ZhgChgLi/github-actions-ci-cd-demo/pull/11){:target="_blank"}

Demo PR

You can merge the PR.

  • If a new commit is pushed, it will automatically rerun the checks.

Complete code: CI-Testing.yml

Auto-merge:

Additionally, you can enable the following in Repo Settings → General → Pull Request:

  • Automatically delete head branches: Automatically delete branches after merging PR
  • Allow Auto-merge: When checks pass + required approvals are met, the PR will automatically merge. The Enable auto-merge button will only appear when conditions are set and the current conditions cannot merge.

CD — Package + Deploy to Firebase App Distribution

Process

Use GitHub Actions form to trigger the packaging job, specifying version number and Release Notes. After packaging, it will automatically upload to Firebase App Distribution for the team to download and test.

CD-Deploy.yml

Repo → Actions → New workflow → set up a workflow yourself.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
# Workflow(Action) name
name: CD-Deploy

# Title name in Actions Log
run-name: "[CD-Deploy] ${{ github.ref }}"

# If there is a new Job in the same Concurrency Group, it will cancel the running one
# For example, triggering the same branch's packaging task repeatedly will cancel the previous task
concurrency:
  group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
  cancel-in-progress: true

# Trigger events
on:
  # Manual form trigger
  workflow_dispatch:
    # Form Inputs fields
    inputs:
      # App version number
      VERSION_NUMBER:
        description: 'Version Number of the app (e.g., 1.0.0). Auto-detect from the Xcode project if left blank.'
        required: false
        type: string
      # App Build Number
      BUILD_NUMBER:
        description: 'Build number of the app (e.g., 1). Will use a timestamp if left blank.'
        required: false
        type: string
      # App Release Note
      RELEASE_NOTE:
        description: 'Release notes of the deployment.'
        required: false
        type: string
  # Other Workflows calling this Workflow to trigger
  # Nightly Build will call this
  workflow_call:
    inputs:
      # App version number
      VERSION_NUMBER:
        description: 'Version Number of the app (e.g., 1.0.0). Auto-detect from the Xcode project if left blank.'
        required: false
        type: string
      # App Build Number
      BUILD_NUMBER:
        description: 'Build number of the app (e.g., 1). Will use a timestamp if left blank.'
        required: false
        type: string
      # App Release Note
      RELEASE_NOTE:
        description: 'Release notes of the deployment.'
        required: false
        type: string
      BRANCH:
        description: 'Branch'
        type: string

# Define global static variables
env:
  APP_STORE_CONNECT_API_KEY_FILE_NAME: "app_store_connect_api_key.json"

# Job tasks
# Jobs will run concurrently
jobs:
  # Job ID
  deploy:
    # Job name (optional, setting it improves readability in logs)
    name: Deploy - Firebase App Distribution
    
    # Runner Label - Use GitHub Hosted Runner macos-15 to execute the job
    # Note: Since this project is a Public Repo, it can be used infinitely for free
    # Note: Since this project is a Public Repo, it can be used infinitely for free
    # Note: Since this project is a Public Repo, it can be used infinitely for free
    # If it's a Private Repo, it will be charged based on usage, and macOS machines are the most expensive (10 times), possibly reaching the 2,000 minutes free limit after running 10 times
    # It is recommended to use a self-hosted Runner
    runs-on: macos-15

    # Set the maximum timeout duration to prevent endless waiting in case of anomalies
    timeout-minutes: 30

    # use zsh
    # Optional, just a personal preference; the default is bash
    defaults:
      run:
        shell: zsh {0}

    # Job steps
    # Steps will execute in order  
    steps:
      # git clone the current project & checkout to the executing branch
      - name: Checkout repository
        uses: actions/checkout@v3
        with:
          # Git Large File Storage, not needed for our testing environment
          # default: false
          lfs: false
          
          # If specified, checkout the specified branch; otherwise, use the default (current branch)
          # Since the on: schedule event can only run on the main branch, if you want to do Nightly Build or similar tasks, you need to specify the branch
          # e.g. on: schedule -> main branch, Nightly Build master branch
          ref: ${{ github.event.inputs.BRANCH || '' }}

      # ========== Certificates Steps ==========
      
      # It is recommended to use Fastlane - Match to manage development certificates and directly execute match installation in the Lane
      # Match will manage certificates using another Private Repo, but you need to set up SSH Agent to have permission to git clone the private repo
      # ref: https://stackoverflow.com/questions/57612428/cloning-private-github-repository-within-organisation-in-actions
      #
      #
      # --- Below is the method to directly download & import certificates for the Runner without using Fastlane - Match ---
      # ref: https://docs.github.com/en/actions/how-tos/use-cases-and-examples/deploying/installing-an-apple-certificate-on-macos-runners-for-xcode-development
      #
      # GitHub Actions Secrets cannot store files, so all certificate files must first be converted to Base64 Encoded text format and stored in Secrets
      # In the GitHub Actions Step, read them dynamically, write them to TEMP files, and move them to the correct location for the system to read and use
      # For other configuration details, please refer to the article
      #
      - name: Install the Apple certificate and provisioning profile
        env:
          BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
          P12_PASSWORD: ${{ secrets.BUILD_CERTIFICATE_P12_PASSWORD }}
          BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }}
          # GitHub Hosted Runner is a custom string
          # Self-hosted Runner is the machine login password
          KEYCHAIN_PASSWORD: ${{ secrets.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 "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
          echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH

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

          # import certificate to keychain
          security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          security set-key-partition-list -S apple-tool:,apple: -k "$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

      # App Store Connect API Fastlane JSON Key
      # Another almost essential App Store Connect API Fastlane JSON Key (.json) in the packaging environment
      # format: .json content format: https://docs.fastlane.tools/app-store-connect-api/
      # Contains the App Store Connect API .p8 Key
      # Will be passed to Fastlane later for uploading to Testflight and App Store API usage
      #
      # GitHub Actions Secrets cannot store files, so all certificate files must first be converted to Base64 Encoded text format and stored in Secrets
      # In the GitHub Actions Step, read them dynamically, write them to TEMP files for other steps to reference
      # For other configuration details, please refer to the article
      - name: Read and Write Apple Store Connect API Key to Temp
        env:
          APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}
          APP_STORE_CONNECT_API_KEY_PATH: "${{ runner.temp }}/${{ env.APP_STORE_CONNECT_API_KEY_FILE_NAME }}"
        run: |
          # import certificate and provisioning profile from secrets
          echo -n "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 --decode -o $APP_STORE_CONNECT_API_KEY_PATH

      # ========== Env Setup Steps ==========
      
      # Read the specified XCode version for the project
      # In the following steps, we will manually specify the XCode_x.x.x.app we use
      # instead of using xcversion, as xcversion has been sunset and is unstable. 
      - name: Read .xcode-version
        id: read_xcode_version
        run: |
          XCODE_VERSION=$(cat .xcode-version)
          echo "XCODE_VERSION: ${XCODE_VERSION}"
          echo "xcode_version=${XCODE_VERSION}" >> $GITHUB_OUTPUT

          # You can also specify the global XCode version here, so you don't need to specify DEVELOPER_DIR in subsequent steps
          # But this command requires sudoer privileges; if it's a self-hosted runner, ensure the runner environment has sudo privileges
          # sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer"

      # Read the specified Ruby version for the project
      - name: Read .ruby-version
        id: read_ruby_version
        run: |
          RUBY_VERSION=$(cat .ruby-version)
          echo "RUBY_VERSION: ${RUBY_VERSION}"
          echo "ruby_version=${RUBY_VERSION}" >> $GITHUB_OUTPUT

      # Install or set the Runner Ruby version to the specified project version
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: "${{ steps.read_ruby_version.outputs.ruby_version }}"

      # Optional, because previously when running multiple self-hosted runners for CI/CD, the cocoapods repos are shared directories
      # The problem solved is: there is a very small chance of conflicts when pulling cocoapods repos during simultaneous pod installs (because the default is $HOME/.cocoapods/)
      # GitHub Hosted Runner does not need this setting
      # - name: Change Cocoapods Repos Folder
      #   if: contains(runner.labels, 'self-hosted')
      #   run: |
      #     # Each Runner uses its own .cocoapods folder to prevent resource conflicts
      #     mkdir -p "$HOME/.cocoapods-${{ env.RUNNER_NAME }}/"
      #     export CP_HOME_DIR="$HOME/.cocoapods-${{ env.RUNNER_NAME }}"
      #     rm -f "$HOME/.cocoapods-${{ env.RUNNER_NAME }}/repos/cocoapods/.git/index.lock"

      # ========== Cache Setting Steps ==========
      # Note that even if self-hosted, Cache is currently also Cloud Cache and will count towards usage
      # Rules: Automatically delete if not hit for 7 days, single Cache limit is 10 GB, Cache only on successful Action
      # Public Repo: Free and unlimited
      # Private Repo: Starting from 5 GB
      # Self-hosted can write Cache & Restore strategies using shell scripts or use other tools for assistance
      
      # Bundle Cache (Gemfile)
      # Corresponds to the installation path specified in Makefile under ./vendor
      - name: Cache Bundle
        uses: actions/cache@v3
        with:
          path: |
            ./vendor
          key: ${{ runner.os }}-bundle-${{ hashFiles('Gemfile.lock') }}
          restore-keys: |
            ${{ runner.os }}-bundle-

      # CocoaPods Cache (Podfile)
      # Default is under project/Pods
      - name: Cache CocoaPods
        uses: actions/cache@v3
        with:
          path: |
            ./Product/Pods
          key: ${{ runner.os }}-cocoapods-${{ hashFiles('Product/Podfile.lock') }}
          restore-keys: |
            ${{ runner.os }}-cocoapods-

      # Mint cache
      # Corresponds to the installation path specified in Makefile under ./mint
      - name: Cache Mint
        uses: actions/cache@v3
        with:
          path: ./mint
          key: ${{ runner.os }}-mint-${{ hashFiles('Mintfile') }}
          restore-keys: |
            ${{ runner.os }}-mint-

      # ====================

      # Project Setup & Dependency Installation
      - name: Setup & Install Dependency
        run: |
          # Execute the Setup command encapsulated in Makefile, corresponding to commands like:
          # brew install mint
          # bundle config set path 'vendor/bundle'
          # bundle install
          # mint bootstrap
          # ...
          # etc. setup commands
          make setup

          # Execute the Install command encapsulated in Makefile, corresponding to commands like:
          # mint run yonaskolb/XcodeGen --quiet
          # bundle exec pod install
          # ...
          # etc. install commands
          make install

      - name: Deploy Beta
        id: deploy
        # Specify the working directory, so subsequent commands do not need to cd ./Product/
        working-directory: ./Product/
        env:
          # Packaging input parameters
          VERSION_NUMBER: ${{ inputs.VERSION_NUMBER || '' }}
          BUILD_NUMBER: ${{ inputs.BUILD_NUMBER || '' }}
          RELEASE_NOTE: ${{ inputs.RELEASE_NOTE || '' }}
          AUTHOR: ${{ github.actor }}

          # Repo -> Settings -> Actions secrets and variables -> secrets
          # Firebase CLI Token secret (refer to the article for obtaining)
          FIREBASE_CLI_TOKEN: ${{ secrets.FIREBASE_CLI_TOKEN }}
          # Apple Developer Program Team ID
          TEAM_ID: ${{ secrets.TEAM_ID }}
                    
          # Specify that this Job should use the specified XCode_x.x.x version
          DEVELOPER_DIR: "/Applications/Xcode_${{ steps.read_xcode_version.outputs.xcode_version }}.app/Contents/Developer"
        run: |
          # Get the current Timestamp
          BUILD_TIMESTAMP=$(date +'%Y%m%d%H%M%S')
          # If BUILD_NUMBER is not set, use Timestamp as App Build Number
          BUILD_NUMBER="${BUILD_NUMBER:-$BUILD_TIMESTAMP}"
          
          ID="${{ github.run_id }}"
          COMMIT_SHA="${{ github.sha }}"
          BRANCH_NAME="${{ github.ref_name }}"
          AUTHOR="${{ env.AUTHOR }}"
          
          # Combine Release Note
          RELEASE_NOTE="${{ env.RELEASE_NOTE }}
          ID: ${ID}
          Commit SHA: ${COMMIT_SHA}
          Branch: ${BRANCH_NAME}
          Author: ${AUTHOR}
          "
          
          # Execute Fastlane packaging & deployment Lane
          bundle exec fastlane beta release_notes:"${RELEASE_NOTE}" version_number:"${VERSION_NUMBER}" build_number:"${BUILD_NUMBER}"
          
          # Recommended self-hosted security settings for GitHub Actions:
          # ref: https://docs.github.com/en/actions/how-tos/use-cases-and-examples/deploying/installing-an-apple-certificate-on-macos-runners-for-xcode-development#required-clean-up-on-self-hosted-runners
          # Corresponding Step: Install the Apple certificate and provisioning profile
          # Purpose is to delete the downloaded key certificate from the machine
          # If you are using Match, you need to rewrite it to Match's Clean
          - name: Clean up keychain and provisioning profile
            if: ${{ always() && contains(runner.labels, 'self-hosted') }}
            run: |
              security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
              rm ~/Library/MobileDevice/Provisioning\ Profiles/build_pp.mobileprovision
  • Remember to add a TEAM_ID variable in Repo -> Settings -> Actions secrets and variables -> secrets, with the content being the Apple Developer Team ID string.

Commit files to the Repo main branch and test the packaging feature:

Please note that if other branches want to use this Action, you need to merge the CD-Deploy.yml file from the main branch first.

Wait for the task to complete:

[Demo](https://github.com/ZhgChgLi/github-actions-ci-cd-demo/actions/runs/16114046420){:target="_blank"}

Demo

Packaging + Deployment Successful ✅

Complete code: CD-Deploy.yml

Technical Details — Obtaining & Setting Firebase CLI Token

Follow the steps in the Firebase official documentation:

First, install the Firebase CLI tool:

1
curl -sL https://firebase.tools | bash

Run:

1
firebase login:ci

Complete the login and authorization:

Return to the Terminal and copy the Firebase CLI Token:

Go to Repo → Settings → Secrets and variables → Actions → Add a Secret: FIREBASE_CLI_TOKEN and paste the Firebase CLI Token.

This Token = your login identity Please keep it safe; if the account is no longer in use, it also needs to be changed.

Technical Details — Install the Apple certificate and provisioning profile

Supplementary details on importing development certificates into the Runner.

Since GitHub Actions Secrets cannot store files, all certificate files must first be converted to Base64 Encoded text format and stored in Secrets. The GitHub Actions Step will then dynamically read them and write them to TEMP files, moving them to the correct location for the system to read and use.

Packaging Development requires two key certificates:

cicd.mobileprovision

cicd.mobileprovision

development.cer

development.cer

The Certificate downloaded from Apple Developer is in .cer format, while we need it in .p12 format. You can double-click the downloaded .cer to install it in Keychain, then right-click the certificate in Keychain and select Export.

File name: cicd.p12, Format: .p12

P12 key password: Enter a secure custom string (the example is a bad demonstration, using 123456)

Now both files: cicd.p12 and cicd.mobileprovision are ready.

Convert to BASE64 format string and store in Repo Secrets:

1
base64 -i cicd.mobileprovision | pbcopy

Go to Repo → Settings → Secrets and variables → Actions → Add a Secret: BUILD_PROVISION_PROFILE_BASE64 and paste the above content.

-

1
base64 -i cicd.p12 | pbcopy

Go to Repo → Settings → Secrets and variables → Actions → Add a Secret: BUILD_CERTIFICATE_BASE64 and paste the above content.

- Go to Repo → Settings → Secrets and variables → Actions → Add a Secret: P12_PASSWORD with the password you set when exporting the P12 key.

- Go to Repo → Settings → Secrets and variables → Actions → Add a Secret: KEYCHAIN_PASSWORD: If it is a GitHub Hosted Runner, enter any arbitrary string. If it is a Self-hosted Runner, it should be the login password of the macOS Runner user.

Technical Details — App Store Connect API Key

Fastlane packaging and deployment to the App Store, Testflight requires providing a .json key. Similarly, due to GitHub Actions Secrets being limited to storing strings and not files, we also need to convert the key content into a Base64 string. The GitHub Actions Step will then dynamically read it and write it to a TEMP file, providing the file path for Fastlane to reference.

First, go to App Store Connect to create & download the App Store Connect API Key ( .p8):

-----BEGIN PRIVATE KEY-----
sss
axzzvcxz
zxzvzcxv
vzxcvzxvczxcvz
-----END PRIVATE KEY-----

Create a new app_store_connect_api.json file ( content reference ):

1
2
3
4
5
6
7
{
  "key_id": "Key ID written on App Store Connect",
  "issuer_id": "Issuer ID written on App Store Connect",
  "key": "-----BEGIN PRIVATE KEY----- remember to replace line breaks with\n-----END PRIVATE KEY-----",
  "duration": 1200, # optional (maximum 1200)
  "in_house": false # optional but may be required if using match/sigh
}

After saving the file, run:

1
base64 -i app_store_connect_api.json | pbcopy

Paste the string content into Repo → Settings → Secrets and variables → Actions → Add a Secret: APP_STORE_CONNECT_API_KEY_BASE64 and paste the above content.

After the Read and Write Apple Store Connect API Key to Temp Step is completed, in subsequent Steps, just pass in the env APP_STORE_CONNECT_API_KEY_PATH:

1
2
3
4
5
- name: Deploy
  env:
    APP_STORE_CONNECT_API_KEY_PATH: "${{ runner.temp }}/${{ env.APP_STORE_CONNECT_API_KEY_FILE_NAME }}"
  run: |
    ....

Fastlane will automatically retrieve and use it.

Technical Extension — Reuse Action Workflow to Separate Packaging and Deployment Actions

In this case, we directly use Fastlane’s beta Lane to execute both packaging and deployment actions.

In practical cases, we may need to deploy the same package result to different platforms (Firebase, Testflight, etc.), so a better approach is to have packaging as one Action and deployment as another Action; otherwise, it will run packaging twice, which also aligns better with CI/CD’s division of responsibilities.

The following is an example introduction:

CI-Build.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
name: Build

on:
  push:
    branches:
      - main
  workflow_call:
     inputs:
        RELEASE_NOTE:
          description: 'Release notes of the deployment.'
          required: false
          type: string

jobs:
  build:
    runs-on: macos-latest

    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Install Dependencies
        run: |
          make setup
          make install

      - name: Build Project
        run: bundle exec fastlane build

      - name: Upload Build Artifact
        uses: actions/upload-artifact@v4
        with:
          name: build-artifact
          path: ./fastlane/build/

CD-Deploy-Firebase.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
name: Deploy Firebase

on:
  # Automatically triggered when Build Action completes
  workflow_run:
    workflows: ["Build"]
    types:
      - completed

jobs:
  deploy:
    runs-on: ubuntu-latest
    # Only execute deployment if completed and successful
    if: ${{ github.event.workflow_run.conclusion == 'success' }}

    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Install Dependencies
        run: |
          make setup

      - name: Download Build Artifact
        uses: actions/download-artifact@v4
        with:
          name: build-artifact
          path: ./fastlane/build/

      - name: Deploy to Production
        run: |
          bundle exec fastlane deploy-firebase

CD-Deploy-Testflight.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
name: Deploy Testflight

on:
  # Automatically triggered when Build Action completes
  workflow_run:
    workflows: ["Build"]
    types:
      - completed

jobs:
  deploy:
    runs-on: ubuntu-latest
    # Only execute deployment if completed and successful
    if: ${{ github.event.workflow_run.conclusion == 'success' }}

    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Install Dependencies
        run: |
          make setup

      - name: Download Build Artifact
        uses: actions/download-artifact@v4
        with:
          name: build-artifact
          path: ./fastlane/build/

      - name: Deploy to Production
        run: |
          bundle exec fastlane deploy-testflight

You can also use Reusing Workflow:

CD-Deploy-Firebase.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
name: Deploy Firebase

on:
  # Any trigger condition, here using manual form trigger as an example
  workflow_dispatch:
    inputs:
      RELEASE_NOTE:
        description: 'Release notes of the deployment.'
        required: false
        type: string
jobs:
  build:
    needs: Build
    uses: ./.github/workflows/CD-Build.yml
    secrets: inherit
    with:
      RELEASE_NOTE: ${{ inputs.RELEASE_NOTE }}

  deploy:
    runs-on: ubuntu-latest
    # Job defaults to concurrent execution; use needs to wait for build to complete before executing
    needs: [build]
    # Only deploy if successful
    if: ${{ always() && needs.deploy.result == 'success' }}
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Install Dependencies
        run: |
          make setup

      - name: Download Build Artifact
        uses: actions/download-artifact@v4
        with:
          name: build-artifact
          path: ./fastlane/build/

      - name: Deploy to Production
        run: |
          bundle exec fastlane deploy-firebase

GitHub Actions — Artifact

Similar to Cache, even for Self-hosted Runner, the Artifact feature still operates through GitHub Cloud and is subject to usage limits ( free account starts at 500MB ).

To achieve similar effects with a Self-hosted Runner, you can create a shared host directory or find other tools as alternatives.

Currently, I only use Artifact to store small data, such as error results from Snapshot Tests, test reports, etc.

CI— Nightly Build Executing Snapshot + Unit Tests + Packaging + CD Deployment to Firebase App Distribution

Process

Every day at 3 AM, all tests (unit + snapshot tests) are automatically run against the main (develop or master) branch. If they fail, a failure notification is sent to the Slack workgroup; if successful, a version is packaged and deployed to Firebase App Distribution, with success/failure notifications sent to Slack.

CI-Nightly-Build-And-Deploy.yml

Repo → Actions → New workflow → set up a workflow yourself.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# Workflow(Action) name
name: CI-Nightly Build And Deploy

# Title name in Actions Log
run-name: "[CI-Nightly Build And Deploy] ${{ github.ref }}"

# Trigger events
on:
  # Scheduled to run automatically
  # https://crontab.guru/
  # UTC time
  schedule:
    # UTC 19:00 = 03:00 daily UTC+8
    - cron: '0 19 * * *'
  # Manual trigger
  workflow_dispatch:

# Job tasks
# Jobs will run concurrently
jobs:
  # Testing job
  testing:
    # Reuse Workflow (workflow_call)
    uses: ./.github/workflows/CI-Testing.yml
    # Pass all Secrets to CD-Testing.yml
    secrets: inherit
    with:
      # Execute all tests
      TEST_LANE: "run_all_tests"
      # Target branch: main, develop or master...etc
      BRANCH: "main"

  deploy-env:
    runs-on: ubuntu-latest
    outputs:
      DATE_STRING: ${{ steps.get_date.outputs.DATE_STRING }}
    steps:
      - name: Get Date String
        id: get_date
        run: |
          VERSION_DATE=$(date -u '+%Y%m%d')
          echo "${VERSION_DATE}"
          echo "DATE_STRING=${VERSION_DATE}" >> $GITHUB_ENV
          echo "DATE_STRING=${VERSION_DATE}" >> $GITHUB_OUTPUT
    
  deploy:
    # Job defaults to concurrent execution; use needs to wait for testing and deploy-env to complete before executing
    needs: [testing, deploy-env]
    # Only execute if tests are successful
    if: ${{ needs.testing.result == 'success' }}
    # Reuse Workflow (workflow_call)
    uses: ./.github/workflows/CD-Deploy.yml
    # Pass all Secrets to CD-Deploy.yml
    secrets: inherit
    with:
      VERSION_NUMBER: NightlyBuild-${{ needs.deploy-env.outputs.DATE_STRING }}
      RELEASE_NOTE: NightlyBuild-${{ needs.deploy-env.outputs.DATE_STRING }}
      # Target branch: main, develop or master...etc
      BRANCH: "main"

# ----- Slack Notify -----
  testing-failed-slack-notify:
    needs: [testing]
    runs-on: ubuntu-latest
    if: ${{ needs.testing.result == 'failure' }}
    steps:
      - name: Post text to a Slack channel
        uses: slackapi/slack-github-action@v2.1.0
        with:
          method: chat.postMessage
          token: ${{ secrets.SLACK_BOT_TOKEN }}
          payload: |
            channel: ${{ vars.SLACK_TEAM_CHANNEL_ID }}
            text: ":x: Nightly Build - Testing failed\nWorkflow: <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>"

  deploy-failed-slack-notify:
    needs: [deploy]
    runs-on: ubuntu-latest
    if: ${{ needs.deploy.result == 'failure' }}
    steps:
      - name: Post text to a Slack channel
        uses: slackapi/slack-github-action@v2.1.0
        with:
          method: chat.postMessage
          token: ${{ secrets.SLACK_BOT_TOKEN }}
          payload: |
            channel: ${{ vars.SLACK_TEAM_CHANNEL_ID }}
            text: ":x: Nightly Build Deploy failed\nWorkflow: <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>"

  deploy-success-slack-notify:
    needs: [deploy]
    runs-on: ubuntu-latest
    if: ${{ needs.deploy.result == 'success' }}
    steps:
      - name: Post text to a Slack channel
        uses: slackapi/slack-github-action@v2.1.0
        with:
          method: chat.postMessage
          token: ${{ secrets.SLACK_BOT_TOKEN }}
          payload: |
            channel: ${{ vars.SLACK_TEAM_CHANNEL_ID }}
            text: ":white_check_mark: Nightly Build Deploy successful\nWorkflow: <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>"

Commit files to the Repo main branch and manually trigger the test and packaging feature to see the results:

It will be automatically triggered daily in the future.

[Demo](https://github.com/ZhgChgLi/github-actions-ci-cd-demo/actions/runs/16119750747){:target="_blank"}

Demo

After waiting for the test tasks, packaging and deployment tasks, and notification tasks to complete, check the results.

We can directly install the Nightly Build version on our mobile phones for an early experience test.

Technical Details

This Action reuses the previously designed CI-Testing and CD-Deploy to create our Nightly Build, which is very flexible and easy to use!

Complete Code: CI-Nightly-Build-And-Deploy.yml

Self-hosted Runner Considerations

This article uses a Public Repo, so we directly use GitHub Hosted macOS Runner. However, in actual work, our Repo will definitely be Private, and using GitHub Hosted Runner is very expensive (you could buy a Mac Mini for the price of a month’s usage and run it comfortably in the office), and each machine can run multiple Runners concurrently based on performance.

For details, refer to the previous articleBuilding and Switching to Self-hosted Runner “ section After installing XCode and the basic environment on your local computer, register and enable the Runner, and change runs-on in the Action Workflow YAML to [self-hosted].

The issue of multiple Runners on the same computer has mostly been resolved in the Actions above, such as changing all shared directories to local directories. There is also a problem that may arise during testing, which is the simulator conflict issue: “When two test Jobs are run by two Runners on the same machine simultaneously, if they specify the same simulator, they will interfere with each other, causing the tests to fail.

The solution is simple: set a separate simulator for each Runner.

Simulator Configuration for Multiple Runners on the Same Machine

Assuming I have two Runners on the same computer receiving tasks in parallel:

  • ZhgChgLideMacBook-Pro-Runner-A
  • ZhgChgLideMacBook-Pro-Runner-B

In the XCode simulator settings, we need to add two simulators:

  • Model and iOS version should match the testing environment.

Change the testing steps in CI-Testing.yml to:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Run Fastlane Unit Test Lane
      - name: Run Tests
        id: testing
        # Specify the working directory, so subsequent commands don't need to cd ./Product/
        working-directory: ./Product/
        env:
          # ...
          # Repo -> Settings -> Actions secrets and variables -> variables
          # iOS version of the simulator
          SIMULATOR_IOS_VERSION: ${{ vars.SIMULATOR_IOS_VERSION }}

          # Current Runner name
          RUNNER_NAME: ${{ runner.name }}
          
          # ...
        run: |

          # ...
          bundle exec fastlane ${TEST_LANE} device:"${RUNNER_NAME} (${SIMULATOR_IOS_VERSION})" | tee "$RUNNER_TEMP/testing_output.txt"
          # ...
  • Change device to ${RUNNER_NAME} (${SIMULATOR_IOS_VERSION})
  • SIMULATOR_IOS_VERSION should still refer to the Repo variables.

The combined result will be (using 18.4 as an example):

  • Runner: ZhgChgLideMacBook-Pro-Runner-A Simulator: ZhgChgLideMacBook-Pro-Runner-A(18.4)
  • Runner: ZhgChgLideMacBook-Pro-Runner-B Simulator: ZhgChgLideMacBook-Pro-Runner-B(18.4)

This way, when both Runners are executing tests simultaneously, they will each run their own simulators.

Complete Project Repo

Additional SSH Agent Configuration — For Fastlane Match or Private CocoaPods Repo

When using Fastlane Match or Private CocoaPods Repo, since it is in another Private Repo, the current Repo/Action environment cannot directly git clone. You need to set up the SSH agent environment to have permission to operate during Action execution.

Step 1. Generate SSH Key

1
ssh-keygen -t ed25519 -C "zhgchgli@gmail.com"

Enter file in which to save the key (/Users/zhgchgli/.ssh/id_ed25519): /Users/zhgchgli/Downloads/zhgchgli

  • Enter the download path for easy copying of the content.

Enter passphrase for “/Users/zhgchgli/Downloads/zhgchgli” (empty for no passphrase):

  • Leave it blank: CI/CD usage cannot input Passphrase interactively in CLI, so please leave it blank.
  • Generation complete (with .pub/private_key).

Step 2. Set Deploy Key in Private Repo

github-actions-ci-cd-demo-certificates Repo

github-actions-ci-cd-demo-certificates Repo

Settings → Security → Deploy keys → Add deploy key.

  • Title: Enter the key name.
  • Key: Paste the .pub key content.

Done.

Step 3. Set SSH Private Key to Secrets in Action’s Repo

github-actions-ci-cd-demo Repo

github-actions-ci-cd-demo Repo

Settings → Secrets and variables → Actions → New repository secret.

  • Name: Enter the secret variable name SSH_PRIVATE_KEY.
  • Secret: Paste the private_key content.

Done.

Step 4. SSH Agent Configuration Complete, Verify Git Clone Private Repo Permissions

Demo-Git-Clone-Private-Repo.yml :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
name: Demo Git Clone Private Repo

on:
  workflow_dispatch:

jobs:
  clone-private-repo:
    name: Git Clone Private Repo
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      # 🔐 Enable SSH Agent and add private key
      - name: Setup SSH Agent
        uses: webfactory/ssh-agent@v0.9.0
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

      # 🛡️ Add github.com to known_hosts to avoid host verification errors
      - name: Add GitHub to known_hosts
        run: |
          mkdir -p ~/.ssh
          ssh-keyscan github.com >> ~/.ssh/known_hosts
      # 📦 Use SSH to clone private repo and verify
      - name: Clone and Verify Private Repo
        run: |
          git clone git@github.com:ZhgChgLi/github-actions-ci-cd-demo-certificates.git ./fakeMatch/
          if [ -d "./fakeMatch/.git" ]; then
            echo "✅ Repo cloned successfully into ./fakeMatch/"
            cd ./fakeMatch
            echo "📌 Current commit: $(git rev-parse --short HEAD)"
          else
            echo "❌ Clone failed. SSH Agent may not be configured properly."
            exit 1
          fi

You can use the above Action to verify if the setup was successful.

Success, subsequent fastlane match or pod install for private pods should execute correctly.

Summary

This article documents the complete iOS CI/CD process using GitHub Actions. The next article will optimize the user experience (engineers/PMs/designers) by enhancing Slack notifications and using Google Apps Script Web App to connect GitHub Actions to create a free and easy-to-use cross-team packaging platform tool.

Series Articles:

Buy me a coffee

This series of articles has taken a lot of time and effort to write. If the content has helped you or significantly improved your team’s work efficiency and product quality, feel free to buy me a coffee as a token of appreciation!

[Buy me a coffee](https://www.buymeacoffee.com/zhgchgli){:target="_blank"}

Buy me a coffee

If you have any questions or suggestions, feel free to contact me.


This article was first published on Medium ➡️ Click Here

Automatically converted and synchronized using ZMediumToMarkdown and Medium-to-jekyll-starter.

Improve this page on Github.

Buy me a beer

51 Total Views
Last Statistics Date: 2025-07-23 | 47 Views on Medium.
This post is licensed under CC BY 4.0 by the author.