Скажу честно: в 2026 году ручная сборка и публикация мобильных приложений выглядит как ритуал из прошлого десятилетия. Команды, которые до сих пор вручную запускают dotnet publish, переподписывают AAB на ноутбуке тимлида и заливают билды через Transporter, теряют дни на каждом релизе — и регулярно ловят ошибки подписи в самый неудобный момент (обычно в пятницу вечером, конечно).
Эта статья — пошаговое руководство по настройке полноценного CI/CD для .NET MAUI 10 на GitHub Actions: от структуры пайплайна до автопубликации в App Store и Google Play. Мы разберём подпись iOS и Android в безопасном окружении, fastlane и App Store Connect API, версионирование, кеширование и те самые типичные ошибки, которые ломают пайплайн в продакшене.
Поехали.
Зачем .NET MAUI команде CI/CD в 2026 году
.NET MAUI 10 принёс ряд изменений, которые делают CI/CD не просто удобной практикой, а необходимостью:
- NativeAOT для iOS — времена компиляции выросли в 2–4 раза по сравнению с обычным AOT, и держать релизные сборки на машине разработчика стало просто непрактично.
- Trimming и анализаторы — большинство проблем тримминга вылезают только в релизной сборке, и их нужно ловить регулярно, а не за неделю до релиза (поверьте, ловить такое в спешке — отдельный вид страданий).
- Apple требует Privacy Manifests для всех приложений с мая 2024 — пайплайн должен валидировать манифесты до загрузки в App Store.
- Google Play требует target API 35 (Android 15) для всех новых релизов с 31 августа 2025 года — и это нужно проверять автоматически.
Архитектура пайплайна: что и где собирать
Главная особенность .NET MAUI — невозможность собрать iOS-приложение нигде, кроме macOS. Это разделяет любой пайплайн как минимум на два джоба:
| Платформа | Раннер GitHub Actions | Стоимость |
|---|---|---|
| Android | ubuntu-latest | Базовая |
| iOS / macCatalyst | macos-15 или macos-15-arm64 | ×10 от Linux |
| Windows | windows-latest | ×2 от Linux |
В 2026 году берите macos-15-arm64. На M-чипах сборка iOS .NET MAUI идёт примерно на 35% быстрее, чем на Intel-раннерах, и для приватных репозиториев разница в стоимости минут окупается уже после 5–7 сборок. Я лично переводил наш пайплайн на arm64 — экономия минут раннера за месяц получилась заметная, и за это никто потом не пожалел.
Базовая структура workflow
name: Build and Deploy MAUI
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
env:
DOTNET_VERSION: '10.0.x'
PROJECT_PATH: 'src/MyApp/MyApp.csproj'
jobs:
build-android:
runs-on: ubuntu-latest
# ...
build-ios:
runs-on: macos-15-arm64
# ...
deploy:
needs: [build-android, build-ios]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
# ...
Подготовка проекта .NET MAUI к CI/CD
Раздельные TargetFrameworks
Распространённая ошибка — оставлять все таргеты в одном <TargetFrameworks>. На Linux-раннере сборка тогда упадёт с ошибкой "Could not find iOS workload" — и вы будете полчаса смотреть в логи, пытаясь понять, что не так. Используйте условные таргеты:
<PropertyGroup>
<TargetFrameworks>net10.0-android</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('osx'))">
net10.0-android;net10.0-ios;net10.0-maccatalyst
</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">
$(TargetFrameworks);net10.0-windows10.0.19041.0
</TargetFrameworks>
</PropertyGroup>
Это позволяет на Linux-раннере собирать только Android, не загружая лишние workloads и не падая на iOS-зависимостях.
Управление версиями приложения
В .NET MAUI 10 версия задаётся через ApplicationDisplayVersion (видимая пользователю, например 1.4.2) и ApplicationVersion (целое число для магазинов, должно монотонно расти):
<PropertyGroup>
<ApplicationDisplayVersion>1.4.2</ApplicationDisplayVersion>
<ApplicationVersion>142</ApplicationVersion>
</PropertyGroup>
В CI лучше переопределять обе через MSBuild-параметры, чтобы избежать ручного коммита версии перед каждым релизом:
dotnet publish $PROJECT_PATH \
-c Release \
-f net10.0-android \
-p:ApplicationDisplayVersion=${{ github.ref_name }} \
-p:ApplicationVersion=${{ github.run_number }}
github.run_number монотонно растёт от запуска к запуску, что идеально подходит как versionCode в Android и CFBundleVersion в iOS.
Сборка и подпись Android
Создание keystore
На локальной машине сгенерируйте ключ один раз и сохраните в безопасном месте (и да, в очень безопасном — потеря keystore означает, что вы больше никогда не сможете обновить своё приложение в Play Store):
keytool -genkeypair -v \
-keystore myapp-release.keystore \
-alias myapp \
-keyalg RSA -keysize 4096 \
-validity 10000
Закодируйте файл в base64 и добавьте в GitHub Secrets:
base64 -i myapp-release.keystore | pbcopy
Создайте секреты: ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD.
Workflow Android-сборки
build-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Setup Java 17
uses: actions/setup-java@v4
with:
distribution: 'microsoft'
java-version: '17'
- name: Install MAUI workload
run: dotnet workload install maui-android
- name: Restore dependencies
run: dotnet restore $PROJECT_PATH
- name: Decode keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > $RUNNER_TEMP/release.keystore
- name: Build and sign AAB
run: |
dotnet publish $PROJECT_PATH \
-c Release \
-f net10.0-android \
-p:AndroidPackageFormat=aab \
-p:AndroidKeyStore=true \
-p:AndroidSigningKeyStore=$RUNNER_TEMP/release.keystore \
-p:AndroidSigningStorePass='${{ secrets.ANDROID_KEYSTORE_PASSWORD }}' \
-p:AndroidSigningKeyAlias='${{ secrets.ANDROID_KEY_ALIAS }}' \
-p:AndroidSigningKeyPass='${{ secrets.ANDROID_KEY_PASSWORD }}' \
-p:ApplicationVersion=${{ github.run_number }}
- name: Upload AAB artifact
uses: actions/upload-artifact@v4
with:
name: android-aab
path: '**/bin/Release/net10.0-android/publish/*-Signed.aab'
retention-days: 30
Обратите внимание: пароли передаются через MSBuild-параметры в одинарных кавычках. Это критично — в двойных кавычках bash может попытаться раскрыть спецсимволы пароля, и тогда вы получите крайне странные ошибки подписи, которые часами не получается воспроизвести локально.
Очистка ключей после сборки
Хорошей практикой считается удаление keystore сразу после публикации, даже на временном раннере:
- name: Clean up keystore
if: always()
run: rm -f $RUNNER_TEMP/release.keystore
Сборка и подпись iOS
iOS-подпись — самая хрупкая часть пайплайна. Без преувеличений. У вас есть два пути: классический codesign вручную или fastlane match. В 2026 году fastlane match по-прежнему де-факто стандарт индустрии — он хранит сертификаты и provisioning profiles в зашифрованном Git-репозитории и автоматически скачивает их на любой раннер.
Настройка fastlane match
На локальной машине, имея доступ к Apple Developer аккаунту:
cd ios-certs-repo
fastlane match init
fastlane match appstore --app_identifier com.mycompany.myapp
Это создаст приватный Git-репозиторий с зашифрованными сертификатами. Для CI добавьте в GitHub Secrets: MATCH_PASSWORD, MATCH_GIT_URL, MATCH_GIT_BASIC_AUTHORIZATION (base64 от username:personal_access_token).
Workflow iOS-сборки
build-ios:
runs-on: macos-15-arm64
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_16.2.app
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Install MAUI workload
run: dotnet workload install maui-ios
- name: Setup Ruby and fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Sync certificates with match
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
run: |
bundle exec fastlane match appstore \
--readonly \
--git_url ${{ secrets.MATCH_GIT_URL }}
- name: Restore dependencies
run: dotnet restore $PROJECT_PATH
- name: Build IPA
run: |
dotnet publish $PROJECT_PATH \
-c Release \
-f net10.0-ios \
-p:ArchiveOnBuild=true \
-p:CodesignKey="Apple Distribution: My Company (TEAMID)" \
-p:CodesignProvision="match AppStore com.mycompany.myapp" \
-p:RuntimeIdentifier=ios-arm64 \
-p:ApplicationVersion=${{ github.run_number }}
- name: Upload IPA artifact
uses: actions/upload-artifact@v4
with:
name: ios-ipa
path: '**/bin/Release/net10.0-ios/ios-arm64/publish/*.ipa'
Решение типичных ошибок iOS-подписи
No matching provisioning profiles found— имя профиля вCodesignProvisionдолжно точно совпадать с тем, что match создал в Keychain. Имя имеет форматmatch AppStore <bundle_id>.Code signing identity not found— проверьте, чтоCodesignKeyсодержит team ID в скобках. После выполнения match можно вывести список доступных идентичностей:security find-identity -v -p codesigning.- Privacy Manifest валидация — добавьте в проект
PrivacyInfo.xcprivacyи проверяйте его наличие в пайплайне: Apple отклоняет билды без манифеста с конца 2024 года.
Автоматическая публикация в Google Play
Создание Service Account
В Google Cloud Console создайте service account и в Google Play Console дайте ему права Release manager. Скачайте JSON-ключ и сохраните как секрет GOOGLE_PLAY_SERVICE_ACCOUNT_JSON.
Публикация через fastlane supply
deploy-android:
needs: build-android
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
- name: Download AAB
uses: actions/download-artifact@v4
with:
name: android-aab
path: ./artifacts
- name: Setup Ruby and fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Decode service account
run: |
echo '${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}' > $RUNNER_TEMP/sa.json
- name: Upload to Google Play (internal track)
run: |
bundle exec fastlane supply \
--aab ./artifacts/*.aab \
--package_name com.mycompany.myapp \
--track internal \
--json_key $RUNNER_TEMP/sa.json \
--release_status draft
- name: Cleanup
if: always()
run: rm -f $RUNNER_TEMP/sa.json
Альтернатива fastlane — официальный action r0adkll/upload-google-play@v1, но он не поддерживает rollback и staged rollout. Для серьёзных проектов fastlane всё равно выигрывает.
Стратегия треков
Хорошая практика — публиковать каждый билд из main на internal трек автоматически, а в production переводить вручную через отдельный workflow с защищённым окружением:
promote-to-production:
environment: production-release # требует ручного approval
steps:
- run: |
bundle exec fastlane supply \
--track_promote_to production \
--rollout 0.1 \
--json_key $RUNNER_TEMP/sa.json
Параметр --rollout 0.1 запускает staged rollout на 10% пользователей. В 2026 году это уже must-have практика для всех приложений с аудиторией свыше 10 000 DAU — слишком велик риск выкатить в продакшен баг, который заметят только через сутки.
Автоматическая публикация в App Store
App Store Connect API key
В App Store Connect → Users and Access → Integrations создайте API-ключ с ролью App Manager. Сохраните три значения как секреты GitHub: APP_STORE_CONNECT_KEY_ID, APP_STORE_CONNECT_ISSUER_ID, APP_STORE_CONNECT_PRIVATE_KEY (содержимое .p8 файла).
Загрузка через fastlane pilot
deploy-ios:
needs: build-ios
runs-on: macos-15-arm64
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
- name: Download IPA
uses: actions/download-artifact@v4
with:
name: ios-ipa
path: ./artifacts
- name: Setup Ruby and fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Upload to TestFlight
env:
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
run: |
bundle exec fastlane pilot upload \
--ipa ./artifacts/*.ipa \
--skip_waiting_for_build_processing true
Флаг --skip_waiting_for_build_processing true экономит до 30 минут на каждом релизе: пайплайн заканчивается сразу после загрузки, а Apple асинхронно обработает билд и пришлёт письмо. Мелочь, а приятно — особенно когда релизы идут в конце спринта.
Альтернатива: xcrun altool
Если не хотите тащить Ruby и fastlane на iOS-раннер, используйте встроенный xcrun altool или новый xcrun notarytool. Минус — нет нормального управления TestFlight группами и метаданными:
xcrun altool --upload-app \
--type ios \
--file ./artifacts/MyApp.ipa \
--apiKey $APP_STORE_CONNECT_KEY_ID \
--apiIssuer $APP_STORE_CONNECT_ISSUER_ID
Кеширование зависимостей
На macos-15-arm64 установка MAUI workload и восстановление NuGet занимает 4–6 минут. На больших проектах это ощутимо — особенно когда нужно прогнать пайплайн срочно. Добавьте кеш:
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: nuget-${{ runner.os }}-
- name: Cache MAUI workloads
uses: actions/cache@v4
with:
path: ~/.dotnet/sdk-manifests
key: maui-workload-${{ env.DOTNET_VERSION }}-${{ runner.os }}
На практике кеш NuGet экономит 1–2 минуты, кеш workloads — ещё 2–3 минуты на iOS-раннере. Складывается за месяц во вполне ощутимые цифры.
Тестирование в пайплайне
До сборки релизных артефактов всегда запускайте unit-тесты. .NET MAUI поддерживает device tests через Microsoft.DotNet.XHarness.CLI, но в большинстве случаев достаточно изолированных юнит-тестов на ViewModel-слое:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Test
run: |
dotnet test tests/MyApp.Tests/MyApp.Tests.csproj \
--configuration Release \
--logger "trx;LogFileName=test-results.trx" \
--collect:"XPlat Code Coverage"
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: '**/test-results.trx'
Сделайте джобы build-android и build-ios зависимыми от test через needs: [test]. Это сэкономит дорогие минуты macOS-раннера, если тесты упали — а упасть они могут в любой момент.
Безопасность пайплайна в 2026 году
OIDC вместо долгоживущих секретов
Если вы публикуете в App Store через сторонние сервисы вроде Codemagic или Bitrise — переходите на OIDC federation. GitHub Actions выдаёт короткоживущий токен, который обменивается на временные credentials. Никаких .p8 файлов в Secrets:
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/MAUI-Deploy
aws-region: eu-west-1
Принцип минимальных привилегий
Service Account в Google Play не должен иметь роль Admin — достаточно Release manager. App Store Connect API key — с ролью App Manager, а не Admin. Создавайте отдельные ключи для production и staging, и не ленитесь делать ротацию хотя бы раз в полгода.
Защищённые окружения
Для деплоя в production используйте GitHub Environments с required reviewers:
jobs:
promote-to-production:
environment:
name: production
url: https://play.google.com/store/apps/details?id=com.mycompany.myapp
runs-on: ubuntu-latest
steps:
# ...
В настройках environment production укажите 1–2 обязательных ревьюера и ограничьте секреты только этим окружением. Даже если кто-то добавит шаг cat $RUNNER_TEMP/sa.json в обычный workflow, он не получит к ним доступа — а это, как ни крути, реальный сценарий компрометации в больших командах.
Версионирование и автогенерация changelog
Самая болезненная часть релизного процесса — release notes. Серьёзно, никто не любит их писать. Используйте conventional commits и release-please от Google:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
with:
release-type: simple
package-name: myapp
Action анализирует коммиты со времён последнего релиза, генерирует changelog, создаёт PR с обновлённой версией и тегом v1.4.2. Ваш основной workflow триггерится по тегам v* и автоматически собирает и публикует релиз. Один раз настроил — и забыл.
Мониторинг успешности пайплайна
Настройте уведомления в Slack или Teams через slackapi/slack-github-action:
- name: Notify Slack on failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "Release ${{ github.ref_name }} failed at job ${{ github.job }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Для серьёзной аналитики подключите DataDog CI Visibility или Honeycomb: они покажут тренды длительности джобов, флакающие тесты и регрессии производительности билдов. Полезная штука, когда команда вырастает за 5–6 человек.
Полный пример .github/workflows/release.yml
Сводный workflow, который собирает, подписывает, тестирует и деплоит .NET MAUI 10 приложение по тегу v*:
name: Release MAUI App
on:
push:
tags: ['v*']
env:
DOTNET_VERSION: '10.0.x'
PROJECT_PATH: 'src/MyApp/MyApp.csproj'
jobs:
test:
runs-on: ubuntu-latest
# см. раздел "Тестирование"
build-android:
needs: test
runs-on: ubuntu-latest
# см. раздел "Сборка Android"
build-ios:
needs: test
runs-on: macos-15-arm64
# см. раздел "Сборка iOS"
deploy-android:
needs: build-android
runs-on: ubuntu-latest
environment: production
# см. раздел "Публикация в Google Play"
deploy-ios:
needs: build-ios
runs-on: macos-15-arm64
environment: production
# см. раздел "Публикация в App Store"
Часто задаваемые вопросы
Сколько стоит CI/CD для .NET MAUI на GitHub Actions?
Для приватных репозиториев одна полная сборка (Android + iOS) занимает примерно 25–35 минут iOS-раннера и 8–12 минут Linux-раннера. По тарифам GitHub Actions 2026 года это около 0.32 USD за релиз. Для публичных репозиториев — бесплатно. Если делаете 50 релизов в месяц, бюджет получается ≈ 16 USD. Self-hosted Mac mini окупается при 200+ релизах в месяц — но добавляет головной боли с обслуживанием.
Можно ли собирать iOS без macOS-раннера?
Технически — нет: компилятор Xamarin.iOS, AOT-компилятор и codesign доступны только на macOS. Существуют коммерческие облачные сервисы (Codemagic, Bitrise, MacStadium), но это всё равно macOS под капотом. JetBrains Rider предлагает remote build agent на собственном Mac mini — рабочий вариант, если у вас уже есть Mac.
Что лучше — fastlane или нативные инструменты dotnet?
fastlane выигрывает для команд, которые поддерживают и .NET MAUI, и нативные iOS/Android приложения — единый инструмент, общие практики. Если у вас только .NET MAUI и команда не знает Ruby, можно обойтись xcrun altool, r0adkll/upload-google-play и встроенными MSBuild-задачами. Но fastlane match для подписи iOS пока не имеет адекватной альтернативы — и это, пожалуй, единственная причина, по которой я бы всё-таки потащил Ruby в пайплайн.
Как правильно версионировать .NET MAUI приложение в CI?
Используйте github.run_number как ApplicationVersion (целое число для магазинов), а Git-тег вида v1.4.2 как ApplicationDisplayVersion. Это даёт монотонно растущий versionCode/CFBundleVersion и человекочитаемую версию. Никогда не коммитьте версию в .csproj — это источник конфликтов и ошибок.
Как ускорить сборку .NET MAUI в CI?
Три самых эффективных шага: 1) Кеширование NuGet и MAUI workloads (экономит 4–6 минут на iOS); 2) Параллельный запуск Android и iOS джобов (вместо последовательного); 3) Включение EnableMauiHotReload=false и UseInterpreter=false в релизной сборке. На больших проектах ещё помогает разделение solution на несколько проектов и dotnet build --no-restore после явного dotnet restore.
Нужен ли отдельный пайплайн для PR и для релизов?
Да. На PR запускайте только тесты и Debug-сборку Android (без подписи) — это быстро и не тратит iOS-минуты. Полный релизный пайплайн с подписью и публикацией триггерится только по тегам v* или вручную через workflow_dispatch. Это снижает стоимость в 5–10 раз и убирает риск случайной публикации с feature-ветки.