· 13 min read Posted by Kevin Galligan
KMMBridge Without GitHub
Overview
KMMBridge is ultimately a component that needs to exist in a larger build ecosystem. Production build environments tend to be fairly complex and custom, so attempting to provide specific instructions for a range of environments would be a lot of work, and generally fall short of the mark.
Many teams trying KMP want to do a quick POC, but that involves a lot of up front work. We added a “batteries-included” flow for GitHub, which will allow you to create and publish shared Kotlin code to your native mobile teams. Using that flow requires that you’re on GitHub.
It may seem like using KMMBridge in any other environment is significantly more difficult, but that is because we are able to pull a lot of info from GitHub while running, and have made opinionated defaults. You will need to supply that info and make those decisions yourself if you are using KMMBridge in a different environment.
This post will not explain how to do that in your environment. Build environments and needs vary considerably. This post will be a rough map down the road of what you will need to configure, and why.
Basic Parts
There are a few minimum steps required to publish binaries with KMMBridge. Beyond those, you’ll need to make some decisions about your development workflow, versioning, and dependency manager integrations (SPM and CocoaPods). We’ll mostly follow what we did with the KMMBridge Quick Start, but explain the thought process behind those decisions and point out where you can divert from those in your own environment.
What does KMMBridge Do?
KMMBridge itself only cares about Xcode Frameworks built from Kotlin. How to package them, publish them, and have users consume them. KMMBridge is not involved in your Android (or other) targets, does not impose requirements on your dev workflow, and beyond a few uses of version
, does not care about how you version your builds.
When you apply KMMBridge to a Kotlin module, it does the following:
- Find all Kotlin/Native targets with a Framework output.
- Converts those Framework instances to XCFramework.
- Creates a zip with all of those XCFramework instances.
- Pushes that zip to the storage location of your choice.
- For SPM, it will write a
Package.swift
file in the repo root, with info about that zip. - For CocoaPods, it will create a podspec, and attempt to publish it to the git Podspec repo of your choice.
That is (essentially) it. Everything else that our KMMBridge Quick Start does is an opinionated decision on our part about how you would develop shared Kotlin code in a small environment.
Framework Configuration
KMMBridge should be applied on the Kotlin module that produces an Xcode Framework. KMMBridge will automatically detect that configuration, and wrap it with XCFramework
from the Kotlin Gradle plugin (reference).
KMMBridge will then add its tasks and create dependencies on these XCFramework
assemblies. By default, KMMBridge will build RELEASE
versions, but you can override this.
XCFramework
config in your build file, KMMBridge will have issues. If you would like to have the ability to let KMMBridge bypass that part of the config, please reach outGenerally speaking, you don’t need to decide much for Framework config. Just be aware of what KMMBridge is doing.
Artifact Publishing
Your XCFramework zip file needs to live somewhere. “Where” is not critical, as long as you can push to it, and Xcode (& friends) can access it.
Pushing is more flexible. KMMBridge only comes with 2 implementations of ArtifactManager
out of the box. That means if you have a custom location, it probably won’t be compatible with the stock implementations. However, writing your own is simple.
-
Maven: KMMBridge ships with an
ArtifactManager
that can publish to a Maven repo. We use this for GitHub Packages, but you can use it for other Maven repos. -
AWS S3: KMMBridge also ships with an implementation for AWS S3. See docs. You cannot pull private S3 files with basic auth, so using S3 for private binaries is not simple. However, you can mark your files public, and use S3 to distribute public libraries.
Pulling binaries into Xcode is less simple, mostly because there are not many options. If the URL is public, well, no problem. That’ll work, as long as it’s https
and not some less-common protocol (s3:
comes to mind). For private urls, if you can do basic auth, you should be good. If you need something more exotic, that is beyond the scope of this post. Generally speaking, when this is the case, you’ll need to configure your dev machine to be able to access these private files directly. Xcode’s config only handles basic auth. You’ll generally need some kind of VPN config. Many larger orgs do have private network access to things like S3 or shared drives. That config is outside the scope of KMMBridge or Xcode config.
Dependency Manager(s)
While you can directly drag Xcode Frameworks into a project, pretty much everybody uses a dependency manager for obvious reasons. There are a few, but the only two that really matter are Swift Package Manager (SPM) and CocoaPods (sorry Carthage :().
If you have an internal team, it is likely that you’ll only need one of these. If this was 2020/2021, I’d say probably CocoaPods. In 2023, probably SPM.
KMMBridge directly supports SPM and CocoaPods. You can configure KMMBridge to publish to either SPM, CocoaPods, or both. Generally speaking, for internal teams, you’ll be using one or the other, but KMMBridge can publish to both (and, while a little complicated, you can use both in a single Xcode project).
SPM
Most teams now seem to be moving to SPM. To setup and configure a publishing flow with SPM, you should really understand the basics of how SPM works. From our experience, SPM functions well and is pretty smooth once set up, but that is because SPM is rather strict on how you set it up.
You can read the overview in the KMMBridge Docs.
In summary:
- An SPM library lives in a git repo.
- The Package.swift file for that library lives at the root of that repo.
- Versioning of the library is strict semantic versioning, ex 1.2.3.
- Versions are marked by git tags.
KMMBridge writes the Package.swift
file pointing at your remote artifact, and our GitHub flow deals with git version tags. When writing your own CI, be sure to pay attention to the git tag details, as specifying versions and committing tags is done by the CI process.
Package.swift
file really needs to live in the repo root. We have tried a number of different configurations, but ultimately ended up fighting SPM. It is best to do things the way SPM expects. KMMBridge assumes a rather simple SPM configuration. If you want/need something more custom, we would like to hear about the use cases. The best place for this is probably Discussions in the KMMBridge repo.CocoaPods
CocoaPods uses a separate git repo to hold package config info, in the form of podspec files. CocoaPods has a CLI tool to publish these, which we use. For that to work, you need to have a separate repo, and access to that repo needs to be configured correctly. CocoaPods publishing has a number of points at which it can break as a result. Be prepared to dig into this for a while.
The rules around versioning are a little less strict, but CocoaPods is a little less strict about everything in general.
Be sure to refer to the KMMBridge CocoaPods Docs for more info.
Publishing Workflow
You will need to make some decisions about publishing workflow. In fact, whether you’re using GitHub or another CI, if your intended use case differs from our GitHub Flow, you will need to make some customizations.
A lot of the extra “stuff” in our GitHub Flow is focused around incremental version numbering. This allows a dev build process that can have frequent publications under a major/minor semver version. If you are handling versions in a different way, you’ll need to customize the flow.
GitHub Flow, Step By Step
Our main GitHub Actions workflow is here.
You would call that workflow from another repo (see example, passing in arguments for various features.
Let’s walk through the steps in the flow to discuss what it does.
Checkout the repo with tags
- name: Checkout the repo with tags
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
Nothing complex here. The only really important part to point out is you’ll want to make sure you have tags for versioning, assuming you’re using SPM.
Version Base Property
- uses: touchlab/read-property@0.1
id: versionBasePropertyValue
with:
file: ./gradle.properties
property: ${{ inputs.versionBaseProperty }}
- name: Print versionBasePropertyValue
id: output
run: echo "${{ steps.versionBasePropertyValue.outputs.propVal }}"
This is a property defined in gradle.properties
that is specific to our flow. It is the “base version”. For development, we supply the major/minor part of a semver version, and automatically increment the patch version on each publication.
So, in our sample template, we have the following:
LIBRARY_VERSION=0.1
This will likely be custom to your workflow. Grabbing and incrementing the version is tied to GitHub actions. As far as KMMBridge is concerned, it just needs a regular Gradle version
defined.
Touchlab Sample Sanity Check
- name: Touchlab Sample Sanity Check (Ignore this for your CI)
uses: touchlab/sample-group-sanity-check@0.1
You can just ignore this. We added this to make sure somebody using our template didn’t read instructions and attempted to publish with the same group
value. GitHub Packages can get weird with namespaces. Your CI should have no use for this step.
AutoVersion-NextVersion
- uses: touchlab/autoversion-nextversion@main
id: autoversion
with:
versionBase: ${{ steps.versionBasePropertyValue.outputs.propVal }}
- name: Print Next Version
id: outputversion
run: echo "${{ steps.autoversion.outputs.nextVersion }}"
Autoversion-nextversion is a GitHub Action, written in Typescript. The logic is relatively simple. It runs through the git tags to find the highest version for the provided base version (major/minor semver).
For example, if your base version is 2.1
, then autoversion-nextversion
will scan your tags for that base version. If the matching tags were as follows:
2.1.0
2.1.2
2.1.3
2.1.4
autoversion-nextversion
would calculate the next version as 2.1.5
.
There is one special note to point out. Because publishing can fail, and by that point an artifact may have been pushed already, a build will mark a version with a temp prefix. The prefix is autoversion-tmp-publishing-
. So, if the list of versions is actually the following:
2.1.0
2.1.2
2.1.3
2.1.4
autoversion-tmp-publishing-2.1.5
The next calculated version would be 2.1.6
.
AutoVersion-TagMarker
- uses: touchlab/autoversion-tagmarker@main
id: autoversion-tagmarker
with:
nextVersion: ${{ steps.autoversion.outputs.nextVersion }}
This is another GitHub Action that is part of the AutoVersion family. This action actually runs twice: once at the start, and once when the publication is successful, to clean up. When a publication is starting, this action will add the marker tag mentioned above.
AutoVersion-BuildBranch
- uses: touchlab/autoversion-buildbranch@main
id: autoversion-buildbranch
with:
buildBranch: "build-${{ steps.autoversion.outputs.nextVersion }}"
This is yet another GitHub Action. This one is very simple. It just creates a branch for the build. The available actions didn’t quite do what we wanted, so we created this one.
Apply SSH Key
- name: Apply SSH Key
if: ${{ env.PODSPEC_SSH_KEY_EXISTS == 'true' }}
uses: webfactory/ssh-agent@v0.5.4
with:
ssh-private-key: ${{ secrets.PODSPEC_SSH_KEY }}
This is only necessary for CocoaPods publication, assuming you’re using SSH to handle auth for publishing. See Publishing Podspecs to GitHub for details.
Apply netrc
- uses: extractions/netrc@v1
with:
machine: ${{ inputs.netrcMachine }}
username: ${{ secrets.netrcUsername != '' && secrets.netrcUsername || 'cirunner' }}
password: ${{ secrets.netrcPassword != '' && secrets.netrcPassword || secrets.GITHUB_TOKEN }}
This is for CocoaPods publishing and private URLs. CocoaPods publication involves actually building a test app with the publishing framework, to confirm that everything is set up correctly. To access binaries from private repos, or even public GitHub repos, you need to set up authentication with netrc. This step pulls values from GitHub in the default GitHub workflow, but you can specify values if needed.
JVM/KMP Build Setup
- uses: actions/setup-java@v2
with:
distribution: "adopt"
java-version: ${{ inputs.jvmVersion }}
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
- name: Cache build tooling
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.konan
key: ${{ runner.os }}-v4-${{ hashFiles('*.gradle.kts') }}
These tasks simply prepare the build setup for KMP.
Build and Publish
- name: Build Main
run: ./gradlew ${{ env.MODULE }}${{ inputs.publishTask }} -PAUTO_VERSION=${{ steps.autoversion.outputs.nextVersion }} -PENABLE_PUBLISHING=true -PGITHUB_PUBLISH_TOKEN=${{ secrets.GITHUB_TOKEN }} -PGITHUB_REPO=${{ github.repository }} ${{ secrets.gradle_params }} --no-daemon --stacktrace
env:
GRADLE_OPTS: -Dkotlin.incremental=false -Dorg.gradle.jvmargs="-Xmx3g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=512m"
This task publishes the KMP library/libraries. For our GitHub Workflow, you can pass in an alternative task, or tasks, but the default task is kmmBridgePublish
. That is a KMMBridge-specific task that publishing the Apple Xcode Framework. You will need some task to actually publish your binaries (obviously).
Our template project, KMMBridge With SKIE Template, supports two basic flows.
- Publish Xcode Frameworks only
- Publish Android aars and Xcode Frameworks
Read the template docs to see how that works.
Finish Release
- uses: touchlab/autoversion-finishrelease@main
id: autoversion-finishrelease
with:
commitMessage: "KMM SPM package release for ${{ steps.autoversion.outputs.nextVersion }}"
tagMessage: "KMM release version ${{ steps.autoversion.outputs.nextVersion }}"
tagVersion: ${{ steps.autoversion.outputs.nextVersion }}
branchName: "build-${{ steps.autoversion.outputs.nextVersion }}"
This GitHub Action applies the actual release tag to your build. A tag is technically only needed for SPM, but obviously tagging the builds has some value regardless.
AutoVersion-TagMarker (Cleanup)
- uses: touchlab/autoversion-tagmarker@main
id: autoversion-tagmarker-cleanup
with:
nextVersion: ${{ steps.autoversion.outputs.nextVersion }}
cleanupMarkers: 'true'
This is the same action as AutoVersion-TagMarker above. However, cleanupMarkers: 'true'
tells it to delete all tags with the marker prefix (autoversion-tmp-publishing-
). Presumably, if we got here, the publication was successful, so we can clean up the markers.
Delete branch
- name: Delete branch
if: (!inputs.retainBuildBranch) && (!cancelled())
uses: touchlab/action-delete-branch@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branches: "build-${{ steps.autoversion.outputs.nextVersion }}"
We create a build branch when publishing. There are multiple reasons for this.
First, when building SPM, KMMBridge will modify the Package.swift
file to point at our newly published binary. This may conflict with a local-dev config, so we don’t want that in the main branch.
Second, adding commits to the main branch will clutter up the history.
You can leave the build branch, but by default, our GitHub workflow deletes it. When you view the tag in GitHub, you will get a warning that the tag points at a commit that is in no branch, but the commit still exists, and if needed, you can create a new branch from it. The headless commit does not impact SPM’s versioning capability.
Summary
Our GitHub Workflow and template project together form an opinionated approach to building and publishing shared Kotlin libraries. Adapting this to your CI and workflow will require performing similar steps. While the workflow does seem to have a lot of steps, none of them are really all that complex.