Azure PipelinesのYAMLでiOSアプリのCI環境を構築する方法

「Azure PipelinesのYAMLでiOSアプリのCI/CD環境を構築する」は3部構成です。
記事を順番に読み進めると、Azure PipelinesでiOSアプリのCI/CD環境が構築できるようになります。

- 第一部：CI環境の構築　__←イマココ__
- 第二部：[App Center配布パイプラインの構築](https://qiita.com/uhooi/items/1245344a6add3e86cdc8)
- 第三部：[TestFlight配布パイプラインの構築](https://qiita.com/uhooi/items/f421fcd02a6594653924)

## はじめに

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

## 本記事で説明しないこと

- Azure Pipelinesの概要や基本的な操作方法  
[私が以前書いた記事](https://qiita.com/uhooi/items/5ed8db6aaec01425e453)が参考になると思います

## Makefileの作成

まず、ビルドや単体テストなどの実行コマンドをまとめたMakefileを作成します。
スクリプトを直接CIの定義ファイルに記述してもいいのですが、以下の理由から避けています。

- ローカルでもかんたんに各コマンドを実行したい
- 他のCIサービスに移行しやすくしたい

Makefileは長いので、折りたたみます。

<details><summary>Makefile</summary><div>

`PRODUCT_NAME` は自分のプロジェクトの製品名に置き換えてください。

```make:Makefile
PRODUCT_NAME := Foo
SCHEME_NAME := ${PRODUCT_NAME}
UI_TESTS_TARGET_NAME := ${PRODUCT_NAME}UITests

TEST_SDK := iphonesimulator
TEST_CONFIGURATION := Debug
TEST_PLATFORM := iOS Simulator
TEST_DEVICE ?= iPhone 12 Pro Max
TEST_OS ?= 14.4
TEST_DESTINATION := 'platform=${TEST_PLATFORM},name=${TEST_DEVICE},OS=${TEST_OS}'
COVERAGE_OUTPUT ?= html_report

XCODEBUILD_BUILD_LOG_NAME := xcodebuild_build.log
XCODEBUILD_TEST_LOG_NAME := xcodebuild_test.log

.PHONY: install-bundler
install-bundler: # Install Bundler dependencies
	bundle config path vendor/bundle
	bundle install --jobs 4 --retry 3

.PHONY: generate-licenses
generate-licenses: # Generate licenses with LicensePlist
	mint run LicensePlist license-plist --output-path ${PRODUCT_NAME}/Settings.bundle

.PHONY: generate-xcodeproj
generate-xcodeproj: # Generate project with XcodeGen
	mint run xcodegen xcodegen generate

.PHONY: build-debug
build-debug: # Xcode build for debug
	set -o pipefail && \
xcodebuild \
-sdk ${TEST_SDK} \
-configuration ${TEST_CONFIGURATION} \
-project ${PROJECT_NAME} \
-scheme ${SCHEME_NAME} \
-destination ${TEST_DESTINATION} \
-clonedSourcePackagesDirPath './SourcePackages' \
build \
| tee ./${XCODEBUILD_BUILD_LOG_NAME} \
| bundle exec xcpretty --color

.PHONY: test
test: # Xcode test # TEST_DEVICE=[device] TEST_OS=[OS]
	set -o pipefail && \
xcodebuild \
-sdk ${TEST_SDK} \
-configuration ${TEST_CONFIGURATION} \
-project ${PROJECT_NAME} \
-scheme ${SCHEME_NAME} \
-destination ${TEST_DESTINATION} \
-skip-testing:${UI_TESTS_TARGET_NAME} \
-clonedSourcePackagesDirPath './SourcePackages' \
clean test \
| tee ./${XCODEBUILD_TEST_LOG_NAME} \
| bundle exec xcpretty --color --report html

.PHONY: get-coverage-html
get-coverage-html: # Get code coverage for HTML # COVERAGE_OUTPUT=[output directory]
	bundle exec slather coverage --html --output-directory ${COVERAGE_OUTPUT}

.PHONY: get-coverage-cobertura
get-coverage-cobertura: # Get code coverage for Cobertura
	bundle exec slather

.PHONY: show-devices
show-devices: # Show devices
	xcrun xctrace list devices
```

</div></details>

今回のメインはAzure Pipelinesなので、Makefileの内容は解説しません。
[私が以前書いたGitHub Actionsの記事](https://qiita.com/uhooi/items/29664ecf0254eb637951)が参考になると思うので、よかったらご参照ください。

今回のMakefileは、本記事で使うコマンドのみを抜粋および編集して紹介しています。
私が普段使っているMakefileの全容は[こちら](https://github.com/uhooi/UhooiPicBook/blob/develop/Makefile)にあるので、よかったら参考にしてください。

## 設定ファイルの構成

今回はYAMLファイルを使ってCI環境を構築します。
Classic Editorを使ってGUIで構築する場合、[私が以前書いた記事](https://qiita.com/uhooi/items/5db5594d0bfc0cc0e27d)が参考になると思います。

最初に全体の構成を説明します。

```yaml
name: $(SourceBranchName)_$(Date:yyyyMMdd)$(Rev:.r)

trigger:
  …

schedules:
  …

variables:
  DEVELOPER_DIR: '/Applications/Xcode_12.4.app'
  MINT_PATH: 'mint/lib'
  MINT_LINK_PATH: 'mint/bin'

jobs:
- job: build
  pool:
    vmImage: 'macos-latest'

  steps:
    …
```

|名前|説明|
|:--|:--|
|name|パイプライン実行の番号|
|trigger|パイプライン実行のトリガー|
|schedules|パイプライン定期実行のスケジュール|
|variables|全ジョブで使う環境変数<br />詳細は [こちら](https://qiita.com/uhooi/items/29664ecf0254eb637951#%E8%A8%AD%E5%AE%9A%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%81%AE%E6%A7%8B%E6%88%90) を参照|
|jobs|ワークフローのジョブ|
|jobs > pool > vmImage|対象ジョブを実行するマシン<br />iOSアプリはmacOSでしかビルドできないため `macos-latest` を指定している|
|jobs > variables|対象ジョブで使う環境変数<br />今回は定義していない|
|jobs > steps|パイプラインのステップ|

## 各項目の紹介

各項目を上から順に紹介します。

### name

パイプラインの実行ごとに振られる番号です。
__パイプライン自体の名前ではない__ のでご注意ください。

省略するとデフォルト値の `$(Date:yyyyMMdd).$(Rev:r)` が適用されます。
例: `#20211006.1`

トリガーとなったブランチを含めるとわかりやすいので、私は `$(SourceBranchName)_$(Date:yyyyMMdd)$(Rev:.r)` を指定しています。
例: `#develop_20211006.1`

他にもいろいろカスタマイズできます。
詳細は公式ドキュメントをご参照ください。

https://docs.microsoft.com/en-us/azure/devops/pipelines/process/run-number?view=azure-devops&tabs=yaml

### trigger

パイプラインを実行するトリガーを設定します。

私は以下のブランチのプッシュをトリガーにしています。

- `main`
- `develop`

`feature/*` ブランチや `release/*` ブランチは、ブランチポリシーを設定してPR時に自動でCIを回すようにしているので、こちらでは指定していません。

```yaml
trigger:
  batch: true
  branches:
    include:
    - main
    - develop
  paths:
    exclude:
    - Docs
    - README.md
    - LICENSE
    - Rambafile
```

`README.md` や `Rambafile` など、iOSアプリに直接関係ないファイルは、プッシュしてもCIが動かないようにしています。

### schedules

パイプラインを定期実行するスケジュールを設定します。

私は毎日0時に以下のブランチをCIするようにしています。

- `develop`  
- `main`

```yaml
schedules:
- cron: "0 15 * * *"
  displayName: Daily midnight build
  branches:
    include:
    - main
    - develop
  always: true
```

cron式で指定する時間はUTCであり、日本時間にするには9時間足す必要があります。
`15:00 UTC` = `00:00 JST`

alwaysで `false` を指定すると、前回の実行から変更があったブランチのみを対象にします。
ライブラリのバージョンが変わらなくてもビルドできなくなるなど、自分のソース以外の変更によってCIが通らなくなることがあるため、 `true` にするのがオススメです。

### variables

「設定ファイルの構成」で説明した通りなので省略します。

ステップ間で環境変数を引き継がないので、以下のように環境変数をエクスポートするステップを作っても意味がありません。

```yaml:×次のステップに環境変数が引き継がれない
steps:
- script: |
    export DEVELOPER_DIR=/Applications/Xcode_12.4.app/Contents/Developer
    export MINT_PATH=mint/lib
    export MINT_LINK_PATH=mint/bin
  displayName: Export environment variables
```

### steps

ステップは各ジョブの `steps` に記述します。

```yaml
jobs:
- job: test
  pool:
    …
  variables:
    …

  steps:
    # ここにステップを記述する
    …
```

#### 注意

- GitHub Actionsと異なり、特別なことをしない限りはチェックアウトのステップは不要

### jobs

今回はジョブを3つ用意しており、順番に紹介します。

#### build

ビルドを実行するジョブです。

[GitHub ActionsでiOSアプリのCIを構築する方法](https://qiita.com/uhooi/items/29664ecf0254eb637951) とほぼ同様なので、全体図のみ紹介します。

セットアップは `test` ジョブでも使うため、テンプレートにしています。

```yaml:templates/setup-template.yml
parameters:
- name: environment
  type: string
- name: destination
  type: string

steps:
# Bundlerで管理しているライブラリのインストール
- script: make install-bundler
  displayName: Bundle install

# Mintのインストール
- script: brew install mint
  displayName: Install Mint

# ライセンス情報の生成
- script: make generate-licenses
  displayName: Generate licenses

# プロジェクトファイルの生成
- script: make generate-xcodeproj-${{ parameters.environment }}-${{ parameters.destination }}
  displayName: Generate Xcode project for ${{ parameters.environment }} and ${{ parameters.destination }}
```

```yaml:ci.yml
  # セットアップ
  - template: templates/setup-template.yml
    parameters:
      environment: 'develop'
      destination: 'appcenter'

  # ビルド
  - script: make build-debug
    displayName: Xcode build for debug

  # ログをアーティファクトのステージングへコピー
  - task: CopyFiles@2
    inputs:
      contents: '**/xcodebuild_build.log'
      targetFolder: '$(Build.ArtifactStagingDirectory)'
    condition: failed()

  # ログのアップロード
  - task: PublishBuildArtifacts@1
    inputs:
      pathtoPublish: '$(Build.ArtifactStagingDirectory)'
      artifactName: 'drop'
      publishLocation: 'Container'
    condition: failed()
```

#### test

単体テストを実行するジョブです。

`build` ジョブと同様、詳細は省略します。

```yaml:ci.yml
- job: test
  pool:
    vmImage: 'macos-latest'

  steps:
  # セットアップ
  - template: templates/setup-template.yml
    parameters:
      environment: 'develop'
      destination: 'appcenter'

  # 単体テストの実行
  - script: make test
    displayName: Xcode test

  # ログをアーティファクトのステージングへコピー
  - task: CopyFiles@2
    inputs:
      contents: '**/xcodebuild_test.log'
      targetFolder: '$(Build.ArtifactStagingDirectory)'
    condition: failed()

  # ログのアップロード
  - task: PublishBuildArtifacts@1
    inputs:
      pathtoPublish: '$(Build.ArtifactStagingDirectory)'
      artifactName: 'drop'
      publishLocation: 'Container'
    condition: failed()

  # コードカバレッジをHTML形式で取得
  - script: make get-coverage-html
    displayName: Get code coverage for HTML

  # テスト結果とコードカバレッジをアーティファクトのステージングへコピー
  - task: CopyFiles@2
    inputs:
      Contents: |
        **/build/reports/**/*
        **/html_report/**/*
      TargetFolder: '$(Build.ArtifactStagingDirectory)'

  # アーティファクトへアップロード
  - task: PublishBuildArtifacts@1
    inputs:
      PathtoPublish: '$(Build.ArtifactStagingDirectory)'
      ArtifactName: 'drop'
      publishLocation: 'Container'

  # コードカバレッジをCobertura形式で取得
  - script: make get-coverage-cobertura
    displayName: Get code coverage for Cobertura

  # コードカバレッジのアップロード
  - task: PublishCodeCoverageResults@1
    inputs:
      codeCoverageTool: 'cobertura'
      summaryFileLocation: '$(System.DefaultWorkingDirectory)/xml_report/cobertura.xml'
      failIfCoverageEmpty: true
```

#### info

CIに関係ない情報を出力するジョブです。

`build` や `test` ジョブと同様、詳細は省略します。

他のワークフローでも使うため、テンプレートにしています。

```yaml:templates/info-template.yml
steps:
# Xcodeのバージョン出力
- script: xcodebuild -version
  displayName: Show Xcode version

# Makeのバージョン出力
- script: make --version
  displayName: Show Make version

# Rubyのバージョン出力
- script: ruby --version
  displayName: Show Ruby version

# Bundlerのバージョン出力
- script: bundle version
  displayName: Show Bundler version

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

# 端末の一覧出力
- script: make show-devices
  displayName: Show devices
```

```yaml:ci.yml
- job: info
  pool:
    vmImage: 'macos-latest'

  steps:
  # 情報の出力
  - template: templates/info-template.yml
```

## 設定ファイルの全体図

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

```yaml
name: $(SourceBranchName)_$(Date:yyyyMMdd)$(Rev:.r)

trigger:
  batch: true
  branches:
    include:
    - main
    - develop
  paths:
    exclude:
    - Docs
    - README.md
    - LICENSE
    - Rambafile

schedules:
- cron: "0 15 * * *"
  displayName: Daily midnight build
  branches:
    include:
    - main
    - develop
  always: true

variables:
  DEVELOPER_DIR: '/Applications/Xcode_12.4.app'
  MINT_PATH: 'mint/lib'
  MINT_LINK_PATH: 'mint/bin'

jobs:
- job: build
  pool:
    vmImage: 'macos-latest'

  steps:
  # セットアップ
  - template: templates/setup-template.yml
    parameters:
      environment: 'develop'
      destination: 'appcenter'

  # ビルド
  - script: make build-debug
    displayName: Xcode build for debug

  # ログをアーティファクトのステージングへコピー
  - task: CopyFiles@2
    inputs:
      contents: '**/xcodebuild_build.log'
      targetFolder: '$(Build.ArtifactStagingDirectory)'
    condition: failed()

  # ログのアップロード
  - task: PublishBuildArtifacts@1
    inputs:
      pathtoPublish: '$(Build.ArtifactStagingDirectory)'
      artifactName: 'drop'
      publishLocation: 'Container'
    condition: failed()

- job: test
  pool:
    vmImage: 'macos-latest'

  steps:
  # セットアップ
  - template: templates/setup-template.yml
    parameters:
      environment: 'develop'
      destination: 'appcenter'

  # 単体テストの実行
  - script: make test
    displayName: Xcode test

  # ログをアーティファクトのステージングへコピー
  - task: CopyFiles@2
    inputs:
      contents: '**/xcodebuild_test.log'
      targetFolder: '$(Build.ArtifactStagingDirectory)'
    condition: failed()

  # ログのアップロード
  - task: PublishBuildArtifacts@1
    inputs:
      pathtoPublish: '$(Build.ArtifactStagingDirectory)'
      artifactName: 'drop'
      publishLocation: 'Container'
    condition: failed()

  # コードカバレッジをHTML形式で取得
  - script: make get-coverage-html
    displayName: Get code coverage for HTML

  # テスト結果とコードカバレッジをアーティファクトのステージングへコピー
  - task: CopyFiles@2
    inputs:
      Contents: |
        **/build/reports/**/*
        **/html_report/**/*
      TargetFolder: '$(Build.ArtifactStagingDirectory)'

  # アーティファクトへアップロード
  - task: PublishBuildArtifacts@1
    inputs:
      PathtoPublish: '$(Build.ArtifactStagingDirectory)'
      ArtifactName: 'drop'
      publishLocation: 'Container'

  # コードカバレッジをCobertura形式で取得
  - script: make get-coverage-cobertura
    displayName: Get code coverage for Cobertura

  # コードカバレッジのアップロード
  - task: PublishCodeCoverageResults@1
    inputs:
      codeCoverageTool: 'cobertura'
      summaryFileLocation: '$(System.DefaultWorkingDirectory)/xml_report/cobertura.xml'
      failIfCoverageEmpty: true

- job: info
  pool:
    vmImage: 'macos-latest'

  steps:
  # 情報の出力
  - template: templates/info-template.yml
```

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

## おまけ: PR時にCIを回す

PR時に自動でCIを回し、成功しないとマージできないように設定できます。
詳細は以下の記事をご参照ください。
https://qiita.com/uhooi/items/826e624f36e9a230ec3c

## おわりに

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

キャッシュが取れていなかったり、静的解析していなかったりと改善点もありますが、参考になれば幸いです。

## 参考リンク

- [YAML schema - Azure Pipelines | Microsoft Docs](https://docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema?view=azure-devops&tabs=schema%2Cparameter-schema)
- [Pipeline caching - Azure Pipelines | Microsoft Docs](https://docs.microsoft.com/en-us/azure/devops/pipelines/release/caching?view=azure-devops)