Publishing to Maven Repositories with GitLab CI, with Signed Artefacts
Preamble
I maintain a number of Open Source projects, of which a handful are Java projects which are released to Maven Central for consumption by other users.
I utilise the Sonatype OSSRH (OSS Repository Hosting) as the means to deploy to Maven Central more easily, which enforces checks before the artefact is published, such as all arefacts have a corresponding GPG signature.
It's a little awkward to perform a release locally, as even though I'm using the Gradle Nexus Publish Plugin, I still have to remind myself of the process, and make sure that my local machine is set up with all the right configuration.
I use GitLab and GitLab CI where possible, and couldn't find a lot of folks documenting how they'd managed to get a GitLab CI configuration working, so the artefacts could be signed and uploaded correctly, so in the spirit of blogumentation, I thought I'd work out how to do it and document it.
Prerequisites
Although I have a signing key that I'm currently using to release my libraries, I decided that continuing to use this key for CI purposes wasn't a good idea.
I followed this article to set up a GPG sub-key to use this new sub-key for the purpose of automation.
Current CI setup
Let's say that we've got the following .gitlab-ci.yml
:
image: openjdk:11
stages:
- test
variables:
# NOTE this is required to allow for caching, but isn't required for this
# process to work. If you don't set this variable, you'll need to replace
# references to `$GRADLE_USER_HOME` with `~/.gradle`
GRADLE_USER_HOME: '.gradle'
GIT_DEPTH: "0" # Tells git to fetch all the branches of the project, required by the analysis task
cache:
paths:
- .gradle/wrapper
- .gradle/caches
test:
stage: test
script:
- ./gradlew clean build
- ./gradlew sonarqube -Dsonar.qualitygate.wait=true
only:
- branches
- merge_requests
Setting up branch + tag protection
Because we're going to restrict the usage of the CI/CD secret variables to protected branches and tags, we need to make sure that any branches we're expecting to perform releases from are correctly set up in GitLab's UI, as well as setting up tag protection (which is a separate step).
If you don't have this set up, you may receive errors like:
Example GPG error when not running on a protected branch
$ gpg --pinentry-mode loopback --passphrase $GPG_PASSPHRASE --import $GPG_USER_KEY
gpg: directory '/root/.gnupg' created
gpg: keybox '/root/.gnupg/pubring.kbx' created
gpg: no valid OpenPGP data found.
gpg: Total number processed: 0
Gradle Configuration
To use the GPG signing with the Gradle Nexus Publish Plugin, we need to set the following configuration in a gradle.properties
, as per the Gradle docs:
signing.gnupg.keyName=6B82211F5E150224CB970404DF7507BC5D21FAD0
signing.gnupg.passphrase=this-would-be-the-actual-passphrase
I also found that on the Docker image I was running, the GPG command was gpg
, not gpg2
which is the default, so I needed to add the following:
signing.gnupg.executable=gpg
Finished Product
The solution I've come to is as follows, and allows limiting the secrets to protected branches and tags, and handles fully automated signing and publishing of artefacts.
You can see the final version of the pipeline below:
image: openjdk:11
stages:
- test
- deploy
variables:
# NOTE this is required to allow for caching, but isn't required for this
# process to work. If you don't set this variable, you'll need to replace
# references to `$GRADLE_USER_HOME` with `~/.gradle`
GRADLE_USER_HOME: '.gradle'
GIT_DEPTH: "0" # Tells git to fetch all the branches of the project, required by the analysis task
cache:
paths:
- .gradle/wrapper
- .gradle/caches
test:
stage: test
script:
- ./gradlew clean build
- ./gradlew sonarqube -Dsonar.qualitygate.wait=true
only:
- branches
- merge_requests
deploy:
stage: deploy
before_script:
- gpg --pinentry-mode loopback --passphrase $GPG_PASSPHRASE --import $GPG_USER_KEY
- mkdir -p $GRADLE_USER_HOME
- cat "$GRADLE_PROPERTIES" > $GRADLE_USER_HOME/gradle.properties
- echo signing.gnupg.passphrase=$GPG_PASSPHRASE >> $GRADLE_USER_HOME/gradle.properties
script:
- ./gradlew publish closeAndReleaseSonatypeStagingRepository
only:
# TODO: you may want to make this only trigger on `main` if you're using a
# SNAPSHOT, especially if you're using tags for release uploads, or only
# push releases from `main` to avoid cases where we have a tag and `main`
# pushing a release at the same time, and clashing
- main
- tags
Which requires the following variables set in the GitLab UI:
Type | Key | Value | Protected |
---|---|---|---|
Variable | GPG_PASSPHRASE | (passphrase for the key) | |
File | GPG_USER_KEY | i.e. gpg2 --armor --export-secret-keys 6B82211F5E150224CB970404DF7507BC5D21FAD0 | |
File | GRADLE_PROPERTIES | the gradle.properties as mentioned above, with newline at the end of file / appended when cat ing it | |
Variable | ORG_GRADLE_PROJECT_sonatypeUsername | The password retrieved using your User Token | |
Variable | ORG_GRADLE_PROJECT_sonatypePassword | The password retrieved using your User Token |
This now allows you to handily publish our binaries to Maven Central - for example.