The topic of supporting Kotlin multiplatform is something that came up a few times, so I decided to iterate on a potential design with Claude Code in a way that users of generated projects can declare not only a dependency on a Kotlin-built artifact, but dependencies on any valid build product regardless of the build system. I’d be curious to get your thoughts.
Summary
This proposal introduces a new dependency type that allows Tuist to depend on artifacts built by external build systems (Gradle, CMake, Cargo, etc.). The dependency declares a build script, its inputs for cache hashing, and wraps an existing binary dependency type (.xcframework, .framework, .library) as its output. This enables Tuist’s binary caching to work with Kotlin Multiplatform, Rust, C++, or any toolchain that produces linkable artifacts.
Motivation
Teams increasingly mix build systems within iOS projects:
- Kotlin Multiplatform (KMP): Shared business logic built with Gradle
- Rust: Performance-critical code built with Cargo
- C/C++: Legacy or cross-platform libraries built with CMake
These external artifacts are rebuilt from scratch on every clean build because Tuist has no visibility into them. Developers waste time rebuilding identical binaries, and CI pipelines slow down unnecessarily.
Proposed Solution
New Dependency Type
Add a new TargetDependency case that wraps an existing binary dependency with build instructions:
let app = Target.target(
name: "MyApp",
destinations: .iOS,
product: .app,
bundleId: "com.example.app",
sources: ["Sources/**"],
dependencies: [
.externalBuild(
name: "SharedKMP",
script: """
cd "$SRCROOT/../kmp"
./gradlew :shared:assembleXCFrameworkRelease
""",
output: .xcframework(path: "../kmp/shared/build/XCFrameworks/release/shared.xcframework"),
cacheInputs: [
.folder("../kmp/shared/src"),
.file("../kmp/shared/build.gradle.kts"),
.file("../kmp/gradle.properties"),
]
)
]
)
API
The output reuses existing binary dependency types:
extension TargetDependency {
/// Dependency on an artifact built by an external build system
///
/// - Parameters:
/// - name: A unique name for this dependency (used for caching and display)
/// - script: Shell script that builds the artifact
/// - output: The binary dependency produced by the script (xcframework, framework, or library)
/// - cacheInputs: Files and folders that affect the cache hash
public static func externalBuild(
name: String,
script: String,
output: TargetDependency, // .xcframework(), .framework(), or .library()
cacheInputs: [CacheInput] = []
) -> TargetDependency
}
public enum CacheInput: Codable, Hashable, Sendable {
/// A single file that affects the cache hash
case file(Path)
/// A folder whose contents affect the cache hash (recursive)
case folder(Path)
/// A glob pattern matching files that affect the cache hash
case glob(Path)
/// A script that returns a hash string (useful when the external build system can compute its own cache key)
case script(String)
}
How It Works
1. Project Generation
When generating the Xcode project:
- Create a
PBXAggregateTargetfor each unique external build dependency - Add a shell script build phase with the user’s script
- Add target dependency from the consuming target to the aggregate target
- Configure linking based on the output dependency type
2. Build Flow
When building in Xcode:
- Aggregate target runs first (due to target dependency)
- Script executes and produces the artifact at the output path
- Dependent target links against the artifact (as if it were a regular
.xcframework/.framework/.librarydependency)
3. Cache Hashing
The cache hash is computed from:
- All paths specified in
cacheInputs(file contents hashed) - Output of any
cacheInputsscripts (stdout is used as hash input) - The build script content
- The output dependency configuration
- Tuist cache version
The .script() cache input is useful when the external build system already knows how to compute a cache key (e.g., Gradle’s build cache). This avoids Tuist having to enumerate all input files.
4. Cache Integration
On tuist cache (warming):
- Compute hash from
cacheInputs - Check remote/local cache for existing artifact
- If miss: execute script, upload resulting artifact
- If hit: skip
On tuist generate (with cache enabled):
- Compute hash from
cacheInputs - If hit: download artifact, skip aggregate target, use the output as a regular precompiled dependency
- If miss: generate aggregate target as normal
Examples
Kotlin Multiplatform (with file inputs)
.externalBuild(
name: "SharedKMP",
script: """
cd "$SRCROOT/../kmp"
./gradlew :shared:assembleXCFrameworkRelease
""",
output: .xcframework(path: "../kmp/shared/build/XCFrameworks/release/shared.xcframework"),
cacheInputs: [
.folder("../kmp/shared/src"),
.file("../kmp/shared/build.gradle.kts"),
.file("../kmp/gradle.properties"),
.file("../kmp/gradle/libs.versions.toml"),
]
)
Kotlin Multiplatform (with hash script)
When the external build system can compute its own cache key:
.externalBuild(
name: "SharedKMP",
script: """
cd "$SRCROOT/../kmp"
./gradlew :shared:assembleXCFrameworkRelease
""",
output: .xcframework(path: "../kmp/shared/build/XCFrameworks/release/shared.xcframework"),
cacheInputs: [
.script("""
cd "$SRCROOT/../kmp"
./gradlew :shared:cacheKey --quiet
""")
]
)
Rust Library
.externalBuild(
name: "CryptoCore",
script: """
cd "$SRCROOT/../rust-crypto"
./build-xcframework.sh
""",
output: .xcframework(path: "../rust-crypto/target/CryptoCore.xcframework"),
cacheInputs: [
.folder("../rust-crypto/src"),
.file("../rust-crypto/Cargo.toml"),
.file("../rust-crypto/Cargo.lock"),
]
)
CMake C++ Library
.externalBuild(
name: "ImageProcessor",
script: """
cd "$SRCROOT/../cpp-image"
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
""",
output: .library(
path: "../cpp-image/build/libimage_processor.a",
publicHeaders: "../cpp-image/include",
swiftModuleMap: nil
),
cacheInputs: [
.folder("../cpp-image/src"),
.folder("../cpp-image/include"),
.file("../cpp-image/CMakeLists.txt"),
]
)
Implementation
Changes to TargetDependency
public enum TargetDependency: Codable, Hashable, Sendable {
// ... existing cases ...
/// Dependency on an artifact built by an external build system
case externalBuild(
name: String,
script: String,
output: TargetDependency,
cacheInputs: [CacheInput]
)
}
The output parameter accepts only binary dependency types (.xcframework, .framework, .library). Other dependency types would be a validation error.
Graph Representation
External build dependencies become nodes in the graph. They can be:
- Shared across multiple targets (deduplicated by name)
- Cached independently
- Replaced with their output dependency on cache hit
Alternatives Considered
1. New Target Type
Instead of a dependency, make it a Target with product: .externalBinary. However:
- External builds aren’t really “targets” in the Tuist sense
- They don’t have sources, resources, or most target properties
- A dependency better represents what it is: something you depend on
2. Pre-action Scripts
Use scheme pre-actions to build external dependencies. However:
- No caching support
- Not represented in the dependency graph
- Runs every time, even when unchanged
3. Manual XCFramework Reference
Build externally and reference via .xcframework(). However:
- No automatic rebuilding when sources change
- Manual cache management
- Poor developer experience
Open Questions
-
Naming: Is
.externalBuild()the right name? Alternatives:.scriptBuild().customBuild().buildable()
-
Build configurations: How to handle Debug vs Release? Options:
- Environment variable (
$CONFIGURATION) available in script - Separate dependencies per configuration
- Configuration mapping parameter
- Environment variable (
-
Deduplication: If multiple targets depend on the same external build (same name), should we:
- Build once and share (current proposal)
- Require explicit sharing via a separate declaration
-
Error handling: How should build failures surface? Options:
- Pass through stderr to Xcode build log
- Parse for common error patterns
- Custom error formatting
-
Script vs path: Should we also support referencing a script file instead of inline script?
.externalBuild( name: "SharedKMP", scriptPath: "scripts/build-kmp.sh", output: .xcframework(path: "..."), cacheInputs: [...] )