Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/
plugins {
alias(libs.plugins.kotlin) apply false
id("org.jetbrains.kotlin.plugin.allopen") version "1.9.0" apply false
alias(libs.plugins.allopen) apply false
alias(libs.plugins.maven.publish) apply false
id("org.jlleitschuh.gradle.ktlint") version "12.2.0" apply false
}
Expand Down Expand Up @@ -50,4 +50,4 @@ subprojects {
dependsOn(tasks.getByName("ktlintCheck"))
}
}
}
}
337 changes: 337 additions & 0 deletions docs/SPM_MULTI_MODULE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,337 @@
# Multi-Module SPM Support

This guide explains how to use KMMBridge to publish multiple KMP modules as a single SPM package with automatic Package.swift generation.

## Overview

When you have multiple KMP darwin modules (e.g., `module-a-darwin`, `module-b-darwin`), KMMBridge can automatically:

1. Discover all modules with KMMBridge configured
2. Build/publish all XCFrameworks
3. Generate a unified `Package.swift` with all modules

No manual Package.swift editing required!

## Quick Start

### 1. Apply the SPM plugin to your root project

```kotlin
// Root build.gradle.kts
plugins {
id("co.touchlab.kmmbridge.spm")
}

kmmBridgeSpm {
packageName.set("my-sdk") // Optional, defaults to project name
}
```

Apply `co.touchlab.kmmbridge.spm` **only** to the root project, and only there - never combine it
with the per-module `co.touchlab.kmmbridge` plugin on the same project. They are two separate
plugins with two separate jobs: the root plugin aggregates, the per-module plugin builds/publishes.

### 2. Configure each darwin module (simplified)

```kotlin
// Each darwin module's build.gradle.kts
plugins {
id("co.touchlab.kmmbridge")
}

kmmbridge {
gitHubReleaseArtifacts()
spm(swiftToolVersion = "5.9") {
iOS { v("15") }
macOS { v("15") }
}
}
```

Note: No need for `useCustomPackageFile` or `perModuleVariablesBlock` - the root plugin handles everything automatically!

Each module's `frameworkName` (the name passed to `Framework { baseName = ... }`/derived from the
Kotlin target) must be **unique across all participating modules**. Two modules sharing the same
name will fail Package.swift generation with a clear error rather than produce a broken manifest -
see [Troubleshooting](#duplicate-framework-name).

### 3. Run the tasks

**For local development:**
```bash
./gradlew spmDevBuildAll
```

**For CI publishing:**
```bash
./gradlew kmmBridgePublishAll
```

## Available Tasks

| Task | Description |
|------|-------------|
| `spmDevBuildAll` | Builds all XCFrameworks locally and generates Package.swift with local paths |
| `kmmBridgePublishAll` | Publishes all modules to artifact storage and generates Package.swift with URLs |
| `generatePackageSwift` | Generates Package.swift from published module metadata |

> **Note**: When the root SPM plugin is applied, each module's own `spmDevBuild` and `updatePackageSwift`
> tasks still run (and can still be up-to-date-checked), but their write step short-circuits at
> execution time - each logs a `Skipping ...` message and defers to `spmDevBuildAll`/the root
> `generatePackageSwift` instead of writing its own single-module Package.swift and racing with the
> aggregated one. This is expected; you'll see these log lines for every module when running
> `spmDevBuildAll` or `kmmBridgePublishAll` from the root.
>
> `kmmBridgePublishAll` generates Package.swift as a **dependency**, not a finalizer: if any module's
> publish fails, `generatePackageSwift` does not run at all, so a failed release never leaves behind a
> stale or partially-updated Package.swift.

## Configuration Options

```kotlin
kmmBridgeSpm {
// SPM package name (default: rootProject.name)
packageName.set("my-awesome-sdk")

// Swift tools version (default: "5.9", or max from modules)
swiftToolsVersion.set("5.9")

// Output directory (default: rootProject.projectDir)
outputDirectory.set(file("."))

// Include only specific modules (default: all KMMBridge modules)
includeModules.set(setOf(
":module-a:module-a-darwin",
":module-b:module-b-darwin"
))

// Exclude specific modules
excludeModules.set(setOf(":legacy-module"))
}
```

## Generated Package.swift

### Local Development (`spmDevBuildAll`)

```swift
// swift-tools-version:5.9
// Generated by KMMBridge (LOCAL DEV) - DO NOT COMMIT
import PackageDescription

let package = Package(
name: "my-sdk",
platforms: [
.iOS(.v15),
.macOS(.v15)
],
products: [
.library(name: "ModuleA", targets: ["ModuleA"]),
.library(name: "ModuleB", targets: ["ModuleB"]),
],
targets: [
.binaryTarget(
name: "ModuleA",
path: "module-a/module-a-darwin/build/XCFrameworks/debug/ModuleA.xcframework"
),
.binaryTarget(
name: "ModuleB",
path: "module-b/module-b-darwin/build/XCFrameworks/debug/ModuleB.xcframework"
),
]
)
```

### CI Publishing (`kmmBridgePublishAll`)

```swift
// swift-tools-version:5.9
// Generated by KMMBridge - DO NOT EDIT MANUALLY
import PackageDescription

let package = Package(
name: "my-sdk",
platforms: [
.iOS(.v15),
.macOS(.v15)
],
products: [
.library(name: "ModuleA", targets: ["ModuleA"]),
.library(name: "ModuleB", targets: ["ModuleB"]),
],
targets: [
.binaryTarget(
name: "ModuleA",
url: "https://github.com/example/repo/releases/download/1.0.0/ModuleA.xcframework.zip",
checksum: "abc123..."
),
.binaryTarget(
name: "ModuleB",
url: "https://github.com/example/repo/releases/download/1.0.0/ModuleB.xcframework.zip",
checksum: "def456..."
),
]
)
```

## GitHub Actions Workflow

```yaml
name: KMMBridge-Release
on:
workflow_dispatch:

permissions:
contents: write

jobs:
publish:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: actions/setup-java@v4
with:
distribution: "adopt"
java-version: 17

- name: Create Release
id: release
uses: softprops/action-gh-release@v2
with:
draft: true
tag_name: ${{ github.ref_name }}

- name: Build and Publish
run: |
./gradlew kmmBridgePublishAll \
-PENABLE_PUBLISHING=true \
-PGITHUB_ARTIFACT_RELEASE_ID=${{ steps.release.outputs.id }} \
-PGITHUB_PUBLISH_TOKEN=${{ secrets.GITHUB_TOKEN }} \
-PGITHUB_REPO=${{ github.repository }}

- uses: touchlab/ga-update-release-tag@v1
with:
tagVersion: ${{ github.ref_name }}
```

## How It Works

### Architecture

```
┌─────────────────────────────────────────────────────────┐
│ Root Project │
│ ┌─────────────────────────────────────────────────┐ │
│ │ co.touchlab.kmmbridge.spm plugin │ │
│ │ - Discovers all KMMBridge modules │ │
│ │ - Collects metadata from each module │ │
│ │ - Generates unified Package.swift │ │
│ └─────────────────────────────────────────────────┘ │
│ ▲ ▲ ▲ │
│ │ │ │ │
│ ┌────────┴──┐ ┌──────┴────┐ ┌────┴──────┐ │
│ │ Module A │ │ Module B │ │ Module C │ │
│ │ kmmbridge │ │ kmmbridge │ │ kmmbridge │ │
│ │ plugin │ │ plugin │ │ plugin │ │
│ └───────────┘ └───────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────┘
```

### Flow

1. **Module Configuration**: Each darwin module configures `kmmbridge { spm { ... } }`
2. **Discovery**: Root plugin discovers all modules with KMMBridge
3. **Build/Publish**: Each module builds its XCFramework and (optionally) publishes
4. **Metadata**: Each module writes metadata JSON with framework info
5. **Generation**: Root plugin collects all metadata and generates Package.swift

## Migration from Manual Package.swift

If you're currently using `useCustomPackageFile = true` with manual markers:

### Before

```kotlin
// Each module
kmmbridge {
spm(useCustomPackageFile = true, perModuleVariablesBlock = true) { ... }
}

// Manual Package.swift with markers
// BEGIN KMMBRIDGE VARIABLES BLOCK FOR 'MyModule' (do not edit)
// ...
// END KMMBRIDGE BLOCK FOR 'MyModule'
```

### After

```kotlin
// Root build.gradle.kts
plugins {
id("co.touchlab.kmmbridge.spm")
}

// Each module (simplified)
kmmbridge {
spm { ... } // No special flags needed!
}

// Package.swift is auto-generated - delete your manual one!
```

## Troubleshooting

### "No KMMBridge modules found"

Make sure:
- Each module applies the `co.touchlab.kmmbridge` plugin
- Each module configures `spm { ... }` in the `kmmbridge` block

### "No module metadata found"

For `generatePackageSwift`, if **no** discovered module has metadata:
- Metadata is created during publishing
- Run `kmmBridgePublishAll` instead, or use `spmDevBuildAll` for local dev

### "Missing or unreadable SPM metadata for module(s): ..."

For `generatePackageSwift`, if **some but not all** discovered modules have metadata, the task fails
fast with this error instead of silently generating an incomplete Package.swift missing those
modules. Make sure every selected module (see `includeModules`/`excludeModules`) has actually been
published - i.e. its `writeSpmMetadata` task has run successfully - before generating Package.swift.

### "XCFramework not found"

For `spmDevBuildAll`:
- Make sure XCFrameworks are built first
- The task should auto-depend on assemble tasks, but you can run `./gradlew assembleXCFramework` manually

### Duplicate framework name

If two modules share the same `frameworkName`, Package.swift generation (both `generatePackageSwift`
and `spmDevBuildAll`) fails with `Duplicate framework name(s) found across KMMBridge modules: ...`.
SPM product/target names must be unique within a single Package.swift, so give each module's
framework a distinct name.

## Platform Resolution

When modules specify different platform versions, the plugin takes the **maximum** version for each platform:

```kotlin
// Module A: iOS 14, macOS 11
// Module B: iOS 15, macOS 12
// Module C: iOS 13, macOS 13

// Result: iOS 15, macOS 13
```

## Swift Tools Version Resolution

Similarly, Swift tools version is resolved to the maximum across all modules, or the configured
default if none specified. This applies identically to **both** generation paths: `generatePackageSwift`
(remote, using each module's published metadata) and `spmDevBuildAll` (local dev, using each module's
configured `swiftToolVersion`) resolve the version the same way - a module built with a newer
`swiftToolVersion` raises the version for the whole generated package in either case.
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin-api

[plugins]
kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
allopen = { id = "org.jetbrains.kotlin.plugin.allopen", version.ref = "kotlin" }
maven-publish = { id = "com.vanniktech.maven.publish.base", version.ref = "mavenPublish" }

2 changes: 1 addition & 1 deletion kmmbridge-github/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
plugins {
`kotlin-dsl`
alias(libs.plugins.kotlin)
id("org.jetbrains.kotlin.plugin.allopen")
alias(libs.plugins.allopen)
id("java-gradle-plugin")
alias(libs.plugins.maven.publish)
id("com.gradle.plugin-publish") version "1.0.0"
Expand Down
2 changes: 1 addition & 1 deletion kmmbridge-gitlab/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
plugins {
`kotlin-dsl`
alias(libs.plugins.kotlin)
id("org.jetbrains.kotlin.plugin.allopen")
alias(libs.plugins.allopen)
id("java-gradle-plugin")
alias(libs.plugins.maven.publish)
id("com.gradle.plugin-publish") version "1.0.0"
Expand Down
2 changes: 1 addition & 1 deletion kmmbridge-test/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
plugins {
`kotlin-dsl`
alias(libs.plugins.kotlin)
id("org.jetbrains.kotlin.plugin.allopen")
alias(libs.plugins.allopen)
id("java-gradle-plugin")
alias(libs.plugins.maven.publish)
id("com.gradle.plugin-publish") version "1.0.0"
Expand Down
Loading
Loading