irgaly/xcode-cache は、XcodeのDerivedDataとSwiftPMのパッケージをGitHub Actionsのキャッシュに保存するアクションである。コミットごとにキャッシュを保存し、次のビルドでは直前のキャッシュを復元して、変更のあったファイルだけをコンパイルする。

- uses: actions/checkout@v5
- uses: irgaly/xcode-cache@v1
  with:
    key: xcode-cache-deriveddata-${{ github.workflow }}-${{ github.sha }}
    restore-keys: xcode-cache-deriveddata-${{ github.workflow }}-
- name: Test
  run: xcodebuild test -project [プロジェクト名].xcodeproj -scheme [スキーム名] -destination 'platform=macOS,arch=arm64'

対象はmacOSのランナーだけである。実行例は、macos-26ランナー(Xcode 26.6)とirgaly/xcode-cache@v1(v1.9.2)のもの。

保存されるもの

アクションは、次の3つを保存する。

対象既定の場所キャッシュのキー
DerivedData~/Library/Developer/Xcode/DerivedDatakeyで指定した値
SourcePackages~/Library/Developer/Xcode/DerivedData/[アプリ名]-[ID]/SourcePackagesPackage.resolvedのハッシュ値から自動で作られる
ソースファイルのmtimeDerivedData/xcode-cache-mtime.jsonDerivedDataに含まれる

SourcePackagesのキャッシュは、DerivedDataとは別のキャッシュとして保存される。キーはirgaly/xcode-cache-sourcepackages-[ハッシュ値]の形式で、Package.resolvedの内容から作られる。

1回目のジョブでは、ジョブの最後に2つのキャッシュが保存された。サイズは、DerivedDataが約60MB、SourcePackagesが約27MBである。

$ gh cache list
xcode-cache-deriveddata-CI-[コミットのSHA]	59.59 MiB
irgaly/xcode-cache-sourcepackages-[ハッシュ値]	27.22 MiB

増分ビルドが効く

Yamsに依存する5つのSwiftファイルとテストの小さなプロジェクトで、次の順に実行した。

  1. キャッシュがない状態でテストを実行する
  2. Calc1.swiftだけを変更してコミットし、テストを実行する

1回目はキャッシュがないので、すべてのファイルをコンパイルし、パッケージを取得する。ログには、次のとおり出力される。

DerivedData cache not found
There are no SourcePackages directory in DerivedData, skip restoring SourcePackages
Skipped restoring mtime because of DerivedData is not restored

2回目は、keyのSHAが1回目と違うので完全一致しないが、restore-keysの前方一致で1回目のキャッシュが復元される。

Cache hit for restore-key: xcode-cache-deriveddata-CI-[1回目のコミットのSHA]
Cache hit for: irgaly/xcode-cache-sourcepackages-[ハッシュ値]
Restored 5 file's mtimes.

xcodebuildのログで確認した結果は、次のとおりである。SwiftCompileは、ログのSwiftCompileで始まる行数である。

実行SwiftCompileパッケージの取得xcodebuild testの所要時間
1回目(キャッシュなし)39行する26秒
2回目(Calc1.swiftを変更)2行しない14秒

2回目にコンパイルされたファイルは、変更したCalc1.swiftだけだった。実行例のプロジェクトは小さいので、短縮された時間は十数秒である。ファイルやターゲットが増えると、コンパイルを省略した分だけ差が広がる。

ソースファイルのmtimeが復元される

Xcodeは、ソースファイルの更新時刻(mtime)をナノ秒の精度でDerivedDataに記録する。CIではジョブごとにcheckoutし直すので、中身が同じファイルでもmtimeが変わる。DerivedDataを復元しても、Xcodeはすべてのファイルを変更されたとみなして、再コンパイルする。

irgaly/xcode-cacheは、ジョブの最後にソースファイルのmtimeとSHA-256をxcode-cache-mtime.jsonに保存する。復元時に、SHA-256が同じファイルのmtimeを元に戻す。内容が変わったファイルのmtimeは戻されない。

[
  {
    "path": "Sources/Calc1.swift",
    "time": "1694751978.570357000",
    "sha256": "[SHA-256]"
  }
]

比較のために、actions/cacheでDerivedDataだけを保存するワークフローも実行した。同じ構成のプロジェクトで、キャッシュを保存した後にCalc3.swiftだけを変更したところ、変更していないCalc1.swiftからCalc5.swiftの5ファイルとテストのファイルもコンパイルされた。

- uses: actions/cache@v4
  with:
    path: ~/Library/Developer/Xcode/DerivedData
    key: plain-deriveddata-${{ github.sha }}
    restore-keys: plain-deriveddata-
方法変更したファイルコンパイルされたファイル
irgaly/xcode-cacheCalc1.swiftCalc1.swiftのみ
actions/cacheCalc3.swiftCalc1.swiftからCalc5.swiftとテストのファイル

DerivedDataをキャッシュする目的がコンパイルの省略であれば、mtimeの復元が必要である。実行例の小さなプロジェクトでは、actions/cacheとの所要時間の差より、再コンパイルされるファイルの差が明確だった。

keyにgithub.shaを使う

DerivedDataは、増分ビルドのための状態を持つ。keyにはコミットごとに変わるgithub.shaを入れ、restore-keysには共通の接頭辞を指定する。

key: xcode-cache-deriveddata-${{ github.workflow }}-${{ github.sha }}
restore-keys: xcode-cache-deriveddata-${{ github.workflow }}-

コミットごとに新しいキャッシュが保存され、次のジョブにはrestore-keysで直近のキャッシュが復元される。

同じコミットのジョブを再実行すると、keyが完全一致する。この場合は、Cache hit for:と表示され、保存の処理はスキップされる。

Cache hit for: xcode-cache-deriveddata-CI-[コミットのSHA]
Restored 6 file's mtimes.
Skipped storing DerivedData

再実行したジョブでは、SwiftCompileが0行で、パッケージの取得もなかった。

Package.resolvedを変更したとき

SourcePackagesのキーには、Package.resolvedのハッシュ値が使われる。依存パッケージのバージョンを変更してPackage.resolvedが変わると、キーが変わる。

Package.resolvedのYamsのバージョンを6.2.2から6.0.0に変更したコミットでは、次のログになった。

Cache hit for restore-key: xcode-cache-deriveddata-CI-[直前のコミットのSHA]
SourcePackages cache not found
Restored 6 file's mtimes.

DerivedDataは復元されたが、SourcePackagesは見つからず、パッケージを再取得した。依存パッケージが変わったので、SwiftCompileも39行に戻り、全ファイルをコンパイルした。

Fetching from https://github.com/jpsim/Yams
Creating working copy of package ‘Yams’
Checking out 6.0.0 of package ‘Yams’

新しいPackage.resolvedに対応するSourcePackagesのキャッシュが、ジョブの最後に追加で保存される。古いキーのSourcePackagesのキャッシュは残る。

参考: 【xcodebuild】SwiftPMの依存関係の解決だけを先に実行する (-resolvePackageDependencies)

使い終わったキャッシュを削除する

github.shaをkeyに含めると、コミットごとにDerivedDataのキャッシュが増える。GitHub Actionsのキャッシュには容量の上限があり、上限を超えると、最後に使われた日時が古いキャッシュから削除される。

delete-used-deriveddata-cache: trueを指定すると、ジョブが成功したときに、restore-keysでヒットして復元に使った、同じブランチの古いDerivedDataのキャッシュを削除する。keyが完全一致したジョブ(同じコミットの再実行)では、削除されない。キャッシュの削除にはAPIを使うので、ジョブにactions: writeの権限が必要である。

permissions:
  contents: read
  actions: write
steps:
  - uses: actions/checkout@v5
  - uses: irgaly/xcode-cache@v1
    with:
      key: xcode-cache-deriveddata-${{ github.workflow }}-${{ github.sha }}
      restore-keys: xcode-cache-deriveddata-${{ github.workflow }}-
      delete-used-deriveddata-cache: true

3つのキャッシュがある状態で実行すると、復元に使った直近のキャッシュだけが削除され、新しいコミットのキャッシュが追加された。復元に使わなかった古いキャッシュは、そのまま残った。

[ジョブの実行前]
xcode-cache-deriveddata-CI-[SHA 1]
xcode-cache-deriveddata-CI-[SHA 2]  ← restore-keysで復元に使う
irgaly/xcode-cache-sourcepackages-[ハッシュ値]

[ジョブの実行後]
xcode-cache-deriveddata-CI-[SHA 1]
xcode-cache-deriveddata-CI-[SHA 3]  ← 新しく保存される
irgaly/xcode-cache-sourcepackages-[ハッシュ値]

復元の結果を後続のステップで使う

アクションは、復元の結果をステップの出力に設定する。ステップにidを付けると、後続のステップで参照できる。

- uses: irgaly/xcode-cache@v1
  id: xcode-cache
  with:
    key: xcode-cache-deriveddata-${{ github.workflow }}-${{ github.sha }}
    restore-keys: xcode-cache-deriveddata-${{ github.workflow }}-
- run: echo "restored=${{ steps.xcode-cache.outputs.restored }} swiftpm=${{ steps.xcode-cache.outputs.swiftpm-restored }}"
出力値
restoredDerivedDataが復元されたときはtrue(restore-keysのヒットを含む)
restored-keyヒットしたDerivedDataのキャッシュのキー
swiftpm-restoredSourcePackagesが復元されたときはtrue
swiftpm-restored-keyヒットしたSourcePackagesのキャッシュのキー

保存先を指定する

-derivedDataPathや-clonedSourcePackagesDirPathでビルドの保存先を変えているときは、アクションにも同じ場所を指定する。

- uses: irgaly/xcode-cache@v1
  with:
    key: xcode-cache-deriveddata-${{ github.workflow }}-${{ github.sha }}
    restore-keys: xcode-cache-deriveddata-${{ github.workflow }}-
    deriveddata-directory: DerivedData
    sourcepackages-directory: SourcePackages
- name: Test
  run: xcodebuild test -project [プロジェクト名].xcodeproj -scheme [スキーム名] -destination 'platform=macOS,arch=arm64' -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages

ソースファイルのmtimeを保存する対象は、既定で**/*.swift、**/*.xcassets、**/*.plistなど、Xcodeのビルドの入力になる拡張子のファイルである。ほかの拡張子のファイルも対象にするには、restore-mtime-targetsにグロブパターンを指定する。

restore-mtime-targets: |
  [フォルダ名]/**/*

どちらの方法を選ぶか

やりたいこと方法
変更したファイルだけをコンパイルするkeyにgithub.shaを、restore-keysに共通の接頭辞を指定する
パッケージの取得を省略するPackage.resolvedをコミットする(SourcePackagesのキーに使われる)
キャッシュの増加を抑えるdelete-used-deriveddata-cache: trueとactions: write
保存先を変えたビルドで使うderiveddata-directoryとsourcepackages-directory

DerivedDataのキャッシュで増分ビルドを効かせるには、ソースファイルのmtimeの復元が必要である。actions/cacheだけでは、checkoutでmtimeが変わるので、ファイルの内容が同じでも再コンパイルされた。

参考: 【GitHub Actions,Xcode】CIで使うXcodeのバージョンを固定する (xcode-select)