Max Mannstein

Subscribe :)

Building and Publishing a .NET MAUI App with GitHub Actions

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. The p12-password is securely fetched from GitHub secrets.
  • 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.
    The hardest thing here, which is to be fair pretty straight forward is to get everything regarding the serviceAccountJSONPlainText right. I followed that documentation and got it working first try: https://github.com/r0adkll/upload-google-play?tab=readme-ov-file#configure-access-via-service-account

    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 }}

    Leave a Reply

    Your email address will not be published. Required fields are marked *