RFC: External Build System Dependencies

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 PBXAggregateTarget for 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:

  1. Aggregate target runs first (due to target dependency)
  2. Script executes and produces the artifact at the output path
  3. Dependent target links against the artifact (as if it were a regular .xcframework/.framework/.library dependency)

3. Cache Hashing

The cache hash is computed from:

  • All paths specified in cacheInputs (file contents hashed)
  • Output of any cacheInputs scripts (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):

  1. Compute hash from cacheInputs
  2. Check remote/local cache for existing artifact
  3. If miss: execute script, upload resulting artifact
  4. If hit: skip

On tuist generate (with cache enabled):

  1. Compute hash from cacheInputs
  2. If hit: download artifact, skip aggregate target, use the output as a regular precompiled dependency
  3. 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

  1. Naming: Is .externalBuild() the right name? Alternatives:

    • .scriptBuild()
    • .customBuild()
    • .buildable()
  2. Build configurations: How to handle Debug vs Release? Options:

    • Environment variable ($CONFIGURATION) available in script
    • Separate dependencies per configuration
    • Configuration mapping parameter
  3. 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
  4. Error handling: How should build failures surface? Options:

    • Pass through stderr to Xcode build log
    • Parse for common error patterns
    • Custom error formatting
  5. 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: [...]
    )
    

Thanks for the proposal! Generally very much aligned :slightly_smiling_face:

Yeah, that’s tricky. I was thinking whether it would make sense for the externalBuild to be just the underlying base model, but users would primarily define them with predefined factories from ProjectDescription, such as:

.kmm(modulePath: "../kmp/shared", configuration: .release)

I’m not sure how standard the tasks for building an xcframework via KMM are, though, so this may not make sense.

As for the naming itself, external evokes to me a third-party dependency, which is not the case here. What do you think about foreignBuild, similar to Bazel’s rules_foreign_cc.

I would start with passing the stderr to the Xcode build log.

We can start with an inline script. In the end, you can also call out to a script file, so it’s not like you’d be always forced to specify everything directly in the Project.swift.

Not very standard I’d say, but we could explore down the line to provide a task ourselves, since we’ll soon have a plugin.

I like foreignBuild.