← Back to list

Baking a Version String into a GraalVM Native Image

How I Got Here

Somak Dutta in FAUN.dev() 🐾 · 2026-06-19 05:43 · 0 claps · 3.9 min read
#graalvm #java #maven #version-control-system
Open on Medium ↗
Wiki topics: 🍳 · Food & Cooking

Baking a Version String into a GraalVM Native Image

How I Got Here

I was building [jbeats](https://github.com/somak2kai/jbeats) — a Java CLI tool that parses Java source files and emits per-method metadata as JSON. I distribute jbeats as a GraalVM native binary via Homebrew.

I tagged a release, the CI built the native images, Homebrew picked them up. Everything looked fine:

$ brew upgrade jbeats
==> Upgrading somak2kai/tap/jbeats
  0.1.2 -> 0.1.4
🍺  /opt/homebrew/Cellar/jbeats/0.1.4: 3 files, 20.5MB, built in 3 seconds

Then I ran:

$ jbeats --version
jbeats 0.1.0

No no no not 0.1.0 :O :(

The version I had hardcoded in Main.java on day one. Homebrew correctly installed 0.1.4. The binary reported 0.1.0. Something was clearly wrong.

The first fix seemed obvious — pass the version from the git tag at build time:

mvn -Pnative package -DskipTests -Djbeats.version=v0.1.4

And read it back:

$ jbeats --version
jbeats dev

The fallback dev was being printed .. me sad again :( The property had vanished entirely.

That’s when I understood what GraalVM native-image actually does — and why -D properties don't behave the way you expect.

The Problem

You’re building a CLI tool in Java. You compile it to a native binary with GraalVM’s native-image. You want mytool --version to print the version from your release tag — say, v1.2.0.

The obvious thing to try:

native-image -Dapp.version=1.2.0 -jar myapp.jar

Then in code:

System.out.println(System.getProperty("app.version", "dev"));

You build. You run. It prints dev.

The property evaporated. Here’s why.

GraalVM native-image is not a JIT compiler. It performs Ahead-of-Time (AOT) compilation — it runs a static analysis of your entire program, determines what code paths are reachable, and compiles everything down to a standalone binary. No JVM at runtime.

System properties passed with -D during the native-image build are build-time properties. They influence the analysis phase — things like logging configuration, feature flags, and framework bootstrapping during compilation. They are not automatically carried into the final binary as runtime state.

The answer is --initialize-at-build-time.

How Class Initialization Works in Native Image

In a standard JVM, classes are initialized lazily — the first time they are accessed at runtime. Their static initializers (static {} blocks and static field assignments) run then.

GraalVM native-image gives you control over this. You can tell it to initialize specific classes during the build instead. When it does, static fields get their values computed and baked into the binary as constants. At runtime, those classes are already initialized — no work to do, no properties to look up.

This is the lever we need.

The Fix

Create a dedicated class whose sole job is to hold the version string:

package com.example;

/**
 * Initialized at native-image build time.
 * The VERSION field is resolved during compilation and baked
 * into the binary — no runtime property lookup needed.
 */
public final class Version {
    public static final String VALUE =
        System.getProperty("app.version", "dev");

    private Version() {}
}

Tell GraalVM to initialize it at build time. In your native-image arguments (or Maven pom.xml):

--initialize-at-build-time=com.example.Version

Now the flow is:

  1. native-image starts, initializes Version during build
  2. System.getProperty("app.version", "dev") runs at compile time — reads v1.2.0 from the build environment
  3. "v1.2.0" is stored in the static field and baked into the binary
  4. At runtime, Version.VALUE is just a constant — no JVM, no property lookup, no fallback

Wiring It Up with Maven

In your pom.xml, define a default property:

<properties>
    <app.version>dev</app.version>
</properties>

Pass it to native-image in the GraalVM plugin configuration:

<plugin>
    <groupId>org.graalvm.buildtools</groupId>
    <artifactId>native-maven-plugin</artifactId>
    <version>0.10.4</version>
    <configuration>
        <mainClass>com.example.Main</mainClass>
        <imageName>mytool</imageName>
        <buildArgs>
            <arg>--no-fallback</arg>
            <arg>-Dapp.version=${app.version}</arg>
            <arg>--initialize-at-build-time=com.example.Version</arg>
        </buildArgs>
    </configuration>
</plugin>

And Et voilà …

An Alternative I Considered — And Why I Dropped It

Before landing on the --initialize-at-build-time approach, I looked at Maven resource filtering. The idea is straightforward: create a version.properties file in src/main/resources/, put a placeholder in it, and let Maven replace it before compilation.

# src/main/resources/version.properties
version=${app.version}

Enable filtering in pom.xml:

<build>
    <resources>
        <resource>
            <directory>src/main/resources</directory>
            <filtering>true</filtering>
        </resource>
    </resources>
</build>

Then read it at runtime:

Properties props = new Properties();
props.load(Main.class.getResourceAsStream("/version.properties"));
String version = props.getProperty("version", "dev");
System.out.println("jbeats " + version);

On paper this works. In practice with GraalVM native-image, there are two problems.

Problem 1: GraalVM doesn’t bundle resources by default.

native-image does not include classpath resources in the binary unless you explicitly register them. So getResourceAsStream("/version.properties") returns null at runtime — the file simply isn't there. To fix it you need a resource-config.json in src/main/resources/META-INF/native-image/:

json

{
  "resources": {
    "includes": [
      { "pattern": "version.properties" }
    ]
  }
}

That’s an extra config file to maintain. Forget it, or get the pattern wrong, and you get a silent null — same symptom as before, no build error to catch it.

Problem 2: Silent failure if filtering doesn’t run.

If Maven resource filtering is skipped or misconfigured, the placeholder never gets replaced. Your binary ships with the literal string ${app.version} as its version. Again, no build error — it compiles and links just fine.

Two extra failure modes, both silent, both producing wrong output rather than a build break. That’s worse than the problem I was trying to solve.

The --initialize-at-build-time approach fails loudly if something goes wrong — if the property isn't set, you get dev, which is visibly wrong and easy to catch in CI. It also requires zero extra config files. That's why I went with it.

This is the first time I came across --initialize-at-build-time approach and I have to say — i kinda like it.


메타데이터
post_id
40b1535c57d6
slug
baking-a-version-string-into-a-graalvm-native-image-40b1535c57d6
url
https://medium.com/@somaktukai/baking-a-version-string-into-a-graalvm-native-image-40b1535c57d6
canonical_url
https://medium.com/@somaktukai/baking-a-version-string-into-a-graalvm-native-image-40b1535c57d6
author_url
https://medium.com/@somaktukai
status
ok
fetched_at
2026-06-25 16:53:31