Packaging
A plain build stops at jars. Packaging turns those jars into something a user can run without a Java project in front of them: a self-contained application image, a trimmed runtime, a zip to drop onto a JRE, a single executable jar, a container image, or an ahead-of-time-compiled native binary. Each form answers the same question differently - how much of the runtime travels with the program - and this chapter walks through the options.
Every form is opt-in and driven by convention, not a build script. You name it in a
packaging.properties file in the configuration folder, and it runs for every module
that declares a main class.
Key in packaging.properties |
Produces |
|---|---|
jpackage=app-image | deb | rpm | dmg | pkg | exe | msi |
a self-contained application image or a native installer |
jlink=true |
a custom runtime image (modular only) |
jmod=true |
a .jmod module file (modular only) |
bundle=true |
a bundle.zip to drop onto a stock JRE |
launcher=true |
a single executable jar |
docker=<base image> |
a container build context - a Dockerfile and the jars it copies |
native=true |
a GraalVM native binary |
build.jenesis/ packages that module alone, while a project-wide file packages them all. The
jmod step joins the module's own build; every other key adds a step to the
package phase, which runs after every module has built, so packaging never blocks a
sibling's compile.
What makes a module packageable
Only one thing: a declared entry point. It is the same @jenesis.main tag - or <mainClass> property - that
Execute already launches a module with:
/**
* @jenesis.main sample.Sample
*/
module demo.modular.executable {
exports sample;
}
Every packaging step keys off that one declaration and skips a module that has none. A library needs no packaging configuration to be left alone, and an application needs no packaging-specific entry point.
The application image
jpackage=app-image produces a self-contained application image: a native launcher with its own bundled
Java runtime, so a user runs it without installing a JDK. It is the only jpackage type that needs no
platform-native tooling, which makes it the CI-friendly choice.
# build.jenesis/packaging.properties
jpackage=app-image
java build/jenesis/Project.java stage
The --name, --main-jar/--main-class (or --module) arguments are derived from the module's coordinate
and main class. The stage target collects each produced image under stage/packages/, the staging analogue
of stage/maven and stage/modular, in a folder named for the module it came from:
target/stage/packages/output/demo.modular.executable/ the image jpackage produced
|-- bin/demo.modular.executable the launcher
`-- lib/ app jars + bundled runtime
The image bundles the whole runtime closure, not just your own code: a dependency your app uses sits next to the application jar. Because the image is self-contained, a deployable container needs no JDK, only a minimal base:
FROM debian:stable-slim
COPY target/stage/packages/output/demo.modular.executable /opt/app
ENTRYPOINT ["/opt/app/bin/demo.modular.executable"]
Modular images are smaller
How big the image is depends on the layout. A modular project lets jpackage run jlink internally and
trim the bundled runtime to just the modules the graph resolves. A class-path (Maven-layout) project
cannot be trimmed, so it ships a full runtime.
java.base alone links to ~60 MB
and a full JDK is ~303 MB.
Passing jpackage its own flags
jpackage has flags of its own - an icon, a vendor, a description, a licence file. They go in a
process-jpackage.properties file in the configuration folder, one flag per line, exactly as
Building & running described for javac:
# build.jenesis/process-jpackage.properties
--vendor=Example Ltd
--icon=branding/app.png
process-jlink.properties, process-jmod.properties and process-native-image.properties do the same for
the tools below. One flag is derived for you: --app-version comes from jenesis.project.version with any
non-numeric suffix stripped, because jpackage accepts only dotted numbers - 1.4.0-SNAPSHOT becomes 1.4.0.
Native installers
The other jpackage values build a native installer - the single artifact you hand a user to install,
rather than a directory to launch in place. The value is passed straight to jpackage --type:
| Value | Platform |
|---|---|
deb, rpm |
Linux |
exe, msi |
Windows |
dmg, pkg |
macOS |
An installer carries the whole bundled runtime, so it is tens of megabytes. Producing one needs the platform's
own packaging tooling on the PATH: dpkg-deb/fakeroot for deb and rpmbuild for rpm on Linux, the
WiX Toolset on Windows, the bundled productbuild/hdiutil on macOS. For that reason an installer is usually
built locally, while the tooling-free app-image covers the packaging path in CI.
Runtime images and .jmod files
Two modular-only keys expose the lower-level artifacts that jpackage builds internally. Both need modules, so a class-path project has nothing to link or pack.
jlink=true links a custom runtime image holding only the modules your app needs, staged under
stage/runtime. It runs straight from its own bin/java with no JDK installed:
target/stage/runtime/output/module-sources/bin/java \
-m demo.modular.executable/sample.Sample Ada Lovelace
The folder under output/ is the module's build identity rather than its name: module for a module whose
descriptor sits at the project root, module-<folder> otherwise - module-sources for a module under
sources/. The docker context below uses the same naming.
jmod=true packs the module into a .jmod, staged beside the modular jar. Its one advantage over a jar is
that it can carry native libraries, commands, and config files, which jlink then lays into the runtime's
lib/, bin/, and conf/. The three steps chain - jmod โ jlink โ jpackage - so a config file packed this
way reaches the shipped app, where the program reads it from <java.home>/conf/. Packed into a jar instead,
it would be stranded there.
jlink links explicit modules only. Every jar it links must carry a
module-info, or be a .jmod. A plain jar - and an automatic module, which declares
no requires of its own - is rejected with "automatic module cannot be used with jlink". With
jlink=true beside it, jpackage is handed that linked runtime and inherits the rule.
On its own, jpackage stages the jars as its module path and roots the whole path instead, as
the bundle section below describes. The next section is how a closure of plain jars becomes one
jlink accepts.
Making a closure linkable
A dependency that ships as a plain jar - or that you gave a name with a
module alias - is an automatic module, and jlink will not take one. A
modules.properties file in the configuration folder closes that gap by turning the module's whole resolved
closure into explicit named modules:
# build.jenesis/modules.properties
mode=declared
An empty file means the same thing, since declared is the default. Every jar that already declares a
module-info passes through untouched. For the rest, jdeps works out what each one actually reads and a
generated module-info is injected into a copy of it.
The rewritten closure then replaces the resolved one for everything the module builds: javac, the tests,
and every packaging step. That is the point rather than a side effect. A module graph that does not hold
together - a split package, two jars claiming one name, a requires nothing provides - fails at compile or
test time instead of first appearing in the shipped image.
The mode key decides what happens to a jar with no name of its own to carry: declared fails the build and
names the coordinate, synthetic invents a stable name derived from the jar's digest, and none skips the
rewrite - which is how a single module opts out of a project-wide file.
pin records all keep reading the artifacts as they were
downloaded, so a rewritten jar's bytes can never reach a @jenesis.pin checksum.
Bundles for a JRE base
jpackage bundles a runtime into every image. The lighter alternative is to ship only your jars onto an
off-the-shelf JRE base. bundle=true wires a step that writes one bundle.zip per runnable module:
bundle.zip
|-- application.properties mainClass=sample.Sample, mainModule=demo.bundle
|-- modulepath/ jars that are modules (the app jar and its module dependencies)
`-- classpath/ any non-modular (plain) jars
The zip carries exactly the runtime closure the Execute launcher would run, split the same way: real and
automatic modules under modulepath/, plain jars under classpath/. The application.properties describes
the launch with three keys: mainClass (always), mainModule (only for a modular launcher), and
javaOptions (only when needed - see below). Dropped onto a -jre base it needs no JDK and no jpackage. It
is the input a container image, an init script, or any other deployment builds around.
The trade against an app-image is the classic one. An app-image is self-contained but duplicates the JVM per service. A bundle is tiny and shares one JVM layer across every image built on the same base - leaner in aggregate for many services, at the cost of coupling to that base's JVM version.
javaOptions means. A module graph is self-contained when every jar on
the module path is an explicit named module, so the launcher reaches all of them through the main module's
requires. An automatic module or a plain jar breaks that, because a module it uses only
internally is never pulled in. The build detects this and writes
javaOptions=--add-modules=ALL-MODULE-PATH,ALL-DEFAULT, which a consumer splices into the
java command to root the whole module path and the default platform modules. jpackage, the
container context and the native image apply the same correction for you; the key only tells a bundle's
consumer which case they are in.
A container build context
Writing that Dockerfile around a bundle by hand is the one step the build can do for you. The docker key
takes the base image - the one thing no build can infer - and stages a complete build context:
# build.jenesis/packaging.properties
docker=eclipse-temurin:25-jre
java build/jenesis/Project.java stage
docker build -t sample target/stage/docker/output/module-sources
The staged folder holds a generated Dockerfile beside the modulepath/ and classpath/ folders it copies
in, split exactly the way a bundle splits them. Its ENTRYPOINT is the same entry point every other packaging
form reads, so a container can never drift from what the app image or the launcher jar starts. When the
module graph is not self-contained, it carries the same --add-modules=ALL-MODULE-PATH,ALL-DEFAULT correction.
The base image is the only knob, and deliberately so: ENV, USER, EXPOSE and the rest are inherited from
the base, so image environment belongs in a base image rather than in build configuration.
podman build and
buildah bud consume the same folder.
A single executable jar
launcher=true produces a single executable jar you run with java -jar app.jar, without flattening
dependencies into a fat jar. The build shades the published Jenesis Launcher into the jar as its Main-Class
and explodes each dependency into its own classpath/<jar>/ or modulepath/<jar>/ subfolder. At run time the
launcher rebuilds the module graph from those subfolders in process, so module-infos and META-INF/services
never collide.
Unlike jpackage and bundle, this carries no JVM and no jlink runtime. It is a plain jar that runs on any
JDK 25 or newer, and unlike a bundle it needs no launch script. The shaded launcher is
pinned like any other dependency, in its own launcher group, so the exact bytes are
verified and the build stays reproducible.
Native images
native=true compiles the application ahead of time into a single standalone native executable with
GraalVM native-image: a binary that starts in milliseconds and carries no Java runtime, because the runtime
it needs is linked into the binary itself. The stage target collects it under stage/native, and you run it
directly, with no java in the command:
target/stage/native/output/demo.graal.image Ada
Native compilation needs GraalVM. The tool is located through GRAALVM_HOME, then the running JDK's own
bin/, then PATH, so either run the build on a GraalVM JDK or point GRAALVM_HOME at one:
GRAALVM_HOME=~/.sdkman/candidates/java/25.0.3-graal java build/jenesis/Project.java stage
Reachability metadata, captured from tests
native-image's closed-world analysis cannot see reflection, JNI, resources, or proxies, so it needs
reachability metadata for anything dynamic. Jenesis captures that automatically: drop a graal.properties
marker file in the configuration folder and its presence attaches GraalVM's tracing agent to the test run.
The agent records every dynamic access the tests trigger, and the native build picks it up directly. A
single build both captures the metadata and compiles the image, with no committed META-INF/native-image/
directory to maintain.
ClassNotFoundException. You can still commit
metadata by hand under sources/META-INF/native-image/, which native-image discovers
inside every jar - the way to vet exactly what reflection is baked into a published artifact.
native-image or jpackage?
Both turn a modular app into something a user runs without a JDK, but they differ in kind. jpackage ships your bytecode plus a trimmed JVM: normal startup, tens of megabytes, no extra tooling. native-image compiles the program and its runtime into machine code: near-instant startup and a small binary, at the cost of GraalVM, a slow compile, and complete reachability metadata. They are alternatives, not a progression. Choose jpackage for a faithful bundle of the JVM you tested against, and native-image when startup and footprint matter more.
.jmod and a jlink runtime);
demo-07 unpacks a bundle and
runs it on a stock JRE;
demo-35 packs extra
content into a .jmod and carries it through jlink into a jpackage image;
demo-31 makes an
unlinkable closure linkable with a modules.properties; and
demo-44 builds a GraalVM
native image end to end. See Demos.