Packaging Archives with Ant
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Apache Ant 1.10.18. Every example was run on JDK 26 on 2026-10-16 with the Ant 1.10.18
distribution unpacked in a scratch directory and the ant launch script on the PATH; the multi-release example was also
run on JDK 17 and 21. The sources are the tidepool scenario used across this section: tidepool-model (records),
tidepool-engine (reads data/stations.csv with Commons CSV, so it also needs Commons IO and Commons Codec at run time)
and tidepool-cli (a picocli command). Two small additions exist only for this page: a service interface with one
implementation in tidepool-engine (for the service element) and a RuntimeInfo class with a Java 21 variant (for the
multi-release JAR). Ant does not download libraries, so the four library JARs sit in a lib/ directory; the pages on
Ivy Overview and
Maven Resolver Ant Tasks show how to fetch
them from a repository instead. Paths in console output were shortened to /work/packaging.
Packaging is the step that turns a directory of class files into the things that are shipped: a library JAR for other
builds, a JAR that can be started with java -jar, a web application, a distribution that users unpack, a runtime image.
In Ant there is no packaging phase and no default archive; each archive is one task call that lists, file by file, what
goes in. The page covers that task family in the order a build grows: the shared build file, jar and its manifest, the
runnable and the uber JAR, service providers and multi-release JARs, war and ear, zip and tar, extraction,
signing, modular runtime images, reproducible output, and finally the tidepool CLI packaged as a runnable JAR plus a
zip and a tar.gz distribution. Paths, filesets and patterns are explained on
Paths, Filesets & Patterns; compiling the
classes that are packaged is on Compiling Java.
The archive tasks at a glance
Every archive task in Ant is a variation of <zip>: a JAR is a ZIP file with a manifest, a WAR is a JAR with a fixed
layout, and a <tar> is the same idea for a different container format. The extra tasks around them sign, verify and
unpack archives, or wrap the JDK tools that build modular runtime images.
| Task | What it does | Since |
|---|---|---|
|
Creates a JAR: a ZIP with |
core, |
|
Writes or updates a manifest file, also usable inline inside |
core |
|
Turns a path into a relative |
1.7 |
|
A |
core |
|
A |
core |
|
The base task: any files, with prefix, full path and Unix permissions per fileset |
core |
|
Creates a tar file, optionally gzip, bzip2 or xz compressed, with owner and mode per fileset |
core |
|
Compress a single file ( |
core |
|
Extract an archive, with patternsets and mappers |
core |
|
Create a key, sign JARs, verify signatures, all through the JDK |
core |
|
Wrap the JDK |
1.10.6 |
|
Deprecated: merges ZIP and JAR files; unrelated to the JDK |
deprecated |
Source: Ant manual: jar
Source: Ant manual: zip
The build file used on this page
Every example on the page is a target of one build.xml (the extraction, signing and reproducibility examples are separate
small build files, named where they appear). The header declares the directories and the class path, and the compile
target builds the three modules. The classes are compiled for Java 17, so that the multi-release example later has a real
Java 17 baseline; a second javac compiles the Java 21 variant of RuntimeInfo into its own directory.
<?xml version="1.0" encoding="UTF-8"?>
<project name="tidepool-packaging" default="dist" basedir=".">
<property name="version" value="1.0.0"/>
<property name="build.dir" location="build"/>
<property name="classes.dir" location="${build.dir}/classes"/>
<property name="classes21.dir" location="${build.dir}/classes21"/>
<property name="jars.dir" location="${build.dir}/jars"/>
<property name="dist.dir" location="${build.dir}/dist"/>
<property name="stage.dir" location="${build.dir}/stage/tidepool-${version}"/>
<path id="lib.path">
<fileset dir="lib" includes="*.jar"/>
</path>
<target name="compile" description="Compile the three modules and the Java 21 overlay">
<mkdir dir="${classes.dir}"/>
<javac destdir="${classes.dir}" release="17" includeantruntime="false">
<src path="tidepool-model/src/main/java"/>
<src path="tidepool-engine/src/main/java"/>
<src path="tidepool-cli/src/main/java"/>
<classpath refid="lib.path"/>
</javac>
<mkdir dir="${classes21.dir}"/>
<javac srcdir="tidepool-cli/src/main/java21" destdir="${classes21.dir}" release="21"
includeantruntime="false">
<classpath>
<pathelement location="${classes.dir}"/>
</classpath>
</javac>
</target>
<!-- the targets below are added to this file, before the closing project element -->
</project>
Every target that follows has a description attribute so that ant -p lists it. The targets were added one at a time;
after the last one the project listing is:
$ ant -p
Buildfile: /work/packaging/build.xml
Main targets:
cli-jar Runnable thin JAR with Class-Path
compile Compile the three modules and the Java 21 overlay
dist Zip and tar.gz distributions
genkey Create a throw-away development key
image Modular JARs, jmod files and a runtime image
library-jars One JAR per library module
manifest Write a manifest file
mrjar Multi-release JAR (JEP 238)
sign Sign and verify a JAR
uber-jar Single JAR with all dependencies merged
war Web archive and enterprise archive
war-no-descriptor Servlet 3+ WAR without web.xml
Default target: dist
The files in this section are this page’s own setup for the examples that follow, not taken from the documentation.
The jar task
<jar> takes the same file selection as <zip>: an implicit fileset made of basedir plus includes, excludes,
includesfile, excludesfile and defaultexcludes, and any number of nested filesets, zipfileset and zipgroupfileset
elements. On top of that it writes a manifest, checks the package versioning entries and can write service files and an
index. The manual says that file permissions are not stored in a JAR. In practice the entries carry fixed default modes
(unzip -Z on a JAR built here shows -rw-r--r-- for files and drwxr-xr-x for directories), so the executable bit of
a source file is not preserved in a JAR; that is why a start script is shipped in a zip or a
tar and not inside a JAR.
The first example builds one JAR for each library module, with Implementation-* entries in the manifest and, for the
engine, a service registration (explained in the next-but-one section):
<target name="library-jars" depends="compile" description="One JAR per library module">
<mkdir dir="${jars.dir}"/>
<jar destfile="${jars.dir}/tidepool-model-${version}.jar" basedir="${classes.dir}"
includes="io/tidepool/model/**">
<manifest>
<attribute name="Implementation-Title" value="tidepool-model"/>
<attribute name="Implementation-Version" value="${version}"/>
</manifest>
</jar>
<jar destfile="${jars.dir}/tidepool-engine-${version}.jar" basedir="${classes.dir}"
includes="io/tidepool/engine/**">
<manifest>
<attribute name="Implementation-Title" value="tidepool-engine"/>
<attribute name="Implementation-Version" value="${version}"/>
</manifest>
<service type="io.tidepool.engine.spi.StationExporter"
provider="io.tidepool.engine.spi.CsvStationExporter"/>
</jar>
</target>
Running it a first time compiles and packages; running it again does nothing, because the JARs are newer than every class file (and the inline manifest has not changed, which the task also checks):
$ ant library-jars
Buildfile: /work/packaging/build.xml
compile:
[mkdir] Created dir: /work/packaging/build/classes
[javac] Compiling 11 source files to /work/packaging/build/classes
[mkdir] Created dir: /work/packaging/build/classes21
[javac] Compiling 1 source file to /work/packaging/build/classes21
library-jars:
[mkdir] Created dir: /work/packaging/build/jars
[jar] Building jar: /work/packaging/build/jars/tidepool-model-1.0.0.jar
[jar] Building jar: /work/packaging/build/jars/tidepool-engine-1.0.0.jar
BUILD SUCCESSFUL
Total time: 0 seconds
$ ant library-jars
Buildfile: /work/packaging/build.xml
compile:
library-jars:
BUILD SUCCESSFUL
Total time: 0 seconds
$ jar tf build/jars/tidepool-engine-1.0.0.jar
META-INF/
META-INF/MANIFEST.MF
META-INF/services/io.tidepool.engine.spi.StationExporter
io/
io/tidepool/
io/tidepool/engine/
io/tidepool/engine/spi/
io/tidepool/engine/StationReader.class
io/tidepool/engine/TideEngine.class
io/tidepool/engine/spi/CsvStationExporter.class
io/tidepool/engine/spi/StationExporter.class
Source: Ant manual: jar
Attributes of jar
The attributes below are all the ones the task accepts; the ones that most often matter are destfile, basedir,
manifest, update, duplicate, index, modificationtime and zip64Mode.
| Attribute | Meaning | Default (since) |
|---|---|---|
|
The JAR to create (required) |
none |
|
Directory to jar; with the nested filesets this is optional |
none |
|
Deflate the entries; |
|
|
For entries copied from existing archives, keep their original compression |
|
|
Character encoding of the entry names |
UTF8 |
|
Store only file entries, no directory entries |
|
|
The implicit fileset |
all files |
|
An external manifest file, or the name of a JAR (added through a fileset) whose manifest is used |
generated |
|
What to do with the manifest of a JAR added through |
|
|
Update an existing JAR instead of overwriting it |
|
|
If only the manifest matches: |
|
|
When the same entry name is found twice: |
|
|
Write |
|
|
Encoding used to read an external manifest (it is always written as UTF-8) |
JVM default |
|
Round modification times up to the next even second (ZIP stores 2-second granularity) |
|
|
Package-versioning errors: |
|
|
Keep a Unix permission field of 0 instead of replacing it with defaults when updating or copying from another archive |
|
|
File-name encoding controls, as on |
|
|
Merge or flatten |
|
|
|
|
|
Set every stored modification time: milliseconds since the epoch or an ISO 8601 timestamp |
none (1.10.2) |
The nested elements are manifest (inline manifest), metainf (a fileset that lands in META-INF, with the manifest
ignored and a warning if it is included), indexjars and indexjarsmapper (1.10.9), service (1.7.0), plus all the
elements of zip such as zipfileset and zipgroupfileset.
Source: Ant manual: jar, parameters
Two things the defaults do wrong
The default duplicate="add" writes a second entry when two filesets contribute the same name. ZIP allows that, but tools
disagree about which entry wins. Two filesets that both select Height.class give a JAR with two entries of that name:
$ jar tf build/more/dup.jar
META-INF/
META-INF/MANIFEST.MF
io/
io/tidepool/
io/tidepool/model/
io/tidepool/model/Height.class
io/tidepool/model/Height.class
Use duplicate="preserve" to keep the first entry, or duplicate="fail" to find out at build time. With fail the build
stops with a message that names the entry:
BUILD FAILED
/work/packaging/errors.xml:9: Duplicate file io/tidepool/cli/Main.class was found and the duplicate attribute is 'fail'.
The second default that hurts is update. With update="true" Ant adds new and newer files, but it never removes an
entry whose source file was deleted, so the archive keeps stale content. The example creates a ZIP with two files, deletes
one of the files and runs the same task again; the deleted file is still in the archive:
$ unzip -l build/more/stale.zip
Archive: build/more/stale.zip
Length Date Time Name
--------- ---------- ----- ----
1 10-16-2026 11:43 a.txt
1 10-16-2026 11:43 b.txt
--------- -------
2 2 files
Without update the archive is rebuilt only when a source file is newer than the archive, so deleting a source file does
not trigger a rebuild either. In a build that matters, delete the archive (or run clean) before packaging; the manual
shows the other idiom, copying an archive without an entry through <zipfileset src="old.jar"> with an exclude.
Manifests
A manifest is a list of Name: value attributes in a main section followed by named sections that carry per-package or
per-file attributes. Ant offers it in three forms: an external file given as manifest="...", an inline <manifest>
element inside jar, war or ear (both are merged when given together), and the stand-alone <manifest> task that
writes a file. The task has the attributes file (required), mode (replace, the default, or update), encoding
(used to read an existing file; the file is always written as UTF-8) and mergeClassPathAttributes and
flattenAttributes (both since 1.8.0). Its nested attribute elements need a name that matches
[A-Za-z0-9][A-Za-z0-9-_]* and a value, and a section element holds attributes for one named section.
<target name="manifest" depends="compile" description="Write a manifest file">
<mkdir dir="${build.dir}/meta"/>
<manifest file="${build.dir}/meta/MANIFEST.MF">
<attribute name="Main-Class" value="io.tidepool.cli.Main"/>
<attribute name="Implementation-Title" value="tidepool-cli"/>
<attribute name="Implementation-Version" value="${version}"/>
<section name="io/tidepool/model/">
<attribute name="Sealed" value="true"/>
</section>
</manifest>
<concat><fileset file="${build.dir}/meta/MANIFEST.MF"/></concat>
</target>
The manifest starts with the two attributes Ant adds on its own: Ant-Version and Created-By. In Ant 1.10.18 on JDK 26
Created-By is the JVM version and vendor string, not "Apache Ant" as older manual examples show. That string differs
between JDKs, which matters for reproducible builds (see below).
$ ant manifest
Buildfile: /work/packaging/build.xml
compile:
manifest:
[mkdir] Created dir: /work/packaging/build/meta
[concat] Manifest-Version: 1.0
[concat] Ant-Version: Apache Ant 1.10.18
[concat] Created-By: 26+35-2893 (Oracle Corporation)
[concat] Main-Class: io.tidepool.cli.Main
[concat] Implementation-Title: tidepool-cli
[concat] Implementation-Version: 1.0.0
[concat]
[concat] Name: io/tidepool/model/
[concat] Sealed: true
[concat]
BUILD SUCCESSFUL
Total time: 0 seconds
The Sealed: true section is the Java manifest way of saying that every class of the package io.tidepool.model must come
from this JAR; the JAR file specification defines it, together with the Specification-* and Implementation-* entries
(Ant’s strict attribute checks them).
Source: Ant manual: manifest
Updating a manifest and the 72-byte rule
With mode="update" the task reads the existing file and merges the new attributes into it; a value for an existing name
replaces the old one:
<manifest file="build/extras/MANIFEST.MF">
<attribute name="Main-Class" value="io.tidepool.cli.Main"/>
</manifest>
<manifest file="build/extras/MANIFEST.MF" mode="update">
<attribute name="Implementation-Version" value="1.0.0"/>
<attribute name="Main-Class" value="io.tidepool.cli.Probe"/>
</manifest>
[concat] Manifest-Version: 1.0
[concat] Ant-Version: Apache Ant 1.10.18
[concat] Created-By: 26+35-2893 (Oracle Corporation)
[concat] Main-Class: io.tidepool.cli.Probe
[concat] Implementation-Version: 1.0.0
A manifest line is limited to 72 bytes, and longer lines are wrapped with a continuation line that starts with one space.
Ant does this correctly, and the manual says that most complaints about "invalid manifests" come from third-party readers
that do not follow the specification. The wrapped Class-Path below is therefore not a bug; a reader must join the lines
and drop the leading space:
Class-Path: commons-codec-1.19.0.jar commons-csv-1.14.1.jar commons-io
-2.20.0.jar picocli-4.7.7.jar tidepool-engine-1.0.0.jar tidepool-mode
l-1.0.0.jar
Source: Ant manual: manifest
Source: JAR File Specification
Merging manifests: filesetmanifest
When a zipfileset or zipgroupfileset brings in another JAR, its manifest is ignored (filesetmanifest="skip"). With
merge all those manifests are merged into the result. That is rarely what an uber JAR wants, and it can be harmful: the
example merges only the manifest of Commons CSV into a JAR, and the result inherits the dependency’s Multi-Release: true,
its Bundle-* and Import-Package entries and its Created-By, which then overwrites the one Ant wrote:
<jar destfile="build/extras/merged.jar" filesetmanifest="merge">
<zipfileset src="lib/commons-csv-1.14.1.jar" includes="META-INF/MANIFEST.MF"/>
<manifest>
<attribute name="Main-Class" value="io.tidepool.cli.Main"/>
</manifest>
</jar>
[concat] Manifest-Version: 1.0
[concat] Ant-Version: Apache Ant 1.10.18
[concat] Created-By: Apache Maven Bundle Plugin 6.0.0
[concat] Build-Jdk-Spec: 21
[concat] Specification-Title: Apache Commons CSV
[concat] Implementation-Title: Apache Commons CSV
[concat] Bundle-Name: Apache Commons CSV
[concat] Multi-Release: true
[concat] Main-Class: io.tidepool.cli.Main
(The output is shortened to six of the inherited entries.) Leave filesetmanifest at skip unless you want the
dependency’s metadata, and use mergewithoutmain if you want everything except the main section.
Source: Ant manual: jar, filesetmanifest
A runnable JAR with a Class-Path
java -jar needs a Main-Class attribute. Without one the JVM stops with a message from the launcher, not from Ant, so
the build is green and the JAR is useless:
$ java -jar build/jars/tidepool-model-1.0.0.jar
no main manifest attribute, in build/jars/tidepool-model-1.0.0.jar
The classes of the dependencies must also be reachable. A "thin" runnable JAR lists them in its Class-Path attribute,
relative to the JAR’s own location. <manifestclasspath> computes that value from a path, so the build file does not
repeat the file names. The task needs property (which must not be set yet; properties are immutable) and jarfile (the
JAR that will contain the manifest; it need not exist, its directory must), and maxParentLevels (default 2) limits how
many .. the relative paths may contain. Calling it for a JAR in the same directory as the libraries gives bare file names.
<target name="cli-jar" depends="library-jars" description="Runnable thin JAR with Class-Path">
<copy todir="${stage.dir}/lib" flatten="true">
<fileset dir="${jars.dir}" includes="tidepool-model-*.jar tidepool-engine-*.jar"/>
<path refid="lib.path"/>
</copy>
<manifestclasspath property="cli.classpath" jarfile="${stage.dir}/lib/tidepool-cli-${version}.jar">
<classpath>
<fileset dir="${stage.dir}/lib" includes="*.jar" excludes="tidepool-cli-*.jar"/>
</classpath>
</manifestclasspath>
<jar destfile="${stage.dir}/lib/tidepool-cli-${version}.jar" basedir="${classes.dir}"
includes="io/tidepool/cli/**" excludes="io/tidepool/cli/Probe.class,io/tidepool/cli/RuntimeInfo.class">
<manifest>
<attribute name="Main-Class" value="io.tidepool.cli.Main"/>
<attribute name="Class-Path" value="${cli.classpath}"/>
<attribute name="Implementation-Title" value="tidepool-cli"/>
<attribute name="Implementation-Version" value="${version}"/>
</manifest>
</jar>
</target>
The excludes on the class-path fileset is not decoration. On the second run the directory already contains the CLI JAR;
without the exclusion it would list itself in its own Class-Path, the inline manifest would differ from the one inside
the existing JAR, and the task would rebuild the JAR on every run. The manifest of the result is the one printed above;
the JAR runs from its directory:
$ java -Duser.language=en -Duser.country=US -jar build/stage/tidepool-1.0.0/lib/tidepool-cli-1.0.0.jar --version
tidepool 1.0.0
$ java -Duser.language=en -Duser.country=US -jar build/stage/tidepool-1.0.0/lib/tidepool-cli-1.0.0.jar data/stations.csv BOS
Boston: -0.662 m
Copy the JAR alone to another directory and it fails when picocli is first needed. The JVM ignores Class-Path entries
whose files it cannot find, without a warning, so the symptom is a NoClassDefFoundError and not a message about the
manifest:
$ java -jar build/lone/tidepool-cli-1.0.0.jar data/stations.csv SF
Exception in thread "main" java.lang.NoClassDefFoundError: picocli/CommandLine
at io.tidepool.cli.Main.main(Unknown Source)
Caused by: java.lang.ClassNotFoundException: picocli.CommandLine
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:580)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:502)
Source: Ant manual: manifestclasspath
An uber JAR
A single JAR that contains the application and every dependency starts with one file and needs no Class-Path. In Ant it
is a jar with the application classes as a fileset and the libraries as a zipgroupfileset, which merges every JAR found
in a directory the way a zipfileset src=... merges one:
<target name="uber-jar" depends="library-jars" description="Single JAR with all dependencies merged">
<mkdir dir="${dist.dir}"/>
<jar destfile="${dist.dir}/tidepool-cli-${version}-all.jar" duplicate="preserve">
<fileset dir="${classes.dir}" excludes="io/tidepool/cli/Probe.class,io/tidepool/cli/RuntimeInfo.class"/>
<zipgroupfileset dir="lib" includes="*.jar"/>
<manifest>
<attribute name="Main-Class" value="io.tidepool.cli.Main"/>
</manifest>
</jar>
</target>
Three of the four libraries carry META-INF/LICENSE.txt and META-INF/NOTICE.txt, and all four a multi-release
META-INF/versions/9/module-info.class, so with duplicate="preserve" Ant keeps the first and reports each skipped one:
$ ant uber-jar
Buildfile: /work/packaging/build.xml
compile:
library-jars:
uber-jar:
[jar] Building jar: /work/packaging/build/dist/tidepool-cli-1.0.0-all.jar
[jar] META-INF/LICENSE.txt already added, skipping
[jar] META-INF/NOTICE.txt already added, skipping
[jar] META-INF/versions/9/module-info.class already added, skipping
[jar] META-INF/LICENSE.txt already added, skipping
[jar] META-INF/NOTICE.txt already added, skipping
[jar] META-INF/versions/9/module-info.class already added, skipping
[jar] META-INF/versions/9/module-info.class already added, skipping
BUILD SUCCESSFUL
Total time: 0 seconds
$ java -Duser.language=en -Duser.country=US -jar build/dist/tidepool-cli-1.0.0-all.jar data/stations.csv HNL
Honolulu: -0.112 m
That output is the cost of a fat JAR in one screen. The merged archive keeps one license file instead of three, so the
legal notices of the other libraries are lost; the stray module-info.class files of whichever library came first are
kept (harmless here because the JAR is not multi-release, but misleading); and the merged archive has no information about
which library each class came from. Version conflicts are resolved by whichever JAR is listed first. When a dependency is
signed, its META-INF/*.SF, .DSA and .RSA files are copied too, and the JVM then rejects the merged JAR with a
signature error because the digests no longer match, so the signature files must be left out.
The manual’s other merging idiom, a restrict over archives, lets you filter the merged entries by name. This
variant leaves out the signature files, the dependencies' manifests and the multi-release directory:
<jar destfile="build/more/all-clean.jar" duplicate="preserve">
<fileset dir="build/classes" excludes="io/tidepool/cli/Probe.class,io/tidepool/cli/RuntimeInfo.class"/>
<restrict>
<not>
<or>
<name name="META-INF/*.SF"/>
<name name="META-INF/*.DSA"/>
<name name="META-INF/*.RSA"/>
<name name="META-INF/MANIFEST.MF"/>
<name name="META-INF/versions/**"/>
</or>
</not>
<archives>
<zips>
<fileset dir="lib" includes="*.jar"/>
</zips>
</archives>
</restrict>
<manifest>
<attribute name="Main-Class" value="io.tidepool.cli.Main"/>
</manifest>
</jar>
Run on the same libraries it skips only the license files; no .SF, .RSA, versions or module-info.class entry reaches
the result:
[jar] Building jar: /work/packaging/build/more/all-clean.jar
[jar] META-INF/LICENSE.txt already added, skipping
[jar] META-INF/NOTICE.txt already added, skipping
[jar] META-INF/LICENSE.txt already added, skipping
[jar] META-INF/NOTICE.txt already added, skipping
Merging works for libraries that are plain class files. It cannot merge files that several libraries own under the same
name and that must be concatenated, such as META-INF/services/* files or reference.conf; for those the other tools
have transformers (the shade plugin, the Gradle Shadow plugin), and in Ant you have to write the merged file yourself.
The alternative that avoids the problem is the thin JAR above with a lib directory in a distribution (below).
Source: Ant manual: zip, zipgroupfileset
Service providers
Java finds implementations of a service interface through java.util.ServiceLoader, which reads files named
META-INF/services/<interface name> from every JAR on the class path. The nested <service> element of jar (since
1.7.0) writes that file, so the build file declares the provider next to the classes instead of keeping a hand-written
resource. type is the interface and provider the implementation; for several implementations nest
<provider classname="..."/> elements:
<jar destfile="pinkyandbrain.jar">
<fileset dir="classes"/>
<service type="javax.script.ScriptEngineFactory">
<provider classname="org.acme.PinkyLanguage"/>
<provider classname="org.acme.BrainLanguage"/>
</service>
</jar>
Source: Ant manual: jar, service
The engine JAR built in the library-jars target above carries one provider. The file contains just the class name:
$ unzip -p build/jars/tidepool-engine-1.0.0.jar META-INF/services/io.tidepool.engine.spi.StationExporter
io.tidepool.engine.spi.CsvStationExporter
If a library is merged into an uber JAR, the service files of different JARs that name the same interface collide and the first one wins; this is the case in which an uber JAR built by merging silently drops providers.
Multi-release JARs
A multi-release JAR (JEP 238, Java 9) holds the default classes at the top and, for newer Java releases, replacement
classes under META-INF/versions/<n>/. The JVM uses the highest version directory that is not newer than itself. A
multi-release JAR is marked by the manifest entry Multi-Release: true; Ant needs no special support for it, it is a
layout that you build with a zipfileset that has a prefix. The example compiles RuntimeInfo twice (the Java 17
baseline and a Java 21 version, in the compile target above) and puts the second under META-INF/versions/21/:
<target name="mrjar" depends="compile" description="Multi-release JAR (JEP 238)">
<mkdir dir="${dist.dir}"/>
<jar destfile="${dist.dir}/tidepool-probe.jar">
<manifest>
<attribute name="Main-Class" value="io.tidepool.cli.Probe"/>
<attribute name="Multi-Release" value="true"/>
</manifest>
<fileset dir="${classes.dir}"/>
<zipfileset dir="${classes21.dir}" prefix="META-INF/versions/21/"/>
<service type="io.tidepool.engine.spi.StationExporter"
provider="io.tidepool.engine.spi.CsvStationExporter"/>
</jar>
</target>
The JAR has two RuntimeInfo.class entries at different paths. The same file runs a different class on a different JVM
(JDK 17 and 21 are the JetBrains Runtime builds installed on the machine):
$ jar tf build/dist/tidepool-probe.jar | grep RuntimeInfo
io/tidepool/cli/RuntimeInfo.class
META-INF/versions/21/io/tidepool/cli/RuntimeInfo.class
$ java -jar build/dist/tidepool-probe.jar # JDK 26
RuntimeInfo: versioned classes (Java 21+)
Exporter: csv
$ jdk-21/bin/java -jar build/dist/tidepool-probe.jar
RuntimeInfo: versioned classes (Java 21+)
Exporter: csv
$ jdk-17/bin/java -jar build/dist/tidepool-probe.jar
RuntimeInfo: baseline classes (Java 17)
Exporter: csv
The versioned class must be compiled for a release that is not newer than its directory (here release="21" for
versions/21), and it must keep the public API of the baseline class: the JAR specification and the JDK tools require
that. Ant does not check this, and it does not remove versioned classes that are identical to the baseline (the manual
mentions that this "tuning" needs more work than the simple layout).
Source: Ant manual: jar, JEP 238 example
Source: JEP 238: Multi-Release JAR Files
Indexing JARs: index and indexjars
index="true" makes jar write META-INF/INDEX.LIST, a table that maps packages to the JAR files of a class path so that
a class loader can skip JARs that cannot contain a class. Unless indexjars names more archives, only the JAR being built
is indexed. The nested indexjars element is a path; the names in the index come from the manifest’s Class-Path when
there is one (Ant matches the entries to the JARs of the path) and from the bare file names otherwise. The element
indexjarsmapper (since 1.10.9) rewrites the names when that guess is wrong. Archives that are empty or contain only
META-INF files are left out unless indexMetaInf is set.
<jar destfile="build/extras/indexed.jar" basedir="build/classes" index="true">
<indexjars>
<fileset dir="lib" includes="*.jar"/>
</indexjars>
<manifest>
<attribute name="Class-Path" value="commons-csv-1.14.1.jar picocli-4.7.7.jar"/>
</manifest>
</jar>
The index lists the JAR itself and the two JARs named in Class-Path (Commons IO and Codec are in the lib path, but not
in the Class-Path, so they are not indexed):
JarIndex-Version: 1.0
indexed.jar
io
io/tidepool
io/tidepool/cli
io/tidepool/engine
io/tidepool/engine/spi
io/tidepool/model
commons-csv-1.14.1.jar
org
org/apache
org/apache/commons
org/apache/commons/csv
picocli-4.7.7.jar
picocli
An index is an old optimisation for applets and early application servers. This page verified only that the file is written as described; no speed-up was measured on JDK 26, so only use an index if a consumer you control is known to read it.
Source: Ant manual: jar, indexjars
War and ear
<war> is a jar for a web application. Its nested elements place files in the right directory of the layout: lib
under WEB-INF/lib, classes under WEB-INF/classes, webinf under WEB-INF (a web.xml found there is ignored with a
warning), and metainf under META-INF; every other fileset lands at the root of the archive and zipfileset with a
prefix can place files anywhere. webxml names the deployment descriptor. Before Servlet 2.5 the descriptor was
mandatory; today the task still refuses to build a WAR without one unless you say so with needxmlfile="false".
<ear> is the same for an enterprise application: appxml names META-INF/application.xml and is required unless
update is true. The remaining attributes are those of jar; webxml, needxmlfile (since 1.7) and appxml are the
additions, and war defaults zip64Mode to never like jar.
<target name="war" depends="library-jars" description="Web archive and enterprise archive">
<mkdir dir="${dist.dir}"/>
<war destfile="${dist.dir}/tidepool-web.war" webxml="webapp/web.xml">
<fileset dir="webapp/web"/>
<lib dir="${jars.dir}" includes="tidepool-model-*.jar tidepool-engine-*.jar"/>
<lib dir="lib" includes="commons-*.jar"/>
<classes dir="${classes.dir}" includes="io/tidepool/cli/RuntimeInfo.class"/>
<webinf dir="data" includes="stations.csv"/>
</war>
<ear destfile="${dist.dir}/tidepool.ear" appxml="ear-meta/application.xml">
<fileset dir="${dist.dir}" includes="tidepool-web.war"/>
</ear>
</target>
<target name="war-no-descriptor" depends="library-jars" description="Servlet 3+ WAR without web.xml">
<war destfile="${dist.dir}/tidepool-lite.war" needxmlfile="false">
<fileset dir="webapp/web"/>
</war>
</target>
The descriptors are the minimum that each container format accepts (Jakarta EE namespaces; the content is not deployed to a server in this page):
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee" version="6.0">
<display-name>tidepool-web</display-name>
</web-app>
<?xml version="1.0" encoding="UTF-8"?>
<application xmlns="https://jakarta.ee/xml/ns/jakartaee" version="10">
<display-name>tidepool</display-name>
<module>
<web>
<web-uri>tidepool-web.war</web-uri>
<context-root>/tidepool</context-root>
</web>
</module>
</application>
The layout of the three archives, as written by Ant:
$ unzip -l build/dist/tidepool-web.war
Archive: build/dist/tidepool-web.war
Length Date Time Name
--------- ---------- ----- ----
0 10-16-2026 11:44 META-INF/
103 10-16-2026 11:44 META-INF/MANIFEST.MF
0 10-16-2026 11:35 WEB-INF/
162 10-16-2026 11:35 WEB-INF/web.xml
44 10-16-2026 11:35 index.html
0 10-16-2026 11:44 WEB-INF/lib/
3809 10-16-2026 11:44 WEB-INF/lib/tidepool-engine-1.0.0.jar
3758 10-16-2026 11:44 WEB-INF/lib/tidepool-model-1.0.0.jar
374716 10-16-2026 11:34 WEB-INF/lib/commons-codec-1.19.0.jar
60964 10-16-2026 11:33 WEB-INF/lib/commons-csv-1.14.1.jar
563971 10-16-2026 11:34 WEB-INF/lib/commons-io-2.20.0.jar
0 10-16-2026 11:44 WEB-INF/classes/
0 10-16-2026 11:44 WEB-INF/classes/io/
0 10-16-2026 11:44 WEB-INF/classes/io/tidepool/
0 10-16-2026 11:44 WEB-INF/classes/io/tidepool/cli/
237 10-16-2026 11:44 WEB-INF/classes/io/tidepool/cli/RuntimeInfo.class
239 10-16-2026 11:38 WEB-INF/stations.csv
--------- -------
1008003 17 files
$ unzip -l build/dist/tidepool.ear
Archive: build/dist/tidepool.ear
Length Date Time Name
--------- ---------- ----- ----
0 10-16-2026 11:44 META-INF/
103 10-16-2026 11:44 META-INF/MANIFEST.MF
296 10-16-2026 11:35 META-INF/application.xml
879585 10-16-2026 11:44 tidepool-web.war
--------- -------
879984 4 files
$ unzip -l build/dist/tidepool-lite.war
Archive: build/dist/tidepool-lite.war
Length Date Time Name
--------- ---------- ----- ----
0 10-16-2026 11:44 META-INF/
103 10-16-2026 11:44 META-INF/MANIFEST.MF
44 10-16-2026 11:35 index.html
--------- -------
147 3 files
(The times show that Ant keeps the modification time of each source file inside the archive; the manifest and directory entries get the time of the build.)
Forgetting the descriptor is the classic failure, and the message names the way out:
BUILD FAILED
/work/packaging/errors.xml:3: No WEB-INF/web.xml file was added.
If this is your intent, set needxmlfile='false'
The manual keeps a standing answer to a frequent bug report: the WAR shows web-inf in lower case when opened with
WinZip, because that program lowercases all-uppercase names by default; jar tf shows the real name. A deployed web
application has a start script neither in the WAR nor in the EAR; to deploy it, copy the archive to the application server
(see Network & Remote Tasks for the
copy-over-SSH tasks). For JSP precompilation before packaging, see
Jasper Configuration & Precompilation.
Source: Ant manual: war
Source: Ant manual: ear
The zip task
<zip> is the base task. It differs from jar in four defaults: no manifest, the file-name encoding is the JVM default
(jar uses UTF-8), zip64Mode is as-needed (jar uses never), and an empty selection skips the archive instead of
creating one. Its extra attributes are whenempty (skip, fail or create), comment (since 1.6.3) and the encoding
group below; the attributes shared with jar behave identically. The empty-selection default is a soft failure: the build
continues, and a missing archive shows up in a later step:
[zip] Warning: skipping zip archive /work/packaging/build/empty.zip because no files were included.
zipfileset: prefix, fullpath, permissions and src
The zipfileset element is a fileset with attributes that change how the entries are stored: prefix puts the files under
a directory in the archive, fullpath stores a single file under another name, src reads the files from an existing
archive instead of a directory, and filemode and dirmode set the Unix permissions of entries (since 1.5.2; by default
files are 644 and directories 755). Ant uses the algorithm of Info-Zip to store the mode, which is the only de facto
standard. The manual’s example puts three sources in one archive:
<zip destfile="${dist}/manual.zip">
<zipfileset dir="htdocs/manual" prefix="docs/user-guide"/>
<zipfileset dir="." includes="ChangeLog27.txt" fullpath="docs/ChangeLog.txt"/>
<zipfileset src="examples.zip" includes="**/*.html" prefix="docs/examples"/>
</zip>
tarfileset is the same for tar sources, so <zip><tarfileset src="release.tar"/></zip> repackages a tar file as a ZIP and
keeps the permissions. The element zipgroupfileset (used for the uber JAR above) is a fileset of archives whose contents
are merged at the root.
Source: Ant manual: zipfileset
Source: Ant manual: zip, zipgroupfileset
File names: encoding, language flag and Unicode extra fields
The ZIP format traditionally encodes names in CP437, and tools have invented several ways around that. Ant’s encoding
attribute is the encoding of the names; when it is UTF-8 Ant also sets the language encoding flag (attribute
useLanguageEncodingFlag, default true, since 1.8.0). createUnicodeExtraFields (never, always or not-encodeable)
writes the name a second time as an Info-Zip extra field, and fallbacktoUTF8 switches an entry to UTF-8 when its name
cannot be encoded in the chosen encoding. The manual’s recommendation: for JARs, UTF-8 with the flag; for maximum
interoperability, UTF-8, the flag and Unicode extra fields; for Windows "compressed folders", the platform encoding. A
ZIP that holds the name données/mareas.txt was built both ways on the test machine, and the JDK’s own jar tf read both
correctly because the platform encoding is UTF-8; the difference appears on a machine whose default encoding is not UTF-8,
which was not tested here.
Zip64 and the 2-second clock
Zip64 extensions lift the limits of 4 GB per archive or entry and 65535 entries. The attribute zip64Mode (since 1.9.1)
has three values: never writes no Zip64 data and is the default of jar, war and ear; always writes it for every
entry; as-needed (the default of zip) writes the extra field to each compressed entry’s local header but to the
central directory only when an entry needs it. Java 5’s java.util.zip cannot read archives with Zip64 fields in the local
headers, which is why the JAR tasks default to never; Java 6 and later read as-needed archives. ZIP also stores
modification times in a 2-second resolution, and roundup (default true, since 1.6.2) rounds up so that the archive does
not look out of date on the next run.
The tar task
<tar> creates a tar file from an implicit fileset, nested resource collections and tarfileset elements, which add
mode/filemode, dirmode, username, group, uid, gid and preserveLeadingSlashes to a fileset. By default
files get 644 and directories 755. The task never appends to an existing tar and does not check whether two collections
select the same file: the file is then stored twice. The attributes are destfile and basedir, the selection attributes,
compression (none, gzip, bzip2, xz), encoding (since 1.9.5, a single-byte encoding is advisable) and
longfile.
Names longer than 100 characters are the reason for longfile: fail, truncate and omit keep the archive readable by
every tar but lose data or stop; gnu writes GNU extension headers and posix PAX extension headers, which every modern
tar reads. The default warn behaves like gnu but logs a warning for each long name. The manual recommends fail or
posix for best interoperability, so the distribution below uses posix.
Compression formats
The compression attribute covers the formats you need most; bzip2 is built in, while xz needs the XZ for Java
library on Ant’s class path, and the task says so when it is missing:
BUILD FAILED
/work/packaging/more.xml:8: xz compression requires the XZ for Java library
Adding xz-1.12.jar (from Maven Central, org.tukaani:xz:1.12) with ant -lib extra-lib makes the task work. A failed
archive task may leave a zero-byte file behind: the build above left an empty data.tar.xz, and after the library was
added the next run reported Nothing to do: ... is up to date instead of rebuilding it. Delete the target after a failed
build, or put the delete into the target. After a clean run, the sizes of the same four files (the single file stations.csv
plus the tar block padding) were:
$ ant -lib extra-lib -f more.xml tar-compression
tar-compression:
[mkdir] Created dir: /work/packaging/build/more
[tar] Building tar: /work/packaging/build/more/data.tar.bz2
[tar] Building tar: /work/packaging/build/more/data.tar.xz
[tar] Building tar: /work/packaging/build/more/data.tar.gz
[tar] Building tar: /work/packaging/build/more/data.tar
[exec] -rw-r--r--@ 1 albertoirurueta wheel 10240 Oct 16 11:43 data.tar
[exec] -rw-r--r--@ 1 albertoirurueta wheel 289 Oct 16 11:43 data.tar.bz2
[exec] -rw-r--r--@ 1 albertoirurueta wheel 289 Oct 16 11:43 data.tar.gz
[exec] -rw-r--r--@ 1 albertoirurueta wheel 348 Oct 16 11:43 data.tar.xz
The sizes show a property of tiny inputs only (the xz container overhead is larger than the savings), not a ranking of
the formats. For a stand-alone file there are also <gzip> and <bzip2> tasks, and <gunzip>/<bunzip2> to undo them;
the manual’s old two-step idiom <tar> then <gzip> still works, but compression="gzip" does it in one.
Source: Ant manual: tar
Source: Ant manual: gzip
Packaging the tidepool CLI as a distribution
The user-facing result of the build is a directory that can be unpacked anywhere and started: bin/tidepool, the CLI JAR
and its libraries in lib/, and the sample data. The dist target stages that directory, then packs it as a ZIP and a
tar.gz. The start script is kept in the project (src-dist/bin/tidepool) and copied, so the target can be skipped when
nothing changed; generating it with <echo file=...> would rewrite the file on every run and defeat the up-to-date
checks.
#!/bin/sh
# Launcher: run the CLI from the unpacked distribution.
HERE="$(cd "$(dirname "$0")" && pwd)"
exec java -jar "$HERE/../lib/tidepool-cli-1.0.0.jar" "$@"
<target name="dist" depends="cli-jar" description="Zip and tar.gz distributions">
<copy todir="${stage.dir}/bin" file="src-dist/bin/tidepool"/>
<copy file="data/stations.csv" todir="${stage.dir}/data"/>
<mkdir dir="${dist.dir}"/>
<zip destfile="${dist.dir}/tidepool-${version}.zip">
<zipfileset dir="${stage.dir}/.." includes="tidepool-${version}/**" excludes="tidepool-${version}/bin/**"/>
<zipfileset dir="${stage.dir}/.." includes="tidepool-${version}/bin/**" filemode="755"/>
</zip>
<tar destfile="${dist.dir}/tidepool-${version}.tar.gz" compression="gzip" longfile="posix">
<tarfileset dir="${stage.dir}/.." includes="tidepool-${version}/**" excludes="tidepool-${version}/bin/**"/>
<tarfileset dir="${stage.dir}/.." includes="tidepool-${version}/bin/**" filemode="755"/>
</tar>
</target>
The two filesets per archive are the manual’s own idiom for per-file modes, and the second must be the exact
complement of the first, or a file is stored twice. The staged directory is used as the base (${stage.dir}/..) so that
every entry starts with tidepool-1.0.0/, a single top-level directory that is polite to users who unpack the archive in
the current directory. The copy task does not carry the executable bit, and jar and zip do not keep permissions by
default, which is why the mode is set explicitly.
$ ant dist
Buildfile: /work/packaging/build.xml
compile:
[mkdir] Created dir: /work/packaging/build/classes
[javac] Compiling 11 source files to /work/packaging/build/classes
[mkdir] Created dir: /work/packaging/build/classes21
[javac] Compiling 1 source file to /work/packaging/build/classes21
library-jars:
[mkdir] Created dir: /work/packaging/build/jars
[jar] Building jar: /work/packaging/build/jars/tidepool-model-1.0.0.jar
[jar] Building jar: /work/packaging/build/jars/tidepool-engine-1.0.0.jar
cli-jar:
[copy] Copying 6 files to /work/packaging/build/stage/tidepool-1.0.0/lib
[jar] Building jar: /work/packaging/build/stage/tidepool-1.0.0/lib/tidepool-cli-1.0.0.jar
dist:
[copy] Copying 1 file to /work/packaging/build/stage/tidepool-1.0.0/bin
[copy] Copying 1 file to /work/packaging/build/stage/tidepool-1.0.0/data
[mkdir] Created dir: /work/packaging/build/dist
[zip] Building zip: /work/packaging/build/dist/tidepool-1.0.0.zip
[tar] Building tar: /work/packaging/build/dist/tidepool-1.0.0.tar.gz
BUILD SUCCESSFUL
Total time: 0 seconds
The ZIP stores the permission bits and so does the tar; the start script is the only executable, and the tar entries have owner and group 0:
$ unzip -Z build/dist/tidepool-1.0.0.zip
Archive: build/dist/tidepool-1.0.0.zip
Zip file size: 1268375 bytes, number of entries: 13
drwxr-xr-x 2.0 unx 0 b- stor 26-Oct-16 11:44 tidepool-1.0.0/
drwxr-xr-x 2.0 unx 0 b- stor 26-Oct-16 11:44 tidepool-1.0.0/data/
drwxr-xr-x 2.0 unx 0 b- stor 26-Oct-16 11:44 tidepool-1.0.0/lib/
-rw-r--r-- 2.0 unx 239 b- defN 26-Oct-16 11:44 tidepool-1.0.0/data/stations.csv
-rw-r--r-- 2.0 unx 374716 b- defN 26-Oct-16 11:44 tidepool-1.0.0/lib/commons-codec-1.19.0.jar
-rw-r--r-- 2.0 unx 60964 b- defN 26-Oct-16 11:44 tidepool-1.0.0/lib/commons-csv-1.14.1.jar
-rw-r--r-- 2.0 unx 563971 b- defN 26-Oct-16 11:44 tidepool-1.0.0/lib/commons-io-2.20.0.jar
-rw-r--r-- 2.0 unx 417640 b- defN 26-Oct-16 11:44 tidepool-1.0.0/lib/picocli-4.7.7.jar
-rw-r--r-- 2.0 unx 2540 b- defN 26-Oct-16 11:44 tidepool-1.0.0/lib/tidepool-cli-1.0.0.jar
-rw-r--r-- 2.0 unx 3809 b- defN 26-Oct-16 11:44 tidepool-1.0.0/lib/tidepool-engine-1.0.0.jar
-rw-r--r-- 2.0 unx 3758 b- defN 26-Oct-16 11:44 tidepool-1.0.0/lib/tidepool-model-1.0.0.jar
drwxr-xr-x 2.0 unx 0 b- stor 26-Oct-16 11:44 tidepool-1.0.0/bin/
-rwxr-xr-x 2.0 unx 162 b- defN 26-Oct-16 11:44 tidepool-1.0.0/bin/tidepool
13 files, 1427799 bytes uncompressed, 1266345 bytes compressed: 11.3%
$ tar tzvf build/dist/tidepool-1.0.0.tar.gz
drwxr-xr-x 0 0 0 0 Oct 16 11:44 tidepool-1.0.0/
drwxr-xr-x 0 0 0 0 Oct 16 11:44 tidepool-1.0.0/data/
drwxr-xr-x 0 0 0 0 Oct 16 11:44 tidepool-1.0.0/lib/
-rw-r--r-- 0 0 0 239 Oct 16 11:44 tidepool-1.0.0/data/stations.csv
...
drwxr-xr-x 0 0 0 0 Oct 16 11:44 tidepool-1.0.0/bin/
-rwxr-xr-x 0 0 0 162 Oct 16 11:44 tidepool-1.0.0/bin/tidepool
The listing is shortened with ...; the omitted lines are the seven JARs under lib/, as in the ZIP. The real test of a
distribution is to unpack it somewhere else and run it:
$ mkdir build/try && tar xzf build/dist/tidepool-1.0.0.tar.gz -C build/try
$ export JAVA_TOOL_OPTIONS="-Duser.language=en -Duser.country=US"
$ build/try/tidepool-1.0.0/bin/tidepool build/try/tidepool-1.0.0/data/stations.csv SF
Picked up JAVA_TOOL_OPTIONS: -Duser.language=en -Duser.country=US
San Francisco: -0.869 m
$ build/try/tidepool-1.0.0/bin/tidepool build/try/tidepool-1.0.0/data/stations.csv XX
Picked up JAVA_TOOL_OPTIONS: -Duser.language=en -Duser.country=US
Unknown station: XX
$ echo $?
1
(The Picked up line is printed by the JVM to the error stream and is only there for the decimal point; the second command
exits with status 1, the CLI’s own error code.)
Running ant dist a second time prints nothing for the JARs and the ZIP, and Nothing to do: ... is up to date for the
tar; touching a source file brings the whole chain back (javac, the model JAR, the copy into lib, a new ZIP and a new
tar.gz). The checks are timestamp-based, so a task that always rewrites its output, such as echo file=, or a manifest
that changes between runs, such as one that lists the archive itself in Class-Path, makes the targets that follow
always run.
Source: Ant manual: tarfileset
Source: Ant manual: tar
Extracting archives
<unzip> (also <unjar> and <unwar>, which are the same task) and <untar> extract an archive into dest. A
nested patternset selects entries, a mapper renames them (the identity mapper is the default), overwrite defaults to
true, and src can be replaced by nested resource collections. untar has compression (none, gzip, xz,
bzip2). Permissions are not restored on extracted files by unzip. The attribute stripAbsolutePathSpec (default true
since 1.10.4; it used to be false) removes a leading / or \ from entry names, and allowFilesToEscapeDest (since
1.10.4, default false) refuses entries such as ../x that would be written outside dest; these are the protections
against the "zip slip" class of archives. Do not set either to a looser value when extracting archives that you did not
build.
<?xml version="1.0" encoding="UTF-8"?>
<project name="extract" default="extract" basedir=".">
<target name="extract">
<delete dir="build/unpacked"/>
<unzip src="build/dist/tidepool-1.0.0.zip" dest="build/unpacked/zip">
<patternset>
<include name="tidepool-1.0.0/lib/tidepool-*.jar"/>
</patternset>
<mapper type="flatten"/>
</unzip>
<untar src="build/dist/tidepool-1.0.0.tar.gz" dest="build/unpacked/tar" compression="gzip">
<patternset>
<include name="tidepool-1.0.0/data/**"/>
</patternset>
</untar>
<unjar src="build/dist/tidepool-probe.jar" dest="build/unpacked/jar">
<patternset>
<include name="META-INF/**"/>
</patternset>
</unjar>
<!-- "delete" an entry: copy an archive without it -->
<zip destfile="build/unpacked/slim.jar">
<zipfileset src="build/dist/tidepool-probe.jar">
<exclude name="io/tidepool/cli/Probe.class"/>
</zipfileset>
</zip>
</target>
</project>
The flatten mapper drops the directory part, so the three JARs end up side by side; the tar keeps its directory. The
slim JAR is a copy of the probe JAR without the Probe class:
$ ant -f extract.xml
$ find build/unpacked -type f | sort
build/unpacked/jar/META-INF/MANIFEST.MF
build/unpacked/jar/META-INF/services/io.tidepool.engine.spi.StationExporter
build/unpacked/jar/META-INF/versions/21/io/tidepool/cli/RuntimeInfo.class
build/unpacked/slim.jar
build/unpacked/tar/tidepool-1.0.0/data/stations.csv
build/unpacked/zip/tidepool-cli-1.0.0.jar
build/unpacked/zip/tidepool-engine-1.0.0.jar
build/unpacked/zip/tidepool-model-1.0.0.jar
unzip with a patternset and a mapper is equivalent to a copy with a zipfileset src and the same mapper; use the copy
form when you also need filtering or several destinations.
Source: Ant manual: unzip
Signing and verifying JARs
Signing proves who published a JAR and that it was not changed; it is done with the JDK jarsigner tool, which Ant calls
(the executable attribute selects another one). Three tasks cover the cycle: genkey creates a key pair in a keystore,
signjar signs, verifyjar checks. Ant passes the store password to jarsigner when it prompts for it, not on the command
line, for signing. For verification it must be passed on the command line, where other users of the machine can see it,
which is why verifyjar documents storepass as optional.
The attributes of signjar are: jar (or a nested path, fileset), alias, storepass (all required),
keystore, storetype, keypass, sigfile, signedjar (only with jar) or destDir, verbose, strict (1.9.1),
internalsf, sectionsonly, lazy (sign only when not already signed by this alias, when the target is the same file),
force (1.8.0), maxmemory, preservelastmodified, executable (1.8.0), sigalg, digestalg, tsadigestalg (1.10.2),
the timestamp-authority group tsaurl, tsacert, tsaproxyhost and tsaproxyport, and the provider group providername,
providerclass and providerarg (1.10.6), plus nested mapper, sysproperty and arg (1.10.6). genkey takes
alias, storepass (at least six characters), keystore, storetype, keypass, sigalg, keyalg, keysize,
validity, verbose, saname and dname (or nested <dname> with <param> children, which Ant escapes). verifyjar
takes jar, alias, storepass, keystore, storetype, keypass, certificates, verbose, strict, maxmemory,
executable and the provider group.
The example generates a throw-away development key (never use it to sign anything that is released), signs the model JAR
and verifies it. The password comes from an environment variable; the check-env target stops the build when it is missing.
<property environment="env"/>
<property name="keystore" location="${build.dir}/sign/dev.p12"/>
<target name="check-env">
<fail unless="env.KEYSTORE_PASS" message="Set the KEYSTORE_PASS environment variable first"/>
</target>
<target name="genkey" depends="check-env" description="Create a throw-away development key">
<mkdir dir="${build.dir}/sign"/>
<delete file="${keystore}"/>
<genkey alias="tidepool-dev" storepass="${env.KEYSTORE_PASS}" keystore="${keystore}"
storetype="PKCS12" keyalg="RSA" keysize="3072" validity="30">
<dname>
<param name="CN" value="Tidepool Development"/>
<param name="O" value="Example"/>
<param name="C" value="US"/>
</dname>
</genkey>
</target>
<target name="sign" depends="genkey,library-jars" description="Sign and verify a JAR">
<signjar jar="${jars.dir}/tidepool-model-${version}.jar"
signedjar="${build.dir}/sign/tidepool-model-${version}-signed.jar"
alias="tidepool-dev" keystore="${keystore}" storetype="PKCS12"
storepass="${env.KEYSTORE_PASS}" digestalg="SHA-256" sigalg="SHA256withRSA"/>
<verifyjar jar="${build.dir}/sign/tidepool-model-${version}-signed.jar"
keystore="${keystore}" storetype="PKCS12" storepass="${env.KEYSTORE_PASS}"
alias="tidepool-dev" certificates="true"/>
</target>
Run with the variable set to a throw-away value of at least six characters (shown here as a placeholder, because genkey
rejects shorter passwords):
$ KEYSTORE_PASS=<throw-away value> ant sign
Buildfile: /work/packaging/build.xml
genkey:
[mkdir] Created dir: /work/packaging/build/sign
[genkey] Generating Key for tidepool-dev
[genkey] Generating 3072-bit RSA key pair and self-signed certificate (SHA384withRSA) with a validity of 30 days
[genkey] for: CN=Tidepool Development, O=Example, C=US
compile:
library-jars:
sign:
[signjar] Signing JAR: /work/packaging/build/jars/tidepool-model-1.0.0.jar to /work/packaging/build/sign/tidepool-model-1.0.0-signed.jar as tidepool-dev
[signjar] jar signed.
[signjar]
[signjar] Warning:
[signjar] The signer's certificate is self-signed.
[signjar] POSIX file permission and/or symlink attributes detected. These attributes are ignored when signing and are not protected by the signature.
[verifyjar] Verifying JAR: /work/packaging/build/sign/tidepool-model-1.0.0-signed.jar
[verifyjar]
[verifyjar] jar verified.
[verifyjar]
[verifyjar] Warning:
[verifyjar] POSIX file permission and/or symlink attributes detected. These attributes are ignored when signing and are not protected by the signature.
[verifyjar]
[verifyjar] Re-run with the -verbose and -certs options for more details.
BUILD SUCCESSFUL
Total time: 3 seconds
$ unzip -l build/sign/tidepool-model-1.0.0-signed.jar | grep META-INF
589 10-16-2026 11:36 META-INF/MANIFEST.MF
641 10-16-2026 11:36 META-INF/TIDEPOOL.SF
1781 10-16-2026 11:36 META-INF/TIDEPOOL.RSA
0 10-16-2026 11:35 META-INF/
The signature is three things: digests added to the manifest, a signature file TIDEPOOL.SF (named after the alias unless
sigfile is set) and the signature block TIDEPOOL.RSA. The warnings come from the JDK: the certificate is self-signed
(expected), and the JAR contains Unix permission attributes that a signature does not cover. Sign last: any change to
the JAR after signing, including merging it into an uber JAR, invalidates the signature.
Two traps are worth knowing. First, an unset environment property is not an error in Ant: ${env.KEYSTORE_PASS} stays as
that literal text. Before the check-env target was added, a run without the variable did not fail; genkey and
signjar used the literal string ${env.KEYSTORE_PASS} as the password, which is longer than six characters, so everything
worked and the keystore was protected by a password that is printed in the build file. Always guard credentials with
<fail unless=...>. Second, a signature without a trusted timestamp stops being valid when the signer’s certificate
expires. With tsaurl the signature gets a timestamp from a timestamp authority, and the verification message says it:
[verifyjar] This jar contains entries whose signer certificate will expire within six months.
[verifyjar] This jar contains signatures that do not include a timestamp. Without a timestamp, users may not be able to validate this jar after any of the signer certificates expire (as early as 2026-11-15).
Timestamping needs a network call to a timestamp authority, so it is not run here. The same applies to signing with a
real code-signing certificate; the usage is identical, with the keystore of the certificate and the authority’s URL
passed as tsaurl.
Source: Ant manual: signjar
Source: Ant manual: verifyjar
Source: Ant manual: genkey
Source: JDK: The jarsigner command
Modular runtime images: jmod and link
Since Java 9 the JDK can assemble a runtime image that contains only the modules an application needs. Two JDK tools
are involved, and Ant (1.10.6 and later, Java 9 or later) wraps both. <jmod> creates a .jmod file from a modular JAR,
the format that jlink links; <link> assembles .jmod files into an image. The tasks do nothing new, they save you
from writing <exec> calls.
A modular JAR is needed first, and the tidepool classes are not modular. So the example uses a small separate
project: io.tidepool.model (the Height record, exported) and io.tidepool.demo (a class that requires the model and
prints a value). The module declarations are:
module io.tidepool.model {
exports io.tidepool.model;
}
module io.tidepool.demo {
requires io.tidepool.model;
}
<property name="mods.dir" location="${build.dir}/mods"/>
<target name="image" description="Modular JARs, jmod files and a runtime image">
<mkdir dir="${mods.dir}/classes/io.tidepool.model"/>
<mkdir dir="${mods.dir}/classes/io.tidepool.demo"/>
<javac srcdir="modular/model" destdir="${mods.dir}/classes/io.tidepool.model" release="21"
includeantruntime="false"/>
<javac srcdir="modular/demo" destdir="${mods.dir}/classes/io.tidepool.demo" release="21"
includeantruntime="false">
<modulepath>
<pathelement location="${mods.dir}/classes"/>
</modulepath>
</javac>
<jar destfile="${mods.dir}/io.tidepool.model-${version}.jar"
basedir="${mods.dir}/classes/io.tidepool.model"/>
<jar destfile="${mods.dir}/io.tidepool.demo-${version}.jar"
basedir="${mods.dir}/classes/io.tidepool.demo">
<manifest>
<attribute name="Main-Class" value="io.tidepool.demo.Hello"/>
</manifest>
</jar>
<jmod destfile="${mods.dir}/io.tidepool.model.jmod" version="${version}"
classpath="${mods.dir}/io.tidepool.model-${version}.jar"/>
<jmod destfile="${mods.dir}/io.tidepool.demo.jmod" version="${version}"
mainclass="io.tidepool.demo.Hello"
classpath="${mods.dir}/io.tidepool.demo-${version}.jar">
<modulepath>
<pathelement location="${mods.dir}"/>
</modulepath>
</jmod>
<link destDir="${build.dir}/image" modules="io.tidepool.demo"
launchers="tidepool-demo=io.tidepool.demo/io.tidepool.demo.Hello">
<modulepath>
<pathelement location="${java.home}/jmods"/>
<pathelement location="${mods.dir}/io.tidepool.model.jmod"/>
<pathelement location="${mods.dir}/io.tidepool.demo.jmod"/>
</modulepath>
</link>
</target>
Three details came from running it. The modulepath of jmod must contain directories; a JAR as a path element gave
ModulePath must contain only directories. The link module path is the one that takes .jmod files, and it also needs
the JDK’s own jmods directory, which ${java.home} points to when Ant runs on the JDK whose modules you want in the
image (the directory must exist, so the JDK must ship its .jmod files). And both tasks are timestamp-aware: a second
ant image right after the first printed nothing for jmod and link, and after touching the model JAR both .jmod
files were created again (the demo’s module path holds the model JAR) and the image was relinked, so a change to a module
reaches the image without deleting build/image by hand.
$ ant image
Buildfile: /work/packaging/build.xml
image:
[mkdir] Created dir: /work/packaging/build/mods/classes/io.tidepool.model
[mkdir] Created dir: /work/packaging/build/mods/classes/io.tidepool.demo
[javac] Compiling 2 source files to /work/packaging/build/mods/classes/io.tidepool.model
[javac] Compiling 2 source files to /work/packaging/build/mods/classes/io.tidepool.demo
[jar] Building jar: /work/packaging/build/mods/io.tidepool.model-1.0.0.jar
[jar] Building jar: /work/packaging/build/mods/io.tidepool.demo-1.0.0.jar
[jmod] Created /work/packaging/build/mods/io.tidepool.model.jmod
[jmod] Created /work/packaging/build/mods/io.tidepool.demo.jmod
[link] Created /work/packaging/build/image
BUILD SUCCESSFUL
Total time: 0 seconds
$ build/image/bin/java --list-modules
io.tidepool.demo@1.0.0
io.tidepool.model@1.0.0
java.base@26
$ build/image/bin/tidepool-demo
High water: Height[metres=1.25]
$ du -sh build/image
48M build/image
The image holds three modules (the application’s two and java.base, the only JDK module the demo needs) and a launcher
script named by the launchers attribute (name=module/mainclass, or name=module when the module has a main class).
The most useful of the other attributes of link: modules (or nested module elements), observableModules, locales,
excludeResources, excludeFiles, resourceOrder, bindServices, ignoreSigning (always treated as true from Java 11),
includeHeaders, includeManPages, includeNativeCommands, debug, verboseLevel, compress, endianness,
checkDuplicateLegal, vmType, and nested releaseInfo with add and delete. The compress attribute accepts none,
strings or zip (or 0, 1, 2); on JDK 26 the underlying tool prints The 2 argument for --compress is deprecated and may be
removed in a future release, and an image with compress="zip" was 33 MB against 48 MB without it on the test machine. For
jmod, version sets the module version, mainclass the entry point, platform the target (for example
windows-amd64), moduleWarnings and the directories for native commands, headers, configuration, legal notices, native
libraries and man pages. Use the JDK tools directly if you need an option that Ant has no attribute for.
The deprecated <jlink> task shares only the name. It merges the entries of several JAR or ZIP files into one archive, and
the manual’s non-deprecated replacement is a jar with zipgroupfileset:
<jlink compress="true" outfile="build/dist/merged.jar">
<mergefiles>
<pathelement path="build/jars/tidepool-model-1.0.0.jar"/>
<pathelement path="build/jars/tidepool-engine-1.0.0.jar"/>
</mergefiles>
</jlink>
It merged the 13 entries of the two library JARs (the classes of both, without the META-INF directories, as the manual
says) into merged.jar:
$ ant -f jlink-old.xml
merge:
[jlink] linking: /work/packaging/build/dist/merged.jar
Source: Ant manual: jmod
Source: Ant manual: link
Source: Ant manual: jlink (deprecated)
Reproducible archives
A reproducible build produces the same bytes from the same inputs, so that anyone can rebuild an archive and compare its checksum. Two things differ between runs of an archive task: the time stamps stored in each entry, and the order and metadata of the entries. Ant offers the following, in the order of importance:
-
modificationtime(since 1.10.2) onzip,jar,warandearsets every stored time to one instant, given in milliseconds since the epoch or as an ISO 8601 string. -
<tstamp>honours theSOURCE_DATE_EPOCHenvironment variable since 1.10.8, as defined by the Reproducible Builds project, so thatDSTAMP,TSTAMPandTODAY(and nestedformatproperties) are the epoch value instead of the current time. Since 1.10.18 these values are in UTC, and a nestedformathas the localeen_USunless it names one. The propertiesant.tstamp.now(seconds since the epoch) andant.tstamp.now.isodo the same from the command line (since 1.10.2). -
tarhas nomodificationtimeattribute; give the staged files one time with<touch>before packing them. -
Do not put
${user.name}intoBuilt-By, or the build time into the manifest. -
The manifest
Created-Byis the JDK version (see above), so a byte-identical result also needs the same JDK.
The build file below stamps the three archive types with one time derived from SOURCE_DATE_EPOCH, and prints their
SHA-256 digests:
<?xml version="1.0" encoding="UTF-8"?>
<project name="repro" default="repro" basedir=".">
<property name="out" location="build/repro"/>
<target name="stamp">
<!-- With SOURCE_DATE_EPOCH set, tstamp uses that instant (Ant 1.10.8) in UTC (Ant 1.10.18) -->
<tstamp>
<format property="build.time" pattern="yyyy-MM-dd'T'HH:mm:ss'Z'" timezone="UTC"/>
<format property="touch.time" pattern="MM/dd/yyyy HH:mm:ss" timezone="UTC"/>
</tstamp>
<echo>build.time = ${build.time}</echo>
</target>
<target name="repro" depends="stamp">
<delete dir="${out}"/>
<mkdir dir="${out}/stage/tidepool"/>
<jar destfile="${out}/model.jar" basedir="build/classes" includes="io/tidepool/model/**"
modificationtime="${build.time}" duplicate="fail"/>
<zip destfile="${out}/data.zip" modificationtime="${build.time}">
<fileset dir="data"/>
</zip>
<!-- tar has no modificationtime attribute: stamp the staged files instead -->
<copy todir="${out}/stage/tidepool"><fileset dir="data"/></copy>
<touch datetime="${touch.time}" pattern="MM/dd/yyyy HH:mm:ss">
<fileset dir="${out}/stage"/>
<dirset dir="${out}/stage"/>
</touch>
<tar destfile="${out}/data.tar.gz" compression="gzip" longfile="posix">
<tarfileset dir="${out}/stage" uid="0" gid="0"/>
</tar>
<checksum file="${out}/model.jar" algorithm="SHA-256" property="jar.sha"/>
<checksum file="${out}/data.zip" algorithm="SHA-256" property="zip.sha"/>
<checksum file="${out}/data.tar.gz" algorithm="SHA-256" property="tar.sha"/>
<echo>model.jar ${jar.sha}</echo>
<echo>data.zip ${zip.sha}</echo>
<echo>data.tar.gz ${tar.sha}</echo>
</target>
</project>
Without SOURCE_DATE_EPOCH, two runs a minute apart give different digests for all three archives (the class files and
data were identical):
$ ant -q -f repro.xml # 09:38:34 UTC
[echo] build.time = 2026-10-16T09:38:34Z
[echo] model.jar 717fef0cb7ed2e567c318252bb9ba6a0c7f1639b5cbaecb5640fb1fb526a3960
[echo] data.zip 937db55454a18a0bca9921efc01c617ddf431f81084753b0018487a5c5ba7d82
[echo] data.tar.gz d70c14617cc950a8456a0d47005cff040ef7daf6a5f3b561f1a86752a990afeb
$ ant -q -f repro.xml # 09:39:37 UTC
[echo] build.time = 2026-10-16T09:39:37Z
[echo] model.jar e20357a5fe49d1598cb9c86c8478c89f125d4542c9627f9a77cde34728dc3598
[echo] data.zip 3ddc6adde98b8b9702ef1969b8aab5eeb18f1323675bc1d9ca85e4935558492e
[echo] data.tar.gz f2e31bc068b722dd9c930600c389b6450399da1d77abdad89c9e436b5814d1aa
With SOURCE_DATE_EPOCH=1767225600 (2026-01-01T00:00:00Z) and sources whose time stamps were changed between the two runs
(touch on the data file and the class files, a pause of three seconds), the digests are identical:
$ export ANT_OPTS=-Duser.timezone=UTC
$ SOURCE_DATE_EPOCH=1767225600 ant -q -f repro.xml
[echo] build.time = 2026-01-01T00:00:00Z
[echo] model.jar f2edd4a866acc1fdc774f5eb311a1d243f653fc0bce39550e6f8653d95529fd9
[echo] data.zip 783941e967377f7cf12ab3fd9b2082329e3f65ff952a81826913491062a57a93
[echo] data.tar.gz fe388a1b66677ddfb5e4adcac0d874c99244093e75c2cf8d445e57658d2390b9
$ touch data/stations.csv build/classes/io/tidepool/model/*.class; sleep 3
$ SOURCE_DATE_EPOCH=1767225600 ant -q -f repro.xml
[echo] build.time = 2026-01-01T00:00:00Z
[echo] model.jar f2edd4a866acc1fdc774f5eb311a1d243f653fc0bce39550e6f8653d95529fd9
[echo] data.zip 783941e967377f7cf12ab3fd9b2082329e3f65ff952a81826913491062a57a93
[echo] data.tar.gz fe388a1b66677ddfb5e4adcac0d874c99244093e75c2cf8d445e57658d2390b9
The ANT_OPTS=-Duser.timezone=UTC line is not decoration, and it is the finding of this section that the manual does not
spell out. ZIP stores local time, so the modificationtime of a JAR or ZIP is converted through the JVM’s default time
zone, and so is the date string that touch parses before the files go into the tar. The same build on machines in three zones, all with the same inputs and the same
SOURCE_DATE_EPOCH, gave three different archives:
| JVM time zone | model.jar (first digits) |
data.zip (first digits) |
|---|---|---|
|
|
|
|
|
|
|
|
|
(The model.jar digests in this table are from an earlier variant of the build file with an extra manifest attribute, so
they differ from the ones printed above; the point is only that one zone gives one digest.) The tar archive differed in the
same way. Pin the zone in the build (ANT_OPTS=-Duser.timezone=UTC or -Duser.timezone=UTC in the CI job) and the digests
agree from machine to machine. The order of entries is also whatever the file system returns for each fileset; the manual
does not promise a sorted order, so verify by building on every kind of file system and operating system you build on.
Maven’s project.build.outputTimestamp and Gradle’s preserveFileTimestamps = false with reproducibleFileOrder = true
(both the default since Gradle 9.0) solve the same problem by fixing the time and the order inside the tool; on Ant you do
both by hand.
Source: Ant manual: tstamp
Source: SOURCE_DATE_EPOCH specification
Failures and classic bugs
| Symptom | Cause | Fix |
|---|---|---|
|
The JAR has no |
Add the attribute to the inline manifest |
|
|
Build the |
WAR build fails with |
|
Add |
Two entries with the same name in one JAR |
|
|
An archive keeps files that were deleted from the sources |
|
Delete the archive before packaging |
A zero-byte archive is "up to date" on the next run |
A failed archive task left an empty file behind |
Delete the target first, or run |
The signed uber JAR is rejected at start-up |
|
Leave them out with a |
The start script is not executable after unpacking |
|
Use |
Different checksums for the same inputs |
Build time in entries and manifest, the JVM default time zone, |
|
The build passes with an unset |
Unset properties stay as literal text |
|
|
A JAR was given as a path element of the |
Point the module path at the directory that holds the modular JARs |
The equivalents in Maven and Gradle
| Ant | Maven (3.10.0) | Gradle 9.8.1 |
|---|---|---|
|
maven-jar-plugin (3.5.1) with |
the |
runnable JAR with |
the jar plugin’s |
|
uber JAR with |
maven-shade-plugin (3.6.2) or the |
a |
|
maven-war-plugin (3.5.1), maven-ear-plugin |
the |
|
maven-assembly-plugin descriptors with |
the |
|
maven-jarsigner-plugin; maven-gpg-plugin for publishing signatures |
the |
|
maven-jmod-plugin, maven-jlink-plugin |
no core task; community plugins wrap |
|
|
reproducible archives by default since 9.0: fixed timestamps, deterministic order, fixed permissions |
The pages on Packaging JARs, WARs & Manifests, Uber JARs with Shade & Assembly and Security & Reproducible Builds cover the Maven side; for Gradle see Java Projects and Working with Files. The side-by-side verdict is on Comparing Ant, Maven & Gradle, and the supply-chain view of signing and checksums is on Supply Chain Security.
Source: Maven JAR Plugin
Source: Maven Assembly Plugin
Source: Maven Shade Plugin
Source: Gradle: The Application Plugin
Where the book is outdated
Java Development with Ant (Ant 1.5, 2002) packages with <jar>, <war>, <ear>, <zip> and <tar> and its
distribution chapters are still a good picture of why a build must produce a self-contained, versioned archive. The
details have moved. The <jlink> task it uses to merge JARs is deprecated in favour of zipgroupfileset and is unrelated
to the JDK’s jlink (Java 9), which Ant 1.10.6 wrapped in the new jmod and link tasks. The ZIP tasks of Ant 1.5 had no
Zip64 (1.9.0 and 1.9.1), no modificationtime (1.10.2), no SOURCE_DATE_EPOCH handling (1.10.8), no file-name encoding
controls (1.8.0) and no service element (1.7.0). The book’s EJB packaging with <ejbjar> is obsolete since EJB 3.0, and
its web chapters assume a mandatory web.xml (optional since Servlet 2.5, hence needxmlfile). Its signing examples use
the algorithms of the JDK of that time; the defaults of jarsigner have moved on (the example above asks for SHA-256
digests explicitly, and the JDK’s own defaults are stronger than the 2002 ones). The book’s bibliography entry is on
the section bibliography. For the rewriting of old
builds see Deprecations & Migration.
Where to go next
-
File Operations for
copy,move,delete,fixcrlfand the tasks that stage files before packaging. -
Documentation & XML for the Javadoc and source JARs.
-
Running Programs to start the packaged CLI from a build.
-
Utility Tasks for
checksum,touchandtstamp. -
Repositories & Publishing Across Tools for where the archives go next.