JINSテックブログ
🪬

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されています。

https://www.itmedia.co.jp/news/spv/1902/01/news068.html

AppleからすればApple Storeの審査を通したいところだと思いますので、ポリシーが厳しいのは当然といえば当然かもしれませんが、GoogleがBANされたと聞くとビビってしまいます。いきなりBANされないようにくれぐれもポリシーは厳守していきましょう。

また、現在ADEPは新規で契約することが難しいようです。
https://www.micss.biz/2020/06/19/1774/

このような現状なので、一般的な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の環境変数として設定していきます。

https://docs.codemagic.io/yaml-code-signing/alternative-code-signing-methods/

証明書関連以外にも、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_IDFIREBASE_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アプリは通常のアプリよりもつまづきポイントが多いと思うので、このブログがどなたかの一助になれば幸いです。

JINSテックブログ
JINSテックブログ

Discussion