How to publish a Kotlin Multiplatform iOS app on App Store with GitHub Actions

SERIES: Publishing a Kotlin Multiplatform Android, iOS, and macOS app with GitHub Actions.

It’s been almost a year since I started working on FeedFlow, an RSS Reader available on Android, iOS, and macOS, built with Jetpack Compose for the Android app, Compose Multiplatform for the desktop app, and SwiftUI for the iOS app.

To be faster and “machine-agnostic” with the deployments, I decided to have a CI (Continuous Integration) on GitHub Actions to quickly deploy my application to all the stores (Play Store, App Store for iOS and macOS, and on GitHub release for the macOS app).

In this post, I will show how to deploy a Kotlin Multiplatform iOS app on the App Store. This post is part of a series dedicated to setting up a CI for deploying a Kotlin Multiplatform app on Google Play, Apple App Store for iOS and macOS, and GitHub releases for distributing a macOS app outside the App Store. To keep up to date, you can check out the other instances of the series in the index above or follow me on Mastodon or Twitter.

Triggers

A trigger is necessary to start the GitHub Action. I’ve decided to trigger a new release when I add a tag that ends with the platform name, in this case, -ios. So, for example, a tag would be 0.0.1-ios.

on:
  push:
    tags:
      - '*-ios'

In this way, I can be more flexible when making platform-independent releases.

Gradle and JDK setup

The first part of the pipeline involves cloning the repo and setting up the infrastructure: JDK and Gradle.

Clone the repository

The actions/checkout action can be used to clone the repository:

- uses: actions/checkout@v4
  with:
    fetch-depth: 0

Set Xcode version

The maxim-lobanov/setup-xcode action can be used to set the Xcode version:

- uses: maxim-lobanov/setup-xcode@v1
  with:
    xcode-version: latest-stable

I prefer to explicitly set the version to ensure that I’m ready for any future changes that may require a specific version different from the default provided by GitHub runners.

JDK Setup

The actions/setup-java action can be used to set up a desired JDK. I want the zulu distribution and version 18 in this case.

- name: set up JDK
  uses: actions/setup-java@v4
  with:
    distribution: 'zulu'
    java-version: 18

Gradle Setup

The gradle/actions/setup-gradle action can be used to set up Gradle with its cache.

In the action, I’m using two parameters: gradle-home-cache-cleanup and cache-encryption-key.

The gradle-home-cache-cleanup parameter will enable a feature that will try to delete any files in the Gradle User Home that were not used by Gradle during the GitHub Actions Workflow before saving the cache. This way, some space can be saved. More info can be found in the documentation.

Instead, the cache-encryption-key parameter provides an encryption key from the GitHub secrets to encrypt the configuration cache. The configuration cache might contain stored credentials and other secrets, so encrypting it before saving it on the GitHub cache is better. More info can be found in the documentation.

- uses: gradle/actions/setup-gradle@v3
  with:
    gradle-home-cache-cleanup: true
    cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }}

Kotlin Native setup

When compiling a Kotlin Multiplatform project that also targets Kotlin Native, some required components will be downloaded in the $USER_HOME/.konan directory. Kotlin Native will also create and use some cache in this directory.

├── .konan
│   ├── cache
│   ├── dependencies
│   └── kotlin-native-prebuilt-macos-aarch64-1.9.23

Caching that directory will avoid redownloading and unnecessary computation. The actions/cache action can be used to do so.

The action requires a key to identify the cache uniquely; in this case, the key will be a hash of the version catalog file since the Kotlin version number is stored there:

- name: Cache KMP tooling
  uses: actions/cache@v4
  with:
    path: |
            ~/.konan
    key: ${{ runner.os }}-v1-${{ hashFiles('*.versions.toml') }}

[Optional] Firebase configuration or other secrets

GitHub secrets can be leveraged to store any sensitive stuff or configuration that can’t be exposed to version control.

To do so, any file can be encoded with base64 and saved inside a GitHub secret.

base64 -i myfile.extension

Then, the GitHub action can decode the content and create the file. For example, here’s the step for the Firebase plist configuration:

- name: Create Firebase Plist
  run: |
    echo "$FIREBASE_PLIST" > iosApp/GoogleService-Info.plist.b64
    base64 -d -i iosApp/GoogleService-Info.plist.b64 > iosApp/GoogleService-Info.plist    
  env:
    FIREBASE_PLIST: ${{ secrets.FIREBASE_PLIST }}

Setup signing certificates

Every iOS application must be signed to be distributed in the app store. The certificates required to sign an iOS application for distribution are called Apple development and Apple distribution. Those certificates can be generated and downloaded from the Apple Developer website by uploading a Certificate Signing Request.

This request can be obtained from the Keychain app on macOS by opening the menu Keychain Access > Certificate Assistant > Request a Certificate From a Certificate Authority. An email must be added to the form that will appear, and the Save to disk option must be selected. The CA Email address field can be blank because the request will be saved on the disk. More information can be found in the Apple documentation.

The certificates can be imported into GitHub Action by using the p12 format, an archive file format for storing many cryptography objects as a single file (Wikipedia).

The Keychain app can be used to generate the p12 file. After downloading the certificates, they must be imported into the Keychain app. Once imported, the certificates can be easily exported by selecting them in the Keychain, right-clicking, and selecting the Export 2 items… option. A password will be used to encrypt the p12 file.

The import-codesign-certs action can be used to import the certificate in the p12 format. To do so, the p12 file must be encoded in base64 (as described in the section above), and the content must be uploaded into GitHub secrets along with the decryption password.

- name: import certs
  uses: apple-actions/import-codesign-certs@v2
  with:
    p12-file-base64: ${{ secrets.CERTIFICATES_P12 }}
    p12-password: ${{ secrets.CERTIFICATES_PASSWORD }}

Setup provisioning profiles

A provisioning profile is required to distribute an iOS app besides signing the app. The provisioning profile ensures that a trusted developer in the Apple Developer Program created and signed the app. This measure prevents unauthorized apps from being used because iOS validates the provisioning profile to ensure that it has been signed with a legitimate certificate from the developer’s account.

Two provisioning profiles are required: one for building the app and one for distributing it. The former is called iOS App Development while the latter is called App Store Connect. Those profiles can be created on the Apple Developer Website.

The App ID is required to create a provisioning profile. If it has not been created (Xcode might already have created one automatically), it can be done on the Apple Developer Website.

Once the provisioning profiles are created, they can be downloaded with the apple-actions/download-provisioning-profiles action. This action retrieves the profiles using the App Store Connect API. To do so, it’s necessary to create an API key with the App Manager access in the App Store Connect website.

The action requires the following data that can be saved inside GitHub secrets:

  • bundle ID of the app;
  • issuer ID, which identifies the issuer who created the authentication token (it can be found on the App Store connect page mentioned above);
  • key ID of the API (it can be found on the App Store connect page mentioned above);
  • private key of the API (it can be downloaded when creating the API key)
- name: download provisioning profiles
  uses: apple-actions/download-provisioning-profiles@v2
  with:
    bundle-id: ${{ secrets.BUNDLE_ID }}
    issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
    api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
    api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}

Build the app

The xcodebuild command line tool can be used to build the iOS app. The command requires some arguments that can be hardcoded directly or provided through GitHub secrets, depending on the level of sensitivity:

  • scheme: the app’s scheme;
  • configuration: Release;
  • SDK: iphoneos;
  • Derived data path: ${RUNNER_TEMP}/Build/DerivedData (the environmental variables ${RUNNER_TEMP} points to a temporary folder created by the action runner);
  • archive path: ${RUNNER_TEMP}/Build/Archives/FeedFlow.xcarchive;
  • result bundle path: ${RUNNER_TEMP}/Build/Artifacts/FeedFlow.xcresult;
  • device destination: generic/platform=iOS for App Store distribution;
  • DEVELOPMENT_TEAM: team ID;
  • PRODUCT_BUNDLE_IDENTIFIER: bundle ID of the app;
  • CODE_SIGN_STYLE: Manual, as signing is performed manually with xcodebuild;
  • PROVISIONING_PROFILE_SPECIFIER,: name of the iOS App Development provisioning profile chosen when creating it.
- name: build archive
  run: |
    cd iosApp

    xcrun xcodebuild \
      -scheme "FeedFlow" \
      -configuration "Release" \
      -sdk "iphoneos" \
      -parallelizeTargets \
      -showBuildTimingSummary \
      -disableAutomaticPackageResolution \
      -derivedDataPath "${RUNNER_TEMP}/Build/DerivedData" \
      -archivePath "${RUNNER_TEMP}/Build/Archives/FeedFlow.xcarchive" \
      -resultBundlePath "${RUNNER_TEMP}/Build/Artifacts/FeedFlow.xcresult" \
      -destination "generic/platform=iOS" \
      DEVELOPMENT_TEAM="${{ secrets.APPSTORE_TEAM_ID }}" \
      PRODUCT_BUNDLE_IDENTIFIER="${{ secrets.BUNDLE_ID }}" \
      CODE_SIGN_STYLE="Manual" \
      PROVISIONING_PROFILE_SPECIFIER="${{ secrets.DEV_PROVISIONING_PROFILE_NAME }}" \
      archive    

Generate export options plist file

Specific parameters, such as the export method, team ID, and the name of the App Store Connect provisioning profile, must be defined to produce the application archive. These parameters can be provided through a plist file. The plist file can be generated dynamically by incorporating the necessary data from GitHub secrets to prevent these details from being included directly in the source control. The file will be saved in the directory where the compiled code is stored, as specified in the previous section. In this case, ${RUNNER_TEMP}/Build.

- name: "Generate ExportOptions.plist"
  run: |
    cat <<EOF > ${RUNNER_TEMP}/Build/ExportOptions.plist
    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    <plist version="1.0">
      <dict>
        <key>destination</key>
        <string>export</string>
        <key>method</key>
        <string>app-store</string>
        <key>signingStyle</key>
        <string>manual</string>
        <key>generateAppStoreInformation</key>
        <true/>
        <key>stripSwiftSymbols</key>
        <true/>
        <key>teamID</key>
        <string>${{ secrets.APPSTORE_TEAM_ID }}</string>
        <key>uploadSymbols</key>
        <true/>
        <key>provisioningProfiles</key>
        <dict>
          <key>${{ secrets.BUNDLE_ID }}</key>
          <string>${{ secrets.DIST_PROVISIONING_PROFILE_NAME }}</string>
        </dict>
      </dict>
    </plist>
    EOF    

Generate IPA for distribution

The xcodebuild command line tool can be used to generate the archive (IPA) that will be uploaded on the App Store. The command requires some arguments that can be hardcoded directly or provided through GitHub secrets, depending on the level of sensitivity:

  • export option plist path defined in the previous step: ${RUNNER_TEMP}/Build/ExportOptions.plist;
  • archive path defined in the building step: ${RUNNER_TEMP}/Build/Archives/FeedFlow.xcarchive;
  • export path: ${RUNNER_TEMP}/Build/Archives/FeedFlow.xcarchive;
  • PRODUCT_BUNDLE_IDENTIFIER: bundle ID of the app.

The IPA will be saved in the following path: ${RUNNER_TEMP}/Build/Archives/FeedFlow.xcarchive/FeedFlow.ipa. This information can be stored on GitHub environmental variables to be used in the final step of the action.

- id: export_archive
  name: export archive
  run: |
    xcrun xcodebuild \
      -exportArchive \
      -exportOptionsPlist "${RUNNER_TEMP}/Build/ExportOptions.plist" \
      -archivePath "${RUNNER_TEMP}/Build/Archives/FeedFlow.xcarchive" \
      -exportPath "${RUNNER_TEMP}/Build/Archives/FeedFlow.xcarchive" \
      PRODUCT_BUNDLE_IDENTIFIER="${{ secrets.BUNDLE_ID }}"

    echo "ipa_path=${RUNNER_TEMP}/Build/Archives/FeedFlow.xcarchive/FeedFlow.ipa" >> $GITHUB_ENV    

Upload on TestFlight

An iOS app can be uploaded to the App Store through TestFlight. The upload can be performed with the upload-testflight-build action.

As for the provisioning profile, this action uses the App Store Connect API to communicate with TestFlight. For this reason, the action requires the same issuer ID, key ID, and private key used in the provisioning step. Additionally, it requires the path of the IPA archive, which can provided by GitHub environmental variables.

- uses: Apple-Actions/upload-testflight-build@v1
  with:
    app-path: ${{ env.ipa_path }}
    issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
    api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
    api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}

Conclusions

And that’s all the steps required to automatically publish a Kotlin Multiplatform iOS app on the App Store with a GitHub Action.

Here’s the entire GitHub Action for reference:

name: iOS TestFlight Release
on:
  push:
    tags:
      - '*-ios'

jobs:
  deploy:
    runs-on: macos-14
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          
      - uses: maxim-lobanov/setup-xcode@v1
        with:
          xcode-version: latest-stable

      - name: Setup Gradle
        uses: ./.github/actions/setup-gradle
        with:
          gradle-cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }}

      - name: Create Firebase Plist
        run: |
          echo "$FIREBASE_PLIST" > iosApp/GoogleService-Info.plist.b64
          base64 -d -i iosApp/GoogleService-Info.plist.b64 > iosApp/GoogleService-Info.plist          
        env:
          FIREBASE_PLIST: ${{ secrets.FIREBASE_PLIST }}

      - name: import certs
        uses: apple-actions/import-codesign-certs@v2
        with:
          p12-file-base64: ${{ secrets.CERTIFICATES_P12 }}
          p12-password: ${{ secrets.CERTIFICATES_PASSWORD }}

      - name: download provisioning profiles
        uses: apple-actions/download-provisioning-profiles@v2
        with:
          bundle-id: ${{ secrets.BUNDLE_ID }}
          issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
          api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
          api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}

      - name: build archive
        run: |
          cd iosApp
          
          xcrun xcodebuild \
            -scheme "FeedFlow" \
            -configuration "Release" \
            -sdk "iphoneos" \
            -parallelizeTargets \
            -showBuildTimingSummary \
            -disableAutomaticPackageResolution \
            -derivedDataPath "${RUNNER_TEMP}/Build/DerivedData" \
            -archivePath "${RUNNER_TEMP}/Build/Archives/FeedFlow.xcarchive" \
            -resultBundlePath "${RUNNER_TEMP}/Build/Artifacts/FeedFlow.xcresult" \
            -destination "generic/platform=iOS" \
            DEVELOPMENT_TEAM="${{ secrets.APPSTORE_TEAM_ID }}" \
            PRODUCT_BUNDLE_IDENTIFIER="${{ secrets.BUNDLE_ID }}" \
            CODE_SIGN_STYLE="Manual" \
            PROVISIONING_PROFILE_SPECIFIER="${{ secrets.DEV_PROVISIONING_PROFILE_NAME }}" \
            archive          

      - name: "Generate ExportOptions.plist"
        run: |
          cat <<EOF > ${RUNNER_TEMP}/Build/ExportOptions.plist
          <?xml version="1.0" encoding="UTF-8"?>
          <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
          <plist version="1.0">
            <dict>
              <key>destination</key>
              <string>export</string>
              <key>method</key>
              <string>app-store</string>
              <key>signingStyle</key>
              <string>manual</string>
              <key>generateAppStoreInformation</key>
              <true/>
              <key>stripSwiftSymbols</key>
              <true/>
              <key>teamID</key>
              <string>${{ secrets.APPSTORE_TEAM_ID }}</string>
              <key>uploadSymbols</key>
              <true/>
              <key>provisioningProfiles</key>
              <dict>
                <key>${{ secrets.BUNDLE_ID }}</key>
                <string>${{ secrets.DIST_PROVISIONING_PROFILE_NAME }}</string>
              </dict>
            </dict>
          </plist>
          EOF          

      - id: export_archive
        name: export archive
        run: |
          xcrun xcodebuild \
            -exportArchive \
            -exportOptionsPlist "${RUNNER_TEMP}/Build/ExportOptions.plist" \
            -archivePath "${RUNNER_TEMP}/Build/Archives/FeedFlow.xcarchive" \
            -exportPath "${RUNNER_TEMP}/Build/Archives/FeedFlow.xcarchive" \
            PRODUCT_BUNDLE_IDENTIFIER="${{ secrets.BUNDLE_ID }}"
          
          echo "ipa_path=${RUNNER_TEMP}/Build/Archives/FeedFlow.xcarchive/FeedFlow.ipa" >> $GITHUB_ENV          

      - uses: Apple-Actions/upload-testflight-build@v1
        with:
          app-path: ${{ env.ipa_path }}
          issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
          api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
          api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}

You can check the action on GitHub