Start
With .NET MAUI (Multi-platform App UI), developers can build native mobile and desktop apps using a single codebase for Android, iOS, macOS, and Windows. While the framework simplifies app development, building and publishing apps to the App Store and Play Store can still be a time-consuming manual process.
This is where continuous integration and delivery (CI/CD) pipelines come into play. CI/CD pipelines automate building, testing, and publishing your apps, allowing you to focus on writing code rather than managing builds. GitHub Actions, GitHub’s own automation tool, is a powerful solution to automate these tasks.
In this blog post, I’ll walk you through setting up a CI/CD pipeline using GitHub Actions to automatically build your .NET MAUI app and publish it to both the Apple App Store and Google Play Store.
What is GitHub Actions?
GitHub Actions is a CI/CD tool that integrates directly with your GitHub repositories. It allows you to automate tasks like building, testing, and deploying applications based on events such as commits, pull requests, or scheduled workflows.
Prerequisites
Before getting started, make sure you have the following:
- A GitHub account: Your project should be hosted in a GitHub repository.
- Apple Developer account: You’ll need this to publish to the Apple App Store.
- Google Play Developer account: Necessary for publishing to the Play Store.
- Certificates: Ensure Xcode (for iOS), and any necessary signing certificates (e.g., Android keystore, iOS provisioning profiles) are accessible.
- App Setup: You have to set up your apps in the App and PlayStore already
iOS-Part
1. Environment Setup
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Setup Xcode version
uses: maxim-lobanov/setup-xcode@v1.6.0
with:
xcode-version: 16.0
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 8.0.x
- name: Install MAUI workload
run: dotnet workload install maui
The first part of the workflow sets up the environment required to build the iOS version of your MAUI app:
- macos-latest: The workflow runs on a macOS machine, which is required for iOS builds.
- Checkout repository: It checks out your code from the repository so that the following steps can access it.
- Setup Xcode: This step installs Xcode (version 16.0), which is essential for building iOS apps.
- Install .NET SDK: This step installs the .NET 8.0 SDK.
- Install MAUI workload: This ensures the MAUI workload is available for building the project.
2. Code Signing
- name: Import Code-Signing Certificates
uses: Apple-Actions/import-codesign-certs@v1
with:
p12-filepath: 'AppleSigningCertificate.p12'
p12-password: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }}
- name: Download Apple Provisioning Profiles
uses: Apple-Actions/download-provisioning-profiles@v1
with:
bundle-id: 'com.max.mannstein.dazeforprivate'
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}
iOS apps require code-signing to be deployed, and this section handles importing the necessary certificates and provisioning profiles:
- Import Code-Signing Certificates: It uses a
.p12
certificate for code-signing, which is required by Apple. Thep12-password
is securely fetched from GitHub secrets.- It is explained here, how you can get those .p12-files from your Certificates in your Apple Keychain: https://support.magplus.com/hc/en-us/articles/203808748-iOS-Creating-a-Distribution-Certificate-and-p12-File
- Then you pack your .p12-File in your project folder and set a GitHub Action Secret with the password you chose.
- Download Apple Provisioning Profiles: It fetches the required provisioning profile based on your app’s bundle identifier, using credentials stored in GitHub secrets.
- The Bundle-Id is your app identifier
- You also have to generate an API Key which you can do like described here: https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api
- Choose Team Key and download it. Copy the id of that key and put it inside a GitHub Actions Secret (APPSTORE_KEY_ID) and copy the content of your key and also put it in a Secret (APPSTORE_PRIVATE_KEY)
- Lastly you need your Issuer Id in a Secret (APPSTORE_ISSUER_ID), which you can get like that: https://github.com/fastlane/fastlane/discussions/18926
3. Build and Upload
- name: Build
run: dotnet publish DAZEHueg/DAZEHueg.csproj -f net8.0-ios -c Release -p:ArchiveOnBuild=true -p:EnableAssemblyILStripping=false
- name: Upload app to TestFlight
uses: Apple-Actions/upload-testflight-build@v1
with:
app-path: 'YourNamespace/bin/Release/net8.0-ios/publish/App.ipa'
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}
This section builds your MAUI project for iOS:
- Build: The
dotnet publish
command compiles the app for the iOS platform using the .NET 8.0 framework in Release configuration. The additional properties ensured for me personally that the app built successful. That’s also why I left out dotnet restore. - Upload app to Testflight: This step uploads the
.ipa
file (iOS app archive) to TestFlight using Apple’s API credentials securely stored in GitHub secrets. Here you done the heavy lifting already and just need to reuse your Secrets from step 2.
My personal iOS-Part
build-ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Setup Xcode version
uses: maxim-lobanov/setup-xcode@v1.6.0
with:
xcode-version: 16.0
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 8.0.x
- name: Install MAUI workload
run: dotnet workload install maui
- name: Import Code-Signing Certificates
uses: Apple-Actions/import-codesign-certs@v1
with:
p12-filepath: 'AppleSigningCertificate.p12'
p12-password: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }}
- name: Download Apple Provisioning Profiles
uses: Apple-Actions/download-provisioning-profiles@v1
with:
bundle-id: 'com.max.mannstein.dazeforprivate'
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}
- name: Build
run: dotnet publish DAZEHueg/DAZEHueg.csproj -f net8.0-ios -c Release -p:ArchiveOnBuild=true -p:EnableAssemblyILStripping=false
- name: List build output
run: ls -R DAZEHueg/bin/Release/net8.0-ios/publish/
- name: Upload app to TestFlight
uses: Apple-Actions/upload-testflight-build@v1
with:
app-path: 'DAZEHueg/bin/Release/net8.0-ios/publish/DAZEHueg.ipa'
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}
Was that helpful so far?
The consider subscribing to my newsletter, where I also talk about .NET MAUI in general, self-improvement and building stuff 🙂
Android-Part
1. Environment Setup
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 8.0.x
- name: Install MAUI workload
run: dotnet workload install maui
The first part of the Android workflow sets up the environment required for building your MAUI Android app:
- windows-latest: The workflow runs on a Windows machine, as Windows is typically used for Android development.
- Checkout repository: It checks out your code from the GitHub repository so the subsequent steps can access it.
- Setup .NET: This step installs the .NET 8.0 SDK.
- Install MAUI workload: Installs the MAUI workload necessary for Android app development.
2. Build the Android App
- name: Build
run: dotnet publish -f net8.0-android -c Release -p:AndroidKeyStore=true -p:AndroidSigningKeyStore=daze-for-private.keystore -p:AndroidSigningKeyAlias=daze-for-private -p:AndroidSigningKeyPass="${{ secrets.KEYSTORE_PASSWORD }}" -p:AndroidSigningStorePass="${{ secrets.KEYSTORE_PASSWORD_ALIAS }}"
Build command: The dotnet publish
command builds the MAUI Android project for the net8.0-android
framework in Release mode.
- Signing parameters: The command uses key properties to enable Android signing:
AndroidKeyStore=true
: Enables the use of the Android keystore for signing.- Keystore path and alias: The app is signed with the specified keystore (
daze-for-private.keystore
) and alias (daze-for-private
). - Passwords: The keystore and alias passwords are securely fetched from GitHub secrets, ensuring the signing process is secure.
- Please note here that I pasted my Keystore File into my Repository. This is not the right and secure way of handling this. I simply didn’t get it working with an other method. As my Repository for the app is private I think that this isn’t as much of an issue.
- Here is a resource of how to do it better also explaining the other stuff like Keystore Password and Keystore Alias Password in more detail: https://www.youtube.com/watch?v=GQuQPm40kys&t=1022s
3. Upload
- uses: r0adkll/upload-google-play@v1.1.3
name: Upload Android Artifact to Play Console
with:
serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_SERVICE_ACCOUNT }}
packageName: com.max.mannstein.dazeforprivate
releaseFiles: YourNamespace/bin/Release/net8.0-android/publish/App-Signed.aab
track: internal
This part automates the upload of the signed Android app to the Google Play Console:
- Google Play upload action: This step uses the
r0adkll/upload-google-play
action to upload the Android App Bundle (.aab
) to the Play Console. - Service account: The Play Store service account credentials are securely passed as a GitHub secret (
PLAYSTORE_SERVICE_ACCOUNT
). - Package name: Specifies the app’s unique package name (
com.max.mannstein.dazeforprivate
), which identifies the app on the Play Store. - Release file: Points to the signed Android App Bundle (
.aab
) that was built in the previous step. - Track: The app is uploaded to the internal track for internal testing or deployment.
3. My personal Android-Part
build-android:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 8.0.x
- name: Install MAUI workload
run: dotnet workload install maui
- name: Build
run: dotnet publish -f net8.0-android -c Release -p:AndroidKeyStore=true -p:AndroidSigningKeyStore=daze-for-private.keystore -p:AndroidSigningKeyAlias=daze-for-private -p:AndroidSigningKeyPass="${{ secrets.KEYSTORE_PASSWORD }}" -p:AndroidSigningStorePass="${{ secrets.KEYSTORE_PASSWORD_ALIAS }}"
- uses: r0adkll/upload-google-play@v1.1.3
name: Upload Android Artifact to Play Console
with:
serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_SERVICE_ACCOUNT }}
packageName: com.max.mannstein.dazeforprivate
releaseFiles: DAZEHueg/bin/Release/net8.0-android/publish/com.max.mannstein.dazeforprivate-Signed.aab
track: internal
Conclusion
My experience and the full yml
This was my first time setting up a CI/CD pipeline with GitHub Actions to build and publish a .NET MAUI app, and it took me around two days to get everything working correctly. Between configuring the environment, managing signing certificates, and learning how to automate the upload process to both the Play Store and App Store, I definitely faced a few challenges along the way.
However, once everything was in place, the process became much smoother, and I hope this guide helps someone who is also trying to streamline their MAUI app deployment. If you’re stuck or confused at any point, don’t worry—you’re not alone. Just keep pushing through, and soon you’ll have a fully automated deployment pipeline!
name: MAUI CI/CD
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
jobs:
build-android:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 8.0.x
- name: Install MAUI workload
run: dotnet workload install maui
- name: Build
run: dotnet publish -f net8.0-android -c Release -p:AndroidKeyStore=true -p:AndroidSigningKeyStore=daze-for-private.keystore -p:AndroidSigningKeyAlias=daze-for-private -p:AndroidSigningKeyPass="${{ secrets.KEYSTORE_PASSWORD }}" -p:AndroidSigningStorePass="${{ secrets.KEYSTORE_PASSWORD_ALIAS }}"
- name: List build output
run: ls -R ./**/*-Signed.aab
- uses: r0adkll/upload-google-play@v1.1.3
name: Upload Android Artifact to Play Console
with:
serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_SERVICE_ACCOUNT }}
packageName: com.max.mannstein.dazeforprivate
releaseFiles: DAZEHueg/bin/Release/net8.0-android/publish/com.max.mannstein.dazeforprivate-Signed.aab
track: internal
build-ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Setup Xcode version
uses: maxim-lobanov/setup-xcode@v1.6.0
with:
xcode-version: 16.0
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 8.0.x
- name: Install MAUI workload
run: dotnet workload install maui
- name: Import Code-Signing Certificates
uses: Apple-Actions/import-codesign-certs@v1
with:
p12-filepath: 'AppleSigningCertificate.p12'
p12-password: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }}
- name: Download Apple Provisioning Profiles
uses: Apple-Actions/download-provisioning-profiles@v1
with:
bundle-id: 'com.max.mannstein.dazeforprivate'
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}
- name: Build
run: dotnet publish DAZEHueg/DAZEHueg.csproj -f net8.0-ios -c Release -p:ArchiveOnBuild=true -p:EnableAssemblyILStripping=false
- name: List build output
run: ls -R DAZEHueg/bin/Release/net8.0-ios/publish/
- name: Upload app to TestFlight
uses: Apple-Actions/upload-testflight-build@v1
with:
app-path: 'DAZEHueg/bin/Release/net8.0-ios/publish/DAZEHueg.ipa'
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}