GitHub ActionsでiOSアプリのCI環境を構築する方法

## はじめに

GitHub Actionsを使い、iOSアプリのビルドと単体テストを行うCIを構築します。

## 「GitHub Actions」とは？

GitHubが提供しているクラウドのCI/CDサービスです。

2019/11/14に一般公開されました。
https://twitter.com/GitHubJapan/status/1194674026607562753?s=20

## 環境

- Xcode：11.6 (11E708)
- Swift：5.2.4

## 注意

今回紹介するCIはもっとブラッシュアップできると思います。
あくまでGitHub ActionsでiOSアプリをCIする参考としてください。
この記事も随時更新していく予定です。

## 設定ファイルの構成

ワークフローはYAMLファイルに記述します。
最初に全体の構成を説明します。

```yaml:ci.yml
name: CI

on:
  …

env:
  DEVELOPER_DIR: /Applications/Xcode_11.6.app

jobs:
  test:
    runs-on: macOS-latest
    env:
      MINT_PATH: mint/lib
      MINT_LINK_PATH: mint/bin
    steps:
      …
```

|名前|説明|
|:--|:--|
|name|ワークフローの名前|
|on|ワークフロー実行のトリガー<br /> `push` や `pull_request` などが指定できる|
|env|全ジョブで使う環境変数<br /> `DEVELOPER_DIR` でXcodeのパスを指定している。本記事ではジョブが1つしかないが、Xcodeのバージョンは全ジョブで統一したいため、ジョブごとに指定していない|
|jobs|ワークフローのジョブ|
|jobs > runs-on|対象ジョブを実行するマシン<br />iOSアプリはmacOSでしかビルドできないため `macOS-latest` を指定している|
|jobs > env|対象ジョブで使う環境変数<br /> `MINT_PATH` でMintのビルドキャッシュのパスを指定している<br /> `MINT_LINK_PATH` でMintのグローバルインストールのシンボリックリンクのパスを指定している。グローバルインストールしないので指定は不要かもしれない|
|jobs > steps|パイプラインのステップ|

詳細は以下をご参照ください。
https://help.github.com/ja/actions/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions

## トリガー

トリガーはAndroidのCIとほぼ同様にしているので、私が書いた以下の記事をご参照ください。
https://qiita.com/uhooi/items/70ffe67ba65d33189db2#on

## ステップの紹介

ワークフロー内のステップを1つずつ紹介します。

ステップは `steps` に記述します。

```yaml:ci.yml
name: CI

on:
  …

env:
  …

jobs:
  test:
    runs-on: macOS-latest
    steps:
      # ここにステップを記述する
      …
```

### チェックアウト

まずはチェックアウトです。
GitHubリポジトリからソースを取得します。

定義されているアクションは `uses` で使えます。

```yaml:ci.yml
- uses: actions/checkout@v2
```

### Xcodeの選択（不要）

~~どのXcodeを使うか選択します。~~

[コメント](https://qiita.com/uhooi/items/29664ecf0254eb637951#comment-8b4ad5348b5074d2b067) を頂いて初めて知ったのですが、 `DEVELOPER_DIR` という環境変数でXcodeのパスを指定できます。
`sudo` を使わずに指定できるため、環境変数を使った方がよさそうです。

```yaml:ci.yml(このスクリプトの実行は不要ですが、一応残しておきます)
- name: Set Xcode Path
  run: sudo xcode-select --switch /Applications/Xcode_11.6.app/Contents/Developer
```

### Xcodeの一覧出力（任意）

CIマシンにインストールされているXcodeの一覧を出力します。

```yaml:ci.yml
- name: Show Xcode list
  run: ls /Applications | grep 'Xcode'
```

必須ではありませんが、毎回実行するとどのタイミングでXcodeのバージョンが増減したかわかるので便利です。

### Xcodeのバージョン出力（任意）

現在使われているXcodeのバージョンを出力します。

```yaml:ci.yml
- name: Show Xcode version
  run: xcodebuild -version
```

こちらも必須ではありませんが、 `DEVELOPER_DIR` が問題なく指定されているかの確認や、CI失敗時のXcodeのバージョン確認に使えるので便利です。

### Ruby製ライブラリのキャッシュ

Bundlerで管理しているライブラリのキャッシュを復元します。

公式が用意している `actions/cache@v2` アクションを使います。
公式の例からコピペするだけでキャッシュできます。
https://github.com/actions/cache/blob/master/examples.md#ruby---gem

`name` に任意のステップ名を付けられます。
他のキャッシュと区別するため、私は何のキャッシュを復元するか記述しています。

```yaml:ci.yml
- name: Cache Gems
  uses: actions/cache@v2
  with:
    path: vendor/bundle
    key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }}
    restore-keys: |
      ${{ runner.os }}-gems-
```

ちなみにキャッシュの取得はワークフローの最後に自動で行ってくれます。
非常に便利です。

### Ruby製ライブラリのインストール

Bundlerで管理しているライブラリをインストールします。

`run` にシェルスクリプトを指定して実行します。

```yaml:ci.yml
- name: Install Bundled Gems
  run: |
    bundle config path vendor/bundle
    bundle install --jobs 4 --retry 3
```

キャッシュを取得するため、ライブラリのインストールパスを明示的に指定しています。
https://github.com/actions/cache/blob/master/examples.md#ruby---gem

Gemfileは以下の通りです。

```ruby:Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "xcpretty"
gem "cocoapods"
gem "slather"
```

各ライブラリの用途は使うときに説明します。

### Mintのインストール

MintはSwift製コマンドラインツールの管理ツールです。
デフォルトではインストールされていないので、Homebrewからインストールします。

```yaml:ci.yml
- name: Install Mint
  run: brew install mint
```

私が個人で開発しているアプリのMintfileは以下の通りです。

```:Mintfile
realm/SwiftLint@0.38.0
uber/mockolo@1.1.1
fromkk/SpellChecker@0.0.3
Carthage/Carthage@0.34.0
yonaskolb/xcodegen@2.11.0
mono0926/LicensePlist@2.12.0
```

### Mintで管理しているライブラリのキャッシュ

Mintで管理しているライブラリのキャッシュを復元します。
公式に例がないので自分で考えました。

せっかく考えたので、公式にドキュメント更新の [PR](https://github.com/actions/cache/pull/378) を送ったのですが、2020/08/26現在はマージされていません。

```yaml:ci.yml
- name: Cache Mint packages
  uses: actions/cache@v2
  with:
    path: mint
    key: ${{ runner.os }}-mint-${{ hashFiles('**/Mintfile') }}
    restore-keys: |
      ${{ runner.os }}-mint-
```

Mintは `mint run` 時にキャッシュの有無を判断してインストールするので、 `mint bootstrap` は実行しないことにします。

### Carthageで管理しているライブラリのキャッシュ

Carthageで管理しているライブラリのキャッシュを復元します。

CocoaPodsと同様、公式の例からコピペしてステップ名を付けます。
https://github.com/actions/cache/blob/master/examples.md#swift-objective-c---carthage

```yaml:ci.yml
- name: Cache Carthage packages
  uses: actions/cache@v2
  with:
    path: Carthage
    key: ${{ runner.os }}-carthage-${{ hashFiles('**/Cartfile.resolved') }}
    restore-keys: |
      ${{ runner.os }}-carthage-
```

### Carthageで管理しているライブラリのインストール

Carthageで管理しているライブラリをインストールします。
私はMintでCarthageを管理しているため、先頭に `mint run carthage` を付けています。

```yaml:ci.yml
- name: Install Carthage frameworks
  run: |
    mint run carthage carthage bootstrap --platform iOS --cache-builds
    echo '*** Resolved dependencies:'
    cat 'Cartfile.resolved'
  env:
    GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

`--cache-builds` でキャッシュしているライブラリのビルドをスキップします。
キャッシュヒットを見てステップごとスキップするより安全です。

CarthageはGitHubへのアクセス数が多いため、`GITHUB_ACCESS_TOKEN` を指定することで、呼び出し制限に引っかからないようにしています。

### ライセンス表示の生成

LicensePlistでOSSのライセンス表示を生成します。

```yaml:ci.yml
 - name: Generate licenses with LicensePlist
  run: mint run LicensePlist license-plist --output-path {製品ターゲット名}/Settings.bundle --github-token ${{ secrets.GITHUB_TOKEN }}
```

### プロジェクトファイルの生成

XcodeGenでプロジェクトファイルを生成します。

```yaml:ci.yml
- name: Generate Xcode project with XcodeGen
  run: mint run xcodegen xcodegen generate
```

### CocoaPodsで管理しているライブラリのキャッシュ

CocoaPodsで管理しているライブラリのキャッシュを復元します。

Bundlerと同様、公式の例からコピペしてステップ名を付けます。
https://github.com/actions/cache/blob/master/examples.md#swift-objective-c---cocoapods

```yaml:ci.yml
- name: Cache Pods
  uses: actions/cache@v2
  with:
    path: Pods
    key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}
    restore-keys: |
      ${{ runner.os }}-pods-
```

### CocoaPodsで管理しているライブラリのインストール

CocoaPodsで管理しているライブラリをインストールします。
私はBundlerでCocoaPodsを管理しているため、先頭に `bundle exec` を付けています。

Bundlerについては以下の記事をご参照ください。
https://qiita.com/uhooi/items/4abf8c282ae23a259e4f

```yaml:ci.yml
- name: Install Pods
  run: bundle exec pod install
```

### ビルド

ビルドを実行し、ビルドエラーにならないか確認します。

`run` 内では改行がスペースに変換されるため、可読性のために改行しています。

```yaml:ci.yml
- name: Xcode build
  run: set -o pipefail &&
    xcodebuild
    -sdk iphonesimulator
    -configuration Debug
    -workspace {ワークスペース名}.xcworkspace
    -scheme {スキーム名}
    build
    | bundle exec xcpretty
```

CocoaPodsなどでワークスペースを使っている場合、ワークスペースとスキームの指定が必要です。

`xcodebuild` コマンドを使ってビルドしていますが、デフォルトだとログの内容が煩雑で読みづらいです。
そのため、ログ整形ツールの `xcpretty` を噛ませて読みやすくしています。

GitHub ActionsのmacOSではデフォルトで `xcpretty` がインストールされているのですが、できる限りバージョンをロックしたいため、Bundlerでインストールして `bundle exec xcpretty` で実行しています。

コマンドをパイプで繋げると、最後のコマンドのエラー判定が使われます。
`xcpretty` は `xcodebuild` の失敗にかかわらず成功するため、ビルドエラーでもワークフローが成功扱いになってしまいます。
そのため、 `set -o pipefail && ` を先頭に付けています。

### 単体テストの実行

単体テストを実行します。

```yaml:ci.yml
- name: Xcode test
  run: set -o pipefail &&
    xcodebuild
    -sdk iphonesimulator
    -configuration Debug
    -workspace {ワークスペース名}.xcworkspace
    -scheme {スキーム名}
    -destination 'platform=iOS Simulator,name=iPhone 11 Pro Max'
    -skip-testing:{UIテスト}
    clean test
    | bundle exec xcpretty --report html
```

単体テストの実行も `xcodebuild` コマンドを使います。
今回はiPhone 11 Pro Maxのシミュレータで実行しています。

`-skip-testing` オプションでUIテストの実行をスキップしています。
私の場合、UIテストは単体テストほど頻繁には実施しません。

`xcpretty --report html` で単体テストの結果をHTMLで出力します。

### コードカバレッジの出力

Slatherを使ってコードカバレッジをHTMLに変換します。

```yaml:ci.yml
- name: Convert Code coverage to HTML
  run: bundle exec slather coverage --html --output-directory html_report
```

`--html` オプションを付けることで、コードカバレッジをHTMLで出力します。

Slatherの設定ファイルは以下の通りです。

```yaml:.slather.yml
coverage_service: cobertura_xml
xcodeproj: {プロジェクト名}.xcodeproj
workspace: {ワークスペース名}.xcworkspace
scheme: {スキーム名}
```

Slatherの詳細は以下の記事をご参照ください。
https://qiita.com/uhooi/items/e1e464777d2163286c59

### テスト結果のアップロード

テスト結果をアーティファクト化してアップロードします。

```yaml:ci.yml
- uses: actions/upload-artifact@v2
  with:
    name: test-results
    path: build/reports
```

`name` に任意のアーティファクト名、 `path` にアップロードしたいファイルやフォルダのパスを指定します。
xcprettyで出力したテスト結果は `build/reports` フォルダに配置されるので、そこを指定すればOKです。

### コードカバレッジのアップロード

テスト結果と同様、コードカバレッジをアーティファクト化してアップロードします。

```yaml:ci.yml
- uses: actions/upload-artifact@v2
  with:
    name: test-coverage
    path: html_report
```

フォルダは先ほど `--output-directory` オプションで指定した `html_report` を指定します。

## 全体図

最後に設定ファイルの全体図を載せます。

```yaml:ci.yml
name: CI

on:
  push:
    branches:
      - master
      - develop
    paths-ignore:
      - Docs/**
      - README.md
      - LICENSE
      - Rambafile
  pull_request:
    branches:
      - develop
    paths-ignore:
      - Docs/**
      - README.md
      - LICENSE
      - Rambafile

env:
  DEVELOPER_DIR: /Applications/Xcode_11.6.app

jobs:
  test:
    runs-on: macOS-latest
    env:
      MINT_PATH: mint/lib
      MINT_LINK_PATH: mint/bin

    steps:
    # チェックアウト
    - uses: actions/checkout@v2

    # Xcodeの一覧出力
    - name: Show Xcode list
      run: ls /Applications | grep 'Xcode'

    # Xcodeのバージョン出力
    - name: Show Xcode version
      run: xcodebuild -version

    # Gemsのキャッシュ
    - name: Cache Gems
      uses: actions/cache@v2
      with:
        path: vendor/bundle
        key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }}
        restore-keys: |
          ${{ runner.os }}-gems-

    # Gemsのインストール
    - name: Install Bundled Gems
      run: |
        bundle config path vendor/bundle
        bundle install --jobs 4 --retry 3

    # Mintのインストール
    - name: Install Mint
      run: brew install mint

    # Mintで管理しているライブラリのキャッシュ
    - name: Cache Mint packages
      uses: actions/cache@v2
      with:
        path: mint
        key: ${{ runner.os }}-mint-${{ hashFiles('**/Mintfile') }}
        restore-keys: |
          ${{ runner.os }}-mint-

    # Carthageで管理しているライブラリのキャッシュ
    - name: Cache Carthage packages
      uses: actions/cache@v2
      with:
        path: Carthage
        key: ${{ runner.os }}-carthage-${{ hashFiles('**/Cartfile.resolved') }}
        restore-keys: |
          ${{ runner.os }}-carthage-

    # Carthageで管理しているライブラリのインストール
    - name: Install Carthage frameworks
      run: |
        mint run carthage carthage bootstrap --platform iOS --cache-builds
        echo '*** Resolved dependencies:'
        cat 'Cartfile.resolved'
      env:
        GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }}

    # ライセンス表示の生成
    - name: Generate licenses with LicensePlist
      run: mint run LicensePlist license-plist --output-path {製品ターゲット名}/Settings.bundle --github-token ${{ secrets.GITHUB_TOKEN }}

    # プロジェクトファイルの生成
    - name: Generate Xcode project with XcodeGen
      run: mint run xcodegen xcodegen generate

    # CocoaPodsで管理しているライブラリのキャッシュ
    - name: Cache Pods
      uses: actions/cache@v2
      with:
        path: Pods
        key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}
        restore-keys: |
          ${{ runner.os }}-pods-

    # CocoaPodsで管理しているライブラリのインストール
    - name: Install Pods
      run: bundle exec pod install

    # ビルド
    - name: Xcode build
      run: set -o pipefail &&
        xcodebuild
        -sdk iphonesimulator
        -configuration Debug
        -workspace {ワークスペース名}.xcworkspace
        -scheme {スキーム名}
        build
        | bundle exec xcpretty

    # 単体テストの実行
    - name: Xcode test
      run: set -o pipefail &&
        xcodebuild
        -sdk iphonesimulator
        -configuration Debug
        -workspace {ワークスペース名}.xcworkspace
        -scheme {スキーム名}
        -destination 'platform=iOS Simulator,name=iPhone 11 Pro Max'
        -skip-testing:{UIテスト}
        clean test
        | bundle exec xcpretty --report html

    # コードカバレッジの出力
    - name: Convert Code coverage to HTML
      run: bundle exec slather coverage --html --output-directory html_report

    # テスト結果のアップロード
    - uses: actions/upload-artifact@v2
      with:
        name: test-results
        path: build/reports

    # コードカバレッジのアップロード
    - uses: actions/upload-artifact@v2
      with:
        name: test-coverage
        path: html_report
```

シンプルなYAMLファイルなので、慣れれば読みやすいと思います。

## アーティファクトの確認

アップロードしたアーティファクトはGitHub Actions上からダウンロードできます。
<img width="1280" alt="スクリーンショット_2019-12-24_0_17_03.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138245/d5899753-d4de-1fd3-f0cc-81d3f2432aba.jpeg">

__テスト結果__
<img width="1280" alt="スクリーンショット_2019-12-24_0_35_56.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138245/114900d6-0ffe-dc23-379f-7240531a35d7.jpeg">

__コードカバレッジ__
<img width="1280" alt="スクリーンショット_2019-12-24_0_36_13.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138245/4fc954e3-661b-afd8-14fc-13af68235fa1.jpeg">

テスト結果とコードカバレッジをHTMLでいい感じに出力できました🎉

S3などにアップロードし、直接HTMLを確認できるようにするとさらによさそうです。

## 他にやりたいこと

時間の都合上で実現できていないことを、備忘録として残します。

### 静的解析

Lintなどで静的解析します。

- SwiftLint
- SpellChecker

### Slackに通知

__通知は多過ぎると読まれない__ ため、アクションを起こす必要がある場合のみSlackに通知します。
具体的には以下の通りです。

- 静的解析で警告やエラーが発生する
  - ファイル名
  - クラス名
  - メソッド名
  - 行数
  - メッセージ
- ビルドに失敗する  
  - エラーメッセージ
- ビルドで警告が発生する
  - 警告メッセージ
- 失敗するテストがある
  - ファイル名
  - クラス名
  - メソッド名
  - 行数
  - 失敗メッセージ
- コードカバレッジが80%以下のファイルがある
  - ファイル名
  - コードカバレッジ

## おまけ：公式のアクション

公式が用意している `actions/*` アクションは、OSSとしてGitHubに公開されています。
本記事で使っている公式のアクションは以下の3つです。

- actions/checkout  
https://github.com/actions/checkout
- actions/cache  
https://github.com/actions/cache
- actions/upload-artifact  
https://github.com/actions/upload-artifact

他によく使われる公式のアクションは、 `actions/setup-java` （AndroidアプリのCIに必要）や `actions/github-script` （Issueの作成などをトリガーにスクリプトを実行する）などがあります。

- actions/setup-java  
https://github.com/actions/setup-java
- actions/github-script  
https://github.com/actions/github-script

メジャーバージョンの更新が行われることもあるので、リリースをウォッチしておくと、バージョンアップに気づいてすぐに対応できます。
使い方が変わることもありますが、基本的にはバージョンを上げるだけで最新バージョンを使えます。

```diff_yaml:例：actions/checkoutのバージョンを1から2に更新する
- - uses: actions/checkout@v1
+ - uses: actions/checkout@v2
```

## おわりに

GitHub Actionsで基本的なiOSアプリのCIを回すことができました！

ほぼ全てシェルのコマンドをラップせずに使っているため、GitHub Actions以外のサービスを使っていても役立つ内容となっています😊

## 参考リンク

- [GitHub Actionsについて - GitHub ヘルプ](https://help.github.com/ja/actions/automating-your-workflow-with-github-actions/about-github-actions)
- [依存関係をキャッシュしてワークフローのスピードを上げる - GitHub ヘルプ](https://help.github.com/ja/actions/automating-your-workflow-with-github-actions/caching-dependencies-to-speed-up-workflows#using-the-cache-action)
- [Add Mint example by uhooi · Pull Request #378 · actions/cache](https://github.com/actions/cache/pull/378)  
Mintをキャッシュする例が記載されていなかったため、 `README` を編集してPRを出しました
- [Alamofire/ci.yml at master · Alamofire/Alamofire](https://github.com/Alamofire/Alamofire/blob/master/.github/workflows/ci.yml)
- [Mintについて調べてみた - ryokosuge.io - Medium](https://medium.com/ryokosuge-io/mintについて調べてみた-af180ec45d67)