From 21c653000e2c5ff00c63646f09bb416282c71fed Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 9 Nov 2026 11:41:43 +0100 Subject: [PATCH 1/3] chore: release skill The steps that publish a version: bump and changelog, publishPlugins, a Portal-only check, tag and GitHub release. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/release/SKILL.md | 88 +++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 .claude/skills/release/SKILL.md 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. From 2adeb8fe5e97a3dd37ea95a7f27537e636e93521 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 9 Nov 2026 11:41:43 +0100 Subject: [PATCH 2/3] fix: AGP 9's built-in Kotlin kotlinNativeDistribution() took the Kotlin version only when a Kotlin plugin id was applied. Built-in Kotlin applies none, only the kotlin extension, so an Android module got the plugin's default version (2.4.20) instead of its own (AGP 9.2.1's 2.2.10). The version now comes from the extension, asked lazily so plugin order does not matter. JNI bindings are Kotlin. In a module with android { enableKotlin = false } AGP keeps the Kotlin source directory but registers no Kotlin compile, so the bindings were never compiled and Java callers could not find them. Configuring such a variant's build now fails, naming enableKotlin and ffm() as the ways out. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 9 ++++ README.md | 4 +- .../kotlin/io/github/lemcoder/KonanPlugin.kt | 10 ++--- .../github/lemcoder/dsl/config/KonanConfig.kt | 8 ++-- .../io/github/lemcoder/wiring/AgpWiring.kt | 23 ++++++++++- .../github/lemcoder/wiring/GradlePluginId.kt | 2 - .../lemcoder/KonanPluginFunctionalTest.kt | 41 +++++++++++++++++++ 7 files changed, 83 insertions(+), 14 deletions(-) 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..6f0ff5a 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("\\", "/") From 678e8708fd2d2a4d3a6216d48efb8f0e7aee9782 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 9 Nov 2026 11:48:32 +0100 Subject: [PATCH 3/3] test: a host name with an underscore matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux x64's distribution is kotlin-native-prebuilt-linux-x86_64-… Co-Authored-By: Claude Opus 5.5 --- src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 6f0ff5a..afc0c25 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -395,7 +395,7 @@ class KonanPluginFunctionalTest { 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) + assertContains(output, Regex("distribution: kotlin-native-prebuilt-[a-z0-9_-]+-${Regex.escape(kotlin)}$", RegexOption.MULTILINE), plugins) } }