Publish a multi-module Java/Android library to Maven Central + GitHub CI automation at 2021

Petros Douvantzis
8 min readApr 22, 2021

As an Android developer, I have to fiddle with Gradle from time to time, but I never quite managed to get to a point where my thoughts resulted to a working Gradle configuration. My experience was always a frustrating process of extreme Googling ,trying things out and asking questions to the Gradle forums (which most of the times got answered). Publishing a library to Maven is not a trivial task, even though the situation has become as lot easier than what it used to be.

In my case, I had an Android Studio project that contained a pure Java module and an Android library module that depended on the pure Java module. My goal was to publish them in Maven, including their Javadoc and source code. Even if your project does not match exactly mine, I am sure that the following guide will help you at some extent.

This is a lengthy process and can be broken down to these steps (where each step relies on the previous one):

Publish to Maven local

We are going to use the Maven Publish plugin for publishing our library module to Maven. Since we have more than one modules that we want published, we will create a publish-helper.gradle file to our root folder and we will include it in each module. Here’s our helper file:

apply plugin: 'maven-publish'

task androidJavadoc(type: Javadoc) {
source = android.sourceSets.main.java.srcDirs
classpath += project.files(android.getBootClasspath().join(File.pathSeparator))
android.libraryVariants.all { variant ->
if (
variant.name == 'release') {
owner.classpath += variant.javaCompileProvider.get().classpath
}
}

exclude '**/R.html', '**/R.*.html', '**/index.html'
options.encoding 'utf-8'
options {
addStringOption 'docencoding', 'utf-8'
addStringOption 'charset', 'utf-8'
links 'https://docs.oracle.com/javase/7/docs/api/'
links 'https://d.android.com/reference'
links 'https://developer.android.com/reference/androidx/'
}
}

task androidJavadocJar(type: Jar, dependsOn: androidJavadoc) {
archiveClassifier.set('javadoc')
from androidJavadoc.destinationDir

preserveFileTimestamps = false
reproducibleFileOrder = true
}

task javaSourcesJar(type: Jar) {
archiveClassifier.set('sources')
from android.sourceSets.main.java.srcDirs

preserveFileTimestamps = false
reproducibleFileOrder = true
}

afterEvaluate {
publishing {
publications {
release(MavenPublication) {

from components.release

artifact androidJavadocJar
artifact javaSourcesJar

groupId ''
version ''
pom {
name = artifactId
description = ''
url = ''
licenses {
license {
name = ''
url = ''
}
}
scm {
connection = ''
url = ''
}
developers {
developer {
id = ''
name = ''
email = ''
}
}
}
}
}
}
}

Most of this code was taken from here. The preserveFileTimestamps and reproducibleFileOrder arguments were added so that our source code jar and Javadoc jar result in the same hash every time we generate them, unless the source code has actually changed. Extra arguments have been added in the Javadoc generation task, so that any UTF-8 characters contained in your source code appear correctly to the final Javadoc (yeah, it requires 3 arguments to make this right). At the end, we have to specify some metadata that will be required when publishing to Maven. In this example, empty strings have been added as placeholders.

We can now include this file to the end of the Android Library’s build.gradle file:

apply from: '../publish-helper.gradle'

We can also override default values like so:

apply from: '../publish-helper.gradle'

afterEvaluate {
publishing {
publications {
release(MavenPublication) {
// Specify custom artifactId if needed,
// otherwise it will use module's name by default.
artifactId = "our library name"
}
}
}

Let Gradle sync, and open the Gradle pane on the right of Android Studio and find the publishReleasePublicationToMavenLocal task. If you run it, your Android Library will be published locally to the .m2 folder under your user directory .

If your Android Library depends on another module, you will have to include the publish-helper.gradle to that module as well. The maven-publish plugin will publish both of the modules and the generated pom file (the file that mentions your dependencies) will list the dependent module using its group id and artifact id. If you used api for the dependency, it will be converted to compile. If you used implementation, it will be converted to runtime.

It’s time to make a test project that consumes the locally published library and test that your library works as expected. In the project’s build.gradle add the maven local repository:

allprojects {
repositories {
mavenLocal() // add it here
google()
jcenter()
}
}

You can now add your published library as a dependency, like you would normally do.

In case your published library is a pure Java library (it uses the java or the java-library plugin), we have to make some adjustments when building the Javadoc and source code. Instead of creating a different publish helper file for each file, we will use if (plugins.hasPlugin()) to differentiate between the two as explained here. So, our publish-helper.gradle file becomes:

apply plugin: 'maven-publish'

task androidJavadoc(type: Javadoc) {
if (
plugins.hasPlugin('android-library')) {
source = android.sourceSets.main.java.srcDirs
classpath += project.files(android.getBootClasspath().join(File.pathSeparator))
android.libraryVariants.all { variant ->
if (
variant.name == 'release') {
owner.classpath += variant.javaCompileProvider.get().classpath
}
}
}
else {
source = sourceSets.main.allJava
classpath += configurations.runtimeClasspath
classpath += configurations.compileClasspath
}
exclude '**/R.html', '**/R.*.html', '**/index.html'
options.encoding 'utf-8'
options {
addStringOption 'docencoding', 'utf-8'
addStringOption 'charset', 'utf-8'
links 'https://docs.oracle.com/javase/7/docs/api/'
links 'https://d.android.com/reference'
links 'https://developer.android.com/reference/androidx/'
}
}

task androidJavadocJar(type: Jar, dependsOn: androidJavadoc) {
archiveClassifier.set('javadoc')
from androidJavadoc.destinationDir
preserveFileTimestamps = false
reproducibleFileOrder = true
}

task javaSourcesJar(type: Jar) {
archiveClassifier.set('sources')
if (
plugins.hasPlugin('android-library')) {
from android.sourceSets.main.java.srcDirs
}
else {
from sourceSets.main.allSource
}
preserveFileTimestamps = false
reproducibleFileOrder = true
}

afterEvaluate {
publishing {
publications {
release(MavenPublication) {
if (
plugins.hasPlugin('android-library')) {
from components.release
}
else if (
plugins.hasPlugin('java')) {
from components.java
jar.preserveFileTimestamps = false
jar.reproducibleFileOrder = true
}

artifact androidJavadocJar
artifact javaSourcesJar

groupId ''
version ''
pom {
name = artifactId
description = ''
url = ''
licenses {
license {
name = ''
url = ''
}
}
scm {
connection = ''
url = ''
}
developers {
developer {
id = ''
name = ''
email = ''
}
}
}
}
}
}
}

Publish to Maven Central from your computer

So far, we have managed to publish the library locally, but we want to publish it to Maven Central. The steps needed are:

  • Create a ticket with Sonatype
  • Create a GPG key (I recommend using Kleopatra)
  • Upload Public Key To Directory Service
  • Configure gradle for signing and publishing

You can find details on the first three steps in these guides: 1, 2 You can also read the official Sonaytpe guidelines. Let’s jump to configuring gradle.

Place the following to your project’s build.gradle:

File secretPropsFile = project.rootProject.file('local.properties')
if (
secretPropsFile.exists()) {
Properties p = new Properties()
p.load(new FileInputStream(secretPropsFile))
p.each { name, value ->
ext[name] = value
}
}

Open your local.properties and populate the following values:

signing.keyId=
signing.password=
signing.secretKeyRingFile=
ossrhUsername=
ossrhPassword=

The first 3 values are related to your key. signing.secretKeyRingFile is the file path to your private key. The ossrhUsername and ossrhPassword are the ones used to login to Sonaytpe. Instead of placing your actual credentials, you can generate tokens as explained here. Note that by default, the local.properties file is not added to Git. Make sure that this is the case for your case as well.

We need to make the following changes to our publish-helper.gradle. Add the signing plugin:

apply plugin: 'maven-publish'
apply plugin: 'signing'

Add this to the end (even though we will remove the “repositories” part later):

afterEvaluate {
publishing {
publications {
...
}
repositories {
maven {
name = "sonatype"

def releasesRepoUrl = "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/"
def snapshotsRepoUrl = "https://s01.oss.sonatype.org/content/repositories/snapshots/"
url = version.endsWith('SNAPSHOT') ? snapshotsRepoUrl : releasesRepoUrl

credentials {
username ossrhUsername
password ossrhPassword
}
}
}
}
}

signing {
sign publishing.publications
}

This configuration enables uploading SNAPSHOT versions of your library. You will just have to name the version to something like: 1.0.0-SNAPSHOT and it will be released to Maven’s snapshot repo.

Go to the Gradle pane on the right and run the publishReleasePublicationToSonatypeRepository task. This will publish your library to the staging repo, which you can find here. Check that everything’s ok and close the repository. You can now release the repository (and check “automatically drop”). If this was your first release, you should go back to the Jira ticket and comment to let them know that your repository setup and publication is working. The library will eventually become public.

Prepare for GitHub

We need to make a couple of adjustments so that our Gradle tasks can be run by GitHub.

Instead of manually going to Sonaytpe to release the staging repo every time, we will use the gradle-nexus.publish-plugin. Place the plugin to your project’s build.gradle :

plugins {
id "io.github.gradle-nexus.publish-plugin" version "1.0.0"
}

and configure it:

nexusPublishing {
repositories {
sonatype {
stagingProfileId = ''
nexusUrl.set(uri("https://s01.oss.sonatype.org/service/local/"))
snapshotRepositoryUrl.set(uri("https://s01.oss.sonatype.org/content/repositories/snapshots/"))
username = ossrhUsername
password = ossrhPassword
version = ''
}
}
}

You can get your stagingProfileId by logging in Sonatype and selecting “Staging Profiles” on the left sidebar. You find the id on the URL.

We should also remove the repository configuration we added in our publish-helper.gradle in the previous step. The publishing plugin will take care of that now. Otherwise, we will end up with 2 similarly named Gradle tasks.

Let Gradle sync. Some new tasks will appear in he publishing section. In order to publish your library and close & release the corresponding staging repository, run from the console:

./gradlew publishReleasePublicationToSonatypeRepository closeAndReleaseSonatypeStagingRepository

As a final preparation step, we have to read all the variables from environmental variables. This is because sensitive information can be store to GitHub secrets, which can be exposed to scripts as environmental variables. We also need to to write the private key to a file, since we will read its contents in base64 from an environmental variable. So, the project’s build.gradle will be updated as:

// Initialize publishing/signing extra properties with environmental vars
ext['signing.keyId'] = System.getenv('SIGNING_KEY_ID') ?: ''
ext['signing.password'] = System.getenv('SIGNING_PASSWORD') ?: ''
ext['signing.secretKeyRingFile'] = System.getenv('SIGNING_SECRET_KEY_RING_FILE') ?: ''
ext['ossrhUsername'] = System.getenv('OSSRH_USERNAME') ?: ''
ext['ossrhPassword'] = System.getenv('OSSRH_PASSWORD') ?: ''
// Override with local.properties if available
File secretPropsFile = project.rootProject.file('local.properties')
if (
secretPropsFile.exists()) {
Properties p = new Properties()
p.load(new FileInputStream(secretPropsFile))
p.each { name, value ->
ext[name] = value
}
}


def pgpKeyContent = System.getenv('PGP_KEY_CONTENTS')
if (
pgpKeyContent != null) {
def
tmpDir = new File("$rootProject.rootDir/tmp")
mkdir tmpDir
def keyFile = new File("$tmpDir/key.pgp")
keyFile.createNewFile()
def
os = keyFile.newDataOutputStream()
os.write(pgpKeyContent.decodeBase64())
os.close()
pgpKeyContent = ''

ext['signing.secretKeyRingFile'] = keyFile.absolutePath
}

This configurations tries to read the required properties from environmental variables. If local.prperties is found, it uses that instead. This way, the set-up can work at GitGub and locally. Even if someone pulls your repo, without setting up these properties, they can build the project without errors. The script will also write the private key to a tmp/key.pgp .

Build a GitHub workflow

You can start by creating a new GitHub action using the Gradle command. It’s better to also setup a cache for Gradle as explained in the guide. Our goal is to create a workflow that is triggered when a new release is published:

on:
release:
types: [published]

The step that runs our Gradle tasks can be:

- name: Build and publish to Maven
env:
SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }}
SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }}
OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }}
PGP_KEY_CONTENTS: ${{ secrets.PGP_KEY_CONTENTS }}
run:
./gradlew publishReleasePublicationToSonatypeRepository closeAndReleaseSonatypeStagingRepository

The secrets that are referenced have to be set in your GitHub’s repository as shown in the documentation. PGP_KEY_CONTENTS should contain the Base64 version of your private key.

Conclusion

I may have skipped some steps, but I tried to reduce the guide as much as possible. If you want to see a fully working example, check my builde.gradle and publish-helper.gradle

--

--