はじめてのiOSネイティブアプリCI/CD：主要サービスを比較してみた

---
この記事は [株式会社TRAILBLAZER Advent Calendar 2024](https://qiita.com/advent-calendar/2024/trail-blazer) の23日目の記事です。

---

こんにちは。TRAILBLAZERのソリューション事業部の @big_noble といいます。
[株式会社TRAILBLAZER Advent Calendar 2024 20日目の記事](https://qiita.com/big_noble/items/5cd03b482b7b6ba7c294) の記事に続けて担当します。

アドベントカレンダー2024にて三度目の登場となります。
私が何をしている人か分からない感じの記事が続いておりましたが、ここにきて少しテック寄りの記事を書かせていただく運びとなりました。

# iOSアプリのCI/CDサービス選定のための比較検討
### あらすじ
iOSアプリのビルド〜デプロイ（ストアへのアップロード）までのステップを自動化するべく、主要なCI/CDサービスの調査と比較検討を実施しました。

チーム内にネイティブアプリのCI/CDに関するナレッジが少なかったこともあり、実際にサービスをトライアルしてみて使用感を確認しながら、各種サービスの特徴とメリット・デメリットを洗い出していきました。

### 選定候補
- XCodeCloud　（Apple公式サービスということもあり最有力候補）

https://developer.apple.com/jp/xcode-cloud/

- Codemagic

https://codemagic.io/ja/

- Bitrise

https://bitrise.io/

- Github Actions

https://github.co.jp/features/actions

- CircleCI

https://circleci.com/ja/

- ~~TravisCI~~　（時間の制約もあり、トライアルせず選定候補から後ほど除外）

https://www.travis-ci.com/

### 検証前に考えていたこと
- コストはなるべく抑えたい
- 現環境の制約上、AWS CodeCommitのリポジトリと連携可能なサービスが良い
- 追々AndroidネイティブアプリのCI/CDも検討するので併用できると尚良い
- ビルド〜デプロイステップ構築の難易度が、サービスによってどう異なるか
- 現運用ではFirebase App Distributionもテスト用途で利用している
ビルドフローの中で、Firebase App Distributionへのアップロードも検討したい

### 検証を進めてわかってきたこと
#### 証明書、プロビジョニングプロファイルの実装・管理が煩雑
- ビルドフローを実装する上で証明書とプロビジョニングプロファイルの扱いに苦労
- 実装の難しさだけでなく、用途に応じた証明書類をどう適切に管理すべきかも考慮が必要

##### 検証を進めながら比較表を作成
業務事情も記載しているため、詳細はマスキングして後述
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/210070/a1d090eb-9407-aaf4-9503-912ac1df0f4f.png)

#### 各サービスの特徴や良し悪しをざっくり
##### XCodeCloud
- 毎月の無料利用枠があり、制限を超えた後の従量課金も比較的安価
- AWS CodeCommitとの連携はできない
- 上述の証明書類に関する管理の煩雑性をほとんど気にする必要がない
- XCode上からGUIで必要なワークフローの設定を行う
- ただし、ワークフローのコード管理はできない
- XCode上から実行するだけでビルドとストアへのアップロードまでしてくれる
- ビルドログやアーティファクトをXcodeから確認し、ダウンロードすることも可能
- Firebase App Distributionへの考慮は、カスタムスクリプトを実装しないといけなさそう
- チームで共有したり適切な権限制御をしたくなった場合の要件にどう対応できるかがよく分からない

##### Codemagic
- AWS CodeCommitとの連携が可能
- 証明書類のファイルはUIコンソール上から事前登録しておくことで、ビルドフロー時に自動参照してくれる
- ワークフローはYamlで定義（Flutter製アプリであればGUIでの構築も可）
- YamlはCodemagic CLIのコマンドをベースとしており、煩雑になりがちなコードを簡素化してくれている
- Firebase App DistributionのPublishはコマンドが用意されているため簡易的に実現可
- ビルドの実行のみ可能なユーザやWebhookトリガーだけ実行可能なユーザなどの権限制御が可能

##### Bitrise
- 他サービスと比較してコスト高なのがネック
- AWS CodeCommitとの連携が可能
- 証明書類のファイルはUIコンソール上から事前登録しておくことで、ビルドフロー時に自動参照してくれる
- ワークフローはBistriseが提供している処理ステップを組み合わせてノーコードツールのような操作感で実行フローを作成する。事前の環境設定のみでステップ内で適時実行してくれる
- Yamlをリポジトリ内に保管しフローを構築することも可能
- GUIで構築できるのは良かった反面、処理ステップ内の設定値管理やエラー時の原因究明が意外と難しい
- Firebase App DistributionのPublishは処理ステップが用意されているため簡易的に実現可
- Admin, Developer, QA/Tester, PlatformEngineer の４ロールが設定可能で、比較的柔軟な権限制御が可能

##### Github Actions
- 毎月の無料利用枠があり、制限を超えた後の従量課金も比較的安価
- AWS CodeCommitとの連携はできない
- 証明書類はBase64エンコード化してシークレットに登録。ワークフロー内でデコードして利用することで実現可能。fastlane match を利用すれば、証明書類はリポジトリで管理できる
- ワークフローはYamlで定義
- 証明書類やアーカイブ、ストアアップロードまでの手続きを自前で実装する必要があり初見では一苦労。fastlaneを使った方が効率化できるが、これもそれなりに学習コストがかかる。XCodeCloudやCodemagicの簡便性にここで気づく
- 一方で内部フローの理解も深まり、先駆者も多く情報量は多いので調べればなんとかなるという実感もあり
- ブランチプロテクションルールやプルリクエストを活用して、権限責務者の目を必ず通す運用は可能
- Github Actions内で、Codemagic CLIをインストールしてコマンドを簡素化するのも一考ではないか

##### ~~CircleCI~~
- AWS CodeCommitとの連携はできない
- ワークフローはYamlで定義
- 概ねGithub Actionsと同じ所感でありつつ、環境変数などの設定、読み込み手順で苦戦
- あえてこれからCircleCIを利用するメリットも検証では浮かばなかったので、途中で対象から除外

### どのCI/CDサービスを利用するのが良いか
今のところ、Github Actionsを利用することを想定しています。

「え？なんか手こずったように見えるけど？」という気持ちも分かります。

確かに初期設計や構築のハードルは他サービスと比較してやや難易度が高いと感じました。
ですが、良くも悪くも基本的には自前でビルドフローを実装するため、今は想定すらできていない課題や不確定要素が多い中進めていくことになるので、柔軟性の高さや情報量の多さで「無難」だと考えています。

また、AWS CodeCommitとの連携を前提に考えていたところでしたが、[CodeCommitは新規受け入れ停止のニュース](https://aws.amazon.com/jp/blogs/news/how-to-migrate-your-aws-codecommit-repository-to-another-git-provider/)などもあり実はGithubへのリポジトリ移行も並行して検討なされていたタイミングでした。
それだったらまずはGithub Actionsでやってみようよという流れも後押ししました。

あとは、AndroidアプリのCI/CD化も見据えると、なるべく利用するサービスを統一したかった、という点もありますね。

他サービスの評価としては、XCodeCloudの簡便性は圧倒的に良く、Codemagicも個人的な操作感としては扱いやすい印象でした。
しかし、比較的新しいサービスなのでエラーで困ったときの情報量が少なかったり、簡素化されている弊害としてトラブルシューティングが必要になる状況で路頭に迷いそうな不安が残りました。

とはいえ、まずはCI/CDの本導入に向けて歩み始めたという段階で、他のサービスも今後の状況に応じて再検討の余地は十分にありますし、今回実際にトライアルして構築までのナレッジは蓄積することができたので、再検討する際の精度も高まるだろうと思います。

### おまけ
検証で実装したワークローのYamlサンプルです。
BitriseはGUIで作成、CircleCIは途中で対象から除外したため、CodemagicとGithub Actionsのサンプルになります。

##### Github Actions
- fastlaneを使うパターンと使わないパターンを検証しましたが、下記サンプルは使わないパターンです
- ビルド番号は暫定で実行時に指定する形としていますが、fastlaneを使えばストアにアップロードされている最新バージョンを取得することも容易に可能です
- Firebase App Distributionへのアップロードも、fastlaneを使うことで容易に実現できました（サンプルは記述外）
- [こちらの記事](https://www.cobeisfresh.com/blog/how-to-implement-a-ci-cd-workflow-for-ios-using-github-actions)を参考にさせていただきました
<details><summary>サンプルコード</summary>

```.yml
name: iOS build

on:
  workflow_dispatch:
    inputs:
      build-number: 
        description: "build_number"
        required: true

jobs:
  build:
    name: Build
    runs-on: macos-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Install the Apple certificate and provisioning profile
        env:
          BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
          P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
          BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: |
          # create variables
          CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
          PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db

          # import certificate and provisioning profile from secrets
          echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
          echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH

          # create temporary keychain
          security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH

          # import certificate to keychain
          security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security list-keychain -d user -s $KEYCHAIN_PATH

          # apply provisioning profile
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles

      - name: Increment Build number
        run: |
          agvtool new-version ${{ github.event.inputs.build-number }}

      - name: Archive iOS app
        run: xcodebuild -scheme DemoApp clean archive -archivePath "DemoApp" -configuration Debug
      
      - name: Export ipa
        env:
          EXPORT_PLIST: ${{ secrets.IOS_EXPORT_PLIST_BASE64 }}
        run: |
          # create export options
          EXPORT_PLIST_PATH=$RUNNER_TEMP/ExportOption.plist
          echo -n "$EXPORT_PLIST" | base64 --decode --output $EXPORT_PLIST_PATH
          xcodebuild -exportArchive -archivePath $GITHUB_WORKSPACE/DemoApp.xcarchive -exportOptionsPlist $EXPORT_PLIST_PATH -exportPath $RUNNER_TEMP/export

      - name: Decode auth. api key file and save it
        env:
          API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}
        run: |
          ls ~/
          mkdir ~/private_keys
          ls ~/private_keys
          echo -n "$API_KEY_BASE64" | base64 --decode --output ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8
          echo "After saving: "
          ls ~/private_keys
          
      - name: Upload file to TestFlight using CLI
        run: |
          echo "Starting upload."
          ls ~/private_keys
          xcrun altool --validate-app -f /Users/runner/work/_temp/export/DemoApp.ipa -t ios --apiKey ${{ secrets.APP_STORE_CONNECT_API_KEY_ID}} --apiIssuer ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          xcrun altool --upload-app -f /Users/runner/work/_temp/export/DemoApp.ipa -t ios --apiKey ${{ secrets.APP_STORE_CONNECT_API_KEY_ID}} --apiIssuer ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
```
</details>

##### Codemagic
- TestFlightへアップロードするワークフローと、Firebase App Distributionへアップロードするワークフローが同梱されています
- やっていることはGithub Actionsと同じですが、Github Actionsと比較してステップ内の記述が直感的だと思います（が、詳しい方からすると逆に中で何やっているか分からない、ともなり得る点）
- [Codemagicの公式ドキュメント](https://docs.codemagic.io/)を参照することで十分に構築可能でした

<details><summary>サンプルコード</summary>

```.yaml
workflows:
    ios-build-deploy-to-firebase-workflow:
        name: iOS build for Firebase App Distribution Sample Workflow
        max_build_duration: 120
        instance_type: mac_mini_m2
        environment:
            xcode: 15.4
            vars:
                BUNDLE_ID: "xxxxxxxxxxxxxxxxxxxx"
                XCODE_WORKSPACE: "DemoApp.xcworkspace"
                XCODE_PROJECT: "DemoApp.xcodeproj"
                XCODE_SCHEME: "DemoApp"
            ios_signing:
                provisioning_profiles:
                    - DemoApp
                certificates:
                    - DemoApp
            groups:
                - test_firebase_credentials
        
        scripts:
          - name: Set up code signing settings on Xcode project
            script: xcode-project use-profiles

          - name: Run Build
            script: |
              xcode-project build-ipa \
                  --project "$XCODE_PROJECT" \
                  --scheme "$XCODE_SCHEME"
        
        artifacts:
            - build/ios/ipa/*.ipa
            - /tmp/xcodebuild_logs/*.log
            - $HOME/Library/Developer/Xcode/DerivedData/**/Build/**/*.app
            - $HOME/Library/Developer/Xcode/DerivedData/**/Build/**/*.dSYM
        
        publishing:
          firebase:
            firebase_service_account: $FIREBASE_SERVICE_ACCOUNT
            ios:
              app_id: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
              groups:
                - xxxxx

    ios-build-deploy-to-app-store-connect-workflow:
        name: iOS build for TestFlight Sample Workflow
        max_build_duration: 120
        instance_type: mac_mini_m2
        integrations:
            app_store_connect: xxxxxxxxxx
        environment:
            xcode: 15.4
            vars:
                BUNDLE_ID: "xxxxxxxxxxxxxxxxxxxx"
                XCODE_WORKSPACE: "DemoApp.xcworkspace"
                XCODE_PROJECT: "DemoApp.xcodeproj"
                XCODE_SCHEME: "DemoApp"
                APP_STORE_APPLE_ID: xxxxxxxxxx
            ios_signing:
                provisioning_profiles:
                    - DemoApp
                certificates:
                    - DemoApp
        
        scripts:
          - name: Set up code signing settings on Xcode project
            script: xcode-project use-profiles

          - name: Increment build number
            script: | 
              cd $CM_BUILD_DIR
              LATEST_BUILD_NUMBER=$(app-store-connect get-latest-testflight-build-number "$APP_STORE_APPLE_ID")
              agvtool new-version -all $(($LATEST_BUILD_NUMBER + 1))

          - name: Run Build
            script: |
              xcode-project build-ipa \
                  --project "$XCODE_PROJECT" \
                  --scheme "$XCODE_SCHEME"
        
        artifacts:
            - build/ios/ipa/*.ipa
            - /tmp/xcodebuild_logs/*.log
            - $HOME/Library/Developer/Xcode/DerivedData/**/Build/**/*.app
            - $HOME/Library/Developer/Xcode/DerivedData/**/Build/**/*.dSYM
        
        publishing:
          app_store_connect:
              auth: integration
              submit_to_testflight: false
              submit_to_app_store: false

```
</details>

# さいごに
私自身、ネイティブアプリの技術領域はまだまだ勉強中で、今回の検証を通して学びが多くありました。拙いところもあるかと思いますが少しでも参考になれば幸いです。
最後までお読みいただきありがとうございました。

https://hrmos.co/pages/trail-blazer