In Zero-Commit, Zero-Input Maven Releases, I described how JLine uses Nisse to derive the project version and build timestamp entirely from git. Push a tag, get a release. No version commits, no -Drevision=, no CI configuration.

But the post was honest about a trade-off:

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.

This matters more than it sounds. If nobody can rebuild your project from a source archive and get the same bytes, your “reproducible build” is reproducible in theory only. You’ve done the hard work — deterministic timestamps, stripped manifests, _reproducible=true in bnd — but the verification step is impossible for anyone who isn’t cloning from git.

This post describes the fix, shipped in Nisse 0.9.5, that closes the gap entirely — zero flags, Maven 3 and 4.

The problem, concretely

Download JLine’s source archive for a tag that predates the fix (say, 4.3.1):

curl -sL https://github.com/jline/jline3/archive/refs/tags/4.3.1.tar.gz | tar xzf -
cd jline3-4.3.1
./mvx mvn install
[ERROR] 'version' must be a constant version but is '${nisse.jgit.dynamicVersion}'.

No .git directory means nisse can’t resolve any properties. The build fails immediately.

You could work around it by passing the version and timestamp manually:

./mvx mvn install \
  -Dnisse.jgit.dynamicVersion=4.3.1 \
  -Dproject.build.outputTimestamp=2026-06-30T21:15:51+02:00

That produces byte-identical JARs — but good luck figuring out that timestamp without access to the git history.

The fix: nisse.properties + export-subst

The solution combines two mechanisms:

  1. Git’s export-subst — a little-known feature where git archive expands $Format:...$ placeholders in marked files using the same format specifiers as git log --format. The expansion happens at archive creation time. GitHub uses git archive internally for tag tarballs, so this works automatically.

  2. Nisse’s .mvn/nisse.properties (maveniverse/nisse#182) — a low-priority fallback property source. Nisse reads this file directly, independent of Maven’s property loading chain, so it works with both Maven 3 and Maven 4. Properties from this file slot below JGit-resolved values but above nothing:

PrioritySource
HighestCLI -D / maven.config
HighJGit-resolved properties
Low.mvn/nisse.properties

Crucially, nisse also detects unexpanded $Format:…$ placeholders and silently skips them — so in a git checkout, the literal placeholder strings are ignored and JGit’s resolved values win.

The change

Two files, three lines, zero ongoing maintenance.

.gitattributes:

.mvn/nisse.properties export-subst

.mvn/nisse.properties:

# Fallback values for source archives (expanded by git archive via export-subst)
nisse.jgit.dynamicVersion=$Format:%(describe:tags=true)$
nisse.jgit.date=$Format:%cI$

How it works in a git checkout

The file contains the literal strings $Format:%(describe:tags=true)$ and $Format:%cI$. Nisse detects the unexpanded placeholders and skips them. Then JGit resolves both properties from git history — business as usual:

$ ./mvx mvn validate
[INFO] Maveniverse Nisse 0.9.7 loaded
[INFO] Nisse injecting 21 properties into User Properties
[INFO]  * ${nisse.jgit.dynamicVersion}=4.4.1-5-SNAPSHOT
[INFO] BUILD SUCCESS

How it works in a source archive

Git expanded the placeholders at archive time. The file now contains concrete values:

nisse.jgit.dynamicVersion=4.4.0
nisse.jgit.date=2026-08-26T20:52:48+02:00

Nisse loads these as fallback properties. JGit finds no .git directory and returns nothing. The fallback values stick. The build just works — no -D flags needed:

$ cd jline3-4.4.0
$ ./mvx mvn install
[INFO] Maveniverse Nisse 0.9.7 loaded
[INFO] BUILD SUCCESS

Why not .mvn/maven-user.properties?

Our first attempt used .mvn/maven-user.properties for the fallback values. It worked — but only with Maven 4. That file is part of Maven 4’s new properties loading chain; Maven 3 ignores it entirely.

We also considered .mvn/maven.config, but that has the opposite problem: maven.config entries are CLI-level -D properties with the highest priority, so the unexpanded literal $Format:…$ in a git checkout would override nisse’s JGit-resolved values.

The .mvn/nisse.properties approach avoids both issues. Nisse owns the file and reads it directly — no dependency on Maven’s version-specific property loading. It works identically on Maven 3.9+ and Maven 4.

Verifying the build

With this setup, anyone can verify that published artifacts match their source:

# Download source archive
curl -sL https://github.com/jline/jline3/archive/refs/tags/4.4.0.tar.gz | tar xzf -
cd jline3-4.4.0

# Build — version and timestamp are embedded in the archive
./mvx mvn install -DskipTests

# Compare against Maven Central
curl -sL https://repo1.maven.org/maven2/org/jline/jline-terminal/4.4.0/jline-terminal-4.4.0.jar \
  -o /tmp/central.jar

sha256sum terminal/target/jline-terminal-4.4.0.jar /tmp/central.jar
613fd2c5b76af79dc1dc4e13447863b2d707cc9cf8393d2081d8b24c8cbca353  terminal/target/jline-terminal-4.4.0.jar
613fd2c5b76af79dc1dc4e13447863b2d707cc9cf8393d2081d8b24c8cbca353  /tmp/central.jar

Same bytes. No flags. No guessing.

Why export-subst?

The obvious alternative is to generate a metadata file in the release workflow and attach it to the GitHub release. But that has drawbacks:

  • It’s a separate download — not inside the tarball, so the user needs to know it exists
  • It requires CI logic to generate and upload the file
  • It doesn’t help with git archive outside of GitHub (local git archive, Gitea, GitLab, etc.)

export-subst is baked into git itself. It works with every tool that uses git archive under the hood — GitHub, GitLab, Gitea, Forgejo, local tarballs. No CI step, no post-processing, no separate asset. The archive is self-contained by construction.

What about %(describe)?

The %(describe:tags=true) format specifier (added in Git 2.35, January 2022) runs git describe --tags at archive time. On a tagged commit, it returns exactly the tag name — 4.4.0. Between tags, it returns something like 4.4.0-3-g6b83c24.

The :tags=true option ensures lightweight tags are included, not just annotated ones. If your project uses only annotated tags, plain %(describe) works too.

Adopting this

If your project uses nisse for zero-commit releases, you can adopt the same pattern in one commit:

  1. Upgrade nisse to 0.9.5+ in .mvn/extensions.xml
  2. Add .mvn/nisse.properties with $Format:...$ placeholders
  3. Mark it for export-subst in .gitattributes

Both JLine and Scalpel have already adopted this approach.

The only requirement is Git 2.35+ on the server generating the archive (for %(describe) support) — a version from January 2022, so effectively universal by now.