はじめに
こんにちは
アプリ開発チームで主に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移行記事はこちらをご覧ください。
XcodeCloud
XcodeCloudとは?
XcodeCloudはAppleが公式に提供しているCI/CDサービスです。
2021年6月9に発表され、2022年の6月6から使えるようになりました。
「リリースやベータ版の配信(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-formatやSwiftGen, 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上でセットアップできます。
Workflowを作る
Rettyでは現在三つのWorkflowを利用しています。
1. 個別QA用Workflow
このWorkflowは各タスクを検証するため、個別QAベータ版の配信用です。
しかし、このWorkflowではXcodeCloudのStartConditionは使っていません。 では、どうやってビルドするかというと、GHAをビルドのトリガーとして使用しています。 この話は後述の「GHA連携」で記述します。
2. リリース用
このWorkflowで配信されたバージョンを使ってリリース検証やリリースを行っています。
masterブランチにpushされたタイミングで、このWorkflowが実行します。
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が設定できます。
ただ、これだけだと適切なビルドタイミングを決めるのが難しかったため、一部の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の作成ができるようにしています。
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全体的なフロー
これからの改善
FeatureFlagを活性化できるようにする
RettyではFeatureFlagを運用し、開発をしています。
XcodeCloudを導入する前はSwift Compiler-Custom Flagsを利用し、ビルド時に変数を渡すことでFeatureFlagの活性化ができましたが、 XcodeCloudではビルドのflagを渡すのが不可能になったため、他の方法で活性化する必要がありました。
一応FirebaseのRemoteConfigを使う方法もありますが、開発版のみ設定できるように機能を追加すれば良いだけなので、開発版ではツール機能を表示し、flagを切り替えるようにしようと思います。
今のRettyでは開発版とリリース版のSchemeが分けずに一つなので、
したがって、今後このような機能を追加できるように、Schemeを分けて運用したいと考えています。
おわりに
今回の記事ではiOSのCI/CD移行について背景や作業についてご紹介しました。
結果としてはリリースや個別QAでは、社内のビルド用Mac miniを運用した時よりメンテナンスが少なくなって、運用が楽になりました。 あと全体的に費用も削減できました 👍
まだXcodeCloudがリリースしてからあまり時間が経ってないから不便なところもありますが、今後の改善をお楽しみにしています!
アプリ開発チームのメンバーとMeetyでお話しできます!本記事でRettyのアプリ開発チームについて興味が湧きましたら、ぜひMeetyで気軽にお話ししましょう!