feat(spm): add multi-module Package.swift auto-generation#284
feat(spm): add multi-module Package.swift auto-generation#284hanrw wants to merge 4 commits intotouchlab:mainfrom
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
kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/dependencymanager/SpmDependencyManager.kt
Outdated
Show resolved
Hide resolved
kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmPlugin.kt
Outdated
Show resolved
Hide resolved
|
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.
kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/dependencymanager/SpmDependencyManager.kt
Outdated
Show resolved
Hide resolved
kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmPlugin.kt
Show resolved
Hide resolved
kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmPlugin.kt
Show resolved
Hide resolved
kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/dependencymanager/SpmDependencyManager.kt
Outdated
Show resolved
Hide resolved
| ".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}" | ||
| )""" |
There was a problem hiding this comment.
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.
| ".library(name: \"${module.frameworkName}\", targets: [\"${module.frameworkName}\"])" | ||
| } | ||
|
|
||
| val targetsString = modules | ||
| .sortedBy { it.frameworkName } | ||
| .joinToString(",\n ") { module -> | ||
| """.binaryTarget( | ||
| name: "${module.frameworkName}", | ||
| path: "${module.localPath}" | ||
| )""" |
There was a problem hiding this comment.
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.
kmmbridge/src/test/kotlin/co/touchlab/kmmbridge/spm/PackageSwiftGeneratorTest.kt
Show resolved
Hide resolved
|
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.
| modules = localModules, | ||
| ) | ||
|
|
||
| val outputFile = File(outputDir, "Package.swift") |
There was a problem hiding this comment.
Same as above: ensure outputDirectory exists before writing Package.swift, otherwise writeText can fail when outputDirectory points at a not-yet-created folder.
| val outputFile = File(outputDir, "Package.swift") | |
| val outputFile = File(outputDir, "Package.swift") | |
| outputFile.parentFile?.mkdirs() |
| 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 |
| kmmBridgeModules.forEach { module -> | ||
| val publishTask = module.tasks.findByName(BaseKMMBridgePlugin.PUBLISH_TASK_NAME) | ||
| if (publishTask != null) { | ||
| dependsOn(publishTask) | ||
| } | ||
| } | ||
|
|
||
| // Then generate Package.swift | ||
| finalizedBy(generateTask) |
There was a problem hiding this comment.
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).
| 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.", | |
| ) | |
| } | |
| } |
| // Find XCFramework in build directory | ||
| val buildType = kmmBridgeExt.buildType.get() | ||
|
|
||
| val xcFrameworkDir = module.layout.buildDirectory.asFile.get() | ||
| .resolve("XCFrameworks/${buildType.getName()}/$frameworkName.xcframework") |
There was a problem hiding this comment.
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.
| // 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") |
|
|
||
| // Default platforms (we could enhance this to read from config) | ||
| val spmDependencyBlock = kmmBridgeExt.dependencyManagers.get() | ||
| .find { it is SpmDependencyManager } as SpmDependencyManager |
There was a problem hiding this comment.
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.
| .find { it is SpmDependencyManager } as SpmDependencyManager | |
| .find { it is SpmDependencyManager } as? SpmDependencyManager | |
| ?: return@mapNotNull null |
| modules = metadata, | ||
| ) | ||
|
|
||
| val outputFile = File(outputDir, "Package.swift") |
There was a problem hiding this comment.
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.
| val outputFile = File(outputDir, "Package.swift") | |
| val outputFile = File(outputDir, "Package.swift") | |
| outputFile.parentFile?.mkdirs() |
|
@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 :) |
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