For years, the Maven release process has been a source of pain. The maven-release-plugin runs your build three times, creates two commits to bump versions back and forth, and is notoriously fragile. In 2016, Axel Fontaine declared it dead and buried, proposing a CI-friendly alternative using ${revision} and a single mvn deploy scm:tag -Drevision=$BUILD_NUMBER command.

That was a big improvement. But it still requires something — a CI server, a build number, someone or something to decide what the version should be. What if the version could be derived entirely from git, with no input at all?

That’s what we do on JLine. Here’s how a release works:

git tag 4.1.0
git push --tags

That’s it. No -Drevision=, no version commits, no release plugin, no CI configuration per release. The GitHub Actions workflow picks up the tag, the version is computed automatically, and artifacts land on Maven Central.

How it works

The trick is Nisse, a Maven extension by Tamás Cservenák. Nisse provides “property sources” — small providers that inject properties into your build. One of them, the jgit source — originally contributed by Jimisola Laursen — inspects your git repository and derives a version from the closest tag.

If you’re on the 4.1.0 tag exactly, the version is 4.1.0. If you’re 3 commits after, it’s 4.1.1-3-SNAPSHOT. You never touch a <version> in a POM.

Setup

1. Add Nisse as a core extension in .mvn/extensions.xml:

<extensions>
  <extension>
    <groupId>eu.maveniverse.maven.nisse</groupId>
    <artifactId>extension</artifactId>
    <version>0.7.0</version>
  </extension>
</extensions>

2. Enable the dynamic version in .mvn/maven.config:

-Dnisse.source.jgit.dynamicVersion=true

(For Maven 4.x, you can use .mvn/maven-user.properties with nisse.source.jgit.dynamicVersion=true instead. Note that .mvn/maven-user.properties is not read by Maven 3.x.)

3. Use the nisse property in your POM:

<groupId>com.example</groupId>
<artifactId>my-project</artifactId>
<version>${nisse.jgit.dynamicVersion}</version>

That’s it — three steps. Nisse handles everything else:

  • It extends Maven’s ModelVersionProcessor to allow nisse.* properties in <version> fields (normally Maven 3.x only allows ${revision}, ${sha1}, and ${changelist})
  • It includes a built-in property inliner that automatically rewrites POM files before install/deploy, replacing ${nisse.jgit.dynamicVersion} with the actual version number

No flatten-maven-plugin, no translation tables, no ${revision} indirection. The version is defined in one place — the nisse extension computes it, the POM references it, and nisse inlines it in deployed artifacts.

$ mvn package
# Building MyProject 1.0.1-3-SNAPSHOT  ← 3 commits after 1.0.0 tag

$ git tag 1.1.0
$ mvn package
# Building MyProject 1.1.0             ← exactly on tag

The release workflow

With the version derived from git, the release workflow becomes trivially simple. Here’s a minimal GitHub Actions workflow:

on:
  push:
    tags:
      - '[0-9]*.[0-9]*.[0-9]*'

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0  # Full history for git describe

      - uses: actions/setup-java@v5
        with:
          distribution: temurin
          java-version: 22

      - name: Release
        run: mvn -B deploy -Psign

Push a tag, the workflow fires, the version is derived from the tag, artifacts are deployed. No parameters, no configuration per release.

The full picture

On JLine, the workflow does more than just mvn deploy. Here’s what the real workflow handles:

  1. Version guard — rejects tags containing - (like 4.1.0-SNAPSHOT hint tags) to prevent accidental SNAPSHOT deployments
  2. GPG signing — imports the signing key and signs all artifacts
  3. Deploy to Maven Central — via the Njord publisher with auto-publish
  4. Milestone management — closes the current milestone and creates the next patch milestone (e.g., closes 4.0.5, creates 4.0.6)
  5. GitHub Release — creates a release from the tag with auto-generated notes

All of this is triggered by a single git tag && git push --tags. The workflow is entirely self-contained — no manual steps after pushing the tag.

Version bumps

After tagging 4.0.0, any subsequent commit automatically gets version 4.0.1-1-SNAPSHOT — nisse bumps the patch number and appends the commit distance. For most projects, this is fine: patches are the common case.

But what if you want the next release to be 4.1.0, not 4.0.1? You create a version hint tag:

git tag 4.1.0-SNAPSHOT
git push --tags

Nisse looks for tags matching ${version}-SNAPSHOT and uses them as hints. With this tag in place, the version immediately becomes 4.1.0-SNAPSHOT instead of 4.0.1-N-SNAPSHOT. When you’re ready to release, just tag the release commit:

git tag 4.1.0
git push --tags

The hint tag can point to any commit — it’s just a signal to nisse about what the next version should be. You can think of it as the equivalent of the “prepare for next development iteration” commit that the release plugin creates, except it’s a lightweight tag instead of a commit that modifies your POMs.

The complete lifecycle looks like this:

git tag 1.0.0          →  version: 1.0.0      (release)
<commit>               →  version: 1.0.1-1-SNAPSHOT
<commit>               →  version: 1.0.1-2-SNAPSHOT
git tag 1.1.0-SNAPSHOT →  version: 1.1.0-SNAPSHOT  (bump minor)
<commit>               →  version: 1.1.0-SNAPSHOT
git tag 1.1.0          →  version: 1.1.0      (release)
<commit>               →  version: 1.1.1-1-SNAPSHOT

Note that your release workflow should guard against hint tags triggering a deployment. The glob pattern [0-9]*.[0-9]*.[0-9]* matches both 4.1.0 and 4.1.0-SNAPSHOT, so you need an explicit check:

- name: Verify release version
  run: |
    version=${GITHUB_REF#refs/tags/}
    if [[ "$version" == *-* ]]; then
      echo "Tag '$version' is not a release version, skipping"
      exit 1
    fi

Multiple branches

Nisse derives the version from the closest tag reachable from the current commit, so it works naturally with multiple maintenance branches. On JLine, we maintain both master (4.x development) and jline-3.x (3.x maintenance):

master:    4.0.0 → 4.0.1-1-SNAPSHOT → 4.0.1-2-SNAPSHOT → ...
jline-3.x: 3.30.5 → 3.30.6-1-SNAPSHOT → ...

Each branch sees its own tags and computes its own version independently. You can tag and release from any branch — the workflow is the same. No branch-specific configuration needed.

Why not just -Drevision=$TAG?

You could extract the tag name in CI and pass it as -Drevision. But that means:

  • Local builds get a different version than CI. Developers see 0-SNAPSHOT or whatever default you picked, not the real version.
  • You need CI logic to extract and pass the version. Every CI system does this differently.
  • The version isn’t in the git state — it’s in the CI config. If you rebuild the same commit, you need to know what version to pass.

With nisse, the version is a pure function of the git state. Same commit, same version. Locally, in CI, anywhere. No input needed.

Multi-module projects

For multi-module projects on Maven 3.x, you need a couple of extra things.

Explicit parent references

Every child POM needs the full parent block with the nisse property:

<parent>
    <groupId>com.example</groupId>
    <artifactId>parent</artifactId>
    <version>${nisse.jgit.dynamicVersion}</version>
</parent>

Nisse’s inliner resolves this in deployed POMs, just like the root POM’s version.

Reactor modules in <dependencyManagement>

Maven 3.x doesn’t auto-resolve versions for reactor dependencies. If module A depends on module B and both are in the reactor, you still need an explicit version. The cleanest solution is to list all reactor modules in the parent POM’s <dependencyManagement>:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.example</groupId>
            <artifactId>module-a</artifactId>
            <version>${project.version}</version>
        </dependency>
        <dependency>
            <groupId>com.example</groupId>
            <artifactId>module-b</artifactId>
            <version>${project.version}</version>
        </dependency>
        <!-- ... all reactor modules ... -->
    </dependencies>
</dependencyManagement>

This way, child POMs can declare dependencies on siblings without specifying a version — it’s inherited from the parent.

On JLine, with 16 reactor modules, this is a bit of boilerplate, but it’s a one-time cost.

Gotchas

A few things we learned the hard way:

  • The first tag matters. Nisse needs at least one version tag to derive from. If you’re starting a new project, create your first tag before expecting dynamic versioning to work: git tag 0.1.0.

  • fetch-depth: 0 in CI. GitHub Actions’ actions/checkout defaults to a shallow clone with only the latest commit. Nisse needs the full history (or at least enough to find the nearest tag). Always set fetch-depth: 0.

  • Add .inlined-pom.xml to .gitignore. Nisse’s property inliner creates temporary .inlined-pom.xml files in each module directory during the build. They’re cleaned up at the end of the Maven session, but may linger if the build is interrupted.

Comparison

Release PluginAxel Fontaine (2016)Nisse
Maven executions311
Version commits200
External input neededInteractive promptsCI build numberNone
Works locallyPoorlyFalls back to 0-SNAPSHOTReal version
Version bumpEdit POMs, commitEdit POMs or pass -Dgit tag X.Y.Z-SNAPSHOT
Release triggermvn release:prepare release:performmvn deploy -Drevision=$VERgit tag X.Y.Z && git push --tags
CI automationComplex (release profile, credentials, version passing)Moderate (CI must pass version)Trivial (tag-triggered workflow, version auto-derived)

Trade-offs

No setup is free of trade-offs. Here are the honest ones:

  • Source archives don’t work out of the box. GitHub’s “Download ZIP”, git archive, or extracted tarballs have no .git directory. Without git history, nisse can’t find tags and can’t derive the version. The workaround is to pass the version manually: mvn -Dnisse.jgit.dynamicVersion=4.0.5 package. This is the same situation as every other Maven project — you just need to provide the version explicitly when git isn’t available.

  • Shallow clones break versioning. git clone --depth 1 won’t have the tags nisse needs. CI environments must use full clones (fetch-depth: 0 in GitHub Actions). Most CI setups already do this for other reasons, but it’s easy to forget.

  • IDE experience is slightly rough. IDEs may show ${nisse.jgit.dynamicVersion} as an unresolved property in the POM editor. The build works fine through the IDE’s Maven integration, but the red squiggly in the POM can be distracting.

  • Third-party extension dependency. You depend on nisse being maintained. It’s a build-time extension (not a runtime dependency), and it’s actively developed as part of the Maveniverse ecosystem, but it’s still an external dependency on your build infrastructure.

None of these are showstoppers — the first two have straightforward workarounds, and the last two are minor inconveniences. For the JLine project, the trade-off has been overwhelmingly positive.

With Maven 4

Everything described above works with Maven 3.9.x. If you’re using Maven 4, the multi-module setup gets significantly simpler.

Simpler parent references

Maven 4 allows an empty <parent/> shorthand in child POMs. Instead of repeating groupId, artifactId, and version in every module:

<!-- Maven 3.x -->
<parent>
    <groupId>com.example</groupId>
    <artifactId>parent</artifactId>
    <version>${nisse.jgit.dynamicVersion}</version>
</parent>

You just write:

<!-- Maven 4.x -->
<parent/>

Maven resolves the parent from the reactor automatically.

No reactor <dependencyManagement>

Maven 4 automatically resolves versions for dependencies that are part of the same reactor build. You no longer need to list every reactor module in <dependencyManagement> with ${project.version} — Maven figures it out.

Implicit <groupId> on dependencies

Maven 4 can infer <groupId> for intra-project dependencies when it matches the project’s own groupId. So instead of:

<dependency>
    <groupId>com.example</groupId>
    <artifactId>module-a</artifactId>
</dependency>

You can just write:

<dependency>
    <artifactId>module-a</artifactId>
</dependency>

Simpler configuration

Maven 4 reads .mvn/maven-user.properties, so instead of passing -D flags in .mvn/maven.config:

-Dnisse.source.jgit.dynamicVersion=true

You can use a cleaner properties file (.mvn/maven-user.properties):

nisse.source.jgit.dynamicVersion=true

Summary

With Maven 4, the multi-module boilerplate disappears: no verbose parent blocks, no reactor dependency management, no explicit groupId. The core setup — nisse extension + one property — stays the same.

Try it

The full setup is visible in the JLine repository. The key files are:

After years of fighting the release plugin, and then years of wiring CI servers to pass version numbers around, it’s refreshing to have a release process that’s just… git tag.