リンクをコピーしました

前の記事で GitHub Actions のデプロイ環境は整いましたが、macOS ランナーは分単位で課金されます。
個人開発の小さなアプリには正直コストが気になるので、Xcode Cloud を試してみました。
Apple Developer Program に含まれているので、月 25 時間までは追加料金なしで使えます。

Flutter は Xcode Cloud で公式サポートされていないため、ひと手間必要でした。そのあたりをまとめます。
正直キャッシュ周りが謎で、ビルドするたびに結果が変わったりなどしたため、ここに書いたもので落ち着いていますが、よくわかっていないというのが正直なところです。
それをご理解頂けると助かります。

Xcode Cloud とは

Xcode Cloud は Apple が提供する CI/CD サービスで、Xcode から直接設定できます。
macOS・iOS・watchOS・tvOS アプリのビルドに特化しており、App Store Connect との連携がスムーズです。

Apple Developer Program の会員であれば月 25 時間の無料枠があり、個人開発レベルであれば十分まかなえることが多いです。

Flutter を Xcode Cloud で動かすには

Xcode Cloud のビルド環境には Flutter が入っていません。
また、Homebrew の Cask も使えないので brew install --cask flutter という方法も不可です。

解決策は ci_post_clone.sh の中で Flutter SDK を Git から直接クローンすることです。

こちらのFlutter公式にも載っています。

ディレクトリ構成

Xcode Cloud が認識する CI スクリプトは ios/ci_scripts/ 以下に置きます。
詳しいCIスクリプトのドキュメントはこちらを参照ください。

ios/
└── ci_scripts/
    ├── ci_post_clone.sh       # クローン後に実行(Flutter インストール等)
    ├── ci_pre_xcodebuild.sh   # xcodebuild 直前に実行
    └── clean_spm_caches.sh    # SPM キャッシュのクリーンアップ(共通処理)

ci_post_clone.sh

クローン後に Flutter SDK をインストールして flutter pub get まで済ませます。

#!/bin/sh

# Xcode Cloud: install Flutter + dependencies after clone.
# Do NOT use `brew install --cask flutter` — Caskroom is unavailable on CI.

set -e

SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$SCRIPT_DIR/clean_spm_caches.sh"

# デフォルトの cwd は ios/ci_scripts/。リポジトリルートに移動する。
cd "$CI_PRIMARY_REPOSITORY_PATH"

echo "=== Installing Flutter SDK ==="
FLUTTER_DIR="$HOME/flutter"
if [ ! -d "$FLUTTER_DIR/bin" ]; then
  git clone https://github.com/flutter/flutter.git --depth 1 -b stable "$FLUTTER_DIR"
fi
export PATH="$PATH:$FLUTTER_DIR/bin"

echo "=== Flutter precache (iOS) ==="
flutter precache --ios

echo "=== Flutter pub get ==="
flutter pub get

echo "=== CocoaPods ==="
if ! command -v pod >/dev/null 2>&1; then
  if command -v brew >/dev/null 2>&1; then
    HOMEBREW_NO_AUTO_UPDATE=1 brew install cocoapods
  else
    gem install cocoapods --no-document
  fi
fi

echo "=== Flutter iOS config-only ==="
flutter build ios --config-only

clean_swiftpm_caches

echo "=== ci_post_clone complete ==="

exit 0

flutter build ios --config-only が重要

flutter build ios --config-only を実行することで、FlutterGeneratedPluginSwiftPackage の iOS deployment target が IPHONEOS_DEPLOYMENT_TARGET に合わせて更新されます。
これをやらないと deployment target が 13.0 のままになり、Firebase などの SPM パッケージが要求するバージョンと合わずビルドエラーになります。

Firebase の SPM 問題と clean_spm_caches.sh

Xcode Cloud で Firebase を使っているとき、SPM のキャッシュが残っていると次のようなエラーが出ることがあります。

error: package at '...' already exists in the file system

これは Xcode Cloud のキャッシュ機構と SwiftPM のキャッシュが干渉することが原因です。
対策として、ビルドごとにキャッシュをクリアしてワークスペース内のディレクトリにリダイレクトしています。

#!/bin/sh
# Xcode Cloud: Firebase/gRPC SPM の "already exists in file system" エラーを避ける

SWIFTPM_WORKSPACE_CACHE="/Volumes/workspace/.swiftpm-cache"

_remove_tree() {
  target="$1"
  [ ! -e "$target" ] && return 0
  chmod -R u+w "$target" 2>/dev/null || true
  rm -rf "$target"
}

_redirect_swiftpm_cache() {
  link_path="$1"
  parent_dir=$(dirname "$link_path")
  mkdir -p "$parent_dir" || return 1
  _remove_tree "$link_path" || return 1
  ln -sfn "$SWIFTPM_WORKSPACE_CACHE" "$link_path"
}

clean_swiftpm_caches() {
  echo "=== Cleaning SwiftPM caches ==="

  _remove_tree "$SWIFTPM_WORKSPACE_CACHE"
  mkdir -p "$SWIFTPM_WORKSPACE_CACHE"

  for cache_root in \
    "$HOME/Library/Caches/org.swift.swiftpm" \
    "/Users/local/Library/Caches/org.swift.swiftpm" \
    "$HOME/Library/org.swift.swiftpm" \
    "/Users/local/Library/org.swift.swiftpm"; do
    _remove_tree "$cache_root"
  done

  _remove_tree /Volumes/workspace/DerivedData

  # xcodebuild は "local" ユーザーで動く — そのキャッシュもリダイレクト
  _redirect_swiftpm_cache "/Users/local/Library/Caches/org.swift.swiftpm"
  if [ "$HOME" != "/Users/local" ]; then
    _redirect_swiftpm_cache "$HOME/Library/Caches/org.swift.swiftpm"
  fi

  echo "=== SwiftPM cache redirect OK -> $SWIFTPM_WORKSPACE_CACHE ==="
}

/Users/local というのは Xcode Cloud が xcodebuild を実行するときのユーザーアカウントです。
自分の $HOME とは別ユーザーなので、両方のキャッシュをリダイレクトする必要があります。

ci_pre_xcodebuild.sh

xcodebuild 直前に実行されるスクリプトです。
ci_post_clone.sh でキャッシュのシンボリックリンクを張ったので、ここではその確認だけ行っています。

#!/bin/sh

set -e

LOCAL_SPM="/Users/local/Library/Caches/org.swift.swiftpm"
if [ -L "$LOCAL_SPM" ]; then
  echo "=== ci_pre_xcodebuild: SwiftPM cache -> $(readlink "$LOCAL_SPM") ==="
else
  echo "=== ci_pre_xcodebuild: warning: SwiftPM cache is not a workspace symlink ==="
fi

exit 0

Xcode Cloud でのワークフロー設定

Product → Xcode Cloud → Create Workflows or Manage Workflows から設定します。

ただし、Archive などを作っておかないと、Create Workflows や Manage Workflows はグレーアウトしていると思います。
僕の環境では作らないと管理も作成も行えませんでした。。。(なんか微妙な。。。)

最初はios以下にあるRunner.xcworkspaceなどを開いて、Archiveなどを作成しておいてください。

Archive

基本的な設定はこんな感じです。

Workflows
項目設定値
開始条件main or deploy へのプッシュ、またはタグ
アクションBuild & Archive
配布先TestFlight(内部テスター)

証明書とプロビジョニングプロファイルは、App Store Connect と連携しているため自動で管理されます。
fastlane match は不要で、Xcode Cloud 側で証明書を発行・管理してくれます。

GHA と Xcode Cloud の比較

実際に両方使ってみた感想です。

GitHub ActionsXcode Cloud
コストmacOS ランナーは従量課金月 25 時間無料
ビルドsubosito/flutter-actionなどのアクションやfastlaneで簡単ci_post_clone.sh で手動インストール
証明書管理fastlane match が必要自動管理

| デバッグのしやすさ | ローカルで act を使える | ログを見るしかない(謎が多いが、セットアップを終えたらこっちがいい) |

コスト面では Xcode Cloud が有利ですが、Flutter アプリの場合は ci_post_clone.sh のメンテナンスが必要なのと、デバッグがやや面倒です。
GHAで回しておいて、仕方なく節約が必要になったら Xcode Cloud に移行してという使い方が個人開発には合っている気がします。

まとめ

個人開発の iOS アプリならGHAで済ませておいて、すごい回す必要があるならxcode cloudというのがいい気がします。
Claude や Cursor などのサービスを活用してスマホから Cloud 上のエージェントに処理させて、ビルド結果を見たいということがあると思うので、そういう時は Xcode Cloud を使ってガンガン回す必要がありそうです。

シリーズ記事

この記事は Flutter iOS ビルド・配布の一連の流れの最終回です。前の 2 本もあわせてどうぞ。

関連リンク