App Version and Build Number Automation in .NET MAUI 10: iOS and Android CI/CD (2026)

Drive .NET MAUI 10 ApplicationDisplayVersion and ApplicationVersion from Git history, then inject them at build time so TestFlight and Play Console stop rejecting your uploads. Full GitHub Actions and Azure DevOps recipes inside.

.NET MAUI 10 Version Automation Guide (2026)

Updated: September 17, 2026

To automate app version and build numbers in .NET MAUI 10, drive both ApplicationDisplayVersion and ApplicationVersion from a single source of truth (usually Git tags plus commit height) using Nerdbank.GitVersioning or MinVer, then inject the values into your .csproj during CI so iOS CFBundleVersion and Android versionCode stay monotonic across TestFlight and Play Console. Manual version bumps in <ApplicationVersion> are, honestly, the number-one reason MAUI builds get rejected on upload. Automating this makes the problem disappear.

  • ApplicationDisplayVersion maps to CFBundleShortVersionString on iOS and versionName on Android (the human-readable "1.4.2" string).
  • ApplicationVersion maps to CFBundleVersion on iOS and versionCode on Android, the integer that must strictly increase per store upload.
  • Android versionCode is a 32-bit signed integer capped at 2,100,000,000 by Google Play. Plan a scheme that will not overflow in five years.
  • TestFlight rejects any build whose CFBundleVersion is not strictly greater than the last accepted build for the same CFBundleShortVersionString.
  • Nerdbank.GitVersioning, MinVer, and GitVersion all read Git history and produce SemVer 2.0 output that MSBuild can inject with zero developer effort.
  • Never commit the generated version back into your .csproj. Overwrite it at build time from CI environment variables instead.

How .NET MAUI 10 version properties map to iOS and Android

Most teams skip this part, and then two weeks later they're debugging a mysterious TestFlight rejection at 11pm. I've been there. .NET MAUI 10 exposes two MSBuild properties in the .csproj that the build targets translate into the correct native fields for each platform:

<PropertyGroup>
  <!-- The human-readable version, e.g. "1.4.2" -->
  <ApplicationDisplayVersion>1.4.2</ApplicationDisplayVersion>

  <!-- The integer build number, MUST strictly increase per store upload -->
  <ApplicationVersion>142</ApplicationVersion>
</PropertyGroup>

At publish time the MAUI targets rewrite the platform manifests for you. On iOS, ApplicationDisplayVersion is emitted into Info.plist as CFBundleShortVersionString, and ApplicationVersion becomes CFBundleVersion. On Android, they land in AndroidManifest.xml as android:versionName and android:versionCode. That mapping is documented in the official Microsoft .NET MAUI deployment guide and hasn't changed between .NET 8 and .NET 10.

Here's the trap. If you hard-code these in your .csproj and commit them, you're one merge conflict away from shipping the wrong version. Worse, if two developers ship builds from different branches without bumping the integer, both TestFlight and Play Console will reject the second upload with an error message that isn't obvious. Automate this from day one, even for a solo project.

What is the difference between versionCode and versionName?

versionCode is the machine-readable integer the operating system and app store use to compare builds. versionName is the string humans see. On both platforms, only the integer matters for update logic and store validation. You can ship "1.4.2" a hundred times as long as the integer keeps climbing, and users will never see the difference. Understanding this split is the whole game.

Android versionCode limits

Android's versionCode is a 32-bit signed integer, so the hard ceiling is 2,147,483,647. Google Play tightens that to 2,100,000,000 to leave room for split APKs and instant apps. If you use a Unix timestamp (a tempting shortcut) you'll burn through this ceiling in 2036. Pick a scheme like MMmmpprr (major, minor, patch, revision) or commit-height-per-release, and you're safe for decades.

iOS CFBundleVersion rules

iOS is stricter and more forgiving in different ways. CFBundleVersion is a dotted string like 142 or 1.4.2.5, but TestFlight compares them numerically per CFBundleShortVersionString. You can reset the build number when you bump the marketing version, but you can't reuse a build number within the same marketing version. See Apple's CFBundleVersion reference for the exact rules.

Git-based versioning with Nerdbank.GitVersioning

The DevOps rule of thumb here: your version should be a pure function of Git state. Same commit, same tag, same version, every time, on every machine. Three .NET-friendly tools do this well: Nerdbank.GitVersioning (Microsoft-maintained, powerful), MinVer (dead simple, tag-driven), and GitVersion (config-heavy, cross-language). I lean toward Nerdbank.GitVersioning for MAUI because it plays nicely with MSBuild and doesn't require post-processing scripts.

Install once at the repo root:

dotnet tool install --global nbgv
cd MyMauiApp
nbgv install

This creates version.json next to your solution. Edit it to match the release branch you want tracked:

{
  "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
  "version": "1.4",
  "publicReleaseRefSpec": [
    "^refs/heads/main$",
    "^refs/heads/release/.+$"
  ],
  "cloudBuild": {
    "buildNumber": { "enabled": true }
  }
}

Add the MSBuild package to your MAUI project (not the tool, the package):

<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115" PrivateAssets="all" />

Now every build produces a deterministic version. On main, commit height 42 gives you 1.4.42. On a feature branch, you get 1.4.42-alpha.g<shortsha>. The package exposes properties like $(NBGV_SimpleVersion) and $(NBGV_VersionHeight) that you can pipe straight into the MAUI version fields.

Injecting versions at build time with MSBuild

So, let's wire the generated version into the MAUI properties. Delete any hard-coded ApplicationDisplayVersion or ApplicationVersion from your .csproj and add a target that runs after Nerdbank.GitVersioning populates its properties:

<Target Name="SetMauiVersionsFromNbgv"
        BeforeTargets="_GenerateAppxManifest;_ComputeApplicationVersion"
        DependsOnTargets="GetBuildVersion">
  <PropertyGroup>
    <ApplicationDisplayVersion>$(NBGV_SimpleVersion)</ApplicationDisplayVersion>
    <ApplicationVersion>$(NBGV_VersionHeight)</ApplicationVersion>
  </PropertyGroup>
  <Message Text="MAUI version: $(ApplicationDisplayVersion) build $(ApplicationVersion)"
           Importance="high" />
</Target>

The trick here is DependsOnTargets="GetBuildVersion". That's Nerdbank's target that hydrates all the $(NBGV_*) properties. Without it, the values will be empty on the first evaluation pass. If you prefer MinVer, swap for $(MinVerVersion) and $(MinVerBuildMetadata), and change the DependsOnTargets to MinVer.

You can also override from the CI command line without touching the file, which is my preference for hotfix branches:

dotnet publish -f net10.0-ios \
  -c Release \
  -p:ApplicationDisplayVersion=1.4.2 \
  -p:ApplicationVersion=$BUILD_NUMBER

Automating iOS and Android builds in GitHub Actions

Here's the pipeline shape I ship on every MAUI project. The runner checks out with full Git history (Nerdbank needs it), computes the version, and publishes for both platforms. Notice we never write to the repo. The version exists only for this build.

name: mobile-release

on:
  push:
    tags: ['v*']
  workflow_dispatch:

jobs:
  build:
    runs-on: macos-15
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # nbgv needs full history

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

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

      - name: Compute version
        id: v
        run: |
          dotnet tool install --global nbgv
          echo "display=$(nbgv get-version -v SimpleVersion)" >> $GITHUB_OUTPUT
          echo "build=$(nbgv get-version -v VersionHeight)" >> $GITHUB_OUTPUT

      - name: Publish iOS
        run: |
          dotnet publish src/MyApp/MyApp.csproj \
            -f net10.0-ios -c Release \
            -p:ApplicationDisplayVersion=${{ steps.v.outputs.display }} \
            -p:ApplicationVersion=${{ steps.v.outputs.build }} \
            -p:ArchiveOnBuild=true

      - name: Publish Android
        run: |
          dotnet publish src/MyApp/MyApp.csproj \
            -f net10.0-android -c Release \
            -p:ApplicationDisplayVersion=${{ steps.v.outputs.display }} \
            -p:ApplicationVersion=${{ steps.v.outputs.build }} \
            -p:AndroidPackageFormat=aab

The fetch-depth: 0 line is the one everyone forgets. GitHub Actions defaults to a shallow clone, which means Nerdbank sees a commit height of 1 and every build is 1.4.1. If you see that number twice in a row, check your checkout step first. For the full CI story around certificates and provisioning, pair this with my write-up on code signing .NET MAUI apps in CI/CD.

Azure DevOps YAML for the same job

Azure Pipelines gets you the same result with a slightly different vocabulary. The checkout step defaults to a shallow clone here too, so we explicitly disable it. The NBGV task from the Nerdbank marketplace publishes variables you can reference by name.

trigger:
  tags:
    include: ['v*']

pool:
  vmImage: 'macos-15'

steps:
  - checkout: self
    fetchDepth: 0

  - task: UseDotNet@2
    inputs:
      version: '10.0.x'

  - script: dotnet workload install maui
    displayName: Install MAUI workload

  - task: nbgv@0
    displayName: Compute version

  - script: |
      dotnet publish src/MyApp/MyApp.csproj \
        -f net10.0-ios -c Release \
        -p:ApplicationDisplayVersion=$(NBGV_SimpleVersion) \
        -p:ApplicationVersion=$(NBGV_VersionHeight) \
        -p:ArchiveOnBuild=true
    displayName: Publish iOS

  - script: |
      dotnet publish src/MyApp/MyApp.csproj \
        -f net10.0-android -c Release \
        -p:ApplicationDisplayVersion=$(NBGV_SimpleVersion) \
        -p:ApplicationVersion=$(NBGV_VersionHeight) \
        -p:AndroidPackageFormat=aab
    displayName: Publish Android

If you already automate uploads with Fastlane, feed the same variables into your lanes. My Fastlane with .NET MAUI guide shows the plumbing for pilot and supply so the same version reaches TestFlight and the Play Console internal track without further intervention.

TestFlight and Play Console rules that break naive automation

The stores enforce constraints that a fresh automation script will trip over on the very first release. I've seen every one of these in production. Save yourself the pager alerts by understanding them upfront.

TestFlight monotonicity per marketing version

CFBundleVersion must be strictly greater than the last accepted build for the same CFBundleShortVersionString. If you release 1.4.2 build 42, you cannot then upload 1.4.2 build 42 again, even to a different TestFlight lane. You can reset to build 1 when you go to 1.4.3, but if you use commit height across releases you avoid the reset entirely.

Play Console versionCode ceiling and step size

Google Play doesn't require a specific increment, but each versionCode must be unique across all tracks for that package. It's also capped at 2,100,000,000. If you compose the version code from parts (say major * 10000000 + minor * 100000 + patch * 1000 + build), verify the math will not overflow. A single miscounted zero has burned entire release schedules.

iOS build number format oddities

iOS accepts 1.4.2.42 as well as 42. Both work, but if you mix them across releases the numeric comparison behaves unpredictably. Pick one and stick with it. In my last project I defaulted to a single integer for simplicity and used the semantic version only in CFBundleShortVersionString, which kept the compare rules trivial.

Common gotchas and how to avoid them

The failure modes below are the ones I've personally debugged more than once. Each has a five-line fix once you know where to look.

Shallow clones producing constant version 1.0.1

GitHub Actions, Azure Pipelines, and GitLab CI all default to shallow clones. Nerdbank.GitVersioning and MinVer both compute version from commit height, so a depth-1 checkout produces the same number forever. Set fetch-depth: 0 (Actions), fetchDepth: 0 (Azure), or GIT_DEPTH: 0 (GitLab).

Version resets on squash merges

If your team squash-merges every PR, commit height still increases by one per merge. If you rebase-merge with many commits, height jumps. Neither is wrong, but pick one and educate the team, or you'll get inconsistent version bumps between releases. Combine with CI/CD for .NET MAUI policies to enforce it.

Local dotnet build emits a pre-release version

Nerdbank labels non-publicReleaseRefSpec branches with a -alpha.gSHA suffix. That's correct behavior, and it prevents you from accidentally publishing a dev build. But it can confuse people who see 1.4.42-alpha.gabc1234 in their local artefact and think something is broken. Document it in the README.

Info.plist overrides winning silently

If you have a legacy Platforms/iOS/Info.plist with hard-coded CFBundleShortVersionString and CFBundleVersion, those values override the MSBuild properties on some MAUI versions. Delete the keys entirely from Info.plist and let the build inject them. This bit me on a migrated Xamarin.Forms project. The plist file lingered untouched, and it took an hour of head-scratching to notice.

Version drift between platform targets

If you build iOS on one machine and Android on another, and both compute the version independently, a merge between the two runs can produce different numbers. Compute the version once, publish it as a build artifact or pipeline variable, then reuse it for every target framework. The GitHub Actions example above already does this via steps.v.outputs.

Frequently Asked Questions

How do I set the version number in .NET MAUI?

Set <ApplicationDisplayVersion> (the human-readable string like 1.4.2) and <ApplicationVersion> (the integer build number) inside a <PropertyGroup> in your MAUI .csproj. MAUI writes them into Info.plist and AndroidManifest.xml automatically at publish time.

What is ApplicationDisplayVersion in .NET MAUI?

ApplicationDisplayVersion is the MSBuild property that maps to iOS CFBundleShortVersionString and Android versionName. It's the version users see in App Store listings and Settings, and it should follow SemVer (e.g. 1.4.2).

How do I auto-increment the build number for iOS?

Compute the build number from Git commit height using Nerdbank.GitVersioning or MinVer, then pass it to dotnet publish as -p:ApplicationVersion=$BUILD_NUMBER. Don't commit the incremented value back to your repo. Regenerate it fresh in CI.

Can I use GitVersion with .NET MAUI 10?

Yes. GitVersion works fine with MAUI as long as your MSBuild target reads $(GitVersion_MajorMinorPatch) for ApplicationDisplayVersion and either $(GitVersion_CommitsSinceVersionSource) or a computed integer for ApplicationVersion. Nerdbank.GitVersioning integrates a bit more smoothly with MSBuild, but the choice is preference.

What happens if I upload the same build number to TestFlight twice?

TestFlight rejects the second upload with an error indicating the CFBundleVersion must be greater than the previously uploaded value for that CFBundleShortVersionString. The build never appears in the TestFlight console. The fix is either to bump the integer or bump the marketing version and reset. Automation prevents this by design.

Sofia Rodriguez
About the Author Sofia Rodriguez

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