Lately, I was working on a .NET MAUI project, where I had to build different Azure DevOps pipelines for build and publish. There are some blogposts which are pretty helpful, but I ended up with a slightly different solution after knowledge gathering from the following posts:
- Azure DevOps for .NET MAUI using YML
- Setting up CI for your .NET MAUI iOS app in Azure DevOps
- Set-up Azure DevOps for MAUI
I ended up with setting up 2 pipelines. One for building the application and publishing the artifact (which is an IPA file) for testing to able to easily install on a QA device. On the other hand I wanted to automate the publishing for a review to the App Store.
Prerequisites for my pipelines
File structure
├─ 📂 src | |
│ └─ ♾️ MyApplication.Mobile.sln | |
├─ 🚀 azure-pipelines-build.yml | |
├─ 🚀 azure-pipelines-publish.yml | |
├─ 📄 MyApplication.mobileprovision | |
└─ 📄 MyApplicationDistribution.mobileprovision |
The development.p12
& distribution.p12
file
These files are required to able to build a signed IPA file. You can download these certs from the Apple Developer center and install to the Keychain and then export the .p12 files – for this step you need a macOS. You need two different certifications, one for development and another for App Store distribution.
Once, these certs are generated, those should be uploaded to Azure DevOps library as secret files. The pipelines below, are using this library to retrieve the certs and upload to the Keychain of the virtual agent machine.
Visual Studio project file
The .csproj of the project needs to be also slightly edited to able to use the pipelines below, here are the relevant parts:
<Project Sdk="Microsoft.NET.Sdk"> | |
<PropertyGroup> | |
<!-- 'Test' OR 'Prod', 'Test' by default --> | |
<Environment>Test</Environment> | |
<!-- Use distribution provision profile or development, false by default --> | |
<Distribute>false</Distribute> | |
<DefineConstants>ENV_$(Environment)</DefineConstants> | |
</PropertyGroup> | |
<PropertyGroup Condition="'$(TargetFramework)'=='net6.0-ios'"> | |
<ProvisioningType>automatic</ProvisioningType> | |
</PropertyGroup> | |
<PropertyGroup Condition="'$(TargetFramework)'=='net6.0-ios' and '$(Distribute)' == true"> | |
<ProvisioningType>manual</ProvisioningType> | |
<CodesignKey>iPhone Distribution: Account Name (Account ID)</CodesignKey> | |
<CodesignProvision>MyApplicationDistribution</CodesignProvision> | |
</PropertyGroup> | |
</Project> |
Build pipeline
trigger: | |
- main | |
variables: | |
DotNetVersion: 6.0.x | |
stages: | |
# iOS | |
- stage: BuildiOS | |
dependsOn: '' | |
displayName: Build iOS | |
jobs: | |
- job: BuildMAUIApps | |
displayName: Build app | |
pool: | |
vmImage: 'macos-latest' | |
demands: 'xcode' | |
steps: | |
- task: UseDotNet@2 | |
displayName: Use .NET version | |
inputs: | |
packageType: 'sdk' | |
version: '$(DotNetVersion)' | |
- task: CmdLine@2 | |
displayName: Install MAUI | |
inputs: | |
script: 'dotnet workload install maui' | |
- task: InstallAppleCertificate@2 | |
displayName: Install Apple Certificate | |
inputs: | |
certSecureFile: 'development.p12' | |
certPwd: '' | |
keychain: 'temp' | |
- task: InstallAppleProvisioningProfile@1 | |
displayName: Install Apple Provisioning Profile | |
inputs: | |
provisioningProfileLocation: 'sourceRepository' | |
provProfileSourceRepository: 'MyApplication.mobileprovision' | |
- task: Bash@3 | |
displayName: Restore nuget | |
inputs: | |
targetType: 'inline' | |
script: | | |
cd src | |
dotnet restore MyApplication.Mobile.sln | |
- task: Bash@3 | |
displayName: Publish iOS app | |
inputs: | |
targetType: 'inline' | |
script: | | |
cd src | |
dotnet publish -f net6.0-ios -c Release -p:ArchiveOnBuild=true -p:EnableAssemblyILStripping=false -p:Environment=$ENVIRONMENT | |
env: | |
ENVIRONMENT: $(Environment) | |
- task: CopyFiles@2 | |
displayName: Copy artifacts | |
inputs: | |
SourceFolder: '$(agent.builddirectory)' | |
Contents: | | |
**/*.ipa | |
TargetFolder: '$(build.artifactstagingdirectory)' | |
flattenFolders: true | |
- task: PublishBuildArtifacts@1 | |
displayName: Publish artifacts | |
inputs: | |
PathtoPublish: '$(Build.ArtifactStagingDirectory)' | |
ArtifactName: 'drop-ios' | |
publishLocation: 'Container' |
Publishing pipeline
trigger: none | |
variables: | |
DotNetVersion: 6.0.x | |
stages: | |
# PROD iOS build and upload | |
- stage: BuildiOS | |
dependsOn: '' | |
displayName: PROD - Build iOS and upload for review | |
jobs: | |
- job: BuildMAUIApps | |
displayName: Build app | |
pool: | |
vmImage: 'macos-latest' | |
demands: 'xcode' | |
steps: | |
- task: UseDotNet@2 | |
displayName: Use .NET version | |
inputs: | |
packageType: 'sdk' | |
version: '$(DotNetVersion)' | |
- task: CmdLine@2 | |
displayName: Install MAUI | |
inputs: | |
script: 'dotnet workload install maui' | |
- task: InstallAppleCertificate@2 | |
displayName: Install Apple Certificate | |
inputs: | |
certSecureFile: 'distribution.p12' | |
certPwd: '' | |
keychain: 'temp' | |
- task: InstallAppleProvisioningProfile@1 | |
displayName: Install Apple Provisioning Profile | |
inputs: | |
provisioningProfileLocation: 'sourceRepository' | |
provProfileSourceRepository: 'MyApplicationDistribution.mobileprovision' | |
- task: Bash@3 | |
displayName: Restore nuget | |
inputs: | |
targetType: 'inline' | |
script: | | |
cd src | |
dotnet restore MyApplication.Mobile.sln | |
- task: Bash@3 | |
displayName: Publish iOS app | |
inputs: | |
targetType: 'inline' | |
script: | | |
cd src | |
dotnet publish -f net6.0-ios -c Release -v d -p:ArchiveOnBuild=true -p:Environment='Prod' -p:Distribute=true | |
- task: CopyFiles@2 | |
displayName: Copy artifacts | |
inputs: | |
SourceFolder: '$(agent.builddirectory)' | |
Contents: | | |
**/*.ipa | |
TargetFolder: '$(build.artifactstagingdirectory)' | |
flattenFolders: true | |
- task: PublishBuildArtifacts@1 | |
displayName: Publish artifacts | |
inputs: | |
PathtoPublish: '$(Build.ArtifactStagingDirectory)' | |
ArtifactName: 'drop-ios' | |
publishLocation: 'Container' | |
- task: Bash@3 | |
displayName: Validate | |
inputs: | |
targetType: 'inline' | |
script: | | |
cd $BUILDDIR | |
xcrun altool --validate-app -f MyApplication.Mobile.ipa -t ios -u $APPLEACCOUNTUSER -p $APPLEACCOUNTPASSWORD --output-format json --verbose | |
env: | |
APPLEACCOUNTUSER: $(AppleAccountUser) | |
APPLEACCOUNTPASSWORD: $(AppleAccountPassword) | |
BUILDDIR: $(Build.ArtifactStagingDirectory) | |
- task: Bash@3 | |
displayName: Upload to App Store | |
inputs: | |
targetType: 'inline' | |
script: | | |
cd $BUILDDIR | |
xcrun altool --upload-app -f MyApplication.Mobile.ipa -t ios -u $APPLEACCOUNTUSER -p $APPLEACCOUNTPASSWORD --output-format json --verbose | |
env: | |
APPLEACCOUNTUSER: $(AppleAccountUser) | |
APPLEACCOUNTPASSWORD: $(AppleAccountPassword) | |
BUILDDIR: $(Build.ArtifactStagingDirectory) |
Great post. I had to make a few changes:
I’m building locally using on premise MacOS build agent and DevOps Server, so I changed:
pool:
vmImage: ‘macos-latest’
demands: ‘xcode’
To:
pool:
name: {poolname}
I had to use InstallAppleCertificate-sxs@2 (available in DevOps extensions) on macOS 13.x to get around an OpenSSL SHA1->SHA256 issue.
I added -r ios-arm64 to the dotnet publish task.
I also had to alter SourceFolder: ‘$(agent.builddirectory)’ on the CopyFiles@2 tasks to be a lower level directory. The task is a bit buggy with certain pathing on MacOS it sounds like.
Have you attempted doing an android build and publish to the Play Store using MacOS? That is up next for me and I’m debating between using windows or MacOS build agent.
LikeLike
Thanks for sharing the findings on an on-prem MacOS build agent and the coffees 🙌 I have done the Android build on a Windows agent but not the publish to Play Store (I skipped that for now, it was just easier to upload manually). If the mentioned articles does not help for you, I am happy to share my Android build pipeline.
LikeLike