Skip to content

feat(spm): add multi-module Package.swift auto-generation#284

Open
hanrw wants to merge 4 commits intotouchlab:mainfrom
tddworks:feature/spm-multi-module-auto-generation
Open

feat(spm): add multi-module Package.swift auto-generation#284
hanrw wants to merge 4 commits intotouchlab:mainfrom
tddworks:feature/spm-multi-module-auto-generation

Conversation

@hanrw
Copy link
Contributor

@hanrw hanrw commented Dec 18, 2025

Summary

Add a new root-level plugin co.touchlab.kmmbridge.spm that automatically generates Package.swift for multi-module KMP projects.

New Features

  • spmDevBuildAll: Builds all XCFrameworks locally and generates Package.swift with local paths for development
  • kmmBridgePublishAll: Publishes all modules and generates Package.swift with URLs for distribution
  • generatePackageSwift: Generates Package.swift from published metadata

Changes

  • Add KmmBridgeSpmPlugin for root-level SPM management
  • Add KmmBridgeSpmExtension for configuration options
  • Add SpmModuleMetadata for JSON metadata exchange between modules
  • Add writeSpmMetadata task to each module for metadata generation
  • Disable module-level spmDevBuild when root SPM plugin is applied
  • Add comprehensive documentation in docs/SPM_MULTI_MODULE.md

Benefits

  • No manual Package.swift editing required
  • No need for useCustomPackageFile or perModuleVariablesBlock flags
  • Unified workflow for both local development and CI publishing
  • Automatic platform version resolution (takes maximum)
  • Automatic Swift tools version resolution

Add a new root-level plugin `co.touchlab.kmmbridge.spm` that automatically
generates Package.swift for multi-module KMP projects.

## New Features

- **spmDevBuildAll**: Builds all XCFrameworks locally and generates
  Package.swift with local paths for development
- **kmmBridgePublishAll**: Publishes all modules and generates
  Package.swift with URLs for distribution
- **generatePackageSwift**: Generates Package.swift from published metadata

## Changes

- Add `KmmBridgeSpmPlugin` for root-level SPM management
- Add `KmmBridgeSpmExtension` for configuration options
- Add `SpmModuleMetadata` for JSON metadata exchange between modules
- Add `writeSpmMetadata` task to each module for metadata generation
- Disable module-level `spmDevBuild` when root SPM plugin is applied
- Add comprehensive documentation in docs/SPM_MULTI_MODULE.md

## Benefits

- No manual Package.swift editing required
- No need for `useCustomPackageFile` or `perModuleVariablesBlock` flags
- Unified workflow for both local development and CI publishing
- Automatic platform version resolution (takes maximum)
- Automatic Swift tools version resolution
@samhill303 samhill303 requested a review from faogustavo January 13, 2026 20:39
Copy link
Contributor

@faogustavo faogustavo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Things seem to be working locally. I'll clean up my tests and push a branch with this new implementation to github.com/touchlab/KMMBridgeSPMQuickStart

@faogustavo
Copy link
Contributor

Adding copilot just for a second pair of eyes 😅

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds comprehensive multi-module SPM support to KMMBridge, enabling automatic Package.swift generation for projects with multiple Kotlin Multiplatform modules. The feature eliminates the need for manual Package.swift editing or using custom package file markers.

Changes:

  • Added a new root-level Gradle plugin co.touchlab.kmmbridge.spm that automatically discovers KMMBridge modules and generates unified Package.swift
  • Introduced metadata exchange system using JSON files to communicate module information between subprojects and the root plugin
  • Extended existing SPM dependency manager to write module metadata and disable conflicting module-level tasks when root plugin is active

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmPlugin.kt Core plugin implementation with task registration, module discovery, and Package.swift generation logic
kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmExtension.kt Configuration DSL for the root-level SPM plugin with properties for package name, version, output directory, and module filters
kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/SpmModuleMetadata.kt Data model and JSON serialization for module metadata exchange
kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/dependencymanager/SpmDependencyManager.kt Added writeSpmMetadata task and logic to skip module-level spmDevBuild when root plugin is present
kmmbridge/build.gradle.kts Registered new plugin with Gradle plugin portal configuration
kmmbridge/src/test/kotlin/co/touchlab/kmmbridge/spm/SpmModuleMetadataTest.kt Unit tests for metadata serialization, deserialization, and file I/O
kmmbridge/src/test/kotlin/co/touchlab/kmmbridge/spm/PackageSwiftGeneratorTest.kt Unit tests for Package.swift generation, version resolution, and platform aggregation logic
docs/SPM_MULTI_MODULE.md Comprehensive documentation covering usage, configuration, workflows, architecture, and troubleshooting

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +382 to +392
".library(name: \"${module.frameworkName}\", targets: [\"${module.frameworkName}\"])"
}

val targetsString = modules
.sortedBy { it.frameworkName }
.joinToString(",\n ") { module ->
""".binaryTarget(
name: "${module.frameworkName}",
url: "${module.url}",
checksum: "${module.checksum}"
)"""
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String interpolation is used directly without escaping when generating Swift code. If framework names, URLs, or checksums contain special characters like quotes or backslashes, the generated Package.swift could be malformed. Consider adding string escaping for these values to ensure robustness against edge cases.

Copilot uses AI. Check for mistakes.
Comment on lines +261 to +270
".library(name: \"${module.frameworkName}\", targets: [\"${module.frameworkName}\"])"
}

val targetsString = modules
.sortedBy { it.frameworkName }
.joinToString(",\n ") { module ->
""".binaryTarget(
name: "${module.frameworkName}",
path: "${module.localPath}"
)"""
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String interpolation is used directly without escaping when generating Swift code. If framework names or local paths contain special characters like quotes or backslashes, the generated Package.swift could be malformed. Consider adding string escaping for these values to ensure robustness against edge cases.

Copilot uses AI. Check for mistakes.
@faogustavo
Copy link
Contributor

Hey @hanrw 👋

Can you check whether you enabled contributor permissions to update the PR? Or cherry-pick this commit into your branch to fix the conversations in this PR?

@hanrw
Copy link
Contributor Author

hanrw commented Mar 10, 2026

Hey @hanrw 👋

Can you check whether you enabled contributor permissions to update the PR? Or cherry-pick this commit into your branch to fix the conversations in this PR?

I’ve just pushed all the changes from the commit you mentioned

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 17 changed files in this pull request and generated 9 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

modules = localModules,
)

val outputFile = File(outputDir, "Package.swift")
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above: ensure outputDirectory exists before writing Package.swift, otherwise writeText can fail when outputDirectory points at a not-yet-created folder.

Suggested change
val outputFile = File(outputDir, "Package.swift")
val outputFile = File(outputDir, "Package.swift")
outputFile.parentFile?.mkdirs()

Copilot uses AI. Check for mistakes.
Comment on lines +287 to 299
private fun swiftTargetPlatforms(project: Project): String = parsePlatformsMap(project)
.map { (platformName, platformVersion) -> ".$platformName(.v$platformVersion)" }
.joinToString(separator = ",\n")

val platforms = platforms(project, targetPlatforms)
return platforms
}
/**
* Parse platforms into a map for metadata JSON.
* Returns a map like {"iOS": "15", "macOS": "15"}
*/
internal fun parsePlatformsMap(project: Project): Map<String, String> {
val targetPlatforms = TargetPlatformDsl()
.apply(_targetPlatforms)
.targetPlatforms

Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, an empty targetPlatforms configuration caused an error (ensuring Package.swift always declares at least one supported platform). With the new parsePlatformsMap/swiftTargetPlatforms implementation, an empty DSL now results in an empty platforms list, which can produce an invalid or misleading Package.swift. Consider restoring the explicit validation (throw when targetPlatforms is empty or when the parsed platform map is empty).

Copilot uses AI. Check for mistakes.
Comment on lines +22 to +34
// Helper class to test Package.swift generation without Gradle
private class PackageSwiftGenerator {
private val versionComparator = Comparator<String> { v1, v2 ->
val parts1 = v1.split(".").mapNotNull { it.toIntOrNull() }
val parts2 = v2.split(".").mapNotNull { it.toIntOrNull() }
val maxLen = maxOf(parts1.size, parts2.size)
for (i in 0 until maxLen) {
val p1 = parts1.getOrElse(i) { 0 }
val p2 = parts2.getOrElse(i) { 0 }
if (p1 != p2) return@Comparator p1.compareTo(p2)
}
0
}
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests re-implement Package.swift generation logic in a private helper rather than exercising the production code in KmmBridgeSpmPlugin. This can allow tests to pass while the real generator regresses. Consider extracting the generator/version/platform resolution into a testable production class or internal functions and asserting against that instead of duplicating logic in tests.

Copilot uses AI. Check for mistakes.
Comment on lines +155 to +158
kmmBridgeModules.forEach { module ->
val assembleTask = module.tasks.findByName("assembleXCFramework")
?: module.tasks.findByName("assembleDebugXCFramework")
if (assembleTask != null) {
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spmDevBuildAll searches for assemble tasks by hard-coded names (assembleXCFramework / assembleDebugXCFramework). In Kotlin MPP the task name can also include the framework baseName (e.g. assemble<FrameworkName>DebugXCFramework), which this won’t pick up. Using the existing findXCFrameworkAssembleTask(NativeBuildType.DEBUG) helper (or similar logic) would be more reliable.

Copilot uses AI. Check for mistakes.
Comment on lines +291 to +293
val hasKmmBridge = subproject.kmmBridgeExtensionOrNull != null
if (!hasKmmBridge) return@filter false

Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

findKmmBridgeModules currently includes any subproject that has the KMMBridge extension, even if it doesn’t configure SPM. That contradicts the log message (“modules with SPM”) and can lead to runtime failures in spmDevBuildAll/metadata collection. Consider additionally filtering on the presence of an SpmDependencyManager in kmmBridgeExtension.dependencyManagers.

Suggested change
val hasKmmBridge = subproject.kmmBridgeExtensionOrNull != null
if (!hasKmmBridge) return@filter false
val kmmBridgeExt = subproject.kmmBridgeExtensionOrNull ?: return@filter false
// Only include modules that have SPM configured via SpmDependencyManager
val hasSpm = kmmBridgeExt.dependencyManagers.any { it is SpmDependencyManager }
if (!hasSpm) return@filter false

Copilot uses AI. Check for mistakes.
Comment on lines +138 to +146
kmmBridgeModules.forEach { module ->
val publishTask = module.tasks.findByName(BaseKMMBridgePlugin.PUBLISH_TASK_NAME)
if (publishTask != null) {
dependsOn(publishTask)
}
}

// Then generate Package.swift
finalizedBy(generateTask)
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kmmBridgePublishAll silently does nothing if module publish tasks aren’t registered (e.g. when ENABLE_PUBLISHING is not set), but will still run generatePackageSwift and likely warn about missing metadata. Consider failing fast or emitting an explicit warning when no kmmBridgePublish tasks are found so the user gets a clear action (enable publishing / configure artifact manager).

Suggested change
kmmBridgeModules.forEach { module ->
val publishTask = module.tasks.findByName(BaseKMMBridgePlugin.PUBLISH_TASK_NAME)
if (publishTask != null) {
dependsOn(publishTask)
}
}
// Then generate Package.swift
finalizedBy(generateTask)
var hasPublishTasks = false
kmmBridgeModules.forEach { module ->
val publishTask = module.tasks.findByName(BaseKMMBridgePlugin.PUBLISH_TASK_NAME)
if (publishTask != null) {
dependsOn(publishTask)
hasPublishTasks = true
}
}
if (hasPublishTasks) {
// Then generate Package.swift
finalizedBy(generateTask)
} else {
// No publish tasks found: warn explicitly so users know how to proceed.
doFirst {
project.logger.warn(
"Task $PUBLISH_ALL_TASK_NAME did not find any '${BaseKMMBridgePlugin.PUBLISH_TASK_NAME}' tasks in KMMBridge modules. " +
"Publishing is disabled or not configured (e.g. ENABLE_PUBLISHING not set); skipping Package.swift generation.",
)
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +212 to +216
// Find XCFramework in build directory
val buildType = kmmBridgeExt.buildType.get()

val xcFrameworkDir = module.layout.buildDirectory.asFile.get()
.resolve("XCFrameworks/${buildType.getName()}/$frameworkName.xcframework")
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spmDevBuildAll depends on debug assemble tasks, but collectLocalModuleInfo computes the XCFramework path using kmmBridgeExt.buildType (often RELEASE by default). This can generate Package.swift paths that don’t match the built XCFrameworks. Consider always using NativeBuildType.DEBUG for the local-dev task (both for the dependency and the resolved XCFramework path), or derive the folder name from the actual assemble task you depended on.

Suggested change
// Find XCFramework in build directory
val buildType = kmmBridgeExt.buildType.get()
val xcFrameworkDir = module.layout.buildDirectory.asFile.get()
.resolve("XCFrameworks/${buildType.getName()}/$frameworkName.xcframework")
// Find XCFramework in build directory.
// For local dev (spmDevBuildAll), we always use the DEBUG build output.
val xcFrameworkDir = module.layout.buildDirectory.asFile.get()
.resolve("XCFrameworks/DEBUG/$frameworkName.xcframework")

Copilot uses AI. Check for mistakes.

// Default platforms (we could enhance this to read from config)
val spmDependencyBlock = kmmBridgeExt.dependencyManagers.get()
.find { it is SpmDependencyManager } as SpmDependencyManager
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cast will throw if a subproject uses KMMBridge but does not configure spm { ... } (or configures a different dependency manager). Either filter discovered modules to only those with an SpmDependencyManager configured, or make this lookup null-safe and skip modules without SPM configuration.

Suggested change
.find { it is SpmDependencyManager } as SpmDependencyManager
.find { it is SpmDependencyManager } as? SpmDependencyManager
?: return@mapNotNull null

Copilot uses AI. Check for mistakes.
modules = metadata,
)

val outputFile = File(outputDir, "Package.swift")
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

outputDirectory can be configured to a path that doesn’t exist yet; writeText will fail if the directory is missing. Create the directory (e.g. outputDir.mkdirs() / outputFile.parentFile.mkdirs()) before writing Package.swift.

Suggested change
val outputFile = File(outputDir, "Package.swift")
val outputFile = File(outputDir, "Package.swift")
outputFile.parentFile?.mkdirs()

Copilot uses AI. Check for mistakes.
@faogustavo
Copy link
Contributor

@hanrw, can you update the brand again and double-check the Copilot comments, please? I think I addressed all of them, but a second pair of eyes would be helpful :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants