.Net MAUI + Azure Pipelines + iOS TestFlight!

In this post, I want to show you how to publish a .NET MAUI app via TestFlight from an Azure Pipeline. These tools allow you to create cross-platform Android and iOS apps, then use pipelines to build the app any time its code changes, and deploy an unofficial version of the app to a group of iOS test users for testing purposes before the app is officially released on the App Store.

Part 1: .Net MAUI Project Requirements

Remove the AOT

Having an Azure DevOps Pipeline successfully publish the app via TestFlight doesn’t guarantee it’ll work when it gets there. My first attempt failed because the app would crash when it showed the splash screen. To fix this, make sure the UseInterpreter statement used for Release builds in your .csproj file is set to true.

In my .csproj, it would look like this:

<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net7.0-ios|AnyCPU'">
    <CreatePackage>false</CreatePackage>
    <MtouchLink>SdkOnly</MtouchLink>
    <UseInterpreter>true</UseInterpreter> <!-- This one -->
    <MtouchUseLlvm>false</MtouchUseLlvm>
</PropertyGroup>

Remove the Bitcode from Referenced Frameworks

The App Store no longer accepts bitcode submissions from Xcode 14, so we need to remove it when we build the app. However, even after removing it from our code, the publishing can throw an error because inner frameworks and libraries still have it enabled by default. Further investigation led me to this thread with a workaround, and it worked for me.

To implement this workaround, add the following to your .csproj file.

<PropertyGroup>
    <!-- Properties used to strip bitcode from frameworks when using Visual Studio for Mac -->
    <_StripBitcodeFromFrameworksMasterAfterTargets Condition="'$(OS)'=='Unix'">_ExpandNativeReferences</_StripBitcodeFromFrameworksMasterAfterTargets>
    <_StripBitcodeFromFrameworksMasterDependsOnTargets Condition="'$(OS)'=='Unix'">_StripBitcodeFromFrameworksOnMac</_StripBitcodeFromFrameworksMasterDependsOnTargets>

    <!-- Properties used to strip bitcode from frameworks when using Visual Studio for Windows -->
    <_StripBitcodeFromFrameworksMasterAfterTargets Condition="'$(OS)'!='Unix'">_ComputeFrameworkFilesToPublish</_StripBitcodeFromFrameworksMasterAfterTargets>
    <_StripBitcodeFromFrameworksMasterDependsOnTargets Condition="'$(OS)'!='Unix'">_StripBitcodeFromFrameworksOnWindows</_StripBitcodeFromFrameworksMasterDependsOnTargets>
  </PropertyGroup>
  
  <Target Name="_StripBitcodeFromFrameworksMasterTarget" 
          Condition="'$(_StripBitcodeFromFrameworksMasterDependsOnTargets)'!='' AND @(_FrameworkNativeReference->Count()) != 0" 
          AfterTargets="$(_StripBitcodeFromFrameworksMasterAfterTargets)" 
          DependsOnTargets="$(_StripBitcodeFromFrameworksMasterDependsOnTargets)" />

  <Target Name="_StripBitcodeFromFrameworksOnMac"
          Condition="('$(OutputType)'!='Library' OR '$(IsAppExtension)'=='True') AND '$(_SdkIsSimulator)'=='False'">
    
    <!-- Find the bitcode_strip command -->
    <Exec Command="xcrun -find bitcode_strip" ConsoleToMSBuild="true">
      <Output TaskParameter="ConsoleOutput" PropertyName="_BitcodeStripCommand" />
    </Exec>

    <!-- Strip the bitcode from frameworks -->
    <Exec Command="$(_BitcodeStripCommand) %(_FrameworkNativeReference.Identity) -r -o %(_FrameworkNativeReference.Identity)" />
  </Target>

  <Target Name="_StripBitcodeFromFrameworksOnWindows"
          Condition="('$(OutputType)'!='Library' OR '$(IsAppExtension)'=='True') AND '$(_SdkIsSimulator)'=='False' AND '$(IsMacEnabled)'=='true'">

    <!-- Strip the bitcode from frameworks -->
    <Exec SessionId="$(BuildSessionId)"
          Command="&quot;%24(xcrun -find bitcode_strip)&quot; %(_FrameworkNativeReference.Identity) -r -o %(_FrameworkNativeReference.Identity)" />

    <CopyFileFromBuildServer 
      SessionId="$(BuildSessionId)" 
      File="%(_FrameworkNativeReference.Identity)" 
      TargetFile="%(_FrameworkNativeReference.Identity)" />    
  </Target>

Fix the Build Target

Usually, the .csproj misses the Entitlements and the RuntimeIdentifier for iOS devices. Add the following code to your .csproj file:

<PropertyGroup Condition="$(TargetFramework.Contains('-ios')) and '$(Configuration)' == 'Release'">
    <RuntimeIdentifier>ios-arm64</RuntimeIdentifier>
    <CodesignProvision>Automatic</CodesignProvision>
    <CodesignKey>iPhone Distribution</CodesignKey>
    <CodesignEntitlements>Platforms\iOS\Entitlements.plist</CodesignEntitlements>
</PropertyGroup>

Part 2: Azure Requirements

Create an Apple Store Connection

First, create an App-Specific Password for Apple account here and store it somewhere safe, as you’ll never see it again. Then, go to the Azure DevOps Project Settings. It’s in the lower left corner:

Go to Service Connection -> Create Service Connection, and select Apple App Store from the menu.

Give it a name and then configure it to use Basic Authentication. Leave the Server URL as https://itunesconnect.apple.com/. Then, enter your user and password for the AppStore Connect App and add the App-Specific Password you created.

Upload Your Certificate and Provisioning Profile

Go to the pipeline Library, select the Secure Files tab, and upload your documents.

After you’ve uploaded them, you need to permit a pipeline to access them. Select the file, go to Pipeline permissions, and add a pipeline from the list

Store Sensitive Information in Secret Variables

It’s strongly advised to store your passwords, ID, and other sensitive information in a variable and configure it to keep it secret. You can then reference those values as variables from your pipeline yaml.

Upload a Signed IPA to App Store Connect

Yep! In order to automate the release of app updates to the App Store, you need to have manually released at least one version of the app beforehand. You can use Transporter on the Mac App Store (apple.com) to do this, if you’d like.

Install the Azure DevOps Extension for the Apple App Store

Next, you’ll need help from your Azure DevOps administrator. The Azure DevOps Extension for the Apple App Store allows you to configure and add a AppStoreRelease task in your yaml.

Part 3: Create the Pipeline

  1. In Azure DevOps, go to the Pipelines block and select the Pipelines option
  2. Click on the “New Pipeline” button
  3. Select the repository type. In this case, the code is stored in Azure, so we’re picking “Azure Repos Git,” and then we select the repo from the list

Now we need to configure it. There are some predefined configurations ready to be used, but MAUI doesn’t have one yet. Instead of using a predefined configuration, we’re creating one from scratch. Select the Starter Pipeline option

Azure will create a pipeline with an Ubuntu VM and some default values. Click on the “Save and Run” button in the upper right

It’s recommended to use a new branch. Azure will create a commit to store this .yml file. Once you’ve configured this, you can run it

Part 4: Configure the YAML

A push trigger specifies which branches cause a continuous integration build to run. In this scenario, we need the Build.iOS branch. Delete the whole YAML to have a clean start, and write the following code:

trigger:
    - Build.iOS

Define which agent pool you’ll be using. We need to build iOS apps, so we’re picking a pool of MacOS agents. The -latest suffix requests the latest available version, but we can replace it with a specific version if we need it. Also, this Pipeline needs xcode a specific capability to build the app, so we are adding it as a demand.

pool:
    vmImage: 'macos-latest'
    demands: xcode

To make it easier to switch some commands, we’re defining some variables here to be used in the document.

  • BuildConfiguration: can be Debug or Release
  • DotNetVersion: The .Net version. used to build the app. It should match the version defined in the .csproj file. You can request a specific version, or you can use an x instead of any of the major, minor, or build numbers to request the latest available (ex. 7.0.x)
  • iOSVersion: The .Net for iOS version used to build the app. You can request a specific version by appending it at the end (ex net7.0-ios16.4)
variables:
    BuildConfiguration: Release
    DotNetVersion: 7.x
    iOSVersion: net7.0-ios

Now we begin with the steps. Steps are a linear sequence of operations that make up a job, and we separate them with tasks.

steps:
-   task: UseDotNet@2
    displayName: 'Install .NET sdk'
    inputs:
        packageType: 'sdk'
        version: $(DotNetVersion)
        installationPath: $(Agent.ToolsDirectory)/dotnet

Next, we install the MAUI workload. Usually, we would need to write a custom script for each OS, but CmdLine abstracts it for us.

-   task: CmdLine@2
    displayName: 'Install Maui Workload'
    inputs:
        script: 'dotnet workload install maui'

Once we’re done installing the MAUI workload, we need to install the P12 certificate and the Apple Provisioning Profile. We’re retrieving the P12 certificate password and the Provisioning Profile we previously stored.

-   task: InstallAppleCertificate@2
    displayName: 'Install P12 Cert'
    inputs:
        certSecureFile: 'TrailheadCert.p12'
        certPwd: '$(iOSCertPassword)'
        keychain: 'temp'
-   task: InstallAppleProvisioningProfile@1
    displayName: 'Install Provisioning Profile'
    inputs:
        provisioningProfileLocation: 'secureFiles'
        provProfileSecureFile: 'Trailhead_Distribution.mobileprovision'

Now we build the binaries with the publish command. It’s necessary to remove the bitcode, or else you’ll get an error while publishing to the store. Here we use the iOSVersion variable defined at the beginning of the yaml

-   task: DotNetCoreCLI@2
    displayName: 'Build the iOS Binaries'
    inputs:
        command: 'publish'
        publishWebProjects: false
        projects: '**/src/*.csproj'
        arguments: '-f:$(iOSVersion) -c:Release /p:ArchiveOnBuild=true /p:EnableAssemblyILStripping=false'
        zipAfterPublish: false
    env:
        DISABLE_BITCODE: 'yes'

Next, we copy the IPA to the staging directory and publish it to the Artifact container. It’s a good practice to clean the target folder and overwrite the file if there’s one already.

-   task: CopyFiles@2
    displayName: 'Copy file from Build to Staging'
    inputs:
        SourceFolder: '$(Agent.BuildDirectory)'
        Contents: '**/*.ipa'
        TargetFolder: '$(Build.ArtifactStagingDirectory)'
        CleanTargetFolder: true
        OverWrite: true
-   task: PublishBuildArtifacts@1
    displayName: 'Publish the Staging Files.'
    inputs:
        PathtoPublish: '$(Build.ArtifactStagingDirectory)'
        ArtifactName: 'drop'
        publishLocation: 'Container'

Finally, we deploy the app to the stores. In this scenario, we’re configuring the AppStoreRelease task to send it to TestFlight.

-   task: AppStoreRelease@1
    inputs:
        serviceEndpoint: 'TH Apple App Store Connection'
        releaseTrack: 'TestFlight'
        appIdentifier: 'com.trailheadtechnology.sample'
        appType: 'iOS'
        shouldSkipWaitingForProcessing: true
        shouldSkipSubmission: true
        appSpecificId: '$(AppSpecificAppleId)'

Phew! This was a long post, but there you go! Now you can deploy your iOS app via TestFlight using Azure DevOps pipelines. Happy coding!

Related Blog Posts

We hope you’ve found this to be helpful and are walking away with some new, useful insights. If you want to learn more, here are a couple of related articles that others also usually find to be interesting:

Our Gear Is Packed and We're Excited to Explore With You

Ready to come with us? 

Together, we can map your company’s software journey and start down the right trails. If you’re set to take the first step, simply fill out our contact form. We’ll be in touch quickly – and you’ll have a partner who is ready to help your company take the next step on its software journey. 

We can’t wait to hear from you! 

Main Contact

This field is for validation purposes and should be left unchanged.

Together, we can map your company’s tech journey and start down the trails. If you’re set to take the first step, simply fill out the form below. We’ll be in touch – and you’ll have a partner who cares about you and your company. 

We can’t wait to hear from you! 

Montage Portal

Montage Furniture Services provides furniture protection plans and claims processing services to a wide selection of furniture retailers and consumers.

Project Background

Montage was looking to build a new web portal for both Retailers and Consumers, which would integrate with Dynamics CRM and other legacy systems. The portal needed to be multi tenant and support branding and configuration for different Retailers. Trailhead architected the new Montage Platform, including the Portal and all of it’s back end integrations, did the UI/UX and then delivered the new system, along with enhancements to DevOps and processes.

Logistics

We’ve logged countless miles exploring the tech world. In doing so, we gained the experience that enables us to deliver your unique software and systems architecture needs. Our team of seasoned tech vets can provide you with:

Custom App and Software Development

We collaborate with you throughout the entire process because your customized tech should fit your needs, not just those of other clients.

Cloud and Mobile Applications

The modern world demands versatile technology, and this is exactly what your mobile and cloud-based apps will give you.

User Experience and Interface (UX/UI) Design

We want your end users to have optimal experiences with tech that is highly intuitive and responsive.

DevOps

This combination of Agile software development and IT operations provides you with high-quality software at reduced cost, time, and risk.

Trailhead stepped into a challenging project – building our new web architecture and redeveloping our portals at the same time the business was migrating from a legacy system to our new CRM solution. They were able to not only significantly improve our web development architecture but our development and deployment processes as well as the functionality and performance of our portals. The feedback from customers has been overwhelmingly positive. Trailhead has proven themselves to be a valuable partner.

– BOB DOERKSEN, Vice President of Technology Services
at Montage Furniture Services

Technologies Used

When you hit the trails, it is essential to bring appropriate gear. The same holds true for your digital technology needs. That’s why Trailhead builds custom solutions on trusted platforms like .NET, Angular, React, and Xamarin.

Expertise

We partner with businesses who need intuitive custom software, responsive mobile applications, and advanced cloud technologies. And our extensive experience in the tech field allows us to help you map out the right path for all your digital technology needs.

  • Project Management
  • Architecture
  • Web App Development
  • Cloud Development
  • DevOps
  • Process Improvements
  • Legacy System Integration
  • UI Design
  • Manual QA
  • Back end/API/Database development

We partner with businesses who need intuitive custom software, responsive mobile applications, and advanced cloud technologies. And our extensive experience in the tech field allows us to help you map out the right path for all your digital technology needs.

Our Gear Is Packed and We're Excited to Explore with You

Ready to come with us? 

Together, we can map your company’s tech journey and start down the trails. If you’re set to take the first step, simply fill out the contact form. We’ll be in touch – and you’ll have a partner who cares about you and your company. 

We can’t wait to hear from you! 

Thank you for reaching out.

You’ll be getting an email from our team shortly. If you need immediate assistance, please call (616) 371-1037.