Skip to content
Open
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
4 changes: 3 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ jobs:
include:
- name: jvm
tasks: jvmTest
# verifyNpmPins guards the npm-* pins in gradle/libs.versions.toml against
# lockfile drift; it belongs on the leg that already has the JS toolchain.
- name: js-wasm
tasks: jsTest wasmJsTest wasmWasiTest
tasks: verifyNpmPins jsTest wasmJsTest wasmWasiTest
- name: native
tasks: linuxX64Test
steps:
Expand Down
16 changes: 16 additions & 0 deletions build-logic/convention/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ plugins {
}

repositories {
google()
gradlePluginPortal()
mavenCentral()
}
Expand All @@ -17,6 +18,13 @@ dependencies {
implementation(libs.asciidoctorj.core)
implementation("org.jetbrains.dokka:dokka-gradle-plugin:2.2.0")
implementation(gradleApi())

// compileOnly, never implementation: build-logic is an included build, so putting these
// on the plugin's runtime classpath would load a second copy of KGP/AGP in a different
// classloader and turn every `getByType(SomeKgpType::class)` into a ClassCastException.
// compileOnly gives us the types while deferring to the classes the consuming build loads.
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.android.gradlePlugin)
}

kotlin {
Expand All @@ -39,5 +47,13 @@ gradlePlugin {
id = "sk.ainet.transformers.bom-coverage"
implementationClass = "sk.ainet.buildlogic.bom.BomCoveragePlugin"
}
register("SKaiNetMultiplatform") {
id = "sk.ainet.multiplatform"
implementationClass = "sk.ainet.buildlogic.kmp.SkainetMultiplatformPlugin"
}
register("SKaiNetNpmPins") {
id = "sk.ainet.npm-pins"
implementationClass = "sk.ainet.buildlogic.npm.NpmPinsPlugin"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package sk.ainet.buildlogic.kmp

import org.jetbrains.kotlin.gradle.dsl.JvmTarget

/**
* The `skainet { }` block contributed by `sk.ainet.multiplatform`.
*
* Holds the settings that can be applied *after* the build script has been evaluated.
* Which platforms a module targets is deliberately **not** here — see [SkainetTargets]
* for why, and for the `skainet.targets` property that controls it.
*/
abstract class SkainetMultiplatformExtension {

/**
* Android namespace. Required when `com.android.kotlin.multiplatform.library` is
* applied, ignored otherwise.
*
* Deliberately explicit rather than derived from the project path: several modules
* publish namespaces that do not match their path, and changing a published
* artifact's namespace is a separate decision from adopting this plugin.
*/
var namespace: String? = null

/**
* `jvmTarget` for the Android compilation. Most modules use [JvmTarget.JVM_11]; the
* `skainet-io` family currently ships [JvmTarget.JVM_1_8] and passes it explicitly.
*/
var androidJvmTarget: JvmTarget = JvmTarget.JVM_11

/** Calls `explicitApi()`. */
var explicitApi: Boolean = true

/** Adds `-Xexpect-actual-classes` to every compilation. */
var expectActualClasses: Boolean = false

/** Adds `kotlin-test` to `commonTest`. */
var kotlinTestInCommonTest: Boolean = true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package sk.ainet.buildlogic.kmp

import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget
import org.gradle.api.GradleException
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalog
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.api.plugins.ExtensionAware
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.targets.js.dsl.KotlinJsSubTargetDsl
import sk.ainet.buildlogic.npm.NpmPinsMarker

private const val ANDROID_KMP_PLUGIN_ID = "com.android.kotlin.multiplatform.library"
private const val NPM_PINS_PLUGIN_ID = "sk.ainet.npm-pins"
private const val SHARED_KARMA_CONFIG_DIR = "gradle/karma.config.d"

/**
* SKaiNET's standard Kotlin Multiplatform module setup.
*
* Replaces the target list, `android { }` block, `explicitApi()`, `kotlin-test` wiring
* and Karma hardening that every library module used to copy by hand.
*
* ```kotlin
* plugins {
* id("sk.ainet.multiplatform")
* alias(libs.plugins.androidMultiplatformLibrary) // opt into Android
* }
*
* skainet {
* namespace = "sk.ainet.pipeline"
* }
* ```
*
* Configuration is split in two, for reasons explained on [SkainetTargets]: which
* platforms are built comes from the `skainet.targets` Gradle property (readable while
* the plugin is applied, which is when targets must be created); everything else lives
* in the [SkainetMultiplatformExtension] DSL and is applied after evaluation.
*/
class SkainetMultiplatformPlugin : Plugin<Project> {

override fun apply(project: Project) {
val extension = project.extensions.create("skainet", SkainetMultiplatformExtension::class.java)
val targets = SkainetTargets.from(project)

// Checked before anything is configured, so a misconfigured build fails on the
// reason rather than on a symptom further down.
if (targets.web) requireNpmPins(project)

project.pluginManager.apply("org.jetbrains.kotlin.multiplatform")
val kotlin = project.extensions.getByType(KotlinMultiplatformExtension::class.java)

// Eager: targets must exist before the module's own `kotlin { }` block runs.
configureTargets(project, kotlin, targets)

// Deferred: needs the values the build script sets in `skainet { }`.
project.afterEvaluate { applyExtension(this, kotlin, extension) }
}

private fun applyExtension(
project: Project,
kotlin: KotlinMultiplatformExtension,
extension: SkainetMultiplatformExtension,
) {
if (extension.explicitApi) kotlin.explicitApi()

if (extension.expectActualClasses) {
kotlin.targets.configureEach {
compilations.configureEach {
compileTaskProvider.configure {
compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes")
}
}
}
}

if (extension.kotlinTestInCommonTest) {
val kotlinTest = project.versionCatalog().findLibrary("kotlin-test").orElseThrow {
GradleException("[skainet-multiplatform] Version catalog is missing the 'kotlin-test' library alias")
}
kotlin.sourceSets.named("commonTest").configure {
dependencies { implementation(kotlinTest) }
}
}

configureAndroid(project, kotlin, extension)
}

/**
* Fails a `js`/`wasmJs` module when the root project does not apply
* `sk.ainet.npm-pins`.
*
* Without the root plugin the npm pins are simply absent: no Yarn `resolutions` are
* written, the lockfiles drift back to whatever the transitive ranges resolve to, and
* `verifyNpmPins` does not exist to catch it. That is a silent security regression,
* so it fails loudly at configuration time instead.
*
* The check reads a build-scoped service rather than `rootProject.pluginManager`, so
* it stays legal under isolated projects — see [NpmPinsMarker].
*/
private fun requireNpmPins(project: Project) {
if (NpmPinsMarker.isRegistered(project)) return

throw GradleException(
"[skainet-multiplatform] ${project.path} builds js/wasmJs targets, but " +
"$NPM_PINS_PLUGIN_ID is not applied to the root project, so npm pins " +
"are not in force. Add it to the root build.gradle.kts:\n" +
" plugins { alias(libs.plugins.skainet.npmPins) }"
)
}

/**
* Fills in the `android { }` body. The AGP KMP plugin creates the target itself when
* it is applied in the module's `plugins { }` block, so this only sets values — which
* is why it can run after evaluation, ahead of AGP's own `afterEvaluate`.
*/
private fun configureAndroid(
project: Project,
kotlin: KotlinMultiplatformExtension,
extension: SkainetMultiplatformExtension,
) {
if (!project.pluginManager.hasPlugin(ANDROID_KMP_PLUGIN_ID)) return

val namespace = extension.namespace
?: throw GradleException(
"[skainet-multiplatform] ${project.path} applies $ANDROID_KMP_PLUGIN_ID, " +
"so it must declare a namespace: skainet { namespace = \"sk.ainet.…\" }"
)

val catalog = project.versionCatalog()
val android = (kotlin as ExtensionAware).extensions
.getByName("android") as KotlinMultiplatformAndroidLibraryTarget

android.namespace = namespace
android.compileSdk = catalog.requiredVersion("android-compileSdk").toInt()
android.minSdk = catalog.requiredVersion("android-minSdk").toInt()
android.compilerOptions { jvmTarget.set(extension.androidJvmTarget) }
}

@OptIn(ExperimentalWasmDsl::class)
private fun configureTargets(
project: Project,
kotlin: KotlinMultiplatformExtension,
targets: SkainetTargets,
) {
if (targets.jvm) kotlin.jvm()

if (targets.js) {
kotlin.js {
browser { hardenBrowserTests(project) }
}
}

if (targets.wasmJs) {
kotlin.wasmJs {
browser { hardenBrowserTests(project) }
if (targets.wasmJsExecutable) binaries.executable()
}
}

if (targets.wasmWasi) kotlin.wasmWasi { nodejs() }

if (targets.apple) {
kotlin.iosArm64()
kotlin.iosSimulatorArm64()
kotlin.macosArm64()
}

if (targets.linux) {
kotlin.linuxX64()
kotlin.linuxArm64()
}

if (targets.androidNative) {
kotlin.androidNativeArm32()
kotlin.androidNativeArm64()
}
}

/**
* Points Karma at the repository-wide config directory instead of a per-module
* `karma.config.d`. The shared config raises capture/disconnect timeouts so that
* parallel `allTests` runs do not fail on a starved headless browser.
*
* `useChromeHeadless()` is not redundant: KGP installs its own default only when no
* test framework has been set yet (`KotlinBrowserJsIr.configureDefaultTestFramework`),
* and `useKarma` replaces the framework wholesale. Dropping it leaves the task with
* "No browsers configured for jsBrowserTest".
*/
private fun KotlinJsSubTargetDsl.hardenBrowserTests(project: Project) {
val sharedConfigDir = project.rootProject.file(SHARED_KARMA_CONFIG_DIR)
testTask {
useKarma {
useChromeHeadless()
useConfigDirectory(sharedConfigDir)
}
}
}

private fun Project.versionCatalog(): VersionCatalog =
extensions.getByType(VersionCatalogsExtension::class.java).named("libs")

private fun VersionCatalog.requiredVersion(alias: String): String =
findVersion(alias)
.orElseThrow { GradleException("[skainet-multiplatform] Version catalog is missing the '$alias' version alias") }
.requiredVersion
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package sk.ainet.buildlogic.kmp

import org.gradle.api.InvalidUserDataException
import org.gradle.api.Project

/**
* Which platforms a module targets.
*
* ## Why this is a Gradle property and not part of `skainet { }`
*
* Kotlin targets have to exist before the module's own `kotlin { }` block runs: the
* source-set convention accessors (`jvmMain`, `iosArm64Main`, …) create source sets on
* access, and KGP then refuses to attach a compilation to a source set that already
* exists —
*
* ```
* ⛔ The compilation 'main' cannot be created after the source set 'jvmMain'
* ```
*
* A `skainet { }` block is evaluated as part of the build script, so anything declared
* there is only known once it is already too late to create targets. Gradle properties,
* on the other hand, are readable while the plugin is being applied. Everything that is
* *not* structural stays in [SkainetMultiplatformExtension].
*
* ## Usage
*
* Most modules want the default set and declare nothing. A module that differs adds one
* line to its own `gradle.properties`:
*
* ```properties
* skainet.targets=jvm
* skainet.wasmJs.executable=true
* ```
*
* `skainet.targets` is a comma-separated list of the names below. `none` selects an
* empty set, for modules that declare every target themselves.
*/
internal data class SkainetTargets(
val jvm: Boolean,
val js: Boolean,
val wasmJs: Boolean,
val wasmWasi: Boolean,
/** `iosArm64`, `iosSimulatorArm64`, `macosArm64`. */
val apple: Boolean,
/** `linuxX64`, `linuxArm64`. */
val linux: Boolean,
/** `androidNativeArm32`, `androidNativeArm64` — vendor backends linking device libraries. */
val androidNative: Boolean,
val wasmJsExecutable: Boolean,
) {
val web: Boolean get() = js || wasmJs

companion object {
const val TARGETS_PROPERTY = "skainet.targets"
const val WASM_JS_EXECUTABLE_PROPERTY = "skainet.wasmJs.executable"

private val DEFAULT = setOf("jvm", "js", "wasmJs", "wasmWasi", "apple", "linux")
private val KNOWN = DEFAULT + setOf("androidNative")

fun from(project: Project): SkainetTargets {
// findProperty, not providers.gradleProperty: as of Gradle 9 the provider API
// only exposes build-level properties, and these are declared per module.
val declared = project.findProperty(TARGETS_PROPERTY)?.toString()
val selected = when {
declared == null -> DEFAULT
declared.isBlank() || declared.trim() == "none" -> emptySet()
else -> declared.split(",").map { it.trim() }.filter { it.isNotEmpty() }.toSet()
}

val unknown = selected - KNOWN
if (unknown.isNotEmpty()) {
throw InvalidUserDataException(
"[skainet-multiplatform] ${project.path}: unknown $TARGETS_PROPERTY entries " +
"${unknown.sorted()}. Known values: ${KNOWN.sorted()}, or 'none'."
)
}

return SkainetTargets(
jvm = "jvm" in selected,
js = "js" in selected,
wasmJs = "wasmJs" in selected,
wasmWasi = "wasmWasi" in selected,
apple = "apple" in selected,
linux = "linux" in selected,
androidNative = "androidNative" in selected,
wasmJsExecutable = project.findProperty(WASM_JS_EXECUTABLE_PROPERTY)?.toString().toBoolean(),
)
}
}
}
Loading
Loading