🍎

iOS/SwiftUI/SPM の Swift コードの CI を GitHub Actions で頑張る

2024/12/30に公開

はじめに

iOS/SwiftUI アプリのための swift code に対する CI を Github Actions であれこれ頑張ってみたという経験の共有です。
普段自分は Web アプリケーションを構築しており、その Web 脳で構築しているので、アプリ特有のビルド/リリースのようなところに手は届いていないですが、多くの人に親しみのある Github Actions であれこれやってみたという初学者向け記事になっているといいなと思います。

対象

それなりの規模の iOS 開発自体もこのCI構築に取り組んだ時が始めてだったのですが、諸々構築に使用された技術が以下の通りであり、こういった構成のコードを対象としています。

  • Xcode 16.0, Swift 6.0
  • SwiftUI ベース
  • Swift Package Manager (SPM) によるマルチモジュール構成

https://speakerdeck.com/d_date/swift-package-centered-project-build-and-practice
https://zenn.dev/kalupas226/articles/73118709e316ad

サマリ

最初のモチベーション

iOS ということで Mac のランナーを使用するのが自然かと思いましたが、何より高コストな印象があったので、なるべく Ubuntu ランナーで

[1] Lint, Format
[2] Build, Testing

を行いたいなと思っていました

https://docs.github.com/en/billing/managing-billing-for-your-products/managing-billing-for-github-actions/about-billing-for-github-actions#per-minute-rates

結果

[1] Lint,Format -> Ubuntu Runner で swift-format による実現

https://github.com/swiftlang/swift-format
https://zenn.dev/treastrain/articles/8f461a75731562

[2] Build,Testing -> macOS Runner で Xcode のバージョンを切り替えることで swift test を実行

Lint,Format

SwiftLint,SwiftFormat? swift-format?

そもそも Swift における Lint や Format については、以前コミュニティによる SwiftLint,SwiftFormat が主流であり現在も根強く使用されているのですが、2019年から Apple 本体から公式推奨の swift-format が提供され始めました。

https://tech.mirrativ.stream/entry/2022/06/27/060850
https://github.com/swiftlang/swift-format
https://zenn.dev/treastrain/articles/8f461a75731562

現在はどちらも利用されているのですが swift-format には Lint,Format どちらの機能もあり、すぐに使い始めるには簡単だったのでこちらを採用し使用することにしました。

ワークフロー実装

[1] swift-format のインストール

swift-format は Swift6 からツールチェーンにデフォルトで含まれているのですが、Ubuntu Runner で頑張るためそのまま利用できません。
swift-format 自体いくつかのインストール方法を提供しており、その中に Homebrew 経由があったため今回はこちらを利用します。

https://github.com/swiftlang/swift-format?tab=readme-ov-file#installing-via-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-formatswift 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 で実行する方針に舵を切ることにしました。

https://www.reddit.com/r/SwiftUI/comments/175oaqa/is_there_any_way_to_code_swiftui_apps_without_a/

つまづき2 - actions/setup-swift のサポートバージョン

swift にも Market place にセットアップ用のアクションがあったのがですが、いまだにバージョン6系に対応できていませんでした。

https://github.com/swift-actions/setup-swift/issues/683

色々調べた結果 Github の提供している macOS Runner はいくつかのバージョンの Xcode が利用できるように組み込まれていました。

https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md#xcode

そしてアプリの swift のバージョンは使用している Xcode のバージョンに依存していることがほとんどだと思います。

https://xcodereleases.com/

そのため開発環境で利用している 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 を利用している人が多いと思います。

https://github.com/marketplace/actions/slack-notify

ただこの action は macOS Runner から実行できませんでした。
そのために macOS Runner における実行結果を ubuntu Runner に伝播させて slack 通知実行を行うようにマルチジョブによるパイプラインを構築して対応しました。
以下の issue で本課題が挙げられており対策例の記載もありました。

https://github.com/rtCamp/action-slack-notify/issues/22

実際には以下のような実装になりました

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

以下を参考にバージョン出力をしています。

https://www.bioerrorlog.work/entry/xcode-sdk-versions
https://stackoverflow.com/questions/18741675/how-to-get-the-path-of-latest-sdk-available-on-mac

もう少し工夫をすればアサーションにすることもできそうです。

[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