CI/CD pentru .NET MAUI cu GitHub Actions: Build, Semnare și Deploy Automat

Ghid pas cu pas pentru configurarea unui pipeline CI/CD cu GitHub Actions pentru aplicații .NET MAUI. Acoperă build-uri Android și iOS, semnare automată, upload în Google Play și TestFlight, plus optimizări practice pentru .NET 10.

De Ce Ai Nevoie de un Pipeline CI/CD pentru Aplicații .NET MAUI

Dacă ai construit o aplicație .NET MAUI și procesul tău de publicare arată cam așa — build manual în Visual Studio, semnare manuală, upload manual în Google Play Console sau App Store Connect — atunci știi deja cât de frustrant e. Nu pierzi doar timp prețios cu fiecare release. Pierzi și nervi. Fiecare pas manual introduce riscul de erori umane: o versiune greșită, un certificat expirat pe care nu l-ai observat, un build nesemnat trimis din greșeală.

Am trecut prin asta de mai multe ori decât aș vrea să recunosc.

Un pipeline CI/CD (Continuous Integration / Continuous Delivery) automatizează întregul flux: de la momentul în care faci push pe un branch, până la generarea artefactelor semnate gata de publicare — sau chiar până la upload-ul automat în TestFlight ori Google Play. Cu GitHub Actions, poți configura totul direct în repository-ul tău, fără servere suplimentare, fără licențe costisitoare.

În acest ghid, construim pas cu pas un pipeline complet pentru .NET 10 MAUI, acoperind atât Android cât și iOS. Fiecare secțiune include fișiere YAML funcționale și explicații pentru deciziile tehnice luate. Hai să începem.

Cerințe Preliminare

Înainte de a configura pipeline-ul, asigură-te că ai pregătit următoarele:

  • Repository GitHub cu proiectul .NET MAUI targetând net10.0-android și net10.0-ios
  • Cont Apple Developer (99 USD/an) — necesar pentru semnarea și publicarea aplicațiilor iOS
  • Cont Google Play Console (25 USD taxă unică) — pentru publicare pe Android
  • Keystore Android pentru semnarea APK/AAB
  • Certificat de distribuție Apple (fișier .p12) și Provisioning Profile
  • global.json în rădăcina proiectului, pentru a fixa versiunea SDK-ului .NET

Nu sări peste niciun punct. Serios — am văzut pipeline-uri care eșuau misterios doar pentru că lipsea un provisioning profile sau keystore-ul era expirat.

Fixarea Versiunii SDK cu global.json

Primul pas critic: nu lăsa pipeline-ul să depindă de versiunea SDK care se întâmplă să fie instalată pe runner-ul GitHub. E o rețetă pentru build-uri care „funcționează la mine dar nu pe CI". Creează un fișier global.json în rădăcina repository-ului:

{
  "sdk": {
    "version": "10.0.100",
    "rollForward": "latestPatch"
  }
}

Setarea rollForward: latestPatch permite actualizări de patch automate (10.0.101, 10.0.102 etc.) dar previne salt-uri majore neașteptate. Un compromis bun între stabilitate și securitate.

Structura Proiectului .NET 10 MAUI pentru CI/CD

Fișierul .csproj trebuie configurat corect pentru build-uri automatizate. Iată secțiunile relevante:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
    <TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">
      $(TargetFrameworks);net10.0-windows10.0.19041.0
    </TargetFrameworks>
    <OutputType>Exe</OutputType>
    <UseMaui>true</UseMaui>
    <ApplicationId>com.compania.aplicatia</ApplicationId>
    <ApplicationDisplayVersion>1.0.0</ApplicationDisplayVersion>
    <ApplicationVersion>1</ApplicationVersion>
  </PropertyGroup>

  <!-- Configurare Android Release -->
  <PropertyGroup Condition="'$(Configuration)' == 'Release' AND '$(TargetFramework)' == 'net10.0-android'">
    <AndroidPackageFormats>aab</AndroidPackageFormats>
    <PublishTrimmed>true</PublishTrimmed>
    <RunAOTCompilation>false</RunAOTCompilation>
  </PropertyGroup>
</Project>

Notă importantă pentru .NET 10: RuntimeIdentifier-ele specifice unei versiuni de OS (precum win10-x64) nu mai sunt suportate. Folosește variante portabile: win-x64, win-x86, win-arm64. Pentru Android, RID-urile rămân: android-arm, android-arm64, android-x86, android-x64.

Configurarea Secretelor GitHub

Înainte de a scrie pipeline-ul, trebuie să configurezi secretele în repository-ul GitHub. Mergi la Settings → Secrets and variables → Actions și adaugă următoarele:

Secrete pentru Android

  • ANDROID_KEYSTORE_BASE64 — keystore-ul codificat ca Base64
  • ANDROID_KEYSTORE_PASSWORD — parola keystore-ului
  • ANDROID_KEY_ALIAS — alias-ul cheii de semnare
  • ANDROID_KEY_PASSWORD — parola cheii

Pentru a genera string-ul Base64 din keystore:

# Pe macOS/Linux
base64 -i my-release-key.jks -o keystore-base64.txt

# Pe Windows (PowerShell)
[Convert]::ToBase64String([IO.File]::ReadAllBytes("my-release-key.jks")) | Out-File keystore-base64.txt

Copiază conținutul fișierului keystore-base64.txt în secretul ANDROID_KEYSTORE_BASE64. Nu salva niciodată keystore-ul direct în repository — e o vulnerabilitate majoră de securitate. Chiar dacă repo-ul e privat.

Secrete pentru iOS

  • IOS_P12_CERTIFICATE_BASE64 — certificatul de distribuție codificat ca Base64
  • IOS_P12_PASSWORD — parola certificatului .p12
  • IOS_PROVISIONING_PROFILE_BASE64 — provisioning profile codificat ca Base64
  • APPSTORE_ISSUER_ID — Issuer ID din App Store Connect API
  • APPSTORE_KEY_ID — Key ID din App Store Connect API
  • APPSTORE_PRIVATE_KEY — cheia privată pentru App Store Connect API

Da, sunt multe secrete. Dar configurarea lor e un efort de o singură dată — după ce le setezi, n-o să te mai gândești la ele (până expiră certificatul, evident).

Pipeline-ul Android: De la Commit la AAB Semnat

Aici devine interesant. Creează fișierul .github/workflows/build-android.yml în repository:

name: Build Android

on:
  workflow_call:
    inputs:
      project-path:
        required: true
        type: string
      build-version:
        required: true
        type: string
      build-number:
        required: true
        type: string

jobs:
  build-android:
    runs-on: windows-latest
    steps:
      - name: Checkout cod
        uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          global-json-file: global.json

      - name: Instalare workload MAUI
        run: dotnet workload install maui

      - name: Decodare keystore
        shell: bash
        run: |
          echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > ${{ github.workspace }}/release.keystore

      - name: Build și semnare Android
        run: |
          dotnet publish ${{ inputs.project-path }} ^
            -c Release ^
            -f net10.0-android ^
            /p:ApplicationDisplayVersion=${{ inputs.build-version }} ^
            /p:ApplicationVersion=${{ inputs.build-number }} ^
            /p:AndroidPackageFormats=aab ^
            /p:AndroidKeyStore=true ^
            /p:AndroidSigningKeyStore=${{ github.workspace }}\release.keystore ^
            /p:AndroidSigningKeyAlias=${{ secrets.ANDROID_KEY_ALIAS }} ^
            /p:AndroidSigningKeyPass="${{ secrets.ANDROID_KEY_PASSWORD }}" ^
            /p:AndroidSigningStorePass="${{ secrets.ANDROID_KEYSTORE_PASSWORD }}"

      - name: Upload artefact AAB
        uses: actions/upload-artifact@v4
        with:
          name: android-aab
          path: |
            **/*.aab
          retention-days: 30

      - name: Curățare keystore
        if: always()
        shell: bash
        run: rm -f ${{ github.workspace }}/release.keystore

Câteva detalii importante despre acest workflow:

  • Runner windows-latest: Build-urile Android funcționează și pe Linux, dar windows-latest vine cu Java și Android SDK pre-instalate. Asta reduce serios timpul de setup.
  • Decodarea keystore-ului: Keystore-ul e reconstruit din secretul Base64 la fiecare rulare și șters la final. Pasul Curățare keystore cu if: always() garantează ștergerea chiar dacă build-ul eșuează — un detaliu mic dar important.
  • Formatul AAB: Google Play cere format Android App Bundle (.aab), nu APK clasic. Dacă ai nevoie de APK pentru testare internă, setează AndroidPackageFormats=aab;apk.

Pipeline-ul iOS: Build, Semnare și Upload în TestFlight

Build-urile iOS necesită obligatoriu un runner macOS — Xcode pur și simplu nu există pe alte platforme. Creează fișierul .github/workflows/build-ios.yml:

name: Build iOS

on:
  workflow_call:
    inputs:
      project-path:
        required: true
        type: string
      build-version:
        required: true
        type: string
      build-number:
        required: true
        type: string

jobs:
  build-ios:
    runs-on: macos-15
    steps:
      - name: Checkout cod
        uses: actions/checkout@v4

      - name: Setup Xcode
        uses: maxim-lobanov/setup-xcode@v1
        with:
          xcode-version: "16.2"

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          global-json-file: global.json

      - name: Instalare workload MAUI
        run: dotnet workload install maui

      - name: Instalare certificat și profil
        env:
          P12_CERTIFICATE: ${{ secrets.IOS_P12_CERTIFICATE_BASE64 }}
          P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }}
          PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
        run: |
          # Creare keychain temporar
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
          KEYCHAIN_PASSWORD=$(openssl rand -hex 32)

          security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
          security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"

          # Import certificat P12
          echo "$P12_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12
          security import $RUNNER_TEMP/certificate.p12 \
            -P "$P12_PASSWORD" \
            -A -t cert -f pkcs12 \
            -k "$KEYCHAIN_PATH"
          security list-keychain -d user -s "$KEYCHAIN_PATH"

          # Instalare provisioning profile
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          echo "$PROVISIONING_PROFILE" | base64 --decode > ~/Library/MobileDevice/Provisioning\ Profiles/distribution.mobileprovision

      - name: Build iOS
        run: |
          dotnet publish ${{ inputs.project-path }} \
            -c Release \
            -f net10.0-ios \
            /p:ApplicationDisplayVersion=${{ inputs.build-version }} \
            /p:ApplicationVersion=${{ inputs.build-number }} \
            /p:ArchiveOnBuild=true \
            /p:CodesignKey="Apple Distribution" \
            /p:CodesignProvision="distribution"

      - name: Upload în TestFlight
        uses: apple-actions/upload-testflight-build@v3
        with:
          app-path: ${{ github.workspace }}/**/*.ipa
          issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
          api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
          api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}

      - name: Curățare certificat
        if: always()
        run: |
          security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
          rm -f $RUNNER_TEMP/certificate.p12
          rm -f ~/Library/MobileDevice/Provisioning\ Profiles/distribution.mobileprovision

Câteva aspecte cheie aici:

  • Runner macos-15: Folosim un runner macOS specific (nu macos-latest) pentru predictibilitate. Și atenție — runner-ele macOS de pe GitHub sunt semnificativ mai scumpe. Un minut pe macOS costă de 10 ori mai mult decât pe Linux.
  • Keychain temporar: Creăm un keychain dedicat build-ului în loc să modificăm keychain-ul de sistem. Previne conflicte și asigură curățare completă.
  • Upload automat în TestFlight: Acțiunea apple-actions/upload-testflight-build folosește App Store Connect API, eliminând necesitatea altool sau xcrun. Sincer, e mult mai simplu decât metoda veche.

Workflow-ul Principal: Orchestrare și Versionare

Acum vine partea în care conectăm totul. Workflow-ul principal orchestrează cele două pipeline-uri și gestionează versionarea automată. Creează .github/workflows/release.yml:

name: Release Pipeline

on:
  push:
    branches: [main]
    paths-ignore:
      - "**/*.md"
      - ".github/ISSUE_TEMPLATE/**"
  workflow_dispatch:
    inputs:
      version:
        description: "Versiune release (ex: 1.2.0)"
        required: true
        type: string

jobs:
  set-version:
    runs-on: ubuntu-latest
    outputs:
      build-version: ${{ steps.version.outputs.version }}
      build-number: ${{ steps.version.outputs.build-number }}
    steps:
      - name: Calcul versiune
        id: version
        run: |
          if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
            echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT
          else
            echo "version=1.0.0" >> $GITHUB_OUTPUT
          fi
          echo "build-number=${{ github.run_number }}" >> $GITHUB_OUTPUT

  android:
    needs: set-version
    uses: ./.github/workflows/build-android.yml
    with:
      project-path: ./src/MyApp/MyApp.csproj
      build-version: ${{ needs.set-version.outputs.build-version }}
      build-number: ${{ needs.set-version.outputs.build-number }}
    secrets: inherit

  ios:
    needs: set-version
    uses: ./.github/workflows/build-ios.yml
    with:
      project-path: ./src/MyApp/MyApp.csproj
      build-version: ${{ needs.set-version.outputs.build-version }}
      build-number: ${{ needs.set-version.outputs.build-number }}
    secrets: inherit

Detaliul cheie aici e build-number: ${{ github.run_number }}. GitHub incrementează automat run_number la fiecare execuție a workflow-ului, ceea ce garantează un număr unic per build. Asta e o condiție obligatorie atât pentru Google Play cât și pentru App Store — dacă trimiți două build-uri cu același număr, ambele platforme le vor respinge.

Gestionarea Erorilor Frecvente

Asta e probabil secțiunea cea mai utilă din întreg ghidul. Din experiența configurării pipeline-urilor CI/CD pentru .NET MAUI, iată problemele pe care le vei întâlni (aproape garantat) și cum le rezolvi:

Eroarea NETSDK1083: RuntimeIdentifier nerecunoscut

Dacă vezi NETSDK1083: The specified RuntimeIdentifier 'win10-x64' is not recognized, înseamnă că proiectul folosește un RID depreciat. În .NET 10 trebuie să folosești RID-uri portabile. Înlocuiește win10-x64 cu win-x64 în fișierul .csproj. Simplu, dar frustrant când nu știi de unde vine eroarea.

Build iOS eșuează cu „No signing certificate found"

Asta e clasic. Verifică următoarele: certificatul P12 nu a expirat, provisioning profile-ul corespunde Bundle ID-ului din .csproj, secretul IOS_P12_CERTIFICATE_BASE64 a fost generat corect (fără caractere newline suplimentare). Pentru a regenera corect:

# Codificare fără line breaks
base64 -i certificate.p12 | tr -d '\n' > cert-base64.txt

Cele mai multe probleme de semnare iOS vin de la newline-uri adăugate accidental în string-ul Base64. Acel tr -d '\n' e important.

Workload-ul MAUI nu se instalează

Adaugă flag-ul --ignore-failed-sources la comanda de instalare. Uneori, feed-urile NuGet terțe configurate local pot cauza erori pe runner:

dotnet workload install maui --ignore-failed-sources

Build-ul durează prea mult

Pipeline-urile .NET MAUI pot fi lente. N-o să mint — primele build-uri pot dura și 20-25 de minute. Câteva optimizări care ajută semnificativ:

  • Cache NuGet: Adaugă actions/cache pentru directorul ~/.nuget/packages
  • Dezactivează AOT pe CI: Compilarea AOT adaugă minute bune și nu e necesară pentru majoritatea build-urilor
  • Folosește dotnet restore separat: Permite caching-ul dependențelor între rulări
- name: Cache NuGet
  uses: actions/cache@v4
  with:
    path: ~/.nuget/packages
    key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
    restore-keys: |
      ${{ runner.os }}-nuget-

Cu caching activat, am reușit să reduc timpul de build de la ~18 minute la ~12 minute pe un proiect mediu. Nu e o diferență enormă, dar se adună.

Upload Automat în Google Play cu Fastlane

Pentru a completa automatizarea pe Android, poți adăuga un pas de upload în Google Play Console folosind Fastlane. Adaugă la finalul pipeline-ului Android:

- name: Setup Ruby
  uses: ruby/setup-ruby@v1
  with:
    ruby-version: "3.2"

- name: Instalare Fastlane
  run: gem install fastlane

- name: Upload în Google Play (Internal Testing)
  env:
    SUPPLY_JSON_KEY_DATA: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
  run: |
    fastlane supply \
      --aab $(find . -name "*.aab" -print -quit) \
      --track internal \
      --package_name "com.compania.aplicatia" \
      --json_key_data "$SUPPLY_JSON_KEY_DATA"

Ai nevoie de un Service Account în Google Cloud Console cu acces la Google Play Developer API, iar JSON-ul cheii trebuie stocat ca secret GitHub (GOOGLE_PLAY_SERVICE_ACCOUNT_JSON). Configurarea inițială a Service Account-ului e puțin laborioasă (trebuie creat în Google Cloud Console, apoi legat în Google Play Console), dar merită efortul.

Optimizări Avansate

Build-uri Condiționale pe Platformă

Nu are sens să rulezi build-ul iOS dacă ai modificat doar un fișier specific Android. Și invers. Folosește paths filter-e:

on:
  push:
    branches: [main]
    paths:
      - "src/**"
      - "!src/**/Platforms/iOS/**"  # Exclude modificări doar-iOS

Asta economisește minute prețioase (și bani, pe runner-ele macOS).

Notificări la Finalizare

Adaugă un pas final care trimite notificări echipei prin Slack sau Microsoft Teams. Un build reușit care nu e comunicat echipei e practic un build uitat:

- name: Notificare Slack
  if: success()
  uses: slackapi/slack-github-action@v2
  with:
    webhook: ${{ secrets.SLACK_WEBHOOK }}
    webhook-type: incoming-webhook
    payload: |
      {
        "text": "Build v${{ needs.set-version.outputs.build-version }} (#${{ github.run_number }}) finalizat cu succes pentru Android și iOS."
      }

Întrebări Frecvente

Pot rula build-uri iOS fără un Mac fizic?

Da — GitHub Actions oferă runner-e macOS cloud, deci nu ai nevoie de un Mac propriu. Totuși, runner-ele macOS GitHub costă de 10 ori mai mult per minut decât cele Linux. Pentru proiecte open-source, GitHub oferă minute gratuite și pe macOS, dar pentru proiecte private, ține cont de costuri. Alternativ, poți folosi servicii precum MacStadium sau AWS EC2 Mac pentru runner-e self-hosted.

Cum creez un keystore Android dacă nu am încă unul?

Folosește utilitarul keytool care vine cu JDK:

keytool -genkey -v -keystore release.keystore -alias app-key -keyalg RSA -keysize 2048 -validity 10000

Păstrează keystore-ul și parolele într-un loc sigur — dacă le pierzi, nu mai poți publica actualizări pentru aceeași aplicație pe Google Play. Serios, nu exagerăm — Google nu oferă nicio metodă de recuperare.

De ce trebuie să folosesc RuntimeIdentifier-e portabile în .NET 10?

Începând cu .NET 8, SDK-ul folosește un graf RID simplificat care nu mai recunoaște identificatori specifici versiunii de OS (de exemplu, win10-x64). În .NET 10, graful vechi nu mai e actualizat deloc. Folosește win-x64, win-arm64 etc. Dacă proiectul tău referențiază RID-uri vechi, vei primi eroarea NETSDK1083.

Pot folosi Azure DevOps în loc de GitHub Actions?

Absolut. Microsoft oferă pipeline-uri oficiale de referință pentru .NET MAUI în Azure DevOps, cu aceeași structură: un pipeline pe macOS pentru iOS și Mac Catalyst, altul pe Windows pentru Android și Windows. Comenzile dotnet build și dotnet publish rămân identice — doar sintaxa YAML diferă.

Cât durează un build complet CI/CD pentru .NET MAUI?

Un build Android tipic durează 5-10 minute, iar unul iOS 10-20 minute (incluzând setup-ul Xcode). Cu caching NuGet activat, poți reduce cu 2-3 minute. Compilarea AOT poate adăuga încă 5-10 minute — dezactiveaz-o pe CI dacă nu e strict necesară pentru tine.

Despre Autor Editorial Team

Our team of expert writers and editors.