Fastlane pentru .NET MAUI 2026: Automatizare Semnare, TestFlight și Google Play

Ghid complet pentru automatizarea semnării și publicării unei aplicații .NET MAUI cu fastlane: match pentru certificate iOS, pilot pentru TestFlight, supply pentru Google Play, plus integrare GitHub Actions.

Fastlane .NET MAUI 2026: Ghid Complet

Actualizat: 18 august 2026

Fastlane pentru .NET MAUI este un set de instrumente Ruby open-source care automatizează semnarea, versionarea și publicarea aplicațiilor iOS și Android generate de dotnet publish, eliminând pașii manuali din Xcode și Google Play Console. Cele mai multe echipe sar peste configurarea fastlane la începutul proiectului, iar apoi ajung să piardă zile întregi la fiecare release cu certificate expirate, provisioning profiles greșite și build numbers duplicate. Am pățit-o eu însumi pe un proiect MAUI 8 anul trecut, când un CFBundleVersion duplicat mi-a blocat un release critic cu 30 de minute înainte de o demonstrație pentru client. În ghidul de față arăt pipeline-ul complet: match pentru certificate, pilot pentru TestFlight, supply pentru Google Play și integrarea cu GitHub Actions.

  • Fastlane 2.222+ funcționează perfect cu .NET MAUI 9 dacă apelezi dotnet publish înainte de gym sau gradle.
  • fastlane match stochează certificatele iOS într-un repo Git privat criptat, eliminând nevoia sincronizării manuale între developeri și CI runner-e.
  • App Store Connect API Key (issuer ID + key ID + .p8) înlocuiește parola Apple ID și evită prompt-urile 2FA în pipeline.
  • fastlane supply încarcă AAB-ul pe Google Play folosind un service account JSON, așa că nu mai e nevoie să deschizi consola manual.
  • Într-un GitHub Actions runner macOS, un release complet iOS plus Android durează aproximativ 14 minute pentru un proiect MAUI mediu.
  • Versioning automat prin increment_build_number previne coliziunile de CFBundleVersion care blochează upload-ul în App Store.

Ce este fastlane și cum funcționează cu .NET MAUI?

Fastlane este un CLI Ruby creat inițial de Felix Krause și menținut acum de Google, care wrap-ează peste 400 de „actions" pentru automatizarea sarcinilor de mobile release engineering. Filozofia lui e simplă: fiecare pas manual pe care îl faci în Xcode, Transporter, App Store Connect sau Play Console poate fi descris într-un fișier Fastfile și rulat identic pe laptopul unui developer, într-un runner de CI, sau pe un Mac mini vechi din colț.

Pentru .NET MAUI 9, fastlane nu înlocuiește dotnet publish. Îl completează. Fluxul arată așa: dotnet publish -f net9.0-ios -c Release produce un fișier .ipa (sau folder .app), iar apoi acțiunile fastlane (match, gym, pilot, deliver) preiau artefactul, îl semnează și îl încarcă. Pentru Android, dotnet publish -f net9.0-android -c Release generează AAB-ul semnat cu keystore-ul tău, iar supply îl trimite la Google Play. E mult mai flexibil decât alternativele integrate în IDE. De exemplu, publish direct din Visual Studio nu îți permite să programezi rollout gradual sau să gestionezi mai multe tracks în paralel.

Concurenții principali sunt .NET MAUI publish targets integrate, Bitrise Steps și Codemagic workflows. Fastlane câștigă când vrei portabilitate: același Fastfile merge pe GitHub Actions, Azure Pipelines, sau local, fără să depinzi de un vendor. Dacă deja rulezi pipeline CI/CD MAUI pe GitHub Actions, fastlane se integrează în 3-4 pași în workflow-ul YAML existent.

Instalarea și configurarea inițială

Prerequisite: Ruby 3.1+ (folosește rbenv sau chruby, nu Ruby-ul de sistem macOS), Xcode Command Line Tools, .NET 9 SDK cu workload-urile maui-ios și maui-android, plus Bundler. Cele mai multe echipe sar peste bundler și instalează fastlane global, iar apoi când CI-ul e pe altă versiune de gem obții surprize la fiecare release. Sincer, este cel mai simplu detaliu care îți salvează o săptămână de „merge la mine, nu merge pe CI".

# La root-ul proiectului .NET MAUI
mkdir fastlane
cd fastlane
bundle init

# Editează Gemfile
echo 'source "https://rubygems.org"' > Gemfile
echo 'gem "fastlane", "~> 2.222"' >> Gemfile
echo 'gem "cocoapods"' >> Gemfile

bundle install --path vendor/bundle
bundle exec fastlane init

La fastlane init alege opțiunea „Manual setup", pentru că variantele automate presupun un proiect Xcode nativ și nu detectează corect structura MAUI. Vei termina cu două fișiere: Appfile (bundle ID plus team ID) și Fastfile (lane-urile efective). Adaugă și un .env.default pentru variabilele publice și un .env.secret pentru chei (adăugat obligatoriu la .gitignore).

# fastlane/Appfile
app_identifier "com.acme.mauishop"
apple_id ENV["APPLE_ID"]
team_id ENV["APPLE_TEAM_ID"]
itc_team_id ENV["APP_STORE_CONNECT_TEAM_ID"]

for_platform :android do
  package_name "com.acme.mauishop"
  json_key_file "fastlane/google-play-service-account.json"
end

Fastlane match: certificate iOS partajate

Match rezolvă cea mai enervantă problemă în signing iOS: sincronizarea certificatelor și a provisioning profiles între developeri și mașini CI. În loc să exportați manual din Keychain, match creează un repo Git privat (sau bucket S3/GCS) care stochează certificatele criptate cu OpenSSL AES-256-CBC. Fiecare mașină rulează bundle exec fastlane match și primește exact ce are nevoie.

Setup-ul inițial (o singură dată per organizație):

# Creează un repo privat separat: acme-certificates.git
bundle exec fastlane match init
# Alegi „git" ca storage și pui URL-ul repo-ului

Adaugă în Fastfile lane-urile pentru development și App Store:

# fastlane/Fastfile
default_platform(:ios)

platform :ios do
  desc "Sync development certificates"
  lane :sync_dev_certs do
    match(
      type: "development",
      app_identifier: "com.acme.mauishop",
      readonly: is_ci
    )
  end

  desc "Sync App Store certificates"
  lane :sync_appstore_certs do
    match(
      type: "appstore",
      app_identifier: "com.acme.mauishop",
      readonly: is_ci,
      api_key_path: "fastlane/appstore_connect_key.json"
    )
  end
end

Parametrul readonly: is_ci este critic. Pe CI, runner-ul nu are voie să creeze certificate noi (ar cauza revocarea celor active). Doar developer-ul care rulează lane-ul fără readonly pe laptop poate crea sau regenera. Aceasta e o convenție de siguranță pe care echipele o descoperă mereu prea târziu.

Pentru App Store Connect API, generează o cheie din App Store Connect → Users and Access → Keys, descarcă fișierul .p8, notează Issuer ID și Key ID. Împachetează-le într-un JSON:

{
  "key_id": "ABC123XYZ",
  "issuer_id": "69a6de70-...-...",
  "key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----",
  "in_house": false
}

Build iOS pentru MAUI cu gym

Aici apare bucla specifică pentru .NET MAUI: gym (aka build_app) e scris pentru proiecte .xcodeproj sau .xcworkspace, dar MAUI generează .ipa-ul direct prin MSBuild. Soluția pe care o folosesc în producție este să sar peste gym și să apelez dotnet publish printr-o acțiune sh, apoi să încarc rezultatul cu upload_to_testflight direct.

# fastlane/Fastfile (continuă în platform :ios do)

desc "Build .NET MAUI iOS Release IPA"
lane :build_ios do
  sync_appstore_certs

  # Calculează build number din commit count
  build_num = number_of_commits

  sh(
    "cd .. && dotnet publish src/AcmeShop/AcmeShop.csproj " \
    "-f net9.0-ios " \
    "-c Release " \
    "/p:ArchiveOnBuild=true " \
    "/p:ApplicationVersion=#{build_num} " \
    "/p:ApplicationDisplayVersion=1.4.0 " \
    "/p:CodesignKey='Apple Distribution: Acme Inc (TEAMID)' " \
    "/p:CodesignProvision='match AppStore com.acme.mauishop' " \
    "/p:RuntimeIdentifier=ios-arm64"
  )

  # IPA-ul apare aici după publish
  ipa_path = "../src/AcmeShop/bin/Release/net9.0-ios/ios-arm64/publish/AcmeShop.ipa"
  ENV["IPA_OUTPUT_PATH"] = ipa_path
end

Cheia sunt cei doi parametri MSBuild. CodesignKey trebuie să conțină numele exact al identității din Keychain (rulează security find-identity -v -p codesigning pentru a-l lista), iar CodesignProvision trebuie să corespundă cu numele profile-ului instalat de match (formatul e mereu match {Type} {BundleId}). Dacă folosești un bundle ID diferit între dev și prod (frecvent pentru build-uri interne), fiecare va avea propriul provisioning profile.

Publicare TestFlight cu pilot

Odată ce ai IPA-ul, pilot (aka upload_to_testflight) încarcă build-ul în TestFlight, așteaptă procesarea Apple, și opțional îl distribuie unui grup de tester-i externi. Timpul de procesare la Apple e undeva între 5 și 15 minute și e complet asincron. Pilot poate aștepta sau poate ieși imediat, tu decizi.

desc "Upload to TestFlight"
lane :beta do
  build_ios

  upload_to_testflight(
    api_key_path: "fastlane/appstore_connect_key.json",
    ipa: ENV["IPA_OUTPUT_PATH"],
    skip_waiting_for_build_processing: false,
    changelog: "Bug fixes and stability improvements.\n\nBuild #{number_of_commits}",
    distribute_external: true,
    groups: ["QA Team", "Beta Testers"],
    notify_external_testers: true,
    beta_app_review_info: {
      contact_email: "[email protected]",
      contact_first_name: "Sofia",
      contact_last_name: "Release Manager",
      contact_phone: "+40712345678",
      demo_account_name: "[email protected]",
      demo_account_password: ENV["DEMO_ACCOUNT_PASSWORD"],
      notes: "Login demo cu credențialele de mai sus. Aplicația necesită internet pentru sync."
    }
  )

  slack(
    message: "TestFlight upload OK — build #{number_of_commits}",
    channel: "#mobile-releases",
    default_payloads: [:git_branch, :last_git_commit_message]
  )
end

Publicare App Store cu deliver

Pentru release-uri publice, deliver (aka upload_to_app_store) încarcă metadata (descrieri, screenshot-uri, keywords, „What's New"), asset-urile, și trimite build-ul spre review. Este disproporționat de puternic pentru cât de puțin e folosit. Echipele care nu-l adoptă redactează același „What's New" în fiecare limbă manual, la 3 dimineața.

desc "Submit to App Store review"
lane :release do
  build_ios

  upload_to_app_store(
    api_key_path: "fastlane/appstore_connect_key.json",
    ipa: ENV["IPA_OUTPUT_PATH"],
    skip_screenshots: true,
    skip_metadata: false,
    metadata_path: "./fastlane/metadata",
    force: true,
    submit_for_review: true,
    automatic_release: false,
    submission_information: {
      add_id_info_uses_idfa: false,
      export_compliance_uses_encryption: false,
      content_rights_contains_third_party_content: false
    },
    precheck_include_in_app_purchases: false
  )
end

Structura folderului metadata este strictă: câte un subfolder per locale (en-US, ro, de-DE) cu fișiere description.txt, keywords.txt, release_notes.txt, promotional_text.txt. Poți popula inițial cu bundle exec fastlane deliver download_metadata care aduce ce e deja în App Store Connect. De aici încolo, orice modificare vine prin PR în Git, deci ai audit trail complet.

Android: keystore și supply pentru Google Play

Partea Android este mai simplă pentru că .NET MAUI generează AAB-ul semnat direct dacă îi dai keystore-ul. Începe prin crearea unui keystore de producție (o dată per aplicație, backup-uit obligatoriu într-un password manager sau HSM):

keytool -genkeypair \
  -v \
  -keystore acme-release.keystore \
  -alias acme \
  -keyalg RSA \
  -keysize 4096 \
  -validity 10950 \
  -storetype PKCS12

Pentru service account-ul Google Play, mergi la Google Play Console → Setup → API access, creează un nou service account în Google Cloud, dă-i rolul „Release Manager" în Play Console, și descarcă JSON key-ul. Trebuie să aștepți aproximativ 24h până când permisiunile propagă înainte de primul upload. Da, exact 24 de ore. Am încercat să grăbesc procesul și n-a mers.

# fastlane/Fastfile
platform :android do
  desc "Build .NET MAUI Android AAB"
  lane :build_android do
    build_num = number_of_commits

    sh(
      "cd .. && dotnet publish src/AcmeShop/AcmeShop.csproj " \
      "-f net9.0-android " \
      "-c Release " \
      "/p:AndroidPackageFormats=aab " \
      "/p:ApplicationVersion=#{build_num} " \
      "/p:ApplicationDisplayVersion=1.4.0 " \
      "/p:AndroidKeyStore=true " \
      "/p:AndroidSigningKeyStore=#{ENV['KEYSTORE_PATH']} " \
      "/p:AndroidSigningKeyAlias=acme " \
      "/p:AndroidSigningKeyPass=#{ENV['KEYSTORE_PASSWORD']} " \
      "/p:AndroidSigningStorePass=#{ENV['KEYSTORE_PASSWORD']}"
    )

    ENV["AAB_OUTPUT_PATH"] =
      "../src/AcmeShop/bin/Release/net9.0-android/publish/com.acme.mauishop-Signed.aab"
  end

  desc "Upload to Google Play internal track"
  lane :beta do
    build_android

    upload_to_play_store(
      package_name: "com.acme.mauishop",
      aab: ENV["AAB_OUTPUT_PATH"],
      track: "internal",
      release_status: "completed",
      skip_upload_apk: true,
      skip_upload_metadata: false,
      skip_upload_changelogs: false,
      skip_upload_images: true,
      skip_upload_screenshots: true,
      json_key: "fastlane/google-play-service-account.json"
    )
  end

  desc "Promote from internal to production with staged rollout"
  lane :promote do
    upload_to_play_store(
      package_name: "com.acme.mauishop",
      track: "internal",
      track_promote_to: "production",
      rollout: "0.1",  # 10% rollout inițial
      json_key: "fastlane/google-play-service-account.json"
    )
  end
end

Pattern-ul cu track-uri (internalalphabetaproduction) plus track_promote_to îți permite să promovezi același AAB între canale fără să rebuilzi. Economisești timp și riști mai puțin să introduci diferențe între ce ai testat și ce ajunge la utilizatori. Vezi și strategia de testing pentru .NET MAUI pentru validarea build-urilor înainte de promovare.

Integrare completă în GitHub Actions

Aici pun cap la cap tot: pipeline-ul YAML care rulează pe fiecare merge în main, împarte build-urile pe două job-uri paralele (macOS pentru iOS, Ubuntu pentru Android), și publică în TestFlight plus Google Play internal track.

# .github/workflows/release.yml
name: Release

on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  ios:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # necesar pentru number_of_commits

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 9.0.x

      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.3
          bundler-cache: true
          working-directory: fastlane

      - name: Install MAUI workloads
        run: dotnet workload install maui-ios

      - name: Write App Store Connect key
        env:
          ASC_KEY_JSON: ${{ secrets.APP_STORE_CONNECT_KEY_JSON }}
        run: echo "$ASC_KEY_JSON" > fastlane/appstore_connect_key.json

      - name: Fastlane beta (TestFlight)
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN }}
          FASTLANE_HIDE_CHANGELOG: 1
        working-directory: fastlane
        run: bundle exec fastlane ios beta

  android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 9.0.x

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 17

      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.3
          bundler-cache: true
          working-directory: fastlane

      - name: Install MAUI Android workload
        run: dotnet workload install maui-android

      - name: Restore keystore
        env:
          KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
        run: echo "$KEYSTORE_B64" | base64 -d > $HOME/acme-release.keystore

      - name: Restore Play Store key
        env:
          PLAY_KEY_JSON: ${{ secrets.GOOGLE_PLAY_JSON_KEY }}
        run: echo "$PLAY_KEY_JSON" > fastlane/google-play-service-account.json

      - name: Fastlane beta (Google Play internal)
        env:
          KEYSTORE_PATH: /home/runner/acme-release.keystore
          KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
        working-directory: fastlane
        run: bundle exec fastlane android beta

Toate secretele (MATCH_PASSWORD, service account JSON-uri, keystore base64) merg în GitHub → Settings → Secrets and variables → Actions. Pentru rotația certificatelor iOS (Apple obligă o dată/an), rulează local bundle exec fastlane match nuke distribution urmat de bundle exec fastlane match appstore. Documentează procedura într-un runbook. La 2 dimineața, când certificatele expiră, nu vrei s-o improvizezi.

Gotchas frecvente și cum le eviți

Cele mai multe echipe sar peste faza asta, iar apoi debuggează la 4 AM. Iată lista scurtă, din propria mea colecție de dureri de cap:

  • Duplicate CFBundleVersion la upload TestFlight. Rezolvi cu build number derivat din number_of_commits sau timestamp, nu manual în .csproj.
  • Match nu găsește profile-ul „match AppStore com.acme.mauishop". Verifică că CodesignProvision în MSBuild se potrivește exact. E ușor să confunzi MATCH_APPSTORE_COM_ACME (env var setat de match) cu string-ul literal, așa că folosește env-varul în MSBuild.
  • Google Play refuză AAB-ul cu „APK signing key mismatch". Play App Signing e activat și primul upload al app-ului a fost cu alt keystore. Trebuie să exporți keystore-ul original și să-l încarci ca „upload key" nou în Play Console.
  • Fastlane hang pe „Waiting for App Store Connect". Fișierul .p8 sau issuer ID greșit. Testează separat cu bundle exec fastlane run app_store_connect_api_key înainte să integrezi în lane.
  • Ruby version mismatch între dev și CI. Adaugă un fișier .ruby-version la root-ul repo-ului fastlane. GitHub Actions îl respectă automat cu ruby/setup-ruby@v1.
  • MSBuild nu găsește identitatea în Keychain pe runner-ul CI. Match instalează certificatele în Keychain-ul temporar creat automat, dar pe self-hosted runners trebuie să deblochezi Keychain-ul cu security unlock-keychain.

Pentru migrări dinspre Xamarin.Forms, vezi și ghidul de migrare Xamarin la .NET MAUI, pentru că exact configurarea signing și publish este una din diferențele majore care sparg pipeline-urile vechi. Fastlane rămâne compatibil, dar comenzile MSBuild și numele property-urilor se schimbă complet.

Întrebări frecvente

Pot folosi fastlane cu .NET MAUI fără Xcode instalat?

Nu pentru iOS, pentru că codesign, xcrun altool și SDK-urile iOS fac parte din Xcode. Poți însă folosi fastlane doar pentru Android pe Linux/Windows, unde nu ai nevoie de Xcode. Un runner Ubuntu în GitHub Actions e suficient pentru lane-urile Android.

Care e diferența între fastlane deliver și fastlane pilot?

pilot (upload_to_testflight) trimite build-uri pentru testare internă sau externă via TestFlight. deliver (upload_to_app_store) trimite build-ul plus metadata către review pentru release public în App Store. Un release complet le folosește pe ambele: pilot pentru staging, deliver pentru production.

Fastlane match îmi șterge certificatele existente?

Doar dacă rulezi fastlane match nuke explicit. Un fastlane match appstore normal reutilizează certificatele existente dacă sunt valide și în repo. Match revocă doar atunci când tu ceri asta explicit sau când limita Apple (3 certificate per team) e atinsă.

Cum stochez secretele fastlane pentru mai mulți developeri?

Pentru MATCH_PASSWORD și API keys folosește un password manager al echipei (1Password Teams, Bitwarden Organization) cu un „shared vault" pentru mobile team. Pentru CI, folosește secretele native (GitHub Actions Secrets, Azure Key Vault). Nu partaja niciodată prin Slack sau email.

Ce fac dacă App Store Connect API returnează 401 din CI?

În 90% din cazuri e problema de escape-uire a private key-ului din .p8: caracterele \n trebuie păstrate literal în JSON-ul cheii. Rulează local bundle exec fastlane run app_store_connect_api_key cu același JSON. Dacă merge local, dar nu în CI, e problemă de secret formatting (rescrie secretul base64-encoded și decodează în workflow).

Sofia Rodriguez
Despre Autor Sofia Rodriguez

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