Build from Source
|
Audience: SKaiNET maintainers and contributors. This page covers building SKaiNET from a clone of the repository. Library users consuming published SKaiNET artifacts via the BOM do not need any of this; see the Tutorials and How-to sections. |
Build Help
Dokka API Documentation
SKaiNET uses Dokka 2.1.0 to generate API reference documentation across all public library modules. A shared convention plugin (sk.ainet.dokka) standardises the configuration.
Generating docs locally
Single module:
./gradlew :skainet-lang:skainet-lang-core:dokkaGeneratePublicationHtml
Output: skainet-lang/skainet-lang-core/build/dokka/html/
Aggregated (all modules):
./gradlew dokkaGenerate
Output: build/dokka/html/index.html
Convention plugin details
The sk.ainet.dokka precompiled script plugin (build-logic/convention/src/main/kotlin/sk.ainet.dokka.gradle.kts) applies org.jetbrains.dokka and configures:
-
moduleName from
project.name -
moduleVersion from the
VERSION_NAMEGradle property -
Documented visibilities: public only
-
Suppressed generated files: KSP-generated code is excluded
-
Suppressed native source sets:
iosArm64Main,iosSimulatorArm64Main,macosArm64Main,linuxX64Main,linuxArm64Mainare suppressed because Dokka 2.x cannot translate native cinterop symbols -
Source links pointing to the GitHub repository
Modules with Dokka enabled
The plugin is applied to 21 library modules:
| Group | Modules |
|---|---|
skainet-lang |
|
skainet-compile |
|
skainet-backends |
|
skainet-data |
|
skainet-io |
|
Other |
|
Excluded: skainet-bom (no source), skainet-apps/*, skainet-test/*, benchmarks, and skainet-lang-ksp-processor (internal).
Root-level aggregation
The root build.gradle.kts applies the Dokka plugin directly (not apply false) and declares dokka(project(…)) dependencies for all 21 modules. Running ./gradlew dokkaGenerate at the root produces a unified API reference that includes every module under a single SKaiNET namespace. The root README.md is included as the landing page.
KSP interaction
skainet-lang-core and skainet-lang-dag use KSP to generate source code. Their build files include:
tasks.matching { it.name.startsWith("dokka") }.configureEach {
dependsOn("kspCommonMainKotlinMetadata")
}
This ensures KSP-generated sources are available before Dokka runs.
GitHub Pages deployment
The workflow .github/workflows/dokka-pages.yml runs on push to main (and manually via workflow_dispatch). It:
-
Checks out the repo
-
Sets up JDK 25
-
Runs
./gradlew dokkaGenerate -
Uploads the
build/dokka/htmldirectory as a Pages artifact -
Deploys to GitHub Pages using
actions/deploy-pages@v4
Prerequisite: The repository must have Pages configured to deploy from GitHub Actions (Settings > Pages > Source: "GitHub Actions").
Multiplatform Convention Plugin
sk.ainet.multiplatform (build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/kmp/) carries the Kotlin Multiplatform setup that every SKaiNET library module would otherwise copy: the target list, the android { } body, explicitApi(), kotlin-test in commonTest, and the shared Karma configuration.
Using it
plugins {
id("sk.ainet.multiplatform")
alias(libs.plugins.androidMultiplatformLibrary) // opt in to Android
alias(libs.plugins.vanniktech.mavenPublish)
id("sk.ainet.dokka")
}
skainet {
namespace = "sk.ainet.pipeline"
}
The default target set is jvm, js, wasmJs, wasmWasi, apple (iosArm64, iosSimulatorArm64, macosArm64) and linux (linuxX64, linuxArm64). Android is not a flag — the AGP plugin has to be applied in plugins { }, and the convention plugin fills in its body once it sees it.
Where each setting lives
Which platforms a module builds comes from the skainet.targets Gradle property, not from skainet { }:
# skainet-data/skainet-data-source/gradle.properties
skainet.targets=jvm
Accepted values are jvm, js, wasmJs, wasmWasi, apple, linux, androidNative, or none. There is also skainet.wasmJs.executable=true.
This split is not cosmetic. Kotlin targets must exist before the module’s own kotlin { } block runs, because 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 part of the build script, so anything declared there is only known once it is too late to create targets. Gradle properties are readable while the plugin is being applied. Everything non-structural — namespace, androidJvmTarget, explicitApi, expectActualClasses, kotlinTestInCommonTest — stays in the skainet { } DSL.
The two skainet { } blocks
The name appears in two places, deliberately: there is one SKaiNET namespace to look for wherever you are. The types behind it differ, because the concerns do.
| Where | What it configures |
|---|---|
module |
That module’s own compilation — |
root |
Conventions global to the build, which have nowhere else to live — currently |
Applying sk.ainet.multiplatform to the root project is not supported and fails with a message saying so: the root project is not a library module, and its skainet { } block means something else.
Migrating a module
-
Replace
alias(libs.plugins.kotlinMultiplatform)withid("sk.ainet.multiplatform"). -
Delete the target declarations,
explicitApi(), theandroid { }block andcommonTest’s `kotlin-test; move the namespace intoskainet { }. -
If the module does not build the full default set, add
skainet.targetsto itsgradle.properties. -
Delete its
karma.config.d/directory — the shared config atgradle/karma.config.d/is applied automatically. -
Rewrite any
val someSourceSet by getting { }to the convention accessors (jvmMain { },iosArm64Main { }). Theby gettingform resolves eagerly and fails now that targets are created by the plugin. -
Confirm nothing moved:
./gradlew :module:tasks --allbefore and after must be identical, and./gradlew apiCheckmust pass without a new dump.
Pre-PR Gate and the Packed-Encoding Golden Parity Tests
CI runs the test legs per target (jvm, js-wasm, native, android, plus assemble), the
golden-parity packed-encoding gate, and — since 0.49.0 — a dedicated api-compatibility leg
running apiCheck, so a stale API dump fails under its own name. Branch protection on develop
requires the aggregated build-job to be green, admins included. Before opening a PR, run the
same set locally:
scripts/pr-gate.sh # full gate: jvmTest, apiCheck, JS/Wasm tests, linuxX64Test, assemble, Java API tests
scripts/pr-gate.sh --quick # jvmTest + apiCheck only, for iteration
scripts/pr-gate.sh --golden # packed-encoding golden parity (JVM + linuxX64) + apiCheck only
scripts/pr-gate.sh --bench # full gate + StorageBenchmarks and JMH microbenchmarks
The script points CHROME_BIN at an installed Chrome/Chromium for the Karma browser tests and
expects a JDK 21+ (JAVA_HOME; CI uses 25).
Golden parity tests (skainet-backends/skainet-backend-cpu/src/goldenTest/kotlin/sk/ainet/exec/golden/)
guard the packed encodings bit-for-bit: for every GGML block format (Q4_0, Q5_0, Q5_1, Q8_0, Q4_K,
Q5_K, Q6_K), ternary (TQ2_0) and TurboQuant they decode seeded bytes, run the scalar reference
kernels and encode/decode seeded vectors, and compare an FNV-64 digest of the raw float bits against
the values recorded in Goldens.kt. They are compiled into the JVM and Kotlin/Native test targets
(JS/Wasm compute Float in double precision and are covered by the tolerance-based
PackedMatmulDispatchParityTest in commonTest instead). A mismatch means a decoder or kernel no
longer produces the same bits; if that is the intent of a PR, re-baseline Goldens.kt in the same
PR and say so. See The memory model and SKEEP-003.
Pinning npm Packages
Kotlin/JS and Kotlin/Wasm dependencies are locked in kotlin-js-store/yarn.lock and kotlin-js-store/wasm/yarn.lock. Both files are generated. Editing them by hand does not survive the next lockfile refresh — see PR #894, where a hand-applied ws security bump was reverted by kotlinWasmUpgradeYarnLock in the same pull request.
Pinning a package takes two edits. The version goes in [versions] of gradle/libs.versions.toml:
npm-ws = "8.21.1" # GHSA-96hv-2xvq-fx4p
and the root build script names the package it belongs to:
skainet {
npmPins {
pin("ws", libs.versions.npm.ws)
}
}
sk.ainet.npm-pins (applied on the root project) turns each pin into a Yarn resolutions entry in both generated root package.json files, so one declaration covers the JS and Wasm dependency graphs. Then regenerate and commit the lockfiles:
./gradlew kotlinUpgradeYarnLock kotlinWasmUpgradeYarnLock
Scoping a pin to one lockfile
Yarn writes a lockfile entry for every resolutions key, whether or not that dependency graph actually requests the package. An unscoped pin for a package that only the JS graph uses therefore adds it — and everything it depends on — to kotlin-js-store/wasm/yarn.lock, where nothing imports it. For webpack that is roughly 76 extra packages the Wasm build would download and install for nothing, and which Dependabot would then report against a lockfile that never uses them.
Pass an NpmPinTarget when a package lives in only one graph:
import sk.ainet.buildlogic.npm.NpmPinTarget
skainet {
npmPins {
pin("ws", libs.versions.npm.ws) (1)
pin("webpack", libs.versions.npm.webpack, NpmPinTarget.JS) (2)
}
}
| 1 | No target: applies to both lockfiles. Correct for ws, which both graphs resolve. |
| 2 | NpmPinTarget.JS: only kotlin-js-store/yarn.lock gets the resolution. |
Check which graph holds a package before deciding:
grep -c '^webpack@' kotlin-js-store/yarn.lock kotlin-js-store/wasm/yarn.lock
verifyNpmPins checks each lockfile against its own pin map, so a scoped pin is never reported as missing from the lockfile it was never meant to reach.
verifyNpmPins re-reads the committed lockfiles and fails if any pinned package resolved elsewhere. It is wired into check and runs on the js-wasm leg of .github/workflows/build.yml.
The root plugin is not optional for web modules: sk.ainet.multiplatform fails at configuration time if a module builds js/wasmJs while the root project does not apply sk.ainet.npm-pins. Without it no resolutions are written and verifyNpmPins does not exist to notice — a silent security regression rather than a build error.
The package name is written out rather than derived from the catalog alias, so names a catalog alias cannot spell need nothing special — pin("socket.io", …), pin("@types/node", …). Versions must be exact: a range such as ^8.21.1 is rejected at configuration time, because verifyNpmPins compares the lockfile’s resolved version for equality and a range could never match.
Pinning Maven/JVM Dependencies
sk.ainet.maven-pins is the JVM/Maven equivalent of sk.ainet.npm-pins: it force-pins a transitive group:artifact coordinate to an audited version across every subproject’s dependency graph, for a high-severity CVE in a package no build script declares directly.
Declaring a pin also takes two edits. The version goes in [versions] of gradle/libs.versions.toml:
maven-netty = "4.1.136.Final" # CVE-2026-56819
and the root build script names the coordinate:
skainet {
mavenPins {
pin("io.netty:netty-handler", libs.versions.maven.netty)
}
}
sk.ainet.maven-pins forces the pin via resolutionStrategy across every subproject’s configurations. Unlike npm pins there is one shared JVM dependency graph, not a per-target lockfile, so there is no NpmPinTarget-style scoping.
verifyMavenPins is registered per subproject (a Gradle task may only resolve configurations belonging to its own project) and wired into that subproject’s check. It walks each *CompileClasspath/*RuntimeClasspath configuration’s live resolved dependency graph and fails if a pinned coordinate resolves to something other than its pinned version. Running ./gradlew verifyMavenPins from the repository root still verifies the whole build — Gradle matches a bare task name against every project — and a coordinate that never resolves anywhere is reported as a warning (a pin can outlive the dependency that once pulled it in transitively), not a failure.
What this cannot fix
resolutionStrategy only reaches a project’s own configurations — it cannot touch a Gradle plugin’s own classpath (the Android Gradle Plugin, Dokka, KSP, and so on). Those classpaths are resolved before any project’s configurations exist, through a separate mechanism entirely. A high-severity CVE in a transitive dependency of a plugin — not the app — cannot be closed with a mavenPins pin, no matter how the coordinate is spelled.
This came up concretely in issue #1046: 22 high-severity Dependabot alerts (Netty, Jackson, jose4j, jdom2) turned out to be transitive to AGP’s and Dokka’s own plugin classpaths, not the app’s dependency graph — confirmed with ./gradlew buildEnvironment, which prints the classpath used to resolve a project’s plugins. None of those packages are reachable via mavenPins, and none are present in anything SKaiNET publishes to Maven Central, so consumers of the library never load them. The alerts were dismissed as tolerable risk rather than pinned. For a plugin-classpath CVE that genuinely needs fixing, the options are: upgrade the plugin to a version that no longer pulls the vulnerable transitive dependency, or (confirmed to work, but not currently used anywhere in this build) add a buildscript { configurations.classpath { resolutionStrategy { ... } } } block directly to the affected script — mavenPins cannot express this because a build-logic-supplied plugin only runs after the script’s own plugins { } block has already resolved.