Retty Tech Blog

実名口コミグルメサービスRettyのエンジニアによるTech Blogです。プロダクト開発にまつわるナレッジをアウトプットして、世の中がHappyになっていくようなコンテンツを発信します。

iOSのCI/CDをXcodeCloud+GitHubActionsに移行し費用削減になったうえに運用効率が向上しました!

はじめに

こんにちは
アプリ開発チームで主にiOS開発をしているレイです。

この記事は Retty Advent Calendar Part2 の23日目の記事です。
Part1 はこちら

今回の記事では下記の内容で話をしようと思います

課題

アプリチームでは下記のような課題を持っていました。

Bitriseを利用したPullRequest(以下PR)でのビルドやテストの確認(CI)

  • プランの値上げで、費用が高くなった

個別QA検証用のベータ版、リリース版

  • Provisioning Profileのメンテナンスのコスト
  • Fastlaneのメンテナンスがコスト
  • ベータ版配信用で使っている社内のMacマシン
    • 電源が切れたらオフィスに行かないといけない
    • たびたびキャッシュが正しく設定されない問題が発生し、そのメンテナンスが大変

Androidのベータ版配信の場合、今年の夏ぐらいに同じチームのメンバーがGithubActions(以下GHA)へに移行しました。
iOSも話し合って、別のサービスに移行することになりました。

CI/CDサービスの比較

BitriseとXcodeCloudの比較(議事録から引用しました)

項目 Xcode Cloud Bitrise
料金
(現状の合計使用時間 → 64.5h)
25時間/月 : 14.99ドル/月
100時間/月 : 44.99ドル/月
❤️ 250時間/月 : 99.99ドル/月
1000時間/月 : 399.99ドル/月
https://www.bitrise.io/pricing
マシンスペック アーキテクチャ x86_64
メモリ 16GB
コア数 4
クロック 2 GHz
アーキテクチャ x86_64
メモリ8GB
4xCPU (Intel Xeon E5-1650 v2)
並列数 可能だけど数は分からない 3 → 新プランで無制限に
リリースフロー TestFlight + AppStoreConnectへ自動アップロード
Fastlaneの管理は不要
Fastlane(Ruby)を利用しFADへアップロード & AppStoreConnectへのアップロードは別
Fastlaneのアップデート・修正が必要
移行までに必要な手順 (任意) Firebase App Distribution → Test Flightへの移行 (軽)
(任意) Swift Package Managerへの移行 (重)
なし
導入までの工数 CocoaPodsを維持
SwiftPackageManagerを導入
なし
dSYMアップロード ci_script経由 Fastlane経由


AndroidのCI/CD移行記事はこちらをご覧ください。

engineer.retty.me

XcodeCloud

XcodeCloudとは?

XcodeCloudAppleが公式に提供しているCI/CDサービスです。
2021年6月9に発表され、2022年の6月6から使えるようになりました。

developer.apple.com

developer.apple.com

「リリースやベータ版の配信(TestFlight)、コードの変更のテスト、テストの自動化、並列ビルド」など他のサービス(Bitrise, CircleCIなど)と同じような機能を持っているので、さまざまなタスクを自動化することができます。

XcodeCloudのメリット・デメリット

メリット

  • 使い慣れたGUIを利用してワークフローを構成できるため、Fastlaneなどが要らない
  • 最新OSのサポートが一番早い
  • コードとCI/CDの両方ともXcodeの中で管理できる
  • Provisioning Profile, 証明書のメンテナンスが要らない
  • コストがすごく安い(250時間/月→ 99.99ドル)

デメリット

  • ビルドトリガーの制約が多い
  • SPM以外にキャッシュ機能を使用することがほとんど不可能or難しい
  • まだネットにも情報が少ないので公式ドキュメント以外は参考にするものがほぼない

他にもいろいろメリットやデメリットがあると思いますが、価格が他社と比べてとても安いという点といろいろ管理が簡単になる点で良いサービスだと思います。

移行作業

XcodeCloudを導入するための変更

1. ライブラリー管理をCocoaPodsからSPMへに移行

XcodeCloudでもHomebrewを利用してCocoaPodsのインストールは可能です。
ただpre-xcodebuildでCocoaPodsをインストールするにはRettyアプリの場合約178秒、Retty iOSで使用している31個のライブラリーをインストールするのに195秒がかかるため、キャッシュ機能は必須です。

しかし、XcodeCloudではキャッシュをDerivedDataにすべて保存するため、従来の通りPodfile.lockを保存する形ではキャッシュ機能を使用できません。
できる可能性のある方法としては、毎回pod内部のフレームワークを全部DerivedDataに移動するのもありかなと思います。
しかしこれが実現できるかどうかは分からず、そもそも毎回ファイルを移動するのも時間がかかるのでSPMに移行することを決めました。

現在、次のように4つのモジュールを分けてビルドに必要なライブラリーとアプリ内で必要なライブラリーに従って運用しています。 今後はrelease, dev によって分ける予定で、残りのライブラリーも少しずつCocoaPodsからSPMに移行する予定です

ビルド用のモジュール

import PackageDescription

let package = Package(
    name: "RettyBuildModule",
    platforms: [
        .macOS(.v10_15)
    ],
    products: [
        .library(
            name: "RettyBuildModule",
            targets: ["RettyBuildModule"])
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-format", branch: "0.50700.1")
    ],
    targets: [
        .target(
            name: "RettyBuildModule",
            dependencies: []),
        .testTarget(
            name: "RettyBuildModuleTests",
            dependencies: ["RettyBuildModule"]),
    ]
)

アプリ内で利用するライブラリーモジュール

import PackageDescription

let package = Package(
    name: "RettyProxyModule",
    platforms: [
        .iOS(.v15)
    ],
    products: [
        .library(
            name: "RettyProxyModule",
            targets: ["RettyProxyModule"]),
    ],
    dependencies: [
        .package ・・・
    ],
    targets: [
        .target(
            name: "RettyProxyModule",
            dependencies: [
                .product ・・・
            ]),
        .testTarget(
            name: "RettyProxyModuleTests",
            dependencies: [
                "RettyProxyModule",                
            ]
        ),
    ]
)

UnitTest用のライブラリモジュール

import PackageDescription

let package = Package(
    name: "RettyTestModule",
    platforms: [
        .iOS(.v15)
    ],
    products: [
        .library(
            name: "RettyTestModule",
            targets: ["RettyTestModule"]),
    ],
    dependencies: [
        .package ・・・
    ],
    targets: [
        .target(
            name: "RettyTestModule",
            dependencies: [
                .product ・・・
            ]),
        .testTarget(
            name: "RettyTestModuleTests",
            dependencies: [
                "RettyTestModule",
            ]),
    ]
)

SnapshotTest用のライブラリモジュール

import PackageDescription

let package = Package(
    name: "RettySnapshotTestModule",
    platforms: [
        .iOS(.v15)
    ],
    products: [
        .library(
            name: "RettySnapshotTestModule",
            targets: ["RettySnapshotTestModule"]),
    ],
    dependencies: [
        .package ・・・
    ],
    targets: [
        .target(
            name: "RettySnapshotTestModule",
            dependencies: []),
        .testTarget(
            name: "RettySnapshotTestModuleTests",
            dependencies: [
                "RettySnapshotTestModule",
                .product ・・・
            ]),
    ]
)

2. BuildPhasesの修正

Rettyではappleのswift-formatSwiftGen, LicensePlistなどいくつかのツールをBuildPhasesで使っているので、 そのpathの修正が必要でした。

例えばswift-formatの場合下記のように使っているpackage-pathを渡します

if [[ $CONFIGURATION = "Debug" ]]; then
    find Retty RettyTests RettySnapshotTests -name "*.swift" -not -path '*/SwiftGen/*' | xargs env -i PATH=\"$PATH\" swift run -c release --package-path RettyBuildModule swift-format lint -r -s -p
fi

LicensePlistの場合はXcodeCloudとローカルのpathが違うため、それぞれの対応をする必要がありました。

if [[ $CONFIGURATION = "Debug" ]]; then
    if which /usr/local/bin/license-plist > /dev/null; then        
        /usr/local/bin/license-plist --single-page --output-path $PRODUCT_NAME/Settings.bundle --github-token トークン --config-path $PRODUCT_NAME/license_plist.yml
    elif which /opt/homebrew/bin/license-plist > /dev/null; then        
        /opt/homebrew/bin/license-plist --single-page --output-path $PRODUCT_NAME/Settings.bundle --github-token トークン --config-path $PRODUCT_NAME/license_plist.yml
    else
        echo "warning: Please install license-plist with 'brew install license-plist'"
        exit 1
    fi
fi

Workflow

Workflowとは

WorkflowではStartCondition(開始条件)actionsなどビルドに必要な情報や設定などがUI上でセットアップできます。

developer.apple.com

Workflowを作る

Rettyでは現在三つのWorkflowを利用しています。

1. 個別QA用Workflow

このWorkflowは各タスクを検証するため、個別QAベータ版の配信用です。

しかし、このWorkflowではXcodeCloudのStartConditionは使っていません。 では、どうやってビルドするかというと、GHAをビルドのトリガーとして使用しています。 この話は後述の「GHA連携」で記述します。

個別QA用

Archive-For-TestFlightのStartConditionは使っていない

2. リリース用

このWorkflowで配信されたバージョンを使ってリリース検証やリリースを行っています。
masterブランチにpushされたタイミングで、このWorkflowが実行します。

リリース用
リリースWorkflowのStartCondition

3. PRの変更をテスト

このWorkflowは各ブランチに差分が入ったタイミングでビルドやテストを行なっています。

テスト用

Custom Build Scriptを作る

XcodeCloudでは三つのカスタムビルドスクリプトが使えます。 具体的にはこちらを参考にしてください。
https://developer.apple.com/documentation/xcode/writing-custom-build-scripts

ci_post_clone

developブランチのアプリバージョンと比較し、同じでなければ終了するコードが入っています。
その理由としては、TestFlightの場合AppStoreのリリースバージョンや現在リリース審査中のバージョンより高くないと、TestFlightへの配信が失敗する仕様になっているため、developブランチとの差分をチェックしています。(developブランチは常にベータ版の配信ができるバージョンに更新している)
また、CocoapodsのインストールとLicensePlistのダウンロードもここで行なっています。。

# ci_post_clone

if [[ $CI_WORKFLOW = "Test-CI" || $CI_WORKFLOW = "Archive-For-TestFlight" ]]; then
    # PRでのビルドやテストの確認するWorkflowや個別QA用Workflowの場合にバージョンの差分をチェックする

    SOURCE_BRANCH=$CI_BRANCH
    RELEASE_BRANCH='release/'
    MASTER_BRANCH='master'
    HOTFIX_BRANCH='hotfix/'

    if [[ $SOURCE_BRANCH != $RELEASE_BRANCH* || $SOURCE_BRANCH != $MASTER_BRANCH* || $SOURCE_BRANCH != $HOTFIX_BRANCH* ]]; then
        git fetch origin develop
        lines=$(git diff remotes/origin/develop..$CI_BRANCH --word-diff-regex='<string>([0-9]+(\.[0-9]+)+)</string>' ${CI_WORKSPACE}/${CI_PRODUCT}/Info.plist | wc -l)
        if [ $lines -gt 0 ]; then
            echo "Retty: Version is not updated"
            exit 1
        fi
    fi
fi

brew install cocoapods
brew install licenseplist

ci_pre_xcodebuild

ここではpodのインストールやCFBundleVersionとCFBundleShortVersionStringの設定をしています。

# ci_pre_xcodebuild

pod install --repo-update

if [[ $CI_WORKFLOW = "Archive-For-TestFlight" ]]; then
    # 個別QA用Workflowの場合、アプリのバージョンは0.0.1上げて、ビルド番号はXcodeCloudのビルド番号に設定

    PATH=/usr/libexec:$PATH
    notificationInfoPlist="${CI_WORKSPACE}/NotificationService/Info.plist"
    mainInfoPlist="${CI_WORKSPACE}/${CI_PRODUCT}/Info.plist"
    
    nextBuildNumber=$CI_BUILD_NUMBER
    originalVersion=$(PlistBuddy -c "print CFBundleShortVersionString" "${mainInfoPlist}")
    nextVersion=$(echo ${originalVersion} | awk -F. -v OFS=. '{$NF += 1 ; print}')
    
    echo "Retty: - versionNumber is ${nextBuildNumber}"
    echo "Retty: - version is ${nextVersion}"
    
    PlistBuddy -c "Set :CFBundleVersion $nextBuildNumber" "${mainInfoPlist}"
    PlistBuddy -c "Set :CFBundleShortVersionString $nextVersion" "${mainInfoPlist}"
    PlistBuddy -c "Set :CFBundleVersion $nextBuildNumber" "${notificationInfoPlist}"
    PlistBuddy -c "Set :CFBundleShortVersionString $nextVersion" "${notificationInfoPlist}"
elif [[ $CI_WORKFLOW = "Archive-For-Release" ]]; then
    # リリース用Workflowの場合、ビルド番号はXcodeCloudのビルド番号に設定

    PATH=/usr/libexec:$PATH
    nextBuildNumber=$CI_BUILD_NUMBER
    notificationInfoPlist="${CI_WORKSPACE}/NotificationService/Info.plist"
    mainInfoPlist="${CI_WORKSPACE}/${CI_PRODUCT}/Info.plist"
    
    echo "Retty: - versionNumber is ${nextBuildNumber}"
    
    PlistBuddy -c "Set :CFBundleVersion $nextBuildNumber" "${mainInfoPlist}"
    PlistBuddy -c "Set :CFBundleVersion $nextBuildNumber" "${notificationInfoPlist}"
fi

CFBundleShortVersionStringの場合ローカルのバージョンを持ってきて0.0.1を上げるだけで、
CFBundleVersionはXcodeCloudで管理しているCI_BUILD_NUMBERをそのまま設定しています。

ci_post_xcodebuild

テストの場合git diffでLicensePlistやswift-formatによる差分があるかを確認し、ベータ版の場合はdSYMのアップロードを行なっています。

# ci_post_xcodebuild

upload_dsym() {
    set -e
    if [[ -n $CI_ARCHIVE_PATH ]]; then
        echo "Found valid archive path, trying to upload dSYMs."
        echo "Start uploading dSYMs"
            
        ${CI_WORKSPACE}/Scripts/upload-symbols -gsp "${CI_WORKSPACE}/GoogleService-Info.plist" -p ios "$CI_ARCHIVE_PATH/dSYMs"
    fi
}

get_pr_info() {
    curl -s \
    -H "Accept: application/vnd.github+json" \
    -H "Authorization: Bearer ${GITHUB_TOKEN}"\
    -H "X-GitHub-Api-Version: 2022-11-28" \
    "https://api.github.com/repos/RettyInc/iPhone4/pulls"
}

get_pr_number() {
    python3 -c "import sys, json; responseJson=json.load(sys.stdin); print([x for x in responseJson if x['head']['ref'] == '${CI_BRANCH}'][0]['number'])"
}

get_app_version() {
    PATH=/usr/libexec:$PATH
    mainInfoPlist="${CI_WORKSPACE}/${CI_PRODUCT}/Info.plist"
    PlistBuddy -c "print CFBundleShortVersionString" "${mainInfoPlist}"
}

version_pr_comment() {
    PR_NUMBER=$(get_pr_info | get_pr_number)
    APP_VERSION=$(get_app_version)
    
    curl -X POST \
    -H "Accept: application/vnd.github.v3+json" \
    -H "Authorization: token ${GITHUB_TOKEN}" \
    "https://api.github.com/repos/RettyInc/iPhone4/issues/$PR_NUMBER/comments" \
    -d '{"body":"ビルドが成功し、下記のバージョンで配信中です。\nApp Version: '${APP_VERSION}'\nBuild Number: '${CI_BUILD_NUMBER}'"}'
}

if [[ $CI_WORKFLOW = "Test-CI" ]]; then
    # PRでのビルドやテストの確認するWorkflowではswift-formatやLicensePlistによる差分があるかチェック

    git diff --exit-code
elif [[ $CI_WORKFLOW = "Archive-For-TestFlight" ]]; then
    # 個別QA用Workflowの場合dsymをアップロードし、ビルド番号やアプリのバージョンをPullRequesstにコメントする
    upload_dsym
    version_pr_comment
elif [[ $CI_WORKFLOW = "Archive-For-Release" ]]; then
    # リリース用Workflowの場合dsymをアップロード
    upload_dsym
fi

GHAとの連携

XcodeCloudでは下の写真のように四つのStartConditionが設定できます。

StartCondition

ただ、これだけだと適切なビルドタイミングを決めるのが難しかったため、一部のWorkflowではXcodeCloudのStartConditionを使わずに、APIを利用してカスタムで開始条件を設定するようにしました。

XcodeCloudのビルドのためには、AppstoreConnect APIを利用します。 https://developer.apple.com/documentation/appstoreconnectapi/start_a_build このAPIを使うとビルドをトリガーするのが可能です。

ただ、このAPIを利用するにはWorkflowのIDやPull RequestのIDまたはブランチIDが必要なので、 これを取得できるAPIから呼ぶ必要があります。
それについては後述の「CI/CD全体的なフロー」で確認できます。

個別QA用のWorkflow

基本的に個別QA用のベータ版配信はPRからトリガーできるように設定しています。

on:
  issue_comment:
    types: [created]

jobs:
  distribute:
    if: contains(github.event.comment.html_url, '/pull/') && startsWith(github.event.comment.body, '/testflight')

このように配信したいブランチのPRで"/testflight"とコメントをすると、 JWT-Tokenを生成し、ビルド開始APIを呼びます。

・・・
     - name: "create jwt token"
        id: "create-jwt-token"
        env:
          ISSUER_ID: ${{secrets.JWT_ISSUER_ID}}
          KEY_ID: ${{secrets.JWT_KEY_ID}}
          PRIVATE_KEY_CODE: ${{secrets.JWT_PRIVATE_KEY_CODE}}
        run: |
          echo "jwtToken=$(ruby ./.github/workflows/create-jwt-token.rb ${ISSUER_ID} ${KEY_ID} "${PRIVATE_KEY_CODE}")" >> $GITHUB_OUTPUT
          
     - name: "distribute to testflight"
        id: "distribute-to-testflight"
        env:
          JWT_TOKEN: ${{steps.create-jwt-token.outputs.jwtToken}}
          BRANCH: ${{ fromJSON(steps.pull_request.outputs.data).head.ref }}
          WORKFLOW_NAME: "Archive-For-TestFlight"
        run: |
          statusCode=$(JWT_TOKEN=${JWT_TOKEN} BRANCH_NAME=${BRANCH} WORKFLOW_NAME=${WORKFLOW_NAME} python3 ./.github/workflows/distribute-appstore.py)
          echo "statusCode=${statusCode}" >> $GITHUB_OUTPUT
・・・

リリースブランチを作るためのWorkflow

リリースブランチを作るためのWorkflowはworkflow_dispatchを利用してボタンからPRの作成ができるようにしています。

リリースPRを作るためのWorkflow

on:
  workflow_dispatch:
    inputs:
      should_skip_update_version:
        type: boolean
        required: true
        default: false

ここでリリースブランチを作る前にはバージョンの設定をして差分をcommitするようにしています。

・・・
     - name: "update version"
        id: "update_version"
        env:
          SKIP_UPDATE: ${{ github.event.inputs.should_skip_update_version }}
        run: |
          nextVersion=$(should_skip=${SKIP_UPDATE} python3 ./.github/workflows/apply-info-version.py)
          echo "nextVersion=${nextVersion}" >> $GITHUB_OUTPUT
・・・

下記のようにshould_skip_update_versionによって必要なバージョン設定を行なっています。

should_skip_update_versionがoffの場合
CFBundleShortVersionStringを0.0.1上げる
- 新しいアプリバージョンをAppStoreに配信

should_skip_update_versionがonの場合
CFBundleVersionだけ更新してビルド番号だけ更新する
- ビルドや検証で問題が発生し、前と同じアプリバージョンを使って、ビルド番号だけ更新してAppStoreに配信

そのあとはmaster向けのReleaseブランチとdevelop向けのUpdate Versionブランチを作ります。

Releaseブランチがmasterにマージされたタイミングで、XcodeCloudでリリースWorkflowが実行します。

CI/CD全体的なフロー

個別QA

リリース

これからの改善

FeatureFlagを活性化できるようにする

RettyではFeatureFlagを運用し、開発をしています。

engineer.retty.me

XcodeCloudを導入する前はSwift Compiler-Custom Flagsを利用し、ビルド時に変数を渡すことでFeatureFlagの活性化ができましたが、 XcodeCloudではビルドのflagを渡すのが不可能になったため、他の方法で活性化する必要がありました。

一応FirebaseのRemoteConfigを使う方法もありますが、開発版のみ設定できるように機能を追加すれば良いだけなので、開発版ではツール機能を表示し、flagを切り替えるようにしようと思います。

今のRettyでは開発版とリリース版のSchemeが分けずに一つなので、
したがって、今後このような機能を追加できるように、Schemeを分けて運用したいと考えています。

おわりに

今回の記事ではiOSのCI/CD移行について背景や作業についてご紹介しました。

結果としてはリリースや個別QAでは、社内のビルド用Mac miniを運用した時よりメンテナンスが少なくなって、運用が楽になりました。 あと全体的に費用も削減できました 👍

まだXcodeCloudがリリースしてからあまり時間が経ってないから不便なところもありますが、今後の改善をお楽しみにしています!

アプリ開発チームのメンバーとMeetyでお話しできます!本記事でRettyのアプリ開発チームについて興味が湧きましたら、ぜひMeetyで気軽にお話ししましょう!

meety.net