Writing Maven Plugins

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 Maven 3.10.0 with Maven Plugin Tools 3.16.0 (the Maven Plugin Plugin and the annotations), the Maven Plugin Testing Harness 3.5.1 and the Maven Invoker Plugin 3.10.1. Every example was run on JDK 26 on 2026-10-16 in a scratch directory with its own local repository, so that the example plugin never touched the real one. The plugin is compiled with release 21 against the Maven 3.9.16 API, which makes the same JAR run on Maven 3.9.16, 3.10.0 and Maven 4.0.0-rc-7 (a release candidate, not a final release); the build of the plugin itself and the consumer builds were run on all three, and the differences are stated. Paths in console output were shortened to /work/writing-plugins/... and $MAVEN_HOME; the three banner lines of each project, blank [INFO] lines and download lines were left out, lines longer than about 200 characters end in [...], and the long lists of artifacts that the Invoker Plugin copies were filtered out and say so. The scenario is the tidepool project used across this section, here as a data-quality goal for the data/stations.csv file that the other modules of tidepool read: the same check that Writing Custom Ant Tasks builds as an Ant task.

Almost everything that Maven does is done by a plugin, and a plugin is not a special kind of thing: it is a JAR with a descriptor, built by an ordinary Maven project, and stored in an ordinary repository. That is why writing one is a small exercise: a Java class per goal, a few annotations, one packaging value in the POM. The page walks through all of it, builds tidepool:check-stations step by step, runs it, breaks it on purpose to collect the real error messages, tests it three ways, and finishes with the Maven 4 plugin API. How to use plugins is on Plugins & Their Configuration; the phases a goal can be bound to are on Maven Build Lifecycle; the alternative that changes how Maven itself builds, a core extension, is on Extensions.

What a plugin is made of

A plugin is a JAR whose META-INF/maven/plugin.xml describes one or more goals. Each goal is implemented by a Mojo, the name being a play on POJO (plain old Java object) with "Maven" in place of "plain". The descriptor says, for every goal, its name, the class that implements it, the phase it fits into by default, the Maven features it needs and every parameter with its type, default value and the user property that sets it. Maven reads the descriptor to decide what to run and how to configure it; the Java code is loaded only when the goal runs.

The descriptor is not written by hand. The Maven Plugin Plugin (maven-plugin-plugin, from the Maven Plugin Tools project) generates it from the annotations and the Javadoc of the Mojo classes while the plugin is built, and the maven-plugin packaging wires that into the lifecycle. The two diagrams show the two halves: how the JAR comes to be, and what Maven does with it when someone types a goal:

flowchart LR SRC["Mojo classes with @Mojo and @Parameter"] --> DESC["plugin:descriptor writes META-INF/maven/plugin.xml"] DESC --> JAR["jar:jar packs classes and descriptor"] HELP["plugin:helpmojo generates HelpMojo.java"] --> SRC JAR --> REPO["install and deploy store the JAR and the prefix mapping"]
flowchart LR CMD["mvn tidepool:check-stations"] --> PFX["prefix resolution: POM plugins, then pluginGroups and repository metadata"] PFX --> RES["resolve the plugin JAR and its dependencies"] RES --> PD["read META-INF/maven/plugin.xml"] PD --> REALM["create the plugin class realm"] REALM --> INST["instantiate the Mojo, inject components, set the parameter fields"] INST --> EXEC["call execute"]

The tidepool goal

tidepool:check-stations validates the station files that the tidepool engine reads (data/stations.csv, one station per row with id, name, latitude, longitude and a semicolon-separated list of constituents). The checks are the ones of the Ant task: five columns per row, latitude within [-90, 90], longitude within [-180, 180], no duplicate ids, enough constituents, every required id present. Only the way the build file talks to the code differs, and that is what this page is about:

Ant <checkstations> Maven tidepool:check-stations Java type of the field How a user sets it

file attribute

file

java.io.File

<file> in <configuration> or -Dtidepool.file; default ${project.basedir}/data/stations.csv

nested <fileset>

files

List<File>

<files><file>...</file></files> in the POM only

minconstituents attribute

minConstituents

int

-Dtidepool.minConstituents; default 1

failonerror attribute

failOnError

boolean

-Dtidepool.failOnError; default true

report attribute (summary or full)

report

a Java enum with the constants SUMMARY and FULL

-Dtidepool.report; default SUMMARY

resultproperty attribute

resultProperty

String

-Dtidepool.resultProperty; the number of problems is stored in that project property

nested <require id="..."/>

required

List<String>

<required><id>SF</id></required> or -Dtidepool.required=SF,HNL

the task is skipped by not calling it

skip

boolean

-Dtidepool.skip; the convention of the official plugins

The Gradle equivalent is a task class with annotated input properties, registered by a plugin with the id tidepool.station-check: see Binary Plugins and Writing Custom Tasks. The Ant version, with its naming-convention setters and reflection, is on the page linked in the introduction.

The plugin project

A plugin project is a normal Maven project with the packaging maven-plugin. The directory is the usual layout plus the integration tests under src/it, which are explained in the testing section:

tidepool-maven-plugin/
  pom.xml
  src/main/java/io/tidepool/maven/
    CheckStationsMojo.java      the goal check-stations
    ListStationsMojo.java       the goal list-stations (forks check-stations first)
    StationReportMojo.java      the report goal station-report
    StationChecker.java         the checks, free of Maven types
    ReportMode.java             the enum of the report parameter
  src/test/java/io/tidepool/maven/
    StationCheckerTest.java     plain JUnit
    CheckStationsMojoTest.java  plugin testing harness
  src/test/resources/           good.csv, bad.csv
  src/it/                       integration tests run by the Invoker Plugin

The POM, complete:

tidepool-maven-plugin/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-maven-plugin</artifactId>
  <version>1.0.0-SNAPSHOT</version>
  <packaging>maven-plugin</packaging>

  <name>Tidepool Maven Plugin</name>
  <description>Validates tidepool station files.</description>

  <properties>
    <maven.compiler.release>21</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.build.outputTimestamp>2026-10-16T00:00:00Z</project.build.outputTimestamp>
    <maven.version>3.9.16</maven.version>
    <plugin-tools.version>3.16.0</plugin-tools.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.apache.maven</groupId>
      <artifactId>maven-plugin-api</artifactId>
      <version>${maven.version}</version>
      <scope>provided</scope>
    </dependency>
    <dependency>
      <groupId>org.apache.maven</groupId>
      <artifactId>maven-core</artifactId>
      <version>${maven.version}</version>
      <scope>provided</scope>
    </dependency>
    <dependency>
      <groupId>org.apache.maven.plugin-tools</groupId>
      <artifactId>maven-plugin-annotations</artifactId>
      <version>${plugin-tools.version}</version>
      <scope>provided</scope>
    </dependency>
    <dependency>
      <groupId>org.apache.maven.reporting</groupId>
      <artifactId>maven-reporting-impl</artifactId>
      <version>4.0.0</version>
    </dependency>
    <dependency>
      <groupId>org.apache.maven.plugin-testing</groupId>
      <artifactId>maven-plugin-testing-harness</artifactId>
      <version>3.5.1</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>6.1.3</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <artifactId>maven-plugin-plugin</artifactId>
        <executions>
          <execution>
            <id>help-mojo</id>
            <goals>
              <goal>helpmojo</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <artifactId>maven-invoker-plugin</artifactId>
        <version>3.10.1</version>
        <configuration>
          <cloneProjectsTo>${project.build.directory}/it</cloneProjectsTo>
          <localRepositoryPath>${project.build.directory}/local-repo</localRepositoryPath>
          <pomIncludes>
            <pomInclude>*/pom.xml</pomInclude>
          </pomIncludes>
          <postBuildHookScript>verify</postBuildHookScript>
          <streamLogs>true</streamLogs>
        </configuration>
        <executions>
          <execution>
            <id>integration-test</id>
            <goals>
              <goal>install</goal>
              <goal>run</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
    <pluginManagement>
      <plugins>
        <plugin><artifactId>maven-clean-plugin</artifactId><version>3.5.0</version></plugin>
        <plugin><artifactId>maven-resources-plugin</artifactId><version>3.5.0</version></plugin>
        <plugin><artifactId>maven-compiler-plugin</artifactId><version>3.16.0</version></plugin>
        <plugin><artifactId>maven-surefire-plugin</artifactId><version>3.6.0</version></plugin>
        <plugin><artifactId>maven-jar-plugin</artifactId><version>3.5.1</version></plugin>
        <plugin><artifactId>maven-install-plugin</artifactId><version>3.2.0</version></plugin>
        <plugin><artifactId>maven-deploy-plugin</artifactId><version>3.2.0</version></plugin>
        <plugin><artifactId>maven-plugin-plugin</artifactId><version>${plugin-tools.version}</version></plugin>
      </plugins>
    </pluginManagement>
  </build>
</project>

What each part does:

  • The coordinates. The artifactId follows the convention <prefix>-maven-plugin, which is what lets Maven derive the goal prefix tidepool by itself. The pattern maven-<prefix>-plugin is reserved for the plugins of the Apache Maven project under the groupId org.apache.maven.plugins, and the official guide calls using it an infringement of the Apache Maven trademark.

  • The packaging. maven-plugin gives the project its own set of lifecycle bindings (next section) and tells Maven that the artifact has to carry a descriptor.

  • Three provided dependencies. maven-plugin-api for AbstractMojo and the exceptions, maven-core for MavenProject, RuntimeInformation and the other build objects, and maven-plugin-annotations for @Mojo and @Parameter. They are provided because Maven supplies them at run time. Their version is the oldest Maven that the plugin supports: the descriptor generated below records requiredMavenVersion 3.9.16 from it and requiredJavaVersion 21 from maven.compiler.release. Maven enforces the Java requirement (see the mistakes section for the message). In a test with a plugin that declared requiredMavenVersion 3.99.0, Maven 3.9.16, 3.10.0 and 4.0.0-rc-7 ran the goal without any complaint, so that entry is documentation rather than a guard.

  • maven-reporting-impl is the one compile-scope dependency, needed only by the report goal. It brings the Doxia sink API and a few helper libraries into the plugin class realm, which is why a plugin without report goals should not have it.

  • maven-plugin-plugin with a helpmojo execution. The descriptor goal is bound by the packaging; the helpmojo goal is not, and generates the help goal described below. Its version is pinned to the Plugin Tools version, as the official guide does.

  • project.build.outputTimestamp makes the JAR reproducible, see Security & Reproducible Builds.

  • Every plugin version is pinned in pluginManagement, as everywhere in this section.

The lifecycle of the maven-plugin packaging

The packaging binds these goals, as listed in the Maven core reference (the Surefire, resources and compiler goals appear twice, for the main and the test code):

Phase Goal

process-resources

resources:resources

compile

compiler:compile

process-classes

plugin:descriptor: extracts the data to build the descriptor from the compiled classes and the sources

process-test-resources, test-compile, test

resources:testResources, compiler:testCompile, surefire:test

package

jar:jar

install

install:install

deploy

deploy:deploy

The descriptor is generated in process-classes, after compilation, from the compiled classes and the sources. Running mvn clean verify on Maven 3.10.0 shows the goals in order, with the helpmojo goal of the POM first (it generates a source file, so it has to run before compile) and the Invoker Plugin at the end:

[INFO] --- clean:3.5.0:clean (default-clean) @ tidepool-maven-plugin ---
[INFO] Deleting /work/writing-plugins/tidepool-maven-plugin/target
[INFO] --- plugin:3.16.0:helpmojo (help-mojo) @ tidepool-maven-plugin ---
[INFO] --- resources:3.5.0:resources (default-resources) @ tidepool-maven-plugin ---
[INFO] skip non existing resourceDirectory /work/writing-plugins/tidepool-maven-plugin/src/main/resources
[INFO] --- compiler:3.16.0:compile (default-compile) @ tidepool-maven-plugin ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 6 source files with javac [debug release 21] to target/classes
[INFO] --- plugin:3.16.0:descriptor (default-descriptor) @ tidepool-maven-plugin ---
[INFO] Using 'UTF-8' encoding to read mojo source files.
[WARNING] Unresolvable link in javadoc tag with value #getOutputName() found in target/maven-plugin-plugin-sources/org.apache.maven.reporting/maven-reporting-impl/4.0.0/sources/org/apache/maven/re [...]
[WARNING] Unresolvable link in javadoc tag with value #getOutputName() found in target/maven-plugin-plugin-sources/org.apache.maven.reporting/maven-reporting-impl/4.0.0/sources/org/apache/maven/re [...]
[INFO] java-annotations mojo extractor found 4 mojo descriptors.
[INFO] java-javadoc mojo extractor found 0 mojo descriptor.
[INFO] ant mojo extractor found 0 mojo descriptor.
[INFO] bsh mojo extractor found 0 mojo descriptor.
[WARNING] Mojo station-report uses Plexus Component requirements (@Component annotation) for fields: [container, siteRenderer, siteTool]
[WARNING] Use JSR 330 annotations to inject dependencies instead.
[INFO] --- resources:3.5.0:testResources (default-testResources) @ tidepool-maven-plugin ---
[INFO] Copying 2 resources from src/test/resources to target/test-classes
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ tidepool-maven-plugin ---
[INFO] Recompiling the module because of changed dependency.
[INFO] Compiling 2 source files with javac [debug release 21] to target/test-classes
[INFO] --- surefire:3.6.0:test (default-test) @ tidepool-maven-plugin ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]  T E S T S
[INFO] Running io.tidepool.maven.CheckStationsMojoTest
SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.
WARNING: Final field logger in class org.apache.maven.plugin.testing.MojoLogWrapper has been mutated reflectively by class org.mockito.internal.util.reflection.ReflectionMemberAccessor in unnamed [...]
WARNING: Use --enable-final-field-mutation=ALL-UNNAMED to avoid a warning
WARNING: Mutating final fields will be blocked in a future release unless final field mutation is enabled
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.883 s -- in io.tidepool.maven.CheckStationsMojoTest
[INFO] Running io.tidepool.maven.StationCheckerTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.018 s -- in io.tidepool.maven.StationCheckerTest
[INFO] Results:
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] --- jar:3.5.1:jar (default-jar) @ tidepool-maven-plugin ---
[INFO] Building jar: /work/writing-plugins/tidepool-maven-plugin/target/tidepool-maven-plugin-1.0.0-SNAPSHOT.jar

The plugin:descriptor lines show the extractors: java-annotations found the four Mojos (the three goals of the plugin and the generated help goal), while java-javadoc (the tags of Maven 2, see the section on outdated material), ant and bsh (Mojos written as Ant or BeanShell scripts) found none. The two warnings about #getOutputName() and about Plexus @Component requirements come from the report goal and are explained in its section. Output of the tests and the integration tests is covered in the testing section.

The Mojo class

The checks live in a plain class that knows nothing about Maven. It reports findings through two callbacks, so a Mojo can send them to the Maven log and a unit test can collect them in a list:

tidepool-maven-plugin/src/main/java/io/tidepool/maven/StationChecker.java
package io.tidepool.maven;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.List;
import java.util.Set;
import java.util.function.BiConsumer;

/** The checks behind the goals, kept free of Maven types so that they are easy to test. */
final class StationChecker {

    /** Receives one finding: the severity and the text. */
    enum Severity { ERROR, WARNING }

    private final int minConstituents;
    private final BiConsumer<Severity, String> findings;
    private final BiConsumer<String, String> rows;

    StationChecker(int minConstituents, BiConsumer<Severity, String> findings, BiConsumer<String, String> rows) {
        this.minConstituents = minConstituents;
        this.findings = findings;
        this.rows = rows;
    }

    /** Checks one file, adds the station ids to {@code seen} and returns the number of problems. */
    int check(File input, Set<String> seen) throws IOException {
        List<String> lines = Files.readAllLines(input.toPath(), StandardCharsets.UTF_8);
        int problems = 0;
        for (int i = 1; i < lines.size(); i++) {      // line 0 is the header
            String[] f = lines.get(i).split(",", -1);
            String where = input.getName() + ":" + (i + 1);
            rows.accept(where, lines.get(i));
            if (f.length != 5) {
                findings.accept(Severity.ERROR, where + " expected 5 columns, found " + f.length);
                problems++;
                continue;
            }
            if (!seen.add(f[0])) {
                findings.accept(Severity.ERROR, where + " duplicate station id " + f[0]);
                problems++;
            }
            problems += range(where, "latitude", f[2], -90, 90);
            problems += range(where, "longitude", f[3], -180, 180);
            int n = f[4].isEmpty() ? 0 : f[4].split(";").length;
            if (n < minConstituents) {
                findings.accept(Severity.WARNING,
                        where + " has " + n + " constituent(s), at least " + minConstituents + " required");
                problems++;
            }
        }
        return problems;
    }

    private int range(String where, String what, String text, double min, double max) {
        try {
            double v = Double.parseDouble(text);
            if (v < min || v > max) {
                findings.accept(Severity.ERROR, where + " " + what + " " + v + " is outside [" + min + ", " + max + "]");
                return 1;
            }
            return 0;
        } catch (NumberFormatException e) {
            findings.accept(Severity.ERROR, where + " " + what + " is not a number: '" + text + "'");
            return 1;
        }
    }
}

The goal itself extends org.apache.maven.plugin.AbstractMojo, which supplies getLog() and the Mojo interface, and implements execute():

tidepool-maven-plugin/src/main/java/io/tidepool/maven/CheckStationsMojo.java
package io.tidepool.maven;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;

import javax.inject.Inject;

import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;
import org.apache.maven.rtinfo.RuntimeInformation;

/**
 * Validates tidepool station files: five columns per row, latitude within [-90, 90], longitude within
 * [-180, 180], no duplicate ids, enough constituents and every required id present.
 *
 * @since 1.0.0
 */
@Mojo(name = "check-stations", defaultPhase = LifecyclePhase.VALIDATE, threadSafe = true)
public class CheckStationsMojo extends AbstractMojo {

    /** The station file to check. */
    @Parameter(property = "tidepool.file", defaultValue = "${project.basedir}/data/stations.csv")
    private File file;

    /** More station files, checked together with {@code file}. */
    @Parameter
    private List<File> files;

    /** Station ids that must be present in the checked files. */
    @Parameter(property = "tidepool.required")
    private List<String> required;

    /** Every station needs at least this many constituents. */
    @Parameter(property = "tidepool.minConstituents", defaultValue = "1")
    private int minConstituents;

    /** Fail the build when a problem is found, or only warn. */
    @Parameter(property = "tidepool.failOnError", defaultValue = "true")
    private boolean failOnError;

    /** {@code SUMMARY} or {@code FULL}; {@code FULL} also lists the station ids that were seen. */
    @Parameter(property = "tidepool.report", defaultValue = "SUMMARY")
    private ReportMode report;

    /** Name of a project property that receives the number of problems. */
    @Parameter(property = "tidepool.resultProperty")
    private String resultProperty;

    /** Skip the check. */
    @Parameter(property = "tidepool.skip", defaultValue = "false")
    private boolean skip;

    private final MavenProject project;
    private final RuntimeInformation runtime;

    @Inject
    public CheckStationsMojo(MavenProject project, RuntimeInformation runtime) {
        this.project = project;
        this.runtime = runtime;
    }

    @Override
    public void execute() throws MojoExecutionException, MojoFailureException {
        if (skip) {
            getLog().info("Skipping the station check");
            return;
        }
        getLog().debug("Running on Maven " + runtime.getMavenVersion());
        List<File> inputs = new ArrayList<>();
        if (file != null) {
            inputs.add(file);
        }
        if (files != null) {
            inputs.addAll(files);
        }
        if (inputs.isEmpty()) {
            throw new MojoExecutionException("Set file or files");
        }

        StationChecker checker = new StationChecker(minConstituents,
                (severity, text) -> {
                    if (severity == StationChecker.Severity.ERROR) {
                        getLog().error(text);
                    } else {
                        getLog().warn(text);
                    }
                },
                (where, row) -> getLog().debug("Row " + where + " -> " + row));

        int problems = 0;
        Set<String> seen = new TreeSet<>();
        for (File input : inputs) {
            getLog().debug("Checking " + input.getName());
            try {
                problems += checker.check(input, seen);
            } catch (IOException e) {
                throw new MojoExecutionException(
                        "Cannot read " + input + " (" + e.getClass().getSimpleName() + ")", e);
            }
        }
        if (required != null) {
            for (String id : required) {
                if (!seen.contains(id)) {
                    getLog().error("Required station " + id + " is missing");
                    problems++;
                }
            }
        }

        if (report == ReportMode.FULL) {
            getLog().info("Stations seen: " + String.join(",", seen));
        }
        if (resultProperty != null) {
            project.getProperties().setProperty(resultProperty, Integer.toString(problems));
        }
        if (problems == 0) {
            getLog().info(inputs.size() + " file(s), " + seen.size() + " station(s): OK");
        } else if (failOnError) {
            throw new MojoFailureException(problems + " problem(s) found in station data");
        } else {
            getLog().warn(problems + " problem(s) found in station data");
        }
    }
}
tidepool-maven-plugin/src/main/java/io/tidepool/maven/ReportMode.java
package io.tidepool.maven;

/** Values of the {@code report} parameter. */
public enum ReportMode {
    /** Only the verdict. */
    SUMMARY,
    /** The verdict and the ids of the stations that were seen. */
    FULL
}

The requirements on a Mojo are few: a public void execute() that throws only MojoExecutionException, MojoFailureException or a runtime exception, and one field per parameter. Maven fills the fields before it calls execute(). Everything else is metadata, and the metadata comes from the annotations.

The @Mojo annotation

@Mojo(name = "check-stations") is the minimal form, and the only attribute without a default. The class Javadoc becomes the description of the goal that tidepool:help and the generated site show, and a @since tag becomes the since entry in the descriptor; the official sample also shows @deprecated on classes and fields. These are all the attributes of @Mojo in maven-plugin-annotations 3.16.0, as listed by javap and the official page:

Attribute Default Meaning

name

required

The goal name, check-stations. The convention is lowercase words joined by hyphens.

defaultPhase

LifecyclePhase.NONE

The phase the goal is bound to when a POM lists it in an execution without a <phase>. NONE means that the goal has no default and the POM must name a phase. VALIDATE makes check-stations run first in every build.

requiresDependencyResolution

ResolutionScope.NONE

Which dependencies Maven must resolve, and download, before the goal runs: NONE, COMPILE, COMPILE_PLUS_RUNTIME, RUNTIME, RUNTIME_PLUS_SYSTEM or TEST. The goal then finds the files through MavenProject.getArtifacts().

requiresDependencyCollection

ResolutionScope.NONE

Like the previous one but only builds the dependency tree, without downloading the files (Maven 3.0 and later).

aggregator

false

true makes the goal run once for a whole multi-module build, not once per module, and gives it access to all of them through MavenSession.getProjects().

requiresProject

true

false allows the goal to run outside a project, with no POM, like archetype:generate.

requiresOnline

false

true makes the goal fail with -o instead of running offline.

threadSafe

false

Declares that the goal can run in parallel builds (-T); see the section on thread safety.

instantiationStrategy

PER_LOOKUP

PER_LOOKUP creates a new Mojo object for every execution, which is the only sensible choice; SINGLETON, KEEP_ALIVE and POOLABLE exist for compatibility.

configurator

the basic configurator

The role hint of a custom component that sets the parameters. Almost never used.

executionStrategy, inheritByDefault, requiresDirectInvocation, requiresReports

various

Unsupported since Maven 3.0 according to the official annotations page; they remain in the annotation for old plugins.

LifecyclePhase has one constant per standard phase of the default, clean and site lifecycles (VALIDATE, INITIALIZE, GENERATE_SOURCES and so on up to DEPLOY, PRE_CLEAN, CLEAN, POST_CLEAN, PRE_SITE, SITE, POST_SITE, SITE_DEPLOY) plus NONE.

The @Parameter annotation

Every field with @Parameter is a parameter of the goal, named after the field (or after the name attribute, which is needed when a setter is annotated and its name does not match). The attributes, again from the official page and javap:

Attribute Meaning

property

The user property that sets the parameter from the command line, -Dtidepool.minConstituents=2, or from a POM <properties> element or settings.xml. Without it the parameter can be set only in <configuration>; that is the case of files.

defaultValue

The value when nothing else sets the parameter. It may contain expressions such as ${project.basedir}, ${project.build.directory} or ${project}.

required

true makes Maven stop with an error when the value is still missing after defaults and configuration are applied.

readonly

true marks a parameter that the user is not meant to configure; it exists to get a build object injected, like ${project}.

alias

A second name accepted in <configuration>, to keep an old name working after a rename.

name

The parameter name when it differs from the field name; used mainly on setter methods.

Annotations on a public setter work as well as on a field, since Plugin Tools 3.7.0, which makes a Mojo usable outside of Maven. The Javadoc of the field is the description in the help output and the generated site, so it is worth writing in full sentences. This is how tidepool:help shows the parameters of the finished goal:

[INFO] --- tidepool:1.0.0-SNAPSHOT:help (default-cli) @ tidepool-data ---
[INFO] Tidepool Maven Plugin 1.0.0-SNAPSHOT
  Validates tidepool station files.

tidepool:check-stations
  Validates tidepool station files: five columns per row, latitude within [-90,
  90], longitude within [-180, 180], no duplicate ids, enough constituents and
  every required id present.

  Available parameters:

    failOnError (Default: true)
      Fail the build when a problem is found, or only warn.
      User property: tidepool.failOnError

    file (Default: ${project.basedir}/data/stations.csv)
      The station file to check.
      User property: tidepool.file

    files
      More station files, checked together with file.

    minConstituents (Default: 1)
      Every station needs at least this many constituents.
      User property: tidepool.minConstituents

    report (Default: SUMMARY)
      SUMMARY or FULL; FULL also lists the station ids that were seen.
      User property: tidepool.report

    required
      Station ids that must be present in the checked files.
      User property: tidepool.required

    resultProperty
      Name of a project property that receives the number of problems.
      User property: tidepool.resultProperty

    skip (Default: false)
      Skip the check.
      User property: tidepool.skip


[INFO] BUILD SUCCESS

The descriptor entry behind that output, in META-INF/maven/plugin.xml of the built JAR (one parameter and the default configuration shown; the file lists every parameter):

    <mojo>
      <goal>check-stations</goal>
      <description>Validates tidepool station files: five columns per row, latitude within [-90, 90], longitude within [-180, 180], no duplicate ids, enough constituents and every required id present.</description>
      <requiresDirectInvocation>false</requiresDirectInvocation>
      <requiresProject>true</requiresProject>
      <requiresReports>false</requiresReports>
      <aggregator>false</aggregator>
      <requiresOnline>false</requiresOnline>
      <inheritedByDefault>true</inheritedByDefault>
      <phase>validate</phase>
      <implementation>io.tidepool.maven.CheckStationsMojo</implementation>
      <language>java</language>
      <instantiationStrategy>per-lookup</instantiationStrategy>
      <executionStrategy>once-per-session</executionStrategy>
      <since>1.0.0</since>
      <threadSafe>true</threadSafe>
      <parameters>
        <!-- the other parameters are left out of this listing -->
        <parameter>
          <name>minConstituents</name>
          <type>int</type>
          <required>false</required>
          <editable>true</editable>
          <description>Every station needs at least this many constituents.</description>
        </parameter>
      </parameters>
      <configuration>
        <!-- one entry per parameter, for example: -->
        <minConstituents implementation="int" default-value="1">${tidepool.minConstituents}</minConstituents>
      </configuration>
    </mojo>

Types and conversion

Maven converts the text of a <configuration> element, or of a -D property, to the type of the field. The rules are in the configuration guide; these are the types that check-stations uses, and what the runs below show:

Field type Conversion

boolean, int and the other primitives and wrappers

The wrapper’s own parser. A bad value fails before execute().

java.io.File

new File(String) with the separators normalized; a relative file is made absolute against the project’s base directory, which is why -Dtidepool.file=data/bad.csv works.

an enum

Enum.valueOf(String), so the case must match the constant exactly.

String

The text as it is, after expression evaluation.

List<String>, List<File>, arrays

One child element per item, whatever its name; the element name does not matter. Since Maven 3.3.9 a comma-separated text also works, and that is what -Dtidepool.required=SF,HNL relies on. Items are converted by the type argument of the list.

The guide documents more: Date (four fixed patterns), URL, URI, Class, StringBuilder, Set (a HashSet, or a TreeSet for a SortedSet), Map<String,String> (child element name as key), Properties, and complex objects (a nested element maps to a class in the Mojo’s package, or to the class named by an implementation attribute). A complex object is the closest analogue of an Ant nested element, and the right choice when a goal needs a structured option such as <station><id>SF</id><minConstituents>3</minConstituents></station>; this example uses a list of plain strings instead.

Running the goal

The plugin is built and installed into a local repository of its own with mvn install -Dmaven.repo.local=..., then used by a consumer project. The consumer is a minimal project with the packaging pom and the three data files:

consumer/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-data</artifactId>
  <version>1.0.0-SNAPSHOT</version>
  <packaging>pom</packaging>

  <build>
    <plugins>
      <plugin>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-maven-plugin</artifactId>
        <version>1.0.0-SNAPSHOT</version>
        <executions>
          <execution>
            <id>check</id>
            <goals>
              <goal>check-stations</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</project>

The execution has no <phase>, so Maven uses defaultPhase, which is validate. A plain mvn validate runs the goal with the defaults. (pom-configured.xml, pom-result.xml and the other POMs of this section are copies of this one with a different <plugin> part.)

$ mvn validate
[INFO] --- tidepool:1.0.0-SNAPSHOT:check-stations (check) @ tidepool-data ---
[INFO] 1 file(s), 3 station(s): OK
[INFO] BUILD SUCCESS

The defaults are data/stations.csv with at least one constituent per station. The same goal on the broken file, with the options of the Ant example given as user properties (bad.csv has a latitude of 137.8, a non-numeric latitude, a duplicate id and a station without constituents):

$ mvn -Dtidepool.file=data/bad.csv -Dtidepool.minConstituents=2 -Dtidepool.required=SF,HNL validate
[INFO] --- tidepool:1.0.0-SNAPSHOT:check-stations (check) @ tidepool-data ---
[ERROR] bad.csv:3 latitude 137.8 is outside [-90.0, 90.0]
[ERROR] bad.csv:4 latitude is not a number: 'north'
[WARNING] bad.csv:4 has 1 constituent(s), at least 2 required
[ERROR] bad.csv:5 duplicate station id SF
[WARNING] bad.csv:5 has 0 constituent(s), at least 2 required
[ERROR] Required station HNL is missing
[ERROR] BUILD FAILURE
[ERROR] Failed to execute goal io.tidepool:tidepool-maven-plugin:1.0.0-SNAPSHOT:check-stations (check) on project tidepool-data: 6 problem(s) found in station data -> [Help 1]
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException

Lines at error level are the problems, lines at warning level are the missing constituents, and the build fails with one summary that carries the name of the exception in its help link. The link is the useful part: it says whether the Mojo threw a MojoFailureException, a MojoExecutionException or whether the configuration failed before the Mojo ran.

The list, the result property and the skip

files takes more files, and resultProperty with failOnError set to false turns the check into a counter that other plugins can read. This POM (pom-result.xml, same project) checks stations.csv plus more.csv, which repeats the station SF, and prints the property with the Antrun plugin, which evaluates ${problems} at the time it runs:

consumer/pom-result.xml (the build section)
  <build>
    <plugins>
      <plugin>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-maven-plugin</artifactId>
        <version>1.0.0-SNAPSHOT</version>
        <executions>
          <execution>
            <id>check</id>
            <goals>
              <goal>check-stations</goal>
            </goals>
            <configuration>
              <failOnError>false</failOnError>
              <resultProperty>problems</resultProperty>
              <files>
                <file>${project.basedir}/data/more.csv</file>
              </files>
            </configuration>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <artifactId>maven-antrun-plugin</artifactId>
        <version>3.2.0</version>
        <executions>
          <execution>
            <id>show</id>
            <phase>validate</phase>
            <goals>
              <goal>run</goal>
            </goals>
            <configuration>
              <target>
                <echo>problems=${problems}</echo>
              </target>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
$ mvn -f pom-result.xml validate
[INFO] --- tidepool:1.0.0-SNAPSHOT:check-stations (check) @ tidepool-data ---
[ERROR] more.csv:3 duplicate station id SF
[WARNING] 1 problem(s) found in station data
[INFO] --- antrun:3.2.0:run (show) @ tidepool-data ---
[INFO] Executing tasks
[WARNING]      [echo] problems=1
[INFO] Executed tasks
[INFO] BUILD SUCCESS

The check fails nothing here: it logs the problem at warning level, and the property reaches the next plugin because project.getProperties().setProperty(...) changes the model that expression evaluation reads. report=FULL adds the station ids, and skip ends the goal at its first line:

$ mvn -Dtidepool.report=FULL validate
[INFO] --- tidepool:1.0.0-SNAPSHOT:check-stations (check) @ tidepool-data ---
[INFO] Stations seen: BOS,HNL,SF
[INFO] 1 file(s), 3 station(s): OK
[INFO] BUILD SUCCESS
$ mvn -Dtidepool.skip validate
[INFO] --- tidepool:1.0.0-SNAPSHOT:check-stations (check) @ tidepool-data ---
[INFO] Skipping the station check
[INFO] BUILD SUCCESS

Parameters that do not convert

Conversion errors are reported by Maven, before execute() is reached, with the parameter and the reason. The enum is case-sensitive, unlike the attribute of the Ant task:

$ mvn -Dtidepool.report=full validate
[INFO] --- tidepool:1.0.0-SNAPSHOT:check-stations (check) @ tidepool-data ---
[ERROR] BUILD FAILURE
[ERROR] Failed to execute goal io.tidepool:tidepool-maven-plugin:1.0.0-SNAPSHOT:check-stations (check) on project tidepool-data: Unable to parse configuration of mojo io.tidepool:tidepool-maven-pl [...]
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/PluginConfigurationException
$ mvn -Dtidepool.minConstituents=two validate
[INFO] --- tidepool:1.0.0-SNAPSHOT:check-stations (check) @ tidepool-data ---
[ERROR] BUILD FAILURE
[ERROR] Failed to execute goal io.tidepool:tidepool-maven-plugin:1.0.0-SNAPSHOT:check-stations (check) on project tidepool-data: Unable to parse configuration of mojo io.tidepool:tidepool-maven-pl [...]
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/PluginConfigurationException

A file that cannot be read is the Mojo’s own error, raised as a MojoExecutionException with the cause kept:

$ mvn -Dtidepool.file=data/nowhere.csv validate
[INFO] --- tidepool:1.0.0-SNAPSHOT:check-stations (check) @ tidepool-data ---
[ERROR] BUILD FAILURE
[ERROR] Failed to execute goal io.tidepool:tidepool-maven-plugin:1.0.0-SNAPSHOT:check-stations (check) on project tidepool-data: Cannot read /work/writing-plugins/consumer/data/nowhere.csv (NoSuch [...]
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoExecutionException

Where a value comes from

A parameter gets its value from the first of these that applies, in this order: the <configuration> of the execution or the plugin in the POM (including inherited and pluginManagement configuration), then the user property named by property, then defaultValue. The order surprises people who expect -D to win. pom-configured.xml sets minConstituents to 2 and the required ids in the POM:

consumer/pom-configured.xml (the plugin part)
      <plugin>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-maven-plugin</artifactId>
        <version>1.0.0-SNAPSHOT</version>
        <configuration>
          <minConstituents>2</minConstituents>
          <required>
            <id>SF</id>
            <id>HNL</id>
          </required>
        </configuration>
        <executions>
          <execution>
            <id>check</id>
            <goals>
              <goal>check-stations</goal>
            </goals>
          </execution>
        </executions>
      </plugin>

The command line asks for 3, and the debug output of the configurator shows which one the Mojo received (the (f) lines are the fields it set, one per parameter that has a value):

$ mvn -X -Dtidepool.minConstituents=3 -f pom-configured.xml validate
[DEBUG] Configuring mojo execution 'io.tidepool:tidepool-maven-plugin:1.0.0-SNAPSHOT:check-stations:check' with basic configurator -->
[DEBUG]   (f) failOnError = true
[DEBUG]   (f) file = /work/writing-plugins/consumer/data/stations.csv
[DEBUG]   (f) minConstituents = 2
[DEBUG]   (f) report = SUMMARY
[DEBUG]   (f) required = [SF, HNL]
[DEBUG]   (f) skip = false
[DEBUG] -- end configuration --
[DEBUG] Running on Maven 3.10.0

The POM’s 2 won. To let users override a value from the command line, leave it out of the POM, or put it in a POM property and refer to it from <configuration> with ${...}. The same output shows that the file default ${project.basedir}/data/stations.csv was evaluated to an absolute path.

Build objects and injection

A Mojo usually needs more than its own parameters: the project, the session, the plugin’s own descriptor, the Maven version, the settings. Maven offers them in three ways; the first is used by check-stations.

Constructor injection with JSR-330 is the form to use in new code. The Mojo has one public constructor annotated with @Inject (javax.inject.Inject on Maven 3), and Maven (through its Eclipse Sisu container) passes the objects in. The official annotations page lists the injectable build objects: MavenSession, MavenProject, MojoExecution, and any component of Maven or of another extension, such as RuntimeInformation used here to log the Maven version. A Mojo with a constructor has final fields and can be created in a unit test with plain new, or with Mockito mocks, without a container. No annotation processing is needed: the descriptor does not list injected constructor arguments, as the plugin.xml excerpt above shows.

Pseudo-parameters are the older way: a read-only parameter whose default is an expression. @Parameter(defaultValue = "${project}", readonly = true) injects the MavenProject; ${session}, ${mojoExecution}, ${plugin} (the plugin descriptor), ${settings}, ${reactorProjects} and the ${project.*} paths work the same. They appear in the descriptor as parameters, and the official page now recommends constructor injection for plugins that target Maven 3.2.5 or later.

Plexus @Component fields are the oldest form, and the one to leave behind. @Component from maven-plugin-annotations injects a Plexus component by role; the build above warns about it, for a field of the report base class: "Mojo station-report uses Plexus Component requirements (@Component annotation) for fields …​ Use JSR 330 annotations to inject dependencies instead." Maven 4 removed Plexus dependency injection as an API (Sisu still ships for compatibility), and the Maven 4 notes say that a plugin that still relies on it must be updated to JSR-330.

The debug line written by the constructor-injected RuntimeInformation proves that the same JAR runs on all three releases:

$ mvn -X validate        (Maven 3.9.16)
[DEBUG] Running on Maven 3.9.16
$ mvn -X validate        (Maven 3.10.0)
[DEBUG] Running on Maven 3.10.0
$ mvn -X validate        (Maven 4.0.0-rc-7)
[DEBUG] Running on Maven 4.0.0-rc-7

Logging and failing

getLog() returns the Maven logger with four levels: debug (shown with -X), info, warn and error. Since Maven 3.1.0 the logging is backed by SLF4J, and the logger is the way to let -q, -X and the logging configuration of the Maven installation decide what the user sees. Ant’s five priorities map onto these four: errors to error, warnings to warn, the verdict to info, and both the per-file and the per-row detail to debug, because Maven has no verbose level between info and debug. The run with -X above shows the rows that check-stations writes at debug level.

A Mojo ends a build by throwing:

  • MojoFailureException: the build failed for a reason that the user can fix, an expected failure such as a failed assertion or a compilation error. check-stations throws it when it found problems.

  • MojoExecutionException: an unexpected problem while the goal ran, such as an unreadable file; the constructor takes the cause, which -e prints. check-stations throws it for an I/O error and for missing input.

  • The Mojo API specification also allows a RuntimeException. It ends the build in the same way, but it signals a bug in the plugin rather than a problem that the user can fix.

The Javadoc of the two classes says that throwing a failure shows "BUILD FAILURE" and an execution exception shows "BUILD ERROR". That is not what Maven 3.10.0 prints: the output of the missing file above shows "BUILD FAILURE" for the MojoExecutionException as well. The difference is only the name in the help link at the end of the message (MojoFailureException against MojoExecutionException, or PluginConfigurationException for the conversion errors), which tools such as IDEs and CI scripts can use. The message of the exception is what users read, so name the file, the value and the reason; the build adds the goal, the execution id and the project. On Maven 4 the exceptions of the new API are different; see the last section.

Binding a goal to the lifecycle

A goal runs when a POM execution binds it to a phase and Maven reaches that phase, or when it is named on the command line. The phase of an execution is the <phase> of the POM, or, when that is missing, the defaultPhase of the @Mojo. The consumer POM above relies on defaultPhase = VALIDATE. A goal run from the command line gets the execution id default-cli:

$ mvn tidepool:check-stations
[INFO] --- tidepool:1.0.0-SNAPSHOT:check-stations (default-cli) @ tidepool-data ---
[INFO] 1 file(s), 3 station(s): OK
[INFO] BUILD SUCCESS

A goal with neither a <phase> nor a defaultPhase is the classic mistake. list-stations has no defaultPhase, so a POM that lists it without a <phase> binds it nowhere, and Maven says nothing at all. pom-phases.xml lists the goal twice, once without a phase (execution list) and once in verify (execution late); the output of mvn verify has no trace of the execution list:

$ mvn -f pom-phases.xml verify
[INFO] --- tidepool:1.0.0-SNAPSHOT:check-stations (check) @ tidepool-data ---
[INFO] 1 file(s), 3 station(s): OK
[INFO] >>> tidepool:1.0.0-SNAPSHOT:list-stations (late) > :check-stations @ tidepool-data >>>
[INFO] --- tidepool:1.0.0-SNAPSHOT:check-stations (check-stations) @ tidepool-data ---
[INFO] 1 file(s), 3 station(s): OK
[INFO] <<< tidepool:1.0.0-SNAPSHOT:list-stations (late) < :check-stations @ tidepool-data <<<
[INFO] --- tidepool:1.0.0-SNAPSHOT:list-stations (late) @ tidepool-data ---
[INFO] SF San Francisco
[INFO] BOS Boston
[INFO] HNL Honolulu
[INFO] BUILD SUCCESS

Because the phase decides the order of the executions, a goal that changes files for others should be bound early, one that checks results late, and an aggregating goal (aggregator = true) in a phase of the parent project. Several executions of the same goal with different configuration, ids, <inherited> and the order of executions within one phase are explained on Plugins & Their Configuration.

Forking a lifecycle with @Execute

A goal can ask Maven to run something else first with @Execute, which takes a goal, or a phase (or a customPhase), and a lifecycle id. list-stations lists the stations of a file, but only after check-stations has accepted it:

tidepool-maven-plugin/src/main/java/io/tidepool/maven/ListStationsMojo.java
package io.tidepool.maven;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.List;

import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.Execute;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;

/**
 * Lists the station ids of a file, after the {@code check-stations} goal accepted it.
 *
 * @since 1.0.0
 */
@Mojo(name = "list-stations", threadSafe = true)
@Execute(goal = "check-stations")
public class ListStationsMojo extends AbstractMojo {

    /** The station file to list. */
    @Parameter(property = "tidepool.file", defaultValue = "${project.basedir}/data/stations.csv")
    private File file;

    @Override
    public void execute() throws MojoExecutionException {
        try {
            List<String> lines = Files.readAllLines(file.toPath(), StandardCharsets.UTF_8);
            for (String line : lines.subList(1, lines.size())) {
                String[] f = line.split(",", -1);
                getLog().info(f[0] + " " + f[1]);
            }
        } catch (IOException e) {
            throw new MojoExecutionException("Cannot read " + file, e);
        }
    }
}

Maven brackets the forked execution in the log with >>> and <<< lines and then runs the goal. The forked execution gets the id of the forked goal, check-stations, and not the id of the caller:

$ mvn tidepool:list-stations
[INFO] >>> tidepool:1.0.0-SNAPSHOT:list-stations (default-cli) > :check-stations @ tidepool-data >>>
[INFO] --- tidepool:1.0.0-SNAPSHOT:check-stations (check-stations) @ tidepool-data ---
[INFO] 1 file(s), 3 station(s): OK
[INFO] <<< tidepool:1.0.0-SNAPSHOT:list-stations (default-cli) < :check-stations @ tidepool-data <<<
[INFO] --- tidepool:1.0.0-SNAPSHOT:list-stations (default-cli) @ tidepool-data ---
[INFO] SF San Francisco
[INFO] BOS Boston
[INFO] HNL Honolulu
[INFO] BUILD SUCCESS
$ mvn tidepool:list-stations -Dtidepool.file=data/bad.csv
[INFO] >>> tidepool:1.0.0-SNAPSHOT:list-stations (default-cli) > :check-stations @ tidepool-data >>>
[INFO] --- tidepool:1.0.0-SNAPSHOT:check-stations (check-stations) @ tidepool-data ---
[ERROR] bad.csv:3 latitude 137.8 is outside [-90.0, 90.0]
[ERROR] bad.csv:4 latitude is not a number: 'north'
[ERROR] bad.csv:5 duplicate station id SF
[WARNING] bad.csv:5 has 0 constituent(s), at least 1 required
[ERROR] BUILD FAILURE
[ERROR] Failed to execute goal io.tidepool:tidepool-maven-plugin:1.0.0-SNAPSHOT:check-stations (check-stations) on project tidepool-data: 4 problem(s) found in station data -> [Help 1]
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException

On the bad file the build stops in the forked execution and the list-stations goal never runs. The forked execution of the same goal also ran in the late execution of pom-phases.xml above, which shows that the check ran twice there: a forked lifecycle repeats work that the main lifecycle did already, so @Execute is meant for goals that are run alone (reports and mvn tidepool:... calls) and not for goals that sit in a normal build. With phase instead of goal, the lifecycle up to that phase is forked instead of a single goal.

The descriptor, the help goal and the prefix

plugin.xml

The descriptor goal wrote target/classes/META-INF/maven/plugin.xml; the JAR contains it, the classes, the generated help Mojo and its data:

$ unzip -l target/tidepool-maven-plugin-1.0.0-SNAPSHOT.jar   (only the names)
META-INF/MANIFEST.MF
META-INF/maven/io.tidepool/tidepool-maven-plugin/plugin-help.xml
META-INF/maven/plugin.xml
io/tidepool/maven/CheckStationsMojo.class
io/tidepool/maven/ListStationsMojo.class
io/tidepool/maven/ReportMode.class
io/tidepool/maven/StationChecker$Severity.class
io/tidepool/maven/StationChecker.class
io/tidepool/maven/StationReportMojo.class
io/tidepool/tidepool_maven_plugin/HelpMojo.class
META-INF/maven/io.tidepool/tidepool-maven-plugin/pom.xml
META-INF/maven/io.tidepool/tidepool-maven-plugin/pom.properties

The header of the descriptor, which is where the goal prefix and the minimum versions are:

<?xml version="1.0" encoding="UTF-8"?>

<!-- Generated by maven-plugin-tools 3.16-->

<plugin>
  <name>Tidepool Maven Plugin</name>
  <description>Validates tidepool station files.</description>
  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-maven-plugin</artifactId>
  <version>1.0.0-SNAPSHOT</version>
  <goalPrefix>tidepool</goalPrefix>
  <isolatedRealm>false</isolatedRealm>
  <inheritedByDefault>true</inheritedByDefault>
  <requiredJavaVersion>21</requiredJavaVersion>
  <requiredMavenVersion>3.9.16</requiredMavenVersion>
  <mojos>
    ...

The java-annotations extractor is the default; two more are included in Plugin Tools and can be selected with the <extractors> parameter of plugin:descriptor: the java-javadoc extractor for the old Javadoc tags and extractors for Ant and BeanShell Mojos (the plugin build above printed a line for each). Only the annotation extractor is used here.

The generated help goal

Every official plugin has a help goal that prints its goals and parameters. The helpmojo goal of the Maven Plugin Plugin generates it: it reads the same descriptor data from plugin-help.xml in the JAR (see the listing above) and generates a source file io/tidepool/tidepool_maven_plugin/HelpMojo.java in target/generated-sources/plugin, compiled into the JAR with the other classes. Without arguments it lists the goals, with -Ddetail it shows the parameters of one goal, and that is what the output of the parameters section came from. The overview:

$ mvn tidepool:help
[INFO] --- tidepool:1.0.0-SNAPSHOT:help (default-cli) @ tidepool-data ---
[INFO] Tidepool Maven Plugin 1.0.0-SNAPSHOT
  Validates tidepool station files.

This plugin has 4 goals:

tidepool:check-stations
  Validates tidepool station files: five columns per row, latitude within [-90,
  90], longitude within [-180, 180], no duplicate ids, enough constituents and
  every required id present.

tidepool:help
  Display help information on tidepool-maven-plugin.
  Call mvn tidepool:help -Ddetail=true -Dgoal=<goal-name> to display parameter
  details.

tidepool:list-stations
  Lists the station ids of a file, after the check-stations goal accepted it.

tidepool:station-report
  Writes a one-page report listing the stations of a file; the page becomes part
  of the project site.


[INFO] BUILD SUCCESS

The Maven Help Plugin can describe any plugin from its coordinates in a similar way (mvn help:describe -Dplugin=...); the help goal inside the plugin is the one that ships with it, and is part of the user experience of a good plugin.

The goal prefix and pluginGroups

mvn tidepool:check-stations works only if Maven can map the prefix tidepool to a plugin. The resolution order is: the plugins declared in the POM (build and reporting, with their descriptors), then, for each group in pluginGroups, the file maven-metadata.xml of that group in the repositories and maven-metadata-local.xml in the local repository. The default groups are org.apache.maven.plugins and org.codehaus.mojo. When the prefix is not derived from the artifactId, or the group is not on the list, the goal must be written with its full coordinates. A project that declares no plugin is the test: without any setting Maven does not know the prefix, and with the group in a settings file it does (a throwaway settings-pg.xml passed with -s, containing only the pluginGroup element):

$ mvn tidepool:check-stations
[ERROR] BUILD FAILURE
[ERROR] No plugin found for prefix 'tidepool' in the current project and in the plugin groups [org.apache.maven.plugins, org.codehaus.mojo] available from the repositories [local (/work/writing-pl [...]
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/NoPluginFoundForPrefixException
$ mvn -s settings-pg.xml tidepool:check-stations
[INFO] Looking for compatible SNAPSHOT version of plugin io.tidepool:tidepool-maven-plugin
[INFO] --- tidepool:1.0.0-SNAPSHOT:check-stations (default-cli) @ bare ---
[INFO] 1 file(s), 3 station(s): OK
[INFO] BUILD SUCCESS

The prefix mapping is stored by install and deploy in the metadata file of the group. This is the one that the local repository received from the two plugins built for this page:

<?xml version="1.0" encoding="UTF-8"?>
<metadata xmlns="http://maven.apache.org/METADATA/1.1.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/METADATA/1.1.0 https://maven.apache.org/xsd/repository-metadata-1.1.0.xsd">
  <plugins>
    <plugin>
      <name>Tidepool Maven Plugin</name>
      <prefix>tidepool</prefix>
      <artifactId>tidepool-maven-plugin</artifactId>
    </plugin>
    <plugin>
      <name>tidepool-maven4-plugin</name>
      <prefix>tidepool4</prefix>
      <artifactId>tidepool-maven4-plugin</artifactId>
    </plugin>
  </plugins>
</metadata>

The prefix is generated from the artifactId by removing the "maven" and "plugin" parts that are surrounded by hyphens. An artifactId that does not follow the convention stops the build, and the fix is the goalPrefix parameter of maven-plugin-plugin (the Maven 4 example below uses it). With tidepool-plugin-x as the artifactId:

$ mvn clean install
[INFO] --- clean:3.5.0:clean (default-clean) @ tidepool-plugin-x ---
[INFO] --- plugin:3.16.0:helpmojo (help-mojo) @ tidepool-plugin-x ---
[ERROR] BUILD FAILURE
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-plugin-plugin:3.16.0:helpmojo (help-mojo) on project tidepool-plugin-x: You need to specify a goalPrefix as it can not be correctly co [...]
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoExecutionException

Prefix mapping is described in depth in the guide; pluginGroups itself is a setting, covered on settings.xml & Password Encryption.

Plugin documentation

maven-plugin-report-plugin 3.16.0 produces a page per goal with its parameters. The plugin goes into <reporting>, and the site build generates the pages. (Calling its report goal directly from the command line failed on this setup with a message about a null sink factory, which is why the pages are generated through mvn site.)

$ mvn -f pom-site.xml site     (in the plugin project)
[INFO] Generating "Plugin Details" report       --- maven-plugin-report-plugin:3.16.0:report
[INFO] BUILD SUCCESS
$ ls target/site | grep mojo
check-stations-mojo.html
help-mojo.html
list-stations-mojo.html
station-report-mojo.html

These pages are the "plugin documentation" that the official guide asks for; they also put the Javadoc of the parameters to use, one more reason to write it as prose.

Thread safety

The -T option runs the modules of a reactor in parallel, and several modules can then be inside the same Mojo class at the same time, each with its own Mojo object (PER_LOOKUP) but possibly sharing static state, files or the plugin class realm. threadSafe = true is the plugin author’s promise that the goal can run like that. check-stations is thread-safe because it keeps no static state and writes only to the project it is running for. A Mojo without the flag makes Maven print a box per module in a parallel build. The same three-module reactor (a parent and two children, each with its own copy of the data) with a build of the plugin that lacks the flag, run with -T2:

$ mvn -T2 validate     (plugin built without threadSafe)
[INFO] Reactor Build Order:
[INFO] tidepool-multi                                                     [pom]
[INFO] tidepool-a                                                         [pom]
[INFO] tidepool-b                                                         [pom]
[INFO] Using the MultiThreadedBuilder implementation with a thread count of 2
[INFO] ---------------------< io.tidepool:tidepool-multi >---------------------
[INFO] Building tidepool-multi 1.0.0-SNAPSHOT                             [1/3]
[INFO] --------------------------------[ pom ]---------------------------------
[WARNING] *****************************************************************
[WARNING] * Your build is requesting parallel execution, but this         *
[WARNING] * project contains the following plugin(s) that have goals not  *
[WARNING] * marked as thread-safe to support parallel execution.          *
[WARNING] * While this /may/ work fine, please look for plugin updates    *
[WARNING] * and/or request plugins be made thread-safe.                   *
[WARNING] * If reporting an issue, report it against the plugin in        *
[WARNING] * question, not against Apache Maven.                           *
[WARNING] *****************************************************************
[WARNING] The following plugins are not marked as thread-safe in tidepool-multi:

The box is printed again for the other two modules (left out here), and the build passes. With the real plugin the warning is gone, and the log shows the two children being built in parallel:

$ mvn -T2 validate
[INFO] Reactor Build Order:
[INFO] tidepool-multi                                                     [pom]
[INFO] tidepool-a                                                         [pom]
[INFO] tidepool-b                                                         [pom]
[INFO] Using the MultiThreadedBuilder implementation with a thread count of 2
[INFO] --- tidepool:1.0.0-SNAPSHOT:check-stations (check) @ tidepool-multi ---
[INFO] 1 file(s), 3 station(s): OK
[INFO] --- tidepool:1.0.0-SNAPSHOT:check-stations (check) @ tidepool-b ---
[INFO] --- tidepool:1.0.0-SNAPSHOT:check-stations (check) @ tidepool-a ---
[INFO] 1 file(s), 3 station(s): OK
[INFO] 1 file(s), 3 station(s): OK
[INFO] Reactor Summary for tidepool-multi 1.0.0-SNAPSHOT:
[INFO] tidepool-multi ..................................... SUCCESS [  0.937 s]
[INFO] tidepool-a ......................................... SUCCESS [  0.002 s]
[INFO] tidepool-b ......................................... SUCCESS [  0.002 s]
[INFO] BUILD SUCCESS

The order of tidepool-a and tidepool-b differs between the two outputs because the two modules run at the same time. The parallel options are covered on Build Performance; the wiki page on parallel builds says that Maven warns about plugins that are not marked thread-safe.

A report goal

A plugin can contribute pages to the project site. A report Mojo is a goal that also implements org.apache.maven.reporting.MavenReport; the easy way is to extend AbstractMavenReport from maven-reporting-impl, the dependency of the POM above. The required methods are getOutputPath() (the page name; getOutputName() is deprecated in favour of it but still abstract, so it delegates), getName(Locale), getDescription(Locale) and executeReport(Locale), which writes the page through the Doxia sink:

tidepool-maven-plugin/src/main/java/io/tidepool/maven/StationReportMojo.java
package io.tidepool.maven;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.List;
import java.util.Locale;

import org.apache.maven.doxia.sink.Sink;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.reporting.AbstractMavenReport;
import org.apache.maven.reporting.MavenReportException;

/**
 * Writes a one-page report listing the stations of a file; the page becomes part of the project site.
 *
 * @since 1.0.0
 */
@Mojo(name = "station-report", defaultPhase = LifecyclePhase.SITE, threadSafe = true)
public class StationReportMojo extends AbstractMavenReport {

    /** The station file to report on. */
    @Parameter(property = "tidepool.file", defaultValue = "${project.basedir}/data/stations.csv")
    private File file;

    @Override
    public String getOutputPath() {
        return "station-report";
    }

    /**
     * @deprecated use {@link #getOutputPath()} instead
     */
    @Override
    @Deprecated
    public String getOutputName() {
        return getOutputPath();
    }

    @Override
    public String getName(Locale locale) {
        return "Tidepool Stations";
    }

    @Override
    public String getDescription(Locale locale) {
        return "The tide stations known to this project.";
    }

    @Override
    protected void executeReport(Locale locale) throws MavenReportException {
        List<String> lines;
        try {
            lines = Files.readAllLines(file.toPath(), StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new MavenReportException("Cannot read " + file, e);
        }
        Sink sink = getSink();
        sink.head();
        sink.title();
        sink.text(getName(locale));
        sink.title_();
        sink.head_();
        sink.body();
        sink.section1();
        sink.sectionTitle1();
        sink.text(getName(locale));
        sink.sectionTitle1_();
        sink.table();
        sink.tableRows(new int[] {Sink.JUSTIFY_LEFT, Sink.JUSTIFY_LEFT, Sink.JUSTIFY_RIGHT, Sink.JUSTIFY_RIGHT}, false);
        sink.tableRow();
        for (String heading : new String[] {"Id", "Name", "Latitude", "Longitude"}) {
            sink.tableHeaderCell();
            sink.text(heading);
            sink.tableHeaderCell_();
        }
        sink.tableRow_();
        for (String line : lines.subList(1, lines.size())) {
            String[] f = line.split(",", -1);
            sink.tableRow();
            for (int i = 0; i < 4; i++) {
                sink.tableCell();
                sink.text(f[i]);
                sink.tableCell_();
            }
            sink.tableRow_();
        }
        sink.tableRows_();
        sink.table_();
        sink.section1_();
        sink.body_();
    }
}

The goal is a normal goal: it can run from the command line, and Maven renders the page with the skin it uses for the site. It writes target/reports/station-report.html when called directly:

$ mvn tidepool:station-report
[INFO] --- tidepool:1.0.0-SNAPSHOT:station-report (default-cli) @ tidepool-data ---
[INFO] Rendering content with org.apache.maven.skins:maven-fluido-skin:jar:2.0.0-M9 skin
[INFO] BUILD SUCCESS

A project lists it under <reporting> to get it into mvn site (the site plugin version is pinned in pluginManagement, as everywhere). The listing of the reporting part, and the lines of the site build that mention the report:

consumer/pom-site.xml (the reporting section)
  <reporting>
    <plugins>
      <plugin>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-maven-plugin</artifactId>
        <version>1.0.0-SNAPSHOT</version>
        <reportSets>
          <reportSet>
            <reports>
              <report>station-report</report>
            </reports>
          </reportSet>
        </reportSets>
      </plugin>
    </plugins>
  </reporting>
$ mvn -f pom-site.xml site     (lines about the plugin; the site also generates the default project reports)
[INFO] Configured 1 report for tidepool-maven-plugin:1.0.0-SNAPSHOT: station-report
[INFO] Generating "Tidepool Stations" report    --- tidepool-maven-plugin:1.0.0-SNAPSHOT:station-report

The table of the generated station-report.html:

<section>
<h1>Tidepool Stations</h1>
<table class="table table-striped">
<tr class="a">
<th>Id</th>
<th>Name</th>
<th>Latitude</th>
<th>Longitude</th></tr>
<tr class="b">
<td style="text-align: left;">SF</td>
<td style="text-align: left;">San Francisco</td>
<td style="text-align: right;">37.8063</td>
<td style="text-align: right;">-122.4659</td></tr>
<tr class="a">
<td style="text-align: left;">BOS</td>
<td style="text-align: left;">Boston</td>
<td style="text-align: right;">42.3539</td>
<td style="text-align: right;">-71.0503</td></tr>
<tr class="b">
<td style="text-align: left;">HNL</td>
<td style="text-align: left;">Honolulu</td>
<td style="text-align: right;">21.3067</td>
<td style="text-align: right;">-157.8645</td></tr></table></section>

Three things came out of running it. First, sink.table() alone produced no <table> element: in the Doxia 2.0.0 of this build the element is opened by tableRows(...), which also takes the alignment of the columns, and closed by tableRows_(); the first version of this class rendered the rows as bare <tr> elements. Second, the descriptor build warns about two things that come from the base class and not from the code above: the Javadoc link #getOutputName() in AbstractMavenReport cannot be resolved while the plugin tools read its source, and its fields (container, siteRenderer, siteTool) are Plexus @Component fields, which is the warning quoted earlier. Third, the base class has not moved to JSR-330 in maven-reporting-impl 4.0.0, which is the reason for the second warning. It still ran on Maven 4.0.0-rc-7, where the same tidepool:station-report call ended with a successful build (the Sisu compatibility layer is still shipped):

$ mvn tidepool:station-report     (Maven 4.0.0-rc-7)
[INFO] --- tidepool:1.0.0-SNAPSHOT:station-report (default-cli) @ tidepool-data ---
[INFO] Rendering content with org.apache.maven.skins:maven-fluido-skin:jar:2.0.0-M9 skin
[INFO] BUILD SUCCESS

Testing a plugin

The official guide to testing plugins separates the unit test, which checks a Mojo as an isolated unit without a real Maven build, from the functional or integration test, which launches Maven on a project and looks at the result. The advice is to write mostly unit tests and some functional tests. The tidepool plugin has three layers.

Unit tests of the logic

The checks are in StationChecker, free of Maven types, so JUnit alone is enough:

tidepool-maven-plugin/src/test/java/io/tidepool/maven/StationCheckerTest.java
package io.tidepool.maven;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

class StationCheckerTest {

    @TempDir
    Path dir;

    private int check(String csv, int minConstituents, List<String> messages) throws IOException {
        Path file = Files.writeString(dir.resolve("stations.csv"), csv);
        StationChecker checker = new StationChecker(minConstituents,
                (severity, text) -> messages.add(severity + " " + text), (where, row) -> { });
        Set<String> seen = new TreeSet<>();
        return checker.check(file.toFile(), seen);
    }

    @Test
    void acceptsAValidFile() throws IOException {
        List<String> messages = new ArrayList<>();
        int problems = check("id,name,latitude,longitude,constituents\n"
                + "SF,San Francisco,37.8,-122.4,M2:0.5:1;S2:0.1:2\n", 2, messages);
        assertEquals(0, problems);
        assertTrue(messages.isEmpty());
    }

    @Test
    void reportsLatitudeOutOfRangeAndDuplicates() throws IOException {
        List<String> messages = new ArrayList<>();
        int problems = check("id,name,latitude,longitude,constituents\n"
                + "SF,San Francisco,137.8,-122.4,M2:0.5:1\n"
                + "SF,Again,37.8,-122.4,M2:0.5:1\n", 1, messages);
        assertEquals(2, problems);
        assertEquals("ERROR stations.csv:2 latitude 137.8 is outside [-90.0, 90.0]", messages.get(0));
        assertEquals("ERROR stations.csv:3 duplicate station id SF", messages.get(1));
    }

    @Test
    void warnsAboutTooFewConstituents() throws IOException {
        List<String> messages = new ArrayList<>();
        int problems = check("id,name,latitude,longitude,constituents\n"
                + "BOS,Boston,42.3,-71.0,M2:1.3:15\n", 2, messages);
        assertEquals(1, problems);
        assertEquals("WARNING stations.csv:2 has 1 constituent(s), at least 2 required", messages.get(0));
    }
}

This layer needs no Maven API at all, runs in milliseconds and is where the many cases go (one per rule). The official page adds the other plain-JUnit technique: Mojos with constructor injection can be created with new and Mockito mocks for the injected objects.

The plugin testing harness

The Maven Plugin Testing Harness runs a Mojo inside a minimal Maven container: it reads the plugin descriptor from the classpath, builds the Mojo with its injected components and sets the parameters. Version 3.4.0 added a JUnit 5 extension in the package org.apache.maven.api.plugin.testing (@MojoTest, @InjectMojo, @MojoParameter, @Basedir), which the page of the project describes as having similar functionality to the Maven 4 test support, to ease migration. The older JUnit 3 and 4 classes (AbstractMojoTestCase and MojoRule) remain in the 3.5.1 JAR for backward compatibility, and the project says that new tests should use JUnit 5. The dependency, in the POM above, is maven-plugin-testing-harness at the test scope. A test:

tidepool-maven-plugin/src/test/java/io/tidepool/maven/CheckStationsMojoTest.java
package io.tidepool.maven;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import org.apache.maven.api.plugin.testing.InjectMojo;
import org.apache.maven.api.plugin.testing.MojoParameter;
import org.apache.maven.api.plugin.testing.MojoTest;
import org.apache.maven.plugin.MojoFailureException;
import org.junit.jupiter.api.Test;

@MojoTest
class CheckStationsMojoTest {

    @Test
    @InjectMojo(goal = "check-stations")
    @MojoParameter(name = "file", value = "src/test/resources/good.csv")
    void acceptsTheGoodFile(CheckStationsMojo mojo) {
        assertDoesNotThrow(mojo::execute);
    }

    @Test
    @InjectMojo(goal = "check-stations")
    @MojoParameter(name = "file", value = "src/test/resources/bad.csv")
    @MojoParameter(name = "minConstituents", value = "2")
    void rejectsTheBadFile(CheckStationsMojo mojo) {
        MojoFailureException e = assertThrows(MojoFailureException.class, mojo::execute);
        assertEquals("5 problem(s) found in station data", e.getMessage());
    }
}

@InjectMojo(goal = "check-stations") looks the goal up in the descriptor that plugin:descriptor wrote to target/classes, which is why the descriptor exists before mvn test (the process-classes phase runs first) and why the test would fail on a plugin whose annotations were wrong. Each @MojoParameter sets one parameter of the Mojo, as if it were in <configuration>; a relative file such as src/test/resources/good.csv is resolved against the working directory of the test. The constructor-injected MavenProject and RuntimeInformation were supplied by the extension without any mock in the test. The surefire part of the build log, on Maven 3.10.0:

[INFO] --- surefire:3.6.0:test (default-test) @ tidepool-maven-plugin ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]  T E S T S
[INFO] Running io.tidepool.maven.CheckStationsMojoTest
SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.
WARNING: Final field logger in class org.apache.maven.plugin.testing.MojoLogWrapper has been mutated reflectively by class org.mockito.internal.util.reflection.ReflectionMemberAccessor in unnamed [...]
WARNING: Use --enable-final-field-mutation=ALL-UNNAMED to avoid a warning
WARNING: Mutating final fields will be blocked in a future release unless final field mutation is enabled
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.883 s -- in io.tidepool.maven.CheckStationsMojoTest
[INFO] Running io.tidepool.maven.StationCheckerTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.018 s -- in io.tidepool.maven.StationCheckerTest
[INFO] Results:
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0

The warning lines come from the dependencies of the harness (Mockito, SLF4J) on JDK 26: Mockito mutates a final field of the harness reflectively, which JDK 26 reports and announces that it will block in a future release. They are harmless for this page and are the kind of noise that a harness test brings. On Maven 4.0.0-rc-7 the same lines are printed with a [WARNING] [stderr] prefix.

The test fails the way any JUnit test does when the Mojo misbehaves; the harness tests the Mojo class and its parameters only. Whether the descriptor, the packaging and the real lifecycle work is a job for the next layer.

Integration tests with the Invoker Plugin

The Maven Invoker Plugin runs real Maven builds on small projects kept under src/it and checks the result with a script. It is what Maven’s own plugins use for their integration tests, and the official testing page points to it for functional tests. The configuration is the one of the POM above: the IT projects are cloned to target/it, the plugin under test is installed into a private repository (target/local-repo, so the user’s repository is not polluted), and the verify script of each project is run after its build.

The three projects of the plugin:

src/it/
  good-data/   pom.xml  invoker.properties  verify.groovy  data/stations.csv
  bad-data/    pom.xml  invoker.properties  verify.groovy  data/stations.csv
  lenient/     pom.xml  invoker.properties  verify.groovy  data/stations.csv

An IT project is a normal POM that uses the plugin under test. The coordinates are written as @project.groupId@, @project.artifactId@ and @project.version@: the Invoker Plugin replaces tokens enclosed in @...@ in the POMs it clones, so the project under test always uses the version that was just built (it needs cloneProjectsTo, which the configuration sets):

src/it/bad-data/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.it</groupId>
  <artifactId>bad-data</artifactId>
  <version>1.0</version>
  <packaging>pom</packaging>

  <build>
    <plugins>
      <plugin>
        <groupId>@project.groupId@</groupId>
        <artifactId>@project.artifactId@</artifactId>
        <version>@project.version@</version>
        <executions>
          <execution>
            <id>check</id>
            <goals>
              <goal>check-stations</goal>
            </goals>
            <configuration>
              <minConstituents>2</minConstituents>
              <required>
                <id>HNL</id>
              </required>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</project>

invoker.properties says what to run and what to expect. bad-data expects the build to fail, which is the point of the test:

src/it/bad-data/invoker.properties
invoker.goals = validate
invoker.buildResult = failure

The post-build script is Groovy (BeanShell works as well). The variable basedir is the cloned project, and the plugin saves the output of the build in build.log there. A failed assert fails the IT:

src/it/bad-data/verify.groovy
def log = new File(basedir, 'build.log').text
assert log.contains('[ERROR] stations.csv:3 latitude 137.8 is outside [-90.0, 90.0]')
assert log.contains('[ERROR] stations.csv:5 duplicate station id SF')
assert log.contains('[ERROR] Required station HNL is missing')
assert log.contains('6 problem(s) found in station data')

The integration test phase of the whole build on Maven 3.10.0 was shown earlier in the build log. These are the lines of the run goal, which pass each project to a fresh Maven, run the script and print the result. The install goal copies the plugin and every artifact of its dependency closure (the long Installing ... list of the build log was left out of the listing):

[INFO] --- invoker:3.10.1:install (integration-test) @ tidepool-maven-plugin ---
[INFO] Installing /work/writing-plugins/tidepool-maven-plugin/target/tidepool-maven-plugin-1.0.0-SNAPSHOT.jar to /work/writing-plugins/tidepool-maven-plugin/target/local-repo/io/tidepool/tidepool- [...]
[INFO] Installing /work/writing-plugins/tidepool-maven-plugin/pom.xml to /work/writing-plugins/tidepool-maven-plugin/target/local-repo/io/tidepool/tidepool-maven-plugin/1.0.0-SNAPSHOT/tidepool-mav [...]
[INFO] --- invoker:3.10.1:run (integration-test) @ tidepool-maven-plugin ---
[INFO] Building: good-data/pom.xml
[INFO] Running post-build script: /work/writing-plugins/tidepool-maven-plugin/target/it/good-data/verify.groovy
[INFO] Finished post-build script: /work/writing-plugins/tidepool-maven-plugin/target/it/good-data/verify.groovy
[INFO]           good-data/pom.xml ................................ SUCCESS (2.466 s)
[INFO] Building: bad-data/pom.xml
[INFO] Running post-build script: /work/writing-plugins/tidepool-maven-plugin/target/it/bad-data/verify.groovy
[INFO] Finished post-build script: /work/writing-plugins/tidepool-maven-plugin/target/it/bad-data/verify.groovy
[INFO]           bad-data/pom.xml ................................. SUCCESS (1.555 s)
[INFO] Building: lenient/pom.xml
[INFO] Running post-build script: /work/writing-plugins/tidepool-maven-plugin/target/it/lenient/verify.groovy
[INFO] Finished post-build script: /work/writing-plugins/tidepool-maven-plugin/target/it/lenient/verify.groovy
[INFO]           lenient/pom.xml .................................. SUCCESS (1.482 s)
[INFO] Build Summary:
[INFO]   Passed: 3, Failed: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

A script that asserts something false produces a failed IT and a failed build. Changing the expected count in the lenient script from 4 to 5 and running a single IT with -Dinvoker.test=lenient (the tests of the other two projects are skipped, and the unit tests with -DskipTests) gives:

$ mvn verify -DskipTests -Dinvoker.test=lenient
[INFO] java-javadoc mojo extractor found 0 mojo descriptor.
[INFO] ant mojo extractor found 0 mojo descriptor.
[INFO] bsh mojo extractor found 0 mojo descriptor.
[WARNING] Mojo station-report uses Plexus Component requirements (@Component annotation) for fields: [container, siteRenderer, siteTool]
[WARNING] Use JSR 330 annotations to inject dependencies instead.
[INFO] --- resources:3.5.0:testResources (default-testResources) @ tidepool-maven-plugin ---
[INFO] Copying 2 resources from src/test/resources to target/test-classes
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ tidepool-maven-plugin ---
[INFO] Nothing to compile - all classes are up to date.
[INFO] --- surefire:3.6.0:test (default-test) @ tidepool-maven-plugin ---
[INFO] Tests are skipped.
[INFO] --- jar:3.5.1:jar (default-jar) @ tidepool-maven-plugin ---
[INFO] --- invoker:3.10.1:install (integration-test) @ tidepool-maven-plugin ---
[INFO] Installing /work/writing-plugins/tidepool-maven-plugin/target/tidepool-maven-plugin-1.0.0-SNAPSHOT.jar to /work/writing-plugins/tidepool-maven-plugin/target/local-repo/io/tidepool/tidepool- [...]
[INFO] Installing /work/writing-plugins/tidepool-maven-plugin/pom.xml to /work/writing-plugins/tidepool-maven-plugin/target/local-repo/io/tidepool/tidepool-maven-plugin/1.0.0-SNAPSHOT/tidepool-mav [...]
[INFO] Installing /work/writing-plugins/m2/org/apache/maven/reporting/maven-reporting-impl/4.0.0/maven-reporting-impl-4.0.0.jar to /work/writing-plugins/tidepool-maven-plugin/target/local-repo/org [...]
[INFO] Installing /work/writing-plugins/m2/org/apache/maven/reporting/maven-reporting-impl/4.0.0/maven-reporting-impl-4.0.0.pom to /work/writing-plugins/tidepool-maven-plugin/target/local-repo/org [...]
[INFO] Installing /work/writing-plugins/m2/org/apache/maven/reporting/maven-reporting-api/4.0.0/maven-reporting-api-4.0.0.jar to /work/writing-plugins/tidepool-maven-plugin/target/local-repo/org/a [...]
[INFO] Installing /work/writing-plugins/m2/org/apache/maven/reporting/maven-reporting-api/4.0.0/maven-reporting-api-4.0.0.pom to /work/writing-plugins/tidepool-maven-plugin/target/local-repo/org/a [...]
[INFO] Installing /work/writing-plugins/m2/org/apache/maven/shared/maven-shared-utils/3.4.2/maven-shared-utils-3.4.2.jar to /work/writing-plugins/tidepool-maven-plugin/target/local-repo/org/apache [...]
[INFO] Installing /work/writing-plugins/m2/org/apache/maven/shared/maven-shared-utils/3.4.2/maven-shared-utils-3.4.2.pom to /work/writing-plugins/tidepool-maven-plugin/target/local-repo/org/apache [...]
[INFO] Installing /work/writing-plugins/m2/org/apache/maven/doxia/doxia-sink-api/2.0.0/doxia-sink-api-2.0.0.jar to /work/writing-plugins/tidepool-maven-plugin/target/local-repo/org/apache/maven/do [...]
[INFO] Installing /work/writing-plugins/m2/org/apache/maven/doxia/doxia-sink-api/2.0.0/doxia-sink-api-2.0.0.pom to /work/writing-plugins/tidepool-maven-plugin/target/local-repo/org/apache/maven/do [...]
[INFO] Installing /work/writing-plugins/m2/org/apache/maven/doxia/doxia-site-model/2.0.0/doxia-site-model-2.0.0.jar to /work/writing-plugins/tidepool-maven-plugin/target/local-repo/org/apache/mave [...]
[INFO] Installing /work/writing-plugins/m2/org/apache/maven/doxia/doxia-site-model/2.0.0/doxia-site-model-2.0.0.pom to /work/writing-plugins/tidepool-maven-plugin/target/local-repo/org/apache/mave [...]
[INFO] Installing /work/writing-plugins/m2/org/apache/maven/doxia/doxia-core/2.0.0/doxia-core-2.0.0.jar to /work/writing-plugins/tidepool-maven-plugin/target/local-repo/org/apache/maven/doxia/doxi [...]
[INFO] Installing /work/writing-plugins/m2/org/apache/maven/doxia/doxia-core/2.0.0/doxia-core-2.0.0.pom to /work/writing-plugins/tidepool-maven-plugin/target/local-repo/org/apache/maven/doxia/doxi [...]

(The Groovy stack trace in the log, between the assertion and the summary, was left out.) The project directory under target/it/lenient keeps build.log, which is the first place to look. -Dinvoker.skip skips the whole run goal, and -Dinvoker.streamLogs or -Dinvoker.streamLogsOnFailures copy the build log of each IT to the console.

The three layers on three Maven releases

The whole build of the plugin (compile, descriptor, the five unit and harness tests and the three integration tests) was run on Maven 3.9.16, 3.10.0 and 4.0.0-rc-7, with the same result on all of them:

Maven Unit and harness tests Invoker ITs Differences in the log

3.9.16

5 run, 0 failures

3 passed

none compared with 3.10.0 (apart from times)

3.10.0

5 run, 0 failures

3 passed

the reference run above

4.0.0-rc-7

5 run, 0 failures

3 passed

error output of the test JVM is prefixed with [WARNING] [stderr]; install also copies the consumer POM of the plugin

The ITs launch the Maven that runs the build, so the Invoker Plugin also checked the installed plugin on each of those releases. A project that must support a range of Maven versions should run the build of its own plugin on the oldest and the newest of the range, which is how the table above was produced. The developers' advice for Maven 4 is to test plugins on Maven 3.9.x first and to pay attention to the warnings.

The page on testing recommends a third tool for functional tests, maven-verifier, a JUnit-based library that launches Maven on a project extracted from the test resources; it is what Maven’s own core integration tests use. It was not used here.

The Maven 4 plugin API (experimental)

Maven 4 (release candidate) comes with a new plugin API: an immutable model, interfaces in org.apache.maven.api.* and a dependency injection framework, Maven DI, with its own annotations. The Maven 4 notes call it experimental in 4.0.0, advise against using it unless one knows precisely what one is doing, and cite the Maven Compiler Plugin 4.x as a user of it. The Mojo specification page says that it does not cover this API. A plugin written against it runs only on Maven 4, so a plugin that must also run on Maven 3 stays on the Maven 3 API, which Maven 4 is expected to run for every Maven 3 plugin that follows the current practices, as tidepool-maven-plugin did in all the runs above. This section writes the same check against the new API, to show how it differs, and runs it. Plugin Tools 4.x is still a pre-release (4.0.0-beta-2 for the plugin and 4.0.0-beta-3 for the tools on Maven Central on 2026-10-16), so versions and details can change.

The POM declares the Maven 4 API instead of the Maven 3 one: maven-api-core for the interfaces and the Mojo annotations, maven-api-di for @Inject, both at provided; the Maven Plugin Plugin 4.0.0-beta-2 generates a descriptor in the new XML format. The artifactId does not follow the -maven-plugin convention here, so the prefix has to be set:

tidepool-maven4-plugin/pom.xml (the part that differs)
  <dependencies>
    <dependency>
      <groupId>org.apache.maven</groupId>
      <artifactId>maven-api-core</artifactId>
      <version>${maven.version}</version>
      <scope>provided</scope>
    </dependency>
    <dependency>
      <groupId>org.apache.maven</groupId>
      <artifactId>maven-api-di</artifactId>
      <version>${maven.version}</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <artifactId>maven-plugin-plugin</artifactId>
        <version>4.0.0-beta-2</version>
        <configuration>
          <goalPrefix>tidepool4</goalPrefix>
        </configuration>
      </plugin>
    </plugins>
    ...
  </build>
tidepool-maven4-plugin/src/main/java/io/tidepool/maven4/CheckStationsMojo.java
package io.tidepool.maven4;

import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;

import org.apache.maven.api.Project;
import org.apache.maven.api.di.Inject;
import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.Mojo;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Parameter;

/**
 * Validates tidepool station files, written against the Maven 4 plugin API.
 *
 * @since 1.0.0
 */
@org.apache.maven.api.plugin.annotations.Mojo(name = "check-stations", defaultPhase = "validate")
public class CheckStationsMojo implements Mojo {

    /** The station file to check. */
    @Parameter(property = "tidepool.file", defaultValue = "${project.basedir}/data/stations.csv")
    Path file;

    /** Station ids that must be present. */
    @Parameter(property = "tidepool.required")
    List<String> required;

    /** Every station needs at least this many constituents. */
    @Parameter(property = "tidepool.minConstituents", defaultValue = "1")
    int minConstituents;

    @Inject
    Log log;

    @Inject
    Project project;

    @Override
    public void execute() throws MojoException {
        log.debug("Project " + project.getArtifactId());
        StationChecker checker = new StationChecker(minConstituents,
                (severity, text) -> {
                    if (severity == StationChecker.Severity.ERROR) {
                        log.error(text);
                    } else {
                        log.warn(text);
                    }
                },
                (where, row) -> log.debug("Row " + where + " -> " + row));
        Set<String> seen = new TreeSet<>();
        int problems;
        try {
            problems = checker.check(file.toFile(), seen);
        } catch (IOException e) {
            throw new MojoException("Cannot read " + file + " (" + e.getClass().getSimpleName() + ")", e);
        }
        if (required != null) {
            for (String id : required) {
                if (!seen.contains(id)) {
                    log.error("Required station " + id + " is missing");
                    problems++;
                }
            }
        }
        if (problems == 0) {
            log.info("1 file(s), " + seen.size() + " station(s): OK");
        } else {
            throw new MojoException(problems + " problem(s) found in station data");
        }
    }
}

The differences from the Maven 3 Mojo are all visible in the listing:

Concern Maven 3 API Maven 4 API

the Mojo contract

extend AbstractMojo (org.apache.maven.plugin)

implement org.apache.maven.api.plugin.Mojo

annotations

org.apache.maven.plugins.annotations.*

org.apache.maven.api.plugin.annotations.*; the phase is a plain string (defaultPhase = "validate")

injection

javax.inject.Inject (Sisu), or the Plexus @Component

org.apache.maven.api.di.Inject (Maven DI)

the log

getLog() of the base class

an injected org.apache.maven.api.plugin.Log

the project

MavenProject

the injected, immutable org.apache.maven.api.Project

paths and failures

java.io.File; MojoFailureException and MojoExecutionException

java.nio.file.Path (as file here); MojoException is the one exception type of the new API

The build and the descriptor on Maven 4.0.0-rc-7 (the plugin tools write a new namespace, http://maven.apache.org/PLUGIN/2.0.0, and a different set of element names such as directInvocationOnly and projectRequired):

$ mvn clean install     (Maven 4.0.0-rc-7)
[INFO] --- clean:3.5.0:clean (default-clean) @ tidepool-maven4-plugin ---
[INFO] Deleting /work/writing-plugins/tidepool-maven4-plugin/target
[INFO] --- resources:3.5.0:resources (default-resources) @ tidepool-maven4-plugin ---
[INFO] skip non existing resourceDirectory /work/writing-plugins/tidepool-maven4-plugin/src/main/resources
[INFO] --- compiler:3.16.0:compile (default-compile) @ tidepool-maven4-plugin ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 2 source files with javac [debug release 21] to target/classes
[INFO] --- plugin:4.0.0-beta-2:descriptor (default-descriptor) @ tidepool-maven4-plugin ---
[INFO] Using 'UTF-8' encoding to read mojo source files.
[INFO] java-annotations mojo extractor found 1 mojo descriptor.
[INFO] --- resources:3.5.0:testResources (default-testResources) @ tidepool-maven4-plugin ---
[INFO] skip non existing resourceDirectory /work/writing-plugins/tidepool-maven4-plugin/src/test/resources
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ tidepool-maven4-plugin ---
[INFO] No sources to compile
[INFO] --- surefire:3.6.0:test (default-test) @ tidepool-maven4-plugin ---
[INFO] No tests to run.
[INFO] --- jar:3.5.1:jar (default-jar) @ tidepool-maven4-plugin ---
[INFO] --- install:3.2.0:install (default-install) @ tidepool-maven4-plugin ---
[INFO] Installing /work/writing-plugins/tidepool-maven4-plugin/target/tidepool-maven4-plugin-1.0.0-SNAPSHOT.jar to /work/writing-plugins/m2/io/tidepool/tidepool-maven4-plugin/1.0.0-SNAPSHOT/tidepo [...]
[INFO] Installing /work/writing-plugins/tidepool-maven4-plugin/pom.xml to /work/writing-plugins/m2/io/tidepool/tidepool-maven4-plugin/1.0.0-SNAPSHOT/tidepool-maven4-plugin-1.0.0-SNAPSHOT-build.pom
[INFO] Installing /work/writing-plugins/tidepool-maven4-plugin/target/consumer-14772637952008824308.pom to /work/writing-plugins/m2/io/tidepool/tidepool-maven4-plugin/1.0.0-SNAPSHOT/tidepool-maven [...]
[INFO] BUILD SUCCESS
<?xml version="1.0" encoding="UTF-8"?>

<!-- Generated by maven-plugin-tools 4.0-->

<plugin xmlns="http://maven.apache.org/PLUGIN/2.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:location="http://maven.apache.org/PLUGIN/2.0.0 https://maven.apache.org/xsd/plugin-2.0.0.xsd">
  <name>tidepool-maven4-plugin</name>
  <description></description>
  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-maven4-plugin</artifactId>
  <version>1.0.0-SNAPSHOT</version>
  <goalPrefix>tidepool4</goalPrefix>
  <isolatedRealm>false</isolatedRealm>
  <inheritedByDefault>true</inheritedByDefault>
  <requiredJavaVersion>21</requiredJavaVersion>
  <requiredMavenVersion>4.0.0-rc-7</requiredMavenVersion>
  <mojos>
    <mojo>
      <goal>check-stations</goal>
      <description>Validates tidepool station files, written against the Maven 4 plugin API.</description>
      <directInvocationOnly>false</directInvocationOnly>
      <projectRequired>true</projectRequired>
      <aggregator>false</aggregator>
      <onlineRequired>false</onlineRequired>
      <inheritedByDefault>true</inheritedByDefault>
      <phase>validate</phase>
      <implementation>io.tidepool.maven4.CheckStationsMojo</implementation>
      <language>java</language>
      <since>1.0.0</since>
      <parameters>
        <!-- the parameters minConstituents and required are left out of this listing -->
        <parameter>
          <name>file</name>
          <type>java.nio.file.Path</type>
          <required>false</required>
          <editable>true</editable>
          <description>The station file to check.</description>
          <expression>${tidepool.file}</expression>
          <defaultValue>${project.basedir}/data/stations.csv</defaultValue>
        </parameter>
      </parameters>
    </mojo>
  </mojos>
</plugin>

Maven 4 installs the POM of the plugin under the name -build.pom and writes a separate consumer POM as .pom (the model differences are on Maven 4 POM Model & Consumer POM). The goal runs from the command line with its full coordinates, the prefix being tidepool4:

$ mvn io.tidepool:tidepool-maven4-plugin:1.0.0-SNAPSHOT:check-stations      (Maven 4.0.0-rc-7)
[INFO] --- tidepool4:1.0.0-SNAPSHOT:check-stations (default-cli) @ tidepool-data ---
[INFO] 1 file(s), 3 station(s): OK
[INFO] BUILD SUCCESS
$ mvn io.tidepool:tidepool-maven4-plugin:1.0.0-SNAPSHOT:check-stations -Dtidepool.file=data/bad.csv -Dtidepool.required=HNL
[INFO] --- tidepool4:1.0.0-SNAPSHOT:check-stations (default-cli) @ tidepool-data ---
[ERROR] bad.csv:3 latitude 137.8 is outside [-90.0, 90.0]
[ERROR] bad.csv:4 latitude is not a number: 'north'
[ERROR] bad.csv:5 duplicate station id SF
[WARNING] bad.csv:5 has 0 constituent(s), at least 1 required
[ERROR] Required station HNL is missing
[ERROR] BUILD FAILURE
[ERROR] Failed to execute goal io.tidepool:tidepool-maven4-plugin:1.0.0-SNAPSHOT:check-stations (default-cli) on project tidepool-data: 5 problem(s) found in station data

Maven 3 cannot run it. The error is the one for a Mojo that the container cannot create; its first lines are shown because the stack trace that precedes it is Sisu’s (it names @Component cannot contain null values), and the result is the same on 3.9.16 and 3.10.0:

$ mvn io.tidepool:tidepool-maven4-plugin:1.0.0-SNAPSHOT:check-stations      (Maven 3.10.0)
[ERROR] BUILD FAILURE
[ERROR] Failed to execute goal io.tidepool:tidepool-maven4-plugin:1.0.0-SNAPSHOT:check-stations (default-cli) on project tidepool-data: Execution default-cli of goal io.tidepool:tidepool-maven4-pl [...]
[ERROR]       role: org.apache.maven.plugin.Mojo
[ERROR]   roleHint: io.tidepool:tidepool-maven4-plugin:1.0.0-SNAPSHOT:check-stations
[ERROR] -> [Help 1]
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/PluginContainerException

The Maven DI page says the same in words: plugins that use Maven DI are only compatible with Maven 4, and plugins that must also run on Maven 3 should use JSR-330. The Maven 3 plugin of this page runs on both, which is what the Maven 4 notes expect of a plugin written to Maven 3 best practices. The step-by-step migration is on Migrating to Maven 4, and what else changed in Maven 4 is on What’s New in Maven 4.

Classic mistakes and their real messages

Every message below was produced by the plugin of this page with one deliberate change. The ones already shown above (a POM value that overrides -D, a wrong enum case, a text that is not a number, a Mojo without a default phase, a missing prefix, a non-thread-safe Mojo in a parallel build and a Maven 4 plugin on Maven 3) are listed in the table at the end of the section.

A project without any Mojo. The build of a plugin whose classes lost their @Mojo annotations fails when the descriptor is extracted:

$ mvn clean install
[INFO] java-annotations mojo extractor found 0 mojo descriptor.
[INFO] java-javadoc mojo extractor found 0 mojo descriptor.
[INFO] ant mojo extractor found 0 mojo descriptor.
[INFO] bsh mojo extractor found 0 mojo descriptor.
[ERROR] BUILD FAILURE
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-plugin-plugin:3.16.0:descriptor (default-descriptor) on project tidepool-maven-plugin: Error extracting plugin descriptor: 'No mojo de [...]

Packaging jar instead of maven-plugin. The JAR installs without complaint, because it is a valid JAR with no descriptor; the consumer fails when it first needs the goal:

$ mvn validate     (consumer, using the jar-packaged build)
[ERROR] BUILD FAILURE
[ERROR] Failed to parse plugin descriptor for io.tidepool:tidepool-maven-plugin:1.0.0-SNAPSHOT (/work/writing-plugins/errs/m2-jar/io/tidepool/tidepool-maven-plugin/1.0.0-SNAPSHOT/tidepool-maven-pl [...]
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/PluginDescriptorParsingException

A required parameter without a value. A new @Parameter(property = "tidepool.owner", required = true) field with no default fails every build that does not set it:

$ mvn validate     (consumer, after adding the field)
[INFO] --- tidepool:1.0.0-SNAPSHOT:check-stations (check) @ tidepool-data ---
[ERROR] BUILD FAILURE
[ERROR] Failed to execute goal io.tidepool:tidepool-maven-plugin:1.0.0-SNAPSHOT:check-stations (check) on project tidepool-data: The parameters 'owner' for goal io.tidepool:tidepool-maven-plugin:1 [...]
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/PluginParameterException

The message names the parameter and the goal, not the property that sets it. A required parameter breaks every existing user of the plugin when it is added in a minor release; give it a default or leave it optional and check in execute().

A plugin that needs a newer Java than the one running Maven. The requiredJavaVersion of the descriptor (21 for the real plugin, from maven.compiler.release) is checked before the goal runs. With the requiredJavaVersion parameter of maven-plugin-plugin set to an absurd 99 in a copy of the plugin, on Maven 3.10.0 (the output on 3.9.16 and on 4.0.0-rc-7 has the same message):

$ mvn validate     (consumer, using the copy with requiredJavaVersion 99)
[ERROR] BUILD FAILURE
[ERROR] Failed to execute goal io.tidepool:tidepool-maven-plugin:1.0.0-SNAPSHOT:check-stations (check) on project tidepool-data:
[ERROR] The plugin io.tidepool:tidepool-maven-plugin:1.0.0-SNAPSHOT has unmet prerequisites:
[ERROR] 	Required Java version 99 is not met by current version: 26
[ERROR] -> [Help 1]
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/PluginIncompatibleException
Mistake What happens

artifactId that does not match <prefix>-maven-plugin and no goalPrefix

The build of the plugin stops with "You need to specify a goalPrefix as it can not be correctly computed".

pluginGroup missing for a prefix

"No plugin found for prefix 'tidepool' in the current project and in the plugin groups".

Goal in a POM execution with neither <phase> nor defaultPhase

Silently never runs.

A value in <configuration> and a -D property for the same parameter

The POM wins; the user property is ignored without a message.

An enum parameter written in the wrong case

"Cannot convert 'full' to Enum: No enum constant …​".

A Mojo without threadSafe = true in a -T build

A box of warnings for every module of the reactor.

A plugin built for a newer Java than the one that runs Maven (requiredJavaVersion)

"The plugin …​ has unmet prerequisites: Required Java version 99 is not met by current version: 26" (here with a deliberately absurd version).

A Maven 4 API Mojo run on Maven 3

"Unable to load the mojo …​ java.util.NoSuchElementException".

Changing the plugin and not reinstalling it

Consumers resolve the plugin from the local repository, so they keep running the old JAR until install (or deploy) runs again.

The last row is the one that costs the most time in practice. It is not an error message: the consumer simply uses the JAR it finds in the local repository. The cure is to run mvn install in the plugin project before running the consumer.

Where older material is outdated

  • Javadoc-tag Mojos. Maven 2 era plugins declared the goal with Javadoc tags (@goal, @phase, @parameter). Plugin Tools still has an extractor for them (it ran on this page’s build and found nothing), but the supported form is the Java annotations of this page, and the official cookbook explains how to upgrade. Maven 4 breaks plugins that still depend on the Maven 2 API.

  • Plexus @Component and plexus-component-annotations. The old way of injecting objects; Maven 4 removed Plexus dependency injection, and the Maven 4 notes ask for JSR-330. Constructor injection also made the Mojo testable without a container.

  • maven-<name>-plugin for your own plugin. The guide now states that the pattern is reserved for the Apache Maven project and that using it infringes the trademark; name plugins <name>-maven-plugin.

  • JUnit 3 AbstractMojoTestCase. Still present in the harness, but the project says that new tests should use the JUnit 5 extension (since 3.4.0).

  • "BUILD ERROR" for MojoExecutionException. The Javadoc of the exception still says so; Maven 3.10.0 prints "BUILD FAILURE" for both exceptions.

  • Books written for Maven 2 and 3.0. They declare Mojos with Javadoc tags. The workflow in this page is the current one: annotations, the generated descriptor, the harness and the Invoker Plugin.

Equivalents in Ant and Gradle

Maven Ant Gradle

A Mojo (a goal) in a plugin JAR with META-INF/maven/plugin.xml

A class that extends Task, found by naming conventions and registered with taskdef or an antlib.

A task class in a binary plugin, registered with tasks.register.

@Parameter fields, <configuration>, -D properties

Setter and nested-element methods; attributes of the XML element; Ant properties.

Task properties with input annotations, set in the build script or by the plugin’s extension.

@Execute and defaultPhase

depends between targets.

Task dependencies and finalizedBy; there is no fixed lifecycle.

MojoFailureException, MojoExecutionException

BuildException.

GradleException, and the @TaskAction method of the task.

threadSafe

Not applicable (single-threaded).

Tasks declare how they can run in parallel; see the Gradle section.

Plugin testing: harness, Invoker Plugin

AntUnit or JUnit with BuildFileRule.

Unit tests with ProjectBuilder and functional tests with GradleRunner (TestKit).

The Ant version of the same check, with its tests, is on Writing Custom Ant Tasks; the Gradle version is built on Binary Plugins and tested on Testing & Publishing Plugins. Releasing the finished plugin follows the same path as any library: Releasing and Publishing to Maven Central. Publishing was not run for this plugin. For the bigger picture of how the three tools extend themselves see Comparing Ant, Maven & Gradle.