Building a CI Pipeline for Kotlin Multiplatform Mobile Using GitHub Actions

Nate Ebel
Engineering at Premise
10 min readOct 6, 2022

--

By Nate Ebel, Android Engineer

When evaluating a Kotlin Multiplatform solution, it was very important to our team that we understand how we could build a CI/CD pipeline for the project. It needed to be easy for our team to push changes and build a new version of the project.

Both our iOS and Android applications use GitHub Actions for our CI needs. So, in this post, we’ll walkthrough how to setup a GitHub Actions workflow for building a Kotlin Multiplatform Mobile project.

For this post, we are assuming that we already know how to build/integrate our shared code locally, and are focused on moving that process to CI.

For the tldr; you can jump to the end to see two sample workflows in their entirety. One for PR builds and one for Release builds.

One implementation note on the Swift Package distribution approach detailed in this post.

This approach is fairly straightforward as it simply publishes a built XCFramework, along with a Package.swift file to a GitHub repo. Xcode can then import this Swift package.

After using this approach for 1.5 years, we’ve identified a an issue with this approach. Xcode does not perform a shallow clone of the Swift Package repo, so when syncing new versions of our Swift Package, Xcode may need to fetch many versions of the built XCFramework which are stored in git history. After 1.5 years this became prohibitively slow.

Our team is currently migrating to a remote url distribution model for our Swift Package, and will write another post on that approach once completed. Additionally, the great folks over at Touchlab are working on some tooling to help make this process easier for more teams to setup from the beginning.

This is a part of an ongoing series on our usage of Kotlin Multiplatform at Premise:

Creating a GitHub Actions Workflow for Kotlin Multiplatform Mobile

To start, we’ll create a new workflow file at .github/workflows/build-release.yml. In this file, we’ll defined our GitHub Actions workflow.

// .github/workflows/build-release.ymlname: Build Release

In our case, we only trigger this workflow when a developer manually kicks off a build from the Actions tab. This is enabled by adding the workflow_dispatchtrigger to our workflow file.

// .github/workflows/build-release.ymlname: Build Release

on:
workflow_dispatch:

If you wanted to publish a new version on any change to your main branch, you could add a push trigger with a filter for the main branch.

// .github/workflows/build-release.ymlname: Build Release

on:
workflow_dispatch:
push:
branches: 'main'

Setting Up Our Workflow Environment

Next up, we’re going to set some environment variables for our build.

...env:
XCODE_VERSION: '13.4.1'
JAVA_VERSION: '14'
ARTIFACTORY_URL: ${{ secrets.ARTIFACTORY_URL }}
ARTIFACTORY_REPO: ${{ secrets.ARTIFACTORY_REPO }}
ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }}
ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }}
ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}

Let’s walk through why we’re using each of these.

We define constants for the Java and Xcode versions we’ll use for the build simply to pull those to the top of thee file and make theme easier to find.

XCODE_VERSION: '13.4.1'
JAVA_VERSION: '14'

The Artifactory variables are setup so we can make those values available to our JVM/Android artifact publication tasks. The actual values are pulled from GitHub Secrets so they can be hidden from the repo and rotated when needed.

ARTIFACTORY_URL: ${{ secrets.ARTIFACTORY_URL }}
ARTIFACTORY_REPO: ${{ secrets.ARTIFACTORY_REPO }}
ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }}
ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }}

And finally, we setup an access token that has read/push access to an additional GitHub repo within our org that we will ultimately used to serve our Swift Package artifact to the iOS application.

ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}

Configuring a Job to Build a Kotlin Multiplatform Mobile Project

Now, we can setup the Job that will actually build our project when our workflow is run.

The most important thing to call out here is that we’ll need to use a Mac OS runner if we want to build any iOS target. This is because we’ll need the Xcode Build Tools for our iOS compile/linking Gradle tasks.

Here, we’ve explicitly chosen the macos-12 runner, but you might also consider using macos-lateset if it has the tooling needed for your build.

You can find more details about available GitHub Actions runners here.

...jobs:
build:
name: Build & Test
runs-on: macos-12
steps:
...

Next, we can start adding individual build steps to our job.

Checking Out Kotlin Multiplatform and Swift Package Repos

The first build steps will be to checkout the two repos needed for the build:

  • Repo 1 is our mobile-shared, Kotlin Multiplatform, project. This is the repo we will actually build and generate output artifacts from.
  • Repo 2 is our mobile-shared-swift-package project. This repo only exists as a location for us to publish the XCFramework and Package.swift file generated from the Kotlin Multiplatform build process

To checkout each repo, we’ll use the standard actions/checkout GitHub Action.

First, we checkout the primary KMP repo. Because we’re going to checkout a second repo, we add explicit repository and path parameters here to clearly indicate which repo we are checking out, and where on disk it should be copied to. Knowing this location will be needed later when we start adding tags and committing changes to our Swift Package repository.

steps:
- name: Checkout
uses: actions/checkout@v3
with:
repository: premisedata/mobile-shared
path: mobile-shared

Next, we check out the repo that stores our Swift Package artifact. Again, here we are explicit about the repository and path so we can differentiate between it, and the primary repo that was checked out, when we need to push our new Swift Package to the repo.

We also explicitly pass a token to the checkout action. This is the token that was setup with our other environment variables. This extra token is needed to authorize requests to this second repo, because a workflow only has access to the repo its defined within by default.

steps:
- name: Checkout
uses: actions/checkout@v3
with:
repository: premisedata/mobile-shared
path: mobile-shared
- name: Checkout
uses: actions/checkout@v3
with:
repository: premisedata/mobile-tutorials-swift-package
path: mobile-tutorials-swift-package
token: ${{env.ACCESS_TOKEN}}

Setting up Java and Xcode Tooling

We want to be explicit in the versioning we use for our CI tooling so we get repeatable results. To help with this, we explicitly setup both the JVM and Xcode tooling versions on our runner.

steps:
...
- uses: actions/setup-java@v1
with:
java-version: ${{ env.JAVA_VERSION }}

- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: ${{ env.XCODE_VERSION }}

Extracting Project Version for Git Tagging

Once our XCFramework and Package.Swift file are generated, we ultimately want to consume them as a Swift Package from Xcode. To do this, we publish both files to our second mobile-shared-swift-package repo.

To resolve a given version of this package, Xcode will match a specified version code to a tag in the repo.

This means that any project version that is released needs to have a matching tag in pushed to the Swift Package repo.

Our version is defined as a Gradle project property within the project’sgradle.properties file.

// gradle.propertieslibraryVersion=1.0.0

To extract this version, and make it available to future workflow steps, we define a build step prints available Gradle properties, extracts the version using grep and cut, and stores it as a env variable in the workflow.

steps:
...
- name: Extract Version Info
run: |
cd mobile-shared
export ARTIFACT_VERSION=$(./gradlew properties | grep ^version: | cut -c 10-)
echo "ARTIFACT_VERSION=$ARTIFACT_VERSION" >> $GITHUB_ENV

In a later build step, we’ll access the saved env variable when tagging a new version of thee Swift Package.

git tag -a ${{ env.ARTIFACT_VERSION }}

Building the Artifacts

Now, it’s finally time to build the actual artifacts.

To do this, we’ll use gradle/gradle-build-action to run our Gradle tasks to build and test the project.

We explicitly define build-root-directory to point to our primary KMP repo when running the Gradle tasks. And the arguments parameters define the actual tasks we want to run; in this case build and createSwiftPackage

steps:
...
- name: Build Artifacts
uses: gradle/gradle-build-action@v2
with:
build-root-directory: mobile-shared
arguments: build createSwiftPackage
  • The build task will compile and test the code
  • The createSwiftPackage task coordinates the generation of a Package.swift file and and zipping/copying of the XCFramework into a more findable location

Publishing the JVM Artifacts

Once we’ve built the project, we can publish our artifacts.

The JVM/Android artifacts are published to our internal Maven repository; currently managed with an internal Artifactory instance. Here, we use those Artifactory env variables we defined initially to configure the publication tasks so they are uploaded to the correct locations.

steps:
...
- name: Publish JVM Artifacts
uses: gradle/gradle-build-action@v2
with:
build-root-directory: mobile-shared
arguments: artifactoryPublish artifactoryDeploy -PARTIFACTORY_URL=${{env.ARTIFACTORY_URL}} -PARTIFACTORY_REPO=${{env.ARTIFACTORY_REPO}} -PARTIFACTORY_USERNAME=${{env.ARTIFACTORY_USERNAME}} -PARTIFACTORY_PASSWORD=${{env.ARTIFACTORY_PASSWORD}}

Publishing the Swift Package

Publishing our Swift Package artifact is more involved than on the JVM side.

First, we run a build step that removes the existing XCFramework file from our mobile-shared-swift-package repo. This is done so there’s only one version of the framework in the repo for any given commit.

steps:
...
- name: Prepare iOS Artifacts
run: |
rm -f mobile-shared-swift-package/mobileshared-*
cp -r mobile-shared/shared/swiftpackage/* mobile-shared-swift-package

Next, we “publish” the new Swift Package to our repo by committing the updated XCFramework and Package.swift file. In addition to committing the changes, we must be sure to tag the commit with the project version so Xcode can resolve this new named version of our Kotlin Multiplatform code.

steps:
...
- name: Prepare iOS Artifacts
run: |
rm -f mobile-shared-swift-package/mobileshared-*
cp -r mobile-shared/shared/swiftpackage/* mobile-shared-swift-package
- name: Publish iOS Artifacts
run: |
cd mobile-shared-swift-package
git add --all
git commit -m "Update to version ${{ env.ARTIFACT_VERSION }}"
git tag -a ${{ env.ARTIFACT_VERSION }} -m "mobile-shared v${{ env.ARTIFACT_VERSION }}"
git push
git push origin ${{ env.ARTIFACT_VERSION }}

The last step here is to tag the Kotlin Multiplatform project itself so the source code has a tag matching the release tag. This step isn’t necessary for integration, but helps us match versions to the code that generated them.

steps:
...
- name: Prepare iOS Artifacts
run: |
rm -f mobile-shared-swift-package/mobileshared-*
cp -r mobile-shared/shared/swiftpackage/* mobile-shared-swift-package
- name: Publish iOS Artifacts
run: |
cd mobile-shared-swift-package
git add --all
git commit -m "Update to version ${{ env.ARTIFACT_VERSION }}"
git tag -a ${{ env.ARTIFACT_VERSION }} -m "mobile-shared v${{ env.ARTIFACT_VERSION }}"
git push
git push origin ${{ env.ARTIFACT_VERSION }}
- name: Tag Release
run: |
cd mobile-shared
git tag -a ${{ env.ARTIFACT_VERSION }} -m "mobile-shared v${{ env.ARTIFACT_VERSION }}"
git push
git push origin ${{ env.ARTIFACT_VERSION }}

Once these commits and tags are pushed to the mobile-shared-swift-package repo, Xcode should be able to resolve the new version of our library.

Reviewing the Final Release Workflow Solution

To help visualize everything together, here’s a complete look at our solution. Some bits of this have been modified for brevity, but all the core steps are here.

// .github/workflows/build-release.ymlname: Build Release

on:
workflow_dispatch:
push:
branches: 'release'

env:
XCODE_VERSION: '13.4.1'
JAVA_VERSION: '14'
ARTIFACTORY_URL: ${{ secrets.ARTIFACTORY_URL }}
ARTIFACTORY_REPO: ${{ secrets.ARTIFACTORY_REPO }}
ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }}
ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }}
ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}

jobs:
build:
name: Build & Test
runs-on: macos-12
steps:
- name: Checkout
uses: actions/checkout@v3
with:
repository: premisedata/mobile-shared
path: mobile-shared

- name: Checkout
uses: actions/checkout@v3
with:
repository: premisedata/mobile-shared-swift-package
path: mobile-shared-swift-package
token: ${{env.ACCESS_TOKEN}}

- uses: actions/setup-java@v1
with:
java-version: ${{ env.JAVA_VERSION }}

- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: ${{ env.XCODE_VERSION }}

- name: Extract Version Info
run: |
cd mobile-shared
export ARTIFACT_VERSION=$(./gradlew properties | grep ^version: | cut -c 10-)
echo "ARTIFACT_VERSION=$ARTIFACT_VERSION" >> $GITHUB_ENV

- name: Build Artifacts
uses: gradle/gradle-build-action@v1
with:
build-root-directory: mobile-shared
arguments: build createSwiftPackage

- name: Publish JVM Artifacts
uses: gradle/gradle-build-action@v1
with:
build-root-directory: mobile-shared
arguments: artifactoryPublish artifactoryDeploy -PARTIFACTORY_URL=${{env.ARTIFACTORY_URL}} -PARTIFACTORY_REPO=${{env.ARTIFACTORY_REPO}} -PARTIFACTORY_USERNAME=${{env.ARTIFACTORY_USERNAME}} -PARTIFACTORY_PASSWORD=${{env.ARTIFACTORY_PASSWORD}}

- name: Prepare iOS Artifacts
run: |
rm -f mobile-shared-swift-package/mobileshared-*
cp -r mobile-shared/shared/swiftpackage/* mobile-shared-swift-package

- name: Publish iOS Artifacts
run: |
cd mobile-shared-swift-package
git add --all
git commit -m "Update to version ${{ env.ARTIFACT_VERSION }}"
git tag -a ${{ env.ARTIFACT_VERSION }} -m "mobile-shared v${{ env.ARTIFACT_VERSION }}"
git push
git push origin ${{ env.ARTIFACT_VERSION }}

- name: Tag Release
run: |
cd mobile-shared
git tag -a ${{ env.ARTIFACT_VERSION }} -m "mobile-shared v${{ env.ARTIFACT_VERSION }}"
git push
git push origin ${{ env.ARTIFACT_VERSION }}

Reviewing a PR Workflow Solution

We’ve walked through a version of our release workflow that builds, tests, and publishes artifacts for our Kotlin Multiplatform project. Because it’s doing a full release, it has some extra build steps that are not required if you simply want to setup a CI workflow for your PRs.

To round out our discussion, here’s an example of a PR workflow for a Kotlin Multiplatform project.

// .github/workflows/pull-request.ymlname: Build Pull Request

on:
pull_request:
branches:
- 'main'
- 'dev'
- 'release'

env:
XCODE_VERSION: '13.4.1'
JAVA_VERSION: '14'

jobs:
build:
name: Build & Test
runs-on: macos-12
steps:
- name: Checkout
uses: actions/checkout@v3

- uses: actions/setup-java@v1
with:
java-version: ${{ env.JAVA_VERSION }}

- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: ${{ env.XCODE_VERSION }}

- name: Build
id: gradle
uses: gradle/gradle-build-action@v2
with:
arguments: check

Final Thoughts

These workflows have worked well for us in the past 1.5 years. Our publishing model for our Swift Package is evolving, but the workflow itself has worked great.

As linking native binaries for the XCFrameworks is slow, and running CI jobs is not free, it’s a good idea to ensure you’re only building native targets for iOS platforms you actually need. For example, you might find that you no longer need an X64 version of your code, and could stop building that in CI to save money.

Premise is constantly looking to hire top-tier engineering talent to help us improve our front-end, mobile offerings, data and cloud infrastructure. Come find out why we’re consistently named among the top places to work in Silicon Valley by visiting our Careers page.

--

--