利用 GitHub Actions 进行 iOS 项目的 CI/CD

搞 iOS 以来,一直在想找一个云主机进行 iOS 项目的 CI 探索,方向想偏了,没找到几家提供 macOS 系统的云主机,遂作罢。

最近在读 《iOS App Distribution & Best Practices》,里面提到现在有大致三类 CI 服务商:

  1. Full-service CI,即全方位服务的 CI 提供商,可以理解为通过交互页面就可以进行部署。虽然简单,但可能不太容易拓展。典型如:
    1. Bitrise: https://www.bitrise.io/
    2. Microsoft’s App Center: https://appcenter.ms/
  2. Managed CI,即在云上替你托管硬件相关的工作,你只需要提供构建脚本,在 iOS 项目中,常见的就是构建方式就是 fastlanexcodebuild。比较流行的如:
    1. CircleCI: https://circleci.com/
    2. GitHub Actions: https://Github.com/features/actions
  3. Manual CI,即手动管理,感觉跟自己在云主机上处理一个概念。便宜,但得自己管理服务器。最著名的就是 Travis CI: https://travis-ci.org/ 了。

怀着激动的心,每个官方都去逛了一遍,确实都提供了 macOS 的构建环境,nice,der~。而且都有都有免费的配额(收费方式都是卖点卡)。

最后还是选择 GitHub Actions 作为远程 CI 的第一个实验品。截图如下

job

workflows

GitHub Actions 使用

中文的概念介绍和使用方式可以去看阮一峰的 GitHub Actions 入门教程

或者直接去看官方文档 GitHub Actions

简单来说,就是在仓库目录 .github/workflows/ 放置脚本,当有 push 或 PR 的时候,就会触发脚本运行。

免费额度查看方式,「右上角LOGO」-「Settings」-「Billings & plans」

触发条件

1
2
3
4
5
6
7
8
name: iOS starter workflow

# 触发条件,在 main 分支上有 push 和 PR 行为时触发脚本
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

Step 整理

构建环境

1
2
3
4
5
6
7
8
9
10
11
12
13
jobs:
build:
name: Build and Test default scheme using any available iPhone simulator
# 环境
runs-on: macos-latest

steps:
# Checkout 当前分支进行处理
- name: Checkout
uses: actions/checkout@v2
- name: stepxxx
# operation

Pod install

iOS 项目免不了用 cocoaPods 安装依赖。

1
2
3
- name: Install Dependencies
run: |
pod install --repo-update

踩坑记录

Podfile 头部标记 source 'https://github.com/CocoaPods/Specs.git' 不是很明智。

cocoaPods 官方在 CocoaPods 1.7.2 — Master Repo CDN is Finalized! 表示,随着 pod 数目的增多,利用 github 作为数据库备份越来越慢了,已经更换成 CDN 的形式了。

1
2
3
# source 'https://github.com/CocoaPods/Specs.git'
# 替换成 CDN,或者干脆删除。因为默认 CDN 源了。
source 'https://cdn.cocoapods.org/'

默认 Scheme

1
2
3
4
5
6
- name: Set Default Scheme
run: |
scheme_list=$(xcodebuild -list -json | tr -d "\n")
default=$(echo $scheme_list | ruby -e "require 'json'; puts JSON.parse(STDIN.gets)['project']['targets'][0]")
echo $default | cat >default
echo Using default scheme: $default

Build

用模拟器进行编译。优先跑 .xcworkspace,无则跑 .xcodeproj。

我测试的时候,在 GitHub Actions 上 xcrun 得到的模拟器名称不正确,所以直接固定了使用 iPhone 11 作为模拟器。

1
2
3
4
5
6
7
8
9
10
11
12
- name: Build
env:
scheme: ${{ 'default' }}
platform: ${{ 'iOS Simulator' }}
run: |
# xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959)
# device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}'`
device='iPhone 11'
if [ $scheme = default ]; then scheme=$(cat default); fi
if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi
file_to_build=`echo $file_to_build | awk '{$1=$1;print}'`
xcodebuild build-for-testing -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,name=$device"

Test

1
2
3
4
5
6
7
8
9
10
11
12
- name: Test
env:
scheme: ${{ 'default' }}
platform: ${{ 'iOS Simulator' }}
run: |
# xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959)
# device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}'`
device='iPhone 11'
if [ $scheme = default ]; then scheme=$(cat default); fi
if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi
file_to_build=`echo $file_to_build | awk '{$1=$1;print}'`
xcodebuild test-without-building -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,name=$device"

通知以及跨Job传递状态

  1. 使用 ravsamhq/notify-slack-action@v1 将 Job 状态通知到 Slack。

  2. 因为该 action 运行在 Linux 系统,所以需要另起一个 Job。

  3. 如何将上一个 Job 的状态通知到下一个 Job?一种思路是将 Job_A的状态通过文本保存到本地,再在Job_B中读取该状态。

    可参考:How get the status of previous jobs

保存状态

1
2
3
4
5
6
7
8
9
10
- name: Create file status_build_test.txt and write the job status into it
if: always()
run: |
echo ${{ job.status }} > status_build_test.txt
- name: Upload file status_build_test.txt as an artifact
if: always()
uses: actions/upload-artifact@v1
with:
name: pass_status_build_test
path: status_build_test.txt

读取状态

1
2
3
4
5
6
7
8
- name: Download artifact pass_status_build_test
uses: actions/download-artifact@v1
with:
name: pass_status_build_test
- name: Set the statuses of Job build output parameters
id: set_outputs
run: |
echo "::set-output name=status_build_test::$(<pass_status_build_test/status_build_test.txt)"

附录-完整版

附上我当前使用的完整脚本,持续完善中。

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
name: iOS starter workflow

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
build:
name: Build and Test default scheme using any available iPhone simulator
runs-on: macos-latest

steps:
- name: Checkout
uses: actions/checkout@v2
- name: Install Dependencies
run: |
pod install --repo-update
- name: Set Default Scheme
run: |
scheme_list=$(xcodebuild -list -json | tr -d "\n")
default=$(echo $scheme_list | ruby -e "require 'json'; puts JSON.parse(STDIN.gets)['project']['targets'][0]")
echo $default | cat >default
echo Using default scheme: $default
- name: Build
env:
scheme: ${{ 'default' }}
platform: ${{ 'iOS Simulator' }}
run: |
# xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959)
# device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}'`
# 直接指定 iPhone 11
device='iPhone 11'
if [ $scheme = default ]; then scheme=$(cat default); fi
if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi
file_to_build=`echo $file_to_build | awk '{$1=$1;print}'`
xcodebuild build-for-testing -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,name=$device"
- name: Test
env:
scheme: ${{ 'default' }}
platform: ${{ 'iOS Simulator' }}
run: |
# xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959)
# device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}'`
# 直接指定 iPhone 11
device='iPhone 11'
if [ $scheme = default ]; then scheme=$(cat default); fi
if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi
file_to_build=`echo $file_to_build | awk '{$1=$1;print}'`
xcodebuild test-without-building -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,name=$device"
- name: Create file status_build_test.txt and write the job status into it
if: always()
run: |
echo ${{ job.status }} > status_build_test.txt
- name: Upload file status_build_test.txt as an artifact
if: always()
uses: actions/upload-artifact@v1
with:
name: pass_status_build_test
path: status_build_test.txt
# 因为 notify-slack-action 跑在 Linux 系统所以另起一个 Job
notify:
needs: [ build ]
if: always()
name: Notify Slack
runs-on: ubuntu-latest
steps:
- name: Download artifact pass_status_build_test
uses: actions/download-artifact@v1
with:
name: pass_status_build_test
- name: Set the statuses of Job build output parameters
id: set_outputs
run: |
echo "::set-output name=status_build_test::$(<pass_status_build_test/status_build_test.txt)"
- name: Notify Slack
uses: ravsamhq/notify-slack-action@v1
if: always()
with:
status: ${{ steps.set_outputs.outputs.status_build_test }}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}