ckipp01 / mill-ci-release   0.1.9

Apache License 2.0 GitHub

A Mill plugin to help making publishing to Sonatype from GitHub Actions easier.

Scala versions: 2.13
Mill plugins: 0.11 0.10


This is a Mill plugin modeled after the fantastic sbt-ci-release plugin which helps automate publishing to Sonatype from GitHub Actions with as little friction as possible. These are the key features of using the plugin.

  • A new git tag is published as a regular release
  • A merge is published as a SNAPSHOT release
  • Auto versioning based on git by mill-vcs-version
  • A simple one-liner in CI to publish

Getting Started

If you've never published to Sonatype before you'll need to do a one-time setup per domain name that you're publishing under. You can find the instructions for this here. If you don't have a domain name you can use io.github.<@your_username>.

NOTE: Keep in mind that as of February 2021 newly created accounts are tied to whereas older accounts will be tied to This matters when logging in. You'll also want to make sure you set sonatypeHost to Some(SonatypeHost.s01) in this scenario.

Installing the Plugin

To start using this plugin you'll want to include the following import in your build file:

import $ivy.`io.chris-kipp::mill-ci-release::<latest-version>`

This plugin under the hood uses mill-vcs-version to manage your version, so if you have a publishVersion set, remove it. The reason for this is that mill-ci-release is making sure that when you're on a snapshot version, it's appending -SNAPSHOT which is necessary to publish to Sonatype Snapshots. You still can override publishVersion locally, but then you're 100% on your own to ensure that -SNAPSHOT is appended when necessary. This might just be easily included in the plugin in the future.

The only other thing you'll need to do to your build is replace PublishModule with CiReleaseModule.

- import de.tobiasroeser.mill.vcs.version.VcsVersion
+ import

object example 
    extends ScalaModule
-    with PublishModule {
+    with CiReleaseModule {

-  def publishVersion = VcsVersion.vcsState().format()

You'll still need to ensure your pomSettings are correctly filled in, just as if you were extending PublishModule.

NOTE: Again, if you have a newly created account (as of February 2021) you'll also want to ensure you add the following:

+ import
+  override def sonatypeHost = Some(SonatypeHost.s01)

This will then set the correct sonatypeUri and sonatypeSnapshotUri for you. If you have an older account, then there is no need to change the default or use sonatypeHost at all.

Using with custom Sonatype Nexus instances

If you're using your own instance of Sonatype Nexus, your configuration needs to be adapted slightly:

-  override def sonatypeHost = Some(SonatypeHost.s01)
+  override def sonatypeUri = "https://your-sonatype-nexus.url/path/to/releases"
+  override def sonatypeSnapshotUri = "https://your-sonatype-nexus.url/path/to/snapshots"
+  // The Open Source version of Nexus does not support staging
+  override def stagingRelease = false


If you've never created a keypair before that can be used to sign your artifacts you'll need to do this. You can find a guide for doing this here.


Before using mill-ci-release in your GitHub actions workflow you'll need to have the following secrets defined. You can add these by going to repo Settings -> Secrets -> New repository secret.

Here are the necessary secrets:

  • PGP_PASSPHRASE: The passphrase that was used when creating your keypair. This will be the same passphrase that you're prompted to use when copying the value for your PGP_SECRET.
  • PGP_SECRET: A base64 encoded secret of your private key that you can export from the command line like below:
# macOS
gpg --export-secret-key -a $LONG_ID | base64 | pbcopy
# Ubuntu (assuming GNU base64)
gpg --export-secret-key -a $LONG_ID | base64 -w0 | xclip
# Arch
gpg --export-secret-key -a $LONG_ID | base64 | sed -z 's;\n;;g' | xclip -selection clipboard -i
# FreeBSD (assuming BSD base64)
gpg --export-secret-key -a $LONG_ID | base64 | xclip
# Windows
gpg --export-secret-key -a %LONG_ID% | openssl base64
  • SONATYPE_USERNAME: The username you use to log into your Sonatype account.
  • SONATYPE_PASSWORD: The password you use to log into your Sonatype account.

GitHub Actions

You can use an exact copy of what is being used in this repo to publish by doing the following command:

curl -sLo .github/workflows/release.yml --create-dirs

This will create a .github/workflows/release.yml file which will be triggered by a new git tag or a merge to main. The contents should look like this:

name: Release
      - main
    tags: ["*"]
    runs-on: ubuntu-latest
      - uses: actions/checkout@v3
          fetch-depth: 0
      - uses: actions/setup-java@v3
          distribution: 'temurin'
          java-version: '17'
      - run: ./mill -i
          PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }}
          PGP_SECRET: ${{ secrets.PGP_SECRET }}

By default, this will publish all of your modules that are extending CiReleaseModule.

How does this differ from just using Mill?

The underlying publish should actually be identical as it's using the same Sonatype publisher. The difference is only in the set up. Below is a comparison of what you'll commonly see with Mill to release vs using mill-ci-release.

      - name: Publish
+       run: ./mill -i
-       run: |
-         if [[ $(git tag --points-at HEAD) != '' ]]; then
-           echo $PGP_PRIVATE_KEY | base64 --decode > gpg_key
-           gpg --import --no-tty --batch --yes gpg_key
-           rm gpg_key
-           ./mill -i mill.scalalib.PublishModule/publishAll \
-             --publishArtifacts __.publishArtifacts \
-             --sonatypeUri "" \
-             --sonatypeSnapshotUri "" \
-             --sonatypeCreds $SONATYPE_USER:$SONATYPE_PASSWORD \
-             --gpgArgs --passphrase=$PGP_PASSWORD,--no-tty,--pinentry-mode,loopback,--batch,--yes,-a,-b \
-             --readTimeout 600000 \
-             --awaitTimeout 600000 \
-             --release true \
-             --signed true
-         fi


I'm getting a 403 when attempting to publish and I have my env variables correct

Most often this is due to not correctly setting the following if you have a new account:

override def sonatypeHost = Some(SonatypeHost.s01)

Or manually doing

override def sonatypeUri = ""
override def sonatypeSnapshotUri =

It's publishing a 0.0.0-something-SNAPSHOT even though I have a git tag

If you see this it's probably because you forgot to add the fetch-depth in checkout, meaning that no git tags are getting pulled in CI:

- uses: actions/checkout@v3
    fetch-depth: 0


This plugin has only really been tested on more minimal projects. There is purposefully not many configuration options mainly because I firmly believe in sane defaults that should easily allow what most users want to do with minimal setup. If you're missing certain configuration options, please do open a discussion or an issue and we can explore adding more customization options to this.