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.propertiesand 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.
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.