Post

CI/CD 實戰指南(三):使用 GitHub Actions 實作 App iOS CI 與 CD 工作流程

iOS App 自動化建置、測試、部署的 GitHub Actions 實作步驟完整教學

CI/CD 實戰指南(三):使用 GitHub Actions 實作 App iOS CI 與 CD 工作流程

ℹ️ℹ️ℹ️ Click here to view the English version of this article, translated by OpenAI.


CI/CD 實戰指南(三):使用 GitHub Actions 實作 App iOS CI 與 CD 工作流程

iOS App 自動化建置、測試、部署的 GitHub Actions 實作步驟完整教學

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

Photo by Robs

前言

前篇「 CI/CD 實戰指南(二):GitHub Actions 與 Self-hosted Runner 使用與建置大全 」我們介紹了 GitHub Actions 的基礎知識與運作流程還有如何使用自己的機器當成 Runner、帶大家實現了三個簡單的自動化 Actions; 本篇將深入著重在現實使用 GitHub Actions 建置 App (iOS) CI/CD 工作流程上 ,一樣手把手帶大家一步一步完成並一邊補足 GitHub Actions 相關知識。

App CI/CD 流程關係圖

本篇將關注在 GitHub Actions 建置 CI/CD 的區塊,下一篇「 CI/CD 實戰指南(四):使用 Google Apps Script Web App (AI Vibe Coding) 結合 GitHub Actions 打造免費易用的跨團隊打包平台工具 」才會介紹右半部分的使用 Google Apps Script Web App 建置跨團隊協作打包平台。

運作流程:

  1. GitHub Actions 開 Pull Request 觸發 or 表單觸發 or 定時觸發
  2. 執行對應 Workflow Jobs/Steps
  3. Step 執行對應 Fastlane (iOS) or (Android Gradle) 腳本
  4. Fastlane 執行對應 xcodebuild (iOS) 指令
  5. 取得執行結果
  6. 後續 Workflow Jobs/Steps 處理結果
  7. 完成

GitHub Actions 成果圖

先上最終成果給大家一點實作動力!

[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

實作開始!

iOS Demo 專案的 Infra 架構

本文用到的 iOS 專案內容包含測試項目都是用 AI 產生的 ,不需在意 iOS 的程式細節,只針對 Infra & CI/CD 部分做討論。

Mint

Mint 工具可以幫我們統一管理依賴工具的版本(Gemfile 只能管理 Ruby Gems),例如 XCodeGen、XCodeGen、SwiftFormat、SwiftLint、Periphery

…etc

Mintfile:

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

這邊我們只用到三個。

覺得太複雜不使用也可以,直接在 Action Workflow Step 中用 brew install 安裝需要的工具即可。

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)

管理 Ruby (Gems) 相關依賴,一般 iOS 專案最常用的就是這兩個 cocoapodsfastlane

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 

雖然已宣告 即將停止維護 ,但 Cocoapods 在有年代的 iOS 專案中仍很常見,這邊簡單加一個 Snapkit 當 Demo。

XCodeGen

避免多人開發中 .xcodeproj / .xcworkspace 異動造成的衝突,統一使用 Project.yaml 定義 XCode Project 內容,然後在本地自己 Gen Project 檔案(不上 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: 使用 Swift Package Manager 管理。

Fastlane

封裝 xcodebuild 指令、封裝串接 App Store Connect API、Firebase API. .等服務的複雜步驟。

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

註: provisioningProfiles、profile_name 對應的是 App Developer 中的 Profiles 憑證名稱 。(如果有用 match 則也不需要這些指定。)

Fastlane 是 iOS CI/CD 當中不可或缺的一部分 ,直接使用它封裝好的方法就能快速開發 CI/CD 實際執行的步驟;我們只需關注在整體的腳本設計,而不需去處理複雜的 API 串接或指令撰寫。

例如:Fastlane 只需要寫「scan(xxx)」就能執行測試,如果要寫成 xcodebuild 則需要「 xcodebuild -workspace ./xxx.xcworkspace -scheme xxx -derivedDataPath xxx ‘platform=iOS Simulator,id=xxx’ clean build test 」,打包部署要自己做更是麻煩,要自行串接 App Store Connect/Firebase API,光金鑰驗證都要寫超過 10 行程式了。

Demo 專案我們只有三個 Lane:

  • run_all_tests: 跑所有類型的測試 (Snapshot+Unit)
  • run_unit_tests: 只跑單元測試 (Unit)
  • beta: 打包部署到 Firebase App Distribution

Fastlane — Matchfile

因 Demo 專案限制,這邊沒用到 Match 管理團隊開發、部署憑證,但這邊還是要提一下,建議使用 Match 去管理團隊的所有開發、部署憑證,方便控管跟統一更新。

有用 Match 就能在專案 Setup 步驟直接使用 match all 之類的指令一鍵安裝好所有開發需要的憑證。

— — —

Makefile

Makefile

Makefile

讓開發端與 CI/CD 統一都使用 Makefile 執行指令,方便我們封裝同樣的環境、路徑與操作行爲。

經典的案例是有的人使用的是本機安裝的 pod install 有的人則使用的是 Bundel 管理的 bundle exec pod install 如果版本不同就可能產生差異。

覺得太複雜不使用也可以,那就是在 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 "📖 可用指令:"
 @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 ## 安裝 Ruby 和 Mint 依賴
 @echo "🔨 Installing Ruby dependencies..."
 bundle config set path 'vendor/bundle'
 bundle install
 @echo "🔨 Installing Mint dependencies..."
 mint bootstrap

## Install
.PHONY: install
install: XcodeGen PodInstall ## 執行 XcodeGen 和 CocoaPods 安裝

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

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

### Mint
check-mint: check-brew ## 檢查 Mint 是否安裝,沒有就自動安裝
 @if ! command -v mint &> /dev/null; then \
  echo "🔨 Installing mint..."; \
  brew install mint; \
 fi

### Brew
check-brew: ## 檢查 Homebrew 是否安裝,沒有就自動安裝
 @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 ## 格式化 Product/ 下所有 Swift 檔
 mint run swiftformat $(PRODUCT_FOLDER)

為了避免污染整個系統或其他專案,我們盡可能把 Dependency 套件(e.g. mint, bundle…etc)的路徑都改指定在專案目錄之下 (再搭配 .gitignore 排除)。

1
2
3
4
5
6
7
8
9
10
11
12
13
├── mint (Mint 依賴)
   └── 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 依賴)
└── vendor (Bundle 依賴)
    └── bundle

make help

make help

使用 Makefine 的統一專案 Setup 步驟:

  1. git clone repo
  2. cd ./repo
  3. make setup 安裝必要的工具依賴 ( brew , mint , bundle , xcodegen, swiftformat,…)
  4. make install 產生專案 (執行 pod install, xcodegen)
  5. 完成
  6. 打開、執行專案

不管是 CI/CD 或新人 onboard 都是照以上步驟把專案建置起來。

本篇 GitHub Actions CI/CD 案例

本篇會介紹三個 GitHub Actions CI/CD 工作流程建置案例,大家也可以參考其中的步驟建置符合自己團隊工作流程的 CI/CD。

  1. CI — 發 Pull Request 執行單元測試
  2. CD — 打包+部署到 Firebase App Distribution
  3. CI +CD— Nightly Build 執行快照+單元測試+打包+部署到 Firebase App Distribution

因 Demo 限制,本文只會串接 打包部署到 Firebase App Distribution,打包到 Testflight 或是 App Store 也是同樣步驟只差別在 Fastlane 裡的腳本不同,大家可以自行發揮。

CI — 發 Pull Request 執行單元測試

流程

Develop 分支 無法直接推送 ,必須發 Pull Request 才能更新;所有 Pull Request 均需 Review 通過加上單元測試通過才能 Merge 、有新 Commit Push 會重新測試。

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
301
# Workflow(Action) 名稱
name: CI-Testing

# Actions Log 的標題名稱
run-name: "[CI-Testing] ${{ github.event.pull_request.title || github.ref }}"

# 同個 Concurrency Group 如果有新的 Job 會取消正在跑的
# 例如 Push Commit 觸發的任務還沒執行就又 Push Commit 時,會取消前一個任務
concurrency:
  group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

# 觸發事件
on:
  # PR 事件
  pull_request:
    # PR - 開啟、重開、有新 Push Commit 時
    types: [opened, synchronize, reopened]
  # 手動表單觸發
  workflow_dispatch:
    # 表單 Inputs 欄位
    inputs:
      # 執行的 Test Fastlane Lane
      TEST_LANE:
        description: 'Test Lane'
        default: 'run_unit_tests'
        type: choice
        options:
          - run_unit_tests
          - run_all_tests
  # 其他 Workflow 呼叫此 Workflow 觸發
  # Nightly Build 會呼叫使用
  workflow_call:
    # 表單 Inputs 欄位
    inputs:
      # 執行的 Test Fastlane Lane
      TEST_LANE:
        description: 'Test Lane'
        default: 'run_unit_tests'
        # workflow_call inputs 不支援 choice
        type: string
      BRANCH:
        description: 'Branch'
        type: string
  
# Job 工作項目
# Job 會並發執行
jobs:
  # Job ID
  testing:
    # Job 名稱 (可省略,有設定在 Log 顯示比較好讀)
    name: Testing
    
    # Runner Label - 使用 GitHub Hosted Runner macos-15 來執行工作
    # 請注意:因為此專案是 Public Repo 可以無限免費使用
    # 請注意:因為此專案是 Public Repo 可以無限免費使用
    # 請注意:因為此專案是 Public Repo 可以無限免費使用
    # 如果是 Private Repo 需要按計量收費,macOS 機器是最貴的(10倍),可能跑 10 次就達到 2,000 分鐘免費上限
    # 建議使用 self-hosted Runner
    runs-on: macos-15

    # 設定最長 Timeout 時間,防止異常情況發生時無止盡的等待
    timeout-minutes: 30

    # use zsh
    # 可省略,只是我習慣用 zsh,預設是 bash
    defaults:
      run:
        shell: zsh {0}
          
    # 工作步驟
    # 工作步驟會照順序執行  
    steps:
      # git clone 當前專案 & checkout 到執行的分支
      - name: Checkout repository
        uses: actions/checkout@v3
        with:
          # Git Large File Storage,我們的測試環境用不到
          # default: false
          lfs: false
          
          # 如果有指定則 Checkout 指定分支,沒有則使用預設(當前分支)
          # 因 on: schedule 事件只能在 main 主分支執行,因此想做 Nightly Build 之類的工作就需要指定分支
          # e.g. on: schedule -> main 分支,Nightly Build master 分支
          ref: ${{ github.event.inputs.BRANCH || '' }}

      # ========== Env Setup Steps ==========
      
      # 讀取專案指定的 XCode 版本
      # 在後續之中,我們自己手動指定使用的 XCode_x.x.x.app
      # 而不使用 xcversion,因為 xcversion 已經 sunset 不穩定。 
      - 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

          # 也可以直接在這指定全域 XCode 版本,這樣就不用在後續步驟指定 DEVELOPER_DIR
          # 但此指令需要 sudoer 權限,如果是 self-hosted runner 就要確定 runner 執行環境有 sudo 權限
          # sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer"

      # 讀取專案指定的 Ruby 版本
      - 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

      # 安裝或設定 Runner Ruby 版本成專案指定版本
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: "${{ steps.read_ruby_version.outputs.ruby_version }}"

      # 可設可不設,原因是之前在 self-hosted 起多個 runner 跑 CI/CD 因為 cocoapods repos 是共用目錄
      # 解決的問題是:有很小的機率會出現在同時 pod install 時拉 cocoapods repos 出現衝突(因為預設都是用) $HOME/.cocoapods/
      # GitHub Hosted Runner 則不需此設定
      # - name: Change Cocoapods Repos Folder
      #   if: contains(runner.labels, 'self-hosted')
      #   run: |
      #     # 每個 Runner 用自己的 .cocoapods 資料夾,防止資源衝突
      #     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 ==========
      # 請注意,就算是 self-hosted,Cache 目前也是 Cloud Cache 會計算用量
      # 規則:7 天未 hit 自動刪除、單個 Cache 上限 10 GB、Action 成功才會 Cache
      # Public Repo: 免費無限制
      # Private Repo: 5 GB 起
      # Self-hosted 可以自己用 shell script 撰寫 Cache & Restore 策略或使用其他工具協助
      
      # Bundle Cache (Gemfile)
      # 對應 Makefile 中我們指定了 Bundle  安裝路徑 ./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)
      # 默認就是 專案/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
      # 對應 Makefile 中我們指定的 Mint 安裝路徑 ./mint 下
      - name: Cache Mint
        uses: actions/cache@v3
        with:
          path: ./mint
          key: ${{ runner.os }}-mint-${{ hashFiles('Mintfile') }}
          restore-keys: |
            ${{ runner.os }}-mint-

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

      # 專案 Setup & 依賴安裝
      - name: Setup & Install Dependency
        run: |
          # 執行 Makefile 中封裝的 Setup 指令,對應成指令大概是:
          # brew install mint
          # bundle config set path 'vendor/bundle'
          # bundle install
          # mint bootstrap
          # ...
          # 等等 setup 指令
          make setup

          # 執行 Makefile 中封裝的 Install 指令,對應成指令大概是:
          # mint run yonaskolb/XcodeGen --quiet
          # bundle exec pod install
          # ...
          # 等等 install 指令
          make install

      # 執行 Fastlane Unit 測試 Lane
      - name: Run Tests
        id: testing
        # 指定工作目錄,這樣後續指令就不用在特別 cd ./Product/
        working-directory: ./Product/
        env:
          # 測試計劃,全跑還是只跑單元測試
          # 如為開 PR 觸發則使用 run_unit_tests,否則看 inputs.TEST_LANE 的值,預設值 run_all_tests
          TEST_LANE: ${{ github.event_name == 'pull_request' && 'run_unit_tests' || github.event.inputs.TEST_LANE || 'run_all_tests' }}
          
          # 指定這個 Job 要使用 XCode_x.x.x 指定的版本執行
          DEVELOPER_DIR: "/Applications/Xcode_${{ steps.read_xcode_version.outputs.xcode_version }}.app/Contents/Developer"
          
          # Repo -> Settings -> Actions secrets and variables -> variables
          # 使用的模擬器名稱
          SIMULATOR_NAME: ${{ vars.SIMULATOR_NAME }}
          # 模擬器的 iOS 版本
          SIMULATOR_IOS_VERSION: ${{ vars.SIMULATOR_IOS_VERSION }}

          # 當前 Runner 名稱
          RUNNER_NAME: ${{ runner.name }}
          
          # 提升 XCodebuild 指令 timeout 時間, retry 次數
          # 因為機器 Loading 比較大的時候可能 3 次就失敗了
          FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 60
          FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 10
        run: |

          # 如果是 self-hosted 在同一台機器起多個 Runner 會出現搶模擬器的問題 (文章後會講)
          # 要避免這問題建議將模擬名稱命名成 Runner 名稱,每個 Runner 都設一個模擬器,這樣就不會互搶導致測試失敗
          # e.g. bundle exec fastlane run_unit_tests device:"${RUNNER_NAME} (${SIMULATOR_IOS_VERSION})"
          # 這邊是用 GitHub Hosted Runner 沒這問題,所以直接用 device:"${SIMULATOR_NAME} (${SIMULATOR_IOS_VERSION})"

          # 發生錯誤不直接退出並將所有輸出都寫入 temp/testing_output.txt 檔案
          # 後續我們會分析檔案內容區分出是 Build Failed 還是 Test Failed,Comment 不同訊息到 PR
          set +e
          
          # EXIT_CODE 儲存執行結果的 exit code.
          # 0 = OK
          # 1 = exit
          EXIT_CODE=0
          
          # 所有輸出都寫入檔案
          bundle exec fastlane ${TEST_LANE} device:"${SIMULATOR_NAME} (${SIMULATOR_IOS_VERSION})" | tee "$RUNNER_TEMP/testing_output.txt"
          # 如果目前 EXIT_CODE 是 0,則將 ${pipestatus[1]} 賦值給 EXIT_CODE
          [[ $EXIT_CODE -eq 0 ]] && EXIT_CODE=${pipestatus[1]}

          # 恢復出錯就退出
          set -e

          # 檢查 Testing Output
          # 如果 Testing Output 包含 "Error building",則設 is_build_error=true 給 Actions 環境變數,為 Build 就失敗
          # 如果 Testing Output 包含 "Tests have failed",則設 is_test_error=true 給 Actions 環境變數,為測試失敗
          
          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

          # 恢復 Exit Code Output
          exit $EXIT_CODE
          
      # ========== Handle Result Steps ==========
      
      # 解析 *.junit 測試報告,並標記結果、Comment(如果是 PR 的話)
      - name: Publish Test Report
        # 直接復用別人寫好的 .junit Paser Actions: https://github.com/mikepenz/action-junit-report
        uses: mikepenz/action-junit-report@v5
        # if:
        # 上一步(Testing) success or
        # 上一步(Testing) failed and is_test_error (build failed 不執行這個 step)
        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
      - name: Build Failure Comment
        # if:
        # 上一步(Testing) failed and is_build_error and 有 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 = [
                `# 專案或測試建置失敗 ❌`,
                `請確認您的 Pull Request 是否可以正確編譯與執行測試。`,
                ``,
                `🔗 **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
              })

技術重點說明:

  • runs-on: 建議改用 self-hosted Runner, GitHub Hosted Runner macOS 很貴的
  • 手動讀取 .xcode-version 檔案取得指定的 XCode 版本並在需要指定 XCode 的 Step 設定 DEVELOPER_DIR env 可以輕鬆免 Sudo 切換 XCode
  • Cache: 可以加速依賴安裝速度,但需注意就算是 self-hosted Runner 依然會使用 GitHub Cloud Cache,受到收費限制
  • 使用 set +e 使指令執行失敗不會馬上退出+將 output 都輸出到檔案+讀取檔案判斷是 Build Failed or Test Failed;如果不這樣做訊息統一都會是 Test Failed。 也可以延伸去判斷其他錯誤,例如: Underlying Error: Unable to boot the Simulator. 模擬器啟動失敗,請重新嘗試。
  • Checkout Code 可接受指定分支:因為 on: schedule 事件只能在 main (Default Branch) 觸發,如果我們希望排程對某個其他分支操作,就需要指定分支。
  • 指定 .cocoapods Repo 路徑可做可不做,之前是遇過 self-hosted 同一台機器兩個 Runner 同時卡在 pod install 就是因為剛好都在針對 .cocoapods Repo 操作造成 git lock。 (但機率很低就是了)
  • 如果你有 Private Pods Repo 需要 Clone 需要 參考此回答 設定 SSH Agent 才有權限 Clone。
  • 記得到 Repo -> Settings -> Actions secrets and variables -> variables 新增: SIMULATOR_IOS_VERSION 模擬器 iOS 版本 SIMULATOR_NAME 模擬器名稱

Commit 檔案到 Repo 主分支,手動觸發一次驗證正不正確:

正確後繼續設定。

GitHub 流程設定

Repo → Settins → Rules → Rulesets。

  • Ruleset Name: 規則名稱
  • Enforcement status: 啟用/停用 此規則限制
  • Target branches: 目標的 Base 分支,設 Default Branch 就是所有想合進到 main or develop 的分支都受到此規則限制
  • Bypass list: 可指定特殊身份、Team 可以不受此限
  • Branch rules:

  • Restrict deletions: 禁止刪除分支
  • Require a pull request before mergin: 只能透過 PR Merge Required approvals: 限制需要幾人 Approve
  • Require status checks to pass: 限制哪些 Checks 要 Passed 才能 Merge 點 + Add checks 輸入 Testing 選擇有 GitHub Actions 標誌的。 這邊有個小問題,如果找不到 Testing 那需要先回到 Actions 多觸發(開 PR 試試)執行幾次,這裡才會出現。
  • Block force pushes: 禁止 Force push

儲存、確定 Enforcement status 是 Active 後,規則就會生效了。

都設定好之後,開 PR 測試看看:

  • Checks 有出現 CI-Testing ( Requried )、Merging is blocked、At least X approving review is required by reviewers with write access. 代表設置成功了。

如果專案建置失敗 (Build Failed) 會 Comment:

如果專案建置成功但測試案例失敗 (Test Failed) 會 Comment:

如果專案建置成功測試也成功(Test Success) 會 Comment:

完成 Review Approve + Check 測試通過後:

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

Demo PR

就能 Merge PR。

  • 如果有 Push New Commit 會自動重跑 Checks 測試。

完整程式碼: CI-Testing.yml

CD — 打包+部署到 Firebase App Distribution

流程

使用 GitHub Actions 表單觸發打包工作,可指定版本號、Release Notes,打包完會自動上傳到 Firebase App Distribution 供團隊下載測試使用。

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
317
318
# Workflow(Action) 名稱
name: CD-Deploy

# Actions Log 的標題名稱
run-name: "[CD-Deploy] ${{ github.ref }}"

# 同個 Concurrency Group 如果有新的 Job 會取消正在跑的
# 例如 重複觸發相同分支的打包任務,會取消前一個任務
concurrency:
  group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
  cancel-in-progress: true

# 觸發事件
on:
  # 手動表單觸發
  workflow_dispatch:
    # 表單 Inputs 欄位
    inputs:
      # App 版本號
      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
  # 其他 Workflow 呼叫此 Workflow 觸發
  # Nightly Build 會呼叫使用
  workflow_call:
    inputs:
      # App 版本號
      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


# 定義全域靜態變數
env:
  APP_STORE_CONNECT_API_KEY_FILE_NAME: "app_store_connect_api_key.json"

# Job 工作項目
# Job 會並發執行
jobs:
  # Job ID
  deploy:
    # Job 名稱 (可省略,有設定在 Log 顯示比較好讀)
    name: Deploy - Firebase App Distribution
    
    # Runner Label - 使用 GitHub Hosted Runner macos-15 來執行工作
    # 請注意:因為此專案是 Public Repo 可以無限免費使用
    # 請注意:因為此專案是 Public Repo 可以無限免費使用
    # 請注意:因為此專案是 Public Repo 可以無限免費使用
    # 如果是 Private Repo 需要按計量收費,macOS 機器是最貴的(10倍),可能跑 10 次就達到 2,000 分鐘免費上限
    # 建議使用 self-hosted Runner
    runs-on: macos-15

    # 設定最長 Timeout 時間,防止異常情況發生時無止盡的等待
    timeout-minutes: 30

    # use zsh
    # 可省略,只是我習慣用 zsh,預設是 bash
    defaults:
      run:
        shell: zsh {0}

    # 工作步驟
    # 工作步驟會照順序執行  
    steps:
      # git clone 當前專案 & checkout 到執行的分支
      - name: Checkout repository
        uses: actions/checkout@v3
        with:
          # Git Large File Storage,我們的測試環境用不到
          # default: false
          lfs: false
          
          # 如果有指定則 Checkout 指定分支,沒有則使用預設(當前分支)
          # 因 on: schedule 事件只能在 main 主分支執行,因此想做 Nightly Build 之類的工作就需要指定分支
          # e.g. on: schedule -> main 分支,Nightly Build master 分支
          ref: ${{ github.event.inputs.BRANCH || '' }}

      # ========== Certificates Steps ==========
      
      # 建議是使用 Fastlnae - Match 管理開發憑證並在 Lane 中直接執行 match 安裝設定好
      # Match 會用另一個 Private Repo 管理憑證,但要設定好 SSH Agent 才有權限 git clone private repo
      # ref: https://stackoverflow.com/questions/57612428/cloning-private-github-repository-within-organisation-in-actions
      #
      #
      # --- 以下是沒有使用 Fastlane - Match 的情況下直接下載 & Import 憑證給 Runner 的做法 ---
      # 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 Secret 無法儲存檔案,因此所有憑證檔案都要先轉成 Base64 Encoded 文字格式存在 Secret
      # 在 GitHub Actions Step 中再動態讀出來寫入 TEMP 檔案並移動到正確位置給系統讀取使用
      # 其他設定細節請參考文章
      #
      - 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 為自定義字串
          # Self-hosted Runner 為機器登入密碼
          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
      # 另一個在打包環境幾乎是必須的 App Store Connect API Fastlane JSON Key (.json)
      # format: .json 內容格式:https://docs.fastlane.tools/app-store-connect-api/
      # 裡面包含 App Store Connect API .p8 Key
      # 會在後續帶給 Fastlane,用於上傳到 Testflight、App Store API 使用
      #
      # GitHub Actions Secret 無法儲存檔案,因此所有憑證檔案都要先轉成 Base64 Encoded 文字格式存在 Secret
      # 在 GitHub Actions Step 中再動態讀出來寫入 TEMP 檔案供其他步驟引用使用
      # 其他設定細節請參考文章
      - 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 ==========
      
      # 讀取專案指定的 XCode 版本
      # 在後續之中,我們自己手動指定使用的 XCode_x.x.x.app
      # 而不使用 xcversion,因為 xcversion 已經 sunset 不穩定。 
      - 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

          # 也可以直接在這指定全域 XCode 版本,這樣就不用在後續步驟指定 DEVELOPER_DIR
          # 但此指令需要 sudoer 權限,如果是 self-hosted runner 就要確定 runner 執行環境有 sudo 權限
          # sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer"

      # 讀取專案指定的 Ruby 版本
      - 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

      # 安裝或設定 Runner Ruby 版本成專案指定版本
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: "${{ steps.read_ruby_version.outputs.ruby_version }}"

      # 可設可不設,原因是之前在 self-hosted 起多個 runner 跑 CI/CD 因為 cocoapods repos 是共用目錄
      # 解決的問題是:有很小的機率會出現在同時 pod install 時拉 cocoapods repos 出現衝突(因為預設都是用) $HOME/.cocoapods/
      # GitHub Hosted Runner 則不需此設定
      # - name: Change Cocoapods Repos Folder
      #   if: contains(runner.labels, 'self-hosted')
      #   run: |
      #     # 每個 Runner 用自己的 .cocoapods 資料夾,防止資源衝突
      #     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 ==========
      # 請注意,就算是 self-hosted,Cache 目前也是 Cloud Cache 會計算用量
      # 規則:7 天未 hit 自動刪除、單個 Cache 上限 10 GB、Action 成功才會 Cache
      # Public Repo: 免費無限制
      # Private Repo: 5 GB 起
      # Self-hosted 可以自己用 shell script 撰寫 Cache & Restore 策略或使用其他工具協助
      
      # Bundle Cache (Gemfile)
      # 對應 Makefile 中我們指定了 Bundle  安裝路徑 ./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)
      # 默認就是 專案/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
      # 對應 Makefile 中我們指定的 Mint 安裝路徑 ./mint 下
      - name: Cache Mint
        uses: actions/cache@v3
        with:
          path: ./mint
          key: ${{ runner.os }}-mint-${{ hashFiles('Mintfile') }}
          restore-keys: |
            ${{ runner.os }}-mint-

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

      # 專案 Setup & 依賴安裝
      - name: Setup & Install Dependency
        run: |
          # 執行 Makefile 中封裝的 Setup 指令,對應成指令大概是:
          # brew install mint
          # bundle config set path 'vendor/bundle'
          # bundle install
          # mint bootstrap
          # ...
          # 等等 setup 指令
          make setup

          # 執行 Makefile 中封裝的 Install 指令,對應成指令大概是:
          # mint run yonaskolb/XcodeGen --quiet
          # bundle exec pod install
          # ...
          # 等等 install 指令
          make install

      - name: Deploy Beta
        id: deploy
        # 指定工作目錄,這樣後續指令就不用在特別 cd ./Product/
        working-directory: ./Product/
        env:
          # 打包 Input 參數
          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 密鑰 (取得方式請參考文章)
          FIREBASE_CLI_TOKEN: ${{ secrets.FIREBASE_CLI_TOKEN }}
          # Apple Developer Program Team ID
          TEAM_ID: ${{ secrets.TEAM_ID }}
                    
          # 指定這個 Job 要使用 XCode_x.x.x 指定的版本執行
          DEVELOPER_DIR: "/Applications/Xcode_${{ steps.read_xcode_version.outputs.xcode_version }}.app/Contents/Developer"
        run: |
          # 取得當前 Timestamp
          BUILD_TIMESTAMP=$(date +'%Y%m%d%H%M%S')

          # 如果 BUILD_NUMBER 沒有值,用 Timestamp 當 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 }}"

          # 組合 Release Note
          RELEASE_NOTE="${{ env.RELEASE_NOTE }}
          ID: ${ID}
          Commit SHA: ${COMMIT_SHA}
          Branch: ${BRANCH_NAME}
          Author: ${AUTHOR}
          "

          # 執行 Fastlane 打包&部署 Lane
          bundle exec fastlane beta release_notes:"${RELEASE_NOTE}" version_number:"${VERSION_NUMBER}" build_number:"${BUILD_NUMBER}"

      # GitHub Actions 建議的 self-hosted 安全性設定:
      # 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
      # 對應 Step: Install the Apple certificate and provisioning profile
      # 用途是刪除機器上下載下來的金鑰憑證
      # 如果你是用 Match 則需要改寫成 Match 的 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
  • 記得到 Repo -> Settings -> Actions secrets and variables -> secrets 新增一個 TEAM_ID 變數,內容是 Apple Developer Team ID 字串。

Commit 檔案到 Repo 主分支,測試看看打包功能:

請注意若其他分支要使用此 Action 需要先 Merge 主分支的 CD-Deploy.yml 檔案。

等待任務跑完:

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

Demo

打包+部署成功 ✅

完整程式碼: CD-Deploy.yml

技術細節 — Firebase CLI Token 取得&設定

按照 Firebase 官方文件步驟

先安裝好 Firebase CLI 工具:

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

執行:

1
firebase login:ci

完成登入、授權:

回到 Terminal 複製 Firebase CLI Token:

到 Repo → Settings → Secrets and variables → Actions → 新增一個 Secret: FIREBASE_CLI_TOKEN 並貼上 Firebase CLI Token。

技術細節 — Install the Apple certificate and provisioning profile

補充開發憑證匯入 Runner 的步驟細節。

因 GitHub Actions Secret 無法儲存檔案,因此所有憑證檔案都要先轉成 Base64 Encoded 文字格式存在 Secrets,GitHub Actions Step 中再動態讀出來寫入 TEMP 檔案並移動到正確位置給系統讀取使用。

打包 Development 需要兩把金鑰憑證:

cicd.mobileprovision

cicd.mobileprovision

development.cer

development.cer

Apple Developer 中下載的 Certificate 是 .cer 格式,而我們需要的是 .p12 格式,可以先把下載下來的 .cer 點兩下安裝到 Keychain,然後打開 Keychain 選擇該憑證右鍵 Export 匯出。

檔案名稱:cicd.p12、格式 .p12

P12 金鑰密碼:輸入一組安全的自訂義字串 (範例是不好的示範,用 123456 )

現在兩個檔案: cicd.p12、cicd.mobileprovision 都準備好了

轉換成 BASE64 格式字串並存到 Repo Secrets:

1
base64 -i cicd.mobileprovision | pbcopy

到 Repo → Settings → Secrets and variables → Actions → 新增一個 Secret: BUILD_PROVISION_PROFILE_BASE64 並貼上以上內容。

-

1
base64 -i cicd.p12 | pbcopy

到 Repo → Settings → Secrets and variables → Actions → 新增一個 Secret: BUILD_CERTIFICATE_BASE64 並貼上以上內容。

-

到 Repo → Settings → Secrets and variables → Actions → 新增一個 Secret: P12_PASSWORD 內容是剛匯出 P12 金鑰設定的密碼。

- 到 Repo → Settings → Secrets and variables → Actions → 新增一個 Secret: KEYCHAIN_PASSWORD : 如果是 GitHub Hosted Runner 則隨便輸入一個任意字串, 如果是 Self-hosted Runner 則為 macOS Runner 使用者的登入密碼

技術細節 —App Store Connect API Key

Fastlane 打包部署到 App Store, Testflight 必須提供的 .json 金鑰 ,同樣受限 GitHub Actions Secrets 只能存字串不能存檔案,所以我們也要把金鑰內容轉成 Base64 字串,GitHub Actions Step 中再動態讀出來寫入 TEMP 檔案並把檔案路徑給 Fastlane 引用使用。

首先到 App Store Connect 建立&下載好 App Store Connect API Key ( .p8) :

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

新增一個 app_store_connect_api.json 檔案( 內容參考 ):

1
2
3
4
5
6
7
{
  "key_id": "App Store Connect 上寫的 Key ID",
  "issuer_id": "App Store Connect 上寫的 Issuer ID",
  "key": "-----BEGIN PRIVATE KEY-----記得把換行改成\n-----END PRIVATE KEY-----",
  "duration": 1200, # optional (maximum 1200)
  "in_house": false # optional but may be required if using match/sigh
}

儲存檔案後執行:

1
base64 -i app_store_connect_api.json | pbcopy

將字串內容貼到 Repo → Settings → Secrets and variables → Actions → 新增一個 Secret: APP_STORE_CONNECT_API_KEY_BASE64 並貼上以上內容。

Read and Write Apple Store Connect API Key to Temp Step 完成之後在後續的 Step 只要傳入 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 就能自動取得使用。

技術延伸 — Reuse Action Workflow 拆分打包和部署動作

在這個案例中我們直接使用 Fastlane beta Lane 執行打包+部署兩個動作。

在實際案例中我們可能需要將同一包打包結果分別部署到不同平台上(Firebase, Testflight…etc) 因此比較好的做法是打包是一個 Action、部署是一個 Action,不然會重復跑兩次打包;而且也更符合 CI/CD 的權責劃分。

以下爲範例介紹:

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 steup
          make instal

      - 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:
  # 當 Build Action 完成時自動觸發執行
  workflow_run:
    workflows: ["Build"]
    types:
      - completed

jobs:
  deploy:
    runs-on: ubuntu-latest
    # 完成+執行成功才執行部署
    if: ${{ github.event.workflow_run.conclusion == 'success' }}

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

      - name: Install Dependencies
        run: |
          make steup

      - 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:
  # 當 Build Action 完成時自動觸發執行
  workflow_run:
    workflows: ["Build"]
    types:
      - completed

jobs:
  deploy:
    runs-on: ubuntu-latest
    # 完成+執行成功才執行部署
    if: ${{ github.event.workflow_run.conclusion == 'success' }}

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

      - name: Install Dependencies
        run: |
          make steup

      - 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

另外也可以用 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:
  # 任意的觸發條件,這裡以手動表單觸發為例
  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 預設是並發執行,用 needs 限制需等待 build 完成才執行
    needs: [build]
    # 執行成功,才部署
    if: ${{ always() && needs.deploy.result == 'success' }}
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Install Dependencies
        run: |
          make steup

      - 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

同 Cache,目前 就算是 Self-hosted Runner Artifcact 功能依然會走 GitHub Cloud 受到使用量限制 ( 免費帳號 500MB 起 )。

Self-hosted Runner 要達到類似的效果可以自行建立共享主機目錄或找其他工具替代。

因此目前 Artifact 實際我只用來存放小資料,例如 Snapshot Tests 的錯誤結果、測試報告…等等

CI— Nightly Build 執行快照+單元測試+打包+CD 部署到 Firebase App Distribution

流程

每天凌晨 3 點自動針對 main(develop or master) 分支跑全部測試(unit+snapshot tests),如果失敗則傳送失敗通知到 Slack 工作群組;如果成功則打包+部署一個版本到 Firebase App Disturbution,打包成功/失敗都會傳送 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: CI-Nightly Build And Deploy

# Actions Log 的標題名稱
run-name: "[CI-Nightly Build And Deploy] ${{ github.ref }}"

# 觸發事件
on:
  # 排程定時自動執行
  # https://crontab.guru/
  # UTC 時間
  schedule:
    # UTC 的 19:00 = 每天 UTC+8 的 03:00
    - cron: '0 19 * * *'
  # 手動觸發
  workflow_dispatch:

# Job 工作項目
# Job 會並發執行
jobs:
  # 測試工作
  testing:
    # Reuse Workflow (workflow_call)
    uses: ./.github/workflows/CI-Testing.yml
    # 傳遞所有 Secrets 給 CD-Testing.yml
    secrets: inherit
    with:
      # 執行全部測試
      TEST_LANE: "run_all_tests"
      # 目標分支: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 預設是並發執行,用 needs 限制需等待 testing 和 deploy-env 完成才執行
    needs: [testing, deploy-env]
    # 如果測試成功才執行
    if: ${{ needs.testing.result == 'success' }}
    # Reuse Workflow (workflow_call)
    uses: ./.github/workflows/CD-Deploy.yml
    # 傳遞所有 Secrets 給 CD-Deploy.yml
    secrets: inherit
    with:
      VERSION_NUMBER: NightlyBuild-${{ needs.deploy-env.outputs.DATE_STRING }}
      RELEASE_NOTE: NightlyBuild-${{ needs.deploy-env.outputs.DATE_STRING }}
      # 目標分支: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 失敗\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 失敗\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 成功\nWorkflow: <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>"

Commit 檔案到 Repo 主分支,手動觸發測試、打包功能看看結果:

日後會每日自動觸發。

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

Demo

等待測試任務、打包部署任務、通知任務都完成後,查看結果。

我們就能直接在手機上安裝 Nightly Build 版本來進行搶先體驗測試。

技術細節

這個 Action 我們直接復用前面設計的 CI-Testing 和 CD-Deploy,組合成我們的 Nightly Build,非常彈性好用!

完整程式碼: CI-Nightly-Build-And-Deploy.yml

Self-hosted Runner 注意事項

本文是 Public Repo 因此直接使用 GitHub Hosted 的 macOS Runner,但在實際工作上我們的 Repo 一定是 Private,直接使用 GitHub Hosted Runner 超貴不划算(約一個月就能買一台 Mac Mini 放在公司吃到飽爽爽跑),每一台依照效能可以起多個 Runner 同時並發接任務來做。

細節可參考 上一篇Self-hosted Runner 建置與改用 」部分 ,在本地電腦安裝好 XCode 跟基本環境後註冊、啟用 Runner、Action Worflow YAML 中把 runs-on 改成 [self-hosted] 即可。

多個 Runner 在同一台電腦的問題,我們多半已在上面的 Actions 裡解決了,例如把所有依賴的共用目錄都改成本地目錄,還有一個測試會遇到的問題要解決,就是搶模擬器問題:「 當兩個測試 Job 被兩個 Runner 在同一台機器上同時檢來做,如果指定同個模擬器就會互相干擾導致測試失敗。」

解決方法也很容易,就是為個別 Runner 都設定一台模擬器。

多個 Runner 在同台機器的模擬器設定

假設我 同一台電腦上有兩個 Runner 在並行接收任務:

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

在 XCode 模擬器設定我們就需要新增兩個模擬器:

  • 型號、iOS 版本同測試環境

CI-Testing.yml 中測試步驟改成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 執行 Fastlane Unit 測試 Lane
      - name: Run Tests
        id: testing
        # 指定工作目錄,這樣後續指令就不用在特別 cd ./Product/
        working-directory: ./Product/
        env:
          # ...
          # Repo -> Settings -> Actions secrets and variables -> variables
          # 模擬器的 iOS 版本
          SIMULATOR_IOS_VERSION: ${{ vars.SIMULATOR_IOS_VERSION }}

          # 當前 Runner 名稱
          RUNNER_NAME: ${{ runner.name }}
          
          # ...
        run: |

          # ...
          bundle exec fastlane ${TEST_LANE} device:"${RUNNER_NAME} (${SIMULATOR_IOS_VERSION})" | tee "$RUNNER_TEMP/testing_output.txt"
          # ...
  • device 改成 ${RUNNER_NAME} (${SIMULATOR_IOS_VERSION})
  • SIMULATOR_IOS_VERSION 還是統一看 Repo variables 變數

組合結果就會是(以 18.4 為例):

  • Runner: ZhgChgLideMacBook-Pro-Runner-A 模擬器: ZhgChgLideMacBook-Pro-Runner-A(18.4)
  • Runner: ZhgChgLideMacBook-Pro-Runner-B 模擬器: ZhgChgLideMacBook-Pro-Runner-B(18.4)

這樣兩個 Runner 同時在執行測試時就會起兩個模擬器自己跑自己的了。

完整專案 Repo

總結

這篇文章詳細紀錄了使用 GitHub Actions 開發完整的 iOS CI/CD 流程,下一篇將優化使用者端(工程師/PM/設計師)體驗, 完善 Slack 通知及使用 Google Apps Script Web App 串接 GitHub Actions 打造免費易用的跨團隊打包平台工具。

下一篇:

[撰寫中,敬請期待] CI/CD 實戰指南(四):使用 Google Apps Script Web App (AI Vibe Coding) 結合 GitHub Actions 打造免費易用的跨團隊打包平台工具

有任何問題及指教歡迎 與我聯絡


本文首次發表於 Medium ➡️ 前往查看

ZMediumToMarkdownMedium-to-jekyll-starter 提供自動轉換與同步技術。

Improve this page on Github.

Buy me a beer

This post is licensed under CC BY 4.0 by the author.