TL;DR Here are the basic steps to set up CI/CD for your iOS MAUI apps using GitHub Actions! I outline every step needed to setup the build environment, import your certificates and provisioning profiles, build, and, finally, upload your iOS apps to Test Flight and the App Store. I leave the macOS, Android, and Windows versions as an exercise for the reader.

The Challenge of MAUI CI/CD

MAUI (Multi-platform App UI) is a powerful framework for building cross-platform applications, but setting up Continuous Integration and Continuous Deployment (CI/CD) can be a bit tricky, especially for iOS apps. The process involves setting up the build server to have all the right Xcode and .NET versions, actually building the app, signing it, and then distributing it to Test Flight and the App Store.

This blog post is a lot longer than I would like it to be, but the good news is that once you have a good CI/CD script running, it’s pretty stable and you can reuse it for all your MAUI apps. So, let’s dive in!

Setting Up the Job

First, you need to setup the job to have the correct versions of Xcode and .NET. This is crucial for building your MAUI app correctly. Let’s start with locking down the macOS version and the Xcode version. You can use the maxim-lobanov/setup-xcode action to specify the Xcode version you want to use.

jobs:
  build:
    name: Build iOS
    runs-on: macos-15
    timeout-minutes: 45

    env:
      DOTNET_CLI_TELEMETRY_OPTOUT: 1
      DOTNET_VERSION: "net9.0"

    steps:

    - name: Checkout Code
      uses: actions/checkout@v4
      with:
        submodules: true

    - name: Set Xcode
      uses: maxim-lobanov/setup-xcode@v1
      with:
        xcode-version: "16.3"

Here, I have specified macOS 15 and Xcode 16.3. You can adjust these versions based on your requirements.

I have also set a timeout of 45 minutes for the job, because macOS jobs are very slow and are also very expensive to run. You want to prevent runaway jobs from costing you a fortune.

I set the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to 1 to disable telemetry, which is a good practice for CI/CD environments.

Lastly, I set the DOTNET_VERSION environment variable to net9.0 because it is repeated throughout these steps (and in build paths) and I like to minimize the things I need to change when updating .NET versions.

Now it’s time to install the .NET SDK. This is a two-step process:

  1. Install the .NET SDK using the actions/setup-dotnet action.
  2. Install the workloads needed for your MAUI app.
    - name: Setup .NET
      uses: actions/setup-dotnet@v4
      with:
        global-json-file: global.json

    - name: Install Workloads
      run: dotnet workload restore MyApp.sln

Here, I reference the global.json file to ensure the correct .NET SDK version is used. If you do not use a global.json file (why aren’t you?), specify the .NET SDK version directly in the dotnet-version input of the actions/setup-dotnet action.

Install Certificates and Provisioning Profiles

To build and sign your iOS app, you need to install the necessary certificates and provisioning profiles. This is a crucial step for iOS apps, as they require proper signing to run on devices and be distributed via Test Flight or the App Store.

The trick is to store your certificate as a GitHub secret. You’ll then restore that certificate to the keychain and then you’ll be able to automatically download the provisioning profile from Apple.

I use the apple-actions/import-codesign-certs action to import the certificate. It requires 2 things:

  1. The base64-encoded P12 file of your certificate. I store this in a GitHub secret named APPSTORE_CERTIFICATE_P12.
  2. The password for the P12 file. I store this in a GitHub secret named APPSTORE_CERTIFICATE_P12_PASSWORD.
    - name: Import Apple Certificate
      uses: apple-actions/import-codesign-certs@v4
      with:
        create-keychain: true
        keychain-password: ${{ secrets.APPSTORE_CERTIFICATE_P12_PASSWORD }}
        p12-file-base64: ${{ secrets.APPSTORE_CERTIFICATE_P12 }}
        p12-password: ${{ secrets.APPSTORE_CERTIFICATE_P12_PASSWORD }}

I use the same password for the keychain and the P12 file because I’m often using the same job to build macOS, iOS, and Catalyst apps and sharing the same keychain is convenient.

To generate the P12 file, you can use the Keychain Access app on your Mac. Export your certificate as a P12 file and save it somewhere with a password. Then, encode it to base64, which you can do with the following command:

base64 -i 'MyAppleCertificate.p12' | pbcopy

That will copy the base64-encoded string to your clipboard, which you can then paste into your GitHub secret. Make sure to also set the password for the P12 file as a GitHub secret.

Now we need to download the provisioning profile from Apple. This is done using the apple-actions/download-provisioning-profiles action. You need to provide the App ID and the Team ID for your Apple Developer account.

    - name: Download Provisioning Profile
      uses: apple-actions/download-provisioning-profiles@v4
      with: 
        bundle-id: 'com.example.myapp'
        profile-type: 'IOS_APP_STORE'
        issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
        api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
        api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}

(Set the bundle-id to match your app’s bundle identifier.)

The APPSTORE_ISSUER_ID, APPSTORE_KEY_ID, and APPSTORE_PRIVATE_KEY are also GitHub secrets that you need to set up. You can generate these from your Apple Developer account.

Go to https://appstoreconnect.apple.com/access/integrations/api to create an API key that you will use for GitHub Actions. This will give you the APPSTORE_ISSUER_ID, APPSTORE_KEY_ID, and the private key that you need to store as a GitHub secret.

Once all those secrets are set up, you can run the job and it will import the certificate and download the provisioning profile automatically.

Build the iOS App

This is the easy part. Take a stretch. Have some coffee. You’ve earned it.

All you need to do is run the dotnet publish command and pass it the project file of your MAUI app. Do not pass the solution as the build process is designed to work with the project file directly and will otherwise try to publish every project in the solution.

There are a few important flags to pass to the dotnet publish command:

  • -c Release: This specifies that you want to build the app in Release mode.
  • -f $-ios: This specifies the target framework for iOS. The env.DOTNET_VERSION variable is set to net9.0 in the env section of the job, so it will resolve to net9.0-ios.
  • -p:ArchiveOnBuild=true: This tells the build process to create an archive of the app, which is necessary for distribution.
  • -p:RuntimeIdentifier=ios-arm64: This specifies the runtime identifier for iOS.
  • "/p:CodesignKey=\"Apple Distribution: My Awesome Company, Inc. (XXX12AB34C)\"": This specifies the code signing key to use for signing the app. Replace this with your own code signing key name that you can see during the key import step. Escaping the quotes is necessary to ensure the command is parsed correctly and is a little insanity making, but it works.
    - name: Build
      run: |
        dotnet publish -c Release -f ${{env.DOTNET_VERSION}}-ios -p:ArchiveOnBuild=true -p:RuntimeIdentifier=ios-arm64 "/p:CodesignKey=\"Apple Distribution: My Awesome Company, Inc. (XXX12AB34C)\"" MyApp/MyApp.csproj

For details on all the wonderful options you can pass to the dotnet publish command, see the official documentation: Publish an iOS app using the command line.

Assuming you were a good developer, paid your taxes, and pass the karma test, this will build your app and create an .ipa file in the bin/Release/$-ios/ios-arm64/publish directory of your MAUI project.

Upload that Puppy to Test Flight

Finally, we need to upload the built .ipa file to Test Flight. This is done using the apple-actions/upload-testflight-build action.

    - name: Upload to TestFlight
      uses: apple-actions/upload-testflight-build@v1
      with:
        app-type: ios
        app-path: 'MyApp/bin/Release/${{env.DOTNET_VERSION}}-ios/ios-arm64/publish/MyApp.ipa'
        issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
        api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
        api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}

You were worried we were going to have to create more secrets weren’t you? Don’t lie. I know you were. Good news! You can use the same APPSTORE_ISSUER_ID, APPSTORE_KEY_ID, and APPSTORE_PRIVATE_KEY secrets that you used to download the provisioning profile.

This action will upload the .ipa file to Test Flight, where you can then test your app before releasing it to the App Store.

Conclusion

You are now ready to build and deploy your MAUI apps using GitHub Actions! Pat yourself on the back, that wasn’t easy. But the good news is that once you have this setup, you can reuse it for all your MAUI apps. Just make sure to adjust the bundle identifier and the project file path in the dotnet publish command.

Now go! Continuously integrate. Deploy continuously. And may your MAUI apps be bug-free and loved by users everywhere!