Gradle Ant Integration

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 Gradle 9.8.1 and was run on 2026-10-16 on JDK 26 (the daemon and the compiler both ran on JDK 26; no toolchain was involved). Every example was run through the wrapper, once with Kotlin DSL build files and once with Groovy DSL build files, and the console output is the same in both apart from the timings, the order of unrelated tasks and the messages that are stated below. The scratch copies pointed their wrapper at a local copy of the same distribution archive; the properties file that a real project commits is

distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

The commands use --console=plain. In the listings the "Consider enabling configuration cache" hint, the link to the problems report and the daemon start-up line are left out, and the scratch directory is shown as /work/ant-integration. Where a listing is shortened the shortening is marked with a line of three dots on its own. The Ant comparison runs used Apache Ant 1.10.18 and Ivy 2.6.0. Gradle 8.14.x was not installed here, so statements about it come from the official documentation and are labelled "not run here".

Gradle can use Ant in two ways, and they answer two different questions. The first is using Ant’s tasks (javac, zip, checksum, a company’s own deploy task) from a Gradle build script, where Gradle is the build and Ant is a library of steps. The second is importing an Ant build, where every target of a build.xml becomes a Gradle task, so that an Ant build can be adopted by Gradle first and rewritten afterwards. Both are old features and both are still supported in 9.8.1, but they have a cost that did not exist when they were written: neither of them works with the configuration cache that is the preferred way to run Gradle since 9.0, with one exception that is shown below. The page ends with the migration of the tidepool Ant and Ivy build, one verified stage at a time. The shorter version of that migration, next to the Ant to Maven and Ivy to Resolver Ant Tasks routes, is in Migrating Between Tools; the Maven direction is Migrating from Maven.

Two layers of Ant

The manual splits Ant into two layers and Gradle treats them differently.

flowchart LR subgraph ant["Apache Ant"] L1["Layer 1: the Ant language<br/>targets, depends, macrodef, properties"] L2["Layer 2: tasks and types<br/>javac, zip, copy, path, fileset"] end L1 -->|"ant.importBuild(...)<br/>each target becomes a task (AntTarget)"| G["Gradle task graph"] L2 -->|"ant.javac(...), ant.zip(...)<br/>AntBuilder inside a task action"| G G --> CC["configuration cache<br/>Task.ant works, importBuild disables it"]
Layer What it contains How Gradle reaches it Configuration cache

The Ant language

The build.xml syntax, targets and their depends, if and unless, macrodef, import, properties

ant.importBuild("build.xml") turns each target into a task of type AntTarget

Not supported. Gradle turns the cache off for the whole build and says so.

Ant tasks and types

javac, jar, zip, copy, checksum, path, fileset, and any task you add with taskdef or typedef

The ant property, an AntBuilder. Groovy calls a task as a method, Kotlin uses withGroovyBuilder.

Works when the task action uses the ant of its own task. Fails when it captures the project’s ant object.

Ant can also be started as an external process ("ant clean compile".execute() in a Groovy script), which is the third and worst way: Gradle cannot see the targets, so nothing is shared, up to date or parallel. The equivalent bridges in the other two tools are maven-antrun-plugin (see Migrating Between Tools) for Maven, and for Ant itself the Java API described in Embedding Ant, which is what Gradle uses inside.

The Ant that is inside Gradle

Gradle does not call the ant command and does not use an installed Ant. It carries its own Ant, as libraries, and the ant object runs the tasks in that one. The lib directory of the 9.8.1 distribution contains ant-1.10.17.jar, ant-launcher-1.10.17.jar and ant-antlr-1.10.15.jar and no other Ant JAR, and a task prints the version it runs:

Kotlin DSL
tasks.register("antVersion") {
    doLast {
        println(ant.properties["ant.version"])
    }
}
Groovy DSL
tasks.register('antVersion') {
    doLast {
        println ant.properties['ant.version']
    }
}
./gradlew antVersion
> Task :antVersion
Apache Ant(TM) version 1.10.17 compiled on April 6 2026

BUILD SUCCESSFUL in 565ms
1 actionable task: 1 executed

The standalone Ant used for the comparison runs on this page is 1.10.18, so the two differ by one patch release, and the Ant inside Gradle is not the one you installed. The upgrade guides record when it moved: 1.10.13 in Gradle 8.2, 1.10.14 in 8.10 and 1.10.15 in 8.12; the 1.10.17 above was measured on 9.8.1. (A Gradle 8.14 distribution was not available here, so its Ant version is not shown, and the 8.x entries come from the guide.) Only the core tasks are in that ant.jar. An optional task needs its own JAR, and without it the failure is the ordinary Ant message about an optional component:

Kotlin DSL
tasks.register("optionalTask") {
    doLast {
        ant.withGroovyBuilder {
            "junit"("printsummary" to "yes")
        }
    }
}
Groovy DSL
tasks.register('optionalTask') {
    doLast {
        ant.junit(printsummary: 'yes')
    }
}
./gradlew optionalTask
> Task :optionalTask FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':optionalTask' (registered in build file 'build.gradle.kts').
> Problem: failed to create task or type junit
  Cause: the class org.apache.tools.ant.taskdefs.optional.junit.JUnitTask was not found.
          This looks like one of Ant's optional components.
  Action: Check that the appropriate optional JAR exists in
          -ANT_HOME/lib
          -the IDE Ant configuration dialogs

The same message appears in Groovy, with a Where: line for the build file added in front of What went wrong. How to put the JAR on a classpath is in the section on custom tasks and Ant libraries below.

Using Ant tasks and types

The ant property of a build script is an AntBuilder. You run an Ant task by calling a method with the task’s name, pass the XML attributes as named arguments, pass nested text as the first plain argument, and pass nested elements in a closure. The translation from the XML that an Ant user knows is mechanical, and Kotlin needs one extra wrapper because the Kotlin compiler cannot call methods that exist only at run time.

Ant XML Groovy DSL Kotlin DSL

<echo message="hi"/>

ant.echo(message: 'hi')

ant.withGroovyBuilder { "echo"("message" to "hi") }

<echo>hi</echo>

ant.echo('hi')

ant.withGroovyBuilder { "echo"("hi") }

<zip destfile="a.zip"> <fileset dir="d"/> </zip>

ant.zip(destfile: 'a.zip') { fileset(dir: 'd') }

"zip"("destfile" to "a.zip") { "fileset"("dir" to "d") } inside withGroovyBuilder

<path id="cp"> ... </path> (a type)

ant.path(id: 'cp') { ... } returns the Path

"path"("id" to "cp") { ... } returns the Path, cast it with as Path

A task name with a hyphen is a string in both languages (ant.'check-stations'(...) in Groovy, "check-stations"(...) in Kotlin), which the custom task example below uses. The example project has a data directory with the tidepool stations.csv and a libs directory with commons-csv-1.14.1.jar; the tasks echo a message, copy, zip and checksum the data, and list a path:

Kotlin DSL
import org.apache.tools.ant.types.Path

tasks.register("hello") {
    doLast {
        val greeting = "hello from Ant"
        ant.withGroovyBuilder {
            "echo"("message" to greeting)
        }
    }
}

tasks.register("echoText") {
    doLast {
        ant.withGroovyBuilder {
            "echo"("hello with nested text")
        }
    }
}

tasks.register("pack") {
    val out = layout.buildDirectory.dir("pack")
    doLast {
        val dir = out.get().asFile
        ant.withGroovyBuilder {
            "mkdir"("dir" to dir)
            "copy"("todir" to dir.resolve("data")) {
                "fileset"("dir" to "data") {
                    "include"("name" to "**/*.csv")
                }
            }
            "zip"("destfile" to dir.resolve("data.zip")) {
                "fileset"("dir" to "data") {
                    "include"("name" to "**.csv")
                    "exclude"("name" to "**.tmp")
                }
            }
            "checksum"("file" to dir.resolve("data.zip"), "algorithm" to "SHA-256", "fileext" to ".sha256")
        }
        println(dir.listFiles()!!.map { it.name }.sorted())
    }
}

tasks.register("list") {
    doLast {
        val path = ant.withGroovyBuilder {
            "path" {
                "fileset"("dir" to "libs", "includes" to "*.jar")
            }
        } as Path
        path.list().forEach {
            println(it.substringAfterLast('/'))
        }
    }
}
Groovy DSL
tasks.register('hello') {
    doLast {
        String greeting = 'hello from Ant'
        ant.echo(message: greeting)
    }
}

tasks.register('echoText') {
    doLast {
        ant.echo('hello with nested text')
    }
}

tasks.register('pack') {
    def out = layout.buildDirectory.dir('pack')
    doLast {
        def dir = out.get().asFile
        ant.mkdir(dir: dir)
        ant.copy(todir: new File(dir, 'data')) {
            fileset(dir: 'data') {
                include(name: '**/*.csv')
            }
        }
        ant.zip(destfile: new File(dir, 'data.zip')) {
            fileset(dir: 'data') {
                include(name: '**.csv')
                exclude(name: '**.tmp')
            }
        }
        ant.checksum(file: new File(dir, 'data.zip'), algorithm: 'SHA-256', fileext: '.sha256')
        println dir.listFiles()*.name.sort()
    }
}

tasks.register('list') {
    doLast {
        def path = ant.path {
            fileset(dir: 'libs', includes: '*.jar')
        }
        path.list().each {
            println it.substring(it.lastIndexOf('/') + 1)
        }
    }
}
./gradlew hello echoText pack list
> Task :hello
[ant:echo] hello from Ant

> Task :echoText
[ant:echo] hello with nested text

> Task :pack
[data, data.zip, data.zip.sha256]

> Task :list
commons-csv-1.14.1.jar

BUILD SUCCESSFUL in 551ms
4 actionable tasks: 4 executed

Everything the Ant task did is visible in the file system, not on the console: pack copied the CSV into build/pack/data, zipped it, and checksum wrote data.zip.sha256 next to the archive. The Ant output prefix [ant:echo] is Gradle’s: it puts the name of the Ant task in brackets after ant: so that you can tell Ant output from Gradle output. Ant types work the same way. path returned an org.apache.tools.ant.types.Path, an ordinary Java object that the task iterated with list(). The same two builds print the same lines in Groovy.

Gradle itself has better tools for these jobs: a Copy, Zip or Sync task has declared inputs and outputs, is up to date when nothing changed, and can be cached, which the ant.copy call above cannot (see Working with Files and Incremental Build). Use the ant object for a step that has no Gradle equivalent (checksum with a particular file extension, a task from a company’s Ant library), not for a step that has one. The manual makes the same point for the file tasks (copy, zip, unzip) when it discusses migration.

The ant object of the project and of the task

There are two ant objects. Every Project has one, and every Task has one too (Task.ant, an AntBuilder of its own). Inside tasks.register("x") { doLast { ant... } } the name ant resolves to the task’s, because the task is the closest receiver. That is the form shown above and it works with the configuration cache: the first run stores the cache and the second reuses it.

./gradlew --configuration-cache hello pack
./gradlew --configuration-cache hello pack
Calculating task graph as no cached configuration is available for tasks: hello pack

> Task :hello
[ant:echo] hello from Ant

> Task :pack
[data, data.zip, data.zip.sha256]

BUILD SUCCESSFUL in 618ms
2 actionable tasks: 2 executed
Configuration cache entry stored.
Reusing configuration cache.

> Task :hello
[ant:echo] hello from Ant

> Task :pack
[data, data.zip, data.zip.sha256]

BUILD SUCCESSFUL in 600ms
2 actionable tasks: 2 executed
Configuration cache entry reused.

The cached run executed the same two tasks (a task that declares no outputs is never up to date) and printed the same lines. A build script that grabs the project’s ant into a variable and uses it inside the action is different, because the cache has to serialize the variable:

Kotlin DSL
val projectAnt = ant
tasks.register("viaProject") {
    doLast {
        projectAnt.withGroovyBuilder { "echo"("message" to "project-level ant object") }
    }
}
Groovy DSL
def projectAnt = ant
tasks.register('viaProject') {
    doLast {
        projectAnt.echo(message: 'project-level ant object')
    }
}
./gradlew --configuration-cache viaProject
Calculating task graph as no cached configuration is available for tasks: viaProject

> Task :viaProject
[ant:echo] project-level ant object


FAILURE: Build failed with an exception.

* What went wrong:
Configuration cache problems found in this build.

1 problem was found storing the configuration cache.
- Task `:viaProject` of type `org.gradle.api.DefaultTask`: cannot serialize Gradle script object references as these are not supported with the configuration cache.
  See https://docs.gradle.org/9.8.1/userguide/configuration_cache_requirements.html#config_cache:requirements:disallowed_types
...
BUILD FAILED in 663ms
1 actionable task: 1 executed
Configuration cache entry discarded with 1 problem.

The Kotlin build fails because it cannot serialize a reference to the build script. The Groovy build fails with a longer message ("2 failures"), which says that the project’s AntBuilder holds Ant’s own Project and Target objects and one of them is a java.util.Vector that uses ObjectOutputStream.putFields, and that a DefaultProject cannot be serialized either. In both languages the repair is to let the closure resolve ant to the task’s.

Custom Ant tasks and Ant libraries

A custom Ant task is a Java class that extends org.apache.tools.ant.Task (see Writing Custom Tasks for the Ant side). The tidepool build has one, CheckStations, which checks the header and the length of the stations file. It was compiled against the Ant 1.10.18 ant.jar with --release 17 and packaged with the jar tool, so that the same JAR runs under the standalone Ant and under the Ant inside Gradle:

package io.tidepool.ant;

import java.io.IOException;
import java.nio.file.Files;
import java.util.List;
import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.Task;

/** Ant task: fails the build when a stations CSV is malformed or too short. */
public class CheckStations extends Task {
    private java.io.File file;
    private int min = 1;

    public void setFile(java.io.File file) { this.file = file; }
    public void setMin(int min) { this.min = min; }

    @Override
    public void execute() {
        if (file == null) {
            throw new BuildException("The file attribute is required");
        }
        try {
            List<String> lines = Files.readAllLines(file.toPath());
            if (lines.isEmpty() || !lines.get(0).equals("id,name,latitude,longitude,constituents")) {
                throw new BuildException(file + " must start with the header id,name,latitude,longitude,constituents");
            }
            int stations = lines.size() - 1;
            if (stations < min) {
                throw new BuildException(file + " has " + stations + " stations, at least " + min + " required");
            }
            log("Checked " + stations + " stations in " + file.getName());
        } catch (IOException e) {
            throw new BuildException("Cannot read " + file, e, getLocation());
        }
    }
}
javac --release 17 -cp apache-ant-1.10.18/lib/ant.jar -d classes src/io/tidepool/ant/CheckStations.java
jar --create --file tools/tidepool-ant-tasks.jar --date 2026-10-16T00:00:00Z -C classes .

Neither command prints anything. Gradle’s taskdef is Ant’s: it takes either a name and a classname, or a resource that is a properties file or an antlib, and a classpath. The manual says taskdef is usually the easier of the two for tasks, and builds the classpath from Gradle’s dependency management by putting the JAR in a configuration and passing asPath as the classpath. A configuration of its own keeps the Ant task JAR off the compile classpath of the code:

Kotlin DSL
// The custom Ant task, taken from a jar on a Gradle configuration instead of a taskdef in build.xml.
val antTasks = configurations.create("antTasks")

dependencies {
    antTasks(files("tools/tidepool-ant-tasks.jar"))
}

tasks.register("checkStations") {
    group = "verification"
    description = "Runs the custom Ant task check-stations"
    doLast {
        ant.withGroovyBuilder {
            "taskdef"(
                "name" to "check-stations",
                "classname" to "io.tidepool.ant.CheckStations",
                "classpath" to antTasks.asPath
            )
            "check-stations"("file" to "data/stations.csv", "min" to 3)
        }
    }
}
Groovy DSL
// The custom Ant task, taken from a jar on a Gradle configuration instead of a taskdef in build.xml.
configurations {
    antTasks
}

dependencies {
    antTasks files('tools/tidepool-ant-tasks.jar')
}

tasks.register('checkStations') {
    group = 'verification'
    description = 'Runs the custom Ant task check-stations'
    doLast {
        ant.taskdef(
            name: 'check-stations',
            classname: 'io.tidepool.ant.CheckStations',
            classpath: configurations.antTasks.asPath
        )
        ant.'check-stations'(file: 'data/stations.csv', min: 3)
    }
}
./gradlew checkStations
> Task :checkStations
[ant:check-stations] Checked 3 stations in stations.csv

BUILD SUCCESSFUL in 608ms
1 actionable task: 1 executed

The task name has a hyphen, so Kotlin passes it as a string and Groovy quotes the method name. Note the prefix: [ant:check-stations] is the log message that the task wrote with log(...), and that is at Ant’s INFO priority, which Gradle hides by default. The output above is shown with a build file that raises the Ant INFO messages to Gradle’s lifecycle level; the next section explains the one line that does it (ant.lifecycleLogLevel). Without that line the task runs and prints nothing. The taskdef does the job of the taskdef element of a build.xml; nothing has to be copied into the Ant installation.

An Ant library from Maven Central

A task library is a JAR, and Gradle can resolve it. The Apache Ant Compress Antlib (org.apache.ant:ant-compress:1.5) is an Ant library, not part of the Ant inside Gradle (1.10.17), whose tasks are in the antlib namespace antlib:org.apache.ant.compress. It is also a good example of why the configuration is separate: the library declares a dependency on org.apache.ant:ant with the version range [1.8.0,), so the resolution adds a second Ant (1.10.18, as the dependency report below shows) to that configuration, which is one more reason to keep Ant task libraries out of the configurations that the code uses.

Kotlin DSL
repositories {
    mavenCentral()
}

// Gradle ships Ant 1.10.17 and the core tasks only; optional Ant libraries need their own classpath.
val antLibs = configurations.create("antLibs")

dependencies {
    antLibs("org.apache.ant:ant-compress:1.5")
}

tasks.register("gzipStations") {
    val classpath = antLibs.incoming.files
    val out = layout.buildDirectory.dir("gz")
    doLast {
        ant.withGroovyBuilder {
            "taskdef"(
                "resource" to "org/apache/ant/compress/antlib.xml",
                "uri" to "antlib:org.apache.ant.compress",
                "classpath" to classpath.asPath
            )
            "mkdir"("dir" to out.get().asFile)
            "antlib:org.apache.ant.compress:gzip"("src" to "data/stations.csv", "destfile" to out.get().file("stations.csv.gz").asFile)
        }
        println(out.get().asFile.list()!!.sorted())
    }
}
Groovy DSL
repositories {
    mavenCentral()
}

// Gradle ships Ant 1.10.17 and the core tasks only; optional Ant libraries need their own classpath.
configurations {
    antLibs
}

dependencies {
    antLibs 'org.apache.ant:ant-compress:1.5'
}

tasks.register('gzipStations') {
    def classpath = configurations.antLibs.incoming.files
    def out = layout.buildDirectory.dir('gz')
    doLast {
        ant.taskdef(resource: 'org/apache/ant/compress/antlib.xml',
                    uri: 'antlib:org.apache.ant.compress',
                    classpath: classpath.asPath)
        ant.mkdir(dir: out.get().asFile)
        ant.'antlib:org.apache.ant.compress:gzip'(src: 'data/stations.csv', destfile: out.get().file('stations.csv.gz').asFile)
        println out.get().asFile.list().sort()
    }
}
./gradlew dependencies --configuration antLibs
./gradlew gzipStations
------------------------------------------------------------
Root project 'tidepool-ant'
------------------------------------------------------------

antLibs
\--- org.apache.ant:ant-compress:1.5
     +--- org.apache.ant:ant:[1.8.0,) -> 1.10.18
     |    \--- org.apache.ant:ant-launcher:1.10.18
     +--- org.apache.commons:commons-compress:1.14
     +--- org.tukaani:xz:1.6
     \--- org.brotli:dec:0.1.2
> Task :gzipStations
[stations.csv.gz]

BUILD SUCCESSFUL in 638ms
1 actionable task: 1 executed

taskdef with a resource and a uri registers the whole antlib under the namespace and the gzip task is then called by its qualified name (antlib:org.apache.ant.compress:gzip).

This build is compatible with the configuration cache, with one change that the listing already contains: the task copies antLibs.incoming.files into a local variable instead of using the Configuration object itself, because a Configuration cannot be stored. With the object captured, the first cached run fails with cannot serialize object of type 'org.gradle.api.internal.artifacts.configurations.DefaultLegacyConfiguration'. With the file collection, the cache is stored and then reused:

./gradlew --configuration-cache gzipStations
Calculating task graph as no cached configuration is available for tasks: gzipStations

> Task :gzipStations
[stations.csv.gz]

BUILD SUCCESSFUL in 657ms
1 actionable task: 1 executed
Configuration cache entry stored.

The second run of the same command reused the entry:

Reusing configuration cache.

> Task :gzipStations
[stations.csv.gz]

BUILD SUCCESSFUL in 253ms
1 actionable task: 1 executed
Configuration cache entry reused.

(Both runs are the Kotlin DSL build; the Groovy DSL build printed the same lines apart from the timings.) The same applies to the Task.ant rule above: the object has to be the task’s own and everything else must be a plain value or a file collection.

Ant properties and references

Ant keeps two global tables for a build: properties (strings that cannot be changed once they are set) and references (objects with an id, such as a path). The AntBuilder exposes both as maps, and also lets you use the Ant tasks that fill them:

Kotlin DSL
tasks.register("props") {
    doLast {
        ant.properties["buildName"] = "tidepool"
        ant.withGroovyBuilder {
            "property"("name" to "dataDir", "location" to "data")
        }
        ant.withGroovyBuilder { "echo"("message" to "name=\${buildName} dataDir=\${dataDir}") }
        ant.withGroovyBuilder { "path"("id" to "libs.path", "location" to "libs") }
        println(ant.references["libs.path"].toString().substringAfterLast('/'))
        println(ant.properties["dataDir"].toString().substringAfterLast('/'))
    }
}
Groovy DSL
tasks.register('props') {
    doLast {
        ant.properties['buildName'] = 'tidepool'
        ant.property(name: 'dataDir', location: 'data')
        ant.echo(message: 'name=${buildName} dataDir=${dataDir}')
        ant.path(id: 'libs.path', location: 'libs')
        println ant.references['libs.path'].toString().substring(ant.references['libs.path'].toString().lastIndexOf('/') + 1)
        println ant.properties['dataDir'].toString().substring(ant.properties['dataDir'].toString().lastIndexOf('/') + 1)
    }
}
./gradlew props
> Task :props
[ant:echo] name=tidepool dataDir=/work/ant-integration/data
libs
data

BUILD SUCCESSFUL in 574ms
1 actionable task: 1 executed

ant.properties is a mutable map, ant.references a map of the Ant objects, and the property task can set a location property. The result was name=tidepool set through the map, the ${dataDir} that the property task made absolute (location resolves the name against the project directory, so the value is an absolute path, shortened here), and the references and properties read back. Gradle does not translate its own project properties into Ant properties: -Pname=value, gradle.properties and the ext block are invisible to an Ant task, and any value that an Ant file needs must be put into ant.properties before the Ant task runs (an imported build needs it before the import, as the next section shows). In the other direction, ant.properties shows the properties that an imported build.xml defined, including the ones that an Ant task set when it ran.

Ant state is shared, and the configuration cache runs tasks in parallel

There is one Ant project behind the ant objects of a Gradle project, and its properties are shared: a property set by one task is visible to another task that runs later. Two tasks, setIt and readIt with mustRunAfter, were run for this with the cache off and then on, and readIt printed shared=set by setIt every time. That sharing has a price once the configuration cache is on, because the cache lets independent tasks of one project run at the same time. Three tasks that do not depend on each other and each set their own Ant property (so no name is used twice) before echoing it:

Kotlin DSL
listOf("a", "b", "c").forEach { n ->
    tasks.register(n) {
        doLast {
            ant.properties["name.$n"] = "tidepool"
            ant.withGroovyBuilder { "echo"("message" to "$n: name=\${name.$n}") }
        }
    }
}
Groovy DSL
['a', 'b', 'c'].each { n ->
    tasks.register(n) {
        doLast {
            ant.properties["name.${n}"] = 'tidepool'
            ant.echo(message: "${n}: name=\${name.${n}}")
        }
    }
}
./gradlew a b c
./gradlew --configuration-cache a b c
./gradlew --configuration-cache a b c
> Task :a
[ant:echo] a: name=tidepool

> Task :b
[ant:echo] b: name=tidepool

> Task :c
[ant:echo] c: name=tidepool

BUILD SUCCESSFUL in 469ms
3 actionable tasks: 3 executed
Calculating task graph as no cached configuration is available for tasks: a b c

> Task :b
[ant:echo] b: name=${name.b}

> Task :a
[ant:echo] a: name=${name.a}

> Task :c
[ant:echo] c: name=tidepool

BUILD SUCCESSFUL in 422ms
3 actionable tasks: 3 executed
Configuration cache entry stored.
Reusing configuration cache.
> Task :b FAILED

> Task :a
[ant:echo] a: name=${name.a}

> Task :c
[ant:echo] c: name=tidepool

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':b' (registered in build file 'build.gradle.kts').
> 1 >= 1
...
BUILD FAILED in 494ms
3 actionable tasks: 3 executed
Configuration cache entry reused.

Without the cache the three tasks ran one after another and printed their properties. With the cache, the first run stored the entry but two of the three tasks did not see the property they had just set (${name.b} was printed as it was written), and on the second run, which reused the entry, a task failed with the unhelpful message 1 >= 1. Which tasks fail changes from run to run, and the Groovy build behaved the same way. This is nondeterministic by nature. In six runs of the Kotlin build with three tasks that set the same property, every run failed at least one task. My reading of the results, which the manual does not state, is that the shared Ant project is not safe for writes from two threads. Tasks that only echo, with no properties involved, were not affected in these runs. What did work was to give the tasks an order, so that they do not overlap:

Kotlin DSL
listOf("a", "b", "c").forEachIndexed { i, n ->
    tasks.register(n) {
        if (i > 0) mustRunAfter(listOf("a", "b", "c")[i - 1])
        doLast {
            ant.properties["name.$n"] = "tidepool"
            ant.withGroovyBuilder { "echo"("message" to "$n: name=\${name.$n}") }
        }
    }
}
Groovy DSL
['a', 'b', 'c'].eachWithIndex { n, i ->
    tasks.register(n) {
        if (i > 0) mustRunAfter(['a', 'b', 'c'][i - 1])
        doLast {
            ant.properties["name.${n}"] = 'tidepool'
            ant.echo(message: "${n}: name=\${name.${n}}")
        }
    }
}
Reusing configuration cache.

> Task :a
[ant:echo] a: name=tidepool

> Task :b
[ant:echo] b: name=tidepool

> Task :c
[ant:echo] c: name=tidepool

BUILD SUCCESSFUL in 225ms
3 actionable tasks: 3 executed
Configuration cache entry reused.

With the chain of mustRunAfter, eight runs of the Kotlin build and eight of the Groovy build with the cache on all succeeded. So when a build keeps ant calls in several tasks and runs with the cache, either order the tasks that write Ant properties or keep the ant calls in as few tasks as possible. Moving the work to Gradle’s own Copy and Zip tasks removes the problem together with the Ant dependency.

Ant logging

Gradle maps the priority of every Ant message to a Gradle log level, so Ant’s output joins the build’s output and obeys -q, --info and --debug (see Directories & Logging).

Ant priority Gradle log level

VERBOSE

DEBUG

DEBUG

DEBUG

INFO

INFO

WARN

WARN

ERROR

ERROR

No priority maps to LIFECYCLE, the level Gradle shows by default, and many Ant tasks log their useful messages at INFO. So javac ("Compiling 6 source files"), jar, mkdir and the ivy:retrieve report are all hidden by default (stage 1 of the migration below shows it) and appear with --info, which also shows everything else. AntBuilder.setLifecycleLogLevel fixes that: every Ant message at that priority or above is shown at LIFECYCLE (at least), and any message below it at INFO (at most). The example logs one message at each of three priorities:

Kotlin DSL
tasks.register("hello") {
    doLast {
        ant.withGroovyBuilder {
            "echo"("level" to "info", "message" to "hello from info priority!")
            "echo"("level" to "verbose", "message" to "hello from verbose priority!")
            "echo"("level" to "warning", "message" to "hello from warning priority!")
        }
    }
}
Groovy DSL
tasks.register('hello') {
    doLast {
        ant.echo(level: 'info', message: 'hello from info priority!')
        ant.echo(level: 'verbose', message: 'hello from verbose priority!')
        ant.echo(level: 'warning', message: 'hello from warning priority!')
    }
}
./gradlew hello
> Task :hello
[ant:echo] hello from warning priority!

BUILD SUCCESSFUL in 648ms
1 actionable task: 1 executed

Only the warning message is shown. After adding one line at the top of the file, the Kotlin DSL uses the enum AntBuilder.AntMessagePriority.INFO and the Groovy DSL the string 'INFO':

Kotlin DSL
ant.lifecycleLogLevel = AntBuilder.AntMessagePriority.INFO
Groovy DSL
ant.lifecycleLogLevel = 'INFO'
> Task :hello
[ant:echo] hello from info priority!
[ant:echo] hello from warning priority!

BUILD SUCCESSFUL in 657ms
1 actionable task: 1 executed

The INFO message now appears at the default level, the WARN message still does, and the verbose message (mapped to DEBUG) stays out. If the level were raised to ERROR, WARN messages would be pushed down to INFO and disappear from the default output. Set it once, where the project is configured, and not inside a task. Both logging builds are compatible with the configuration cache: they were run with --configuration-cache, which stored an entry and then reused it.

Importing an Ant build

ant.importBuild("build.xml") reads an Ant file and registers one Gradle task for every target. Dependencies between targets (depends) are kept, a target’s description becomes the task’s description, and the properties and references of the file become ant.properties and ant.references. The example Ant file has four targets and the Gradle build wires it up in four ways: it passes a property in before the import, renames what it imports, adds a Gradle task that an Ant target depends on, and attaches a Gradle action to an Ant target.

build.xml
<project name="tidepool-ant" default="hello" basedir=".">
    <property name="greeting" value="Hello"/>
    <property name="out.dir" location="build/ant"/>

    <target name="hello" description="Greets from Ant">
        <echo>${greeting}, from Ant</echo>
    </target>

    <target name="pack" depends="hello,stamp" description="Zips data/ (needs the Gradle task stamp)">
        <mkdir dir="${out.dir}"/>
        <zip destfile="${out.dir}/data.zip" basedir="data"/>
    </target>

    <target name="again" description="Calls hello through antcall">
        <antcall target="hello"/>
    </target>

    <target name="maybe" if="do.maybe" description="Runs only when do.maybe is set">
        <echo>maybe ran</echo>
    </target>
</project>
Kotlin DSL
// Pass a Gradle project property on to Ant before the import, so Ant's <property> keeps it.
findProperty("greeting")?.let { ant.properties["greeting"] = it }

// "stamp" is a Gradle task named in depends, so it keeps its name.
ant.importBuild("build.xml") { antTargetName ->
    if (antTargetName == "stamp") antTargetName else "ant-$antTargetName"
}

tasks.register("stamp") {
    doLast {
        println("Gradle task stamp, greeting is ${ant.properties["greeting"]}")
    }
}

// The Ant target "pack" names "stamp" in depends; "hello" gets an extra Gradle action.
tasks.named("ant-hello") {
    doLast {
        println("Hello, from Gradle")
    }
}

// Ant targets have no inputs or outputs; declare them to get UP-TO-DATE checking.
tasks.named("ant-pack") {
    inputs.dir("data")
    outputs.dir(layout.buildDirectory.dir("ant"))
}
Groovy DSL
// Pass a Gradle project property on to Ant before the import, so Ant's <property> keeps it.
if (findProperty('greeting')) {
    ant.properties['greeting'] = findProperty('greeting')
}

// "stamp" is a Gradle task named in depends, so it keeps its name.
ant.importBuild('build.xml') { antTargetName ->
    antTargetName == 'stamp' ? antTargetName : "ant-${antTargetName}".toString()
}

tasks.register('stamp') {
    doLast {
        println "Gradle task stamp, greeting is ${ant.properties['greeting']}"
    }
}

// The Ant target "pack" names "stamp" in depends; "hello" gets an extra Gradle action.
tasks.named('ant-hello') {
    doLast {
        println 'Hello, from Gradle'
    }
}

// Ant targets have no inputs or outputs; declare them to get UP-TO-DATE checking.
tasks.named('ant-pack') {
    inputs.dir('data')
    outputs.dir(layout.buildDirectory.dir('ant'))
}
./gradlew ant-pack
./gradlew ant-pack
> Task :ant-hello
[ant:echo] Hello, from Ant
Hello, from Gradle

> Task :stamp
Gradle task stamp, greeting is Hello

> Task :ant-pack

BUILD SUCCESSFUL in 669ms
3 actionable tasks: 3 executed
> Task :ant-hello
[ant:echo] Hello, from Ant
Hello, from Gradle

> Task :stamp
Gradle task stamp, greeting is Hello

> Task :ant-pack UP-TO-DATE

BUILD SUCCESSFUL in 669ms
3 actionable tasks: 2 executed, 1 up-to-date

The first run executed the three tasks in the order that the Ant depends="hello,stamp" states: the Ant target hello (with the extra Gradle action that printed Hello, from Gradle after the Ant echo), then the Gradle task stamp, then the Ant target pack. The second run executed the first two again and found ant-pack UP-TO-DATE.

What an imported target is

The imported tasks are real Gradle tasks of the type org.gradle.api.tasks.ant.AntTarget; they have a path, a description, and dependencies, and you can configure them as you would configure any other task.

./gradlew help --task ant-pack
> Task :help
Detailed task information for ant-pack

Path
     :ant-pack (registered in build file 'build.gradle.kts')

Type
     AntTarget (org.gradle.api.tasks.ant.AntTarget)

Options
     --rerun     Causes the task to be re-run even if up-to-date.

Description
     Zips data/ (needs the Gradle task stamp)

Group
     -

BUILD SUCCESSFUL in 818ms

(The Groovy build prints build.gradle in the "registered in build file" line, and nothing else differs.) The task has no group, so the plain tasks listing does not show the imported targets at all and ends with the hint "To see all tasks and more detail, run gradlew tasks --all"; with --all they are in the "Other tasks" section with their Ant descriptions. Because an AntTarget has no declared inputs or outputs, Gradle cannot tell whether it is up to date and runs it every time. That is the reason for the last block of the build file above:

  • inputs.dir("data") and outputs.dir(...) on ant-pack give Gradle what it needs to decide that nothing changed, and the second run reported UP-TO-DATE. Gradle does not check that the declaration is true: if the Ant target also reads another file, a change to that file is not noticed.

  • ant-hello has no outputs, so it ran again, and so did stamp.

The manual says the same: incremental build information can still be added to an imported Ant build, but it is more work than in a native Gradle build.

Renaming targets, and an Ant target that depends on a Gradle task

The one-argument importBuild keeps the Ant names, and the two-argument form takes a transformer that maps each name. Why rename? When a plugin that is applied to the project adds a task with the same name as an Ant target, the build stops; the case for jar is in Migrating Between Tools and the case for build and clean is in the migration below. A prefix is the simplest rule, but there is a trap in it. Gradle applies the transformer to every name in a depends attribute, not only to the targets that exist in the file. In the example, pack names stamp, which is a Gradle task. With the plain rule "ant-$antTargetName" the dependency becomes ant-stamp:

ant.importBuild("build.xml") { antTargetName -> "ant-$antTargetName" }
./gradlew ant-pack
FAILURE: Build failed with an exception.

* What went wrong:
Could not determine the dependencies of task ':ant-pack'.
> Imported Ant target 'ant-pack' depends on target or task 'ant-stamp' which does not exist

The Groovy build fails in the same way, and the rule that fixes it is the one in the build file above: the transformer returns the name unchanged for the names that are not Ant targets (stamp). Gradle looks the transformed name up among the Gradle tasks, so a Gradle task (stamp here) can be named in an Ant depends, as long as the transformer lets the name through.

Passing properties to Ant

Properties flow from Gradle to Ant through ant.properties, and they have to be set before the import, because an Ant property that is already defined is not changed by a later property element. The first lines of the build file above do this for a Gradle project property:

./gradlew ant-hello -Pgreeting=Ahoy
> Task :ant-hello
[ant:echo] Ahoy, from Ant
Hello, from Gradle

BUILD SUCCESSFUL in 601ms
1 actionable task: 1 executed

The Ant file says <property name="greeting" value="Hello"/> and the run printed Ahoy, because Gradle set the Ant property first. Without the copy, -Pgreeting=Ahoy would be invisible to Ant, which is also what the manual says about imported Ant tasks and Gradle project properties. In the other direction, the Ant properties are available as ant.properties["name"] once the import has run, which is how the migration below reads the directories of the Ant build.

antcall and ant bypass the Gradle tasks

Ant’s antcall and ant tasks start another target, or another build file, by name inside Ant. Gradle handles them, but what they run is the Ant target, not the Gradle task that was imported from it. In a copy of the example whose pack does not depend on stamp, the Ant file has a target

<target name="again" description="Calls hello through antcall">
    <antcall target="hello"/>
</target>

and the Gradle build still has the extra action on ant-hello:

./gradlew ant-again
> Task :ant-again
[ant:echo] Hello, from Ant

BUILD SUCCESSFUL in 695ms
1 actionable task: 1 executed

The Hello, from Gradle line of the ant-hello action did not run, because antcall called the Ant target hello and not the Gradle task ant-hello. In the build above, where pack depends on the Gradle task stamp, the same antcall fails, because Ant cannot see the Gradle task that its own depends names:

./gradlew ant-again
> Task :ant-again FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':ant-again' (registered in build file 'build.gradle.kts').
> Target "stamp" does not exist in the project "tidepool-ant". It is used from target "pack".

The rule that follows is to avoid antcall and ant (and subant) in the targets you keep, and to replace them by task dependencies as soon as you can.

Multi-project builds

In a multi-project build every project can import its own Ant file, which is the first step of the manual’s recipe: a Gradle build file with a single ant.importBuild line for each project that has a build.xml, and an empty one where there is none. A typical Ant multi-project build calls the build of the project that it needs with <ant dir=... target=...>. The example has two projects, util and web, and web builds util this way:

<project name="web" default="compile">
    <property name="root.dir" location=".."/>

    <target name="buildRequiredProjects">
        <ant dir="${root.dir}/util" target="build"/>
    </target>

    <target name="compile" depends="buildRequiredProjects" description="Compiles the web module">
        <echo>compiling web</echo>
    </target>
</project>

With only the import in web/build.gradle(.kts), ./gradlew :web:compile runs the util Ant target inside the web task:

> Task :web:buildRequiredProjects
[ant:echo] building util

> Task :web:compile
[ant:echo] compiling web

BUILD SUCCESSFUL in 648ms
2 actionable tasks: 2 executed

util was built by the buildRequiredProjects task of web, and the Gradle project util never took part: its tasks, caching and up-to-date checks were bypassed. The manual replaces the Ant detour with a task dependency between the Gradle projects and warns to remove the old one.

Kotlin DSL
ant.importBuild("build.xml")

// Replace the <ant dir="../util" target="build"/> detour with a Gradle task dependency.
tasks.named("compile") {
    setDependsOn(listOf(":util:build"))
}
Groovy DSL
ant.importBuild 'build.xml'

// Replace the <ant dir="../util" target="build"/> detour with a Gradle task dependency.
tasks.named('compile') {
    dependsOn = [':util:build']
}
> Task :util:build
[ant:echo] building util

> Task :web:compile
[ant:echo] compiling web

BUILD SUCCESSFUL in 675ms
2 actionable tasks: 2 executed

setDependsOn (Groovy: assigning dependsOn =) replaces the dependencies of compile, which is what removes buildRequiredProjects; dependsOn(...) would have added to them. The manual calls this less robust than Gradle’s project dependencies, but enough for the immediate problem; the real replacement is a project(":util") dependency once the two projects are native. The order the manual suggests is: leaf projects first (those with no dependencies on other projects of the build), then the projects that depend only on migrated ones, and finally remove the copy tasks that the unmigrated Ant builds needed. See Multi Project Builds.

The configuration cache

The manual states that Ant integration is not fully compatible with the configuration cache: using Task.ant in an action may work, as the first sections showed, but importing an Ant build is not supported, and Gradle turns the cache off when it finds an imported target. A short run of the import example shows the message to look for in a CI log:

./gradlew --configuration-cache ant-hello
Calculating task graph as no cached configuration is available for tasks: ant-hello

> Task :ant-hello
[ant:echo] Hello, from Ant
Hello, from Gradle

Some tasks or features in this build are not compatible with the configuration cache.
See the complete report at file:///work/ant-integration/build/reports/configuration-cache/dxrxbv6qyar068fbr9z1rnx3v/32b53nhzx0w2qyviawmxozs2l/configuration-cache-report.html

BUILD SUCCESSFUL in 555ms
1 actionable task: 1 executed
Configuration cache disabled because incompatible task was found.

The build succeeds and runs without the cache, so a build with an import still works in a CI that enables the cache, but it does not get any of its speed. The cache returns when the last ant.importBuild line is gone, which is the goal of the migration below.

Migrating the tidepool build step by step

The manual’s advice for any Ant migration is to keep the Ant build next to the Gradle one until the Gradle build is proven to produce the same artifacts, to leave the directory layout alone until the end, and to move one piece at a time while the build stays working. This section follows it with the tidepool Ant build, whose dependencies are managed by Ivy and whose build.xml has a custom Ant task. Every stage is a copy of the same project, the build works at the end of every stage, and each stage was run in both DSLs.

flowchart TD S0["Stage 0: Ant 1.10.18 + Ivy 2.6.0<br/>build.xml, ivy.xml, custom Ant task"] S1["Stage 1: import<br/>ant.importBuild, every target is a task"] S2["Stage 2: compile with Gradle<br/>java-library, source set from Ant properties"] S3["Stage 3: dependencies<br/>ivy.xml becomes dependencies and mavenCentral"] S4["Stage 4: custom task and clean-up<br/>typed Gradle task, no import, no Ant files"] S5["Stage 5: verify<br/>same jar entries, same classes"] S0 --> S1 --> S2 --> S3 --> S4 --> S5

Stage 0: the Ant and Ivy build

The sources are directly in src, not src/main/java, the JARs come from Ivy into lib, and the targets use the names that clash with Gradle’s lifecycle (clean, build).

tidepool/
  build.xml
  ivy.xml
  ivysettings.xml
  data/stations.csv
  src/io/tidepool/{engine,model}/*.java
  tools/ivy-2.6.0.jar
  tools/tidepool-ant-tasks.jar
build.xml
<?xml version="1.0" encoding="UTF-8"?>
<project name="tidepool" default="package" basedir="." xmlns:ivy="antlib:org.apache.ivy.ant">
  <property name="version" value="1.0.0"/>
  <property name="src.dir" location="src"/>
  <property name="lib.dir" location="lib"/>
  <property name="build.dir" location="build"/>
  <property name="classes.dir" location="${build.dir}/classes"/>
  <property name="dist.dir" location="${build.dir}/dist"/>

  <taskdef uri="antlib:org.apache.ivy.ant" resource="org/apache/ivy/ant/antlib.xml"
           classpath="${basedir}/tools/ivy-2.6.0.jar"/>
  <taskdef name="check-stations" classname="io.tidepool.ant.CheckStations"
           classpath="${basedir}/tools/tidepool-ant-tasks.jar"/>

  <path id="compile.classpath">
    <fileset dir="${lib.dir}" erroronmissingdir="false" includes="*.jar"/>
  </path>

  <target name="clean" description="Delete build output">
    <delete dir="${build.dir}"/>
  </target>

  <target name="resolve" description="Retrieve the compile configuration with Ivy">
    <ivy:settings file="${basedir}/ivysettings.xml"/>
    <ivy:retrieve conf="compile" pattern="${lib.dir}/[artifact]-[revision].[ext]"/>
  </target>

  <target name="prepare" description="Create the output directories">
    <mkdir dir="${classes.dir}"/>
    <mkdir dir="${dist.dir}"/>
  </target>

  <target name="build" depends="resolve,prepare" description="Compile the main sources">
    <javac srcdir="${src.dir}" destdir="${classes.dir}" classpathref="compile.classpath"
           release="17" includeantruntime="false"/>
  </target>

  <target name="package" depends="build" description="Package the library jar">
    <jar destfile="${dist.dir}/tidepool-${version}.jar" basedir="${classes.dir}"/>
  </target>

  <target name="check-data" description="Check data/stations.csv with the custom Ant task">
    <check-stations file="data/stations.csv" min="3"/>
  </target>
</project>
ivy.xml
<?xml version="1.0" encoding="UTF-8"?>
<ivy-module version="2.0">
  <info organisation="io.tidepool" module="tidepool" revision="1.0.0"/>
  <configurations>
    <conf name="compile" description="Needed to compile the main sources"/>
    <conf name="runtime" extends="compile" description="Needed at run time"/>
  </configurations>
  <dependencies>
    <dependency org="org.apache.commons" name="commons-csv" rev="1.14.1" conf="compile->default"/>
  </dependencies>
</ivy-module>
ivysettings.xml
<ivysettings>
  <settings defaultResolver="central"/>
  <caches defaultCacheDir="${basedir}/.ivy-cache"/>
  <resolvers>
    <ibiblio name="central" m2compatible="true"/>
  </resolvers>
</ivysettings>
ant package check-data
Buildfile: /work/ant-integration/build.xml

resolve:
[ivy:retrieve] :: Apache Ivy 2.6.0 - 20260712075753 :: https://ant.apache.org/ivy/ ::
[ivy:retrieve] :: loading settings :: file = /work/ant-integration/ivysettings.xml
[ivy:retrieve] :: resolving dependencies :: io.tidepool#tidepool;1.0.0
[ivy:retrieve] 	confs: [compile]
...
[ivy:retrieve] :: retrieving :: io.tidepool#tidepool
[ivy:retrieve] 	confs: [compile]
[ivy:retrieve] 	3 artifacts copied, 0 already retrieved (976kB/5ms)

prepare:
    [mkdir] Created dir: /work/ant-integration/build/classes
    [mkdir] Created dir: /work/ant-integration/build/dist

build:
    [javac] Compiling 6 source files to /work/ant-integration/build/classes

package:
      [jar] Building jar: /work/ant-integration/build/dist/tidepool-1.0.0.jar

check-data:
[check-stations] Checked 3 stations in stations.csv

BUILD SUCCESSFUL
Total time: 3 seconds

Ivy retrieved three JARs into lib (commons-csv, and the two that its POM depends on, commons-io and commons-codec), the compile used release="17", and the jar and the custom task ran. This is the reference build. For the Ivy side see Ivy Ant Tasks and Ivy: Module Descriptors & Configurations.

Stage 1: import the build

The first commit only adds a Gradle build file and a settings file with the project name. Nothing about the Ant files changes.

Kotlin DSL
ant.importBuild("build.xml")
Groovy DSL
ant.importBuild 'build.xml'
./gradlew clean package check-data
> Task :clean
> Task :resolve
> Task :prepare
> Task :build
> Task :package
> Task :check-data

BUILD SUCCESSFUL in 749ms
6 actionable tasks: 6 executed

Every Ant target, including the Ivy resolve, ran as a Gradle task, in the order of the depends attributes. No Ant output is shown, because javac, jar and the ivy:retrieve messages are INFO messages (compare the logging section above). The custom task is the same: it ran, and its report was hidden. Raising the Ant INFO level to the lifecycle level, as in the logging section, makes the Ant output visible:

./gradlew check-data
> Task :check-data
[ant:check-stations] Checked 3 stations in stations.csv

BUILD SUCCESSFUL in 596ms
1 actionable task: 1 executed

The imported targets with their descriptions, as ./gradlew tasks --all lists them in the "Other tasks" section:

-----------
build - Compile the main sources
check-data - Check data/stations.csv with the custom Ant task
clean - Delete build output
package - Package the library jar
prepare - Create the output directories
prepareKotlinBuildScriptModel
resolve - Retrieve the compile configuration with Ivy

(prepareKotlinBuildScriptModel is a Gradle task that IDEs use, and it appears in the Groovy listing as well.) This stage gives you a build that Gradle can run, with the same behavior and without the speed. The next stages replace what Gradle does better.

Stage 2: compile with Gradle

Applying the Java Library plugin brings compileJava, assemble, build, clean and check. The manual’s recipe has four steps: apply the plugin before the import, rename the Ant targets that clash, point the source set at the existing layout, and rewire the dependencies. The plugin’s clean and build collide with the Ant targets of the same names, so the transformer renames those two. The sources are read from the Ant property src.dir and the output goes to classes.dir, so Gradle compiles exactly where the Ant build did. The dependencies are still the JARs that Ivy retrieved.

Kotlin DSL
plugins {
    `java-library`
}

group = "io.tidepool"
version = "1.0.0"

// Targets "build" and "clean" collide with the lifecycle tasks of the Java plugin; the others keep their names.
ant.importBuild("build.xml") { antTargetName ->
    if (antTargetName == "build" || antTargetName == "clean") "ant_$antTargetName" else antTargetName
}

// Reuse the layout of the Ant build through its properties.
sourceSets {
    main {
        java.setSrcDirs(listOf(ant.properties["src.dir"]))
        java.destinationDirectory.set(file(ant.properties["classes.dir"] as String))
    }
}

dependencies {
    // Still the jars that Ivy retrieved into lib/ by the Ant "resolve" target.
    implementation(fileTree("lib") { include("*.jar") })
}

tasks.compileJava {
    options.release = 17
    dependsOn("prepare", "resolve")
}

// "package" no longer waits for the Ant compile; "assemble" builds the Ant jar instead of Gradle's.
tasks.named("package") {
    setDependsOn(listOf(tasks.compileJava))
}

tasks.assemble {
    setDependsOn(listOf("package"))
}
Groovy DSL
plugins {
    id 'java-library'
}

group = 'io.tidepool'
version = '1.0.0'

// Targets "build" and "clean" collide with the lifecycle tasks of the Java plugin; the others keep their names.
ant.importBuild('build.xml') { String antTargetName ->
    antTargetName == 'build' || antTargetName == 'clean' ? "ant_${antTargetName}".toString() : antTargetName
}

// Reuse the layout of the Ant build through its properties.
sourceSets {
    main {
        java {
            srcDirs = [ant.properties['src.dir']]
            destinationDirectory = file(ant.properties['classes.dir'])
        }
    }
}

dependencies {
    // Still the jars that Ivy retrieved into lib/ by the Ant "resolve" target.
    implementation fileTree('lib') { include '*.jar' }
}

tasks.named('compileJava') {
    options.release = 17
    dependsOn 'prepare', 'resolve'
}

// "package" no longer waits for the Ant compile; "assemble" builds the Ant jar instead of Gradle's.
tasks.named('package') {
    dependsOn = ['compileJava']
}

tasks.named('assemble') {
    dependsOn = ['package']
}
./gradlew assemble check-data
./gradlew assemble
> Task :resolve
> Task :prepare
> Task :compileJava
> Task :package
> Task :assemble
> Task :check-data

BUILD SUCCESSFUL in 790ms
5 actionable tasks: 5 executed
> Task :resolve
> Task :prepare
> Task :compileJava UP-TO-DATE
> Task :package
> Task :assemble

BUILD SUCCESSFUL in 714ms
4 actionable tasks: 3 executed, 1 up-to-date

compileJava replaced the Ant build target. It is incremental: on the second run it was UP-TO-DATE, while the Ant resolve and package targets ran again, because they have no declared inputs and outputs. The details of the Kotlin DSL fragment: java.setSrcDirs(listOf(...)) and java.destinationDirectory.set(...) configure the source directory set; dependsOn("prepare", "resolve") adds the Ant targets that make the directories and fetch the JARs; and setDependsOn(listOf(...)) replaces the dependencies, which is what detaches package from the Ant build target and assemble from Gradle’s own jar. The Groovy form assigns dependsOn = [...]. The jar task of the plugin is not used yet; the Ant package target still builds the jar.

The assignment options.release = 17 replaces the release="17" of the Ant javac. Gradle’s toolchains are the better way to say which JDK compiles, see Toolchains and Java Projects.

Stage 3: dependencies, from Ivy to Gradle

The manual’s three steps for Ivy are to move the <dependency> elements into the dependencies block, to move the configurations that a standard one cannot replace, and to move the resolvers of the Ivy settings into repositories. In tidepool the ivy.xml has one dependency, with conf="compile->default", and the settings file names Maven Central, so it becomes a dependency on the api configuration and mavenCentral():

Ivy Gradle

<dependency org="org.apache.commons" name="commons-csv" rev="1.14.1" conf="compile->default"/>

api("org.apache.commons:commons-csv:1.14.1")

<ibiblio name="central" m2compatible="true"/> and defaultResolver="central"

mavenCentral()

<ivy:retrieve conf="compile" .../>

nothing; a Copy task if files must be copied

<caches defaultCacheDir=.../>

the Gradle user home dependency cache

More of this table is in Migrating Between Tools, and the Gradle side of the vocabulary is in Declaring Dependencies & Configurations and Repositories & Content Filtering; versions that the Ant build kept in properties move into Version Catalogs. The changed part of the build file:

Kotlin DSL
repositories {
    mavenCentral()
}

// The <dependency> of ivy.xml, with conf="compile->default" written as "api".
dependencies {
    api("org.apache.commons:commons-csv:1.14.1")
}

// Ivy's <retrieve> copied the jars into lib/; Gradle needs no such step, but a Copy task can do it.
tasks.register<Copy>("retrieveRuntimeDependencies") {
    from(configurations.runtimeClasspath)
    into(layout.buildDirectory.dir("libs"))
}

tasks.compileJava {
    options.release = 17
    dependsOn("prepare")
}
Groovy DSL
repositories {
    mavenCentral()
}

// The <dependency> of ivy.xml, with conf="compile->default" written as "api".
dependencies {
    api 'org.apache.commons:commons-csv:1.14.1'
}

// Ivy's <retrieve> copied the jars into lib/; Gradle needs no such step, but a Copy task can do it.
tasks.register('retrieveRuntimeDependencies', Copy) {
    from configurations.runtimeClasspath
    into layout.buildDirectory.dir('libs')
}

tasks.named('compileJava') {
    options.release = 17
    dependsOn 'prepare'
}

The resolve dependency is gone from compileJava, the lib directory is not created any more, and the Ivy cache is not used. The retrieveRuntimeDependencies task is only there to prove the equivalence:

./gradlew assemble check-data retrieveRuntimeDependencies
./gradlew dependencies --configuration runtimeClasspath
> Task :prepare
> Task :compileJava
> Task :package
> Task :assemble
> Task :check-data
> Task :retrieveRuntimeDependencies

BUILD SUCCESSFUL in 819ms
5 actionable tasks: 5 executed
------------------------------------------------------------

runtimeClasspath - Runtime classpath of source set 'main'.
\--- org.apache.commons:commons-csv:1.14.1
     +--- commons-io:commons-io:2.20.0
     \--- commons-codec:commons-codec:1.19.0

and the three files that it copied into build/libs:

commons-codec-1.19.0.jar
commons-csv-1.14.1.jar
commons-io-2.20.0.jar

These are exactly the three JARs that the Ivy retrieve copied into lib in stage 0 (commons-codec-1.19.0.jar, commons-csv-1.14.1.jar and commons-io-2.20.0.jar). Gradle resolved the same versions because both tools read the POM of commons-csv from Maven Central. When the Ant build keeps its JARs in a lib directory without Ivy, the manual gives two routes: a flat-directory repository for the files that are named name-version.jar, and file dependencies for the rest; that case, with its run, is in Migrating Between Tools. Two Ivy ideas have no counterpart in Gradle: lock strategies (the dependency cache is safe for concurrent builds) and latest strategies (there is one conflict rule, the newest version wins, and resolutionStrategy overrides it). The replacement of the Ivy conflict managers is in Dependency Resolution, Conflicts & Capabilities.

Stage 4: the custom Ant task

The manual gives two options for custom Ant tasks: use the existing task from Gradle, which is quick but needs the incremental build API for it to take part in up-to-date checks and has awkward Ant paths and filesets, or rewrite it as a Gradle task type, which is the better long-term choice because task types are typed, simpler and incremental. Both are shown.

The first is the taskdef in a Gradle task from the custom tasks section above, where the JAR sits on a configuration (checkStations there). It replaces the imported check-data, and check depends on it; for the log message to be visible the build file raises the Ant INFO messages to the lifecycle level, as in the logging section. At this stage the check-data Ant target and its taskdef can still stay in build.xml. They are unused.

The second is a task class with declared inputs and outputs. It does what the Ant task did, writes a report file so that Gradle has an output to track, and fails the same way. The class is in the build script here, because it is a few lines; in a real build it belongs to a build-logic included build or buildSrc (see Convention Plugins & Sharing Build Logic and Writing Custom Tasks). The stage also removes the rest of the Ant build: Gradle’s jar task writes the jar where the Ant package target did, so build.xml, ivy.xml, ivysettings.xml, tools and lib are all deleted and there is no ant.importBuild left.

Kotlin DSL
plugins {
    `java-library`
}

group = "io.tidepool"
version = "1.0.0"

repositories {
    mavenCentral()
}

dependencies {
    api("org.apache.commons:commons-csv:1.14.1")
}

// The sources are still directly in src/; moving them to src/main/java is a separate commit.
sourceSets {
    main {
        java.setSrcDirs(listOf("src"))
    }
}

tasks.compileJava {
    options.release = 17
}

// Where the Ant "package" target wrote the jar.
tasks.jar {
    destinationDirectory = layout.buildDirectory.dir("dist")
}

// The custom Ant task, rewritten as a task type with declared inputs and outputs.
abstract class CheckStations : DefaultTask() {
    @get:InputFile
    @get:PathSensitive(PathSensitivity.NONE)
    abstract val stations: RegularFileProperty

    @get:Input
    abstract val minStations: Property<Int>

    @get:OutputFile
    abstract val report: RegularFileProperty

    @TaskAction
    fun check() {
        val file = stations.get().asFile
        val lines = file.readLines()
        if (lines.firstOrNull() != "id,name,latitude,longitude,constituents") {
            throw GradleException("$file must start with the header id,name,latitude,longitude,constituents")
        }
        val count = lines.size - 1
        if (count < minStations.get()) {
            throw GradleException("${file.name} has $count stations, at least ${minStations.get()} required")
        }
        report.get().asFile.writeText("$count stations\n")
        logger.lifecycle("Checked $count stations in ${file.name}")
    }
}

val checkStations = tasks.register<CheckStations>("checkStations") {
    group = "verification"
    description = "Checks data/stations.csv"
    stations = layout.projectDirectory.file("data/stations.csv")
    minStations = 3
    report = layout.buildDirectory.file("reports/stations.txt")
}

tasks.check {
    dependsOn(checkStations)
}
Groovy DSL
plugins {
    id 'java-library'
}

group = 'io.tidepool'
version = '1.0.0'

repositories {
    mavenCentral()
}

dependencies {
    api 'org.apache.commons:commons-csv:1.14.1'
}

// The sources are still directly in src/; moving them to src/main/java is a separate commit.
sourceSets {
    main {
        java {
            srcDirs = ['src']
        }
    }
}

tasks.named('compileJava') {
    options.release = 17
}

// Where the Ant "package" target wrote the jar.
tasks.named('jar') {
    destinationDirectory = layout.buildDirectory.dir('dist')
}

// The custom Ant task, rewritten as a task type with declared inputs and outputs.
abstract class CheckStations extends DefaultTask {
    @InputFile
    @PathSensitive(PathSensitivity.NONE)
    abstract RegularFileProperty getStations()

    @Input
    abstract Property<Integer> getMinStations()

    @OutputFile
    abstract RegularFileProperty getReport()

    @TaskAction
    void check() {
        def file = stations.get().asFile
        def lines = file.readLines()
        if (lines.isEmpty() || lines[0] != 'id,name,latitude,longitude,constituents') {
            throw new GradleException("${file} must start with the header id,name,latitude,longitude,constituents")
        }
        int count = lines.size() - 1
        if (count < minStations.get()) {
            throw new GradleException("${file.name} has ${count} stations, at least ${minStations.get()} required")
        }
        report.get().asFile.text = "${count} stations\n"
        logger.lifecycle("Checked ${count} stations in ${file.name}")
    }
}

def checkStations = tasks.register('checkStations', CheckStations) {
    group = 'verification'
    description = 'Checks data/stations.csv'
    stations = layout.projectDirectory.file('data/stations.csv')
    minStations = 3
    report = layout.buildDirectory.file('reports/stations.txt')
}

tasks.named('check') {
    dependsOn checkStations
}
./gradlew --configuration-cache build
./gradlew --configuration-cache build
Calculating task graph as no cached configuration is available for tasks: build
> Task :processResources NO-SOURCE
> Task :processTestResources NO-SOURCE

> Task :checkStations
Checked 3 stations in stations.csv

> Task :compileJava
> Task :classes
> Task :compileTestJava NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test NO-SOURCE
> Task :check
> Task :jar
> Task :assemble
> Task :build

BUILD SUCCESSFUL in 600ms
3 actionable tasks: 3 executed
Configuration cache entry stored.
Reusing configuration cache.
> Task :processResources NO-SOURCE
> Task :checkStations UP-TO-DATE
> Task :processTestResources NO-SOURCE
> Task :compileJava UP-TO-DATE
> Task :classes UP-TO-DATE
> Task :compileTestJava NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :jar UP-TO-DATE
> Task :assemble UP-TO-DATE
> Task :test NO-SOURCE
> Task :check UP-TO-DATE
> Task :build UP-TO-DATE

BUILD SUCCESSFUL in 513ms
3 actionable tasks: 3 up-to-date
Configuration cache entry reused.

There is no import left, so nothing prevents the configuration cache: the first run stored an entry and the second reused it, with every task UP-TO-DATE, including checkStations, which has a declared input file, a declared property and a declared output file. The Groovy build prints the same lines in a different order for processResources and processTestResources, which Gradle does not fix. A failing check is the same message the Ant task produced, now from Gradle:

./gradlew checkStations

(run with minStations raised to 5)

> Task :checkStations FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':checkStations' (registered in build file 'build.gradle.kts').
> stations.csv has 3 stations, at least 5 required

Stage 5: check that the two builds agree

The manual treats the comparison of artifacts as an essential step, because even a small change such as the contents of a manifest can break a deployment. The jar from the Ant package target (stage 3, which still ran the Ant jar) and the jar from Gradle’s jar task (stage 4) have the same entries:

no difference

(jar tf of each jar, sorted, then diff.) Their class files are byte-identical as well. These are the first 16 characters of the SHA-256 of each class in the Ant jar and the Gradle jar:

io/tidepool/engine/StationReader.class c3c8dceda692ba27 c3c8dceda692ba27
io/tidepool/engine/TideEngine.class 8f983de0e7875875 8f983de0e7875875
io/tidepool/model/Constituent.class 3394980f18831975 3394980f18831975
io/tidepool/model/Height.class c462e026766a42e4 c462e026766a42e4
io/tidepool/model/Station.class 1e0065751b6e13be 1e0065751b6e13be
io/tidepool/model/TideEvent.class 09d849a667f023cb 09d849a667f023cb

The jars are not identical, and that is expected: the manifests differ. The Ant jar task wrote Ant-Version and Created-By lines, and Gradle’s jar wrote only Manifest-Version:

Manifest-Version: 1.0
Ant-Version: Apache Ant 1.10.17
Created-By: 26+35-2893 (Oracle Corporation)

---
Manifest-Version: 1.0

If a consumer depends on those attributes, add them to the jar task’s manifest before you declare victory. Gradle 9 writes archives reproducibly by default (see Publishing). The Gradle build also stopped writing to lib, so a checkout does not need ant resolve any more. When you are satisfied, the cleanup the manual describes remains: move the sources to src/main/java, delete the remaining overrides of the layout, and delete the files of the Ant build.

Which route to take

Situation Route Why

A small Ant build that mostly compiles, tests and packages

Rewrite from scratch with the Java Library plugin, mavenCentral() and the tasks above

Nothing in the Ant file is worth importing, and the result is faster and cacheable at once

A large or unusual Ant build, many targets, many people

Import first, replace one target at a time (stages 1 to 5)

The build never stops working and the team can review each step

Ant tasks with no Gradle equivalent (checksum, a custom task, an FTP upload)

Keep using them through ant, from a configuration

They need no migration; keep the call in a task action that uses Task.ant

A mixed estate where one Ant build must stay

Keep the import for that project only

The other projects migrate; the Ant project gives up the configuration cache until it is converted

The same idea in the other two tools

Need Gradle Maven Ant

Run Ant tasks inside the build

ant (AntBuilder) in a task action

maven-antrun-plugin, with the Ant version of the plugin, not the installed one

(native)

Use a build file written for the other tool

ant.importBuild("build.xml"), one task for each target

maven-antrun-plugin runs Ant targets from a phase; the targets do not become Maven goals

exec of mvn or gradle; for dependencies, Maven Resolver Ant Tasks

Use custom Ant tasks

taskdef with a classpath from a configuration

taskdef in the AntRun target, with the JAR as a plugin dependency

taskdef, typedef, antlibs (Antlibs & Namespaces)

Get the same artifacts from the new build

compare jars (stage 5)

compare jars

(reference build)

The cross-tool comparison is in Comparing Ant, Maven & Gradle; for the Ant that Gradle embeds see Embedding Ant.

Gradle 8.14 and the Ant integration

The Ant integration page of the 8.14.6 manual was compared with the 9.8.1 page, and the only change in the text is the sentence that says the configuration cache is turned off when an Ant build is imported. The examples are the same apart from the command (gradle and ./gradlew) and a shorter form of the pmd dependency. The code on this page should therefore run on 8.14, with these differences, none of which was run here: the embedded Ant is older (the 8.12 upgrade guide names 1.10.15), the Kotlin DSL form by configurations.creating, which 9.8.1 deprecates with a pointer to the Gradle 9.6 upgrading guide, is not yet deprecated, and the configuration cache is off by default in both 8.14 and 9.8.1, so its behavior on this page applies when it is requested with --configuration-cache. The removal of jcenter() in 9.0 matters to old Ant-era builds that used it as a repository.

Where the books are outdated

The Gradle in Action chapter on migration (Gradle 1.7) predates several things on this page. The checks are against the 9.8.1 manual.

Older practice Current status

compile, runtime and testCompile configurations in the dependencies block of the migrated build

Removed in Gradle 7.0; use implementation, api, compileOnly, runtimeOnly and testImplementation

The project and its objects used freely while tasks execute, including project.ant

With the configuration cache (stable in 8.1, preferred since 9.0) the project’s ant object cannot be captured; the ant of the task works

sourceCompatibility for the Java level

Java toolchains are preferred, and options.release replaces the release attribute of Ant’s javac

jcenter() as the repository for Ant-era dependencies

Removed in 9.0; use mavenCentral()

task name << { }

Removed in 5.0; use doLast and tasks.register

The Ant side has moved on too. The Ant inside Gradle 9.8.1 is 1.10.17 and contains only the core tasks, so a task from an optional library needs its JAR on a taskdef classpath, as in the section above.