Codemagicを使ってFlutter製iOS社内アプリ(ADEP)のCICD構築してみた
はじめに
先週、モンゴルでの新規店舗オープンの準備で、人生ではじめてモンゴルの首都ウランバートルにいってきました。世界で最も寒い首都のひとつらしいのですが、今の時期は半袖で快適に過ごせるくらいの調度いい気温で非常に過ごしやすかったです。
この度、自分が開発に携わった店舗スタッフ用のiOSアプリがモンゴルで初めて日の目をみました。課題は多く残りつつも、自分の開発したアプリを使ってメガネを購入しているお客様の姿や、接客オペレーションをしているスタッフを生でみるのは、何ともいえぬ感動がありました。フロントエンド開発をしていて良かった、と思える瞬間でした。
とはいえ本当に課題はまだまだ多く残っているので、気を引き締め直して頑張りたいと思います。
下の画像はモンゴルで食べた羊料理です。羊の魯肉飯(ルーローハン)みたいなやつで、たぶんアク抜きとかしていなくて独特な香りがしました。チヂミみたいな見た目のパン?がとても硬くて美味しかったです。
本題に入ります。JINSでは社内向けアプリをApple Developer Enterprise Program(ADEP)で管理しているのですが、業務内でADEP用のCICD環境を構築したので、構築方法や所感を書いていきたいと思います。使用するCICDツールはCodemagicです。
本記事で実装するCICDパイプラインの概要
検証・本番環境の2環境に対応したCICDパイプラインを構築します。
検証も本番も下記の手順は同じです。
- Github mainブランチのpushイベントでCodemagic発火
- CodemagicでビルドしてFirebase App Distributionにipaファイルをアップロード&配信
本番環境では上記に加えて、ビルド後にMDMにipaファイルをアップロードし、配布する手順を追加しています。
Codemagicとは
Codemagicは、モバイルアプリケーション開発に特化したCI/CDプラットフォームです。
元々はFlutter専門のCI/CDサービスとしてスタートした経緯から、特にFlutter開発者からの評価が高いようです。Flutterの主要バージョンは事前インストール済みです。
GihubActionsと比べると使用可能なmacOSのスペックが優れています。
GeminiDeepResearchで主要CICDツールの簡単な比較表を作成してみました。
主要CICDツールのスペック比較表
機能 | Codemagic | GitHub Actions | CircleCI | Bitrise |
---|---|---|---|---|
標準macOSランナー | Mac mini M2 (8-core CPU, 8GB RAM) | 3-core Intel or 4-core M1 | M1 Medium | M2 Pro Medium (4 CPU, 6GB RAM) |
プレミアムmacOSランナー | M4, M4 Pro, M2/M4 Max Studio (リクエストに応じて) | 12-core Intel or 6-core M1 | M2 Pro, M4 Pro (Medium, Large) | M4 Pro (Large, XL), M2 Pro (Large, XL) |
標準Linuxランナー | あり (8 vCPU, 32GB) | あり (2コア〜) | あり (Docker, VM) | あり (4 vCPU〜) |
標準Windowsランナー | あり (8 vCPU, 32GB) | あり (2コア〜) | あり (Medium〜) | なし (Proプラン以下) |
パフォーマンス戦略 | 最新のApple Siliconを標準で提供し、パフォーマンスを重視 | 標準ランナーは基本スペック。高性能な大規模ランナーは追加料金 | 柔軟なリソースクラス選択。高性能マシンは高クレジット消費 | プランに応じてマシンタイプが固定。高性能マシンは上位プランが必要 |
主要CICDツールのコスト比較表(2025/08/18時点)
カテゴリ | Codemagic | GitHub Actions | CircleCI | Bitrise |
---|---|---|---|---|
無料枠 | 500分/月 (macOS M2) | 2,000分/月 (プライベートリポジトリ) | 30,000クレジット/月 | 300クレジット/月 |
従量課金レート (Linux) | $0.045/分 | $0.008/分 | クレジット消費 (低) | クレジット消費 (低) |
従量課金レート (標準macOS) | $0.095/分 | $0.08/分 | クレジット消費 (高) | クレジット消費 (高) |
macOSレート計算 | 直接的な分単価 | 10倍の分乗数 | リソースクラスに応じた高クレジット消費 | リソースクラスに応じた高クレジット消費 |
エントリー有料プラン | Pay-as-you-go ($0/月ベース) または Fixed Price ($3,990/年) | Team ($4/ユーザー/月ベース) | Performance ($15/月ベース) | Starter ($89/月〜) |
GithubActionsのmacOS使用料が、Linuxと比べて10倍である点に注意が必要です。Githubエコシステムは魅力的ですが、macOSにおいては価格とスペックで他サービスに大きく遅れをとっているようにみえます。
iSOアプリ(とりわけFlutterアプリケーション)のCICD環境としては、Codemagicが最適そうです。
Apple Developer Enterprise Program(ADEP)とは
まずはじめに、ADEPとは何かご存知でない方も多いと思うので説明します。
一言でいうとAppStoreを経由せず、台数無制限で直接アプリ配布するための特別なプログラムです。
Apple Storeを経由しない分、ポリシーは厳しくなっており企業の社員以外へのアプリの配布は原則認められていません。
過去にはなんとFacebook、GoogleがBANされています。
AppleからすればApple Storeの審査を通したいところだと思いますので、ポリシーが厳しいのは当然といえば当然かもしれませんが、GoogleがBANされたと聞くとビビってしまいます。いきなりBANされないようにくれぐれもポリシーは厳守していきましょう。
また、現在ADEPは新規で契約することが難しいようです。
このような現状なので、一般的なiOSアプリのCICD構築は情報がたくさんありますがADEP関連の情報は希少です。どなたかの参考になれば幸いです。
事前準備
- BundleID
- 配布用証明書
- プロビジョニングプロファイル
上記の他に、ローカルPCでビルドが成功し、ipaファイルの作成ができることを確認してください。
Codemagicの設定
Applicationの作成
まずはApplicationを作成していきます。画面右上のAdd applicationをクリックします。
Team、PersonalAccountどちらでもOKです。
使用しているGitリポジトリサービスを選択してください。
JINSではGithubを利用しているので、Githubを選択します。
GIthub側で承認します。
あとは何やかんやでapplicationが作成できます。
codemagicでは、YAMLモードとGUI(workflow)モードを選択できます。
個人的にはYAMLモード推奨です。GUIモードは管理しづらいだけで特にメリットもありません。
本記事ではYAMLモードを選択していきます。
デフォルトではGUIモードに設定されているので、下記画像の「Switch to YAML configuration」からYAMLモードに変更します。
YAMLモードに変更後は、下記のようなUIに変わりました。
YAMLモードでは、codemagic.yamlをプロジェクトに含める必要があります。codemagic.yamlについての詳細は後述します。
手動コード署名
iOSアプリのCICDパイプライン構築は、バイナリ作成後のデジタル署名が肝となります。
CodemagicではADEPアプリケーションの署名で、自動署名を使用できません。
公式ドキュメント
なので、手動でコード署名をする必要があります。
手動コード署名に必要なものは、配布用証明書・証明書パスワード・プロビジョニングプロファイルの3つです。
プロビジョニングプロファイルは、AppleDeveloperサイトから取得してください。
配布用証明書は、Macのキーチェーンから配布用証明書を書き出します。証明書と、その証明書に紐づく「秘密鍵」の両方を選択した状態で「2項目を書き出す...」を選択します。
パスワードを設定するダイアログが表示されるので、任意のパスワードを設定します。ここで設定したものが「証明書のパスワード」になるので、忘れないように安全な場所に保管しておきましょう。
配布用証明書、証明書パスワード、プロビジョニングプロファイルが用意できたら、Codemagicの環境変数に設定していきます。
環境変数の設定
前項で取得した配布用証明書やプロビジョニングプロファイルをCodemagicの環境変数として設定していきます。
証明書関連以外にも、yamlに記載したくない項目(Gitで管理したくない情報)は環境変数に設定しています。
機密情報については、SecretをONにしておくとCodemagic内部で暗号化してデータを保持してくれます。ビルドログ&UIでもvalueが表示されないように自動でマスクしてくれます。
バイナリ(.p12
ファイルなど)や記号を含む可能性がある文字列については、安全にデータを取り扱うために、公式ドキュメントにある通りbase64エンコードしてCodemagicの環境変数に設定します。yamlから参照する場合はデコードしてから参照します。(例:echo "$CERTIFICATE_PASSWORD" | base64 --decode
)
workflowで使用する環境変数は、yaml内でenvironment
> groups
を設定することで使用可能となります。
参考までに、今回作成する環境変数は下記の通りです。Firebase、MDM関連の環境変数については後述します。
Name | group | 説明 |
---|---|---|
GOOGLE_CHAT_WEBHOOK_URL | common | GoogleChat用WebhookURL |
CERTIFICATE_PASSWORD | common | 配布用証明書PW |
CERTIFICATE | common | 配布用証明書 |
PROVISIONING_PROFILE | staging | プロビジョニングプロファイル(staging) |
ENCODED_EXPORT_OPTIONS_PLIST | staging | Export.plist(staging) |
FIREBASE_APP_ID | staging | FADのアプリID(staging) |
PROVISIONING_PROFILE | production | プロビジョニングプロファイル(production) |
ENCODED_EXPORT_OPTIONS_PLIST | production | Export.plist(production) |
FIREBASE_APP_ID | production | FADのアプリID(production) |
FIREBASE_JINS_PAD_KEY | firebase | FAD秘密鍵 |
MDM_API_KEY | mdm | ME MDM API Key |
余談ですが、Codemagicの1つの環境変数に設定可能な文字数制限は公式から発表されていません(たぶん、私が調べた限りは・・)。文字数制限がないからか、公式の手順ではプロビジョニングプロファイルなどはファイル全文を環境変数に設定しています。違和感のある方法ですが、今回は公式のやり方に習っています。
テンプレートファイルを用意してCI内でプレースホルダーを置換する、とかの方が一般的で綺麗だとは思います。気になる方は適宜修正してください。
Githubの設定
Github側でWebhookを設定する
yamlで設定したイベントトリガーを発火させるために、CodemagicのwebhookタブからwebhookURLを取得します。
対象のGithubリポジトリのSettingsから上記で取得したURLを設定します。
Github側の設定はこれだけです。
Firebase App Distributionの設定
Firebase App Distributionとは
こちらの記事を参考にしてください。
Codemagic環境変数設定
FADプロジェクトのアプリID、秘密鍵をCodemagicの環境変数に設定します。
今回はアプリIDはFIREBASE_APP_ID
として設定しています。
秘密鍵は、JSONファイルの中身を丸々コピーしてFIREBASE_JINS_PAD_KEY
として設定しています。
codemagic.yaml編集
上記で設定した環境変数(FIREBASE_APP_ID
、FIREBASE_JINS_PAD_KEY
)をcodemagic.yamlで記述します。今回groups
には、こちらで設定したグループ名を設定しています。
common_publish_settings: &common_publish_settings
firebase:
firebase_service_account: $FIREBASE_JINS_PAD_KEY
ios:
app_id: $FIREBASE_APP_ID
groups:
- "front_developer"
scripts:
- *report_build_status_script
ME MDMの設定
ME MDMとは
こちらの記事を参考にしてください。
ME MDM APIキー発行
ME MDMのメニュータブで「管理」を選択し「APIキーを生成」を選択
APIキーを作成していきます。
今回はCodemagicからME MDMにipaファイルを連携したいので、アプリケーションで「サードパーティアプリ」を設定します。サービス名・許可設定は、よしなに設定してください。
キーは安全な場所に保存しておいてください。
ME MDMへipaファイルをアップロード
次にME MDMへipaファイルをアップロードしていきます。
ME MDM APIでipaファイルアップロードする時は、まず/api/v1/mdm/files
へバイナリをアップロードしてfile_id
を取得し、そのfile_id
を JSONボディに渡して/api/v1/mdm/apps
を呼び出す2段階方式になっています。
また、この処理でエラーとなった際にビルド自体は成功扱いにしています。
ipaファイルさえ作成できていればMDM連携自体は手動で実施可能なので、curl失敗時はexit 0
としています。
mdm_upload_script: &mdm_upload_script
name: Upload IPA to MDM & refresh app
script: |
#!/bin/bash
set -eo pipefail
DECODE_MDM_API_KEY=$(echo "$MDM_APIKEY" | base64 --decode)
IPA_PATH=$(find build/ios/ipa -name "*.ipa" -print -quit)
if [[ -z "$IPA_PATH" ]]; then
echo "IPA file not found. skipping MDM upload"; exit 0
fi
if [[ -z "${DECODE_MDM_API_KEY:-}" ]]; then
echo "DECODE_MDM_API_KEY is not set. skipping MDM upload"; exit 0
fi
: "${MDM_APP_NAME:=My-App}"
: "${MDM_BUNDLE_ID:=$BUNDLE_ID}"
FILE_NAME=$(basename "$IPA_PATH")
echo "Uploading $FILE_NAME to MDM..."
# file upload
UPLOAD_JSON=$(curl -sSL --location \
"https://your-mdm-domain.com/api/v1/mdm/files" \
--header "Authorization: ${DECODE_MDM_API_KEY}" \
--header "Content-Disposition: filename=${FILE_NAME}" \
--header "Content-Type: application/x-itunes-ipa" \
--data-binary @"${IPA_PATH}" ) || {
echo "MDM upload failed (curl exit $?). build will continue"; exit 0; }
FILE_ID=$(echo "$UPLOAD_JSON" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("file_id",""))')
if [[ -z "$FILE_ID" ]]; then
echo "file_id could not be parsed. skipping app update"; exit 0
fi
echo "Received file_id=$FILE_ID"
# update app
curl -sSL -X PUT \
"https://your-mdm-domain.com/api/v1/mdm/apps/${MDM_APP_ID}/labels/${MDM_LABEL_ID}" \
--header "Authorization: ${DECODE_MDM_API_KEY}" \
--header "Content-Type: application/json" \
--data '{
"app_type": 2,
"app_file": '"$FILE_ID"',
"app_category_id": 14,
"platform_type": 1,
"supported_devices":3,
"bundle_identifier":"'"${MDM_BUNDLE_ID}"'"
}' || echo "MDM app update failed. build will continue"
exit 0
codemagic.yamlの作成
Codemagic、Githubの設定が完了したらcodemagic.yamlを作成します。yamlはプロジェクトのルートに配置してください。
最終的なcodemagic.yamlは下記のようになりました。
codemagic.yaml
definitions:
env_base: &env_base
flutter: 3.32.2
xcode: 15.4
cocoapods: default
build_scripts: &build_scripts
- name: Notify build start
script: |
#!/bin/sh
set -e
BUILD_URL="https://codemagic.io/app/${CM_PROJECT_ID}/build/${CM_BUILD_ID}"
MESSAGE="${CHAT_MESSAGE_PREFIX} \n Build Started. \nDetail: <${BUILD_URL}|Link>"
JSON_PAYLOAD=$(printf '{"text": "%s"}' "${MESSAGE}")
curl -X POST -H 'Content-Type: application/json' \
-d "${JSON_PAYLOAD}" \
"${GOOGLE_CHAT_WEBHOOK_URL}" || echo "Failed to send Google Chat notification, continuing build..."
- name: Initialize Keychain
script: keychain initialize
- name: Import Signing Certificate to Keychain
script: |
echo $CERTIFICATE | base64 --decode > /tmp/certificate.p12
DECODE_PASSWORD=$(echo "$CERTIFICATE_PASSWORD" | base64 --decode)
keychain add-certificates --certificate /tmp/certificate.p12 --certificate-password $DECODE_PASSWORD
- name: Decode ExportOptions.plist
script: |
set -e
echo "Decoding ExportOptions.plist from ENCODED_EXPORT_OPTIONS_PLIST..."
if [ -z "$ENCODED_EXPORT_OPTIONS_PLIST" ]; then
echo "Error: ENCODED_EXPORT_OPTIONS_PLIST is not set. Please set it in Codemagic UI."
exit 1
fi
echo "$ENCODED_EXPORT_OPTIONS_PLIST" | base64 --decode > "$FCI_BUILD_DIR/ios/ExportOptions.plist"
echo "Decoded ExportOptions.plist content:"
cat "$FCI_BUILD_DIR/ios/ExportOptions.plist"
- name: Update Bundle ID
script: |
set -e
PBXPROJ="ios/Runner.xcodeproj/project.pbxproj"
grep -n "PRODUCT_BUNDLE_IDENTIFIER" "$PBXPROJ" | head -1
sed -Ei '' \
"s/PRODUCT_BUNDLE_IDENTIFIER[[:space:]]*=[[:space:]]*\"\\$\\(iosBundleId\\)\";/PRODUCT_BUNDLE_IDENTIFIER = $BUNDLE_ID;/" \
"$PBXPROJ"
grep -n "PRODUCT_BUNDLE_IDENTIFIER" "$PBXPROJ" | head -1
- name: Install Provisioning Profile and Configure Xcode Signing
script: |
PROFILES_HOME="$HOME/Library/MobileDevice/Provisioning Profiles"
mkdir -p "$PROFILES_HOME"
PROFILE_PATH="$(mktemp "$PROFILES_HOME"/$(uuidgen).mobileprovision)"
echo ${PROVISIONING_PROFILE} | base64 --decode > "$PROFILE_PATH"
echo "Saved provisioning profile $PROFILE_PATH"
xcode-project use-profiles \
--project="ios/Runner.xcodeproj" \
--profile="$PROFILE_PATH" \
--export-options-plist="$FCI_BUILD_DIR/ios/ExportOptions.plist" \
--archive-method=enterprise
- name: Log Build Configuration Details
script: |
set -e
echo "installed signing assets"
xcodebuild -showBuildSettings \
-workspace ios/Runner.xcworkspace \
-scheme Runner | grep -E "CODE_SIGN_STYLE|PROVISIONING_PROFILE"
echo "Using Bundle ID: $BUNDLE_ID"
echo "Verifying DART_DEFINES_FILE: $DART_DEFINES_FILE"
echo "provisioningProfileKeyPath: $provisioningProfileKeyPath"
echo "provisioningProfilePath: $provisioningProfilePath"
- name: Flutter Build IPA
script: |
set -e
APP_VERSION=$(grep -m1 '^version:' pubspec.yaml \
| sed -E 's/^version:[[:space:]]*([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
echo "Building IPA ${APP_VERSION} (${BUILD_NUMBER})..."
flutter build ipa --release \
--dart-define-from-file=$DART_DEFINES_FILE \
--export-options-plist=$FCI_BUILD_DIR/ios/ExportOptions.plist \
--no-tree-shake-icons \
--build-name=$APP_VERSION \
--build-number=$BUILD_NUMBER
# IPA
DEFAULT_IPA_PATH=$(find build/ios/ipa/ -name "*.ipa" -print -quit)
if [ -z "$DEFAULT_IPA_PATH" ]; then
echo "Error: Default IPA file not found."
exit 1
fi
NEW_IPA_NAME="My-App-${APP_VERSION}-${BUILD_NUMBER}.ipa"
NEW_IPA_PATH="build/ios/ipa/${NEW_IPA_NAME}"
mv "$DEFAULT_IPA_PATH" "$NEW_IPA_PATH"
# Generate release notes
{
echo "Version ${APP_VERSION} (${BUILD_NUMBER})"
echo "Commit : $FCI_COMMIT"
echo
if [ -n "$last_tag" ]; then
echo "Changes since ${last_tag}:"
git log --pretty=format:"- %s" "${last_tag}"..HEAD
else
echo "Recent commits:"
git log -n 20 --pretty=format:"- %s"
fi
} > release_notes.txt
- name: Mark build success
script: |
echo "BUILD_RESULT=success" >> "$CM_ENV"
report_build_status_script: &report_build_status_script
name: Report build status
script: |
#!/bin/sh
set -e
BUILD_URL="https://codemagic.io/app/${CM_PROJECT_ID}/build/${CM_BUILD_ID}"
if [ "$BUILD_RESULT" = "success" ]; then
RESULT="Succeeded!!!"
else
RESULT="Failed..."
fi
MESSAGE="${CHAT_MESSAGE_PREFIX}\nBuild ${RESULT}\nDetail: <${BUILD_URL}|Link>"
JSON_PAYLOAD=$(printf '{"text":"%s"}' "${MESSAGE}")
curl -s -X POST -H 'Content-Type: application/json' \
-d "${JSON_PAYLOAD}" "${GOOGLE_CHAT_WEBHOOK_URL}" \
|| echo "Google Chat notification failed."
mdm_upload_script: &mdm_upload_script
name: Upload IPA to MDM & refresh app
script: |
#!/bin/bash
set -eo pipefail
DECODE_MDM_API_KEY=$(echo "$MDM_APIKEY" | base64 --decode)
IPA_PATH=$(find build/ios/ipa -name "*.ipa" -print -quit)
if [[ -z "$IPA_PATH" ]]; then
echo "IPA file not found. skipping MDM upload"; exit 0
fi
if [[ -z "${DECODE_MDM_API_KEY:-}" ]]; then
echo "DECODE_MDM_API_KEY is not set. skipping MDM upload"; exit 0
fi
: "${MDM_APP_NAME:=My-App}"
: "${MDM_BUNDLE_ID:=$BUNDLE_ID}"
FILE_NAME=$(basename "$IPA_PATH")
echo "Uploading $FILE_NAME to MDM..."
# file upload
UPLOAD_JSON=$(curl -sSL --location \
"https://your-mdm-domain.com/api/v1/mdm/files" \
--header "Authorization: ${DECODE_MDM_API_KEY}" \
--header "Content-Disposition: filename=${FILE_NAME}" \
--header "Content-Type: application/x-itunes-ipa" \
--data-binary @"${IPA_PATH}" ) || {
echo "MDM upload failed (curl exit $?). build will continue"; exit 0; }
FILE_ID=$(echo "$UPLOAD_JSON" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("file_id",""))')
if [[ -z "$FILE_ID" ]]; then
echo "file_id could not be parsed. skipping app update"; exit 0
fi
echo "Received file_id=$FILE_ID"
# update app
curl -sSL -X PUT \
"https://your-mdm-domain.com/api/v1/mdm/apps/${MDM_APP_ID}/labels/${MDM_LABEL_ID}" \
--header "Authorization: ${DECODE_MDM_API_KEY}" \
--header "Content-Type: application/json" \
--data '{
"app_type": 2,
"app_file": '"$FILE_ID"',
"app_category_id": 14,
"platform_type": 1,
"supported_devices":3,
"bundle_identifier":"'"${MDM_BUNDLE_ID}"'"
}' || echo "MDM app update failed. build will continue"
exit 0
common_publish_settings: &common_publish_settings
firebase:
firebase_service_account: $FIREBASE_JINS_PAD_KEY
ios:
app_id: $FIREBASE_APP_ID
groups:
- "front_developer"
scripts:
- *report_build_status_script
workflows:
staging-workflow:
name: Staging Build
max_build_duration: 90
environment:
<<: *env_base
groups:
- common
- staging
- firebase
vars:
CHAT_MESSAGE_PREFIX: "[STG] My-App"
DART_DEFINES_FILE: dart_defines/stg.json
BUNDLE_ID: com.stg.bundle
triggering:
events:
- push
branch_patterns:
- pattern: staging
include: true
scripts: *build_scripts
artifacts:
- build/ios/ipa/*.ipa
- $HOME/Library/Developer/Xcode/DerivedData/**/Build/**/*.dSYM
- "$FCI_BUILD_DIR/ios/ExportOptions.plist"
publishing:
<<: *common_publish_settings
production-workflow:
name: Production Build
max_build_duration: 90
environment:
<<: *env_base
groups:
- common
- production
- firebase
- mdm
vars:
CHAT_MESSAGE_PREFIX: "[PROD] My-App"
DART_DEFINES_FILE: dart_defines/prod.json
BUNDLE_ID: com.prod.bundle
triggering:
events:
- push
branch_patterns:
- pattern: main
include: true
scripts: *build_scripts
artifacts:
- build/ios/ipa/*.ipa
- $HOME/Library/Developer/Xcode/DerivedData/**/Build/**/*.dSYM
- "$FCI_BUILD_DIR/ios/ExportOptions.plist"
publishing:
<<: *common_publish_settings
scripts:
- *report_build_status_script
- *mdm_upload_script
基本は公式ドキュメントを読めばわかると思いますので、要所だけ補足していきます。
workflowとstep
workflowは実行環境が別環境に異なり、stepは同一環境ではあるが実行プロセスが異なります。
このあたりはGithubActionsと同じような仕様です。
step内で値を共有する場合は、下記のようなイメージで$CM_ENV
を利用します。
workflows:
workflow-sample-1:
name: sample
scripts:
- name: ステップ1:変数を設定する
script: |
GENERATED_TAG="my-app-v1.0-$(date +%s)"
# 生成したタグを$CM_ENVファイルに書き込む
echo "BUILD_TAG=$GENERATED_TAG" >> $CM_ENV
- name: ステップ2:変数を参照する
script: |
# 前のステップで設定した変数を$BUILD_TAGとして利用できる
echo "The build tag from the previous step is: $BUILD_TAG"
groups/vars
環境変数登録時に設定したgroup
を指定します。
本記事では環境ごとに異なる環境変数が存在するので、環境ごとにgroup
を設定しています。
Git管理に含めてOKで、頻繁に変更されないような情報はvars
で設定しました。
groups:
- common
- production
- firebase
- mdm
vars:
CHAT_MESSAGE_PREFIX: "[PROD] JINS-Pad"
DART_DEFINES_FILE: dart_defines/prod.json
BUNDLE_ID: com.prod.bundle
triggering
Githubのターゲットブランチ、イベントを設定します。この設定をトリガーにCodemagicのworkflow内のビルドが発火します。
triggering:
events:
- push
- pull_request
- pull_request_labeled #GitHub only
- tag
branch_patterns: # Include or exclude watched branches
- pattern: '*'
include: true
source: true # Applicable only to Pull Request triggers to determine if pattern is for source or target branch
- pattern: excluded-target
include: false
source: false
- pattern: included-source
include: true
source: true
tag_patterns: # Include or exclude watched tag labels
- pattern: '*'
include: true
- pattern: excluded-tag
include: false
- pattern: included-tag
include: true
cancel_previous_builds: false # Set to `true` to automatically cancel outdated webhook builds
署名まわり
iOSアプリをCIで配布する際、「証明書・プロビジョニングプロファイルをMacのキーチェーンに入れて署名する」という作業がボトルネックになりがちです。
Codemagicでは、独自に用意しているラッパーコマンドで、署名まわりのスクリプトを簡潔に書くことができます。
本記事でもCodemagicが用意している独自CLIkeychain
コマンドを使用して実装しています。
keychain initialize
echo $CERTIFICATE | base64 --decode > /tmp/certificate.p12
keychain add-certificates --certificate /tmp/certificate.p12 --certificate-password $DECODE_PASSWORD
一時キーチェーン作成、設定、アクセス権付与をkeychain initialize
で一括でやってくれています。
ちなみに上記をナマで書くと大体下記のようになるみたいです。(Geminiに作成してもらいました)
# 一時キーチェーンの作成
security create-keychain -p "" build.keychain
security set-keychain-settings -lut 21600 build.keychain
# キーチェーンのロック解除
security unlock-keychain -p "" build.keychain
# 作成したキーチェーンをデフォルトとして使う
security list-keychains -d user -s build.keychain login.keychain
security default-keychain -s build.keychain
# 証明書インポート
security import /tmp/certificate.p12 -k build.keychain -P "$CERT_PW" \
-A -T /usr/bin/codesign -T /usr/bin/xcodebuild -T /usr/bin/security
# コードサインに必要なアクセス権を付与
security set-key-partition-list -S apple-tool:,apple:,codesign: -s \
-k "" build.keychain
# 後片付け
# security delete-keychain build.keychain
署名周りを簡潔に書けるのはCodemagicの大きなメリットですね。
メリットではあるのですが、CIツールをCodemagicから他のツールに移行するときは、独自CLI部分を実装しなおす必要があります。なるべく可搬性は高くしたいところだと思うので、使用可否は検討が必要です。
publishing
アーティファクトのアップロード先だったり、通知先を設定できます。
JINSではコミュニケーションツールとしてGoogleChatを使用しているので、ビルドの開始・終了メッセージをGoogleChatに送信するように実装しています。
SlackはCodemagicと統合されているのですが、GoogleChatは未統合なのでcurlで実装しています。
curl -s -X POST -H 'Content-Type: application/json' \
-d "${JSON_PAYLOAD}" "${GOOGLE_CHAT_WEBHOOK_URL}" \
|| echo "Google Chat notification failed."
FAD・ME MDMについては、前述のとおりです。
codemagic.yamlの制約
Codemagicを使用するデメリットと言えるような制約が2つあります。
1. codemagic.yamlは、ルートディレクトリ直下に配置する必要がある
codemagic.yaml
は必ずルートディレクトリに配置する必要があります。サブディレクトリなどをつくって管理はできません。
2. 設定ファイル名はcodemagic.yamlと完全一致である必要がある
Codemagicで使用する設定ファイルはcodemagic.yaml
と完全一致でなければなりません。
CICD用の設定ファイルはcodemagic-staging.yaml
のようなイメージで、環境ごとにファイルを分けて責務を分離することが多いと思いますが、Codemagicではそのような実装はできません。
仕方がないのでStaging環境とProduction環境ごとにそれぞれ別のWorkflowとして定義し、YAMLのアンカー(&)とエイリアス(*)という機能を活用して、複数のワークフローで共有する共通処理と、各環境に固有の個別処理を分離しました。
definitions:
# & (アンカー)でデータブロックに"env_base"という名前を付けて保存
env_base: &env_base
flutter: 3.32.2
xcode: 15.4
cocoapods: default
...省略...
workflows:
staging-workflow:
name: Staging Build
max_build_duration: 90
environment:
# * (エイリアス)でアンカーを展開
<<: *env_base
実際にCICDを起動してみる
諸々の設定が完了したので、実際にCICDを起動してみました。
今回はGitブランチのpush
イベントをトリガーとしているので、main
ブランチに変更を加えてみます。
変更をmain
ブランチにマージ後、Codemagicの「Builds」ページへ遷移するとビルドが開始しました。
数分後、無事ビルドが完了しました。
GoogleChatへの開始・終了通知も飛んでいます。
FADにipaファイルがアップロードされていました。
MDMにもipaファイルがアップロードされています。「すべてを更新」をクリックし、本番アプリを配信します。(本番アプリなので、あえて手動で配布するようにしています)
無事、CICDを構築しアプリを配布することができました。
Codemagicのまとめ
最後に私が感じたCodemagicのメリット・デメリットについてまとめます。
メリット
- Flutterに最適化されている
- 標準スペックでM2サポート
- GithubActionsと比べるとコスパがいい
- 独自CLI(keychain/xcode-project)が便利(ただし独自CLIは、別のCIツール移行時には再実装が必要)
デメリット
- codemagic.yamlはリポジトリ直下にのみ配置可能
- codemagic.yamlは複数YAML不可(codemagic.yamlとファイル名が完全一致である必要あり)
おわりに
CodemagicでCICD構築手順を紹介してみました。
私個人としてはCodeBuildでバックエンドのCICD環境を構築したことはあるのですがアプリ開発でのCICD導入は初めてで、macOS&Enterpriseアプリということもあり思いのほか大変でした。次はCodemagicでE2Eテストの導入をしてみたいと思っています。
ちなみにJINS全体を通しても、モバイルアプリ開発領域でCICDを構築したのは今回が初めてっぽいので、これを皮切りに今後導入を進めていきたいと思っています。
Enterpriseアプリは通常のアプリよりもつまづきポイントが多いと思うので、このブログがどなたかの一助になれば幸いです。
Discussion