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.

flowchart TD zip["zip: files, zipfileset, zipgroupfileset"] --> jar["jar: manifest, service, indexjars"] jar --> war["war: WEB-INF/lib, classes, web.xml"] jar --> ear["ear: META-INF/application.xml"] tar["tar: tarfileset, gzip, bzip2, xz"] jar --> signjar["signjar and verifyjar: jarsigner"] jar --> jmod["jmod: modular JAR to jmod file"] jmod --> link["link: runtime image"] zip --> unzip["unzip, unjar, unwar and untar: extraction"] tar --> unzip
Task What it does Since

jar

Creates a JAR: a ZIP with META-INF/MANIFEST.MF, optional META-INF/services/* files, an optional index

core, service 1.7.0

manifest

Writes or updates a manifest file, also usable inline inside jar, war and ear

core

manifestclasspath

Turns a path into a relative Class-Path value for a manifest

1.7

war

A jar with lib, classes, webinf and metainf nested elements and a web.xml check

core

ear

A jar that requires META-INF/application.xml (attribute appxml)

core

zip

The base task: any files, with prefix, full path and Unix permissions per fileset

core

tar

Creates a tar file, optionally gzip, bzip2 or xz compressed, with owner and mode per fileset

core

gzip, bzip2

Compress a single file (.gz, .bz2)

core

unzip, unjar, unwar, untar

Extract an archive, with patternsets and mappers

core

genkey, signjar, verifyjar

Create a key, sign JARs, verify signatures, all through the JDK keytool and jarsigner

core

jmod, link

Wrap the JDK jmod and jlink tools (Java 9 or later)

1.10.6

jlink

Deprecated: merges ZIP and JAR files; unrelated to the JDK jlink tool

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.

build.xml (header and compile)
<?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):

build.xml (library-jars)
  <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)

destfile

The JAR to create (required)

none

basedir

Directory to jar; with the nested filesets this is optional

none

compress, level

Deflate the entries; level 0 (store) to 9 (maximum)

true; level since 1.7

keepcompression

For entries copied from existing archives, keep their original compression

false (1.6)

encoding

Character encoding of the entry names

UTF8

filesonly

Store only file entries, no directory entries

false

includes, includesfile, excludes, excludesfile, defaultexcludes

The implicit fileset

all files

manifest

An external manifest file, or the name of a JAR (added through a fileset) whose manifest is used

generated

filesetmanifest

What to do with the manifest of a JAR added through zipfileset or zipgroupfileset: skip, merge or mergewithoutmain

skip

update

Update an existing JAR instead of overwriting it

false

whenmanifestonly

If only the manifest matches: create, skip or fail

create

duplicate

When the same entry name is found twice: add, preserve or fail

add

index, indexMetaInf

Write META-INF/INDEX.LIST; with indexMetaInf include META-INF and its children in the index

false; indexMetaInf since 1.8.0

manifestencoding

Encoding used to read an external manifest (it is always written as UTF-8)

JVM default

roundup

Round modification times up to the next even second (ZIP stores 2-second granularity)

true (1.6.2)

strict

Package-versioning errors: fail, warn or ignore

ignore (1.7.1)

preserve0permissions

Keep a Unix permission field of 0 instead of replacing it with defaults when updating or copying from another archive

false (1.8.0)

useLanguageEncodingFlag, createUnicodeExtraFields, fallbacktoUTF8

File-name encoding controls, as on zip

true; never; false (1.8.0)

mergeClassPathAttributes, flattenAttributes

Merge or flatten Class-Path attributes found in several manifests

false (1.8.0)

zip64Mode

never, always or as-needed: when to write Zip64 extensions

never (1.9.1)

modificationtime

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.

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.

build.xml (manifest)
  <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).

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

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.

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.

build.xml (cli-jar)
  <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)

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:

build.xml (uber-jar)
  <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).

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>

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/:

build.xml (mrjar)
  <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).

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.

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.

build.xml (war)
  <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):

webapp/web.xml
<?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>
ear-meta/application.xml
<?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.

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

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.

src-dist/bin/tidepool
#!/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" "$@"
build.xml (dist)
  <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: 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.

extract.xml
<?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.

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.

build.xml (signing)
  <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.

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;
}
build.xml (image)
  <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

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) on zip, jar, war and ear sets every stored time to one instant, given in milliseconds since the epoch or as an ISO 8601 string.

  • <tstamp> honours the SOURCE_DATE_EPOCH environment variable since 1.10.8, as defined by the Reproducible Builds project, so that DSTAMP, TSTAMP and TODAY (and nested format properties) are the epoch value instead of the current time. Since 1.10.18 these values are in UTC, and a nested format has the locale en_US unless it names one. The properties ant.tstamp.now (seconds since the epoch) and ant.tstamp.now.iso do the same from the command line (since 1.10.2).

  • tar has no modificationtime attribute; give the staged files one time with <touch> before packing them.

  • Do not put ${user.name} into Built-By, or the build time into the manifest.

  • The manifest Created-By is 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:

repro.xml
<?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)

Europe/Madrid

6191eb1b6792

098c66aa8d8e

UTC

effc6ac53269

783941e96737

America/New_York

ab9108e646c6

8bffb0cadc6f

(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.

Failures and classic bugs

Symptom Cause Fix

no main manifest attribute, in x.jar

The JAR has no Main-Class

Add the attribute to the inline manifest

NoClassDefFoundError for a dependency of a runnable JAR

Class-Path entries that do not resolve relative to the JAR are silently ignored by the JVM

Build the Class-Path with manifestclasspath and keep the libraries beside the JAR, or build an uber JAR

WAR build fails with No WEB-INF/web.xml file was added.

webxml is missing and needxmlfile is true

Add webxml, or needxmlfile="false" for Servlet 3+ without a descriptor

Two entries with the same name in one JAR

duplicate="add" (the default) with overlapping filesets

duplicate="preserve" or fail

An archive keeps files that were deleted from the sources

update="true", or no rebuild because no source file is newer

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 clean

The signed uber JAR is rejected at start-up

META-INF/*.SF, .DSA or .RSA of signed dependencies were merged

Leave them out with a restrict or per-JAR zipfileset excludes

The start script is not executable after unpacking

copy, jar and zip drop or ignore the executable bit

Use filemode="755" in a zipfileset or tarfileset

Different checksums for the same inputs

Build time in entries and manifest, the JVM default time zone, Created-By, file order

modificationtime, SOURCE_DATE_EPOCH, a fixed zone and a fixed JDK

The build passes with an unset ${env.X}

Unset properties stay as literal text

<fail unless="env.X"/> before using a credential

ModulePath must contain only directories. from jmod

A JAR was given as a path element of the jmod module path

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

jar with an inline manifest

maven-jar-plugin (3.5.1) with archive configuration, bound to the package phase

the jar task and its manifest { attributes(...) }

runnable JAR with Class-Path

the jar plugin’s archive/manifest settings (addClasspath, mainClass)

jar manifest attributes, or the application plugin

uber JAR with zipgroupfileset

maven-shade-plugin (3.6.2) or the jar-with-dependencies descriptor of maven-assembly-plugin (3.8.0)

a Jar task with zipTree of the runtime classpath, or the third-party Shadow plugin

war, ear

maven-war-plugin (3.5.1), maven-ear-plugin

the war and ear plugins

zip and tar distributions

maven-assembly-plugin descriptors with zip and tar.gz formats

the distribution and application plugins (distZip, distTar, installDist), or Zip and Tar tasks

signjar, verifyjar, genkey

maven-jarsigner-plugin; maven-gpg-plugin for publishing signatures

the signing plugin (PGP) for publications

jmod, link

maven-jmod-plugin, maven-jlink-plugin

no core task; community plugins wrap jlink

modificationtime, SOURCE_DATE_EPOCH

project.build.outputTimestamp (the reproducible-builds guide)

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.

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