Migrating Between Tools

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 (with Apache Ivy 2.6.0 and the Maven Resolver Ant Tasks 2.0.0), Apache Maven 3.10.0 and Gradle 9.8.1, and every build on it was run on JDK 26 on macOS, checked on 2026-10-16. The sources are compiled with release 17, so the same builds also work on JDK 17 and 21. It covers the four moves people actually make: Ant to Maven, Ant to Gradle, Maven to Gradle, and Ivy to the Maven Resolver Ant Tasks. The two shared pages that come before it explain the vocabulary: Build Automation Fundamentals and Dependency Management Fundamentals, and Comparing Ant, Maven & Gradle helps to decide whether to move at all. The tool-specific companions are Ant Deprecations & Migration, Gradle Ant Integration and Migrating from Maven to Gradle, which go deeper into their half of each path.

The running scenario is tidepool, a small toolkit that predicts tide heights from harmonic constituents. This page only needs one corner of it, a single module that holds the Station, Constituent, TideEvent and Height records and the StationReader and TideEngine classes, with commons-csv as its only runtime dependency, a JUnit test and a data file data/stations.csv. Paths in the program output are shortened to … where the scratch directory would otherwise fill the line. The Gradle commands are written as ./gradlew; the outputs shown were produced by the Gradle 9.8.1 distribution (the wrapper pins the same release and prints the same output, apart from its own download messages).

Migration paths between Ant, Maven and Gradle, with Ivy and Resolver Ant Tasks

Before you migrate

A build is the one piece of the project that every developer and every pipeline runs all day, so a migration that "mostly works" is worse than none. The Gradle user manual gives the sequence that applies to all three directions: keep the old build next to the new one, do not change the source layout until you are ready to switch, and write down how you will verify that both builds produce the same artifacts. Even a different line in a JAR manifest can break a deployment.

flowchart LR A[Old build keeps running in CI] --> B[Add the new build beside it] B --> C[Run both on the same commit] C --> D{Same artifacts, dependencies and test results?} D -- no --> E[Fix the new build] E --> C D -- yes --> F[Switch CI and releases to the new build] F --> G[Delete the old build files]

When to move, and when to stay

Situation Reasonable choice Why

A working Ant build that rarely changes and has few dependencies

Keep it

Ant 1.10.18 is maintained and runs on Java 8 or later; the cost of the move is real and the benefit is small

An Ant build with a lib/ directory of JARs checked into the repository

Move dependencies first, to Ivy, the Maven Resolver Ant Tasks, or to Maven or Gradle

Hand-managed JARs hide transitive dependencies and versions (see the Gradle flatDir example below)

An Ant build that must stay but needs Maven Central dependencies

Ivy to Maven Resolver Ant Tasks, or keep Ivy

Both resolve from Maven repositories; the Resolver Ant Tasks use the same resolver library as Maven

A Maven build that fights its lifecycle (many antrun executions, long profile chains)

Gradle

A task graph models custom steps directly instead of binding them to fixed phases

A Maven build that works, in a team that knows Maven

Stay, and consider Maven 4 (release candidate)

See Migrating to Maven 4

A mixed estate (some Ant, some Maven, some Gradle)

Bridge, do not rewrite

maven-antrun-plugin runs Ant from Maven, ant.importBuild runs Ant from Gradle, and a Maven repository connects the artifacts

The starting point: a classic Ant build

The legacy build is a single build.xml with dependencies in a lib/ directory and the sources directly in src/, a layout that predates the Maven convention.

legacy/
  build.xml
  data/stations.csv
  lib/commons-csv-1.14.1.jar
  src/io/tidepool/engine/StationReader.java
  src/io/tidepool/engine/TideEngine.java
  src/io/tidepool/model/Constituent.java
  src/io/tidepool/model/Height.java
  src/io/tidepool/model/Station.java
  src/io/tidepool/model/TideEvent.java
legacy/build.xml
<?xml version="1.0" encoding="UTF-8"?>
<project name="tidepool" default="jar" basedir=".">
  <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"/>

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

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

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

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

  <target name="check-data" description="Fail if data/stations.csv has no header row">
    <loadfile property="stations.header" srcfile="data/stations.csv">
      <filterchain><headfilter lines="1"/><striplinebreaks/></filterchain>
    </loadfile>
    <fail message="data/stations.csv must start with the header id,name,latitude,longitude,constituents">
      <condition>
        <not><equals arg1="${stations.header}" arg2="id,name,latitude,longitude,constituents"/></not>
      </condition>
    </fail>
    <echo>stations.csv header OK: ${stations.header}</echo>
  </target>
</project>
ant jar
Buildfile: …/legacy/build.xml

compile:
    [mkdir] Created dir: …/legacy/build/classes
    [javac] Compiling 6 source files to …/legacy/build/classes

jar:
      [jar] Building jar: …/legacy/build/tidepool-1.0.0.jar

BUILD SUCCESSFUL
Total time: 0 seconds

The jar target ran compile first because of depends; check-data is an independent target that the Maven and Gradle sections below reuse. Run on its own it prints:

ant check-data
Buildfile: …/legacy/build.xml

check-data:
     [echo] stations.csv header OK: id,name,latitude,longitude,constituents

BUILD SUCCESSFUL
Total time: 0 seconds

Ant to Maven

Maven replaces the imperative build file with a declarative model: instead of writing the steps, the pom.xml declares coordinates, dependencies and a few plugin settings, and the lifecycle supplies the steps. Three things change, in this order of effort: the directory layout, the dependencies and the build logic.

Directory layout

Maven finds sources by convention. Ant lets every project choose, so check which properties your build.xml uses and move the files to where Maven looks:

Ant property or directory Maven location Notes

src.dir (here src)

src/main/java

Main Java sources; change pom.xml only if moving files is impossible (<sourceDirectory>), which defeats the convention

test sources (test, src-test)

src/test/java

Tests run in a separate phase, not in the jar target

resources next to sources, or copied with copy

src/main/resources

Copied by resources:resources; filtering is opt-in

lib.dir (here lib)

declared dependencies

No JARs are committed; they come from repositories

build.dir and classes.dir

target and target/classes

Output directory, never committed

data files used only by tooling (data)

anywhere

Maven does not care; keep them out of src/main/resources unless they ship in the JAR

Forgetting to move the sources is the classic mistake, and it is silent. With the pom.xml below but the sources still in src/:

[INFO] --- compiler:3.16.0:compile (default-compile) @ tidepool ---
[INFO] Loaded 74 auto-discovered prefixes for remote repository apache.snapshots (prefixes-apache.snapshots.txt)
[INFO] No sources to compile
[INFO]
[INFO] --- resources:3.5.0:testResources (default-testResources) @ tidepool ---
[INFO] skip non existing resourceDirectory …/maven-nolayout/src/test/resources
[INFO]
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ tidepool ---
[INFO] No sources to compile
[INFO]
[INFO] --- surefire:3.6.0:test (default-test) @ tidepool ---
[INFO] No tests to run.
[INFO]
[INFO] --- jar:3.5.1:jar (default-jar) @ tidepool ---
[WARNING] JAR will be empty - no content was marked for inclusion!
[INFO] Building jar: …/maven-nolayout/target/tidepool-1.0.0.jar
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS

The build ends with BUILD SUCCESS and an empty JAR; the single hint is [WARNING] JAR will be empty - no content was marked for inclusion!. Gradle behaves the same way for a missing src/main/java: the compileJava task is reported as NO-SOURCE. A migration that verifies artifacts, as described in the verification section, catches it.

Dependencies

Each JAR in lib/ becomes a coordinate groupId:artifactId:version. The name of the JAR is often enough (commons-csv-1.14.1.jar is org.apache.commons:commons-csv:1.14.1); for a JAR with no version in its name, compute its checksum and search Maven Central by it, or read META-INF/MANIFEST.MF and META-INF/maven/ inside it. Declare only the direct dependencies: Maven reads the POM of each one and adds its own dependencies, so the lib/ directory of a Maven-style build would also contain commons-io and commons-codec. The Ant build above compiled without them only because commons-csv does not need them at compile time, but running StationReader with only that lib/ fails as soon as the parser touches them (a small Run class that reads data/stations.csv, run with java -cp ../noclass:build/classes:lib/commons-csv-1.14.1.jar Run):

Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/io/output/AppendableOutputStream
	at io.tidepool.engine.StationReader.read(Unknown Source)
	at Run.main(Run.java:7)
	…

The Maven build gets the missing JARs automatically, as shown by the dependency tree further down.

maven/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>io.tidepool</groupId>
  <artifactId>tidepool</artifactId>
  <version>1.0.0</version>

  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.build.outputTimestamp>2026-10-16T00:00:00Z</project.build.outputTimestamp>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-csv</artifactId>
      <version>1.14.1</version>
    </dependency>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>6.1.3</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.16.0</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.6.0</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.5.1</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-resources-plugin</artifactId>
        <version>3.5.0</version>
      </plugin>
    </plugins>
  </build>
</project>

The JUnit dependency has the test scope, so it never reaches the JAR; the Ant lib/ directory had no such separation. Plugin versions are pinned, as Maven 3.10 no longer manages them in the super POM. In the POM, project.build.outputTimestamp makes the JAR reproducible. The scope names map to Ivy configurations and Gradle configurations in the mapping table of Dependency Management Fundamentals.

mvn -B -ntp package
…
[INFO] --- resources:3.5.0:resources (default-resources) @ tidepool ---
[INFO] skip non existing resourceDirectory …/maven/src/main/resources
[INFO]
[INFO] --- compiler:3.16.0:compile (default-compile) @ tidepool ---
[INFO] Loaded 74 auto-discovered prefixes for remote repository apache.snapshots (prefixes-apache.snapshots.txt)
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 6 source files with javac [debug release 17] to target/classes
[INFO]
[INFO] --- resources:3.5.0:testResources (default-testResources) @ tidepool ---
[INFO] skip non existing resourceDirectory …/maven/src/test/resources
[INFO]
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ tidepool ---
[INFO] Recompiling the module because of changed dependency.
[INFO] Compiling 1 source file with javac [debug release 17] to target/test-classes
[INFO]
[INFO] --- surefire:3.6.0:test (default-test) @ tidepool ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running io.tidepool.engine.StationReaderTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.030 s -- in io.tidepool.engine.StationReaderTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO]
[INFO] --- jar:3.5.1:jar (default-jar) @ tidepool ---
[INFO] Building jar: …/maven/target/tidepool-1.0.0.jar
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS

From Ant tasks to Maven plugins

Ant task Maven plugin goal Gradle

javac

compiler:compile and compiler:testCompile (maven.compiler.release)

JavaCompile through the java plugin

jar

jar:jar (manifest through maven-archiver)

Jar

junit, junitlauncher

surefire:test for unit tests, failsafe:integration-test and failsafe:verify for integration tests

Test and a second test suite

copy with filterset

resources:resources with <filtering>true</filtering>

processResources with expand

javadoc

javadoc:javadoc and javadoc:jar

Javadoc

zip, tar for a distribution

assembly:single

Zip, Tar and the distribution plugin

exec, java

exec-maven-plugin from MojoHaus (third party), or antrun

Exec, JavaExec

scp, ftp for deployment

deploy:deploy with distributionManagement

maven-publish

maven-antrun-plugin as a bridge

Not everything translates on the first day: a custom task, a data check, a legacy packaging step. maven-antrun-plugin (3.2.0 here) has one goal, run, which runs Ant tasks from a Maven build and gives you a stepping stone: Maven owns the lifecycle and the dependencies, and Ant keeps doing what has not been rewritten. The <target> element holds Ant tasks inline or, as here, calls an existing target of the old build.xml through the ant task.

maven-antrun/pom.xml (added to the build element)
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-antrun-plugin</artifactId>
        <version>3.2.0</version>
        <executions>
          <execution>
            <id>legacy-check-data</id>
            <phase>verify</phase>
            <goals>
              <goal>run</goal>
            </goals>
            <configuration>
              <target>
                <ant antfile="${project.basedir}/legacy/build.xml" dir="${project.basedir}" target="check-data"/>
              </target>
            </configuration>
          </execution>
        </executions>
      </plugin>
mvn -B -ntp verify
…
[INFO] --- jar:3.5.1:jar (default-jar) @ tidepool ---
[INFO] Building jar: …/maven-antrun/target/tidepool-1.0.0.jar
[INFO]
[INFO] --- antrun:3.2.0:run (legacy-check-data) @ tidepool ---
[INFO] Executing tasks
[WARNING]      [echo] stations.csv header OK: id,name,latitude,longitude,constituents
[INFO] Executed tasks
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS

Two details deserve attention. First, Ant output arrives through Maven’s logger, and in this run the echo message appears at WARNING level, which is not a failure. Second, the Ant version that runs inside the plugin is the one the plugin depends on, not the one installed on the machine, so Ant features newer than the plugin’s Ant are not available, and extra Ant tasks (JSch, JUnit launcher) must be added as plugin dependencies. The Gradle page on Maven migration says the same from the other side: Gradle has no AntRun plugin because Ant is built in.

Ant to Gradle

Gradle can import an Ant build and turn each target into a task, so the migration can be incremental: the first commit only adds the import, and every later commit replaces one target with a native task.

Importing the build with ant.importBuild

Kotlin DSL
// Import every Ant target of build.xml as a Gradle task.
ant.importBuild("build.xml") { antTargetName ->
    "ant-$antTargetName"
}

// Make a native Gradle lifecycle task depend on an imported Ant target.
tasks.register("assemble") {
    group = "build"
    description = "Builds the jar through the imported Ant build"
    dependsOn("ant-jar")
}
Groovy DSL
// Import every Ant target of build.xml as a Gradle task.
ant.importBuild('build.xml') { antTargetName ->
    "ant-${antTargetName}".toString()
}

// Make a native Gradle lifecycle task depend on an imported Ant target.
tasks.register('assemble') {
    group = 'build'
    description = 'Builds the jar through the imported Ant build'
    dependsOn 'ant-jar'
}
./gradlew ant-clean assemble
> Task :ant-clean
> Task :ant-compile
> Task :ant-jar
> Task :assemble

BUILD SUCCESSFUL in 382ms
3 actionable tasks: 3 executed

The transformer in braces renames each Ant target on import. Without it the tasks keep their Ant names, and a target that shares its name with a Gradle task breaks the build as soon as a plugin adds that task. With the java-library plugin applied and ant.importBuild("build.xml") without a transformer, Gradle 9.8.1 stops with:

FAILURE: Build failed with an exception.

* Where:
Build file '…/clash/build.gradle.kts' line: 2

* What went wrong:
Cannot add task 'jar' as a task with that name already exists.

Ant description attributes become task descriptions, so ./gradlew tasks --all lists ant-check-data - Fail if data/stations.csv has no header row under "Other tasks". Dependencies between targets (depends) are kept. The output of most Ant tasks is logged at a lower level than the default: with --info the run above also prints [ant:delete] Deleting directory …/build, [ant:mkdir] Created dir: …/build/classes and [ant:javac] Compiling 6 source files to …/build/classes, while echo output is shown by default as [ant:echo]. The Ant properties are available as ant.properties["src.dir"] in Kotlin and ant.properties['src.dir'] in Groovy.

Importing has two limits to plan around. The imported Ant build is not supported by the configuration cache, which has been the preferred execution mode since Gradle 9.0, so you get none of its speed until the conversion is finished (the output of a run with --configuration-cache is shown below). And the Ant build still has to be maintained.

Replacing targets one at a time

The useful property of an imported build is that its targets are real tasks: you can change their dependencies, attach actions, or stop depending on them. The Gradle manual describes the sequence for a library: apply the Java Library plugin, rename the Ant build target if there is one, point the source sets at the existing layout, and rewire dependsOn. If you have already moved the sources to the standard layout, as in the Maven section, it is shorter. In this example only check-data is kept as an Ant target, the compile and package work is done by native tasks, and the version catalog holds the dependency (it is the file gradle init generated in the next section).

Kotlin DSL
plugins {
    `java-library`
}

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

repositories {
    mavenCentral()
}

dependencies {
    api(libs.org.apache.commons.commons.csv)
    testImplementation(platform("org.junit:junit-bom:6.1.3"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.withType<JavaCompile>().configureEach {
    options.release = 17
}

tasks.test {
    useJUnitPlatform()
}

// Keep one legacy Ant target for now: only "check-data" is imported as a task.
ant.importBuild("build.xml") { antTargetName -> "ant-$antTargetName" }
tasks.check {
    dependsOn("ant-check-data")
}
Groovy DSL
plugins {
    id 'java-library'
}

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

repositories {
    mavenCentral()
}

dependencies {
    api libs.org.apache.commons.commons.csv
    testImplementation platform('org.junit:junit-bom:6.1.3')
    testImplementation 'org.junit.jupiter:junit-jupiter'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.withType(JavaCompile).configureEach {
    options.release = 17
}

tasks.named('test') {
    useJUnitPlatform()
}

// Keep one legacy Ant target for now: only "check-data" is imported as a task.
ant.importBuild('build.xml') { String antTargetName -> "ant-${antTargetName}".toString() }
tasks.named('check') {
    dependsOn 'ant-check-data'
}
./gradlew clean build
> Task :clean UP-TO-DATE
> Task :compileJava
> Task :processResources NO-SOURCE
> Task :classes
> Task :jar
> Task :assemble

> Task :ant-check-data
[ant:echo] stations.csv header OK: id,name,latitude,longitude,constituents

> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test
> Task :check
> Task :build

BUILD SUCCESSFUL in 1s
6 actionable tasks: 5 executed, 1 up-to-date

ant-compile and ant-jar are still imported but nothing depends on them any more, and they would not work with the new layout: only targets the build still needs have to remain valid. When the last Ant target is gone, delete build.xml and the ant.importBuild line. With the import still in place, the configuration cache cannot be used:

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

> Task :ant-check-data
[ant:echo] stations.csv header OK: id,name,latitude,longitude,constituents

> Task :compileJava UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :compileTestJava UP-TO-DATE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test UP-TO-DATE
> Task :check

Some tasks or features in this build are not compatible with the configuration cache.
See the complete report at file://…/gradle-hybrid/build/reports/configuration-cache/cq6p1iurtu84mf8oegrns4bjq/8ta2gavd4tua0u9k4larjub7c/configuration-cache-report.html

BUILD SUCCESSFUL in 304ms
4 actionable tasks: 1 executed, 3 up-to-date
Configuration cache disabled because incompatible task was found.

The build succeeds, and Gradle falls back to running without the cache; the last line is the message to look for in CI logs.

Dependencies: lib directories and Ivy

A lib/ directory has two migration paths in the manual. The first is a flat-directory repository, which lets the build resolve JARs named <module>-<version>.jar by coordinates and is the easiest on-ramp to managed dependencies:

Kotlin DSL
plugins {
    `java-library`
}

repositories {
    flatDir {
        name = "libs dir"
        dir(file("libs"))
    }
}

dependencies {
    implementation(":commons-csv:1.14.1")
}

tasks.withType<JavaCompile>().configureEach {
    options.release = 17
}
Groovy DSL
plugins {
    id 'java-library'
}

repositories {
    flatDir {
        name = 'libs dir'
        dir file('libs')
    }
}

dependencies {
    implementation ':commons-csv:1.14.1'
}

tasks.withType(JavaCompile).configureEach {
    options.release = 17
}
./gradlew compileJava dependencies --configuration runtimeClasspath
> Task :compileJava

> Task :dependencies

------------------------------------------------------------
Root project 'tidepool'
------------------------------------------------------------

runtimeClasspath - Runtime classpath of source set 'main'.
\--- :commons-csv:1.14.1

A web-based, searchable dependency report is available by adding the --scan option.

BUILD SUCCESSFUL in 482ms
2 actionable tasks: 2 executed
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.8.1/userguide/configuration_cache_enabling.html

Compilation works, and the runtime classpath shows the weakness of the approach: it contains one JAR, because a flat directory has no metadata, so commons-io and commons-codec are missing and would fail at run time. Moving to mavenCentral() fixes that, since Gradle then reads the POM of commons-csv. Un-versioned JARs have to be attached as file dependencies (implementation(files("libs/our-custom.jar"))), which Gradle cannot check for updates or conflicts.

Ivy to Gradle is, in the words of the manual, usually straightforward, because both tools have configurations and repositories. The three steps are to copy the <dependency> entries into a dependencies {} block using the standard configurations of the plugins you apply, to copy any custom <configurations> that the standard ones cannot replace, and to copy the resolvers of ivysettings.xml into repositories {}. Ivy’s three phases (configure, resolve, retrieve) become two in Gradle: configuration and resolution happen automatically and retrieval does not exist unless you write a task, because Gradle builds classpaths straight from its cache.

Ivy Gradle

<conf name="compile"/>

implementation (or api in a library)

<conf name="runtime" extends="compile"/>

runtimeOnly (the runtime classpath already includes implementation)

<conf name="test"/>

testImplementation and testRuntimeOnly

<conf name="provided"/>

compileOnly

conf="compile->default"

nothing to write: Gradle selects the variant of the other module that matches the configuration

<dependency org name rev/>

group:name:version

rev="latest.integration" or rev="1.+"

dynamic versions; lock them with dependency locking

conflict managers

resolutionStrategy, constraints, and Gradle’s "highest version wins"

ivy:retrieve

not needed; use a Sync or Copy task when files must be copied

ivy:publish and ivy:makepom

maven-publish (or ivy-publish)

Gradle can also read Ivy repositories (ivy {} in repositories {}), which is the way to keep consuming modules that an Ivy build publishes while the producer is migrated. The same conflict and version rules are in the Ivy conflicts and versions page.

Ivy to the Maven Resolver Ant Tasks

If the goal is only to stop checking JARs into the repository, or to leave Ivy behind without leaving Ant, the Maven Resolver Ant Tasks (2.0.0) are the smaller step: the same Ant build, but dependency resolution done by the library that Maven itself uses, with Maven coordinates and the Maven settings.xml (mirrors, proxies, credentials) honoured. The Ivy version below is the Ant build of the previous sections with an ivy.xml and the ivy:retrieve and ivy:cachepath tasks.

ivy/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>
ivy/build.xml (dependency part)
<?xml version="1.0" encoding="UTF-8"?>
<project name="tidepool" default="jar" basedir="." xmlns:ivy="antlib:org.apache.ivy.ant">
  <property name="version" value="1.0.0"/>
  <property name="build.dir" location="build"/>
  <property name="classes.dir" location="${build.dir}/classes"/>

  <taskdef uri="antlib:org.apache.ivy.ant" resource="org/apache/ivy/ant/antlib.xml"
           classpath="${basedir}/../tools-jars/ivy-2.6.0.jar"/>

  <target name="resolve" description="Retrieve compile dependencies with Ivy">
    <ivy:retrieve pattern="lib/[artifact]-[revision].[ext]" conf="compile"/>
    <ivy:cachepath pathid="compile.classpath" conf="compile"/>
  </target>

  <target name="compile" depends="resolve">
    <mkdir dir="${classes.dir}"/>
    <javac srcdir="src" destdir="${classes.dir}" classpathref="compile.classpath"
           release="17" includeantruntime="false"/>
  </target>

  <target name="jar" depends="compile">
    <jar destfile="${build.dir}/tidepool-${version}.jar" basedir="${classes.dir}"/>
  </target>
</project>
ant jar
Buildfile: …/ivy/build.xml
resolve:
[ivy:retrieve] :: Apache Ivy 2.6.0 - 20260712075753 :: https://ant.apache.org/ivy/ ::
[ivy:retrieve] :: loading settings :: url = jar:file:…/tools-jars/ivy-2.6.0.jar!/org/apache/ivy/core/settings/ivysettings.xml
[ivy:retrieve] :: resolving dependencies :: io.tidepool#tidepool;1.0.0
[ivy:retrieve] 	confs: [compile]
[ivy:retrieve] 	found org.apache.commons#commons-csv;1.14.1 in public
[ivy:retrieve] 	found commons-io#commons-io;2.20.0 in public
[ivy:retrieve] 	found commons-codec#commons-codec;1.19.0 in public
[ivy:retrieve] 	[SUCCESSFUL ] org.apache.commons#commons-csv;1.14.1!commons-csv.jar (112ms)
…
[ivy:retrieve] :: resolution report :: resolve 1539ms :: artifacts dl 384ms
[ivy:retrieve] 	confs: [compile]
[ivy:retrieve] 	3 artifacts copied, 0 already retrieved (976kB/3ms)

conf="compile->default" asks for the default configuration of the Maven module, which for a module taken from a Maven repository includes its runtime dependencies, so Ivy found commons-io and commons-codec as well. The Resolver Ant Tasks version has no descriptor: the coordinate is in the build file and the <resolve> task builds the classpath and copies the files.

resolver/build.xml (dependency part)
<?xml version="1.0" encoding="UTF-8"?>
<project name="tidepool" default="jar" basedir="." xmlns:resolver="antlib:org.apache.maven.resolver.ant">
  <property name="version" value="1.0.0"/>
  <property name="build.dir" location="build"/>
  <property name="classes.dir" location="${build.dir}/classes"/>

  <taskdef uri="antlib:org.apache.maven.resolver.ant" resource="org/apache/maven/resolver/ant/antlib.xml"
           classpath="${basedir}/../tools-jars/maven-resolver-ant-tasks-2.0.0-uber.jar"/>

  <target name="resolve" description="Resolve compile dependencies with the Maven Resolver Ant Tasks">
    <resolver:resolve>
      <dependencies>
        <dependency coords="org.apache.commons:commons-csv:1.14.1"/>
      </dependencies>
      <path refid="compile.classpath" classpath="compile"/>
      <files dir="lib" layout="{artifactId}-{version}.{extension}" classpath="runtime"/>
    </resolver:resolve>
  </target>

  <target name="compile" depends="resolve">
    <mkdir dir="${classes.dir}"/>
    <javac srcdir="src" destdir="${classes.dir}" classpathref="compile.classpath"
           release="17" includeantruntime="false"/>
  </target>

  <target name="jar" depends="compile">
    <jar destfile="${build.dir}/tidepool-${version}.jar" basedir="${classes.dir}"/>
  </target>
</project>
Buildfile: …/resolver/build.xml

resolve:
[resolver:resolve] Resolving artifacts

compile:
    [mkdir] Created dir: …/resolver/build/classes
    [javac] Compiling 6 source files to …/resolver/build/classes

jar:
      [jar] Building jar: …/resolver/build/tidepool-1.0.0.jar

BUILD SUCCESSFUL
Total time: 0 seconds

Both builds retrieved the same three files into lib/: commons-codec-1.19.0.jar, commons-csv-1.14.1.jar and commons-io-2.20.0.jar.

Ivy Maven Resolver Ant Tasks

ivy.xml <dependency org name rev conf>

<dependency coords="g:a:v"/> or groupId, artifactId, version, scope attributes

configurations compile, runtime, test

scopes; classpath="compile", "runtime" or "test" on <path> and <files>

ivysettings.xml resolvers (ibiblio, url, chains)

<remoterepo>, <remoterepos>, <mirror>, the user’s settings.xml

ivy:cachepath

<path refid="..." classpath="..."/> inside <resolve>

ivy:retrieve pattern

<files dir layout> with {artifactId}, {version}, {extension}

ivy:publish, ivy:makepom

<install> and <deploy> with a <pom> and <artifact> elements

latest.integration revisions

Maven version ranges and SNAPSHOT versions

resolution report and conflict managers

none: Maven’s nearest-wins mediation, with exclusions and global exclusions

What you give up is Ivy’s configuration model (arbitrary configurations, mappings between them) and its conflict managers; what you gain is one dependency vocabulary shared with Maven, and the ability to resolve from pom.xml directly (<dependencies pomRef="pom"/>). The Ivy project’s own Ivy and Maven comparison describes the differences from Ivy’s point of view.

Maven to Gradle

Automatic conversion with gradle init

gradle init run in a directory with a pom.xml offers to convert the build, and --type pom selects the conversion without a question. The conversion is an incubating feature in Gradle 9.8.1 (the output says so). It reads the POM, writes the Gradle files, and leaves the Maven files untouched, so both builds can run side by side.

gradle init --type pom --dsl kotlin --use-defaults
…
> Task :init
Maven to Gradle conversion is an incubating feature.
For more information, please refer to https://docs.gradle.org/9.8.1/userguide/migrating_from_maven.html in the Gradle documentation.

BUILD SUCCESSFUL in 3s
1 actionable task: 1 executed

It created build.gradle.kts, settings.gradle.kts, gradle.properties, gradle/libs.versions.toml and the wrapper. The version catalog, which highlight.js in the documentation UI cannot colour, is:

gradle/libs.versions.toml
# This file was generated by the Gradle 'init' task.
# https://docs.gradle.org/current/userguide/version_catalogs.html#sec::toml-dependencies-format

[versions]
org-apache-commons-commons-csv = "1.14.1"
org-junit-jupiter-junit-jupiter = "6.1.3"

[libraries]
org-apache-commons-commons-csv = { module = "org.apache.commons:commons-csv", version.ref = "org-apache-commons-commons-csv" }
org-junit-jupiter-junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "org-junit-jupiter-junit-jupiter" }
build.gradle.kts as generated
/*
 * This file was generated by the Gradle 'init' task.
 */

plugins {
    `java-library`
    `maven-publish`
}

repositories {
    mavenLocal()
    maven {
        url = uri("https://repo.maven.apache.org/maven2/")
    }
}

dependencies {
    api(libs.org.apache.commons.commons.csv)
    testImplementation(libs.org.junit.jupiter.junit.jupiter)
}

group = "io.tidepool"
version = "1.0.0"
description = "tidepool"
java.sourceCompatibility = JavaVersion.VERSION_1_8

publishing {
    publications.create<MavenPublication>("maven") {
        from(components["java"])
    }
}

tasks.withType<JavaCompile>() {
    options.encoding = "UTF-8"
}

tasks.withType<Javadoc>() {
    options.encoding = "UTF-8"
}

Four things in this output need review before trusting it. The Maven property maven.compiler.release was not converted: the build compiles for Java 8 (java.sourceCompatibility = JavaVersion.VERSION_1_8). mavenLocal() is the first repository, which makes the build depend on whatever a previous Maven build installed into ~/.m2. The commons-csv dependency became api although this is not a library with consumers of its types; implementation is the default recommendation. And gradle.properties enables the configuration cache (org.gradle.configuration-cache=true), which is the right direction, but the generated wrapper properties have no distributionSha256Sum; add it, as explained in Supply Chain Security. The first of these makes the first build fail on JDK 26, because the sources use records and a switch expression:

./gradlew build
> Task :compileJava FAILED
warning: [options] source value 8 is obsolete and will be removed in a future release
warning: [options] target value 8 is obsolete and will be removed in a future release
warning: [options] To suppress warnings about obsolete options, use -Xlint:-options.
…/src/main/java/io/tidepool/model/Constituent.java:4: error: records are not supported in -source 8
public record Constituent(String name, double amplitude, double phase) {
       ^
  (use -source 16 or higher to enable records)
…
6 errors
3 warnings

…
FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> Compilation failed; see the compiler output below.
…

(The output is trimmed with ….) Once the compilation is fixed, the next failures are in the test task, and there are two of them. The generated script never selects the JUnit Platform. Gradle does not discover JUnit Jupiter tests unless the test task selects the JUnit Platform with useJUnitPlatform(), and Gradle 9 fails a test task that has test sources but discovers no tests (the same build with the line removed, run on 9.8.1):

…
> Task :test FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':test'.
> There are test sources present and no filters are applied, but the test task did not discover any tests to execute. This is likely due to a misconfiguration. Please check your test configuration. If this is not a misconfiguration, this error can be disabled by setting the 'failOnNoDiscoveredTests' property to false.
…
BUILD FAILED in 273ms

With useJUnitPlatform() in place, the Gradle test executor needs the JUnit Platform launcher on the test runtime classpath, which Maven Surefire brings by itself. Without it:

…
> Task :test FAILED

Gradle Test Executor 1 FAILED
    org.gradle.api.internal.tasks.testing.TestSuiteExecutionException at SuiteTestDefinitionProcessor.java:45
        Caused by: org.gradle.api.internal.tasks.testing.RequiresTestFrameworkTestDefinitionProcessor$TestFrameworkNotAvailableException at JUnitPlatformTestDefinitionProcessor.java:101

…
Execution failed for task ':test' (registered by plugin 'org.gradle.jvm-test-suite').
> Test process encountered an unexpected problem.
   > Could not start Gradle Test Executor 1.
      > Failed to load JUnit Platform.  Please ensure that all JUnit Platform dependencies are available on the test's runtime classpath, including the JUnit Platform launcher.
…
BUILD FAILED in 6s

A fixed build script, which sets options.release, uses mavenCentral(), keeps the converted libs accessors, declares the launcher and selects the JUnit Platform:

Kotlin DSL
plugins {
    `java-library`
    `maven-publish`
}

repositories {
    mavenCentral()
}

dependencies {
    api(libs.org.apache.commons.commons.csv)
    testImplementation(libs.org.junit.jupiter.junit.jupiter)
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

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

tasks.withType<JavaCompile>().configureEach {
    options.encoding = "UTF-8"
    options.release = 17
}

tasks.withType<Javadoc>().configureEach {
    options.encoding = "UTF-8"
}

tasks.test {
    useJUnitPlatform()
}

publishing {
    publications.create<MavenPublication>("maven") {
        from(components["java"])
    }
}
Groovy DSL
plugins {
    id 'java-library'
    id 'maven-publish'
}

repositories {
    mavenCentral()
}

dependencies {
    api libs.org.apache.commons.commons.csv
    testImplementation libs.org.junit.jupiter.junit.jupiter
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

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

tasks.withType(JavaCompile).configureEach {
    options.encoding = 'UTF-8'
    options.release = 17
}

tasks.withType(Javadoc).configureEach {
    options.encoding = 'UTF-8'
}

tasks.named('test') {
    useJUnitPlatform()
}

publishing {
    publications {
        maven(MavenPublication) {
            from components.java
        }
    }
}

Both build scripts use the same libs.versions.toml. The Kotlin version ran like this:

./gradlew build
Calculating task graph as configuration cache cannot be reused because file 'build.gradle.kts' has changed.
> Task :processResources NO-SOURCE
> Task :processTestResources NO-SOURCE
> Task :compileJava
> Task :classes
> Task :jar
> Task :assemble
> Task :compileTestJava
> Task :testClasses
> Task :test
> Task :check
> Task :build

BUILD SUCCESSFUL in 1s
4 actionable tasks: 4 executed
Configuration cache entry stored.

For a multi-module POM, gradle init creates a settings.gradle.kts with an include(...) per <module> and one build script per module, and assemblies, shade configurations and any custom plugin are not converted and must be rewritten by hand.

Lifecycle and scope mapping

Maven’s build lifecycle is a fixed list of phases; Gradle has no phases but its base plugin provides lifecycle tasks that line up with the main ones:

Maven phase Gradle task

clean

clean (base plugin)

compile

classes, which also runs processResources

test

test

package

assemble

verify

check (integration tests have to be attached by hand)

install

publishToMavenLocal, only to interoperate with Maven builds

deploy

publish

Dependency scopes map to configurations: compile to implementation or api, runtime to runtimeOnly, test to testImplementation and testRuntimeOnly, provided to compileOnly (the War plugin adds providedCompile and providedRuntime), and import to a platform(). Mediation differs: Maven picks the nearest definition, Gradle the highest version, so a migrated build can resolve different versions even with identical declarations; check with the dependency lists described below.

BOMs, profiles, filtering and integration tests

These four are the parts of a POM that gradle init converts poorly or not at all, so here is one Maven build that uses all four and its Gradle translation. The Maven project imports a BOM, activates a profile with -Pci, filters a properties file, and runs GreeterIT with Failsafe.

feat-maven/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>io.tidepool</groupId>
  <artifactId>features</artifactId>
  <version>1.0.0</version>

  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <deploy.env>dev</deploy.env>
  </properties>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.junit</groupId>
        <artifactId>junit-bom</artifactId>
        <version>6.1.3</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <profiles>
    <profile>
      <id>ci</id>
      <properties>
        <deploy.env>ci</deploy.env>
      </properties>
    </profile>
  </profiles>

  <build>
    <resources>
      <resource>
        <directory>src/main/resources</directory>
        <filtering>true</filtering>
      </resource>
    </resources>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.16.0</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-resources-plugin</artifactId>
        <version>3.5.0</version>
        <configuration>
          <propertiesEncoding>UTF-8</propertiesEncoding>
        </configuration>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.6.0</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.5.1</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-failsafe-plugin</artifactId>
        <version>3.6.0</version>
        <executions>
          <execution>
            <goals>
              <goal>integration-test</goal>
              <goal>verify</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</project>
feat-maven/src/main/resources/app.properties
app.version=${project.version}
app.env=${deploy.env}
mvn -B -ntp verify -Pci
…
[INFO] --- resources:3.5.0:resources (default-resources) @ features ---
[INFO] Copying 1 resource from src/main/resources to target/classes
[INFO]
[INFO] --- compiler:3.16.0:compile (default-compile) @ features ---
[INFO] Loaded 74 auto-discovered prefixes for remote repository apache.snapshots (prefixes-apache.snapshots.txt)
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 1 source file with javac [debug release 17] to target/classes
[INFO]
[INFO] --- resources:3.5.0:testResources (default-testResources) @ features ---
[INFO] skip non existing resourceDirectory …/feat-maven/src/test/resources
[INFO]
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ features ---
[INFO] Recompiling the module because of changed dependency.
[INFO] Compiling 2 source files with javac [debug release 17] to target/test-classes
[INFO]
[INFO] --- surefire:3.6.0:test (default-test) @ features ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running demo.GreeterTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.021 s -- in demo.GreeterTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO]
[INFO] --- jar:3.5.1:jar (default-jar) @ features ---
[INFO] Building jar: …/feat-maven/target/features-1.0.0.jar
[INFO]
[INFO] --- failsafe:3.6.0:integration-test (default) @ features ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running demo.GreeterIT
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.016 s -- in demo.GreeterIT
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO]
[INFO] --- failsafe:3.6.0:verify (default) @ features ---
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS

Resulting target/classes/app.properties:

app.version=1.0.0
app.env=ci

The <propertiesEncoding> setting in the POM matters: without it Maven 3.10.0 prints The encoding used to copy filtered properties files has not been set and falls back to the default of the platform. The Gradle build maps each part as follows: the BOM import becomes a platform() dependency, the profile becomes a project property tested with providers.gradleProperty, filtering becomes expand on processResources, and Failsafe becomes a second JVM test suite attached to check.

Kotlin DSL
plugins {
    `java-library`
}

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

repositories {
    mavenCentral()
}

tasks.withType<JavaCompile>().configureEach {
    options.release = 17
}

// Maven profile "ci" -> a property: run with -Pci
val deployEnv = if (providers.gradleProperty("ci").isPresent) "ci" else "dev"
val projectVersion = project.version.toString()

// Maven resource filtering -> expand()
tasks.processResources {
    inputs.property("deployEnv", deployEnv)
    inputs.property("projectVersion", projectVersion)
    filesMatching("app.properties") {
        expand("version" to projectVersion, "deployEnv" to deployEnv)
    }
}

// Maven BOM import (scope "import") -> a platform; Maven failsafe -> a second test suite
testing {
    suites {
        named<JvmTestSuite>("test") {
            useJUnitJupiter()
            dependencies {
                implementation(platform("org.junit:junit-bom:6.1.3"))
            }
        }
        register<JvmTestSuite>("integrationTest") {
            useJUnitJupiter()
            dependencies {
                implementation(project())
                implementation(platform("org.junit:junit-bom:6.1.3"))
            }
        }
    }
}

tasks.check {
    dependsOn(testing.suites.named("integrationTest"))
}
Groovy DSL
plugins {
    id 'java-library'
}

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

repositories {
    mavenCentral()
}

tasks.withType(JavaCompile).configureEach {
    options.release = 17
}

// Maven profile "ci" -> a property: run with -Pci
def deployEnv = providers.gradleProperty('ci').present ? 'ci' : 'dev'
def projectVersion = project.version.toString()

// Maven resource filtering -> expand()
tasks.named('processResources') {
    inputs.property('deployEnv', deployEnv)
    inputs.property('projectVersion', projectVersion)
    filesMatching('app.properties') {
        expand(version: projectVersion, deployEnv: deployEnv)
    }
}

// Maven BOM import (scope "import") -> a platform; Maven failsafe -> a second test suite
testing {
    suites {
        test {
            useJUnitJupiter()
            dependencies {
                implementation platform('org.junit:junit-bom:6.1.3')
            }
        }
        integrationTest(JvmTestSuite) {
            useJUnitJupiter()
            dependencies {
                implementation project()
                implementation platform('org.junit:junit-bom:6.1.3')
            }
        }
    }
}

tasks.named('check') {
    dependsOn testing.suites.integrationTest
}
feat-gradle/src/main/resources/app.properties
app.version=${version}
app.env=${deployEnv}
./gradlew clean build -Pci
> Task :clean
> Task :compileJava
> Task :processResources
> Task :classes
> Task :jar
> Task :assemble
> Task :compileIntegrationTestJava
> Task :processIntegrationTestResources NO-SOURCE
> Task :integrationTestClasses
> Task :integrationTest
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test
> Task :check
> Task :build
BUILD SUCCESSFUL in 1s
8 actionable tasks: 8 executed
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.8.1/userguide/configuration_cache_enabling.html

Resulting build/resources/main/app.properties:

app.version=1.0.0
app.env=ci

Points that surprise Maven users:

  • -Pci means different things. In Maven it activates the profile with id ci; in Gradle it sets the project property ci, which any build logic can test. Gradle has no profiles; the manual shows the same effect with per-environment secondary scripts selected by a property.

  • expand treats the file as a template of the Groovy SimpleTemplateEngine, so every $ in the file is interpreted; use filter with ReplaceTokens for files that contain literal dollars. The two inputs.property lines are there so that a change of version or profile re-runs processResources.

  • Reading project.version inside the expand action is a deprecated use of Task.project at execution time (the build printed the deprecation warning until the value was captured in a local val/def). It will fail in Gradle 10 and it prevents the configuration cache.

  • The two test suites keep integration tests in src/integrationTest/java, not in *IT file names; and unlike Failsafe, a failing suite stops the build unless ignoreFailures is set.

  • The top-level named<JvmTestSuite>("test") form replaces val test by getting(JvmTestSuite::class): the delegate form is deprecated since Gradle 9.6.

Plugins and Ant goals

Plugins that wrap a tool with its own configuration (Checkstyle, JaCoCo, Shade and its Gradle counterpart Shadow) migrate by carrying over the plugin’s configuration block and ignoring the executions and phase bindings, which Gradle does not need. antrun executions become the built-in ant object (ant.importBuild, or inline ant.withGroovyBuilder), as the manual notes. Custom Maven plugins have to be rewritten as Gradle plugins or tasks; see Writing Maven Plugins and Writing Custom Gradle Tasks.

Verifying a migration

The Gradle manual calls a way to verify that both builds produce the same artifacts "vitally important", and the rule applies to every direction. The build-comparison plugin that Gradle used to ship for this was removed in Gradle 6.0; the upgrade guide points to Build Scans, where two scans of builds can be compared in Develocity (not run here). Without it, three cheap checks give most of the confidence.

Compare artifacts

List the entries of both JARs and diff the lists. Here the Maven JAR is compared with the JAR produced by the converted Gradle build:

jar tf maven/target/tidepool-1.0.0.jar | sort > maven-entries.txt
jar tf mvn2gradle/build/libs/tidepool-1.0.0.jar | sort > gradle-entries.txt
diff maven-entries.txt gradle-entries.txt
3,7d2
< META-INF/maven/
< META-INF/maven/io.tidepool/
< META-INF/maven/io.tidepool/tidepool/
< META-INF/maven/io.tidepool/tidepool/pom.properties
< META-INF/maven/io.tidepool/tidepool/pom.xml

The lines marked < exist only in the Maven JAR. The Gradle jar task does not add META-INF/maven/…/pom.xml and pom.properties, which maven-archiver writes. If a consumer reads them (some tools do, to find the coordinates of a JAR), add them in the Gradle build; if not, document the difference. The same diff between the Ant JAR and the Maven JAR shows the same five lines and nothing else:

2a3,7
> META-INF/maven/
> META-INF/maven/io.tidepool/
> META-INF/maven/io.tidepool/tidepool/
> META-INF/maven/io.tidepool/tidepool/pom.properties
> META-INF/maven/io.tidepool/tidepool/pom.xml

Also compare manifests (unzip -p x.jar META-INF/MANIFEST.MF), the checksums of the files inside if the build is reproducible, and the size. Reproducible archives are the default in Gradle since 9.0 and Maven produces them when project.build.outputTimestamp is set; with both in place, a checksum comparison of the unzipped contents becomes meaningful.

Compare dependency lists

Compare what each tool resolves for the runtime classpath, not what you declared.

mvn -B -ntp dependency:tree -Dscope=runtime
./gradlew dependencies --configuration runtimeClasspath
[INFO] --- dependency:3.11.0:tree (default-cli) @ tidepool ---
[INFO] io.tidepool:tidepool:jar:1.0.0
[INFO] \- org.apache.commons:commons-csv:jar:1.14.1:compile
[INFO]    +- commons-io:commons-io:jar:2.20.0:compile
[INFO]    \- commons-codec:commons-codec:jar:1.19.0:compile

------------------------------------------------------------
Root project 'tidepool'
------------------------------------------------------------
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

Both resolve commons-io:2.20.0 and commons-codec:1.19.0 under commons-csv:1.14.1. A difference here, usually because of nearest-wins against highest-version conflict resolution, is the most common reason why a migrated build runs differently. When it appears, add a constraint (or dependencyManagement on the Maven side) rather than an exclusion, and read the mediation comparison.

Compare test results

Surefire writes target/surefire-reports/TEST-*.xml, Gradle writes build/test-results/test/TEST-*.xml, and both use the JUnit XML format, so the counts can be compared with one grep:

maven/target/surefire-reports/TEST-io.tidepool.engine.StationReaderTest.xml
 name="io.tidepool.engine.StationReaderTest"  tests="1"  errors="0"  skipped="0"  failures="0"
mvn2gradle/build/test-results/test/TEST-io.tidepool.engine.StationReaderTest.xml
 name="io.tidepool.engine.StationReaderTest"  tests="1"  skipped="0"  failures="0"  errors="0"

Compare the number of tests, not only the success. Gradle 9 fails a test task that finds sources but no tests, but a wrong test source directory, a filter that matches nothing or a skipped phase can still end with a green build and zero tests, as the Maven No tests to run. line in the empty-layout example above shows. In CI, fail the job when the number of executed tests drops.

Pitfalls collected

Symptom Cause and fix

BUILD SUCCESS with No sources to compile and an empty JAR

The sources are not in src/main/java; move them or set <sourceDirectory> (Maven), or NO-SOURCE in Gradle

records are not supported in -source 8 after gradle init

The Maven maven.compiler.release property was not converted; set options.release

Failed to load JUnit Platform

Add testRuntimeOnly("org.junit.platform:junit-platform-launcher")

the test task did not discover any tests to execute

Call useJUnitPlatform() in the test task

Configuration cache disabled because incompatible task was found

ant.importBuild is still in use; finish replacing the Ant targets

Cannot add task 'jar' as a task with that name already exists.

An imported Ant target has the name of a task a plugin adds; rename it with the transformer of importBuild

A different dependency version after migrating

Nearest wins in Maven, highest wins in Gradle; add a constraint or dependencyManagement entry

Transitive dependencies missing at run time from a flatDir or lib/

A flat directory has no metadata; use a real repository

Invocation of Task.project at execution time has been deprecated

Read the value at configuration time into a local variable

Ant lib/ JARs copied into the Maven build as system scope

Do not; resolve them from a repository or install them into the local repository

Where the books are outdated

The three books are cited on the section bibliography. For migrations, they describe the tools of their day:

  • Java Development with Ant (Ant 1.5, 2002) predates Ivy, so its builds manage dependencies by hand and it looks to Maven 1 and Centipede for the future; today Ant’s dependency management is Ivy 2.6.0 or the Maven Resolver Ant Tasks 2.0.0, and a lib/ directory is the legacy case this page starts from.

  • Apache Maven 2 Effective Implementation (Maven 2.2, 2009) assumes the Maven 2 layout and plugin versions; Maven 2 has been end of life since 2014, plugins must have their versions pinned, and Maven 3.10.0 and 3.9.16 are the maintained lines, with Maven 4 at 4.0.0-rc-7 (release candidate).

  • Gradle in Action (Gradle 1.7, 2014) shows maven2gradle and the << task operator; the Maven conversion is gradle init now, << was removed in Gradle 5.0, the compile and testCompile configurations were removed in 7.0, and the build-comparison plugin was removed in 6.0.