Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 "<previous version>" -- ':!CHANGELOG.md'` — today `build.gradle.kts` (`version = …`) and the
README's `plugins { }` snippet. Replace each with the new version.
- Add a `## <version>` section at the top of `CHANGELOG.md`, above the previous one, under `### Fixed`,
`### Changed` or `### Added`. Collect what shipped from `git log <previous tag>..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 <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 "<version>" }' > "$dir/build.gradle.kts"
touch "$dir/settings.gradle.kts"
./gradlew -q -p "$dir" --refresh-dependencies buildEnvironment | grep konan # resolves <version>
./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<version> -m "<version>"
git push origin v<version>
```

Release notes are the version's CHANGELOG section followed by the `plugins { }` snippet:

````
<the CHANGELOG section's body, without its "## <version>" heading>

```kotlin
plugins {
id("io.github.lemcoder.konanplugin") version "<version>"
}
```
````

```bash
gh release create v<version> --title <version> --notes-file <notes.md> --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.
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 `compile<Variant>Kotlin`, so the bindings were silently never compiled and
Java calling them could not find them. `AgpWiring.requireKotlinCompile` checks for that task when `pre<Variant>Build` 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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|----------------------------------------------|----------------------------------|----------------------------------------------------|
Expand Down
10 changes: 5 additions & 5 deletions src/main/kotlin/io/github/lemcoder/KonanPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,9 +30,11 @@ abstract class KonanPlugin : Plugin<Project> {
})
}
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)
Expand Down
8 changes: 4 additions & 4 deletions src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 21 additions & 2 deletions src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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 `pre<Variant>Build` 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.
Expand Down
2 changes: 0 additions & 2 deletions src/main/kotlin/io/github/lemcoder/wiring/GradlePluginId.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
41 changes: 41 additions & 0 deletions src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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("\\", "/")
Expand Down
Loading