The nuget {} DSL
Reference for the nuget {} extension.
nuget { }
Member | Configures | Required |
|---|---|---|
|
| optional, omit for a consume-only project |
|
| optional, omit for a publish-only project |
|
| no, default |
|
| no, empty; feeds every dependency restores from, see Declaring dependencies |
The targetFramework is the .NET target framework the generated package targets. It must be exactly netX.0 with X of 10 or higher (the generated C# needs C# 14), and anything else fails the build. The package ships its C# under contentFiles/cs/<tfm>/, so a consumer project on a lower framework fails restore with NU1202 instead of building without the generated code. The same value is used for the restore of bound dependencies and the pre-pack compile check. Platform frameworks such as net10.0-android, netstandard and .NET Framework are not supported.
A project can declare either block, both, or neither meaningfully. publish {} alone publishes a package with no reverse bindings; dependencies {} alone binds C# packages into Kotlin without publishing anything. See Gradle tasks for exactly which tasks each combination registers.
Every setting is a Gradle Property, ListProperty, RegularFileProperty or DirectoryProperty, so you can feed one from a Provider and the build stays configuration-cache friendly. In a Kotlin build script you still write plain = assignment (Gradle 8.2 or later, verified on 9.1.0). A nested block cannot call a function from an enclosing block: include("x") inside repositories { nuget("feed") { } } is a compile error instead of silently widening publish.
Repeating a block merges instead of replacing. These two publish {} blocks produce one configuration with both include entries, and the same holds for bind {}, dependency("X") and nuget("x"):
Breaking in 0.9.0
Scripts that only assign values and call the block functions need no change. These differences matter if you read the DSL back or depend on task shape:
Plugin internals are no longer public: build scripts that imported the
rirmodel, the generator functions (generateKotlinStubs,generateCSharpShims,generateCsproj) orNugetPusherno longer compile. The DSL and the task classes stay public.The package targets
net10.0by default, up from a hardcodednet8.0, andtargetFrameworknow sets it. A consumer project belownet10.0fails restore withNU1202.Reading a value returns a
Provider, sonuget.publish?.packageIdbecomesnuget.publish.packageId(aProperty<String>).nuget.publishis never null; test whether a value was set withpackageId.isPresent.The checks that used to fail while the build was configured now fail when the task runs, with the same message: a repository with no
url,snapshot = truewithout aversionorpackageId, and a project with no supported targets.packNugetexists on every project with apublish {}block and fails at execution when there is nothing to pack. A project withoutpublish {}still has nopackNuget.nugetSnapshotVersionandnugetSnapshotVersionPropsexist on everypublish {}project.packNugetdepends on them only whensnapshotistrue.Repositories and dependencies are processed in name order, not declaration order.
publishNugetdepends on every push task in the project.A second
publish {},bind {},dependency("X")ornuget("x")used to replace the first or register a duplicate (a duplicatenuget("x")failed the build). It now merges.
Renamed so a name says what it selects or does. There are no aliases: a script or CI step using an old task name fails with "task not found".
Before | Now |
|---|---|
|
|
|
|
|
|
Tasks that pack or publish are verb-first (packNuget, publishNuget) and steps that drive the .NET tool start with nuget, as in Kotlin's Gradle plugin; see Gradle tasks.
Groovy build scripts can use the same blocks, but are not tested.
Annotations dependency
Applying the plugin adds nuget-annotations, which carries @CSharpName, to your commonMainApi (published as io.github.xxfast:nuget-annotations). It's a common module, so JVM, JS and native targets all resolve it; you add nothing yourself. See Choosing the C# name.
Only the module that applies the plugin gets the dependency. To annotate a declaration in a dependency module that doesn't apply it, which the cross-module export closure can still bridge, apply the annotations-only plugin there. It adds nuget-annotations to commonMainApi at the plugin's own version and does nothing else: no nuget {} block, no code generation.
Use the same <version> as the main plugin, as in Getting started. Declaring api("io.github.xxfast:nuget-annotations:<version>") yourself works too, but you then keep the version in step by hand.
publish { }
Configures NugetPublishConfig. The five string settings have no default. Nothing in the DSL itself enforces they're set, but packNuget fails once it reads an unset one.
Property | Type | Required | Maps to |
|---|---|---|---|
|
| yes |
|
|
| yes |
|
|
| yes |
|
|
| yes |
|
|
| yes | the Kotlin package the generated C# namespaces are rooted at; sub-packages map relative to it. Also the default export scope: see below |
| function, adds to a | no | empty; when set, only these package prefixes (and their sub-packages) are bridged |
| function, adds to a | no | empty; a package prefix or a qualified declaration name (a class, object, sealed base, or top-level function, plus everything nested under it); applied after |
| function, adds to a | no | empty; additively admits a dependency-module type into the cross-module export closure by qualified name or package prefix, without touching |
|
| no |
|
|
| no |
|
|
| no |
|
|
| no | only consulted when |
|
| no | unset; the complete |
| function | no | empty; declares named feeds |
Snapshot versioning
With snapshot = true, every packNuget run mints a fresh, immutable version instead of reusing version as-is, so a .NET consumer's next restore always sees a new version and never serves NuGet's cached copy of the previous build:
The minted version (1.0.0-snapshot.<epochMillis>) is pinned in an MSBuild props file under a property name derived from packageId: every character outside [A-Za-z0-9_] is dropped, a leading digit gets a _ prefix, and Version is appended (MyCatLib becomes MyCatLibVersion). See Publish a Kotlin/Native library as NuGet for the full local-iteration flow and the consumer-side props import.
Publishing repositories
repositories { } declares named feeds inside publish { }; each nuget(name) { } block gets its own publishNugetTo<Name>Repository task:
See Publish a Kotlin/Native library as NuGet for credential resolution, --dryRun, and --skipDuplicate.
Multi-RID packages
packNuget only links the native targets this host's Kotlin/Native toolchain can build, but a package worth publishing needs every RID your CI matrix produces. prebuiltRuntimes points at a directory built by another host and merges it into this host's own pack, so one packNuget run still produces one package covering both:
A typical two-host CI flow: a Windows leg runs packNuget and uploads its complete staged runtimes/ folder as an artifact, then a macOS leg downloads it, points prebuiltRuntimes at it, and runs packNuget itself, producing one package with both RIDs. Keep the sidecars alongside each RID's native/ folder when transferring the artifact; they let the packing host compare each producer's API and native contract with the package's single generated C# API and contract.
packNuget validates the merge rather than silently dropping anything:
A locally linked RID with no
.dll/.dylib/.soto copy fails the build naming the RID and the directory scanned.A
prebuiltRuntimesdirectory with no RID subdirectories, or a RID subdirectory whosenative/folder is missing or empty, fails naming the expected layout (<prebuiltRuntimes>/<rid>/native/*.dll|*.dylib|*.so).Every RID must also include the producer's
ForwardAbi.jsonandInterop.csbesidenative/. Missing or invalid sidecars fail packing. Binary-only artifacts from older plugin versions must be rebuilt by their original producer and transferred as a completeruntimes/tree.packNugetcompares each RID's native contract and generated C# source with one baseline before writing the package. A mismatch fails with the differing ABI symbols or C# token context; align the exported declarations or publish the incompatible targets in separate packages.The same RID arriving both locally linked and prebuilt fails naming both sources: pick one producer per RID.
A prebuilt RID name this plugin version does not know how to build is a warning only, since the RID set NuGet accepts is open and the tree may come from a newer plugin.
Export scoping
By default NugetProcessor bridges every public declaration in the module. include/exclude scope that down: a package prefix match (pkg == p || pkg.startsWith("$p.")), with exclude always winning over include. exclude additionally matches a qualified declaration name and everything nested under it: exclude("com.contoso.api.Shape") drops the Shape class (and, for a sealed base, its subclasses) from the export set, so a member that references it is skipped with a named diagnostic and the rest of the package still bridges. include stays package-level.
When include is empty, the effective include set defaults to rootPackage if set, otherwise to everything. This makes rootPackage a scoping knob, not just a renaming one: a public declaration outside rootPackage is not bridged unless named explicitly in include. A scope that admits none of the module's public declarations (for example a stray include("kotlin")) warns once with SKIPPED_ALL_DECLARATIONS instead of silently shipping a package with no Interop.cs.
A package that is only reached via dependencies { dependency(...) { bind { } } } (the reverse stub packages generated for a consumed C# dependency) is always exported regardless of include/exclude, since a module that both publishes forward and consumes a NuGet dependency needs those bound types reachable from its own forward return types to keep compiling.
Cross-module export closure
include/exclude/rootPackage/admit decide what crosses a Gradle module boundary. The export set is a reachability closure from the module's own admitted declarations: the processor walks return types, parameter types, property types, type arguments of an admitted carrier (Flow<T>, List<T>/Set<T>/Map<K,V>), sealed subclasses, and primary-constructor parameter types, and admits every discovered declaration through the same predicate: include/rootPackage for a whole package, or an additive admit entry for one type or package, minus anything exclude names, whether it lives in this module or in a dependency module pulled in with implementation(project(":models")). A :models module under the same rootPackage is admitted automatically, with no extra config.
include(...) widens admission by whole package, which is right when your own API genuinely reaches most of a dependency's models. For a single third-party type (a ktor Url, a kermit Severity) that would mean owning and versioning a whole package you don't control just to reach one class or enum. admit(vararg types: String) is the finer-grained, additive alternative: it takes a qualified type name or a package prefix, the same matcher exclude uses, adds entries to the closure's admission predicate only, and never picks roots or replaces the rootPackage default the way an explicit include does.
The types can come straight from published Maven klibs. With the block above and a Kotlin class that exposes them (fun parse(address: String): Url, fun nameOf(severity: Severity): String), C# gets Severity as a plain enum and Url as a disposable handle class:
Severity lives under TestLibrary.Co.Touchlab.Kermit and Url under TestLibrary.Io.Ktor.Http, the dependency's package PascalCased below your packageId. Url has no public constructor, so create it in Kotlin and hand it over. Its String, Int and List<string> members (Host, Port, EncodedPath) bind; members typed with ktor types you didn't admit, such as Protocol and Parameters, are skipped with a hint to admit them.
Admitting a type admits only that declaration, not its package: a sibling type or a member typed with something you didn't also admit still skips named, with a hint that says add admit("<qualified type>"), never include(...) (an additive verb has no replacement trap to word around). exclude still wins over admit the same way it wins over include. Use include to widen a whole dependency package your own API is built around; use admit to pull in one or a few specific types from a package you otherwise leave alone; use exclude to record either omission as deliberate.
Admitting a nested type (admit("dep.edge.Ledger.Entry")) doesn't work: the closure climbs from a nested declaration to its owner before admitting anything, so that entry refuses Ledger and the refusal propagates onto Entry. Admit the outermost type instead (admit("dep.edge.Ledger")), which also declares Entry as its nested type once Ledger is admitted. exclude, by contrast, is tested on the declaration itself before that climb, so it still takes the full nested name (exclude("dep.edge.Ledger.Entry")).
By default an un-admitted dependency type is a warning plus a named skip (below). Set strictDependencyTypes = true to fail the build instead, once every dependency type reached by a public signature is either admitted or excluded by name:
Under strictDependencyTypes, a type the closure refused because it was never admitted, or because admit/include/rootPackage are all unset so the closure never crosses the module boundary at all, becomes ERROR_UNEXPORTED_DEPENDENCY_TYPE and stops the build. A type refused because you excluded it stays a warning: that omission is already deliberate, so escalating it would make strict mode unsatisfiable for any dependency the build genuinely amputates.
A dependency-module type the closure refuses to admit is skipped, and the diagnostic names the actual reason (not admitted, excluded, cross-module admission off entirely, or an expect declaration whose actualization lives in the dependency and can't be brought into scope at all) with an actionable fix for each. See Publishing Kotlin to C# for the full set of messages.
A dependency type nested inside another declaration (Broadcast.Schedule) is declared nested under its owner in the generated C#, exactly like a module-local nested type, once the owner (Broadcast) is admitted. A member naming only the nested type still admits the owner (climbing the chain first), and a declared nested type's own member types are walked too, so Broadcast.Schedule.timetable(): Timetable admits the top-level dependency type Timetable on the strength of a member declared two levels down. A dependency type nested under a still-deferred owner shape (an enum class or a generic interface) is refused admission outright, the same as a module-local one under the same deferred shape; see Classes and objects: Nested types.
Every cross-namespace type reference in the generated Interop.cs is global::Namespace.Name qualified, so the file compiles with no using needed regardless of how many packages a namespace-crossing member touches:
dependencies { }
Configures a NugetDependencyScope, whose only member is dependency(...).
dependency(id, version = null) { }
Parameter | Type | Required |
|---|---|---|
|
| yes, the NuGet package id |
|
| no, omit for an unpinned |
Inside the trailing block, NugetDependency exposes:
Property / function | Type | Required | Notes |
|---|---|---|---|
|
| no | same as the |
|
| no | where to restore from: a feed URL (added to |
| function, configures | no | omit to resolve the dependency without generating any Kotlin bindings for it |
bind { }
Configures NugetBindConfig. Declaring bind {} at all is what triggers nugetExtractApi, nugetGenerateBindings, and nugetGenerateShims for that dependency. See Gradle tasks.
Property / function | Type | Required | Default |
|---|---|---|---|
|
| no | the dependency id, lowercased with |
| function | no | empty, with no |
| function | no | empty |
| function | no | none |
includeNamespaces/excludeNamespaces match a C# namespace exactly, or any of its sub-namespaces (ns == filter or ns.startsWith("$filter.")); when both match the same namespace, excludeNamespaces wins. alias maps one C# namespace to a Kotlin package, overriding both packageName and the id-derived default for that namespace only. Unlike the filters, alias is exact: an included sub-namespace such as Test.Text.Internal needs its own alias line or it lands in the packageName package. Several namespaces may alias to the same Kotlin package.
A second bind { } block for the same dependency merges into the first: namespace lists and aliases accumulate, and packageName is last-wins. To put namespace groups in different Kotlin packages, use one alias per namespace rather than one block per group. For what actually gets bound once a namespace is included, see Consuming C# in Kotlin.