Engineering Article

Build Kotlin Multiplatform iOS Frameworks on a Cloud Mac

Build Kotlin Multiplatform iOS Frameworks on a Cloud Mac

A Kotlin Multiplatform repository that builds locally will not necessarily produce the same result after moving to a cloud Mac. The most common differences are not in the application code, but in the combination of Java, the Gradle Wrapper, Kotlin/Native, CocoaPods, and Xcode, as well as the accidental mixing of device and simulator artifacts. The reliable approach is to pin the toolchain first, then validate the shared module, the Framework, and the final Xcode workspace separately.

Pin the build inputs first

After connecting to the node for the first time, do not run the entire pipeline immediately. Start by recording the tools actually used by the build rather than relying only on the versions recommended in team documentation.

sw_vers
uname -m
xcodebuild -version
xcode-select -p
java -version
./gradlew --version
bundle exec pod --version

The repository should include gradlew, gradle/wrapper/gradle-wrapper.properties, dependency lockfiles, Podfile.lock, and the Ruby dependency lockfile. Use ./gradlew in CI instead of invoking whichever global Gradle installation happens to exist on the machine. Run CocoaPods through bundle exec pod as well, so different jobs do not load different gem versions.

If you need to switch Xcode versions, set the path explicitly at the start of the job and verify it immediately:

sudo xcode-select -s /Applications/Xcode.app
xcodebuild -version

A tool being “installed” is not enough for reproducibility. Its version, path, lockfiles, and invocation entry point must all be verifiable from the logs.

Define device and simulator targets separately

On an Apple Silicon Mac, the iOS Simulator usually runs on arm64 as well, but it is not the same target platform as a physical iPhone. Checking only the CPU architecture can lead to confusing errors during linking.

The shared module can retain two explicit targets:

kotlin {
    iosArm64()
    iosSimulatorArm64()

    targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>()
        .configureEach {
            binaries.framework {
                baseName = "SharedKit"
                isStatic = true
            }
        }
}

Build the shared module on its own first to shorten the failure path:

./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 \
  --no-daemon --stacktrace

./gradlew :shared:linkReleaseFrameworkIosArm64 \
  --no-daemon --stacktrace

Only proceed to the CocoaPods and Xcode layers after both tasks succeed. If either task name does not exist, check the module path, target declarations, and Framework name. Do not work around a configuration problem by copying an existing artifact.

Make CocoaPods and Xcode consume only locked results

CI should not update Pods during every build. Generate or verify the podspec first, then install strictly according to Podfile.lock:

./gradlew :shared:podspec
bundle check
bundle exec pod install --project-directory=iosApp

Next, build the workspace rather than the project file:

xcodebuild \
  -workspace iosApp/App.xcworkspace \
  -scheme App \
  -configuration Debug \
  -sdk iphonesimulator \
  -destination 'generic/platform=iOS Simulator' \
  -derivedDataPath "$PWD/.build/DerivedData" \
  CODE_SIGNING_ALLOWED=NO \
  build

CODE_SIGNING_ALLOWED=NO is appropriate for validating simulator builds, but it does not replace the signing configuration required to archive for a physical device. If the project uses custom configurations, also verify that the corresponding .xcconfig files do not contain local absolute paths for Framework search locations.

Narrow the scope with layered checks

Validation layer Recommended command Check first on failure
Kotlin source ./gradlew :shared:compileKotlinIosSimulatorArm64 expect/actual declarations, dependency visibility
Framework linking linkDebugFrameworkIosSimulatorArm64 Target platform, native dependencies
Pods integration bundle exec pod install Lockfiles, Ruby environment, podspec
Xcode build xcodebuild -workspace Scheme, search paths, build configuration

Cache downloads, not writable workspaces

~/.gradle/caches and ~/.konan are generally worth caching because downloading them again is expensive. Pods and DerivedData require more caution. When concurrent jobs share writable DerivedData, one job may read intermediate files produced by another branch. The safest approach is to create a separate directory for each job and delete it afterward.

At a minimum, cache keys should include:

  • Digests of gradle-wrapper.properties and the dependency lockfiles
  • Digests of libs.versions.toml, Podfile.lock, and the Ruby dependency lockfile
  • The output of xcodebuild -version
  • The Java major version and Kotlin plugin version
  • Whether the target configuration is Debug or Release

Do not package the entire user directory into a long-lived cache. Such a cache is difficult to invalidate and can also include credentials, logs, and unrelated state. After restoring a cache, run a lightweight compilation once. If it fails, clear the cache for the current job first instead of immediately deleting every development directory on the node.

Diagnose by error pattern and preserve evidence

When you see building for iOS Simulator, but linking in object file built for iOS, first determine which target produced the Framework instead of reinstalling CocoaPods. When you see framework not found, inspect the expanded search paths in Build Settings, the Framework’s actual location, and whether the relevant script phase runs before compilation.

Save key diagnostic output as job artifacts:

xcodebuild -showBuildSettings \
  -workspace iosApp/App.xcworkspace \
  -scheme App > .build/build-settings.txt

find shared -type d -name '*.framework' -print \
  > .build/framework-inventory.txt

If the build succeeds locally but fails remotely, compare xcodebuild -version, java -version, lockfile digests, environment variables, and workspace generation times. Do not begin by clearing every cache. That destroys the evidence and may make the problem disappear temporarily without explaining its cause.

Minimum pre-merge checks

At a minimum, a merge request should validate the shared module’s simulator build, the Xcode workspace build, and Release Framework linking for a physical device. Concurrent jobs should use separate DerivedData directories, logs should retain tool versions and the complete failing command, and build artifacts should be named by platform. With these practices in place, the boundaries between the Kotlin, Pods, and Xcode layers remain clear enough to diagnose most failures within a single pipeline run.

Frequently asked questions

Can an arm64 device framework run in an Apple Silicon simulator?

No. The instruction architecture may match, but the target platform identifiers do not. Build iosArm64 and iosSimulatorArm64 outputs separately.

What should a Kotlin Multiplatform CI job cache?

Prioritize Gradle downloads and the Kotlin/Native toolchain. Include lockfiles, the Gradle Wrapper, Xcode and Java versions in the cache key.

What commonly breaks CocoaPods integration in CI?

Frequent causes include updating dependencies instead of honoring Podfile.lock, using a different Ruby environment, opening the project instead of the workspace, and sharing writable Pods or DerivedData directories between jobs.

Dedicated physical node

Run your builds on a dedicated cloud Mac

Choose VMOrbit M4 or VMOrbit M4 Pro, then select a node based on your team’s location. Actual availability and delivery details are provided in real time by the console.

Choose a cloud Mac