Fastlane with .NET MAUI 10: Automate TestFlight and Play Store Deploys

A working guide to shipping .NET MAUI 10 apps with Fastlane: match-based iOS signing, API-key auth for TestFlight, staged Play Store rollouts, and Fastfile lanes that survive real teams.

Fastlane for .NET MAUI 10 Deploy Guide 2026

Updated: July 19, 2026

Fastlane automates the entire release path for a .NET MAUI 10 app. It builds the signed .ipa and .aab, uploads to TestFlight and the Play Store internal track, refreshes provisioning with match, and pushes localized metadata and screenshots, all from a single fastlane deploy command wired into CI. Most teams skip the Fastfile layer and then wonder why every release turns into a two-hour manual ritual involving Xcode, Transporter, and a lot of hoping the certificate hasn't expired.

  • Fastlane runs on top of your existing dotnet publish commands. You don't replace the .NET toolchain, you wrap it in Ruby lanes that handle upload, signing, and metadata.
  • fastlane match replaces the "who has the p12?" problem with a private Git repo of certificates and profiles that every machine, including CI, pulls on demand.
  • pilot uploads iOS builds to TestFlight and supply pushes Android AABs to the Play Store. Both work with API keys instead of Apple ID passwords or the deprecated Google login flow.
  • For .NET MAUI 10, the correct build command inside Fastlane is dotnet publish -f net10.0-ios and -f net10.0-android. The msbuild-based lanes like gym and build_android_app still work, but only after dotnet publish produces the archive.
  • Use App Store Connect API keys (issuer + key ID + .p8) and a Google Play service account JSON for CI. Personal credentials break the moment 2FA changes or a team member leaves.
  • Fastlane lanes should be composable and idempotent: build_ios, upload_testflight, promote_to_beta, each callable on its own, chained by top-level lanes like deploy_ios_beta.

Why Fastlane still matters for .NET MAUI in 2026

People ask me every few months whether Fastlane is dead. It isn't. Xcode Cloud, GitHub Actions marketplace steps, and App Store Connect API SDKs have all improved, but none of them wrap the full release surface (build, sign, upload, metadata, screenshots, changelog, phased rollout) in one shell-scriptable interface the way Fastlane does. For .NET MAUI 10 specifically, the value is even higher because the MAUI toolchain gives you cross-platform build via dotnet publish but leaves App Store Connect and Play Console untouched. Fastlane is what stitches those halves together.

The important 2026 change: Fastlane 2.222 and later ship native support for App Store Connect API JWTs, which is now the only supported upload path after Apple deprecated altool's password-based auth. On the Android side, Google Play's Developer API v3 is what supply talks to, and it's been stable long enough that you can trust automated staged rollouts and release-notes updates. Honestly, if you're already running CI/CD for .NET MAUI with GitHub Actions, adding Fastlane on top gives you deploy commands that work identically on a developer laptop and inside a runner.

Prerequisites and one-time setup

Before you touch a Fastfile, get the boring stuff done once. Skipping this is the number one reason first-time Fastlane pipelines fail with cryptic "unauthorized" errors halfway through a release.

  1. Install Fastlane. On macOS use Homebrew (brew install fastlane) or Bundler with a project-scoped Gemfile. I strongly prefer Bundler for CI, since it pins the version so a Fastlane point release doesn't surprise you at 3 a.m.
  2. Create an App Store Connect API key. In App Store Connect → Users and Access → Integrations → App Store Connect API, create a key with the App Manager role. Download the .p8 file (you get one chance), note the Key ID and Issuer ID.
  3. Create a Google Play service account. In Google Cloud Console create a service account, grant it access in Play Console (Users and permissions → Invite new users → the service account email), give it release management permission for your app, and download a JSON key.
  4. Create a private signing repo. This is what match will use. Empty Git repo on GitHub or your internal Git host; nothing else needs to be in it.
  5. Install Ruby via rbenv. The system Ruby that ships with macOS is old and unwritable without sudo. This bites CI later. Install a modern Ruby (3.3+) and add a .ruby-version file to the repo.

So, once that's done, initialize Fastlane in the platform-specific folders your .NET MAUI project publishes to. I keep the Fastfile at the repo root with per-platform folders, but the standard ios/ and android/ subfolder layout also works. Here's my starter Gemfile:

source "https://rubygems.org"
gem "fastlane", "~> 2.226"
gem "cocoapods", "~> 1.15"
plugins_path = File.join(File.dirname(__FILE__), "fastlane", "Pluginfile")
eval_gemfile(plugins_path) if File.exist?(plugins_path)

Building a .NET MAUI iOS app for Fastlane

The .NET MAUI build for iOS produces an .ipa in bin/Release/net10.0-ios/ios-arm64/publish/. Fastlane's gym normally invokes xcodebuild, but for MAUI the build driver is dotnet, so we call it directly and then hand the archive to Fastlane for upload only. This is the layout that has been stable across MAUI 8, 9, and 10.

# fastlane/Fastfile: iOS build lane

platform :ios do
  desc "Build a signed .NET MAUI 10 iOS release .ipa"
  lane :build_ios do |options|
    version = options[:version] || "1.0.0"
    build_number = options[:build_number] || latest_testflight_build_number(
      version: version,
      app_identifier: "com.example.myapp"
    ) + 1

    csproj = "../src/MyApp/MyApp.csproj"

    # dotnet publish drives the whole iOS build, including signing.
    sh(
      "dotnet", "publish", csproj,
      "-c", "Release",
      "-f", "net10.0-ios",
      "-p:ApplicationDisplayVersion=#{version}",
      "-p:ApplicationVersion=#{build_number}",
      "-p:RuntimeIdentifier=ios-arm64",
      "-p:ArchiveOnBuild=true",
      "-p:CodesignKey=Apple Distribution: Example Ltd (ABCD123456)",
      "-p:CodesignProvision=match AppStore com.example.myapp"
    )

    ipa_path = Dir["../src/MyApp/bin/Release/net10.0-ios/ios-arm64/publish/*.ipa"].first
    UI.user_error!("No .ipa produced") unless ipa_path
    lane_context[SharedValues::IPA_OUTPUT_PATH] = ipa_path
  end
end

Three things are worth calling out here. First, the CodesignProvision name uses the match AppStore com.example.myapp convention that fastlane match creates, and this is what lets the same lane run on any machine after a match appstore. Second, we set ApplicationVersion from the latest TestFlight build number plus one, which prevents "CFBundleVersion already exists" rejections. Third, we hand the produced .ipa path back to Fastlane's shared context so downstream lanes can find it without hardcoding.

Uploading to TestFlight with fastlane pilot

Once the .ipa exists, pilot (alias for upload_to_testflight) handles the App Store Connect upload. In 2026 you must authenticate via an API key, because the old iTMSTransporter password flow no longer works for accounts with mandatory 2FA, which is all of them now.

platform :ios do
  desc "Upload the freshly built .ipa to TestFlight"
  lane :upload_testflight do
    api_key = app_store_connect_api_key(
      key_id: ENV["APP_STORE_CONNECT_KEY_ID"],
      issuer_id: ENV["APP_STORE_CONNECT_ISSUER_ID"],
      key_content: ENV["APP_STORE_CONNECT_KEY_CONTENT"], # base64 of the .p8
      is_key_content_base64: true,
      duration: 1200
    )

    upload_to_testflight(
      api_key: api_key,
      ipa: lane_context[SharedValues::IPA_OUTPUT_PATH],
      skip_waiting_for_build_processing: true,
      changelog: File.read("../CHANGELOG_ios.md"),
      distribute_external: false,
      notify_external_testers: false
    )
  end

  desc "Full iOS beta pipeline"
  lane :deploy_ios_beta do |options|
    build_ios(options)
    upload_testflight
  end
end

I set skip_waiting_for_build_processing: true because Apple's processing queue can take 30 minutes and I don't want CI runners billed for idle time. If you need to gate a downstream job on processing (for example, to auto-promote to external testers), split it into a second lane that polls with wait_for_build_processing_completion. The Apple App Store Connect API documentation lists all the JWT scopes and rate limits you'll hit here.

Building the Android AAB and uploading with fastlane supply

Android is more forgiving than iOS but has more moving parts around signing. The .NET MAUI 10 Android build is again driven by dotnet publish, and produces a signed .aab if you pass the keystore properties. Fastlane's supply then talks to the Play Developer API to upload it to the internal, alpha, beta, or production track.

platform :android do
  desc "Build a signed .NET MAUI 10 Android release .aab"
  lane :build_android do |options|
    version = options[:version] || "1.0.0"
    version_code = options[:version_code] || Time.now.utc.strftime("%y%m%d%H%M").to_i

    csproj = "../src/MyApp/MyApp.csproj"

    sh(
      "dotnet", "publish", csproj,
      "-c", "Release",
      "-f", "net10.0-android",
      "-p:ApplicationDisplayVersion=#{version}",
      "-p:ApplicationVersion=#{version_code}",
      "-p:AndroidPackageFormat=aab",
      "-p:AndroidKeyStore=true",
      "-p:AndroidSigningKeyStore=#{ENV['ANDROID_KEYSTORE_PATH']}",
      "-p:AndroidSigningStorePass=env:ANDROID_KEYSTORE_PASSWORD",
      "-p:AndroidSigningKeyAlias=#{ENV['ANDROID_KEY_ALIAS']}",
      "-p:AndroidSigningKeyPass=env:ANDROID_KEY_PASSWORD"
    )

    aab_path = Dir["../src/MyApp/bin/Release/net10.0-android/publish/*-Signed.aab"].first
    UI.user_error!("No .aab produced") unless aab_path
    lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH] = aab_path
  end

  desc "Upload AAB to the Play Store internal track"
  lane :upload_play_internal do
    upload_to_play_store(
      package_name: "com.example.myapp",
      aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH],
      track: "internal",
      release_status: "draft",
      json_key_data: ENV["PLAY_STORE_SERVICE_ACCOUNT_JSON"],
      skip_upload_apk: true,
      skip_upload_metadata: true,
      skip_upload_changelogs: false,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )
  end

  desc "Full Android beta pipeline"
  lane :deploy_android_beta do |options|
    build_android(options)
    upload_play_internal
  end
end

A few DevOps-flavored notes. The version_code trick (a UTC timestamp compressed to yyMMddHHmm) gives you a monotonically increasing integer you never have to remember. Play Store requires strictly increasing version codes and rejects duplicates, so never hardcode them or read them from a file that developers might revert. The release_status: "draft" flag stops Fastlane from immediately releasing to internal testers, which is what you want when the human release-note review still needs to happen. Once the CI job is green and someone approves, a second lane can promote it. See the Google Play Developer API reference for the full track and status vocabulary.

Managing iOS signing with fastlane match

Everything about iOS provisioning gets easier once match owns your certificates. match encrypts a set of signing artifacts (development, adhoc, appstore) and stores them in a Git repo, then decrypts and installs them on demand. Every developer and every CI runner runs the same match appstore command, and every one of them ends up with identical, valid certs.

# fastlane/Matchfile
git_url("[email protected]:example/ios-signing.git")
storage_mode("git")
type("appstore")
app_identifier(["com.example.myapp"])
team_id("ABCD123456")
# fastlane/Fastfile: signing lane

platform :ios do
  desc "Sync distribution certs and profiles for CI or a new machine"
  lane :sync_signing do
    match(
      type: "appstore",
      readonly: is_ci,          # CI never generates new certs
      api_key: app_store_connect_api_key(
        key_id: ENV["APP_STORE_CONNECT_KEY_ID"],
        issuer_id: ENV["APP_STORE_CONNECT_ISSUER_ID"],
        key_content: ENV["APP_STORE_CONNECT_KEY_CONTENT"],
        is_key_content_base64: true
      )
    )
  end
end

The readonly: is_ci pattern is important: it stops CI from ever creating a new certificate. If certs are missing, you want the developer's laptop to notice, not for CI to silently mint new ones and revoke old ones behind everyone's back. When the Apple distribution cert expires (yearly), one team member runs bundle exec fastlane match appstore locally without --readonly, commits the new encrypted blob to the signing repo, and everyone else picks it up on the next CI run. That is the whole rotation ceremony.

If you're wiring this into GitHub Actions or Azure DevOps, pair it with the guidance in the existing code signing for .NET MAUI in CI/CD writeup, because the secrets layout there is the one match expects.

Fastfile lanes that survive real teams

The Fastfile in a healthy project is composed of small, single-purpose lanes and thin orchestration lanes on top. It's tempting to write one giant deploy lane that does everything. Don't. Every step becomes untestable in isolation, and one flaky upload kills the whole run. Here's the shape I've landed on after a few years of iteration.

# Top-level orchestration lanes

lane :deploy_beta do |options|
  ensure_git_status_clean
  ensure_git_branch(branch: "main")

  version = get_version_from_csproj(csproj: "src/MyApp/MyApp.csproj")

  Fastlane::LaneManager.cruise_lane(
    "ios", "deploy_ios_beta",
    { version: version }, nil, "."
  )
  Fastlane::LaneManager.cruise_lane(
    "android", "deploy_android_beta",
    { version: version }, nil, "."
  )

  slack(
    message: "Beta #{version} shipped to TestFlight and Play internal",
    channel: "#releases",
    default_payloads: [:git_branch, :git_author, :last_git_commit_hash]
  )
end

lane :promote_beta_to_prod do |options|
  version = options[:version] || UI.user_error!("Pass version:X.Y.Z")
  upload_to_app_store(
    api_key: app_store_connect_api_key(...),
    build_number: options[:build_number],
    skip_metadata: false,
    skip_screenshots: true,
    force: true,
    submit_for_review: true,
    automatic_release: false
  )
  upload_to_play_store(
    package_name: "com.example.myapp",
    version_name: version,
    track: "internal",
    track_promote_to: "production",
    rollout: "0.1",
    json_key_data: ENV["PLAY_STORE_SERVICE_ACCOUNT_JSON"]
  )
end

The rules I've made non-negotiable on teams I've worked with:

  • Every lane runs from a clean tree on a known branch. ensure_git_status_clean and ensure_git_branch are cheap insurance against "did you actually commit that fix?" incidents.
  • The version number has one source of truth, the <ApplicationDisplayVersion> in the .csproj. Everything else reads from it. I keep a tiny get_version_from_csproj helper that greps it out.
  • Slack notifications on both success and failure. Silent failures are how a Friday release becomes a Monday incident.
  • Production rollout starts at 10% (rollout: "0.1") on Android and automatic_release: false on iOS. Staged rollouts are free; skipping them is expensive.

Common gotchas and how I fix them

Every .NET MAUI + Fastlane rollout hits a subset of these. Save yourself the debugging session and check them up front.

"No signing certificate 'iOS Distribution' found"

Almost always because CI ran dotnet publish before sync_signing, or because the CodesignKey string doesn't exactly match what match installed. I hit this exact bug shipping a hotfix last spring. Print security find-identity -v -p codesigning in the CI job right after sync_signing and copy the exact certificate name into CodesignKey. Don't paraphrase.

"Package name mismatch" from upload_to_play_store

The package_name in the Fastfile must match the ApplicationId in your .csproj, which must match the app you registered in Play Console. If any of those three drift (usually because someone renamed the app but only updated one), the AAB upload fails after the whole build already ran. Add an ensure_package_name_matches pre-check.

Build numbers colliding across CI runs

If you run parallel CI jobs (say, main and a hotfix branch both cutting builds), latest_testflight_build_number + 1 can produce the same number twice. Use a timestamp-based version code for Android and, on iOS, a monotonic value derived from the build ID or Git commit count (git rev-list --count HEAD) instead of the TestFlight API.

Ruby version mismatch on CI

Your laptop has Ruby 3.3; the runner has 2.7; something silently breaks. Add .ruby-version, use actions/setup-ruby or the Azure DevOps equivalent, and pin the Bundler version in the workflow. Every new team member's first Fastlane run should be identical to CI's.

App Store Connect API key rate limits

The API has a 3600-requests-per-hour ceiling per key. If several lanes each call latest_testflight_build_number, get_app_store_version, and wait_for_build_processing_completion, you'll hit it during a busy release day. Use a single API key object per lane run and pass it around. Don't create a new one for every action.

"dotnet publish" leaves stale artifacts

MAUI's incremental build sometimes retains an old .ipa in the publish folder. If your Dir[...] glob picks the wrong one, you upload yesterday's binary. Add FileUtils.rm_rf("../src/MyApp/bin/Release") at the top of the build lane. The extra 90 seconds is worth it.

For a broader debugging toolkit that pairs with these pipelines, the writeup on symbolicating .NET MAUI crashes with dSYMs and R8 mapping is what I reach for when the uploaded build starts crashing in production and you need to trace the release back.

Frequently Asked Questions

Can Fastlane build a .NET MAUI app directly?

Fastlane doesn't compile .NET MAUI code itself, because the compilation is done by dotnet publish. Fastlane wraps that shell command, then takes the resulting .ipa or .aab and handles signing sync, upload, metadata, and screenshot delivery. This is a healthy separation: MAUI owns the build, Fastlane owns the release plumbing.

Do I need Fastlane if I already have GitHub Actions?

You can technically upload with GitHub-native actions and shell scripts, but Fastlane consolidates App Store Connect, Play Console, Slack, changelog, and screenshot logic in one Ruby DSL that also runs on a developer laptop. Rebuilding all of that in YAML is possible but painful, and it locks you into GitHub Actions. Most teams end up with a hybrid: Actions triggers, Fastlane executes.

How does fastlane match differ from manual signing?

Manual signing means each developer generates their own certificates in the Apple Developer portal and stores .p12 files somewhere private. This breaks when someone leaves, when the cert expires, or when CI needs the same credentials. match replaces that with a shared, encrypted Git repo that every machine can pull, so certificate rotation becomes one commit instead of a coordination meeting.

Which Fastlane version supports .NET MAUI 10?

Fastlane doesn't have MAUI-specific code paths, so any recent version works. I pin ~> 2.226 because that release fully supports the App Store Connect API JWT flow that Apple now requires. Anything older than 2.219 is a bad idea for new setups because password-based auth is being phased out.

Can I use Fastlane on Windows for the Android side?

Yes. supply, screengrab, and the Android lanes run fine on Windows with Ruby installed. The iOS lanes still require macOS because Xcode's build tooling is macOS-only, and dotnet publish for the iOS target likewise needs a Mac. A common pattern is a Windows runner for Android and a Mac runner for iOS, both driven by the same Fastfile.

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.