diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..d0030d6 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,88 @@ +--- +name: release +description: Release a new KonanPlugin version — bump, changelog, publish to the Gradle Plugin Portal, verify, tag, and create the GitHub release. Use when asked to release, publish, or cut a version (e.g. "publish alpha07", "release 2.0.0"). +--- + +# Releasing KonanPlugin + +A release is: version bump commit on `main` → `publishPlugins` → verify from the Portal → tag → GitHub release. +Portal versions are **immutable**: a published version can never be fixed or republished, only superseded. +So every check happens *before* `publishPlugins`, and nothing after it can be retried with the same version. + +## 0. Preconditions + +- Everything to ship is merged into `main`; the release is cut from `main`, never a feature branch. + `git checkout main && git pull --ff-only`, and the working tree is clean. +- CI on `main`'s head is green: `gh run list --branch main -L 3`. +- Pick the version: the next after the last tag (`git tag --sort=-creatordate | head -1`). While on 2.0 + alphas: `2.0.0-alphaNN`, one higher. Never reuse a number, even one whose publish failed half-way. +- Credentials (`gradle.publish.key`, `gradle.publish.secret`) live in `~/.gradle/gradle.properties`. + **Never print, cat, grep or echo them**, and never pass them on a command line. + +## 1. Bump and changelog (one commit on `main`) + +- `git grep -n "" -- ':!CHANGELOG.md'` — today `build.gradle.kts` (`version = …`) and the + README's `plugins { }` snippet. Replace each with the new version. +- Add a `## ` section at the top of `CHANGELOG.md`, above the previous one, under `### Fixed`, + `### Changed` or `### Added`. Collect what shipped from `git log ..main --oneline` and the + merged PRs/issues. Match the existing entries: a bold one-line summary linking the issue or PR, then what + broke and for whom, what it does now, and how to get the old behavior if any. Mark breaking changes + with `**Breaking:**` and what users must change. +- Commit: `chore: version ` with the `Co-Authored-By` trailer. Push only after step 3 succeeds + (keeps `main` from announcing a version that failed to publish). + +## 2. Publish + +```bash +./gradlew publishPlugins +``` + +`checkVersionUnpublished` runs first and fails if the version is already on the Portal — that means the +bump was skipped or the number reused; bump again, never work around it. Do **not** also run +`publishToMavenLocal` for a released version: a local copy shadows the Portal's for consumers with +`mavenLocal()` first (#8). Local test builds use a `-SNAPSHOT` version instead. + +## 3. Verify from the Portal only + +"Works locally" proves nothing. Configure a throwaway project that can only reach the Portal: + +```bash +dir=$(mktemp -d) # or the session scratchpad +echo 'plugins { id("io.github.lemcoder.konanplugin") version "" }' > "$dir/build.gradle.kts" +touch "$dir/settings.gradle.kts" +./gradlew -q -p "$dir" --refresh-dependencies buildEnvironment | grep konan # resolves +./gradlew -q -p "$dir" tasks --group konan # konanConfig is listed +``` + +The Portal can take a few minutes to serve a new version; retry rather than republish. + +## 4. Push, tag, release + +```bash +git push origin main +git tag -a v -m "" +git push origin v +``` + +Release notes are the version's CHANGELOG section followed by the `plugins { }` snippet: + +```` +" heading> + +```kotlin +plugins { + id("io.github.lemcoder.konanplugin") version "" +} +``` +```` + +```bash +gh release create v --title --notes-file --prerelease # --prerelease while alpha/beta/rc +``` + +## 5. Close out + +- Issues fixed by the release: if a PR's `Fixes #N` already closed one, leave a comment naming the version + that ships the fix only if the reporter is waiting on it; ask before closing any still-open issue. +- Report to the user: the Portal URL (`https://plugins.gradle.org/plugin/io.github.lemcoder.konanplugin`), + the release URL, and what the Portal-only check resolved. diff --git a/CLAUDE.md b/CLAUDE.md index cd94fcf..483acf7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,10 @@ resolved the moment the plugin is applied, in *every* project that applies it. them depend on KGP's `downloadKotlinNativeDistribution` (KGP 2.3+) when `konanHome` is the directory `kotlinNativeDistribution()` names (#16). That dependency is a `project.provider { }`: given `konanHome.map { }`, Gradle asks the mapped property for its producer, finds none, and never runs the mapping. +- **`kotlinNativeDistribution()` takes the Kotlin version from the `kotlin` extension, not a plugin id.** AGP 9's + built-in Kotlin registers `KotlinAndroidProjectExtension` without applying `org.jetbrains.kotlin.android`, so a + plugin-id check left the version at the plugin's default. It is asked when the build script calls it, so the + plugins may be applied in either order; `findByName("kotlin")` touches no KGP type. - **The plugin ships Android's `jni.h`** (`src/main/resources/io/github/lemcoder/dsl/bindings/jni/`, Apache 2.0): the JNI generator writes it to its `include/` output, the host link and `KONAN_JNI_INCLUDE_DIR` use it. Its JNI is 1.6 — enough for the generated stubs. @@ -153,6 +157,11 @@ are one `NodePointer`); results are nullable, parameters are not. Bindings are `internal` by default and each carries **`@JvmName`**. JNI resolves the C symbol from the unmangled method name, so a mangled internal name compiles, links, and dies on the first call. +**An Android module may compile no Kotlin**: AGP 9's `android { enableKotlin = false }` keeps `variant.sources.kotlin` +and the `kotlin` extension but registers no `compileKotlin`, so the bindings were silently never compiled and +Java calling them could not find them. `AgpWiring.requireKotlinCompile` checks for that task when `preBuild` is +configured — not in `onVariants`, which runs before AGP registers tasks. + ## Kotlin/Native's toolchain for external builds - **`run_konan` starts a JVM per call**, so `CC` is not a wrapper around it: `KonanToolchainTask` runs diff --git a/README.md b/README.md index 8c7476b..dfbcaed 100644 --- a/README.md +++ b/README.md @@ -501,7 +501,9 @@ The loader is generated into each binding's package, so the bindings still need ### The generated JNI bindings One `external fun` per C function, named after it and carrying its C signature as a doc comment. cinterop's own indexer reads the headers — the same `.def` the Kotlin/Native targets use — and the -plugin writes both sides of each JNI bridge from the C declarations: +plugin writes both sides of each JNI bridge from the C declarations. The bindings are Kotlin, so the +module must compile Kotlin: an Android module with `android { enableKotlin = false }` fails configuration +saying so. Use `ffm()` there, which generates Java. | C type | Kotlin type | Crosses as | |----------------------------------------------|----------------------------------|----------------------------------------------------| diff --git a/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt b/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt index 4ebc809..c7f8bc4 100644 --- a/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt +++ b/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt @@ -8,8 +8,6 @@ import io.github.lemcoder.dsl.libraries.registerLibraries import io.github.lemcoder.kotlin.KotlinGradlePlugin import io.github.lemcoder.kotlin.UsesKonanDistribution import io.github.lemcoder.wiring.AgpWiring -import io.github.lemcoder.wiring.GradlePluginId -import io.github.lemcoder.wiring.withPlugin import org.gradle.api.Plugin import org.gradle.api.Project import java.io.File @@ -32,9 +30,11 @@ abstract class KonanPlugin : Plugin { }) } project.registerConfigReport(konan.config) - // kotlinNativeDistribution() names the project's own Kotlin/Native version once the Kotlin plugin is known. - for (kotlin in listOf(GradlePluginId.KOTLIN_JVM, GradlePluginId.KOTLIN_MULTIPLATFORM, GradlePluginId.KOTLIN_ANDROID)) { - project.withPlugin(kotlin) { konan.config.kotlinPluginVersion = KotlinGradlePlugin.version(project) } + // kotlinNativeDistribution() names the project's own Kotlin/Native version. Told by the `kotlin` extension, + // not a plugin id: AGP 9's built-in Kotlin registers it without applying `kotlin-android`. Asked lazily, so + // the plugins may be applied in either order; findByName touches no KGP type. + konan.config.kotlinPluginVersion = { + if (project.extensions.findByName("kotlin") != null) KotlinGradlePlugin.version(project) else null } konan.androidApiLevel.convention(AgpWiring.minSdk(project)) AgpWiring.ndkDirectory(project, konan.config.androidGradlePluginNdk) diff --git a/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt b/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt index 5dd8c95..6d50c00 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt @@ -67,15 +67,15 @@ abstract class KonanConfig @Inject constructor(private val providers: ProviderFa /** * Where the Kotlin plugin installs Kotlin/Native for this host: `konanHome.set(kotlinNativeDistribution())`. - * The project's Kotlin version when it applies the Kotlin plugin, else the plugin's default. Computed, + * The project's Kotlin version when it compiles Kotlin, else the plugin's default. Computed, * not searched for; a Kotlin Multiplatform build of that version with a native target downloads it. */ fun kotlinNativeDistribution(): File = KonanDistribution.defaultHome(kotlinVersion()) - /** The Kotlin plugin's version, set when the project applies it. */ - internal var kotlinPluginVersion: String? = null + /** The Kotlin plugin's version, or null when the project has none. Asked when the build script asks. */ + internal var kotlinPluginVersion: () -> String? = { null } - private fun kotlinVersion(): String = kotlinPluginVersion ?: KonanDistribution.defaultVersion + private fun kotlinVersion(): String = kotlinPluginVersion() ?: KonanDistribution.defaultVersion /** * [field] for the task [usedBy]: this block, else the Gradle property, else (`androidNdk`) the module's diff --git a/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt b/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt index a0e0950..82ae610 100644 --- a/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt +++ b/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt @@ -3,6 +3,7 @@ package io.github.lemcoder.wiring import com.android.build.api.variant.AndroidComponents import com.android.build.api.variant.AndroidComponentsExtension import com.android.build.api.variant.KotlinMultiplatformAndroidComponentsExtension +import org.gradle.api.GradleException import org.gradle.api.Project import org.gradle.api.file.Directory import org.gradle.api.provider.Property @@ -47,8 +48,10 @@ internal object AgpWiring { if (!enabled()) return@onVariants project.file(relative).mkdirs() when (kind) { - SourceKind.KOTLIN -> - if (!project.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM)) variant.sources.kotlin?.addStaticSourceDirectory(relative) + SourceKind.KOTLIN -> if (!project.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM)) { + variant.sources.kotlin?.addStaticSourceDirectory(relative) + requireKotlinCompile(project, variant.name) + } SourceKind.JAVA -> variant.sources.java?.addStaticSourceDirectory(relative) } } @@ -71,6 +74,22 @@ internal object AgpWiring { } } + /** + * Fails configuring [variant]'s build when nothing compiles the Kotlin added to it — `android { enableKotlin = false }`, + * or AGP without built-in Kotlin or `kotlin-android` — instead of letting `javac` report the bindings missing. + * Judged by the compile task itself, which every way of compiling Kotlin names alike; it is registered after + * the variant callbacks, so the check waits for the variant's `preBuild` to be configured. + */ + private fun requireKotlinCompile(project: Project, variant: String) { + val capitalized = variant.replaceFirstChar { it.uppercase() } + project.tasks.matching { it.name == "pre${capitalized}Build" }.configureEach { + if ("compile${capitalized}Kotlin" !in project.tasks.names) throw GradleException( + "${project.path}: JNI bindings are Kotlin, and variant '$variant' compiles none " + + "(android { enableKotlin = false }?). Enable Kotlin, or call ffm() in the binding for Java bindings." + ) + } + } + /** * The lowest `minSdk` among the module's variants, once AGP has computed them; absent outside Android. * The API level Android links target, so they see every platform library the app may call. diff --git a/src/main/kotlin/io/github/lemcoder/wiring/GradlePluginId.kt b/src/main/kotlin/io/github/lemcoder/wiring/GradlePluginId.kt index c5e5826..7bccfc2 100644 --- a/src/main/kotlin/io/github/lemcoder/wiring/GradlePluginId.kt +++ b/src/main/kotlin/io/github/lemcoder/wiring/GradlePluginId.kt @@ -6,8 +6,6 @@ import org.gradle.api.Project internal enum class GradlePluginId(val id: String) { KOTLIN_JVM("org.jetbrains.kotlin.jvm"), KOTLIN_MULTIPLATFORM("org.jetbrains.kotlin.multiplatform"), - /** Consumes as Android does; only its version matters, for `kotlinNativeDistribution()`. */ - KOTLIN_ANDROID("org.jetbrains.kotlin.android"), /** Every AGP application and library plugin; the KMP Android plugin does not apply it. */ ANDROID_BASE("com.android.base"), ANDROID_KMP_LIBRARY("com.android.kotlin.multiplatform.library"), diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 93d5c35..afc0c25 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -366,6 +366,39 @@ class KonanPluginFunctionalTest { assertFalse("not ANDROID_NDK_HOME" in agreeing, agreeing) } + @Test + fun `JNI bindings in an Android module that compiles no Kotlin fail configuration, not javac`() { + assumeTrue(System.getProperty("test.gradle.version").isNullOrEmpty(), "AGP 9 does not run on the oldest Gradle tested") + useAndroidSdk() + fun preBuild(enableKotlin: Boolean): org.gradle.testkit.runner.GradleRunner { + write("""id("com.android.library")${"\n"}id("io.github.lemcoder.konanplugin")""", """bindings { create("mymath") { defFile.set(file("mymath.def")) } }""") + File(projectDir, "build.gradle.kts").appendText( + "android {\n namespace = \"example\"\n compileSdk = 36\n enableKotlin = $enableKotlin\n}\n" + ) + return runner("preDebugBuild", "--dry-run").withPluginClasspathAndAgp() + } + + val failure = preBuild(enableKotlin = false).buildAndFail().output + assertContains(failure, ": JNI bindings are Kotlin, and variant 'debug' compiles none") + assertContains(failure, "call ffm() in the binding") + // With Kotlin, AGP 9's default, the same module configures. + preBuild(enableKotlin = true).build() + } + + @Test + fun `kotlinNativeDistribution() names the Kotlin version of AGP's built-in Kotlin, whichever plugin comes first`() { + assumeTrue(System.getProperty("test.gradle.version").isNullOrEmpty(), "AGP 9 does not run on the oldest Gradle tested") + useAndroidSdk() + // The Kotlin plugin AGP brings: built-in Kotlin applies no `kotlin-android`, only its `kotlin` extension. + val kotlin = Regex("""kotlin-gradle-plugin-([0-9][^-/\\]*)""").find(System.getProperty("agp.classpath"))!!.groupValues[1] + for (plugins in listOf("""id("com.android.library")${"\n"}id("io.github.lemcoder.konanplugin")""", """id("io.github.lemcoder.konanplugin")${"\n"}id("com.android.library")""")) { + write(plugins, """println("distribution: " + config.kotlinNativeDistribution().name)""") + File(projectDir, "build.gradle.kts").appendText("android {\n namespace = \"example\"\n compileSdk = 36\n}\n") + val output = runner("help").withPluginClasspathAndAgp().build().output + assertContains(output, Regex("distribution: kotlin-native-prebuilt-[a-z0-9_-]+-${Regex.escape(kotlin)}$", RegexOption.MULTILINE), plugins) + } + } + @Test fun `CMake builds with a job count`() { write( @@ -483,6 +516,14 @@ class KonanPluginFunctionalTest { return ndk!!.absolutePath.replace("\\", "/") } + /** An Android SDK into `local.properties`, for fixtures that configure an Android module. */ + private fun useAndroidSdk() { + val sdk = listOfNotNull(System.getenv("ANDROID_HOME"), System.getenv("ANDROID_SDK_ROOT"), "${System.getProperty("user.home")}/Library/Android/sdk", "${System.getProperty("user.home")}/Android/Sdk") + .map(::File).firstOrNull { it.resolve("platforms").isDirectory } + assumeTrue(sdk != null, "needs an Android SDK") + File(projectDir, "local.properties").writeText("sdk.dir=${sdk!!.absolutePath.replace("\\", "/")}\n") + } + private fun realKonanHome(): String { assumeTrue(KonanDistribution.installed() != null, "Kotlin/Native ${KonanDistribution.defaultVersion} is not installed") return KonanDistribution.installed()!!.home.absolutePath.replace("\\", "/")