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șinet10.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 Base64ANDROID_KEYSTORE_PASSWORD— parola keystore-uluiANDROID_KEY_ALIAS— alias-ul cheii de semnareANDROID_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 Base64IOS_P12_PASSWORD— parola certificatului .p12IOS_PROVISIONING_PROFILE_BASE64— provisioning profile codificat ca Base64APPSTORE_ISSUER_ID— Issuer ID din App Store Connect APIAPPSTORE_KEY_ID— Key ID din App Store Connect APIAPPSTORE_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, darwindows-latestvine 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 keystorecuif: 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 (numacos-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-buildfolosește App Store Connect API, eliminând necesitateaaltoolsauxcrun. 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/cachepentru directorul~/.nuget/packages - Dezactivează AOT pe CI: Compilarea AOT adaugă minute bune și nu e necesară pentru majoritatea build-urilor
- Folosește
dotnet restoreseparat: 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.