How to Set Up CI/CD for .NET MAUI: GitHub Actions and Azure DevOps Pipelines

Set up automated CI/CD pipelines for .NET MAUI 10 using GitHub Actions and Azure DevOps. Complete YAML workflows for Android and iOS with code signing, auto-versioning, and store deployment.

If you've ever shipped a .NET MAUI app to the Google Play Store or Apple App Store by hand, you know the pain. Juggling keystores, provisioning profiles, version bumps, and manual uploads — it's tedious and honestly kind of nerve-wracking every single time. One wrong certificate and your release is dead in the water.

A CI/CD pipeline takes all that friction away. Push your code, and the pipeline handles the rest: building, signing, and distributing your app automatically.

In this guide, we'll build production-ready pipelines for .NET MAUI 10 on both GitHub Actions and Azure DevOps. By the end, you'll have automated Android and iOS builds that sign your app, upload artifacts, and optionally deploy straight to the stores. So, let's dive in.

Why CI/CD Matters for .NET MAUI Projects

A .NET MAUI app targets multiple platforms from a single codebase, which is great — but each platform comes with its own build toolchain, signing ceremony, and distribution channel. Without automation, things start falling apart pretty quickly:

  • Inconsistent builds — different developer machines can produce different outputs depending on SDK versions and local configuration. I've seen builds work perfectly on one person's laptop and break on another's.
  • Signing mistakes — forgetting to sign with the production keystore or using an expired iOS certificate can block a release at the worst possible time.
  • Slow release cadence — manual steps add hours to every release, which discourages frequent updates.
  • No audit trail — without pipeline logs, it's really hard to trace exactly which commit produced a given store binary.

A well-designed pipeline solves all of these problems by running the same deterministic steps on every commit, storing secrets securely, and producing versioned, signed artifacts you can trace back to a specific Git SHA.

Prerequisites

Before you start, make sure you have these in place:

  • .NET 10 SDK installed locally (for testing commands before adding them to the pipeline).
  • A GitHub repository (for GitHub Actions) or an Azure DevOps project (for Azure Pipelines) with your MAUI solution checked in.
  • An Android keystore for signing release builds. We'll generate one below if you don't have it yet.
  • An Apple Developer Program membership with a Distribution certificate exported as a .p12 file and an App Store provisioning profile.
  • A Google Play Developer account with your app already created in the Play Console (the first upload always has to be done manually — there's no way around that).
  • An App Store Connect record for your iOS app, including an API key for automated uploads.

Generating and Storing Signing Credentials

Android Keystore

If you don't already have a keystore, create one with the keytool command that ships with the JDK:

keytool -genkeypair \
  -v \
  -keystore myapp-release.jks \
  -alias myapp \
  -keyalg RSA \
  -keysize 2048 \
  -validity 20000 \
  -storepass YOUR_STORE_PASSWORD \
  -keypass YOUR_KEY_PASSWORD

Set the validity to a high number of days — 20000 is roughly 54 years, so the key won't expire before you stop supporting the app. Store the keystore file and both passwords somewhere safe. You'll need the same keystore for every future update, and losing it means you can't push updates to the same Play Store listing.

To store the keystore as a CI secret, Base64-encode it:

# macOS / Linux
base64 -i myapp-release.jks -o keystore-base64.txt

# The contents of keystore-base64.txt go into your CI secret

iOS Distribution Certificate and Provisioning Profile

Export your Apple Distribution certificate from Keychain Access as a .p12 file. Then Base64-encode it the same way:

base64 -i MyCertificate.p12 -o cert-base64.txt

You'll also need an App Store Connect API key. Head to Users and Access > Integrations > App Store Connect API in App Store Connect, generate a key, and note the Key ID, Issuer ID, and the private key file content. Keep these somewhere safe — you can only download the private key once.

GitHub Actions: Android Build Pipeline

Create the file .github/workflows/build-android.yml in your repository. This workflow installs the .NET 10 SDK, restores the MAUI workload, decodes the keystore from a secret, and publishes a signed AAB artifact.

name: Build Android

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

env:
  DOTNET_VERSION: '10.0.x'
  PROJECT_PATH: 'src/MyMauiApp/MyMauiApp.csproj'

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

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

      - name: Setup Java 21
        uses: actions/setup-java@v4
        with:
          distribution: 'microsoft'
          java-version: '21'

      - name: Install MAUI workload
        run: dotnet workload install maui --ignore-failed-sources

      - name: Restore dependencies
        run: dotnet restore ${{ env.PROJECT_PATH }}

      - name: Decode keystore
        run: |
          echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" > keystore-base64.txt
          certutil -decode keystore-base64.txt myapp-release.jks

      - name: Publish signed AAB
        run: |
          dotnet publish ${{ env.PROJECT_PATH }} ^
            -c Release ^
            -f net10.0-android ^
            -p:AndroidKeyStore=true ^
            -p:AndroidSigningKeyStore=${{ github.workspace }}\myapp-release.jks ^
            -p:AndroidSigningKeyAlias=${{ secrets.ANDROID_KEY_ALIAS }} ^
            -p:AndroidSigningKeyPass=${{ secrets.ANDROID_KEY_PASSWORD }} ^
            -p:AndroidSigningStorePass=${{ secrets.ANDROID_STORE_PASSWORD }} ^
            -p:ApplicationVersion=${{ github.run_number }}

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: android-aab
          path: '**/*-Signed.aab'

A few things to note here:

  • actions/setup-java@v4 with Java 21 is required because .NET 10 Android targets API 36, which needs JDK 21.
  • The keystore gets decoded from a Base64 GitHub secret using certutil on the Windows runner.
  • ApplicationVersion is set to github.run_number, which auto-increments on every workflow run. Google Play requires a unique, increasing version code for each upload, so this is a nice fit.
  • The signed AAB gets uploaded as a build artifact so you can download it or pass it to a deployment job downstream.

GitHub Actions: iOS Build Pipeline

iOS builds need a macOS runner — there's no way around that one. Create .github/workflows/build-ios.yml:

name: Build iOS

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

env:
  DOTNET_VERSION: '10.0.x'
  PROJECT_PATH: 'src/MyMauiApp/MyMauiApp.csproj'

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

      - name: Setup Xcode
        uses: maxim-lobanov/setup-xcode@v1
        with:
          xcode-version: '16'

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

      - name: Install MAUI workload
        run: dotnet workload install maui --ignore-failed-sources

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

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

      - name: Publish signed IPA
        run: |
          dotnet publish ${{ env.PROJECT_PATH }} \
            -c Release \
            -f net10.0-ios \
            -p:ArchiveOnBuild=true \
            -p:RuntimeIdentifier=ios-arm64 \
            -p:CodesignKey="${{ secrets.IOS_SIGNING_IDENTITY }}" \
            -p:CodesignProvision="${{ secrets.IOS_PROVISION_PROFILE_NAME }}" \
            -p:ApplicationVersion=${{ github.run_number }}

      - name: Upload IPA artifact
        uses: actions/upload-artifact@v4
        with:
          name: ios-ipa
          path: '**/*.ipa'

Here's what's happening in this workflow:

  • The apple-actions/import-codesign-certs action creates a temporary keychain on the runner and imports your .p12 certificate. It handles all the keychain setup you'd otherwise have to script yourself.
  • The apple-actions/download-provisioning-profiles action uses the App Store Connect API to download matching profiles automatically — no need to manually upload profiles as secrets, which is a real time-saver.
  • ArchiveOnBuild=true tells the build system to produce the .ipa archive required for App Store distribution.
  • macOS runners cost significantly more than Windows runners. To keep costs reasonable, consider running iOS builds only on pushes to main or on release tags rather than on every PR.

GitHub Actions: Deploying to Stores

Once your build jobs produce signed artifacts, you can add deployment jobs that upload them directly to the stores. This is where the pipeline really starts paying for itself.

Google Play Deployment

Use the r0adkll/upload-google-play action to push the signed AAB to an internal test track:

  deploy-android:
    needs: build-android
    runs-on: ubuntu-latest
    steps:
      - name: Download artifact
        uses: actions/download-artifact@v4
        with:
          name: android-aab

      - name: Upload to Google Play
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }}
          packageName: com.yourcompany.myapp
          releaseFiles: '**/*-Signed.aab'
          track: internal

App Store Deployment via TestFlight

Use Apple's xcrun altool to upload the IPA to App Store Connect:

  deploy-ios:
    needs: build-ios
    runs-on: macos-15
    steps:
      - name: Download artifact
        uses: actions/download-artifact@v4
        with:
          name: ios-ipa

      - name: Upload to App Store Connect
        run: |
          xcrun altool --upload-app \
            --type ios \
            --file $(find . -name "*.ipa" -print -quit) \
            --apiKey ${{ secrets.APPSTORE_KEY_ID }} \
            --apiIssuer ${{ secrets.APPSTORE_ISSUER_ID }}

Azure DevOps: Android Pipeline

If your team uses Azure DevOps instead of GitHub, the YAML pipeline follows a similar structure but uses built-in tasks for secure file handling. Honestly, Azure DevOps handles secrets a bit more elegantly here with its Secure Files and Variable Groups features.

Create a file named azure-pipelines-android.yml:

trigger:
  branches:
    include:
      - main

pool:
  vmImage: 'windows-latest'

variables:
  - group: 'MauiAppSecrets'

steps:
  - task: UseDotNet@2
    displayName: 'Install .NET 10 SDK'
    inputs:
      packageType: 'sdk'
      version: '10.0.x'

  - task: JavaToolInstaller@0
    displayName: 'Install JDK 21'
    inputs:
      versionSpec: '21'
      jdkArchitectureOption: 'x64'
      jdkSourceOption: 'PreInstalled'

  - script: dotnet workload install maui --ignore-failed-sources
    displayName: 'Install MAUI workload'

  - task: DownloadSecureFile@1
    name: keystore
    displayName: 'Download keystore'
    inputs:
      secureFile: 'myapp-release.jks'

  - script: |
      dotnet publish src/MyMauiApp/MyMauiApp.csproj \
        -c Release \
        -f net10.0-android \
        -p:AndroidKeyStore=true \
        -p:AndroidSigningKeyStore=$(keystore.secureFilePath) \
        -p:AndroidSigningKeyAlias=$(AndroidKeyAlias) \
        -p:AndroidSigningKeyPass=$(AndroidKeyPassword) \
        -p:AndroidSigningStorePass=$(AndroidStorePassword) \
        -p:ApplicationVersion=$(Build.BuildId)
    displayName: 'Publish signed AAB'

  - task: CopyFiles@2
    displayName: 'Copy AAB to staging'
    inputs:
      SourceFolder: '$(Build.SourcesDirectory)'
      Contents: '**/*-Signed.aab'
      TargetFolder: '$(Build.ArtifactStagingDirectory)'
      flattenFolders: true

  - publish: $(Build.ArtifactStagingDirectory)
    artifact: android-aab

The main differences from GitHub Actions:

  • The keystore file gets uploaded to Pipelines > Library > Secure Files and downloaded at build time with the DownloadSecureFile@1 task. No Base64 encoding needed.
  • Passwords and aliases live in a Variable Group named MauiAppSecrets, with passwords marked as secret.
  • $(Build.BuildId) provides the auto-incrementing version code.

Azure DevOps: iOS Pipeline

Create azure-pipelines-ios.yml. This one runs on a macOS agent and uses built-in tasks to install the certificate and provisioning profile:

trigger:
  branches:
    include:
      - main

pool:
  vmImage: 'macos-15'

variables:
  - group: 'MauiAppSecrets'

steps:
  - task: UseDotNet@2
    displayName: 'Install .NET 10 SDK'
    inputs:
      packageType: 'sdk'
      version: '10.0.x'

  - script: dotnet workload install maui --ignore-failed-sources
    displayName: 'Install MAUI workload'

  - task: InstallAppleCertificate@2
    displayName: 'Install signing certificate'
    inputs:
      certSecureFile: 'AppleDistribution.p12'
      certPwd: '$(AppleCertPassword)'
      keychain: 'temp'

  - task: InstallAppleProvisioningProfile@1
    displayName: 'Install provisioning profile'
    inputs:
      provisioningProfileLocation: 'secureFiles'
      provProfileSecureFile: 'MyApp_AppStore.mobileprovision'

  - script: |
      dotnet publish src/MyMauiApp/MyMauiApp.csproj \
        -c Release \
        -f net10.0-ios \
        -p:ArchiveOnBuild=true \
        -p:RuntimeIdentifier=ios-arm64 \
        -p:CodesignKey="$(APPLE_CERTIFICATE_SIGNING_IDENTITY)" \
        -p:CodesignProvision="$(APPLE_PROV_PROFILE_UUID)" \
        -p:ApplicationVersion=$(Build.BuildId)
    displayName: 'Publish signed IPA'

  - task: CopyFiles@2
    displayName: 'Copy IPA to staging'
    inputs:
      SourceFolder: '$(Build.SourcesDirectory)'
      Contents: '**/*.ipa'
      TargetFolder: '$(Build.ArtifactStagingDirectory)'
      flattenFolders: true

  - publish: $(Build.ArtifactStagingDirectory)
    artifact: ios-ipa

A few things worth noting:

  • The InstallAppleCertificate@2 task imports the P12 certificate from Secure Files and installs it in a temporary keychain on the build agent. Clean and automatic.
  • The InstallAppleProvisioningProfile@1 task installs the provisioning profile and outputs the profile UUID and signing identity as pipeline variables — you reference those directly in the build command.
  • To deploy the IPA to App Store Connect, add the AppStoreRelease@1 task from the Apple App Store extension in the Azure DevOps Marketplace.

Auto-Incrementing Version Numbers

Both Google Play and the App Store require each uploaded binary to have a higher version code than the previous one. Hardcoding the version in your .csproj and manually bumping it every time completely defeats the purpose of automation.

The simplest approach? Tie the version code to the build system's run counter.

<!-- In your .csproj -->
<PropertyGroup>
  <ApplicationDisplayVersion>1.2.0</ApplicationDisplayVersion>
  <!-- ApplicationVersion is overridden by the pipeline -->
  <ApplicationVersion>1</ApplicationVersion>
</PropertyGroup>

Then in your pipeline, pass the override:

# GitHub Actions
-p:ApplicationVersion=${{ github.run_number }}

# Azure DevOps
-p:ApplicationVersion=$(Build.BuildId)

ApplicationDisplayVersion is the user-facing version (like 1.2.0) that you update manually when you cut a new marketing release. ApplicationVersion is the internal build number that just needs to keep going up. Simple and effective.

Managing Secrets Securely

Signing credentials are the crown jewels of your pipeline. Treat them accordingly:

  • Never commit secrets to source control. Keystores, P12 files, and passwords should only exist in your CI system's secret storage. This sounds obvious, but I've seen it happen more than once.
  • Use variable groups or secret environments. In GitHub Actions, use Repository Secrets or Environment Secrets. In Azure DevOps, use Variable Groups with the secret flag toggled on, or Azure Key Vault integration for extra security.
  • Rotate certificates before they expire. iOS distribution certificates last one year. Set a calendar reminder — you don't want to discover an expired cert when you're trying to push a hotfix at midnight.
  • Restrict access. In GitHub, scope secrets to specific environments (e.g., production) and require approvals before deployment jobs run. In Azure DevOps, gate access to variable groups by pipeline or approval.

Troubleshooting Common Pipeline Failures

Even with a well-structured pipeline, you're going to hit issues. It's just how CI/CD goes. Here are the most common failures and how to fix them.

MAUI Workload Not Found

If the build fails with an error about missing workloads, make sure you're calling dotnet workload install maui before the restore step. The MAUI workload is not pre-installed on GitHub-hosted runners or Azure DevOps agents — you have to install it every time.

Java SDK Version Mismatch (Android)

.NET 10 Android needs JDK 21. If you see error XA0031: Java SDK 11.0 or above is required, add an explicit setup-java step targeting version 21. Don't rely on whatever Java version the runner happens to have pre-installed.

iOS Signing Identity Not Found

This is probably the most frustrating error you'll encounter. It usually means the provisioning profile doesn't match the certificate, or the bundle identifier is wrong. Check these three things:

  • The CFBundleIdentifier in your project matches the App ID in the Apple Developer Portal.
  • The provisioning profile references the same distribution certificate you imported.
  • The certificate hasn't expired (they're only valid for one year).

Android Keystore Decoding Fails

When you Base64-encode the keystore, make sure there are no trailing newlines. On macOS, base64 -i produces clean output. On Linux, use base64 -w 0 to avoid line wrapping. If the decoded file is corrupt, the signing step will fail silently and produce an unsigned APK — which is really confusing to debug.

Build Timeout on macOS Runners

iOS builds with full AOT compilation can exceed 30 minutes on free-tier runners. If you're hitting timeouts, consider using a larger runner or disabling AOT for non-release builds by only passing -p:RunAOTCompilation=true on tagged releases.

Frequently Asked Questions

Can I build both Android and iOS in a single pipeline?

Yes, but they need to run on different agents. Android builds require Windows or Linux, while iOS builds require macOS. Use separate jobs in the same workflow file and let them run in parallel — this actually speeds up your pipeline and keeps everything in one place.

Do I need a Mac to build iOS apps with GitHub Actions?

You need a macOS runner, but you don't need your own Mac. GitHub provides hosted macOS runners that come with Xcode pre-installed, and Azure DevOps has Microsoft-hosted macOS agents too. Both let you build and sign iOS apps without owning Apple hardware.

How do I handle provisioning profile and certificate renewal?

Apple Distribution certificates expire after one year, and provisioning profiles must be renewed when the certificate changes. Set a recurring reminder to regenerate these credentials, export the new P12 file, Base64-encode it, and update your CI secrets.

Using the apple-actions/download-provisioning-profiles GitHub Action or the Azure DevOps InstallAppleProvisioningProfile task automates profile downloads, but you still need to update the certificate secret manually. There's no getting around that part.

What is the difference between ApplicationVersion and ApplicationDisplayVersion?

ApplicationDisplayVersion is the human-readable version string (like 2.1.0) that users see in the app store listing. ApplicationVersion is an internal integer build number that the store uses to determine whether a submission is newer than the current live version.

Your pipeline should auto-increment ApplicationVersion on every build, while ApplicationDisplayVersion gets bumped manually when you release a new user-facing version.

How much do GitHub Actions macOS runners cost?

GitHub Actions charges macOS runner minutes at 10x the rate of Linux runners. A typical iOS build takes 5 to 15 minutes, which means each build eats 50 to 150 minutes of your included allowance. That adds up fast.

To save costs, run iOS builds only on pushes to protected branches or on release tags, and stick with Linux or Windows runners for Android builds and unit tests.

About the Author Editorial Team

Our team of expert writers and editors.