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=""%24(xcrun -find bitcode_strip)" %(_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
- In Azure DevOps, go to the Pipelines block and select the Pipelines option
- Click on the “New Pipeline” button
- 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
orRelease
- 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!