kotlin-native-nuget Help

Publish a Kotlin/Native library as NuGet

Configure a Kotlin Multiplatform module, package its native binaries and generated C# API, then add the resulting MyCatLib package to a .NET project.

1. Apply the plugin and build shared libraries

Apply Kotlin Multiplatform and the NuGet plugin, then configure a shared library for each target. The plugin derives the native library name from packageId:

plugins { kotlin("multiplatform") id("io.github.xxfast.kotlin.native.nuget") version "<version>" } kotlin { mingwX64 { binaries { sharedLib { } } } macosArm64 { binaries { sharedLib { } } } }

Replace <version> with the version shown in Getting started, and keep it pinned. Only configured targets in the supported target table that this host can link are added to the package this way; a RID built on another host can still be added via prebuiltRuntimes, see One package for every platform below.

The plugin derives a unique native library name from packageId, so separate Kotlin-built packages can be loaded in one .NET project. MobileEvidence.Kotlin ships kn_mobileevidence_kotlin.dll: kn_ plus the lowercased id, with ., - and _ written as _. Leave baseName unset; a baseName that differs from the derived name fails the build. Prebuilt files must use the derived name, and each prebuilt RID input must contain exactly that package’s expected primary native library. Package ids that differ only in their separators share a native name and cannot be loaded together.

java.lang.NullPointerException at ...KlibModuleOriginKt.getKlibModuleOrigin(KlibModuleOrigin.kt:32) at ...cexport.CAdapterCodegen.buildCAdapter(CAdapterCodegen.kt:58)

Ordinary compilation succeeds; only C-export at link time fails. This is KT-62984, a Kotlin/Native backend bug triggered by plugin-generated IR that carries no klib origin, not anything specific to this plugin. Marking the annotated declarations internal does not help: the crashing IR is generated by the other plugin, not by your code.

The workaround is to exclude the offending plugin from the target's kotlinCompilerPluginClasspath<Target> configuration:

kotlin { macosArm64 { // Excludes the Koin compiler plugin's generated-code dependency from this target's // compiler-plugin classpath only; other targets are unaffected. project.configurations.named("kotlinCompilerPluginClasspathMacosArm64Main") { exclude(group = "io.insert-koin", module = "koin-compiler-plugin") } } }

2. Configure the NuGet package

Publish every public declaration below rootPackage into the corresponding C# namespace:

nuget { publish { packageId = "MyCatLib" version = "1.0.0" authors = "yourname" description = "A Kotlin/Native library for cat lovers" rootPackage = "com.example.cats" } }

For example, add this API under src/nativeMain/kotlin/com/example/cats/Cat.kt:

package com.example.cats interface Pet { val name: String fun speak(): String } enum class Mood { HAPPY, SLEEPY, GRUMPY } abstract class Animal(override val name: String) : Pet class Cat( name: String, val lives: Int = 9, ) : Animal(name) { var brother: Cat? = null var mood: Mood = Mood.SLEEPY val toys: List<Toy> = listOf(Toy("Mouse", "Gray")) val onMeow: () -> String = { "Meow! My name is $name" } override fun speak(): String = "Meow!" } data class Toy(val name: String, val color: String) class Box<T>(val item: T) fun owner(name: String): String? = if (name == "Oreo") "Isuru" else null

The root package maps to the MyCatLib C# namespace. After adding the NuGet package to a .NET project, use the generated API like this:

using MyCatLib; using var oreo = new Cat("Oreo", 9); Console.WriteLine(oreo.Name); // Oreo Console.WriteLine(oreo.Speak()); // Meow! using var mylo = new Cat("Mylo", 9); oreo.Brother = mylo; oreo.Mood = Mood.Happy;

See Publishing Kotlin to C# for the supported Kotlin constructs and their generated C# shapes. The complete package metadata options are in the nuget {} DSL reference.

3. Build and consume the package

Build the package from the project root:

./gradlew packNuget

The package is written to build/nuget/MyCatLib.1.0.0.nupkg. See Gradle tasks for the complete output layout.

Add that directory as a NuGet package source, then add a PackageReference from the C# consumer. For example, create a NuGet.Config beside the .NET solution:

<?xml version="1.0" encoding="utf-8"?> <configuration> <packageSources> <clear /> <add key="nuget.org" value="https://api.nuget.org/v3/index.json" /> <add key="mycatlib-local" value="../my-cat-lib/build/nuget" /> </packageSources> </configuration>
<ItemGroup> <PackageReference Include="MyCatLib" Version="1.0.0" /> </ItemGroup>

Adjust the relative feed path for your directory layout. Keep the package reference version in sync with publish {}. NuGet brings in the generated C# source and selects the native binary for the consumer's runtime identifier.

A framework-dependent C# build also needs a runtime identifier so MSBuild copies the matching native asset beside the host. For a cross-platform project, use the SDK host RID:

<RuntimeIdentifier>$(NETCoreSdkRuntimeIdentifier)</RuntimeIdentifier> <SelfContained>false</SelfContained>

Do not hardcode one host RID into a cross-platform test project. For deployment, select the RID of the target environment.

One package for every platform

No single host can link every RID: a macOS host links macosArm64 and cross-compiles mingwX64, but a Windows host can only link mingwX64, and neither can link the other's Apple- or Windows-only targets. prebuiltRuntimes merges another host's already-linked runtimes/ output into this host's own pack, so one host still produces one publishable package instead of each CI leg shipping a package that only covers its own platform.

A typical two-host flow: the Windows leg runs packNuget and uploads its staged runtimes/ folder as a CI artifact, then the macOS leg downloads that artifact into a local directory and points at it before running its own packNuget:

nuget { publish { packageId = "MyCatLib" version = "1.0.0" authors = "yourname" description = "A Kotlin/Native library for cat lovers" rootPackage = "com.example.cats" prebuiltRuntimes = file("build/prebuilt-runtimes") // <rid>/native/*.dll|*.dylib|*.so } }

The resulting package carries both native libraries and producer contract sidecars for each RID. packNuget checks that all targets agree on the native contract and generated C# API before it writes the package. If a target differs, packing reports the affected RIDs and ABI or C# token differences; align the declarations or publish those targets separately. Comparison is conservative: comments and whitespace outside literals are ignored, but generated declaration order and implementation tokens must match, so equivalent APIs with reordered generated code can be rejected.

MyCatLib.1.0.0.nupkg ├── runtimes/osx-arm64/native/libkn_mycatlib.dylib (locally linked) ├── runtimes/osx-arm64/ForwardAbi.json ├── runtimes/osx-arm64/Interop.cs ├── runtimes/win-x64/native/kn_mycatlib.dll (prebuilt) ├── runtimes/win-x64/ForwardAbi.json ├── runtimes/win-x64/Interop.cs ├── contentFiles/cs/<tfm>/*.cs ├── build/MyCatLib.targets └── MyCatLib.nuspec

Upload and download the complete staged runtimes/ tree, including the two sidecars beside each RID's native/ directory. Binary-only prebuilt runtimes from older plugin versions must be rebuilt by their original producer. See The nuget {} DSL for the full validation rules and ADR-093 for the design rationale.

4. Iterate locally without bumping the version

Without snapshot versioning, every local Kotlin change needs a version bump, or the consumer's packNuget + delete-from-cache + dotnet restore --force --no-cache dance, because NuGet treats a version as immutable and serves the cached copy of the previous build otherwise. Add snapshot = true to publish {} to skip that entirely:

nuget { publish { packageId = "MyCatLib" version = "1.0.0" authors = "yourname" description = "A Kotlin/Native library for cat lovers" rootPackage = "com.example.cats" snapshot = true } }

Every packNuget now mints a fresh 1.0.0-snapshot.<epochMillis> identity and writes build/MyCatLibVersions.props, pinning it under a sanitized property name (see The nuget {} DSL for the naming rule):

<Project> <PropertyGroup> <MyCatLibVersion>1.0.0-snapshot.1754817000000</MyCatLibVersion> </PropertyGroup> </Project>

Import that props file from the .NET consumer, guarded with Exists() so the build still works before the first pack, then reference the package by the property instead of a literal version:

<Project> <Import Project="$(MSBuildThisFileDirectory)../my-cat-lib/build/MyCatLibVersions.props" Condition="Exists('$(MSBuildThisFileDirectory)../my-cat-lib/build/MyCatLibVersions.props')" /> </Project>
<PackageReference Include="MyCatLib" Version="$(MyCatLibVersion)" />

Adjust the relative path to the props file for your directory layout, and to a shared Directory.Build.props if the consumer has several projects. dotnet restore now picks up the new version on every build, no cache clearing required. Successive snapshot packs accumulate .nupkg files in build/nuget; this is harmless, and clean removes them.

5. Publish the package to a feed

The local package source above is useful while developing and testing the library. To distribute MyCatLib, push the generated package to NuGet.org, GitHub Packages, or a private feed with the publishNuget task. It pushes over plain HTTP, so no .NET SDK is required for this step either.

Declare a named repository inside publish { }:

nuget { publish { packageId = "MyCatLib" version = "1.0.0" // authors, description, rootPackage as before repositories { nuget("nugetOrg") { url = "https://api.nuget.org/v3/index.json" // apiKey unset: falls back to the `nugetOrgApiKey` Gradle property } nuget("github") { url = "https://nuget.pkg.github.com/xxfast/index.json" apiKey = providers.environmentVariable("GITHUB_TOKEN") } } } }

Each repository gets its own publishNugetTo<Name>Repository task (publishNugetToNugetOrgRepository, publishNugetToGithubRepository, both depending on packNuget), plus an aggregate publishNuget that runs all of them:

./gradlew publishNuget # or push to just one feed ./gradlew publishNugetToNugetOrgRepository

An unset apiKey, username, or password on a repository named "nugetOrg" resolves from the Gradle properties nugetOrgApiKey, nugetOrgUsername, nugetOrgPassword when the task runs, so a credential never has to sit in the build file:

./gradlew publishNuget -PnugetOrgApiKey=<NUGET_API_KEY> # or, for CI: ORG_GRADLE_PROJECT_nugetOrgApiKey=<NUGET_API_KEY> ./gradlew publishNuget

username/password send HTTP basic auth alongside the key header, which is what GitHub Packages and Azure Artifacts PATs expect; leave them unset for a plain API-key feed like nuget.org.

Two task options:

  • --dryRun resolves the feed and validates the package and credential without pushing anything.

  • --skipDuplicate turns an already-published version (HTTP 409) into a warning instead of a build failure; it is also settable per repository with skipDuplicate = true.

./gradlew publishNuget --dryRun ./gradlew publishNuget --skipDuplicate

Consumers then reference MyCatLib with the same normal PackageReference shown above and restore it from the published feed. They do not need the mycatlib-local entry in their NuGet.Config.

The shared contract package

Every package the plugin builds depends on Xxfast.Kotlin.Native.Interop, a small assembly holding KotlinException, the mapped exception types and KotlinOptional<T> (see Exceptions). It is released with the plugin, at the plugin's version: a package built by plugin 0.9.0 declares it as [0.9.0,1.0.0).

Consumers do not reference it. NuGet restores it from nuget.org alongside your package, so a consumer whose NuGet.Config lists nuget.org needs nothing extra.

A consumer that restores only from a private or offline feed must find it there too. Mirror Xxfast.Kotlin.Native.Interop into that feed, at the plugin version your package was built with or a newer one below the next major. It is published to nuget.org and mirrored to GitHub Packages at https://nuget.pkg.github.com/xxfast/index.json, which needs a token even to read.

The pre-pack compile check in packNuget restores it as well, from nuget.org and any feed declared with sources(...).

dotnet nuget push build/nuget/MyCatLib.1.0.0.nupkg \ --source https://api.nuget.org/v3/index.json \ --api-key <NUGET_API_KEY>
Last modified: 03 November 2026