This repository is a minimal Kotlin Multiplatform sample for a portable mobile CI setup built with Kotlin Toolchain, Fastlane, GitHub Actions, and GitLab CI.
It is intentionally small:
android-app/ios-app/shared/.github/workflows/mobile-ci.yml.gitlab-ci.ymlfastlane/scripts/ci/scripts/regenerate_from_kotlin_toolchain.sh
The app itself started from:
kotlin init compose-multiplatformand was then trimmed down to a mobile-only shape by removing the generated JVM module.
You can refresh the generated app layer in place with:
./scripts/regenerate_from_kotlin_toolchain.shThat script deletes and recreates the Kotlin Toolchain-generated app layer:
kotlinkotlin.batproject.yamlandroid-app/ios-app/shared/
while preserving the CI files and release helpers that belong to this sample.
This sample is not trying to show app architecture. It exists to prove that the CI pattern is reproducible with a minimal Kotlin Multiplatform project.
For the longer explanation of the design, see
docs/portable-kmp-ci.md.
The published documentation site is available at: https://mekate-studio.github.io/Portable-KMP-CI/
It exercises:
- Android debug and release builds
- Android tests
- iOS debug and release builds
- shared CI job dispatch
- runtime secret materialization for release flows
It also shows the same repo-owned job contract through multiple CI adapters:
- GitHub Actions via
.github/workflows/mobile-ci.yml - GitHub self-hosted verification via
.github/workflows/mobile-ci-self-hosted.yml - GitLab CI via
.gitlab-ci.yml
Set a writable Kotlin Toolchain cache:
export KOTLIN_CLI_BOOTSTRAP_CACHE_DIR="$PWD/.kotlin-cli-cache"Use JDK 21 for local runs. The GitHub Actions workflows in this repo also pin Temurin 21 because the current Kotlin Toolchain Android integration requires it.
Run the shared jobs:
./scripts/ci/run_job.sh android-build-debug
./scripts/ci/run_job.sh android-test
./scripts/ci/run_job.sh android-build-release
./scripts/ci/run_job.sh ios-build-debugThose jobs run bundle install through the shared CI helper layer, so you do
not need a separate manual Bundler step just to smoke-test the sample.
Run those smoke-test commands sequentially when using a single local checkout. In CI they run in separate jobs, but locally multiple first-run Kotlin Toolchain processes can contend with each other.
The iOS build jobs target a generic iOS Simulator destination and then force a single simulator architecture that matches the host machine. That avoids both a precreated device dependency and Kotlin Toolchain's current limitation around building multiple simulator architectures in one pass. The remaining host requirement is that Xcode has an iOS Simulator runtime installed.
On GitHub-hosted macOS images, Simulator runtime availability can still vary with runner image updates. If an iOS build fails because no iOS Simulator runtime is installed, the remaining fix is at the runner-image level: select an image/Xcode combination that includes a runtime, or install one explicitly.
If you want to verify the workflow using real GitHub Actions orchestration on your own Mac, this repo also includes:
That workflow is intended for a self-hosted macOS runner with the label:
kmp-sample
Recommended setup:
- Add your Mac as a self-hosted runner for this repository in GitHub.
- Give it the labels
self-hosted,macOS,ARM64, andkmp-sample. - Install JDK 21 on that Mac runner. The workflow pins Temurin 21, and the current Kotlin Toolchain Android integration expects a Java 21 runtime.
- Install a working host Ruby on that Mac runner, preferably via
brew install ruby. The shared CI helper will install the Bundler version required byGemfile.lockautomatically. This sample's self-hosted workflow intentionally uses the machine's Ruby instead ofruby/setup-ruby, becauseruby/setup-rubyexpects the GitHub-hosted macOS toolcache layout under/Users/runner. - Run the
Mobile CI (Self-hosted)workflow manually from GitHub usingworkflow_dispatch.
For a public repository, keep this self-hosted workflow on manual dispatch only. That avoids exposing your machine to arbitrary fork pull requests.
This repository also includes .gitlab-ci.yml as a thin
GitLab adapter for the same shared job contract.
That file intentionally stays small: it defines stages, tags, and artifacts,
then calls the same ./scripts/ci/run_job.sh ... entrypoint used by the
GitHub workflows.
If you are copying this setup into GitLab, the main repo-owned logic does not change. The GitLab adapter only needs to express:
- which runner tags should pick up a job
- which stage a job belongs to
- which artifacts should be preserved
- which jobs are manual or default-branch-only
These are the portable job names used by the dispatcher:
android-build-debugandroid-build-releaseandroid-testios-build-debugios-build-releaseios-archive-releaseios-testflightpublish-internalpromote-alphapromote-betapromote-production
GOOGLE_PLAY_JSON_KEYANDROID_KEYSTORE_FILEorANDROID_KEYSTORE_BASE64ANDROID_KEYSTORE_PASSWORDANDROID_KEY_ALIASANDROID_KEY_PASSWORDANDROID_PACKAGE_NAMEif you change the sample package name
To use real Play publishing, also enable the commented Android signing block
in android-app/module.yaml.
IOS_BUNDLE_IDENTIFIERIOS_DEVELOPMENT_TEAMIOS_PROVISIONING_PROFILE_SPECIFIERAPP_STORE_CONNECT_KEY_IDAPP_STORE_CONNECT_ISSUER_IDAPP_STORE_CONNECT_API_KEY_FILEorAPP_STORE_CONNECT_API_KEY_BASE64
Important: the Apple signing certificate and provisioning profile still need to exist on the macOS runner host.