更新日: 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 10 iOS ビルドは macos-14 ランナー(Apple Silicon / Xcode 16.x 同梱)を利用し、dotnet workload install maui を毎回のジョブで実行する必要があります。
Android 署名は Keystore を Base64 エンコードして GitHub Secrets に格納し、ジョブ内で復元 → dotnet publish -f net10.0-android の AndroidSigningKeyStore プロパティに渡すのが最も再現性が高い方法です。
iOS 署名は Fastlane match(プライベート Git リポジトリで証明書とプロビジョニングプロファイルを共有する仕組み)を使うと、ローカル開発と CI で完全に同じ資材を使えます。
TestFlight 配信には fastlane pilot、Google Play 内部テスト配信には fastlane supply --track internal を使い、それぞれ App Store Connect API キーと Play Developer サービスアカウント JSON を Secrets で渡します。
ビルドキャッシュは ~/.nuget/packages と ~/.dotnet/toolResolverCache、Android は ~/.gradle/caches をキャッシュすると 1 ジョブあたり 3〜6 分短縮できます。
失敗の 8 割は「証明書切れ」「プロビジョニングプロファイルのデバイス未登録」「Keystore パスワードのエスケープ漏れ」に集約されます。事前にチェックリスト化しておくと再発を防げます。
目次
なぜ .NET MAUI の CI/CD は難しいのか
GitHub Actions ランナーの選定:macos-14 と windows-latest
.NET 10 SDK と MAUI Workload のインストール手順
Android 署名を GitHub Actions で自動化する
iOS 署名を Fastlane match で自動化する
TestFlight への自動配信を fastlane pilot で組む
Google Play 内部トラックへ fastlane supply で配信する
パイプラインを高速化するキャッシュ設計
よくあるビルド失敗と対処法
完成版:release.yml の全体構成
なぜ .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 アプリストア公開の完全手順 で扱った手動申請のフローも、この記事のパイプラインが完成すれば、ほとんどのステップが自動化されます。
Note: Windows 向け MSIX 署名やパッケージング証明書は本記事のスコープ外です。Windows ターゲットも配信する場合は、別ジョブを windows-latest で組んで MSBuild -t:Publish と signtool を組み合わせるのが定番です。
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 SDK App 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.22621 MSIX 署名は別途 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
Warning: パスワードに $ や &、バッククォートが含まれていると、シェル展開で壊れて「keystore password was incorrect」となります。パスワードは必ず英数字+ハイフンだけに揃えるか、Secrets 側で二重にエスケープしてから保存してください。
ビルド番号は $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 が誤って新しい証明書を生成して既存の資材を上書きする事故を防げます。
Tip: App Store Connect API キー(.p8)を Secrets に登録しておくと、2 要素認証を経由せずに Apple ID の代わりに使えます。CI では必ず API キー方式にしてください。Apple ID + パスワードだと 6 か月に 1 回セッションが切れて必ずデプロイが止まります。
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 専用のものを作成しログインキーチェーンを汚さないこと、そしてビルドディレクトリを定期的に掃除しないと数週間でディスクが枯渇することです。物理マシンなので、故障時のフェイルオーバー戦略も設計に入れておいてください。