CI/CD за .NET MAUI с GitHub Actions: Автоматизиран билд, подпис и публикуване (2026)

Пълен pipeline за CI/CD на .NET MAUI с GitHub Actions: подпис на Android AAB, iOS IPA и автоматично качване в Google Play и App Store с готови примери.

CI/CD за .NET MAUI с GitHub Actions 2026

Обновено: 23 август 2026

CI/CD за .NET MAUI с GitHub Actions означава да билдвате Android, iOS, Windows и macOS от един workflow, който подписва артефактите автоматично и ги качва в Google Play и App Store Connect без ръчна намеса. Повечето екипи, с които съм работил, стартират проекта с локален dotnet publish, а месеци по-късно се чудят защо всяка нова версия иска половин ден и молитва към Xcode. Тази статия описва пълния pipeline (от подготовката на secrets до публикуването) така, както го изграждам за продукция.

  • За пълен CI/CD ви трябват три runner-а: ubuntu-latest за Android, macos-14 за iOS/macOS и windows-latest за WinUI. Всеки с инсталиран .NET 10 SDK и MAUI workload.
  • Android се подписва с keystore, съхранен като base64 GitHub secret и декодиран в job-а. iOS изисква P12 сертификат, provisioning profile и App Store Connect API key (не парола за Apple ID).
  • Cache-ването на NuGet пакетите и MAUI workload-а спестява 4–7 минути на билд. Без него всеки job тегли към 1.5 GB от нула.
  • Google Play качването минава през сервизен акаунт JSON и r0adkll/upload-google-play. App Store Connect работи най-надеждно с Fastlane pilot, а не с xcrun altool, който е deprecated от 2025.
  • macOS runner-ите струват 10× повече от Linux, така че минимизирайте iOS билдовете чрез trigger-и по tag или ръчен workflow_dispatch, а не при всеки push.
  • Failed release билдовете почти винаги се дължат на грешна версия на SDK-то, липсваща workload инсталация или изтекъл provisioning profile. Три неща, които pipeline-ът трябва да проверява явно.

Защо повечето екипи бъркат CI/CD за .NET MAUI

Честно казано, повечето екипи пропускат тази част и после се чудят защо release-ът в петък отнема осем часа. Типичният сценарий изглежда така: разработчикът има работещ dotnet build -f net10.0-android локално, копира командата в GitHub Actions workflow, добавя --configuration Release и очаква същия резултат. След това runner-ът гърми с „workload not installed", после с „keystore not found", после с „provisioning profile expired". И всяка грешка се появява едва в средата на 12-минутен билд.

Проблемът е, че локалната ви машина има три години акумулирано състояние: MAUI workload, инсталиран от Visual Studio, keystore в ~/.android, Apple Developer акаунт в Keychain, Xcode с автоматично управлявани сертификати. CI runner-ът е празен всеки път. Всяко от тези неща трябва да е явно декларирано в workflow-а, включително точната версия на .NET SDK, workload manifest-ът и таргет платформите. В моята практика съм виждал екипи, които правят release ръчно от лаптопа на един човек цели две години, защото CI никога не проработва напълно. Целта тук е обратното: pipeline, който не изисква „магическата машина на Иван".

Ако все още обмисляте архитектурата на самото приложение, започнете от MVVM с CommunityToolkit.Mvvm и след това се върнете тук. DevOps слоят предполага, че кодът вече е тестваем.

Какво ви трябва, преди да напишете първия workflow

Преди да отворите .github/workflows/, съберете следните артефакти. Ако някой липсва, спрете и го намерете. Да откриете, че нямате valid App Store Connect API key след 20 минути в CI, е болезнено (говоря от опит).

  1. .NET SDK версия: .NET 10 SDK (10.0.100 или по-нова към август 2026). Заковете я в global.json, за да не се разминава между локална машина и CI.
  2. MAUI workload: maui workload manifest, съвпадащ със SDK-то. Различните версии на workload-а хвърлят загадъчни MSB4062 грешки.
  3. Android keystore: .jks или .keystore файл, който вече е използван за качване в Google Play. НЕ генерирайте нов за CI, защото Google Play отхвърля upload-и с различен ключ.
  4. Apple certificates: Distribution сертификат (.p12) и App Store provisioning profile (.mobileprovision) за вашия bundle ID.
  5. App Store Connect API key: JSON файл, генериран от App Store Connect → Users and Access → Keys. Има три полета: key_id, issuer_id, key (P8 съдържание).
  6. Google Play service account: JSON, генериран от Google Cloud Console и upload-нат в Google Play Console → Settings → API access.

Всички чувствителни файлове отиват в GitHub Secrets. За бинарни файлове като .keystore и .p12 ги кодирайте в base64 с base64 -i keystore.jks -o keystore.b64 и поставете съдържанието като secret стойност. За JSON файлове поставяйте суровото съдържание, така избягвате escaping главоболията.

Как да настроите CI/CD за .NET MAUI с GitHub Actions

Скелетът на pipeline-а има три отделни job-а, по един за всяка платформа, плюс trigger, който решава кога да ги пуска. Всеки push към main билдва Android (евтино на Ubuntu). iOS билдва само при push на tag v*, защото macOS runner-ите струват. Ето базовия workflow, който после ще разширим:

name: MAUI CI/CD

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
    branches: [main]
  workflow_dispatch:

env:
  DOTNET_VERSION: '10.0.x'
  MAUI_VERSION: '10.0.100'

jobs:
  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: Cache NuGet
        uses: actions/cache@v4
        with:
          path: ~/.nuget/packages
          key: nuget-${{ hashFiles('**/*.csproj') }}
          restore-keys: nuget-

      - name: Install MAUI workload
        run: dotnet workload install maui-android --version ${{ env.MAUI_VERSION }}

      - name: Restore
        run: dotnet restore src/MyApp/MyApp.csproj

      - name: Build
        run: dotnet build src/MyApp/MyApp.csproj -c Release -f net10.0-android --no-restore

Обърнете внимание на maui-android вместо цялостния maui workload. Инсталирането на пълния workload на Ubuntu тегли и iOS зависимости, които няма да използвате там, и губите 2–3 минути на билд. Инсталирайте само това, което ви трябва за конкретния runner.

За on: trigger-ите използвам pull_request за компилационна проверка, push към main за artifact качване към нашия staging Firebase App Distribution, и tag push за store release. Това разделение пази macOS минутите за реалните release билдове. Според официалната ценова политика на GitHub Actions, macOS минутите се таксуват с множител 10× спрямо Linux.

Билд и подпис на Android AAB стъпка по стъпка

Android подписването в CI изисква три secret-а: ANDROID_KEYSTORE_B64 (base64 на keystore файла), ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS и ANDROID_KEY_PASSWORD. Ето стъпките, които добавяте към build-android job-а след базовия билд:

      - name: Decode keystore
        run: |
          echo "${{ secrets.ANDROID_KEYSTORE_B64 }}" | base64 -d > $RUNNER_TEMP/release.keystore
          echo "KEYSTORE_PATH=$RUNNER_TEMP/release.keystore" >> $GITHUB_ENV

      - name: Publish signed AAB
        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=${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
            -p:AndroidSigningKeyAlias=${{ secrets.ANDROID_KEY_ALIAS }} \
            -p:AndroidSigningKeyPass=${{ secrets.ANDROID_KEY_PASSWORD }} \
            -o publish/android

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: android-aab
          path: publish/android/*.aab
          retention-days: 7

Ключовият детайл: AndroidPackageFormat=aab, а не APK. Google Play изисква Android App Bundle от август 2021 за нови приложения и от октомври 2025 за обновления на съществуващи. Ако pipeline-ът ви още произвежда APK, upload-ът просто ще бъде отхвърлен.

Второ уточнение: $RUNNER_TEMP се изчиства автоматично след job-а, така че декодираният keystore не остава в кеша на runner-а. Никога не декодирайте в $GITHUB_WORKSPACE, защото Actions понякога persist-ват workspace-а между стъпки и рискувате keystore-ът да попадне в artifact.

Мога ли да билдна iOS без Mac и как става със сертификатите

Не, не можете да билдвате .NET MAUI iOS release без macOS машина, защото Apple изисква Xcode toolchain за подписване и ibtool компилацията на storyboards. GitHub Actions предоставя macos-14 runner-и с предварително инсталиран Xcode 16.2 (към август 2026), което е достатъчно за .NET 10 MAUI. Ето workflow-а за iOS:

  build-ios:
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

      - name: Install MAUI iOS workload
        run: dotnet workload install maui-ios --version ${{ env.MAUI_VERSION }}

      - name: Import signing certificate
        uses: apple-actions/import-codesign-certs@v3
        with:
          p12-file-base64: ${{ secrets.IOS_P12_B64 }}
          p12-password: ${{ secrets.IOS_P12_PASSWORD }}

      - name: Install provisioning profile
        uses: apple-actions/download-provisioning-profiles@v3
        with:
          bundle-id: com.mycompany.myapp
          issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
          api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
          api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}

      - name: Publish IPA
        run: |
          dotnet publish src/MyApp/MyApp.csproj \
            -c Release \
            -f net10.0-ios \
            -p:ArchiveOnBuild=true \
            -p:CodesignKey="Apple Distribution: My Company (ABCD123456)" \
            -p:CodesignProvision="MyApp AppStore" \
            -o publish/ios

Двете apple-actions стъпки заменят цялата стара практика с ръчно управление на Keychain и fastlane match. import-codesign-certs създава временен Keychain, който се изтрива в края на job-а. Това е важно, защото persistent Keychain на shared runner е security проблем. download-provisioning-profiles тегли профилите директно от App Store Connect през API, вместо да ги съхранявате като secret. Така profile-ът се обновява автоматично, когато го подновите в Apple Developer портала.

CodesignKey трябва да съвпадне точно с името на сертификата плюс team ID в скоби. Проверете го с security find-identity -v -p codesigning локално. За архитектурата, която ще подпишете, вижте и миграцията от Xamarin.Forms към .NET MAUI. Ако идвате от Xamarin, bundle ID може да изисква обновяване в App Store Connect.

Автоматично публикуване в Google Play и App Store

След като имате подписан AAB и IPA, upload-ът към store-овете е последната стъпка. За Google Play използвам r0adkll/upload-google-play, който работи със service account JSON:

      - name: Upload to Google Play Internal
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SA_JSON }}
          packageName: com.mycompany.myapp
          releaseFiles: publish/android/*.aab
          track: internal
          status: completed
          whatsNewDirectory: distribution/whatsnew

Track-ът internal е за първоначален smoke test с ограничена група тестъри. Качете там от CI, ръчно промотирайте към production, след като верифицирате. Никога не пускайте автоматичен upload директно към production track. При регресия имате часове, докато halt-нете разпространението.

За App Store Connect най-надеждният път през 2026 е Fastlane pilot (TestFlight) или deliver (App Store). xcrun altool беше премахнат от Xcode 16, а xcrun notarytool вече не поддържа iOS upload, само macOS notarization. Инсталирайте Fastlane и качете:

      - name: Setup Fastlane
        run: gem install fastlane --no-document

      - name: Upload to TestFlight
        env:
          APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APPSTORE_KEY_ID }}
          APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APPSTORE_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APPSTORE_PRIVATE_KEY }}
        run: |
          fastlane pilot upload \
            --ipa publish/ios/MyApp.ipa \
            --skip_waiting_for_build_processing true \
            --api_key_path fastlane/api_key.json

skip_waiting_for_build_processing е важен, защото Apple обработва билда 10–40 минути и без този флаг job-ът виси и харчи macOS минути напразно. Уведомете тестърите чрез отделен webhook, когато TestFlight изпрати email за готов билд.

Матрични билдове, кеширане и оптимизация

Веднъж работещ pipeline, следващата задача е да го направите бърз. Първият билд без cache отнема 14–18 минути. С правилно кеширане слизам до 6–8. Ключовете:

  1. NuGet cache: actions/cache@v4 върху ~/.nuget/packages с ключ от hashFiles('**/*.csproj', '**/packages.lock.json'). Включете packages.lock.json, за да инвалидирате кеша, когато transitive зависимостите се променят.
  2. MAUI workload cache: workload файловете живеят в ~/.dotnet. Кеширайте ~/.dotnet/sdk-manifests и ~/.dotnet/packs с ключ, зависещ от MAUI_VERSION.
  3. Gradle cache (Android): ~/.gradle/caches. Спестява около 2 минути при инкрементални билдове.
  4. Xcode DerivedData: при iOS слагам ~/Library/Developer/Xcode/DerivedData в cache, но key-ът зависи от commit SHA, за да не се enable дефектни инкрементални билдове.

Матричните билдове са полезни, когато поддържате няколко target framework-а или конфигурации. Ето пример за multi-configuration Android билд:

  build-android:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        configuration: [Release, ReleaseNoAOT]
        include:
          - configuration: Release
            aot: true
          - configuration: ReleaseNoAOT
            aot: false
    steps:
      - uses: actions/checkout@v4
      # ... setup steps ...
      - name: Build
        run: dotnet publish -c ${{ matrix.configuration }} -p:RunAOTCompilation=${{ matrix.aot }}

За Native AOT в .NET MAUI билдовете отнемат 3–5 пъти повече време. Задължително ги пускайте само на release и никога на PR check.

Често срещани грешки и как да ги избегнете

Последният раздел е списък на нещата, които са ме будили в 2 през нощта. Проверявайте ги преди release, не след като pager-ът е звъннал.

  • „workload not installed" в средата на билд: обикновено означава, че dotnet workload install е успял, но с различна версия от очакваната от csproj-а. Заковете <MauiVersion> в Directory.Build.props и подавайте същата версия във workload install --version.
  • Android AAB се качва, но Play Console казва „unsigned": проверете дали използвате Play App Signing. От август 2021 Google съхранява истинския upload key на техните сървъри. Вашият keystore подписва само upload-а, а Google преподписва за разпространение. Ако сте генерирали нов keystore, upload-ът се отхвърля.
  • iOS „no matching profile found": bundle ID в csproj не съответства на profile-а. Отворете .mobileprovision с security cms -D -i profile.mobileprovision и сравнете application-identifier.
  • App Store Connect API ключ „invalid": P8 файлът трябва да включва header-ите -----BEGIN PRIVATE KEY-----. Ако сте копирали само base64 частта, ключът е технически валиден за base64 decoding, но невалиден като PKCS#8.
  • macOS runner-ът виси на „Restoring workloads": Apple периодично обновява network peering. Понякога NuGet.org е бавен от Azure macOS pool-а. Добавете --source https://api.nuget.org/v3/index.json --disable-parallel при упорити timeout-и.
  • Version code конфликт: Google Play изисква ApplicationVersion да бъде монотонно нарастващ integer. Използвайте ${{ github.run_number }} като -p:ApplicationVersion. Гарантирано уникален и нарастващ.

За детайлно проследяване на грешките в самото приложение след release, вижте препоръчания подход в REST API устойчивостта в .NET MAUI. Logging и telemetry са неразделна част от DevOps loop-а, а не отделна тема.

Често задавани въпроси

Колко струват macOS runner-ите в GitHub Actions за MAUI проект?

macOS runner-ите се таксуват с 10× множител спрямо Linux ($0.08/min срещу $0.008/min за standard runner-и към август 2026). Типичен iOS release билд от 15 минути струва около $1.20 срещу около $0.12 за Android. Личните и small team планове имат месечен безплатен лимит от 2000 минути (Linux еквивалент), което практически ограничава iOS билдовете до около 200 на месец.

Мога ли да използвам self-hosted Mac Mini за iOS билдове?

Да, self-hosted runner на M2 Mac Mini е около 3× по-бърз от macos-14 и се амортизира за около 6 месеца при активно ползване. Ограничения: сами управлявате Xcode обновления, security patches и Apple Silicon compatibility. За екипи под 5 души GitHub-hosted обикновено е по-лесно. За екипи с ежедневни iOS билдове self-hosted е икономически по-добър избор.

Как да подпиша .NET MAUI Windows приложение в CI?

За MSIX подпис ви трябва EV code signing сертификат в PFX формат, съхранен като base64 secret. Използвайте signtool sign /f cert.pfx /p $env:PFX_PASSWORD /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 MyApp.msix след dotnet publish -f net10.0-windows10.0.19041.0. За Microsoft Store допълнително ще ви трябва Microsoft.StoreServices API за автоматичен upload.

Защо build-ът гърми с „invalid provisioning profile" само в CI?

Най-честата причина е, че provisioning profile-ът е изтекъл (валиден е 1 година) или е бил преиздаден в Apple Developer портала след последното качване в GitHub Secrets. Използвайте apple-actions/download-provisioning-profiles@v3, което тегли актуалния profile при всеки билд, вместо да съхранявате статично копие в secrets. Това елиминира почти всички expiry проблеми.

Как да пусна тестове преди release билда, без да губя macOS минути?

Разделете workflow-а на два job-а: test на ubuntu-latest с dotnet test само за net10.0 (без mobile target-и) и build-ios, който има needs: test. Unit тестовете за MVVM, услуги и validation logic не изискват mobile runtime и се въртят за около 90 секунди на Linux. macOS билдът стартира само ако тестовете минат.

Sofia Rodriguez
За Автора Sofia Rodriguez

Mobile DevOps engineer focused on the unglamorous stuff: build pipelines, signing, store releases, and the tooling that keeps teams shipping.