CI/CD для .NET MAUI в 2026: GitHub Actions, подпись iOS/Android и автопубликация в App Store и Google Play

Подробное руководство по настройке CI/CD для .NET MAUI 10 в GitHub Actions: подпись iOS и Android, fastlane, автоматическая публикация в App Store и Google Play, версионирование и безопасность пайплайна.

CI/CD .NET MAUI 10: GitHub Actions — Setup 2026

Скажу честно: в 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Стоимость
Androidubuntu-latestБазовая
iOS / macCatalystmacos-15 или macos-15-arm64×10 от Linux
Windowswindows-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-ветки.

Об авторе Editorial Team

Our team of expert writers and editors.