.NET MAUI 10 GitHub Actions CI/CD パイプライン構築ガイド:iOS署名・Android署名・TestFlight・Play Store自動配信(2026年版)

.NET MAUI 10 の GitHub Actions パイプライン構築ガイド。macos-14 ランナーでの iOS ビルド、Fastlane match による証明書管理、Android Keystore の Base64 化、TestFlight と Google Play への自動配信を実運用の失敗例とともに解説します。

.NET MAUI 10 CI/CD 構築ガイド 2026
On this page
  1. なぜ .NET MAUI の CI/CD は難しいのか
  2. GitHub Actions ランナーの選定:macos-14 と windows-latest
  3. .NET 10 SDK と MAUI Workload のインストール手順
  4. Android 署名を GitHub Actions で自動化する
  5. iOS 署名を Fastlane match で自動化する
  6. TestFlight への自動配信を fastlane pilot で組む
  7. Google Play 内部トラックへ fastlane supply で配信する
  8. パイプラインを高速化するキャッシュ設計
  9. よくあるビルド失敗と対処法
  10. 完成版:release.yml の全体構成

更新日: 2026年9月7日

.NET MAUI 10 の GitHub Actions パイプラインは、macos-14 ランナー上で dotnet workload install maui を実行し、Android は Base64 化した Keystore、iOS は Fastlane match で管理した証明書を GitHub Secrets から復元して署名、成果物を fastlane pilot(TestFlight)と fastlane supply(Google Play)で内部トラックへ配信する構成が最短ルートです。 正直に言うと、私自身が過去のプロジェクトでリリース直前に証明書切れを踏み抜いて丸一日溶かした経験があります。このガイドでは、その反省を踏まえて私が実運用で組んできた MAUI 向け CI/CD の失敗パターンと、それを避けるための .github/workflows/release.yml をトップダウンで組み立てていきます。多くのチームは署名の自動化を後回しにして、リリース直前に手作業のビルドで事故を起こしがち。最初にパイプラインを固めておけば、あとは PR ごとに配信できる状態を維持できます。

なぜ .NET MAUI の CI/CD は難しいのか

.NET MAUI のパイプラインが難しいと感じるのは、ひとつのソリューションが 4 つのターゲット(Android・iOS・Mac Catalyst・Windows)と 2 種類のホスト OS(macOS・Windows)、そして 2 系統の署名(Google の Keystore、Apple の証明書+プロビジョニングプロファイル)をまたぐからです。単純なライブラリの CI と違って、リリースジョブでは OS を切り替えつつ、それぞれのプラットフォームの署名資材を安全に持ち込む必要があります。

私が新しいチームに入って最初に確認するのは、次のチェックリストです。ひとつでも「手動」があれば、そこがリリース事故の火種になります。

  • iOS の証明書とプロビジョニングプロファイルは、誰の Mac に入っているか?
  • Android の Keystore は誰が管理していて、パスワードはどこに書かれているか?
  • アプリのバージョン(ApplicationDisplayVersion)と ApplicationVersion(ビルド番号)は、手で書き換えているか?
  • TestFlight や Google Play へのアップロードは、誰か特定の人の PC からしか叩けなくなっていないか?

これらを GitHub Actions に閉じ込めるのがゴールです。ビルド番号は GITHUB_RUN_NUMBER、署名資材は Secrets、配信は Fastlane に寄せることで、「特定の人が休むとリリースできない」状態を解消できます。.NET MAUI アプリストア公開の完全手順で扱った手動申請のフローも、この記事のパイプラインが完成すれば、ほとんどのステップが自動化されます。

GitHub Actions ランナーの選定:macos-14 と windows-latest

MAUI の CI で最初にハマるのがランナー選定です。iOS/Mac Catalyst のビルドには macOS が必須で、かつ Xcode の同梱バージョンがランナー画像で決まっているので、選定を誤ると xcodebuild: error: SDK "iphoneos" cannot be located のような分かりにくいエラーが出ます。2026年9月時点での推奨は次のとおりです。

用途推奨ランナー同梱 Xcode / SDK備考
iOS / Mac Catalyst のリリースビルドmacos-14(Apple Silicon)Xcode 16.x / iOS 18 SDKApp Store 提出は Xcode 16 以降が必須
iOS の PR 検証(署名なしビルド)macos-14Xcode 16.x署名なしなら -p:CodesignKey="" を渡す
Android のリリースビルドubuntu-latest でも可、統一する場合は macos-14—Ubuntu の方が起動が速く、分単価も安い
Windows / WinUI 3 のビルドwindows-latestWindows SDK 10.0.22621MSIX 署名は別途 signtool が必要

私の運用ルールは「iOS と Android を 別ジョブにする、ただし依存関係の解決だけは 共通ジョブで行う」です。ジョブを分けることで、Android だけ失敗しても iOS の TestFlight 配信が生き残ります。GitHub-hosted runners のドキュメントで、各画像に何が入っているかは必ずリリース前に確認してください。画像更新で Xcode が上がると、突然ビルドが通らなくなることがあります。

.NET 10 SDK と MAUI Workload のインストール手順

GitHub Actions のホスト画像には .NET SDK は入っていますが、MAUI ワークロードは入っていません。毎回のジョブで dotnet workload install maui を実行する必要があり、これに 90〜120 秒かかります。ここを省略しようとしてキャッシュに逃げると、WorkloadManifest の不整合で「ローカルでは通るのに CI で通らない」典型的な事故が起きます。

- name: Setup .NET 10 SDK
  uses: actions/setup-dotnet@v4
  with:
    dotnet-version: '10.0.x'

- name: Install .NET MAUI workload
  run: |
    dotnet workload install maui --ignore-failed-sources
    dotnet workload list

- name: Restore NuGet packages
  run: dotnet restore MyApp.sln --locked-mode

ポイントは 3 つです。ひとつめは --ignore-failed-sources。NuGet のミラーが一時的にダウンしても失敗しません。ふたつめは dotnet workload list をログに残すこと。バージョンドリフトが起きたときに、どのマニフェストが入ったかを後から追跡できます。みっつめは --locked-mode。packages.lock.json を Git 管理下に置き、CI ではロックファイル通りにのみ復元することで、依存性の予期しないアップグレードを防げます。Microsoft Learn の MAUI インストールガイドにワークロード ID の一覧が載っています。iOS だけを別ジョブで組む場合は maui-ios、Android なら maui-android のように必要なワークロードだけを入れれば、インストール時間を 30 秒ほど削減できます。

Android 署名を GitHub Actions で自動化する

Android 署名は「秘密鍵と証明書が入った Keystore ファイル(.jks または .keystore)」をビルド時に渡すだけです。難しいのは、バイナリファイルを Secrets に安全に持ち込む方法と、MAUI プロジェクトの .csproj にどのプロパティを渡すかの 2 点です。

手順 1:Keystore を Base64 化して Secrets に登録

# ローカルの Mac / Linux で 1 回だけ実行
base64 -i myapp-release.keystore | pbcopy   # macOS
# 出力を GitHub → Settings → Secrets → Actions に貼り付ける
# キー名の例: ANDROID_KEYSTORE_BASE64

# あわせて次の 3 つも Secrets に登録する
#   ANDROID_KEYSTORE_PASSWORD  … keytool の -storepass
#   ANDROID_KEY_ALIAS          … keytool の -alias
#   ANDROID_KEY_PASSWORD       … keytool の -keypass

手順 2:ジョブ内で Keystore を復元してビルド

- name: Decode Android keystore
  env:
    KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
  run: |
    echo "$KEYSTORE_B64" | base64 --decode > $RUNNER_TEMP/release.keystore
    echo "KEYSTORE_PATH=$RUNNER_TEMP/release.keystore" >> $GITHUB_ENV

- name: Publish Android AAB
  env:
    KEYSTORE_PWD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
    KEY_ALIAS:    ${{ secrets.ANDROID_KEY_ALIAS }}
    KEY_PWD:      ${{ secrets.ANDROID_KEY_PASSWORD }}
  run: |
    dotnet publish src/MyApp/MyApp.csproj \
      -c Release \
      -f net10.0-android \
      -p:AndroidPackageFormat=aab \
      -p:AndroidKeyStore=true \
      -p:AndroidSigningKeyStore=$KEYSTORE_PATH \
      -p:AndroidSigningStorePass="$KEYSTORE_PWD" \
      -p:AndroidSigningKeyAlias="$KEY_ALIAS" \
      -p:AndroidSigningKeyPass="$KEY_PWD" \
      -p:ApplicationVersion=$GITHUB_RUN_NUMBER \
      -o $RUNNER_TEMP/android-out

ビルド番号は $GITHUB_RUN_NUMBER を ApplicationVersion(Android の versionCode)に渡すのが実務上の定番です。人間が触らない単調増加の整数なので、Play Console 側で「同じ versionCode が既にある」と拒否されるトラブルを避けられます。.NET MAUI クリーンアーキテクチャの記事で紹介したソリューション構造なら、そのままこのパイプラインに載ります。

iOS 署名を Fastlane match で自動化する

iOS 署名の自動化は、素朴に「証明書ファイルと .mobileprovision を Secrets に入れる」やり方でも動きますが、証明書の更新のたびに 3 つのファイルを差し替える運用が発生します。実務では Fastlane match を使い、証明書とプロビジョニングプロファイルをプライベート Git リポジトリで暗号化管理するのが標準です。ローカル開発者と CI で「同じ資材を match で引っ張ってくる」ため、証明書切れの追跡が劇的に楽になります。

match の初期セットアップ

# ローカルで 1 回だけ実行(macOS)
gem install fastlane
cd platforms/ios   # Fastfile を置くディレクトリ
fastlane match init          # 対話式で Git リポジトリ URL を設定
fastlane match appstore      # App Store 用証明書と provisioning を生成 → 暗号化 push

CI ジョブでの復元とビルド

- name: Restore iOS signing assets via match
  env:
    MATCH_PASSWORD:      ${{ secrets.MATCH_PASSWORD }}
    MATCH_GIT_URL:       ${{ secrets.MATCH_GIT_URL }}
    MATCH_GIT_BASIC_AUTH: ${{ secrets.MATCH_GIT_BASIC_AUTH }}
  run: |
    cd platforms/ios
    bundle exec fastlane match appstore --readonly

- name: Publish iOS IPA
  env:
    APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
  run: |
    dotnet publish src/MyApp/MyApp.csproj \
      -c Release \
      -f net10.0-ios \
      -p:RuntimeIdentifier=ios-arm64 \
      -p:ArchiveOnBuild=true \
      -p:CodesignKey="Apple Distribution: My Company ($APPLE_TEAM_ID)" \
      -p:CodesignProvision="match AppStore com.mycompany.myapp" \
      -p:ApplicationVersion=$GITHUB_RUN_NUMBER \
      -o $RUNNER_TEMP/ios-out

ここで大事なのは CodesignProvision の値です。match が生成するプロビジョニングプロファイルの名前は match AppStore <bundle-id> という固定フォーマットなので、この文字列を .csproj か CI コマンドラインに渡せば追加設定は不要です。--readonly を付けると、CI が誤って新しい証明書を生成して既存の資材を上書きする事故を防げます。

TestFlight への自動配信を fastlane pilot で組む

ビルド済みの IPA を TestFlight に上げるだけなら xcrun altool でも可能ですが、fastlane の pilot アクションを使うと、変更ログ・テスターグループ・レビュー提出まで宣言的に書けます。App Store Connect API キー(.p8)を Secrets に持たせるのがモダンな方法です。

- name: Upload IPA to TestFlight
  env:
    APP_STORE_CONNECT_API_KEY_ID:       ${{ secrets.ASC_KEY_ID }}
    APP_STORE_CONNECT_API_ISSUER_ID:    ${{ secrets.ASC_ISSUER_ID }}
    APP_STORE_CONNECT_API_KEY_CONTENT:  ${{ secrets.ASC_KEY_P8 }}
  run: |
    bundle exec fastlane pilot upload \
      --api_key_path <(cat << JSON
      {
        "key_id":     "$APP_STORE_CONNECT_API_KEY_ID",
        "issuer_id":  "$APP_STORE_CONNECT_API_ISSUER_ID",
        "key":        "$APP_STORE_CONNECT_API_KEY_CONTENT",
        "in_house":   false
      }
      JSON
      ) \
      --ipa "$RUNNER_TEMP/ios-out/MyApp.ipa" \
      --skip_waiting_for_build_processing true \
      --changelog "Build $GITHUB_RUN_NUMBER from $GITHUB_SHA"

--skip_waiting_for_build_processing true は絶対に付けてください。付けないと Apple 側の処理待ちで 10〜30 分ジョブが待機します。GitHub Actions の分課金では致命的です。TestFlight の内部テスターにビルドを自動配布したい場合は、--distribute_external false と --groups "internal" を追加すると、アップロード直後にグループへ紐付けられます。

Apple の App Store Connect API ドキュメントにはキー生成手順と権限モデルが詳しく書かれています。「App Manager」ロールでキーを発行すれば、TestFlight 配信とメタデータ更新の両方が可能です。

Google Play 内部トラックへ fastlane supply で配信する

Google Play への配信は、サービスアカウント JSONを Google Cloud Console で発行し、Play Console 側で「アプリのアクセス権」を付与する 2 段構えです。この JSON を Secrets に入れて fastlane supply で使います。

- name: Restore Play service account JSON
  env:
    PLAY_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
  run: |
    echo "$PLAY_JSON" > $RUNNER_TEMP/play-service-account.json
    echo "PLAY_JSON_PATH=$RUNNER_TEMP/play-service-account.json" >> $GITHUB_ENV

- name: Upload AAB to Google Play internal track
  run: |
    bundle exec fastlane supply \
      --package_name "com.mycompany.myapp" \
      --aab "$RUNNER_TEMP/android-out/com.mycompany.myapp-Signed.aab" \
      --track internal \
      --release_status draft \
      --json_key "$PLAY_JSON_PATH" \
      --skip_upload_metadata true \
      --skip_upload_images true \
      --skip_upload_screenshots true

--release_status draft にしておくと、Play Console 側で最終確認を挟めます。完全自動化したい場合は completed にしてください。--skip_upload_metadata 系のフラグを付けているのは、ストアリスティング(説明文・スクリーンショット)はレビュープロセスに乗せて別のワークフローで扱うためです。CI で無条件にメタデータを上書きすると、翻訳チームの手作業を巻き戻してしまいます。

Google Play Publishing API の詳細は Google Play Developer API のリファレンスを確認してください。サービスアカウントには「リリースの管理」「アプリ情報の編集」の権限だけを付け、財務系の権限は絶対に渡さないでください。CI キーが漏洩したときの被害範囲を小さく保つのが原則です。

パイプラインを高速化するキャッシュ設計

正直、ここが一番差が出る場所です。MAUI のフルビルドは、キャッシュなしだと iOS で 12〜18 分、Android で 8〜12 分かかります。actions/cache をきちんと当てると、それぞれ 3〜5 分は削れます。何をキャッシュするか、キーの粒度をどうするかで効きが大きく変わるので、実測値を出しながらチューニングしてください(私は最初、雑に actions/cache を貼っただけで満足していて、後から計測して衝撃を受けました)。

- name: Cache NuGet packages
  uses: actions/cache@v4
  with:
    path: |
      ~/.nuget/packages
      ~/.dotnet/toolResolverCache
    key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }}
    restore-keys: |
      nuget-${{ runner.os }}-

- name: Cache Gradle
  if: runner.os != 'Windows'
  uses: actions/cache@v4
  with:
    path: |
      ~/.gradle/caches
      ~/.gradle/wrapper
    key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*') }}
    restore-keys: |
      gradle-${{ runner.os }}-

MAUI ワークロード(~/.dotnet/sdk-manifests や ~/.dotnet/packs)自体もキャッシュ可能ですが、ここはキャッシュしない方が安全です。マニフェストの微妙な不整合で「AOT コンパイラだけ古い」といった事故が起きます。90 秒余分にかかっても、毎回 dotnet workload install を走らせる方が結果的に運用コストは下がります。Native AOT やパフォーマンス最適化を有効にする場合は、キャッシュミス時のフルビルドが長くなるので、リリース専用ブランチだけで有効化するなど工夫すると良いでしょう。

よくあるビルド失敗と対処法

この 2 年ほどでチームから拾ったビルド失敗の内訳を、原因と対処法にまとめました。ほとんどが署名関連で、テストコードや MAUI SDK 自体のバグはほぼありません。

症状原因対処
No signing certificate "iOS Distribution" foundmatch の --readonly が効いていない/キーチェーン未解錠fastlane match appstore --readonly を先に実行し、security unlock-keychain で CI キーチェーンを開ける
Version code 12 has already been usedApplicationVersion がハードコード-p:ApplicationVersion=$GITHUB_RUN_NUMBER を必ず付ける
xcodebuild: error: SDK "iphoneos" cannot be locatedランナー画像更新で Xcode パスが変わったsudo xcode-select -s /Applications/Xcode_16.app を明示的に指定する
keystore password was incorrectSecrets のパスワードに特殊文字が含まれているパスワードを英数字+ハイフンだけに揃える/シェル展開しないように単一引用符で渡す
Play Console で「デバッグ可能な APK」と拒否Debug 構成で publish した-c Release を必須にし、PR 用ワークフローと分ける
fastlane pilot が 2FA を要求してくるApple ID +パスワード方式を使っているApp Store Connect API キー(.p8)方式に切り替える

テストを自動化していれば、これらの多くは PR の段階で検知できます。.NET MAUI テスト戦略のガイドで紹介した xUnit と Appium の組み合わせを、この記事のパイプラインに追加すれば、リリースまでの安全網はほぼ完成します。

完成版:release.yml の全体構成

ここまでの内容をまとめた、実運用に耐える .github/workflows/release.yml のスケルトンを載せます。ジョブを build-android、build-ios、deploy に分け、Android と iOS を並列で走らせ、両方成功したときだけデプロイに進む構成です。

name: Release

on:
  push:
    tags: [ 'v*.*.*' ]
  workflow_dispatch:

jobs:
  build-android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: '10.0.x' }
      - run: dotnet workload install maui-android --ignore-failed-sources
      # ... (Android 署名 → publish → artifact upload)

  build-ios:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: '10.0.x' }
      - uses: ruby/setup-ruby@v1
        with: { ruby-version: '3.3', bundler-cache: true }
      - run: dotnet workload install maui-ios --ignore-failed-sources
      # ... (match → publish → artifact upload)

  deploy:
    needs: [ build-android, build-ios ]
    runs-on: macos-14
    steps:
      - uses: actions/download-artifact@v4
      # ... (fastlane pilot / fastlane supply)

タグ v1.2.3 を打ったときだけリリースが走るように on.push.tags を絞っています。日常の PR 検証には別ファイル(ci.yml)で署名なしビルドとテストだけを回す 2 本立てにするのが、私が現場で採用している定番構成です。

よくある質問

GitHub Actions の macOS ランナーは MAUI で無料枠でも使えますか?

パブリックリポジトリでは無料ですが、プライベートリポジトリでは macOS ランナーは Linux の 10 倍の分課金です(2026年9月時点)。iOS ビルドは 15〜25 分かかることが多いため、月間ビルド回数が 100 回を超えるチームはセルフホストの Mac mini を検討する価値があります。Android と依存性復元は ubuntu-latest に寄せて、macOS ランナーは iOS ジョブと deploy ジョブだけに絞るとコストを 60% 前後削減できます。

Fastlane match の代わりに手動で証明書を Secrets に置く方法でも動きますか?

動きますが、証明書の 1 年更新のたびに .p12 と .mobileprovision を Base64 化して差し替える作業が発生します。3〜5 人以上のチームなら match の初期学習コストの方が確実に安くつきます。1 人開発でも、証明書切れをリリース直前に気付く事故を防げるので、私は必ず match を勧めています。

App Center が終了した後の代替手段は何ですか?

App Center は 2025年3月に廃止されました。ビルドとリリース配信は GitHub Actions+Fastlane、クラッシュレポートは Sentry か Firebase Crashlytics、分析は Firebase Analytics か App Store Connect / Google Play Console の内蔵ダッシュボードに移行するのが 2026年時点での標準構成です。CI/CD だけを見れば、本記事の構成が App Center の完全な代替になります。

TestFlight のビルド処理待ちを CI で待たない方法はありますか?

fastlane pilot upload --skip_waiting_for_build_processing true を指定すれば、アップロード完了時点でジョブを終了できます。処理完了は Apple から自動でメール通知が来るので、CI で待つ必要はありません。処理完了後に自動でテスターへ配布したい場合は、別途 App Store Connect Webhooks を使って通知を受けるか、日次のスケジュールジョブで fastlane pilot distribute を回す構成が現実的です。

セルフホストランナーで iOS ビルドを回す場合の注意点は?

Mac mini(M2 以降推奨)に Xcode を手動インストールし、ランナーサービスを常駐させます。注意点は 3 つ。macOS のセキュリティアップデートで Xcode が動かなくなることがあるので更新前に検証環境で試すこと、キーチェーンは CI 専用のものを作成しログインキーチェーンを汚さないこと、そしてビルドディレクトリを定期的に掃除しないと数週間でディスクが枯渇することです。物理マシンなので、故障時のフェイルオーバー戦略も設計に入れておいてください。