利用 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
job

GitHub Actions 使用

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

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

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

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

触发条件

name: iOS starter workflow

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

Step 整理

构建环境

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 安装依赖。

      - 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 的形式了。

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

默认 Scheme

      - 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 作为模拟器。

      - 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

      - 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

保存状态

      - 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

读取状态

      - 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: 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 }}