Migrating to Maven 4

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 migrates a real, small Maven 3 project to Maven 4 and records everything that went wrong on the way. It follows the three steps of the official migration guide: prepare on the latest Maven 3, test with Maven 4 using only changes that Maven 3 still accepts, and migrate by dropping Maven 3 and adopting the Maven 4 features, with the help of the Maven Upgrade Tool mvnup. Maven 4.0.0-rc-7 is a release candidate, not a final release: the guide itself says it will keep changing, and no 4.0.0 had been published when this page was written (see Maven Versions & Release History). Everything shown was run on 2026-10-16 with Maven 3.9.16, Maven 3.10.0 and Maven 4.0.0-rc-7, on JDK 26 (JDK 17 where stated), against a scratch local repository so that nothing touched ~/.m2. Paths in the output were shortened to /work/... and the lines Loaded N auto-discovered prefixes ... that rc-7 prints on its first contact with each repository were filtered out; other trimming is marked.

What is new in Maven 4, and why, is on Maven 4: What’s New, the model changes are on Maven 4: POM Model & Consumer POM and the lifecycle on Maven 4: Lifecycle. This page does not repeat them; it shows the steps of moving a project and the failures you will meet. Books about Maven were written for Maven 2 and 3 and say nothing about any of this; the official guide is the only source that is kept up to date, and it says so about itself.

flowchart LR P["Step 1, prepare: latest Maven 3.9 or 3.10 and latest Maven 3 plugins"] T["Step 2, test: JDK 17 and Maven 4 beside Maven 3, build POM stays 4.0.0"] M["Step 3, migrate: drop Maven 3, model 4.1.0, mvnup apply"] P --> T --> M

The project that gets migrated

The project is tidepool: a parent POM and three subprojects, tidepool-model (a Java record), tidepool-engine and tidepool-cli, built for Java 17. It is committed to a scratch git repository so that every change below is a git diff. It is written the way a Maven 3 project of a few years ago often is, so that it carries the problems the guide lists:

  • the CLI POM declares maven-jar-plugin twice;

  • a script of the engine binds maven-antrun-plugin to pre-integration-test, and one of the model binds it to post-clean;

  • maven-antrun-plugin is at 3.1.0, and every other plugin version is pinned in the parent;

  • there is no .mvn directory and no root attribute;

  • the install and deploy plugins are the Maven 3 lines (3.2.0), and the deploy target is a file: repository below the project.

tidepool/
|-- pom.xml                       (parent, packaging pom, lists the three modules)
|-- tidepool-model/
|   |-- pom.xml                   (antrun bound to post-clean)
|   `-- src/main/java/io/tidepool/model/Tide.java
|-- tidepool-engine/
|   |-- pom.xml                   (antrun bound to pre-integration-test, junit-jupiter test)
|   `-- src/...
`-- tidepool-cli/
    |-- pom.xml                   (maven-jar-plugin declared twice)
    `-- src/...

The parent POM, abridged (the pluginManagement section pins maven-compiler-plugin 3.16.0, maven-resources-plugin 3.5.0, maven-jar-plugin 3.5.1, maven-install-plugin 3.2.0, maven-deploy-plugin 3.2.0, maven-clean-plugin 3.5.0, maven-surefire-plugin 3.6.0 and maven-antrun-plugin 3.1.0, the stable lines of each at the time of writing):

<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
  <modelVersion>4.0.0</modelVersion>
  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-parent</artifactId>
  <version>1.0.0-SNAPSHOT</version>
  <packaging>pom</packaging>

  <modules>
    <module>tidepool-model</module>
    <module>tidepool-engine</module>
    <module>tidepool-cli</module>
  </modules>

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

  <distributionManagement>
    <repository>
      <id>local-file</id>
      <url>file://${maven.multiModuleProjectDirectory}/target/repo</url>
    </repository>
  </distributionManagement>
  ...
</project>

The tidepool-cli subproject, in full below the parent declaration (this is the file that Maven 3.9 warns about):

  <artifactId>tidepool-cli</artifactId>
  <dependencies>
    <dependency>
      <groupId>io.tidepool</groupId>
      <artifactId>tidepool-engine</artifactId>
      <version>${project.version}</version>
    </dependency>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.12.2</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <artifactId>maven-jar-plugin</artifactId>
        <configuration>
          <archive>
            <manifest>
              <mainClass>io.tidepool.cli.Main</mainClass>
            </manifest>
          </archive>
        </configuration>
      </plugin>
      <plugin>
        <artifactId>maven-jar-plugin</artifactId>
        <configuration>
          <forceCreation>true</forceCreation>
        </configuration>
      </plugin>
    </plugins>
  </build>

The two antrun bindings are one-liners that print a message at level="info":

<phase>pre-integration-test</phase>   <!-- tidepool-engine -->
<phase>post-clean</phase>             <!-- tidepool-model  -->

These POMs are this page’s own lab setup, not examples from the Maven documentation.

Step 1: prepare on the latest Maven 3

The guide’s first step has two prerequisites: the project builds with the latest Maven 3, and it uses the latest Maven 3 versions of its plugins (and, it adds, not yet a plugin version that needs Maven 4). Nothing in this step needs Maven 4, but it is where the first failure appears, and it appears earlier than the guide suggests.

Maven 3.9.16 warns, Maven 3.10.0 fails

The same project, the same command, the two Maven 3 lines:

Maven 3.9.16: mvn clean verify
[INFO] Scanning for projects...
[WARNING]
[WARNING] Some problems were encountered while building the effective model for io.tidepool:tidepool-cli:jar:1.0.0-SNAPSHOT
[WARNING] 'build.plugins.plugin.(groupId:artifactId)' must be unique but found duplicate declaration of plugin org.apache.maven.plugins:maven-jar-plugin @ line 37, column 15
[WARNING]
[WARNING] It is highly recommended to fix these problems because they threaten the stability of your build.
[WARNING]
[WARNING] For this reason, future Maven versions might no longer support building such malformed projects.

The build then succeeds (BUILD SUCCESS), and the jar manifest has the Main-Class of the first declaration: Maven 3.9 merged the two declarations itself. Maven 3.10.0 does not:

Maven 3.10.0: mvn clean verify
[INFO] Scanning for projects...
[ERROR] [ERROR] Some problems were encountered while processing the POMs:
[ERROR] 'build.plugins.plugin.(groupId:artifactId)' must be unique but found duplicate declaration of plugin org.apache.maven.plugins:maven-jar-plugin @ line 37, column 15
 @
[ERROR] The build could not read 1 project -> [Help 1]
[ERROR]
[ERROR]   The project io.tidepool:tidepool-cli:1.0.0-SNAPSHOT (/work/tidepool-cli/pom.xml) has 1 error
[ERROR]     'build.plugins.plugin.(groupId:artifactId)' must be unique but found duplicate declaration of plugin org.apache.maven.plugins:maven-jar-plugin @ line 37, column 15

This is documented: the Maven 3.10.0 release notes list "Stricter POM validation" among the changes that can break a build coming from 3.9.x ("many WARNINGs from the 3.9 lineage …​ will now FAIL the build"), with duplicated dependency entries and duplicated plugin entries as the examples. The practical consequence for a migration is that Maven 3.10.0 is the better Maven 3 to prepare on: it fails on what Maven 4 will fail on, while 3.9.16 only warns, so a project that is clean on 3.10.0 has already passed the first Maven 4 hurdle.

The fix is to merge the two declarations into one, which is what Maven 3.9 did silently:

@@ -32,11 +32,6 @@
               <mainClass>io.tidepool.cli.Main</mainClass>
             </manifest>
           </archive>
-        </configuration>
-      </plugin>
-      <plugin>
-        <artifactId>maven-jar-plugin</artifactId>
-        <configuration>
           <forceCreation>true</forceCreation>
         </configuration>
       </plugin>

After the merge the project builds on 3.9.16 and on 3.10.0 (BUILD SUCCESS, tests 1 and 1), and the manifest of tidepool-cli carries Main-Class: io.tidepool.cli.Main.

Plugin versions: ask the plugins, not only the versions plugin

The guide recommends versions:display-plugin-updates of the Versions Maven Plugin to find the latest Maven 3 version of each plugin, and to avoid, for now, the versions that need Maven 4. Run on tidepool with versions-maven-plugin 2.23.0 on Maven 3.9.16, it separates the two groups by the Maven version each plugin release requires (excerpt, the older Maven 2 lines trimmed):

[INFO] All plugins with a version specified are using the latest versions.
...
[INFO] Require Maven 4.0.0-rc-6 to use the following plugin updates:
[INFO]   maven-compiler-plugin ...................... 3.16.0 -> 4.0.0-beta-5
[INFO]   maven-deploy-plugin ......................... 3.2.0 -> 4.0.0-beta-3
[INFO]   maven-install-plugin ........................ 3.2.0 -> 4.0.0-beta-3
[INFO]
[INFO] Require Maven 4.0.0-rc-7 to use the following plugin updates:
[INFO]   maven-clean-plugin .......................... 3.5.0 -> 4.0.0-beta-3
[INFO]   maven-resources-plugin ...................... 3.5.0 -> 4.0.0-beta-2

So on this project the Maven 3 lines of compiler, jar, resources, clean, install, deploy and surefire are the right ones for steps 1 and 2, and the 4.0.0-beta lines are the ones that need Maven 4 (they are pre-releases, not stable). The report has a blind spot, though. It said the specified plugins are "using the latest versions" while maven-antrun-plugin 3.1.0 was pinned and 3.2.0 had been on Central for a year. What flagged the old antrun was Maven’s own plugin validation, which is the other tool of this step:

Maven 3.10.0 and Maven 4.0.0-rc-7: -Dmaven.plugin.validation=verbose
[WARNING] Plugin [INTERNAL, EXTERNAL] validation issues were detected in following plugin(s)
[WARNING]
[WARNING]  * org.apache.maven.plugins:maven-antrun-plugin:3.1.0
[WARNING]   Declared at location(s):
[WARNING]    * io.tidepool:tidepool-engine:1.0.0-SNAPSHOT (tidepool-engine/pom.xml) @ line 27
[WARNING]   Mojo EXTERNAL issue(s):
[WARNING]    * Mojo antrun:run (org.apache.maven.plugins.antrun.AntRunMojo)
[WARNING]      - Plugin uses Plexus Component requirements (@Component annotation). Use Maven 4 Dependency Injection (for v4 plugins) or JSR 330 annotations (for v3 plugins) to inject dependencies instead.

Maven 3.9.16 printed no such report with the same property, so this check also needs 3.10.0 or Maven 4. The cause is the Plexus injection that Maven 4 no longer promises to support (see the plugins section of the overview). The report suggests upgrading; maven-antrun-plugin 3.2.0 is a Maven 3 release (Central lists it from October 2025), and bumping the single version in the parent removed the report on both 3.10.0 and Maven 4 and left every build green on all three Maven versions. Plugin versions and configuration in general are on Plugins & Their Configuration.

Step 2: test on Maven 4

Java 17 to run Maven

Maven 4 needs Java 17 to run, while Maven 3.9 and 3.10 run on Java 8 and later. That is a statement about the JVM that runs Maven, not about the release your code compiles for, which stays 17 here but could stay 8 or 11 with a toolchain (Compiling & Toolchains). Everything on this page ran on JDK 26. Because Maven 3 also runs on JDK 17, the guide suggests moving the build machines (laptops, CI images, containers) to a JDK 17 first. The prepared project builds unchanged on JDK 17 (JetBrains Runtime 17.0.14) with Maven 3.10.0 and with rc-7:

$ JAVA_HOME=.../jbr-17.0.14/Contents/Home mvn -v
Apache Maven 4.0.0-rc-7 (6d8f301b38af7399b36b862969d520974944f0b9)
Maven home: /.../apache-maven-4.0.0-rc-7
Java version: 17.0.14, vendor: JetBrains s.r.o., runtime: /.../jbr-17.0.14/Contents/Home

Where Maven is selected is part of this step too: the distributionUrl in .mvn/wrapper/maven-wrapper.properties (Maven Wrapper), a requireMavenVersion enforcer rule (Enforcer), and the CI scripts. The recommended trial is a CI job that builds with Maven 4 beside the one that builds with Maven 3, reports Maven 4 failures as feedback and does not gate releases yet.

The duplicate plugin, again

Maven 4 rejects the duplicate too, and it reports it twice, once for the subproject and once for the parent that includes it in the reactor:

Maven 4.0.0-rc-7 on the unfixed project
[WARNING] Unable to find the root directory. Create a .mvn directory in the root directory or add the root="true" attribute on the root project's model to identify it.
[INFO] Scanning for projects...
[ERROR] Encountered 2 problem(s) while processing the POMs
[ERROR] The build could not read 2 projects -> [Help 1]
[ERROR]
[ERROR]   The project io.tidepool:tidepool-cli:1.0.0-SNAPSHOT (/work/tidepool-cli/pom.xml) has 1 error
[ERROR]     'build.plugins.plugin.(groupId:artifactId)' must be unique but found duplicate declaration of plugin org.apache.maven.plugins:maven-jar-plugin @ line 37, column 7
[ERROR]
[ERROR]   The project io.tidepool:tidepool-parent:1.0.0-SNAPSHOT (/work/pom.xml) has 1 error
[ERROR]     'build.plugins.plugin.(groupId:artifactId)' must be unique but found duplicate declaration of plugin org.apache.maven.plugins:maven-jar-plugin @ io.tidepool:tidepool-cli:1.0.0-SNAPSHOT, file:///work/tidepool-cli/pom.xml, line 37, column 7

With the prepared project (merged jar plugin, antrun 3.2.0), rc-7 builds everything: all four reactor entries SUCCESS, tests passing, and the same artifacts. This is the point where the project builds on Maven 3.9.16, Maven 3.10.0 and Maven 4.0.0-rc-7 from the same unchanged build POM, with the model version still 4.0.0, which is the intended state of "test".

The root directory warning

The first line of the output above is the next thing to fix. Maven 4 wants to know where the root of a multi-project build is, and warns when it cannot find out. There are two ways to tell it. A .mvn directory in the root works with Maven 3 too (a committed empty file keeps git from dropping the empty directory), and the root="true" attribute on the root <project> needs model version 4.1.0, so it is for step 3. For the test step use .mvn:

mkdir .mvn
touch .mvn/.gitkeep

There is a trap here that makes the warning disappear without anyone fixing anything. On a project without a .mvn directory, the first Maven 4 build prints the warning and then creates .mvn/target/project-local-repo itself, so the second build finds a .mvn directory and prints nothing:

$ git clone tidepool trap && mvn -B validate     # first run
[WARNING] Unable to find the root directory. Create a .mvn directory in the root directory or add the root="true" attribute on the root project's model to identify it.
[INFO] Scanning for projects...
$ mvn -B validate                                # second run, same checkout
[INFO] Scanning for projects...

The directory is ignored by a target/ rule in .gitignore, so git status shows nothing and the warning never comes back, while the checkout of a colleague or of CI still warns. Commit a real .mvn entry. What the root is used for follows.

Make the build fail on warnings

Maven 4’s --fail-on-severity (-fos) option turns log output of a given severity into a build failure and is the guide’s tool for finding what the build is complaining about. It fails before the .mvn fix and passes after it:

Maven 4.0.0-rc-7, mvn -B -fos WARN clean verify, without .mvn
[INFO] Enabled to break the build on log level WARN.
[WARNING] Unable to find the root directory. Create a .mvn directory in the root directory or add the root="true" attribute on the root project's model to identify it.
...
[ERROR] BUILD FAILURE
...
[ERROR] Build failed due to log statements with a higher severity than allowed. Fix the logged issues or remove flag --fail-on-severity (-fos).

With .mvn in place the same command ends in BUILD SUCCESS. Note that any plugin message logged at WARNING counts: the antrun scripts of tidepool print with <echo level="info"> for exactly this reason, because the default level of the Ant echo task is warning, and the first run of this experiment failed on the scripts' own messages. Keep -fos WARN for a CI trial, not as a permanent flag, until your plugins stop logging warnings for normal operation.

Directory properties: what resolves where

The guide says Maven 4 adds ${project.rootDirectory}, ${session.topDirectory} and ${session.rootDirectory} for paths, and that several internal properties, among them executionRootDirectory and multiModuleProjectDirectory, are deprecated or removed. To see what that means in practice, a two-level project (a parent with .mvn and one subproject) defines one POM property per name, and mvn help:effective-pom shows what each became:

Expression in a POM property Maven 3.9.16 Maven 3.10.0 Maven 4.0.0-rc-7

${maven.multiModuleProjectDirectory}

root

root

root

${project.rootDirectory}

not resolved

root

root

${session.rootDirectory}

not resolved

not resolved

root

${session.topDirectory}

not resolved

not resolved

root

${multiModuleProjectDirectory}

not resolved

not resolved

not resolved

${session.executionRootDirectory}

not resolved

not resolved

not resolved

${executionRootDirectory}

not resolved

not resolved

not resolved

${project.basedir} and ${basedir}

subproject directory

subproject directory

subproject directory

"Not resolved" means the text stayed as written, ${...} included. Two lessons. The names the guide calls removed were never available to a POM in this form on the Maven 3 versions tested, so a POM that uses them either never worked or only worked through a plugin’s own expression evaluator; search for them. And the one spelling that works everywhere is the system property ${maven.multiModuleProjectDirectory}, which is why the tidepool deploy URL uses it. The Maven 3.10.0 release notes say session.topDirectory, session.rootDirectory and project.rootDirectory were promoted to regular properties; in this experiment only ${project.rootDirectory} was substituted in POM interpolation on 3.10.0, so test your own expressions before relying on the session.* names in a build that must also run on Maven 3. Replacing a hand-rolled root lookup with ${project.rootDirectory} worked on 3.10.0 and 4 in this experiment (the test project had a .mvn directory).

pre- and post- phases

The guide says the pre-integration-test, post-integration-test, pre-clean, post-clean and similar phases are replaced by before: and after: phases for every phase, and tells you to move bindings. In rc-7 the old names are still accepted: tidepool’s pre-integration-test and post-clean antrun bindings ran without any message. What changed is when a post-clean binding runs. On Maven 3 the clean phase stops before post-clean, so a script bound there ran only when you asked for it by name. On rc-7 it runs as part of every mvn clean:

Command (project with a post-clean binding) Maven 3.9.16 Maven 3.10.0 Maven 4.0.0-rc-7

mvn clean

script did not run

script did not run

script ran

mvn post-clean

script ran

script ran

script ran

A script that deletes or regenerates something at post-clean therefore starts firing on every clean build when the project moves to Maven 4. mvnup rewrites the phase names (see below); read the diff, since the new name does what rc-7 already does. The lifecycle tree and the before:/after: phases are on Maven 4: Lifecycle.

Install and deploy at the end of the build

The guide marks as a breaking change that installAtEnd and deployAtEnd default to true in Maven 4: the artifacts of a multi-project build are installed or deployed together after every project has built, so a failure in a later subproject no longer leaves earlier ones half-published. The default belongs to the plugins, not to Maven 4 itself, and the plugin version decides. Pinned to the Maven 3 plugin lines (install 3.2.0), a Maven 4 build behaves like Maven 3. To make the difference visible, the test of tidepool-cli fails on request (-Dfail=true), so the last subproject breaks after model and engine have been built:

mvn install -Dfail=true with maven-install-plugin 3.2.0 Maven 3.9.16 Maven 3.10.0 Maven 4.0.0-rc-7

Jars installed before the failure

model and engine (2)

model and engine (2)

model and engine (2)

Moving only maven-install-plugin and maven-deploy-plugin to 4.0.0-beta-3 (the lines that need rc-6 or later, from the update report above) changes the outcome on rc-7:

         <plugin>
           <artifactId>maven-install-plugin</artifactId>
-          <version>3.2.0</version>
+          <version>4.0.0-beta-3</version>
         </plugin>
         <plugin>
           <artifactId>maven-deploy-plugin</artifactId>
-          <version>3.2.0</version>
+          <version>4.0.0-beta-3</version>
         </plugin>

With the failing test, the install goal now runs for every subproject but installs nothing (installed jars: 0, and the scratch local repository has no io/tidepool directory). With the tests passing, the Installing lines all appear when the last subproject’s install runs, and mvn deploy does the same:

Maven 4.0.0-rc-7 with the 4.0.0-beta-3 plugins, mvn deploy, tests passing (excerpt, paths shortened)
[INFO] --- install:4.0.0-beta-3:install (default-install) @ tidepool-model ---
[INFO] --- deploy:4.0.0-beta-3:deploy (default-deploy) @ tidepool-model ---
...
[INFO] --- install:4.0.0-beta-3:install (default-install) @ tidepool-cli ---
[INFO] Installing /work/pom.xml to /work/m2/io/tidepool/tidepool-parent/1.0.0-SNAPSHOT/tidepool-parent-1.0.0-SNAPSHOT-build.pom
[INFO] Installing /work/target/consumer-13417990385539797849.pom to /work/m2/io/tidepool/tidepool-parent/1.0.0-SNAPSHOT/tidepool-parent-1.0.0-SNAPSHOT.pom
[INFO] Installing /work/tidepool-model/target/tidepool-model-1.0.0-SNAPSHOT.jar to /work/m2/io/tidepool/tidepool-model/1.0.0-SNAPSHOT/tidepool-model-1.0.0-SNAPSHOT.jar
...
[INFO] --- deploy:4.0.0-beta-3:deploy (default-deploy) @ tidepool-cli ---
[INFO] Deploying artifacts [io.tidepool:tidepool-parent:pom:1.0.0-SNAPSHOT, io.tidepool:tidepool-parent:pom:consumer:1.0.0-SNAPSHOT, ...] to repository local-file (file:///work/target/repo)

The -build.pom and the consumer POM in the listing are the two POMs Maven 4 publishes per project, explained on Maven 4: POM Model & Consumer POM. The guide’s advice for a build that depended on the old default (false) is to look for a configuration error that the setting was hiding, rather than to set it back. Install and deploy in general are on Install, Deploy & Distribution Management. Whether it is worth leaving the Maven 3 plugin lines before the migration is finished is a choice: they are pre-releases (beta), and the project can stay on 3.2.0 until Maven 4.0.0 is out.

Step 3: migrate with mvnup

Step 3 drops Maven 3 and adopts the Maven 4 features. mvnup is shipped with Maven 4 from 4.0.0-rc-4 on and has two goals: check reports what it would change and writes nothing, and apply writes the changes. Its options, from mvnup --help of rc-7:

Option Meaning

-m, --model-version

target model version, 4.0.0 (the default, still buildable with Maven 3) or 4.1.0

-d, --directory

directory at which POM discovery starts (otherwise the current directory)

-a, --all

equivalent to --model-version 4.1.0 --infer --model --plugins

--model

fix constructs that are incompatible with Maven 4

--plugins

upgrade plugins known to fail with Maven 4 to the minimum compatible version

-i, --infer

remove information Maven 4 can infer (parent coordinates, subproject lists and similar)

-B, -q, -X, -s, -fos and others

the usual Maven batch, quiet, verbose and settings options

With no option, --model --plugins --infer apply and the model version stays 4.0.0. Run mvnup in the project directory: started from another directory with -d, it printed Project root directory and multiModuleProjectDirectory are not aligned before every strategy. Commit your work first so that git diff is the review tool.

First run: the default, on the project as it was

To see what the tool does with the problems rather than with a tidy project, it was run on the first state of tidepool, the one with the duplicate plugin and without .mvn:

$ mvnup -B check
[INFO] Maven Upgrade Tool - Check
[INFO] Discovering POM files...
[INFO] Found 4 POM file(s)
[INFO] Executing 11 upgrade strategy(ies):
[INFO]   - Applying Maven inference optimizations
[INFO]   - Removing duplicate XML elements
[INFO]   - Applying Maven 4 compatibility fixes
[INFO]   - Injecting standalone Nashorn for antrun JavaScript compatibility
[INFO]   - Deduplicating dependency and plugin declarations
[INFO]   - Widening RequireMavenVersion ranges to allow Maven 4
[INFO]   - Adding binary file extension exclusions to resource filtering
[INFO]   - Adding maven-toolchains-plugin for JDK source level compatibility
[INFO]   - Upgrading HTTP repository URLs to HTTPS
[INFO]   - Fixing invalid Automatic-Module-Name manifest entries
[INFO]   - Upgrading Maven plugins to recommended versions
...
[WARNING]     ⚠ Reactor pre-build failed, falling back to per-POM resolution: 1 problem was  for io.tidepool:tidepool-parent:pom:1.0.0-SNAPSHOT
    - [ERROR] 'build.plugins.plugin.(groupId:artifactId)' must be unique but found duplicate declaration of plugin org.apache.maven.plugins:maven-jar-plugin @ line 37, column 7
...
[INFO]     /work/tidepool-cli/pom.xml (checking for duplicate declarations)
[INFO]       • Removed duplicate plugin declaration for org.apache.maven.plugins:maven-jar-plugin in build/plugins
...
[INFO] === Overall Results ===
[INFO]   Total POMs processed: 4
[INFO]   ✓ Total POMs modified: 1
[INFO]
[INFO] Creating .mvn directory if needed to avoid root directory warnings...
[INFO] → Would create .mvn directory at /work/.mvn

check leaves the working tree untouched and prints, in the past tense, what apply would do. apply then changed one file and created the .mvn directory. This is the diff, and it is the lesson of this section:

--- a/tidepool-cli/pom.xml
+++ b/tidepool-cli/pom.xml
@@ -24,16 +24,6 @@
   </dependencies>
   <build>
     <plugins>
-      <plugin>
-        <artifactId>maven-jar-plugin</artifactId>
-        <configuration>
-          <archive>
-            <manifest>
-              <mainClass>io.tidepool.cli.Main</mainClass>
-            </manifest>
-          </archive>
-        </configuration>
-      </plugin>
       <plugin>
         <artifactId>maven-jar-plugin</artifactId>
         <configuration>

mvnup fixed the duplicate by deleting the first declaration and keeping the last, not by merging them. The build is green, and the jar is wrong: the manifest no longer has a Main-Class.

$ unzip -p tidepool-cli/target/tidepool-cli-1.0.0-SNAPSHOT.jar META-INF/MANIFEST.MF
Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.5.1
Java-Version: 17
Build-Jdk-Spec: 26

$ java -jar tidepool-cli/target/tidepool-cli-1.0.0-SNAPSHOT.jar
no main manifest attribute, in tidepool-cli/target/tidepool-cli-1.0.0-SNAPSHOT.jar

Merging the two declarations by hand in step 1, as above, gives the manifest with Main-Class: io.tidepool.cli.Main. The rule that follows is this page’s advice, not the tool’s: never run apply without reading git diff, and fix the semantic problems (duplicates with different configuration, version choices) before running it.

The prepared project

Starting again from the state at the end of step 2 (jar plugin merged, antrun 3.2.0, .mvn committed), the default mvnup check finds nothing to change:

[INFO] === Overall Results ===
[INFO]   Total POMs processed: 4
[INFO]   No POMs required modifications
[INFO]
[INFO] Creating .mvn directory if needed to avoid root directory warnings...
[INFO] ✓ .mvn directory already exists at /work/.mvn

That is the result of a good step 2: with the default options mvnup only repairs things that Maven 4 would reject. The Maven 4 features need the 4.1.0 model, so they need --model-version 4.1.0, or --all, which adds two strategies to the eleven above (13 in total): "Upgrading POM model version" and "Migrating source configuration to <source> elements".

Model 4.1.0: check and apply --all

mvnup -B check --model-version 4.1.0 --all reports four modified POMs. apply then rewrites them (git diff --stat: 4 files changed, 18 insertions, 33 deletions). The parent:

-<project xmlns="http://maven.apache.org/POM/4.0.0"
+<project xmlns="http://maven.apache.org/POM/4.1.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>
+         xsi:schemaLocation="http://maven.apache.org/POM/4.1.0 https://maven.apache.org/xsd/maven-4.1.0.xsd">
   <groupId>io.tidepool</groupId>
   <artifactId>tidepool-parent</artifactId>
   <version>1.0.0-SNAPSHOT</version>
   <packaging>pom</packaging>

-  <modules>
-    <module>tidepool-model</module>
-    <module>tidepool-engine</module>
-    <module>tidepool-cli</module>
-  </modules>
-
   <properties>
-    <maven.compiler.release>17</maven.compiler.release>
     <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
   </properties>
...
+    <sources>
+      <source>
+        <targetVersion>17</targetVersion>
+      </source>
+    </sources>
   </build>

And a subproject (tidepool-engine; tidepool-model and tidepool-cli change the same way):

-<project xmlns="http://maven.apache.org/POM/4.0.0"
+<project xmlns="http://maven.apache.org/POM/4.1.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>
+         xsi:schemaLocation="http://maven.apache.org/POM/4.1.0 https://maven.apache.org/xsd/maven-4.1.0.xsd">
   <parent>
-    <groupId>io.tidepool</groupId>
-    <artifactId>tidepool-parent</artifactId>
-    <version>1.0.0-SNAPSHOT</version>
-  </parent>
+</parent>
   <artifactId>tidepool-engine</artifactId>
...
-            <phase>pre-integration-test</phase>
+            <phase>before:integration-test</phase>

What happened, item by item, and what to check:

  • Namespace, schema and model version. The modelVersion element is removed because Maven 4 infers it from the namespace. From now on Maven 3 cannot read these POMs.

  • <modules> removed. The list matched the direct child directories, so it is redundant: Maven 4 discovers subprojects. If a subproject must be excluded or lives elsewhere, use <subprojects>.

  • Parent coordinates trimmed. groupId, artifactId and version are inferred from the parent in the same project. The result <parent> followed by </parent> on the next line is valid but not pretty; <parent/> is the same thing.

  • Phase names. pre-integration-test became before:integration-test and post-clean became after:clean.

  • maven.compiler.release migrated to <sources>. This is the one that breaks the build, below.

The tool left alone what it does not understand or does not consider wrong: the dependency <version>${project.version}</version> entries (valid, though 4.1.0 can drop them for projects in the same reactor), the pinned plugin versions, the deploy URL, and it did not add root="true", since .mvn was already there.

What breaks: Maven 3, then the compiler

Both Maven 3 lines now refuse the project, with the same message, because they do not know the new namespace:

[ERROR] [ERROR] Some problems were encountered while processing the POMs:
[ERROR] Malformed POM /work/pom.xml: Unrecognised tag: 'sources' (position: START_TAG seen ...</pluginManagement>\n    <sources>... @59:14)  @ /work/pom.xml, line 59, column 14
[ERROR] 'modelVersion' is missing. @ line 4, column 110
[ERROR] 'modelVersion' is missing. @ line 4, column 110

This is the point of no return: from here the only Maven that builds the project is Maven 4. (The consumer POM published for the project is still model 4.0.0, so users of the artifacts are not affected.) On rc-7 the build starts, and then fails in the first subproject:

[INFO] --- compiler:3.16.0:compile (default-compile) @ tidepool-model ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 1 source file with javac [debug target 1.8] to target/classes
...
[ERROR] COMPILATION ERROR :
[ERROR] /work/tidepool-model/src/main/java/io/tidepool/model/Tide.java:[3,8] records are not supported in -source 8
  (use -source 16 or higher to enable records)

mvnup replaced the maven.compiler.release property with a <sources> entry, and the Maven 3 compiler plugin pinned in the build (3.16.0) does not read <sources>, so it fell back to its defaults: target 1.8. The mechanics and the other effects of <sources> are on the overview; here only the two ways out matter, and both were run:

  1. Keep the property. Put maven.compiler.release back in the parent and drop the <sources> block. The Maven 3 compiler plugin reads the property, so rc-7 builds again (javac [debug release 17], class file major version 61).

  2. Move the compiler to the Maven 4 line. Pin maven-compiler-plugin 4.0.0-beta-5 (needs rc-6 or later) and keep <sources>. rc-7 builds (compiler:4.0.0-beta-5:compile …​ BUILD SUCCESS, class file major version 61). A pre-release plugin, so a choice for projects that are all-in on Maven 4.

 <properties>
+  <maven.compiler.release>17</maven.compiler.release>
   <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
 </properties>
...
-    <sources>
-      <source>
-        <targetVersion>17</targetVersion>
-      </source>
-    </sources>

With the second option, after all changes, mvn -B -fos WARN clean verify on rc-7 ends in BUILD SUCCESS: four reactor entries, no warnings, no root directory message. Building on JDK 17 gives the same result.

What --plugins upgrades, and to what

mvnup apply --plugins upgrades plugins "known to fail with Maven 4" to their minimum compatible version, not to the latest. On a throw-away POM that pinned a deliberately old line of 27 plugins, the log lists the moves (excerpt):

[INFO]       • Upgraded org.apache.maven.plugins:maven-compiler-plugin from 3.8.1 to 3.11.0 in build/plugins
[INFO]       • Upgraded org.apache.maven.plugins:maven-jar-plugin from 3.0.2 to 3.4.1 in build/plugins
[INFO]       • Upgraded org.apache.maven.plugins:maven-install-plugin from 2.5.2 to 3.1.4 in build/plugins
[INFO]       • Upgraded org.apache.maven.plugins:maven-deploy-plugin from 2.8.2 to 3.1.4 in build/plugins
[INFO]       • Upgraded org.apache.maven.plugins:maven-surefire-plugin from 2.22.2 to 3.5.2 in build/plugins
[INFO]       • Upgraded org.codehaus.mojo:exec-maven-plugin from 1.6.0 to 3.5.0 in build/plugins
[INFO]       • Added plugin management for org.apache.maven.plugins:maven-ear-plugin version 3.4.0 (found through effective model analysis)
...

Plugins that need nothing (here maven-site-plugin, maven-javadoc-plugin, maven-gpg-plugin, maven-release-plugin and others) were left alone. Two points. The target is the oldest version that works (compiler 3.11.0, whereas 3.16.0 is current), so after mvnup you still want to move to the current Maven 3 lines, and a plugin that was already newer than the minimum is not touched. And --plugins also added a <pluginManagement> block with 14 entries to the parent for the plugins that the effective model brought in, which is a large diff to review in an existing build.

The other strategies, on a project that triggers them

Most of the eleven strategies did nothing for tidepool. A second throw-away project, tidepool-legacy (Java 8 settings, an http: repository, a requireMavenVersion rule capped below 4, a filtered resource, an invalid Automatic-Module-Name and an Ant <script language="javascript">), shows them working. mvnup check reported these changes, and apply made them:

-      <url>http://repo.example.org/maven2</url>
+      <url>https://repo.example.org/maven2</url>
...
-              <Automatic-Module-Name>io.tidepool-legacy</Automatic-Module-Name>
+              <Automatic-Module-Name>io.tidepool.legacy</Automatic-Module-Name>
...
-                  <version>[3.6.3,4.0.0)</version>
+                  <version>[3.6.3,5)</version>
...
+        <dependencies>
+          <dependency>
+            <groupId>org.openjdk.nashorn</groupId>
+            <artifactId>nashorn-core</artifactId>
+            <version>15.7</version>
+          </dependency>
+        </dependencies>

It also added a <pluginManagement> entry for maven-resources-plugin with a nonFilteredFileExtensions list of 33 binary extensions (xlsx, png, jar and so on), which prevents filtering from corrupting binary resources. Two of these deserve a comment:

  • The enforcer range. [3.6.3,4.0.0) is a trap on a release candidate: Maven 4.0.0-rc-7 sorts below 4.0.0, so the rule passed on rc-7 (Rule 0: ...RequireMavenVersion passed) and will fail on 4.0.0. A build that passes on the release candidate is not proof that it will pass on the release. mvnup widened the range to [3.6.3,5). See Enforcer.

  • Nashorn. Inline JavaScript in Ant scripts has not worked since Java 15 removed the engine, on any Maven version: the unmodified project failed on JDK 26 with Unable to create javax script engine for javascript on Maven 3.9.16 and on rc-7. After apply the same script prints answer=42 on rc-7. mvnup only added the engine as a plugin dependency, which its own log calls a quick fix and recommends GraalVM JavaScript for the long term.

The strategy for the toolchains plugin did nothing here, because --source 8 is accepted by the JDK used. It acts when the source level is too old for the JDK that runs Maven.

What broke, in one table

Symptom Seen on Fix Step

Duplicate maven-jar-plugin in one POM

warning on 3.9.16, error on 3.10.0 and Maven 4

merge the declarations by hand (mvnup deletes the first one)

1

maven-antrun-plugin 3.1.0 uses Plexus injection

reported by maven.plugin.validation=verbose on 3.10.0 and Maven 4

plugin 3.2.0

1

Unable to find the root directory

Maven 4

.mvn directory committed, or root="true" with model 4.1.0

2

The warning vanishes after the first run

Maven 4 creates .mvn/target

commit a real .mvn entry

2

${multiModuleProjectDirectory} and ${session.executionRootDirectory} unresolved

Maven 3 and 4

${maven.multiModuleProjectDirectory}, or ${project.rootDirectory} on 3.10.0 and 4

2

post-clean script now runs on mvn clean

Maven 4

intended with after:clean; check the script

2

Nothing installed when a later subproject fails

Maven 4 with maven-install-plugin 4.x

expected (installAtEnd); investigate the failure instead

2

records are not supported in -source 8 after mvnup apply

Maven 4, compiler 3.16.0

keep maven.compiler.release, or compiler 4.0.0-beta-5

3

'modelVersion' is missing, Unrecognised tag: 'sources'

Maven 3.9 and 3.10 on a 4.1.0 POM

expected: build with Maven 4 only

3

[3.6.3,4.0.0) enforcer range passes on rc-7

Maven 4.0.0-rc-7

widen to [3.6.3,5)

3

A checklist for your own project

  1. Build with Maven 3.10.0 (-Dmaven.plugin.validation=verbose) and fix every duplicate and every plugin report, in the POM and by upgrading plugins to their latest Maven 3 lines.

  2. Move the build machines to JDK 17 and keep building with Maven 3. Add a Maven 4 job that does not block releases.

  3. Commit a .mvn directory. Search the POMs for ${pom., executionRootDirectory, multiModuleProjectDirectory, pre- and post- phase names, installAtEnd and deployAtEnd.

  4. Run mvn -fos WARN verify with Maven 4 once and read what it complains about.

  5. Commit everything, run mvnup check --model-version 4.1.0 --all, then apply, and review the diff: duplicates, <sources> against the compiler plugin version, the phase names, the plugin versions.

  6. Build with Maven 4 on JDK 17 and on the JDK you ship with; update the wrapper, the CI images and the enforcer range at the same time. After this commit Maven 3 cannot build the project.

  7. Adopt the rest in small steps: CI-friendly versions without the flatten plugin, the bom packaging, root="true", the all and each phases.

  8. Keep the rc-7 caveat in mind: re-run the migration when 4.0.0 is released, since mvnup itself is still being fixed (the rc-7 release notes list several mvnup fixes).

Troubleshooting a build that still fails, with -e, -X and help:effective-pom, is on Troubleshooting & Best Practices.

Equivalents in Ant and Gradle

Apache Ant has no model version, no plugin versions and no lifecycle, so there is nothing to migrate in the Maven 4 sense; what changes between Ant lines is on Ant Deprecations & Migration. Gradle’s equivalent of a Maven major upgrade is a Gradle major upgrade, which is done in the same three steps (run the latest minor first, fix the deprecation warnings, then change the wrapper version), and ./gradlew wrapper plays the part of the Maven wrapper properties. Moving a project between the tools, rather than between versions, is on Migrating Between Tools, and the three tools are compared on Comparing Ant, Maven & Gradle.