Anyone who has published an open source library knows the pain: getting a library published to Maven Central through Sonatype is far from simple. You need to meet a long list of requirements, and for Gradle projects, even though everything uses the maven-publish plugin, the configuration differs across project types – Gradle Plugin, Android Library, and Java Library all have their quirks. In a multi-module Gradle project, writing nearly identical but subtly different DSL blocks for each module is tedious, and Gradle‘s DSL can be bewildering for newcomers.

Sonatype

Sonatype provides automatic syncing to Maven Central, but publishing to it requires several steps:

  1. Register an account
  2. Submit a new project JIRA ticket
  3. Reply to the JIRA ticket from step 2 to prove you have admin access to the domain namespace corresponding to your groupId
  4. Generate a GPG key
  5. Configure your Gradle project so that uploaded artifacts satisfy the following requirements:
  • Sources JAR file
  • Javadoc JAR file
  • POM file containing:
    • Maven coordinates
      • groupId
      • artifactId
      • version
    • Project information
      • name
      • description
      • url
    • License information
    • Developer information
    • SCM (Source Code Management) information
  • A signature (.asc) file for each of the above

Steps 1-4 are one-time tasks. The last step is required for every project.

Java/Kotlin Library Projects

The publishing configuration for Java/Kotlin Library projects is the simplest, requiring roughly 3 steps:

  1. Create JAR Tasks for sources and javadoc. For Kotlin projects, use Kotlin/dokka to generate Javadoc
  2. Register a MavenPublication named mavenJava in publications
  3. Sign mavenJava

Here is a complete example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
project.run {
val sourceSets = the<SourceSetContainer>()
val javadocJar = tasks.register("packageJavadocFor${name.capitalize()}", Jar::class.java) {
archiveClassifier.set("javadoc")
from(tasks["dokkaHtml"])
}
val sourcesJar = tasks.register("packageSourcesFor${name.capitalize()}", Jar::class.java) {
dependsOn(JavaPlugin.CLASSES_TASK_NAME)
archiveClassifier.set("sources")
from(sourceSets["main"].allSource)
}

publishing {
repositories {
maven {
url = uri("https://oss.sonatype.org/service/local/staging/deploy/maven2/")
}
}
publications {
register("mavenJava", MavenPublication::class) {
groupId = "${project.group}"
artifactId = project.name
version = "${project.version}"

from(components["java"])

artifact(sourcesJar.get())
artifact(javadocJar.get())

pom.withXml {
asNode().apply {
appendNode("name", project.name)
appendNode("url", "https://github.com/johnsonlee/${project.name}")
appendNode("description", project.description ?: project.name)
appendNode("scm").apply {
appendNode("connection", "scm:git:git://github.com/johnsonlee/${project.name}.git")
appendNode("developerConnection", "scm:git:[email protected]:johnsonlee/${project.name}.git")
appendNode("url", "https://github.com/johnsonlee/${project.name}")
}
appendNode("licenses").apply {
appendNode("license").apply {
appendNode("name", "Apache License")
appendNode("url", "http://www.apache.org/licenses/LICENSE-2.0")
}
}
appendNode("developers").apply {
appendNode("developer").apply {
appendNode("id", "johnsonlee")
appendNode("name", "Johnson Lee")
appendNode("email", "[email protected]")
}
}
}
}
}
}
}

signing {
sign(publishing.publications["mavenJava"])
}
}

Android Library Projects

Unlike Java/Kotlin Library projects, Android Library projects need to generate sources and javadoc JARs for each variant. When necessary, you also need to publish an AAR for each variant. This is typically done by iterating over all variants via android.libraryVariants:

1
2
3
4
5
val android = extensions.getByName("android") as LibraryExtension

android.libraryVariants.forEach { variant ->
// Register a MavenPublication for each variant
}

Since libraryVariants is configured lazily, this must be executed inside a project.afterEvaluate callback. The complete code looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
project.run {
val android = extensions.getByName("android") as LibraryExtension

afterEvaluate {
publishing {
publications {
android.libraryVariants.forEach { variant ->
val javadoc = tasks.register("javadocFor${variant.name.capitalize()}", Javadoc::class.java) {
dependsOn("dokkaHtml")
source(android.sourceSets["main"].java.srcDirs)
classpath += files(android.bootClasspath + variant.javaCompileProvider.get().classpath)
exclude("**/R.html", "**/R.*.html", "**/index.html")
}

val javadocJar = tasks.register("packageJavadocFor${variant.name.capitalize()}", Jar::class.java) {
dependsOn(javadoc)
archiveClassifier.set("javadoc")
from(tasks["dokkaHtml"])
}

val sourcesJar = tasks.register("packageSourcesFor${variant.name.capitalize()}", Jar::class.java) {
archiveClassifier.set("sources")
from(android.sourceSets["main"].java.srcDirs)
}

create(variant.name, MavenPublication::class.java) {
groupId = project.group
artifactId = project.name
version = project.version
from(components[variant.name])
artifact(javadocJar)
artifact(sourcesJar)

pom.withXml {
asNode().apply {
appendNode("name", project.name)
appendNode("url", "https://github.com/johnsonlee/${project.name}")
appendNode("description", project.description ?: project.name)
appendNode("scm").apply {
appendNode("connection", "scm:git:git://github.com/johnsonlee/${project.name}.git")
appendNode("developerConnection", "scm:git:[email protected]:johnsonlee/${project.name}.git")
appendNode("url", "https://github.com/johnsonlee/${project.name}")
}
appendNode("licenses").apply {
appendNode("license").apply {
appendNode("name", "Apache License")
appendNode("url", "http://www.apache.org/licenses/LICENSE-2.0")
}
}
appendNode("developers").apply {
appendNode("developer").apply {
appendNode("id", "johnsonlee")
appendNode("name", "Johnson Lee")
appendNode("email", "[email protected]")
}
}
}
}
}
}
}
}
}

signing {
sign(publishing.publications)
}
}

Gradle Plugin Projects

Gradle officially provides java-gradle-plugin for generating Gradle Plugin-related POM files, but its output only includes Maven coordinates and basic project info – nowhere near enough for Sonatype‘s requirements. Developers need to configure the rest manually, but Gradle’s official documentation doesn’t explain how to modify the POM generated by java-gradle-plugin. It’s actually not hard – just different from Java/Kotlin Library and Android Library projects. Since java-gradle-plugin already creates MavenPublication instances automatically, you don’t need to create or register new ones. Just iterate over the existing ones and append the required POM information. The complete code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
project.run {
publishing {
publications {
val sourceSets = the<SourceSetContainer>()
val javadocJar = tasks.register("packageJavadocFor${name.capitalize()}", Jar::class.java) {
dependsOn("dokkaHtml")
archiveClassifier.set("javadoc")
from(tasks["dokkaHtml"])
}
val sourcesJar = tasks.register("packageSourcesFor${name.capitalize()}", Jar::class.java) {
archiveClassifier.set("sources")
from(sourceSets["main"].allSource)
}

withType<MavenPublication>().configureEach {
groupId = "${project.group}"
version = "${project.version}"
artifact(javadocJar)
artifact(sourcesJar)
}
}
}

signing {
sign(publishing.publications)
}
}

Once and for All

After looking at the publishing configurations for different project types above, you’ll notice that most of the code is nearly identical. In a multi-module project, this becomes a real hassle – some modules need to be published and others don’t, and using allprojects or subprojects doesn’t simplify things much. Since the code is so similar, can we make the whole thing simpler? The answer is yes – and that’s exactly what sonatype-publish-plugin was built for. It truly takes just one line:

1
2
3
4
5
6
plugins {
id("io.johnsonlee.sonatype-publish-plugin") version "1.3.0"
}

group = "io.johnsonlee"
version = "1.0.0"

Just configure the appropriate environment variables and you can upload directly via command line:

1
2
3
4
5
6
7
./gradlew clean publishToSonatype \
-POSSRH_USERNAME=johnsonlee \
-POSSRH_PASSWORD=********** \
-POSSRH_PACKAGE_GROUP=io.johnsonlee \
-Psinging.keyId=xxxxxxxx \
-Psinging.password=******** \
-Psinging.secretKeyRingFile=/Users/johnsonlee/.gnupg/secring.gpg

After uploading to Sonatype‘s staging repository, publish to the release repository with:

1
2
3
4
./gradlew closeAndReleaseRepository \
-POSSRH_USERNAME=johnsonlee \
-POSSRH_PASSWORD=********** \
-POSSRH_PACKAGE_GROUP=io.johnsonlee

Once published, you can find it on Maven Central. For more details, see the project page.

The plugin also supports publishing to private Nexus repositories, such as a company’s internal Nexus server. Just configure these properties or environment variables:

  • NEXUS_URL
  • NEXUS_USERNAME
  • NEXUS_PASSWORD

Then publish to your private Nexus repository with:

1
2
3
4
./gradlew clean publish \
-PNEXUS_URL=http://nexus.johnsonlee.io/ \
-PNEXUS_USERNAME=johnsonlee \
-PNEXUS_PASSWORD=**********