iOS/SwiftUI/SPM の Swift コードの CI を GitHub Actions で頑張る
はじめに
iOS/SwiftUI アプリのための swift code に対する CI を Github Actions であれこれ頑張ってみたという経験の共有です。
普段自分は Web アプリケーションを構築しており、その Web 脳で構築しているので、アプリ特有のビルド/リリースのようなところに手は届いていないですが、多くの人に親しみのある Github Actions であれこれやってみたという初学者向け記事になっているといいなと思います。
対象
それなりの規模の iOS 開発自体もこのCI構築に取り組んだ時が始めてだったのですが、諸々構築に使用された技術が以下の通りであり、こういった構成のコードを対象としています。
- Xcode 16.0, Swift 6.0
- SwiftUI ベース
- Swift Package Manager (SPM) によるマルチモジュール構成
サマリ
最初のモチベーション
iOS ということで Mac のランナーを使用するのが自然かと思いましたが、何より高コストな印象があったので、なるべく Ubuntu ランナーで
[1] Lint, Format
[2] Build, Testing
を行いたいなと思っていました
結果
[1] Lint,Format -> Ubuntu Runner で swift-format
による実現
[2] Build,Testing -> macOS Runner で Xcode のバージョンを切り替えることで swift test
を実行
Lint,Format
SwiftLint,SwiftFormat? swift-format?
そもそも Swift における Lint や Format については、以前コミュニティによる SwiftLint
,SwiftFormat
が主流であり現在も根強く使用されているのですが、2019年から Apple 本体から公式推奨の swift-format
が提供され始めました。
現在はどちらも利用されているのですが swift-format
には Lint,Format どちらの機能もあり、すぐに使い始めるには簡単だったのでこちらを採用し使用することにしました。
ワークフロー実装
[1] swift-format
のインストール
swift-format
は Swift6 からツールチェーンにデフォルトで含まれているのですが、Ubuntu Runner で頑張るためそのまま利用できません。
swift-format
自体いくつかのインストール方法を提供しており、その中に Homebrew 経由があったため今回はこちらを利用します。
具体的には以下のようなワークフローの宣言になります。
- name: Set up Homebrew
id: set-up-homebrew
uses: Homebrew/actions/setup-homebrew@master
- name: Install swift-format
run: brew install swift-format
[2] SPMによるマルチモジュールを考慮した swift format
コマンドのエイリアス化
swift-format
は swift format
で実行できます。サブコマンドで format
,lint
を指定することでそれぞれの命令を実行できます。
% swift format --help
OVERVIEW: Format or lint Swift source code
...
SUBCOMMANDS:
...
format (default) Format Swift source code
lint Diagnose style issues in Swift source code
ここで swift-format
を実行するためにいくつか工夫/注意点があります
A. フォルダ指定による実行速度効率化
swift format format
などでフォルダやファイル指定をしないと、ルート直下に直接落としてきた Swift コードなどがあるとそういったものに対しても実行されてしまい、意図しない変更や想定以上の実行時間がかかります。具体的には僕のプロジェクトでは apollo-ios による自動生成されたクライアントコードがあったためにこの問題を踏みました。
-r
オプションを使用することで絞り込みが可能なのでこちらを活用します。
B. SPMによるマルチモジュール構成によるアプリ本体フォルダとモジュール群のターゲット切り替え
Swift Package Manager のマルチモジュール構成の場合は一例として以下のようなフォルダ構成になっていることがあります。
|- /App ... アプリのエントリーポイント
|- /Sources ... 各モジュール
|- /Tests ... 各モジュールのテストコード
...
この場合 /App
と /{Sources,Tests}
と分けて実行することで、モジュール群に対する実行と(これらモジュールを利用した)アプリコードに対する実行を明確に使い分ける/見分けることができます。
この観点を踏まえたコマンドを Makefile
で定義することで、CI上もわかりやすく、ローカルでも同等のコマンドを実行しやすくします。
# Makefile
lint_app:
swift format lint -s --configuration .swift-format -r App
lint_modules:
swift format lint -s --configuration .swift-format -r Sources Tests
lint_all: lint_app lint_modules
format_app:
swift format format --configuration .swift-format -ipr App
format_modules:
swift format format --configuration .swift-format -ipr Sources Tests
format_all: format_app format_modules
その上でワークフローでは以下のように結果をそれぞれ変数に出力することで、問題があるコード群を明確にできるようにしました。
- name: Lint
run: |
APP_LINT_STATUS=0
MODULES_LINT_STATUS=0
make lint_app || APP_LINT_STATUS=$?
make lint_modules || MODULES_LINT_STATUS=$?
if [ "$APP_LINT_STATUS" -ne 0 ] || [ "$MODULES_LINT_STATUS" -ne 0 ]; then
echo "Lint failed with status: app=$APP_LINT_STATUS modules=$MODULES_LINT_STATUS"
exit 1
fi
最終的なワークフローは以下のようになります
.github/workflows/static-analytics.yml
name: Static Analytics
on:
pull_request:
branches:
- main
paths:
- "**/*.swift"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Set up Homebrew
id: set-up-homebrew
uses: Homebrew/actions/setup-homebrew@master
- name: Install swift-format
run: brew install swift-format
- uses: actions/checkout@v4
- name: Lint
run: |
APP_LINT_STATUS=0
MODULES_LINT_STATUS=0
make lint_app || APP_LINT_STATUS=$?
make lint_modules || MODULES_LINT_STATUS=$?
if [ "$APP_LINT_STATUS" -ne 0 ] || [ "$MODULES_LINT_STATUS" -ne 0 ]; then
echo "Lint failed with status: app=$APP_LINT_STATUS modules=$MODULES_LINT_STATUS"
exit 1
fi
Build,Testing
ビルド,テスト実行にはいくつかのつまづきとそれによる対応ポイントがあるので、サマリとして工夫点一覧を出した上で各種つまづきに触れていきたいと思います。
ポイント
A. SwiftUI のため Ubuntu ではなく macOS Runner を利用する
B. macOS runner に含まれた Xcode から swift version をターゲットに一致させる
C. (+α) 節約のためのスケジュール実行と実行ランナーを分けて slack 通知
つまづき1 - Ubuntu で SwiftUI が解決できない?
そもそもなぜこのプロセスには Ubuntu ではなく macOS なのかというとUIのコアライブラリとして使用していた純正の SwiftUI
の依存関係解決がどうしてもできなかったためです。詳細は理解できていませんが、以下のリンク等から macOS で実行する方針に舵を切ることにしました。
つまづき2 - actions/setup-swift のサポートバージョン
swift にも Market place にセットアップ用のアクションがあったのがですが、いまだにバージョン6系に対応できていませんでした。
色々調べた結果 Github の提供している macOS Runner はいくつかのバージョンの Xcode が利用できるように組み込まれていました。
そしてアプリの swift のバージョンは使用している Xcode のバージョンに依存していることがほとんどだと思います。
そのため開発環境で利用している Xcode のバージョンにスイッチさせることで使用したい swift のバージョンを適用することができました。
env:
...
XCODE_VERSION: "16.1"
jobs:
build-and-test:
...
steps:
- name: Set Xcode for CI
run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app
+α: macOS Runner でのCI結果の slack 通知
Github Actions の結果の slack 通知は rtCamp/action-slack-notify
を利用している人が多いと思います。
ただこの action は macOS Runner から実行できませんでした。
そのために macOS Runner における実行結果を ubuntu Runner に伝播させて slack 通知実行を行うようにマルチジョブによるパイプラインを構築して対応しました。
以下の issue で本課題が挙げられており対策例の記載もありました。
実際には以下のような実装になりました
jobs:
build-and-test:
runs-on: macos-latest
outputs:
SLACK_TITLE: ${{ steps.slack_message_config.outputs.SLACK_TITLE }}
SLACK_MESSAGE: ${{ steps.slack_message_config.outputs.SLACK_MESSAGE }}
BUILD_JOB_STATUS: ${{ steps.slack_message_config.outputs.BUILD_JOB_STATUS }}
steps:
...
- name: Configure Slack Message
id: slack_message_config
if: always()
run: |
echo "SLACK_MESSAGE=${{ github.event.head_commit.message }}" >> $GITHUB_OUTPUT
if [[ '${{ job.status }}' == 'success' ]]; then
echo "SLACK_TITLE=[✅ Success] CI: Build & Unit Test" >> $GITHUB_OUTPUT
echo "BUILD_JOB_STATUS=success" >> $GITHUB_OUTPUT
else
echo "SLACK_TITLE=[❌ Failure] CI: Build & Unit Test" >> $GITHUB_OUTPUT
echo "BUILD_JOB_STATUS=failure" >> $GITHUB_OUTPUT
post-build:
name: post-build
needs: build-and-test
if: always()
runs-on: ubuntu-latest
steps:
- name: Slack Build Result Notification
if: always()
env:
SLACK_COLOR: ${{ needs.build-and-test.outputs.BUILD_JOB_STATUS }}
SLACK_TITLE: ${{ needs.build-and-test.outputs.SLACK_TITLE }}
SLACK_MESSAGE: ${{ needs.build-and-test.outputs.SLACK_MESSAGE }}
uses: rtCamp/action-slack-notify@v2
その他ワークフロー実装
実装ポイント自体は前セクションであげたポイントで全てではありますが、他にも手を加えている部分があります
[1] バージョン出力
- name: Check versions
run: |
swift --version
xcodebuild -sdk -version
xcrun --show-sdk-path
以下を参考にバージョン出力をしています。
もう少し工夫をすればアサーションにすることもできそうです。
[2] (おまけ) 節約のためのスケジュール実行
on:
schedule:
- cron: "0 0 * * *"
これらも踏まえて最終的なワークフローは以下のようになります
.github/workflows/testing.yml
name: Testing
on:
workflow_dispatch:
# note: periodic run once because macOS runner costs too much
schedule:
- cron: "0 0 * * *"
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_NOTIFICATION_CHANNEL }}
# REF: https://github.com/actions/runner-images/blob/main/images/macos/macos-14-arm64-Readme.md
# swift version from https://developer.apple.com/support/xcode/
XCODE_VERSION: "16.1"
defaults:
run:
shell: bash -euo pipefail {0}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
runs-on: macos-latest
outputs:
SLACK_TITLE: ${{ steps.slack_message_config.outputs.SLACK_TITLE }}
SLACK_MESSAGE: ${{ steps.slack_message_config.outputs.SLACK_MESSAGE }}
BUILD_JOB_STATUS: ${{ steps.slack_message_config.outputs.BUILD_JOB_STATUS }}
steps:
- name: Set Xcode for CI
run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app
- uses: actions/checkout@v4
- name: Check versions
run: |
swift --version
xcodebuild -sdk -version
xcrun --show-sdk-path
- name: Build
run: swift build
- name: Unit test
run: swift test
- name: Configure Slack Message
# https://github.com/rtCamp/action-slack-notify/issues/22
id: slack_message_config
if: always()
run: |
echo "SLACK_MESSAGE=${{ github.event.head_commit.message }}" >> $GITHUB_OUTPUT
if [[ '${{ job.status }}' == 'success' ]]; then
echo "SLACK_TITLE=[✅ Success] CI: Build & Unit Test" >> $GITHUB_OUTPUT
echo "BUILD_JOB_STATUS=success" >> $GITHUB_OUTPUT
else
echo "SLACK_TITLE=[❌ Failure] CI: Build & Unit Test" >> $GITHUB_OUTPUT
echo "BUILD_JOB_STATUS=failure" >> $GITHUB_OUTPUT
fi
post-build:
name: post-build
needs: build-and-test
if: always()
runs-on: ubuntu-latest
steps:
- name: Slack Build Result Notification
if: always()
env:
SLACK_COLOR: ${{ needs.build-and-test.outputs.BUILD_JOB_STATUS }}
SLACK_TITLE: ${{ needs.build-and-test.outputs.SLACK_TITLE }}
SLACK_MESSAGE: ${{ needs.build-and-test.outputs.SLACK_MESSAGE }}
uses: rtCamp/action-slack-notify@v2
まとめ
今回 SwiftUI/SPMによるマルチモジュール構成アプリを意図した基本的なCIを構築してみました。いくつかピックアップすると
- 省力/低コストなシンプルな Lint,Format
- 任意の swift version での Build,Testing
- マルチジョブを活用した Slack 通知の実装
などは良い参考情報とすることができたかと思います。
今回はQAとしてのCIでしたので、もう少しアプリに踏み込んで Xcode Buikld や Bitrise などにも手を広げてアプリらしいCDを構築することにチャレンジしたいと思います。
最後までお読みいただきありがとうございました!🙇
Discussion