feat(spm): add multi-module Package.swift auto-generation - #284
Conversation
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
faogustavo
left a comment
There was a problem hiding this comment.
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
|
Adding copilot just for a second pair of eyes 😅 |
There was a problem hiding this comment.
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.spmthat 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.
|
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? |
… feature/spm-multi-module-auto-generation
I’ve just pushed all the changes from the commit you mentioned |
There was a problem hiding this comment.
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.
| 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 | ||
|
|
There was a problem hiding this comment.
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).
| // 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 | ||
| } |
There was a problem hiding this comment.
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.
| kmmBridgeModules.forEach { module -> | ||
| val assembleTask = module.tasks.findByName("assembleXCFramework") | ||
| ?: module.tasks.findByName("assembleDebugXCFramework") | ||
| if (assembleTask != null) { |
There was a problem hiding this comment.
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.
| val hasKmmBridge = subproject.kmmBridgeExtensionOrNull != null | ||
| if (!hasKmmBridge) return@filter false | ||
|
|
There was a problem hiding this comment.
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.
| 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 |
|
@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 :) |
- Address Copilot review feedback for SPM multi-module support - Move Package.swift content generation logic to a new class for testability - Simplify KmmBridgeSpmPlugin by delegating package generation - Enhance platform version parsing in SpmDependencyManager with max-version logic - Remove obsolete local Package.swift generation code, use centralized generator class
I've just pushed the latest changes based on the comments. |
|
Some of the changes from the Copilot review still seem relevant. I'll take some time to review them and try to merge this |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 18 changed files in this pull request and generated 9 comments.
Suppressed comments (2)
kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmPlugin.kt:172
- These lookups miss the task that KMMBridge actually creates for named frameworks. The shared helper in
internal/ProjectExtensionsInternal.kt:55-56triesassemble${frameworkName}DebugXCFrameworkbefore the generic form; for a module such asallshared, this leavesspmDevBuildAllwith no build dependency, so it generates a package from a missing or stale framework. ReusefindXCFrameworkAssembleTask(NativeBuildType.DEBUG)instead of hard-coding these names.
val assembleTask = module.tasks.findByName("assembleXCFramework")
?: module.tasks.findByName("assembleDebugXCFramework")
kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmPlugin.kt:100
writeSpmMetadatadepends on the module upload task (SpmDependencyManager.kt:109), so this makes the supposedly read-onlygeneratePackageSwifttask upload every module whenever it is invoked. That requires publishing credentials and can republish artifacts even though the task is documented as generating from already-published metadata. Let module publish tasks produce metadata, while this task only consumes it.
kmmBridgeModules.forEach { module ->
val uploadTask = module.tasks.findByName(WRITE_SPM_METADATA_TASK_NAME)
if (uploadTask != null) {
dependsOn(uploadTask)
| publishRemoteTask.configure { | ||
| dependsOn(updatePackageSwiftTask) | ||
| dependsOn(writeMetadataTask) | ||
| } |
| inputs.files(zipFile, urlFile) | ||
| outputs.file(metadataFile) |
| val xcFrameworkDir = module.layout.buildDirectory.asFile.get() | ||
| .resolve("XCFrameworks/${NativeBuildType.DEBUG.getName()}/$frameworkName.xcframework") |
| val swiftToolsVersion = extension.swiftToolsVersion.get() | ||
| val moduleProjects = kmmBridgeModules.toList() | ||
|
|
||
| outputs.file(File(outputDir, "Package.swift")) |
| val packageSwift = generator.generateLocalPackageSwift( | ||
| packageName = packageName, | ||
| swiftToolsVersion = swiftToolsVersion, | ||
| modules = localModules, |
| val metadata = collectMetadata(moduleProjects) | ||
| if (metadata.isEmpty()) { | ||
| project.logger.warn("No module metadata found. Make sure modules have been published.") | ||
| return | ||
| } |
| val swiftToolsVersion = extension.swiftToolsVersion.get() | ||
| val moduleProjects = kmmBridgeModules.toList() | ||
|
|
||
| outputs.file(File(outputDir, "Package.swift")) |
|
|
||
| if (hasPublishTasks) { | ||
| // Then generate Package.swift | ||
| finalizedBy(generateTask) |
| val platforms = resolvePlatforms(modules) | ||
| val platformsString = formatPlatforms(platforms) | ||
|
|
||
| val productsString = modules |
Summary
Add a new root-level plugin
co.touchlab.kmmbridge.spmthat automatically generates Package.swift for multi-module KMP projects.New Features
Changes
KmmBridgeSpmPluginfor root-level SPM managementKmmBridgeSpmExtensionfor configuration optionsSpmModuleMetadatafor JSON metadata exchange between moduleswriteSpmMetadatatask to each module for metadata generationspmDevBuildwhen root SPM plugin is appliedBenefits
useCustomPackageFileorperModuleVariablesBlockflags