Background Image

2024-08-05

Publishing to Maven Central with Gradle

Ryan Scott image

Introduction

This is article 6 in the series about creating a Compose Multiplatform UI library. In the previous article, we added appropriate README.md files to our repository, created a reference documentation website, bundled the site inside a javadoc.jar of each of our publications, and tested publishing by publishing to our local maven repository. In this article, we'll publish these publications to Maven Central.

At the end of this article, you will have:

  • A published Compose Multiplatform UI library on Maven Central
  • An updated README.md file with instructions for consuming your library and a badge that links to your reference documentation
  • A tagged release on your GitHub repository
  • A published reference documentation website

TLDR

  1. Create a GPG key for signing your library
  2. Add the signing plugin to your Gradle build and configure it
  3. Ensure that signing happens before publishing in the Gradle Build
  4. Sign up for Maven Central via the Maven Central Portal
  5. Prove you own your domain
  6. Configure the Maven Central publications
  7. Publish your library to Maven Central
  8. Access your generated reference documentation website
  9. Add instructions for how to consume your library from Maven Central
  10. Create a tagged release
  11. Test your library by consuming it from Maven Central

Signing

Before we can publish to Maven Central, we need to be able to digitally sign our artifacts. This is to ensure that the artifacts have not been tampered with between the time they were published and the time they are consumed. GPG is the tool used for signing.

Note

You only need one set of GPG keys for all libraries you publish

Create a GPG Key

First, install GPG if you don't already have it installed. On a Mac, you can install it with homebrew:

$ brew install gpg

Next, create a GPG key with gpg --gen-key and follow the instructions. You will need to provide a name, email, and passphrase.

When prompted, you should select (1) RSA and RSA. You should also select the maximum key size of 4096. You can choose how long the key should be valid for. I chose 0 for the key to never expire.

Note

Remember your passphrase. You will need it later to sign your artifacts.

Finally, ensure you've got a key pair:

$ gpg --list-keys
/Users/ryan/.gnupg/pubring.kbx
------------------------------
pub   rsa4096 2021-05-01 [SC]
      08BA1270A166D26B015465985D93084BE8F8A051
uid           [ultimate] FS Ryan <ryan@fsryan.com>
sub   rsa4096 2021-05-01 [E]

Configure the Signing Plugin

Add the signing plugin to your segmented-display/build.gradle.kts file:

plugins {
    // other plugins
    signing
}

Now sync your project and add the signing configuration to the same file:

fun Project.canSignArtifacts(): Boolean {
    return hasProperty("signing.keyId") && hasProperty("signing.password") && hasProperty("signing.secretKeyRingFile")
}

signing {
    if (project.canSignArtifacts()) {
        sign(publishing.publications)
    } else {
        println("Cannot sign artifacts: missing signing information")
        if (!project.hasProperty("signing.keyId")) {
            println("\tMissing signing.keyId")
        }
        if (!project.hasProperty("signing.password")) {
            println("\tMissing signing.password")
        }
        if (!project.hasProperty("signing.secretKeyRingFile")) {
            println("\tMissing signing.secretKeyRingFile")
        }
    }
}

Then update the section where we configured publishing so that any added publications also get signed:

publishing {
    publications.withType(MavenPublication::class.java) {
        configureMultiplatformPublishing(project)
    }
    publications.whenObjectAdded {
        (this as? MavenPublication)?.configureMultiplatformPublishing(project)
        if (project.canSignArtifacts()) {
            project.signing.sign(this)
        }
    }
}

In order to configure the signing plugin, we have a few options. As you can tell from the code above, I'm using the default properties the signing plugin looks for (signing.keyId, signing.password, and signing.secretKeyRingFile). So in my case, I added the following to my ~/.gradle/gradle.properties file:

signing.keyId=E8F8A051
signing.password=<redacted>
signing.secretKeyRingFile=/Users/ryan/.gnupg/secring.gpg
Note

You can run the gpg --list-keys command to get the values for signing.keyId and signing.secretKeyRingFile. You have to remember the password you used.

Work around Gradle Signing Plugin Issue

There is presently a problem with the Gradle signing plugin when publishing a multiplatform library that is what I assume to be a false positive when it checks for task dependencies. If you were to try to run the :segmented-display:publishToMavenLocal task now, you'd receive the following error:

> Task :segmented-display:signIosSimulatorArm64Publication FAILED

FAILURE: Build failed with an exception.

* What went wrong:
A problem was found with the configuration of task ':segmented-display:signIosSimulatorArm64Publication' (type 'Sign').
  - Gradle detected a problem with the following location: '/Users/ryan/Downloads/MyUILibrary/segmented-display/build/libs/segmented-display-0.0.3-javadoc.jar.asc'.
    
    Reason: Task ':segmented-display:publishIosArm64PublicationToMavenLocal' uses this output of task ':segmented-display:signIosSimulatorArm64Publication' without declaring an explicit or implicit dependency. This can lead to incorrect results being produced, depending on what order the tasks are executed.
    
    Possible solutions:
      1. Declare task ':segmented-display:signIosSimulatorArm64Publication' as an input of ':segmented-display:publishIosArm64PublicationToMavenLocal'.
      2. Declare an explicit dependency on ':segmented-display:signIosSimulatorArm64Publication' from ':segmented-display:publishIosArm64PublicationToMavenLocal' using Task#dependsOn.
      3. Declare an explicit dependency on ':segmented-display:signIosSimulatorArm64Publication' from ':segmented-display:publishIosArm64PublicationToMavenLocal' using Task#mustRunAfter.
    
    For more information, please refer to https://docs.gradle.org/8.7/userguide/validation_problems.html#implicit_dependency in the Gradle documentation.

To work around this, segmented-display/build.gradle.kts file:

// Workaround found here: https://slack-chats.kotlinlang.org/t/13149393/i-m-getting-the-following-two-errors-when-trying-to-publish-
// Further information found here: https://youtrack.jetbrains.com/issue/KT-46466
tasks.withType<AbstractPublishToMaven>().configureEach {
    val signingTasks = tasks.withType<Sign>()
    mustRunAfter(signingTasks)
}
Note

I am not 100% sure that this is a false positive or that there is a bug in the signing plugin, but the workaround above does seem to work for me and other users who have run into this same issue.

Now, check that the :segmented-display:publishToMavenLocal task runs successfully and then look inside the ~/.m2/repository/com/fsryan/segmented-display/0.0.3 directory to ensure that the artifacts are signed.

Previously, we had:

$ ls ~/.m2/repository/com/fsryan/ui/segmented-display/0.0.3
segmented-display-0.0.3-javadoc.jar                  segmented-display-0.0.3-sources.jar                  segmented-display-0.0.3.module
segmented-display-0.0.3-kotlin-tooling-metadata.json segmented-display-0.0.3.jar                          segmented-display-0.0.3.pom

But now we have:

$ ls ~/.m2/repository/com/fsryan/ui/segmented-display/0.0.3/
segmented-display-0.0.3-javadoc.jar                      segmented-display-0.0.3-sources.jar                      segmented-display-0.0.3.module
segmented-display-0.0.3-javadoc.jar.asc                  segmented-display-0.0.3-sources.jar.asc                  segmented-display-0.0.3.module.asc
segmented-display-0.0.3-kotlin-tooling-metadata.json     segmented-display-0.0.3.jar                              segmented-display-0.0.3.pom
segmented-display-0.0.3-kotlin-tooling-metadata.json.asc segmented-display-0.0.3.jar.asc                          segmented-display-0.0.3.pom.asc

All those .asc files are the ASCII-armored signature of the related files without the .asc extension.

Maven Central

Here's the big moment. We're finally going to make our library available to the world. We'll do this by publishing to Maven Central, the most widely-used repository.

Sign Up for Maven Central

Sonatype's website has much better instructions than I can provide. Follow them. Don't worry--this article will still be here when you get back.

Prove you own your domain

After you have created your account, you must prove that you own your domain by registring your namespace. Again, I won't repeat the instructions here. Follow them and come back.

Note

Your namespace should be your reversed domain name. For example, mine is com.fsryan, and the domain I own is fsryan.com

When you've verified your namespace, it should look like this:

Verified Namespace

Configure your Maven Central API Token

Navigate to https://central.sonatype.com/account, and then click on the "Generate User Token". You should see something like this:

Maven Central API Token

Make a note of the username and password. You'll need to update your gradle properties.

Configure the Maven Central Publications

Open up the gradle/libs.versions.toml file and add the vanniktech version of the sonatype central publishing plugin:

[plugins]
# other plugins
vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version = "0.29.0" }
Note

There are other gradle plugins that can be used to publish to Maven Central, but none of them are official. If you don't like the one I chose for myself, you may consider one of the others.

Then add the following gradle plugin to your segmented-display/build.gradle.kts file:

plugins {
    // other plugins
    alias(libs.plugins.vanniktech.maven.publish)
}

Now, add your sonatype credentials to your ~/.gradle/gradle.properties file:

mavenCentralUsername=<redacted>
mavenCentralPassword=<redacted>

The vanniktech maven publish plugin will add the necessary publishing tasks to publish to Maven Central. You can see them by running ./gradlew tasks --group=publishing:

$ ./gradlew :segmented-display:tasks --group=publishing

> Task :segmented-display:tasks

------------------------------------------------------------
Tasks runnable from project ':segmented-display'
------------------------------------------------------------

Publishing tasks
----------------
generateMetadataFileForAndroidReleasePublication - Generates the Gradle metadata file for publication 'androidRelease'.
generateMetadataFileForIosArm64Publication - Generates the Gradle metadata file for publication 'iosArm64'.
generateMetadataFileForIosSimulatorArm64Publication - Generates the Gradle metadata file for publication 'iosSimulatorArm64'.
generateMetadataFileForIosX64Publication - Generates the Gradle metadata file for publication 'iosX64'.
generateMetadataFileForJvmPublication - Generates the Gradle metadata file for publication 'jvm'.
generateMetadataFileForKotlinMultiplatformPublication - Generates the Gradle metadata file for publication 'kotlinMultiplatform'.
generateMetadataFileForWasmJsPublication - Generates the Gradle metadata file for publication 'wasmJs'.
generatePomFileForAndroidReleasePublication - Generates the Maven POM file for publication 'androidRelease'.
generatePomFileForIosArm64Publication - Generates the Maven POM file for publication 'iosArm64'.
generatePomFileForIosSimulatorArm64Publication - Generates the Maven POM file for publication 'iosSimulatorArm64'.
generatePomFileForIosX64Publication - Generates the Maven POM file for publication 'iosX64'.
generatePomFileForJvmPublication - Generates the Maven POM file for publication 'jvm'.
generatePomFileForKotlinMultiplatformPublication - Generates the Maven POM file for publication 'kotlinMultiplatform'.
generatePomFileForWasmJsPublication - Generates the Maven POM file for publication 'wasmJs'.
publish - Publishes all publications produced by this project.
publishAllPublicationsToMavenCentralRepository - Publishes all Maven publications produced by this project to the mavenCentral repository.
publishAndroidReleasePublicationToMavenCentralRepository - Publishes Maven publication 'androidRelease' to Maven repository 'mavenCentral'.
publishAndroidReleasePublicationToMavenLocal - Publishes Maven publication 'androidRelease' to the local Maven repository.
publishIosArm64PublicationToMavenCentralRepository - Publishes Maven publication 'iosArm64' to Maven repository 'mavenCentral'.
publishIosArm64PublicationToMavenLocal - Publishes Maven publication 'iosArm64' to the local Maven repository.
publishIosSimulatorArm64PublicationToMavenCentralRepository - Publishes Maven publication 'iosSimulatorArm64' to Maven repository 'mavenCentral'.
publishIosSimulatorArm64PublicationToMavenLocal - Publishes Maven publication 'iosSimulatorArm64' to the local Maven repository.
publishIosX64PublicationToMavenCentralRepository - Publishes Maven publication 'iosX64' to Maven repository 'mavenCentral'.
publishIosX64PublicationToMavenLocal - Publishes Maven publication 'iosX64' to the local Maven repository.
publishJvmPublicationToMavenCentralRepository - Publishes Maven publication 'jvm' to Maven repository 'mavenCentral'.
publishJvmPublicationToMavenLocal - Publishes Maven publication 'jvm' to the local Maven repository.
publishKotlinMultiplatformPublicationToMavenCentralRepository - Publishes Maven publication 'kotlinMultiplatform' to Maven repository 'mavenCentral'.
publishKotlinMultiplatformPublicationToMavenLocal - Publishes Maven publication 'kotlinMultiplatform' to the local Maven repository.
publishToMavenLocal - Publishes all Maven publications produced by this project to the local Maven cache.
publishWasmJsPublicationToMavenCentralRepository - Publishes Maven publication 'wasmJs' to Maven repository 'mavenCentral'.
publishWasmJsPublicationToMavenLocal - Publishes Maven publication 'wasmJs' to the local Maven repository.
Note

There are several publishing tasks ToMavenCentralRepository. These were all added by the vanniktech maven publish plugin.

We now need to make a small change to some of our custom code in segmented-display/build.gradle.kts. Presently, we have this block:

with(pom) {
    description.set("$publicationName target of the Compose Multiplatform FS Ryan library for rendering segmented displays")
    inceptionYear.set("2024")
    url.set("https://github.com/fsryan-org/fs-segmented-display")

    issueManagement {
        url.set("https://github.com/fsryan-org/fs-segmented-display/issues")
        system.set("GitHub Issues")
    }

    licenses {
        license {
            name.set("The Apache Software License, Version 2.0")
            url.set("https://www.apache.org/licenses/LICENSE-2.0.txt")
            distribution.set("repo")
        }
    }

    developers {
        developer {
            id.set("ryan")
            name.set("Ryan Scott")
            email.set("ryan@fsryan.com")
            organization.set("FS Ryan")
            organizationUrl.set("https://www.fsryan.com")
        }
    }

    scm {
        url.set("https://github.com/fsryan-org/fs-segmented-display.git")
        developerConnection.set("scm:git:git@github.com:fsryan-org/fs-segmented-display.git")
    }
}

We're going to export all of this to an extension function on MavenPom:

fun MavenPom.configure(publicationName: String) {
    description.set("$publicationName target of the Compose Multiplatform FS Ryan library for rendering segmented displays")
    inceptionYear.set("2024")
    url.set("https://github.com/fsryan-org/fs-segmented-display")

    issueManagement {
        url.set("https://github.com/fsryan-org/fs-segmented-display/issues")
        system.set("GitHub Issues")
    }

    licenses {
        license {
            name.set("The Apache Software License, Version 2.0")
            url.set("https://www.apache.org/licenses/LICENSE-2.0.txt")
            distribution.set("repo")
        }
    }

    developers {
        developer {
            id.set("ryan")
            name.set("Ryan Scott")
            email.set("ryan@fsryan.com")
            organization.set("FS Ryan")
            organizationUrl.set("https://www.fsryan.com")
        }
    }

    scm {
        url.set("https://github.com/fsryan-org/fs-segmented-display.git")
        developerConnection.set("scm:git:git@github.com:fsryan-org/fs-segmented-display.git")
    }
}

Then we'll call this function where we formerly were configuring the pom:

fun MavenPublication.configureMultiplatformPublishing(project: Project) {
    pom.configure(name)
    if (name != "androidRelease") {
        artifact(project.tasks.withType<Jar>().first { it.name == "dokkaHtmlJar" })
    }
}

We made this refactor so that we could reuse the MavenPom.configure function. Now add the following to your segmented-display/build.gradle.kts file:

mavenPublishing {
    coordinates(
        groupId = project.group.toString(),
        artifactId = project.name,
        version = project.version.toString()
    )

    val publicationName = name
    pom {
        configure(publicationName)
    }
    @Suppress("UnstableApiUsage")
    configureBasedOnAppliedPlugins(javadocJar = false)
    publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL)
    if (project.canSignArtifacts()) {
        signAllPublications()
    }
}
Note

The configureBasedOnAppliedPlugins(javadocJar = false) call in the above code is unfortunately necessary to avoid having the javadoc jar added twice. In adding this, the vanniktech maven publish plugin does too much, in my opinion. However, this workaround prevents the plugin from attempting to add the javadoc.jar.

Publish to Maven Central

Now we're going to try publishing to Maven Central. The vanniktech maven publish plugin adds a publishAndReleaseToMavenCentral task that we can run, which we can see here:

$ ./gradlew :segmented-display:tasks --group=release
        
> Task :segmented-display:tasks

------------------------------------------------------------
Tasks runnable from project ':segmented-display'
------------------------------------------------------------

Release tasks
-------------
createStagingRepository - Create a staging repository on Sonatype OSS
dropRepository - Drops a staging repository on Sonatype OSS
publishAndReleaseToMavenCentral - Publishes to a staging repository on Sonatype OSS and releases it to MavenCentral
publishToMavenCentral - Publishes to a staging repository on Sonatype OSS
releaseRepository - Releases a staging repository on Sonatype OSS

So, let's run the publishAndReleaseToMavenCentral task:

$ ./gradlew clean :segmented-display:publishAndReleaseToMavenCentral

At the end, you should see:

BUILD SUCCESSFUL in 1m 27s

If you don't see this, then you'll need to troubleshoot. There are A LOT of moving parts, and some of the moving parts are not well-documented. If you run into trouble, please comment below, and either I or a member of the community will do their best to help.

Supposing that your library was pushed to Maven Central, you should be able to see a deployment:

Maven Central Deployment

View your online reference documentation

If you followed the instructions in the previous article, then your reference documentation site should be available to be scanned by javadoc.io. Check this by navigating to https://javadoc.io/ and searching for your library.

Searching for your library's reference documentation

It can take a while to scan, but you can refresh:

Refreshing the javadoc.io page

When it's done, you should see your library's reference documentation:

The Reference Documentation Page

Not only is your reference documentation page available, but it's also searchable:

Reference Documentation Search

Update your repository with the newest info

When we searched for our reference documentation on javadoc.io, the tool gave us some markdown we could use to add a badge to our README.md that would direct users to our reference documentation. Let's add that to the top of our root README.md file.

# segmented-display

![Segmented Display](docs/images/readme_headline.png)

[![javadoc](https://javadoc.io/badge2/com.fsryan.ui/segmented-display/javadoc.svg)](https://javadoc.io/doc/com.fsryan.ui/segmented-display)

Add Instructions for Consuming Your Library

Especially for less-seasoned developers, it also helps to add code users can copy and paste to add the dependency on your library. You can do this by updating the root README.md file to your repository. Here's an example of what you might add:

## Adding this Library to your Project

If using the kotlin multiplatform plugin, first, configure the compose plugin. Then add:
```kotlin
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("com.fsryan.ui:segmented-display:x.y.z")
        }
    }
}
```

Otherwise, configure Compose in your project. Then add the following to the dependencies block:

```kotlin
dependencies {
    implementation("com.fsryan.ui:segmented-display:x.y.z")
}
```

Create a tagged release

Finally, you'll want to add a tagged release and push to your git remote (called origin here). This will appear in the releases list on GitHub.

git add -A
git commit -m 'add instructions for consuming the library'
git tag -a 0.0.3 -m "Release 0.0.3"
git push origin --follow-tags

When this is done, the output should be like this:

$ git push origin --follow-tags
Enumerating objects: 1, done.
Counting objects: 100% (1/1), done.
Writing objects: 100% (1/1), 156 bytes | 156.00 KiB/s, done.
Total 1 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
To github.com:fsryan-org/fs-segmented-display.git
 * [new tag]         0.0.3 -> 0.0.3

Now, on your repository's releases page (mine was https://github.com/fsryan-org/fs-segmented-display/releases), create a new release.

Creting a new Release

Select the tag you just created (mine was 0.0.3) and add a title and description. Submit the form, and you should have a release that your consumers could download for themselves:

A completed release

Test Consuming your Library

It would be good to make sure that we have everything set up correctly by consuming our library from Maven Central. To do this, open up the composeApp/build.gradle.kts file and add a dependency on the library from Maven Central (which is already a repository used by this project, as you can see from the first article in this series:

kotlin {
    /* ... */
    sourceSets {
        /* ... */
        commonMain.dependencies {
            implementation(compose.runtime)
            implementation(compose.foundation)
            implementation(compose.material3)
            implementation(compose.ui)
            implementation(compose.components.resources)
            implementation(compose.components.uiToolingPreview)
//            implementation(project(":segmented-display"))
            implementation("com.fsryan.ui:segmented-display:0.0.3")
        }
        /* ... */
    }
}
Note

You can remove the project dependency, but I'm leaving it in so that I can more easily test local changes via the sample application.

Note

You should switch back to the project dependency when you're done testing the library from Maven Central. This will allow your sample application to pick up local edits to the segmented-display module's code without publishing anything.

You should then be able to run your project on all four targets.

Conclusion

Congratulations! You now have a multiplatform UI library published to Maven Central that anyone can use!

This concludes the series on creating a Compose Multiplatform UI library. Take a moment to pat yourself on the back if you made it all the way from the beginning. Also, please add a comment if you found anything in the series to be useful.

Deploying your library to Maven Central, however, is just the beginning. I allowed a bug to slip in to the article on improving the look of the segmented display that we should address with a new version and some release notes. I also want to cover setting up GitHub Actions to automate the release process when we merge to the main branch and talk about the lifecycle of a change. Those will come in some bonus articles that I will publish in the near future.