GitHub Actions で Xcode のインクリメンタルビルドを実現する (xcode-cache アクション)


GitHub Actions で iOS アプリをビルドするときの Xcode のインクリメンタルビルドを有効にするためのキャッシュ設定について解説します。

CI でのビルドで Xcode のインクリメンタルビルドが使えるようになれば、毎回 CI 上でフルビルドし40分程度かかっていたプロジェクトが、差分のみのビルドでビルド時間が5分に短縮されたりすることが期待できます。

# 環境

この記事では、以下の環境で調査・検証した結果を記載しています。

* ローカル環境
    * macOS Ventura 13.5.1
    * Xcode 14.3.1 (14E300c)
    * APFS (Encrypted / Case Insentive)
* GitHub Actions 環境
    * macos-latest
        * macOS Monterey 12.6.8
        * Xcode 14.2.0 (14C18)

# 結論

結論としては xcode-cache Action の使用がおすすめです。
この記事後半で解説する Xcode のインクリメンタルビルドの仕組みを理解すれば xcode-cache Action を使用しなくてもインクリメンタルビルドを実現することはできます。

xcode-cache Action ではこの記事で解説している内容をふまえてキャッシュを保存してくれます。

## xcode-cache GitHub Action

GitHub Actions で Xcode のインクリメンタルビルドを実現する xcode-cache GitHub Action をリリースしました。

https://github.com/irgaly/xcode-cache

### xcode-cache Workflow 設定

GitHub Actions Workflow に `irgaly/xcode-cache` アクションを設定します。

```yaml
...
    steps:
      - uses: actions/checkout@v4
      ...
      # SwiftGen のコード生成などがあればここで対応
      ...
      # DerivedData、SourcePacakegs とソースコードの mtime の復帰
      - uses: irgaly/xcode-cache@v1
        with:
          key: xcode-cache-deriveddata-${{ github.workflow }}-${{ github.sha }}
          restore-keys: xcode-cache-deriveddata-${{ github.workflow }}-
      # fastlane などでアプリをビルドする
      - name: Build Apps
        run: |
          fastlane ...
      ...
      # irgaly/xcode-cache Post 処理で DerivedData などがキャッシュされます
      ...
```

`iragly/xcode-cache` アクションにより、Xcode のインクリメンタルビルドが使えるように Xcode の中間生成物をキャッシュし、`irgaly/xcode-cache` step でキャッシュを復元してくれます。

キャッシュキーとなる `key` は任意の文字列を設定できますが、ここではおすすめの設定を載せています。
インクリメンタルビルドに使用する中間生成物のキャッシュは、直前のビルドで保存したものを取り出して使用し、ビルドのたびに新しいキャッシュを保存する必要があるため、キャッシュキーに `${{ github.sha }}` を含め、`restore-keys` で直前のビルドのキャッシュを復元できるようにしています。


# 解説

Xcode で iOS アプリをビルドするとき、通常であれば Xcode はインクリメンタルビルドが有効となります。インクリメンタルビルドではビルドの中間生成物を保存し、そのあとのビルドでは変更のあったソースコードや Asset など前回のビルドからの差分のみを処理することでビルド時間が短縮されます。

ローカルでビルドをするときにはインクリメンタルビルドはうまく機能しますが、Xcode は CI 上でのインクリメンタルビルドは想定されておらず、CI で中間生成物をキャッシュしていてもインクリメンタルビルドしてくれません。

## CI でインクリメンタルビルドされない原因

Xcode はソースコードやリソースのファイルの mtime (最終更新日) を見ることでビルドのもととなるファイルが変更されたかを検出しています。

CI ではビルドのたびに git からソースコードを clone しますが、git はファイルの最終更新日を保持していないため checkout した時刻をファイルの最終更新日としてファイルを配置します。

CI ではXcode の中間生成物をキャッシュしていたとしても、ビルドするたびにすべてのソースコードやリソースの最終更新日がキャッシュよりも新しくなっているためすべてのファイルに変更があったとみなし、毎回フルビルドが実行されることになります。

## mtime について

Xcode は中間生成物のすべてを DerivedData ディレクトリ (通常は `~/Library/Developer/Xcode/DerivedData`) に保存します。

たとえば M1 Mac で `MyApp` プロジェクトをシミュレータ向けに Debug ビルドしたとき、以下のファイルにソースコードの mtime が保存されています。

`~/Library/Developer/Xcode/DerivedData/MyApp-{ID}/Build/Intermediates.noindex/MyApp.build/Debug-iphoneos/MyApp.build/Objects-normal/arm64/MyApp-master.swiftdeps`

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/9e9d0185-851b-683c-9df8-5fb950b919f7.png)

mtime はナノ秒精度で保存されています。たとえばここでは `MyAppApp.swift` の最終更新日が UNIX 時間で 1694751978.543212000 秒であることを示しています。

>  "/Users/.../xcode-cache/sample/MyApp/MyApp/MyAppApp.swift": [1694751978, 543212000]

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/1028f00a-ff7c-009c-b75c-35e74e4800f9.png)

Xcode はこの最終変更日が **ナノ秒単位で一致していれば** ファイルに変更なしと判断しキャッシュを採用してインクリメンタルビルドしますが、**ナノ秒単位で一致しなければ** ファイルに変更があったと判断して対象ファイルを再ビルドします。

macOS 10.13 High Sierra からファイルシステムに APFS が採用されています。APFS からファイルの最終更新日はナノ秒精度で記録されるようになりました。APFS のファイルシステム上で Xcode プロジェクトをビルドするときにはナノ秒単位の最終更新日に気を使う必要があります。

## tar コマンドの --posix オプションについて

GNU tar コマンドでファイルをまとめるとき、通常は最終更新日は秒単位までしか保存されません。

`--posix` (--format=pax または --format=posix と同義) オプションをつけることでナノ秒単位の最終更新日を含めた tar を作成できます。

macOS の bsdtar 実装でも同様に `--posix` オプションが必要です。

> **Note**
> 私が macOS 13.5.1 のローカルで bsdtar の tar コマンドを試したところ `--posix` オプションなしでも最終更新日はナノ秒単位で保存されていましたが、GitHub Actions macOS コンテナでは `--posix` オプションがなければナノ秒単位で保存されませんでした。GitHub Actions macOS コンテナでは bsdtar を使う場合でも `--posix` オプションをつける必要があるようです。
> GNU tar では macOS であってもナノ秒単位の最終更新日を保存するには `--posix` オプションが必要です。

https://manpages.ubuntu.com/manpages/impish/en/man1/tar.1.html

つまり、tar ファイル生成時は以下のコマンドが必要です。

```shell
% tar -cf {file}.tar --posix {target files...}
```

tar ファイルの展開時はオプション指定がなくてもナノ秒単位の最終更新日が復元されます。

```shell
% tar -xf {file}.tar
```

## CI でのキャッシュ対象

CI 環境でインクリメンタルビルドを実現するために、以下の対象をキャッシュとして保存する必要があります。

* DerivedData
* SourcePackages
    * SwiftPM で依存関係を管理している場合のみ必要
* ソースコードやリソースの mtime 情報

### DerivedData

Xcode はビルド中に発生した中間生成物のすべてを DerivedData ディレクトリへ保存します。
これがインクリメンタルビルドのためのメインとなるキャッシュです。

DerivedData ディレクトリはデフォルトでは `~/Library/Developer/Xcode/DerivedData/{プロジェクト名}-{ID}` ですが、xcodebuild コマンドや fastlane には DerivedData ディレクトリの場所を指定するオプションがあります。DerivedData を任意の場所に保存している場合はそのディレクトリをキャッシュしましょう。

xcodebuild の `-derivedDataPath` オプション:

```shell
% xcodebuild -project {...}.xcodeproj -scheme {...} ... -derivedDataPath DerivedData
```

fastlane の `--derived_data_path` オプション:

```shell
% fastlane gym --project {...}.xcodeproj --scheme {...} ... --derived_data_path DerivedData
```

DerivedData には中間生成物となるコンパイル済みのオブジェクトファイル(`*.o`)なども含まれます。これらの中間生成物の最終更新日も保持しておく必要があります。

GitHub Actions の [Cache action](https://github.com/actions/cache) では [`tar ... --posix ...` コマンドでキャッシュを保存してくれる](https://github.com/actions/cache/pull/233) ため、 GitHub Actions Workflow から `actions/cache` により DerivedData を保存・復元することができます。

#### DerivedData のキャッシュキー

この記事の冒頭で示したとおり、DerivedData ディレクトリはインクリメンタルビルドに使用するため直前のビルドのキャッシュを使用できるように `${{ github.sha }}` を含めるなどの工夫をします。

### SourcePackages

Xcode プロジェクトで SwiftPM パッケージを使用しているとき、Xcode は対象の SwiftPM git リポジトリを SourcePackages ディレクトリへ保存します。

SourcePackages には以下のディレクトリが含まれています。

* checkouts
    * 依存する対象 SwiftPM の対象バージョンの git checkout ディレクトリ
* repositories
    * 依存する対象 SwiftPM パッケージの git bare リポジトリ
* artifacts
    * バイナリで提供される SwiftPM パッケージについて、ダウンロードした XCFramework を設置するディレクトリ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/e9b00d86-d7c0-f6b4-d892-fc7c3932c68a.png)

SourcePackages ディレクトリはデフォルトでは `{DerivedData ディレクトリ}/SourcePackages` ですが、xcodebuild コマンドや fastlane には SourcePackages ディレクトリの場所を指定するオプションがあります。SourcePackages を任意の場所に保存している場合はそのディレクトリをキャッシュしましょう。

xcodebuild の `-clonedSourcePackagesDirPath` オプション:

```shell
% xcodebuild -project {...}.xcodeproj -scheme {...} ... -clonedSourcePackagesDirPath SourcePackages
```

fastlane の `--cloned_source_packages_path` オプション:

```shell
% fastlane gym --project {...}.xcodeproj --scheme {...} --cloned_source_packages_path SourcePackages
```

こちらも、GitHub Actions Workflow から `actions/cache` により最終更新日のナノ秒単位の情報を含めて SourcePackages を保存・復元することができます。

#### SourcePackages のキャッシュキー

SourcePackages ディレクトリはビルドするたびに変化するものではなく、SwiftPM の依存関係が変わらなければ変化しないものであるため、SwiftPM パッケージ依存関係が記載されている `Package.resolved` ファイルのハッシュ値を `...${{ hashFiles('.../Package.resolved') }}` などで指定すると適切です。

xcode-cache の README.md にも SourcePackages のキャッシュキーの例を載せています。合わせて確認してください。

https://github.com/irgaly/xcode-cache

#### ソースコードやリソースの mtime 情報

ソースコードやリソースなど、git で checkout したファイルについての mtime 情報 (最終更新日) をナノ秒単位で保存し、復元する必要があります。

これは git の機能や GitHub Actions `actions/cache` などでは実現できません。独自のスクリプトを書くか、これを実現できる外部のツールを使いましょう。

方針としては以下の二通りの対応があります。

以下のどちらかの対応が必要:

* git log から git 管理下のファイルの最終更新日を擬似的に復元する
* ビルドのたびに対象ファイルのハッシュ値と最終更新日を書き出しておく

##### git log から最終更新日を復元する方法

git はファイルの最終更新日をリポジトリに保存していませんが、git 管理下のそれぞれのファイルがリポジトリに追加されたり変更されたコミットの日時を参照することで、擬似的にファイルの最終更新日を復元する方法があります。

この方法は、最終更新日を復元したい対象となるファイルの git コミットログが必要となるため、GitHub Actions では checkout 時に以下の設定が必要です。

```yml
...
- uses: actions/checkout@4
  with:
    # ファイル最終更新日復元のためすべての git log を取得する 
    fetch-depth: 0
...
```

git リポジトリが大きく、すべての git コミットログを取得したくない場合は、`--filter=blob:none` オプションをつけて clone することでデータサイズや clone の時間を節約できるかもしれません。

https://github.blog/jp/2021-01-13-get-up-to-speed-with-partial-clone-and-shallow-clone/

`actions/checkout` では `filter: blob:none` で指定できます。

```yml
...
- uses: actions/checkout@4
  with:
    # ファイル最終更新日復元のためすべての git log を取得する
    # 履歴のみが必要であるため、blob は取得しない
    filter: blob:none
...
```

git コミットログからファイルの更新日時を復元するスクリプトはここでは詳細は解説しませんが、たとえば以下の記事に記載されているシェルスクリプトは使えるかもしれません。

> 以下の記事からの引用
> `git submodule foreach 'rev=HEAD; for f in $(git ls-tree -r -t --full-name --name-only "$rev") ; do     touch -t $(git log --pretty=format:%cd --date=format:%Y%m%d%H%M.%S -1 "$rev" -- "$f") "$f"; done'
    rev=HEAD; for f in $(git ls-tree -r -t --full-name --name-only "$rev") ; do     touch -t $(git log --pretty=format:%cd --date=format:%Y%m%d%H%M.%S -1 "$rev" -- "$f") "$f"; done`

https://faq.sealedabstract.com/github_actions/

ただし、git コミットログから mtime 情報を復元する方法は　git で管理しているファイルでしか使えません。SwiftGen などのコード生成を用いている場合は git の情報から mtime 情報を復元することはできないため SwiftGen のコード生成の元となるファイルの mtime 情報を取得して生成されたファイルの mtime 情報を更新するなどの特殊な対応が必要になります。

##### ビルドのたびに対象ファイルのハッシュ値と最終更新日を保存する方法

ビルドのたびに、ソースコードやリソースなどビルドの元となるファイルのハッシュ値と最終更新日を保存しておき、git checkout 後に該当ファイルの最終更新日を復元する方法です。

初回のビルドでは以下の処理を行います。

1. git clone / checkout
2. プロジェクトをビルドする
3. 対象ファイルのハッシュ値(例えばSHA-256)と最終更新日をファイルに書き出し、DerivedData とともにキャッシュする

2回目以降のビルドでは以下の処理を行います

1. git clone / checkout
2. キャッシュしておいたハッシュ値と最終更新日の情報を読み取る
3. 対象ファイルのハッシュ値を計算する
    * キャッシュされていたハッシュ値と一致すれば対象ファイルの最終更新日をキャッシュされていた最終更新日に書き換える
    * 内容が変化していたファイルや、最終更新日をキャッシュしていないファイルはなにもしない
3. プロジェクトをビルドする
4. 対象ファイルのハッシュ値(例えばSHA-256)と最終更新日をファイルに書き出し、DerivedData とともにキャッシュする

キャッシュするハッシュ値は SHA-256 でも他のハッシュ値でも構いませんが、最終更新日は `ナノ秒単位` の情報を保存・復元する必要があることには注意してください。

最終更新日をキャッシュする対象となるファイルは、ビルドに必要となるビルド元のファイルすべてです。
SwiftGen などでソースコードやリソースの生成をしている場合はそれらもキャッシュ対象に含める必要があります。

[xcode-cache](https://github.com/irgaly/xcode-cache) では、`*.swift` や `*.m` などのパターンで判定し、パターンにマッチするファイルの最終更新日を JSON ファイルで保存しています。

# 備考

## Xcode のオプションについて

### -driver-show-incremental

`Build Settings` > `Other Swift Flags` へ `-driver-show-incremental` を設定すると、Swift Compiler がインクリメンタルビルドキャッシュを使用したかどうかがログに出力されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/e94d0594-62e1-1b2a-5561-cc2d6d97d756.png)

たとえば、以下の出力では `Queuing (initial):` と表示されており、ビルドキャッシュが存在しないためキャッシュを使用せずにビルドしていることがわかります。

```
note: Incremental compilation: Read dependency graph '/Users/.../Library/Developer/Xcode/DerivedData/MyApp-bkbkfdsiaixhrjgasblplwkaekhq/Build/Intermediates.noindex/MyApp.build/Debug-iphonesimulator/MyApp.build/Objects-normal/arm64/MyApp-master.priors' (in target 'MyApp' from project 'MyApp')
note: Incremental compilation: Enabling incremental cross-module building (in target 'MyApp' from project 'MyApp')
note: Incremental compilation: Scheduling changed input  {compile: ContentView.o <= ContentView.swift} (in target 'MyApp' from project 'MyApp')
note: Incremental compilation: Scheduling changed input  {compile: MyAppApp.o <= MyAppApp.swift} (in target 'MyApp' from project 'MyApp')
note: Incremental compilation: Queuing (initial):  {compile: ContentView.o <= ContentView.swift} (in target 'MyApp' from project 'MyApp')
note: Incremental compilation: Queuing (initial):  {compile: MyAppApp.o <= MyAppApp.swift} (in target 'MyApp' from project 'MyApp')
note: Incremental compilation: not scheduling dependents of ContentView.swift; unknown changes (in target 'MyApp' from project 'MyApp')
note: Incremental compilation: not scheduling dependents of MyAppApp.swift; unknown changes (in target 'MyApp' from project 'MyApp')
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/a63cfeb2-446c-57e4-a494-6c6479eff5c8.png)

以下のログでは `Skipping input:` と表示されており、`ContentView.swift` はビルドキャッシュが存在し、コンパイルがスキップされていることがわかります。

```
note: Incremental compilation: Read dependency graph '/Users/.../Library/Developer/Xcode/DerivedData/MyApp-bkbkfdsiaixhrjgasblplwkaekhq/Build/Intermediates.noindex/MyApp.build/Debug-iphonesimulator/MyApp.build/Objects-normal/arm64/MyApp-master.priors' (in target 'MyApp' from project 'MyApp')
note: Incremental compilation: Enabling incremental cross-module building (in target 'MyApp' from project 'MyApp')
note: Incremental compilation: May skip current input:  {compile: ContentView.o <= ContentView.swift} (in target 'MyApp' from project 'MyApp')
note: Incremental compilation: Scheduling changed input  {compile: MyAppApp.o <= MyAppApp.swift} (in target 'MyApp' from project 'MyApp')
note: Incremental compilation: Queuing (initial):  {compile: MyAppApp.o <= MyAppApp.swift} (in target 'MyApp' from project 'MyApp')
note: Incremental compilation: not scheduling dependents of MyAppApp.swift; unknown changes (in target 'MyApp' from project 'MyApp')
note: Incremental compilation: Skipping input:  {compile: ContentView.o <= ContentView.swift} (in target 'MyApp' from project 'MyApp')
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/b53a7f52-e7a2-d9dc-315b-448f60901628.png)

### IgnoreFileSystemDeviceInodeChanges は不要

コマンドラインから `defaults write com.apple.dt.XCBuild IgnoreFileSystemDeviceInodeChanges -bool YES` とするか、または `xcodebuild ... IgnoreFileSystemDeviceInodeChanges=YES` によりファイルシステム上の inode 番号の変化を無視することができるらしいのですが、**インクリメンタルビルドのキャッシュ判定には関係ありませんでした**。

このオプションの詳細は不明ですが Xcode を IDE として起動してファイルを編集しているときのファイル変更監視の仕組みに inode を使っているのかもしれません。

CI のインクリメンタルビルドを活用するためにこのオプションの設定は不要です。

### EnableSwiftBuildSystemIntegration は YES でも NO でもインクリメンタルビルドできる

コマンドラインから `defaults write com.apple.dt.XCBuild EnableSwiftBuildSystemIntegration -bool {YES/NO}` とするか、または `xcodebuild ... EnableSwiftBuildSystemIntegration={YES/NO}` により Xcode から使用する Swift のビルドシステムが変化するようですが、**YES / NO どちらでもインクリメンタルビルドは機能します**。

CI でのインクリメンタルビルドを活用するためにこのオプションの設定は不要です。

## stat コマンドでナノ秒単位のファイル最終更新日を確認する

stat コマンドの以下のオプションで APFS ファイルシステムのナノ秒単位のファイル更新日を表示できます。
以下のコマンドは macOS 環境を想定しています。

```shell
% stat -f %Fm sample/MyApp/MyLibrary/MyLibrary.swift
1694767537.969242000
```

## touch コマンドでナノ秒単位のファイル最終更新日を設定する

シェルスクリプトなどから対象ファイルの更新日をナノ秒単位で更新するには以下のコマンドを使用します。
touch コマンドが扱える1秒未満の精度はその環境の OS とファイルシステムの精度に依存します。
macOS / APFS 環境であればナノ秒まで設定可能です。

以下のコマンドは macOS 環境を想定しています。

```shell
% touch -d 2023-09-25T17:45:47.918893842 hoge
```

UNIX 時間でファイルの最終更新日を指定する場合は、以下のように date コマンドを併用します。
`{seconds}.{nano seconds}` 形式を touch コマンドの解釈可能な文字列へ変換します。
変数展開は bash / zsh を想定しています。

```shell
% time=1695631548.918893842
% seconds=${time%.*}
% nano=${time#*.}
% touch -d $(date -r $seconds +%Y-%m-%dT%H:%M:%S).$nano hoge
```

## デフォルトの DerivedData ディレクトリ名について

デフォルトの DerivedData ディレクトリは `~/Library/Developer/Xcode/DerivedData/{プロジェクト名}-{ID}` ですが、`{ID}` は .xcodeproj ディレクトリまたは .xcworkspace ディレクトリのファイルパスをハッシュ化したものとのことです。

`DevToolsCore.framework` の `hashStringForPath()` により 28 文字のハッシュ文字列が生成されるとのことです。

Objective-C の実装はこちらの記事に記載があります。

https://pewpewthespells.com/blog/xcode_deriveddata_hashes.html

C++, Ruby, Python の実装はこちらが参考となりそうです。

* https://gist.github.com/Kila2/bcf2847473f81a773aa543c0234c8a41

通常は fastlane および xcodebuild の引数で DerivedData の場所を指定することをおすすめします。