Publishing to Maven Central using Apache Maven

Last week JFrog announced changes to its Bintray & JCenter services which will eventually lead to these services being discontinued by February 2022. I've been a happy Bintray user since the early days. Their services make uploading archives and having them available to the public a snap. Syncing those artifacts from JCenter to Maven Central can also be automated, simplifying the full release process. Previously I blogged about the options available to Gradle projects for publishing to Maven Central via Bintray. I'm saddened by the recent news of Bintray riding into the sunset, I'll miss it dearly. And like many developers I found myself looking for alternatives to keep publishing artifacts to Maven Central. I'm not going to lie, one of the reasons for turning to Bintray (back then) is that the process for releasing to Maven Central was more complicated that the push of a button. With that old (and outdated) experience still on my head I began the search for options, dreading what I would find.

Well it so happens that things are not so bad as I remembered. What follows is the setup I found that works for me using Apache Maven in combination with Github Actions, with a release cycle that is adjusted to my preferences. Perhaps some of these steps may apply to your needs as well. This setup is heavily inspired in the work done by Rafael Winterhalter (@rafaelcodes) for the Byte Buddy build. Some of these steps can be customized to your liking. Let's begin.

The first step for publishing to Maven Central is to have a Sonatype account. Follow this guide if you don't have an account.

Step number two is to make sure your POM files comply with the rules required for uploading to Maven Central, that is, each POM contains the minimum set of elements that identify and describe the generated artifacts. Pay special attention to this step as it's very easy to forget an element and have the whole release cycle fail at the last possible moment. We better avoid this. I'd suggest checking your POMs using the pomchecker-maven-plugin's check-maven-central goal.

The next order of business is generating -sources and -javadoc JARs that should be deployed alongside the binary JARs. This is done by adding extra configuration to the maven-sources-plugin and the maven-javadoc-plugin. Additionally it's a good idea to include the project's license file in the binary JAR, also renaming it so that it does not clash with other license files, after all you never know if your project will be shaded into a bigger JAR, do you? This last bit of configuration is completely optional but nice to have. Given that these additions make sense when we're ready to publish it's a good idea to move them to a profile that's not active by default, that way we can select these additions when needed. Thus we end of with the following

<profile>
  <id>publication</id>
  <activation>
    <activeByDefault>false</activeByDefault>
  </activation>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-source-plugin</artifactId>
        <version>3.2.1</version>
        <executions>
          <execution>
            <id>attach-sources</id>
            <goals>
              <goal>jar</goal>
            </goals>
            <configuration>
              <attach>true</attach>
            </configuration>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-javadoc-plugin</artifactId>
        <version>3.2.0</version>
        <executions>
          <execution>
            <id>attach-javadocs</id>
            <goals>
              <goal>jar</goal>
            </goals>
            <configuration>
              <attach>true</attach>
            </configuration>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <groupId>com.coderplus.maven.plugins</groupId>
        <artifactId>copy-rename-maven-plugin</artifactId>
        <version>1.0.1</version>
        <executions>
          <execution>
            <id>copy-license-file</id>
            <phase>generate-sources</phase>
            <goals>
              <goal>copy</goal>
            </goals>
            <configuration>
              <sourceFile>${project.basedir}/LICENSE</sourceFile>
              <destinationFile>${project.build.outputDirectory}/META-INF/LICENSE-myproject</destinationFile>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</profile>

Next we have to configure signing of all artifacts. This can be done by adding the maven-gpg-plugin to the POM. Just like with publication we'll set this plugin in its own profile so that it becomes active when we need it.

<profile>
  <id>gpg</id>
  <activation>
    <activeByDefault>false</activeByDefault>
  </activation>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-gpg-plugin</artifactId>
        <version>1.6</version>
        <executions>
          <execution>
            <phase>verify</phase>
            <goals>
              <goal>sign</goal>
            </goals>
            <configuration>
              <gpgArguments>
                <arg>--pinentry-mode</arg>
                <arg>loopback</arg>
              </gpgArguments>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</profile>

Note that the signing is bound to the verify lifecycle phase, that way we can be sure all artifacts have been attached to the reactor, as verify is the next phase after package. Finally we configure a plugin that performs the upload. The nexus-staging-maven-plugin is a must, however as I also want the build to take care of tagging and bumping the version to next development iteration I configured the maven-release-plugin as well. This results in the following configuration inside the <plugins> section

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-release-plugin</artifactId>
  <version>3.0.0-M1</version>
  <configuration>
    <useReleaseProfile>false</useReleaseProfile>
    <releaseProfiles>publication,gpg</releaseProfiles>
    <autoVersionSubmodules>true</autoVersionSubmodules>
    <tagNameFormat>v@{project.version}</tagNameFormat>
  </configuration>
</plugin>
<plugin>
  <groupId>org.sonatype.plugins</groupId>
  <artifactId>nexus-staging-maven-plugin</artifactId>
  <version>1.6.8</version>
  <extensions>true</extensions>
  <configuration>
    <serverId>central</serverId>
    <nexusUrl>${nexus.url}</nexusUrl>
    <autoReleaseAfterClose>true</autoReleaseAfterClose>
  </configuration>
</plugin>

Deployment requires having a <distributionManagement> section defined in your POM. This is the one I have

<distributionManagement>
  <snapshotRepository>
    <id>ossrh</id>
    <url>${nexus.url}/content/repositories/snapshots</url>
  </snapshotRepository>
  <repository>
    <id>ossrh</id>
    <url>${nexus.url}/service/local/staging/deploy/maven2/</url>
  </repository>
</distributionManagement>

You also need a proper <scm> section otherwise the release will fail

<scm>
  <connection>scm:git:${repository.url}</connection>
  <developerConnection>scm:git:${repository.url}</developerConnection>
  <url>${repository.url}</url>
  <tag>HEAD</tag>
</scm>

If you're wondering about those variable placeholders, they are defined as properties in the POM

<properties>
  <nexus.url>https://oss.sonatype.org</nexus.url>
  <project.repository>aalmiray/myproject</project.repository>
  <repository.url>git@github.com:${project.repository}.git</repository.url>
</properties>

These are all the changes required to the POM at this moment. We could turn to the Github Action workflow but before we do that I'd like to share yet another custom profile, local-deploy, that can be used to publish all artifacts to a given directory, letting you inspect them before attempting a remote release, because better be safe than sorry and retry a release, right?

<profile>
  <id>local-deploy</id>
  <activation>
    <activeByDefault>false</activeByDefault>
  </activation>
  <distributionManagement>
    <snapshotRepository>
      <id>local-snapshot</id>
      <url>file://${local.repository.path}/snapshot</url>
    </snapshotRepository>
    <repository>
      <id>local-release</id>
      <url>file://${local.repository.path}/release</url>
    </repository>
  </distributionManagement>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-clean-plugin</artifactId>
        <inherited>false</inherited>
        <configuration>
          <filesets>
            <fileset>
              <directory>${local.repository.path}</directory>
            </fileset>
          </filesets>
        </configuration>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-antrun-plugin</artifactId>
        <version>3.0.0</version>
        <inherited>false</inherited>
        <executions>
          <execution>
            <id>generate-repository-directories</id>
            <phase>generate-sources</phase>
            <configuration>
              <target>
                <mkdir dir="${local.repository.path}/snapshot" />
                <mkdir dir="${local.repository.path}/release" />
              </target>
            </configuration>
            <goals>
              <goal>run</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</profile>

A local deployment is thus executed with a command similar to

$ mvn -Ppublication,local-deploy -Dlocal.repository.path=/tmp/repository

This command packages all binaries, sources, and javadoc JARS, deploying them to the given path. You may specify the target path using a command argument or setting it as a property in the POM. Alright, now we have a look at the Github Action workflow.

The idea is to trigger a release with a specific commit. We can do this by inspecting the commit message searching for a specific pattern, in my case the commit message should begin with the "[release]" prefix. You may pick your own trigger and matching conditions of course. Another thing we have to keep in mind is the GPG keys required for signing artifacts. We can set them as part of the secrets belonging to the project's repository, or you can also set them at the organization level, allowing you to share them across all repositories belonging to said organization. Specifically we need one private key and a passphrase to decrypt the key. Refer to Github's documentation on how to add secrets to a repository. If you don't have a GPG key already then you must generate one. This key must be verifiable, the upload process will check the following key servers looking for a matching public key

  • http://keyserver.ubuntu.com
  • http://keys.openpgp.org
  • http://keys.gnupg.net

Thus to get going we'll need the private key and store in a GPG_PRIVATE_KEY secret. Retrieve the key with

$ gpg --armor --export-secret-key <keyid>

Retrieve the public key with

$ gpg --armor --export <keyid>

Then upload it to any of the previously mentioned key servers. We'll need 3 additional secrets:

  • GPG_PASSPHRASE - the passphrase required to decrypt the key
  • SONATYPE_USERNAME - the username that can publish artifacts to the given groupId.
  • SONATYPE_PASSWORD - the username's password

This is what we're aiming for

And now for the workflow itself. Notice that there are 2 steps for setting up the Java version, this is to set the correct credentials required for publication. I believe it may be possible to combine both steps into one

name: Release

on:
  push:
    branches: [ master ]

jobs:
  Release:
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && startsWith(github.event.head_commit.message, '[release]')
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Setup Java
        uses: actions/setup-java@v1
        with:
          java-version: 1.8

      - name: Cache Maven
        uses: actions/cache@v1
        with:
          path: ~/.m2
          key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
          restore-keys: ${{ runner.os }}-m2

      - name: Build
        run: |
          chmod +x mvnw
          ./mvnw -B verify --file pom.xml

      - name: Set up Maven Central
        uses: actions/setup-java@v1
        with:
          java-version: 1.8
          server-id: central
          server-username: MAVEN_USERNAME
          server-password: MAVEN_CENTRAL_TOKEN
          gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }}
          gpg-passphrase: MAVEN_GPG_PASSPHRASE

      - name: Release
        env:
          MAVEN_USERNAME: ${{ secrets.SONATYPE_USERNAME }}
          MAVEN_CENTRAL_TOKEN: ${{ secrets.SONATYPE_PASSWORD }}
          MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
        run: |
          git config user.name "${{ github.event.head_commit.committer.name }}"
          git config user.email "${{ github.event.head_commit.committer.email }}"
          mvn -B --file pom.xml release:prepare release:perform -Drepository.url=https://${GITHUB_ACTOR}:${{ secrets.GITHUB_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git

The release step changes the name of the user required for making commits, as the release plugin will perform commits and tag the repository. After all this, just pushing a commit with "[release]" triggers the whole process, and barring any network issues you should see the artifacts successfully published to Maven Central after a couple of minutes. A fully working example of this setup can be found at the sdkman/sdkman-vendor-maven-plugin repository.

Keep on coding!

Image by jplenio from Pixabay

Liked it? Take a second to support aalmiray on Patreon!
Become a patron at Patreon!

2 comments

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

ˆ Back To Top