Binary 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 Gradle 9.8.1 and was run on JDK 26 on 2026-10-16 (the daemon ran on JDK 26; the plugin is compiled with options.release = 17, because Gradle 9 itself needs JVM 17 or newer, and no toolchain was involved). Every example was run through the wrapper in scratch copies of the tidepool project, once with Kotlin DSL build files and once with Groovy DSL build files. The plugin itself is written once, in Java, and is the same in both copies: only the build files differ, and the console output is the same in both apart from timings, the Where: line of a failure (build.gradle.kts or build.gradle) and the scratch path. The scratch copies pointed their wrapper at a local copy of the same distribution archive; the properties file that a real project commits is

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

The commands use --console=plain. In the listings the blank lines, the "Consider enabling configuration cache" hint and the Try: hints of failures are left out, the tasks of the included plugin build (:tidepool-gradle-plugin:compileJava and the like, all up to date after the first build) are not shown in builds that use the plugin, and the scratch directory is shown as /work/tidepool. Examples run with --configuration-cache say so, and each example states whether it is compatible with the configuration cache. Gradle 8.14.x was not installed here, so statements about it come from the official documentation and are labelled "not run here". Publishing to the Gradle Plugin Portal is not shown at all (not run here); the plugin is published only to a file:// repository below the plugin’s build directory.

A script plugin (a .gradle file applied with apply from) or a precompiled script plugin is the quickest way to share build logic, and a build that only wants to share its own conventions should start there (see Convention Plugins & Sharing Build Logic). A binary plugin is the next step: a class that implements Plugin<Project>, compiled and packaged like any other JVM library, with its own version, its own tests and, if you want, a public id on a plugin repository. That is the shape of nearly every plugin you apply from the plugins {} block, from the core java plugin to third-party ones; the overview of the kinds of plugin is on Plugins Overview. This page writes one: tidepool.station-check, which gives a build a checkStations task that validates the data/stations.csv file the tidepool engine reads, an extension to configure it and a place on the check lifecycle. It is the Gradle version of the check that Writing Maven Plugins builds as the goal tidepool:check-stations and that Writing Custom Ant Tasks builds as the task <checkstations>. The task class has the same contract as the buildSrc task on Writing Custom Gradle Tasks; what this page adds is everything around it: packaging, id, extension, wiring and reactions.

The anatomy of a binary plugin

A plugin is made of three cooperating parts, and the tidepool plugin has all three:

Part Purpose In the tidepool plugin

Plugin class

Implements Plugin<Project> (or Plugin<Settings>, Plugin<Gradle>); apply is the entry point. It registers tasks, creates extensions and wires defaults, and should do no real work.

StationCheckPlugin

Extension

A managed object that exposes the plugin’s configuration as a DSL block of the build script, built from lazy Property, SetProperty, ConfigurableFileCollection and nested objects.

StationCheckExtension, with Limits and a container of DataSet objects

Task type

The work, with annotated inputs and outputs so that Gradle can skip it, cache it and run it in parallel.

CheckStations

The flow from the id in a plugins {} block to the finished task graph is the same for every plugin: Gradle finds the id in the plugin descriptor inside the plugin’s JAR, instantiates the class named there and calls apply once per project. Everything the plugin contributes happens inside that call, or in callbacks that it registers there:

flowchart LR ID["plugins block: id tidepool.station-check"] --> DESC["META-INF/gradle-plugins/tidepool.station-check.properties"] DESC --> CLS["implementation-class: StationCheckPlugin"] CLS --> APPLY["apply of the project"] APPLY --> EXT["extensions.create stationCheck"] APPLY --> TASK["tasks.register checkStations"] APPLY --> DS["dataSets.all registers one task per data set"] APPLY --> REACT["pluginManager.withPlugin lifecycle-base"] EXT -->|"conventions wire lazily"| TASK EXT -->|"conventions wire lazily"| DS REACT --> CHECK["check dependsOn every CheckStations"]

The plugin project

A plugin is built by an ordinary Gradle build. Because the plugin is only used by tidepool, the quickest layout puts it in an included build next to the project that applies it, and the consumer finds it through pluginManagement. That layout and its trade-offs are covered on Composite Builds and Convention Plugins & Sharing Build Logic; here it is simply a directory:

tidepool/                              the project that applies the plugin
  settings.gradle(.kts)                pluginManagement { includeBuild(...) }
  build.gradle(.kts)                   plugins { id "tidepool.station-check" }
  data/stations.csv
  data/stations-broken.csv
  tidepool-gradle-plugin/              the plugin build
    settings.gradle(.kts)
    build.gradle(.kts)
    src/main/java/io/tidepool/gradle/
      StationCheckPlugin.java
      StationCheckExtension.java
      Limits.java
      DataSet.java
      ReportMode.java
      CheckStations.java

The java-gradle-plugin plugin

The build file of the plugin build applies java-gradle-plugin, the Java Gradle Plugin Development plugin. It applies the java-library plugin, adds the Gradle API to the compile classpath (gradleApi() in compileOnlyApi, so the plugin does not ship it), generates the plugin descriptor from the gradlePlugin {} block, configures the plugin marker publication, and adds validations. Its id for a plugin is whatever the id property says; implementationClass names the Plugin<Project> class:

Kotlin DSL: tidepool-gradle-plugin/build.gradle.kts
plugins {
    `java-gradle-plugin`
    `maven-publish`
}

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

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

gradlePlugin {
    plugins {
        create("stationCheck") {
            id = "tidepool.station-check"
            implementationClass = "io.tidepool.gradle.StationCheckPlugin"
            displayName = "Tidepool station check"
            description = "Validates the tidepool station data files."
        }
    }
}

publishing {
    repositories {
        maven {
            name = "local"
            url = uri(layout.buildDirectory.dir("repo"))
        }
    }
}
Groovy DSL: tidepool-gradle-plugin/build.gradle
plugins {
    id 'java-gradle-plugin'
    id 'maven-publish'
}

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

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

gradlePlugin {
    plugins {
        stationCheck {
            id = 'tidepool.station-check'
            implementationClass = 'io.tidepool.gradle.StationCheckPlugin'
            displayName = 'Tidepool station check'
            description = 'Validates the tidepool station data files.'
        }
    }
}

publishing {
    repositories {
        maven {
            name = 'local'
            url = uri(layout.buildDirectory.dir('repo'))
        }
    }
}

maven-publish is only there for the repository example further down; displayName and description are what a plugin repository shows. The plugin build has a one-line settings file, rootProject.name = "tidepool-gradle-plugin" in the Kotlin DSL and rootProject.name = 'tidepool-gradle-plugin' in the Groovy DSL. The plugin is written in Java, which needs no extra compiler plugin and gives one source for both DSLs; Kotlin works the same way with the kotlin-dsl plugin (which also applies java-gradle-plugin) or the Kotlin JVM plugin, and since Gradle 9.0 that plugin compiles with Kotlin language version 2.2.

The plugin adds three tasks to the plugin build:

Task What it does

pluginDescriptors

Generates META-INF/gradle-plugins/<id>.properties from the gradlePlugin {} block.

validatePlugins

Checks the public task and artifact-transform types of the plugin: every property must carry an input or output annotation, or @Internal. Failures fail the build (a real one is shown under "Classic mistakes").

pluginUnderTestMetadata

Writes the classpath manifest that a TestKit GradleRunner uses to load the plugin under test; see Testing & Publishing Plugins.

Building the plugin produces a JAR with the compiled classes and the generated descriptor:

$ ./gradlew -p tidepool-gradle-plugin jar
> Task :compileJava
> Task :pluginDescriptors
> Task :processResources
> Task :classes
> Task :jar
BUILD SUCCESSFUL in 2s
4 actionable tasks: 4 executed
$ unzip -l tidepool-gradle-plugin/build/libs/tidepool-gradle-plugin-1.0.0.jar    (names only)
META-INF/
META-INF/MANIFEST.MF
io/
io/tidepool/
io/tidepool/gradle/
io/tidepool/gradle/CheckStations$Issue.class
io/tidepool/gradle/CheckStations.class
io/tidepool/gradle/DataSet.class
io/tidepool/gradle/Limits.class
io/tidepool/gradle/ReportMode.class
io/tidepool/gradle/StationCheckExtension.class
io/tidepool/gradle/StationCheckPlugin.class
META-INF/gradle-plugins/
META-INF/gradle-plugins/tidepool.station-check.properties
$ unzip -p tidepool-gradle-plugin/build/libs/tidepool-gradle-plugin-1.0.0.jar META-INF/gradle-plugins/tidepool.station-check.properties
implementation-class=io.tidepool.gradle.StationCheckPlugin

The descriptor is a properties file whose name is the plugin id and whose single entry points at the class. That is all the id is: when a build asks for tidepool.station-check, Gradle looks for a descriptor of that name on the plugin classpath. A plugin can ship several descriptors, one per plugins.create entry, and there is no limit on the number of ids in one JAR.

Choosing a plugin id

The id is global, so choose it as you would choose a package name. The user guide lists the rules: only alphanumeric characters, . and -; at least one . separating a namespace from the name; lowercase by convention; no leading, trailing or doubled .; and nothing in the org.gradle, com.gradle or com.gradleware namespaces. Ids for a plugin published for others should start with a reverse domain name or a GitHub-style namespace that you own (com.github.foo.bar, org.baz.bar). tidepool.station-check follows the rules; a plugin published for others would use a namespace that its author owns. The id and the Maven coordinates of the plugin’s JAR are independent (the JAR here is io.tidepool:tidepool-gradle-plugin:1.0.0); the link between them is the marker artifact described below.

The plugin

The extension is the plugin’s public face, so it comes first. It is an abstract class whose getters Gradle implements (managed properties, see Lazy Configuration & Providers), so there are no fields, no constructors and no setters, and every value is a lazy Property, SetProperty or ConfigurableFileCollection that can still be changed after apply has run. Three kinds of nested structure show how a DSL grows beyond flat properties:

  • limits is a nested object. The getter is annotated @Nested, and a method that takes an Action lets the build script write a block.

  • dataSets is a NamedDomainObjectContainer of DataSet objects: the build script names as many groups of station files as it likes.

  • stations, required, failOnError, report and reportDirectory are the flat settings of the default check.

StationCheckExtension.java
package io.tidepool.gradle;

import org.gradle.api.Action;
import org.gradle.api.NamedDomainObjectContainer;
import org.gradle.api.file.ConfigurableFileCollection;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.provider.Property;
import org.gradle.api.provider.SetProperty;
import org.gradle.api.tasks.Nested;

/** The stationCheck { } block. */
public abstract class StationCheckExtension {

    /** The files checked by the checkStations task. */
    public abstract ConfigurableFileCollection getStations();

    @Nested
    public abstract Limits getLimits();

    public void limits(Action<? super Limits> action) {
        action.execute(getLimits());
    }

    public abstract SetProperty<String> getRequired();

    public abstract Property<Boolean> getFailOnError();

    public abstract Property<ReportMode> getReport();

    public abstract DirectoryProperty getReportDirectory();

    /** Extra, named groups of station files: dataSets { register("x") { ... } }. */
    public abstract NamedDomainObjectContainer<DataSet> getDataSets();
}
Limits.java
package io.tidepool.gradle;

import org.gradle.api.provider.Property;
import org.gradle.api.tasks.Input;

public interface Limits {
    @Input
    Property<Integer> getMinConstituents();

    @Input
    Property<Integer> getMaxConstituents();
}
DataSet.java
package io.tidepool.gradle;

import org.gradle.api.Named;
import org.gradle.api.file.ConfigurableFileCollection;
import org.gradle.api.provider.Property;
import org.gradle.api.provider.SetProperty;

/** One named group of station files; the plugin registers a check task for each one. */
public interface DataSet extends Named {
    ConfigurableFileCollection getFiles();

    SetProperty<String> getRequired();

    Property<Boolean> getFailOnError();
}
ReportMode.java
package io.tidepool.gradle;

public enum ReportMode {
    SUMMARY, FULL
}

Limits is annotated with @Input because the same interface is also the @Nested bean of the task: the extension and the task share the type, so the task can copy the values lazily. DataSet implements Named, which is all a type needs to be an element of a container whose elements Gradle creates; the interface is a managed type, so Gradle generates the implementation and the name property. The extension’s container is declared as an abstract getter of the type NamedDomainObjectContainer<DataSet> and Gradle creates that too (the older way, calling objects.domainObjectContainer(...) in apply, still works and is the way the user guide shows it).

The plugin class creates the extension, gives every property a convention (a default that the build script can override and that is used only when nothing has been set), registers the tasks and reacts to the lifecycle-base plugin:

StationCheckPlugin.java
package io.tidepool.gradle;

import org.gradle.api.NamedDomainObjectContainer;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.tasks.TaskContainer;
import org.gradle.api.tasks.TaskProvider;

public class StationCheckPlugin implements Plugin<Project> {

    @Override
    public void apply(Project project) {
        StationCheckExtension ext = project.getExtensions().create("stationCheck", StationCheckExtension.class);

        // conventions: the defaults a build script can override
        ext.getStations().convention(project.getLayout().getProjectDirectory().file("data/stations.csv"));
        ext.getLimits().getMinConstituents().convention(1);
        ext.getLimits().getMaxConstituents().convention(8);
        ext.getFailOnError().convention(true);
        ext.getReport().convention(ReportMode.SUMMARY);
        ext.getReportDirectory().convention(project.getLayout().getBuildDirectory().dir("reports/tidepool"));

        TaskContainer tasks = project.getTasks();
        tasks.register("checkStations", CheckStations.class, task -> {
            task.setGroup("verification");
            task.setDescription("Validates the station files.");
            task.getStations().from(ext.getStations());
            task.getRequired().convention(ext.getRequired());
            task.getFailOnError().convention(ext.getFailOnError());
            task.getReportFile().convention(ext.getReportDirectory().file("checkStations.txt"));
            wireCommon(task, ext);
        });

        // one more task for every data set the build script declares
        NamedDomainObjectContainer<DataSet> dataSets = ext.getDataSets();
        dataSets.all(dataSet -> {
            String name = "check" + capitalize(dataSet.getName()) + "Stations";
            tasks.register(name, CheckStations.class, task -> {
                task.setGroup("verification");
                task.setDescription("Validates the station files of the data set '" + dataSet.getName() + "'.");
                task.getStations().from(dataSet.getFiles());
                task.getRequired().convention(dataSet.getRequired().orElse(ext.getRequired()));
                task.getFailOnError().convention(dataSet.getFailOnError().orElse(ext.getFailOnError()));
                task.getReportFile().convention(ext.getReportDirectory().file(name + ".txt"));
                wireCommon(task, ext);
            });
        });

        // react to another plugin instead of applying it: attach to "check" when the build has one
        project.getPluginManager().withPlugin("lifecycle-base", applied ->
            tasks.named("check", check -> check.dependsOn(tasks.withType(CheckStations.class))));
    }

    private static void wireCommon(CheckStations task, StationCheckExtension ext) {
        task.getLimits().getMinConstituents().convention(ext.getLimits().getMinConstituents());
        task.getLimits().getMaxConstituents().convention(ext.getLimits().getMaxConstituents());
        task.getReport().convention(ext.getReport());
    }

    private static String capitalize(String name) {
        return name.substring(0, 1).toUpperCase() + name.substring(1);
    }
}

Points worth noting:

  • project.getExtensions().create("stationCheck", StationCheckExtension.class) instantiates the class through Gradle’s object factory (which is what implements the abstract getters), registers it under the name stationCheck and adds a block of that name to the build script’s DSL, plus a type-safe accessor to the Kotlin DSL.

  • Every default is a convention, not a set. A build script that writes failOnError = false replaces the convention; a build script that writes nothing gets it. The user guide recommends conventions for exactly this reason.

  • ext.getStations().convention(...) on a ConfigurableFileCollection is an incubating API since Gradle 8.8: the convention applies only while no file has been added to the collection. If data/stations.csv does not exist at all, the task has no source and is skipped (shown below).

  • The task wiring never calls get(). task.getRequired().convention(ext.getRequired()) hands the task a provider of the extension’s value, so a required.add("SF") that appears in the build script after the plugin was applied is still seen. Calling ext.getRequired().get() inside apply would freeze the value as it is at that moment and ignore the build script; it is the classic mistake of the lazy API, and apply is the place where it is easiest to make it.

  • orElse on a data set’s required property means "the data set’s own value if it set one, otherwise the extension’s value", which is exactly what a per-data-set override of a project-wide default means.

  • Data set tasks are registered inside dataSets.all, not configureEach: the container’s elements are only realized when something asks for them, and a task registered after the task graph is built does not exist for the command line. all realizes every element as soon as it is declared. The user guide uses all for the same reason. The mistake and its error message are shown below.

  • withPlugin("lifecycle-base", ...) runs now if the plugin is already applied and later if it is applied after this one. The check lifecycle task belongs to lifecycle-base (the base plugin and the java plugin both apply it), and the callback makes the plugin useful when it is there and harmless when it is not. The alternative, applying base from the plugin, would force a conventions layer on every project that applies it.

  • The tasks.withType(CheckStations.class) collection given to dependsOn is lazy: tasks registered later are included.

The extension values reach the tasks like this:

flowchart LR S1["stationCheck block: required, limits, failOnError, report"] --> E["StationCheckExtension"] S2["dataSets: register broken and lenient"] --> E E -->|"convention provider"| T1["checkStations"] E -->|"orElse provider"| T2["checkBrokenStations and checkLenientStations"] T1 --> R1["reports/tidepool/checkStations.txt"] T2 --> R2["reports/tidepool/checkBrokenStations.txt and checkLenientStations.txt"]

The task class

The task is the check from the Writing Custom Gradle Tasks page, translated to Java so that the plugin has one source for both DSLs. It has the same seven properties (stations, limits, required, failOnError, report, maxMessages and reportFile), the same annotations (@CacheableTask, @InputFiles with @SkipWhenEmpty and @PathSensitive, @Nested, @Optional, @OutputFile, @Internal) and the same command-line options (--require, --fail-on-error, --report), explained annotation by annotation there. Two things are new. The task no longer carries conventions for failOnError, report and the limits, because the plugin sets them from the extension; only maxMessages keeps one. And it reports its findings through the Problems API (see below), which needs an injected Problems service:

CheckStations.java
package io.tidepool.gradle;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.TreeSet;
import javax.inject.Inject;
import org.gradle.api.Action;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.file.ConfigurableFileCollection;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.problems.ProblemGroup;
import org.gradle.api.problems.ProblemId;
import org.gradle.api.problems.ProblemReporter;
import org.gradle.api.problems.Problems;
import org.gradle.api.provider.Property;
import org.gradle.api.provider.SetProperty;
import org.gradle.api.tasks.CacheableTask;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.InputFiles;
import org.gradle.api.tasks.Internal;
import org.gradle.api.tasks.Nested;
import org.gradle.api.tasks.Optional;
import org.gradle.api.tasks.OutputFile;
import org.gradle.api.tasks.PathSensitive;
import org.gradle.api.tasks.PathSensitivity;
import org.gradle.api.tasks.SkipWhenEmpty;
import org.gradle.api.tasks.TaskAction;
import org.gradle.api.tasks.options.Option;

/** The task of the plugin; the same contract as the buildSrc task on the Writing Custom Tasks page. */
@CacheableTask
public abstract class CheckStations extends DefaultTask {

    private static final ProblemGroup GROUP = ProblemGroup.create("io.tidepool", "Tidepool");
    private static final ProblemId BAD_ROW = ProblemId.create("bad-station-row", "Invalid station data", GROUP);
    private static final ProblemId FAILED = ProblemId.create("station-check-failed", "Station data check failed", GROUP);

    private record Issue(String file, String path, int line, String text) {
        @Override
        public String toString() {
            return line > 0 ? file + ":" + line + " " + text : text;
        }
    }

    @InputFiles
    @SkipWhenEmpty
    @PathSensitive(PathSensitivity.RELATIVE)
    public abstract ConfigurableFileCollection getStations();

    @Nested
    public abstract Limits getLimits();

    @Input
    @Optional
    @Option(option = "require", description = "A station id that must be present.")
    public abstract SetProperty<String> getRequired();

    @Input
    @Option(option = "fail-on-error", description = "Fails the build when a problem is found.")
    public abstract Property<Boolean> getFailOnError();

    @Input
    @Option(option = "report", description = "How much the report file lists.")
    public abstract Property<ReportMode> getReport();

    @Internal
    public abstract Property<Integer> getMaxMessages();

    @OutputFile
    public abstract RegularFileProperty getReportFile();

    @Inject
    protected abstract Problems getProblems();

    public CheckStations() {
        getMaxMessages().convention(10);
    }

    public void limits(Action<? super Limits> action) {
        action.execute(getLimits());
    }

    @TaskAction
    public void check() throws IOException {
        int min = getLimits().getMinConstituents().get();
        int max = getLimits().getMaxConstituents().get();
        var seen = new TreeSet<String>();
        var issues = new ArrayList<Issue>();

        List<File> files = new ArrayList<>(getStations().getFiles());
        files.sort(Comparator.comparing(File::getName));
        for (File file : files) {
            List<String> lines = Files.readAllLines(file.toPath());
            for (int i = 1; i < lines.size(); i++) {
                String[] columns = lines.get(i).split(",", -1);
                String name = file.getName();
                String path = file.getAbsolutePath();
                int row = i + 1;
                if (columns.length != 5) {
                    issues.add(new Issue(name, path, row, "expected 5 columns, found " + columns.length));
                    continue;
                }
                if (!seen.add(columns[0])) {
                    issues.add(new Issue(name, path, row, "duplicate station id " + columns[0]));
                }
                range(file, row, "latitude", columns[2], -90.0, 90.0, issues);
                range(file, row, "longitude", columns[3], -180.0, 180.0, issues);
                int n = columns[4].isEmpty() ? 0 : columns[4].split(";").length;
                if (n < min) issues.add(new Issue(name, path, row, "has " + n + " constituent(s), at least " + min + " required"));
                if (n > max) issues.add(new Issue(name, path, row, "has " + n + " constituent(s), at most " + max + " allowed"));
            }
        }
        for (String id : getRequired().get()) {
            if (!seen.contains(id)) issues.add(new Issue("", "", 0, "required station " + id + " is missing"));
        }

        File out = getReportFile().get().getAsFile();
        out.getParentFile().mkdirs();
        StringBuilder text = new StringBuilder();
        text.append("stations=").append(seen.size()).append('\n');
        text.append("problems=").append(issues.size()).append('\n');
        if (getReport().get() == ReportMode.FULL) text.append("ids=").append(String.join(",", seen)).append('\n');
        Files.writeString(out.toPath(), text);

        String summary = issues.size() + " problem(s) found in station data";
        ProblemReporter reporter = getProblems().getReporter();
        if (issues.isEmpty()) {
            getLogger().lifecycle(files.size() + " file(s), " + seen.size() + " station(s): OK");
        } else if (getFailOnError().get()) {
            throw reporter.throwing(new GradleException(summary), FAILED, spec -> spec
                .contextualLabel(summary)
                .details(String.join("\n", issues.stream().limit(getMaxMessages().get()).map(Issue::toString).toList()))
                .solution("Fix the station files, or set failOnError = false to only report the problems"));
        } else {
            issues.stream().limit(getMaxMessages().get()).forEach(issue -> getLogger().warn(issue.toString()));
            for (Issue issue : issues) {
                reporter.report(BAD_ROW, spec -> {
                    spec.contextualLabel(issue.toString());
                    if (issue.line() > 0) spec.lineInFileLocation(issue.path(), issue.line());
                });
            }
            getLogger().warn(summary);
        }
    }

    private static void range(File file, int row, String what, String text, double min, double max, List<Issue> issues) {
        double value;
        try {
            value = Double.parseDouble(text);
        } catch (NumberFormatException e) {
            issues.add(new Issue(file.getName(), file.getAbsolutePath(), row, what + " is not a number: '" + text + "'"));
            return;
        }
        if (value < min || value > max) {
            issues.add(new Issue(file.getName(), file.getAbsolutePath(), row, what + " " + value + " is outside [" + min + ", " + max + "]"));
        }
    }
}

The task class is public API of the plugin. It is also, as the validatePlugins task will confirm, the part where a forgotten annotation costs the most, because a property without an input or output annotation silently falls out of the up-to-date check in a build that does not run the validation.

Applying the plugin

The consumer build includes the plugin build in the plugin resolution (not the dependency resolution) with includeBuild in the pluginManagement {} block of the settings file, and applies the plugin by id. No version is given: a plugin that comes from an included build has none.

Kotlin DSL: settings.gradle.kts
pluginManagement {
    includeBuild("tidepool-gradle-plugin")
}

rootProject.name = "tidepool"
Groovy DSL: settings.gradle
pluginManagement {
    includeBuild 'tidepool-gradle-plugin'
}

rootProject.name = 'tidepool'

The build file applies base (which provides check) and the plugin, and configures the extension: SF must be present, every station needs at least two constituents, and two data sets check the deliberately broken file, one failing and one only reporting. The broken file is

id,name,latitude,longitude,constituents
SF,San Francisco,37.8063,-122.4659,M2:0.55:120;S2:0.13:150
SF,San Francisco again,37.8063,-122.4659,M2:0.55:120
XX,Nowhere,95.0,-122.4,M2:1.0:0
HNL,Honolulu,21.3067,not-a-number,
Kotlin DSL: build.gradle.kts
plugins {
    base
    id("tidepool.station-check")
}

stationCheck {
    required.add("SF")
    limits {
        minConstituents = 2
    }
    dataSets {
        register("broken") {
            files.from("data/stations-broken.csv")
        }
        register("lenient") {
            files.from("data/stations-broken.csv")
            failOnError = false
        }
    }
}
Groovy DSL: build.gradle
plugins {
    id 'base'
    id 'tidepool.station-check'
}

stationCheck {
    required.add('SF')
    limits {
        minConstituents = 2
    }
    dataSets {
        register('broken') {
            files.from('data/stations-broken.csv')
        }
        register('lenient') {
            files.from('data/stations-broken.csv')
            failOnError = false
        }
    }
}

The DSL is the one the extension defines: stationCheck { } is the extension, limits { } is the nested object (through the Action method), dataSets { register("broken") { ... } } is the container, and files, failOnError and required inside a data set are the properties of DataSet. The Kotlin DSL gets the stationCheck accessor for the plugin applied in the plugins {} block, so required.add("SF") and minConstituents = 2 are type-checked. A plugin applied any other way (the legacy apply(plugin = ...)) does not get accessors and must be configured with configure<StationCheckExtension> { }, as explained on Kotlin DSL.

The plugin registered one task for the default check and one for every data set, all in the verification group:

$ ./gradlew tasks --group verification
> Task :tasks
------------------------------------------------------------
Tasks runnable from root project 'tidepool'
------------------------------------------------------------
Verification tasks
------------------
check - Runs all checks.
checkBrokenStations - Validates the station files of the data set 'broken'.
checkLenientStations - Validates the station files of the data set 'lenient'.
checkStations - Validates the station files.
To see all tasks and more detail, run gradlew tasks --all
To see more detail about a task, run gradlew help --task <task>
BUILD SUCCESSFUL in 848ms
5 actionable tasks: 1 executed, 4 up-to-date

Running the default check, then again with nothing changed (the tasks of the plugin build, which are up to date, are not shown):

$ ./gradlew checkStations --configuration-cache
Calculating task graph as no cached configuration is available for tasks: checkStations
> Task :checkStations
1 file(s), 3 station(s): OK
BUILD SUCCESSFUL in 852ms
5 actionable tasks: 1 executed, 4 up-to-date
Configuration cache entry stored.

$ ./gradlew checkStations --configuration-cache
Reusing configuration cache.
> Task :checkStations UP-TO-DATE
BUILD SUCCESSFUL in 843ms
1 actionable task: 1 up-to-date
Configuration cache entry reused.

Both runs used --configuration-cache. The plugin is compatible with the configuration cache: it reads no project state at execution time, the task action uses only the task’s own properties and injected services, and the wiring is done with providers. The Groovy DSL project prints the same lines.

A failing check names the file and the row. The failure comes from throwing of the Problems API, so the console shows the problem’s display name, its label, its details and the solution:

$ ./gradlew checkBrokenStations
> Task :checkBrokenStations FAILED
[Incubating] Problems report is available at: file:///work/tidepool/build/reports/problems/problems-report.html
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':checkBrokenStations' (registered by plugin 'tidepool.station-check').
> Station data check failed
    6 problem(s) found in station data
      stations-broken.csv:3 duplicate station id SF
      stations-broken.csv:3 has 1 constituent(s), at least 2 required
      stations-broken.csv:4 latitude 95.0 is outside [-90.0, 90.0]
      stations-broken.csv:4 has 1 constituent(s), at least 2 required
      stations-broken.csv:5 longitude is not a number: 'not-a-number'
      stations-broken.csv:5 has 0 constituent(s), at least 2 required
      Possible solution: Fix the station files, or set failOnError = false to only report the problems.
BUILD FAILED in 1s
5 actionable tasks: 1 executed, 4 up-to-date

The lenient data set sets failOnError = false: the same findings are logged and reported as problems, and the build succeeds.

$ ./gradlew checkLenientStations --configuration-cache
Calculating task graph as no cached configuration is available for tasks: checkLenientStations
> Task :checkLenientStations
stations-broken.csv:3 duplicate station id SF
stations-broken.csv:3 has 1 constituent(s), at least 2 required
stations-broken.csv:4 latitude 95.0 is outside [-90.0, 90.0]
stations-broken.csv:4 has 1 constituent(s), at least 2 required
stations-broken.csv:5 longitude is not a number: 'not-a-number'
stations-broken.csv:5 has 0 constituent(s), at least 2 required
6 problem(s) found in station data
[Incubating] Problems report is available at: file:///work/tidepool/build/reports/problems/problems-report.html
BUILD SUCCESSFUL in 1s
5 actionable tasks: 1 executed, 4 up-to-date
Configuration cache entry stored.

check runs all three tasks (and, with --continue, runs the others even though one fails):

$ ./gradlew check --continue
> Task :checkBrokenStations FAILED
> Task :checkLenientStations UP-TO-DATE
> Task :checkStations UP-TO-DATE
[Incubating] Problems report is available at: file:///work/tidepool/build/reports/problems/problems-report.html
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':checkBrokenStations' (registered by plugin 'tidepool.station-check').
> Station data check failed
    6 problem(s) found in station data
      stations-broken.csv:3 duplicate station id SF
      stations-broken.csv:3 has 1 constituent(s), at least 2 required
      stations-broken.csv:4 latitude 95.0 is outside [-90.0, 90.0]
      stations-broken.csv:4 has 1 constituent(s), at least 2 required
      stations-broken.csv:5 longitude is not a number: 'not-a-number'
      stations-broken.csv:5 has 0 constituent(s), at least 2 required
      Possible solution: Fix the station files, or set failOnError = false to only report the problems.
BUILD FAILED in 1s
7 actionable tasks: 1 executed, 6 up-to-date

The task has the command-line options declared with @Option and the standard --rerun. help --task lists them:

$ ./gradlew help --task checkStations
> Task :help
Detailed task information for checkStations
Path
     :checkStations (registered by plugin 'tidepool.station-check')
Type
     CheckStations (io.tidepool.gradle.CheckStations)
Options
     --fail-on-error     Fails the build when a problem is found.
     --no-fail-on-error     Disables option --fail-on-error.
     --report     How much the report file lists.
                  Available values are:
                       FULL
                       SUMMARY
     --require     A station id that must be present.
     --rerun     Causes the task to be re-run even if up-to-date.
Description
     Validates the station files.
Group
     verification
BUILD SUCCESSFUL in 2s
5 actionable tasks: 1 executed, 4 up-to-date

The options replace the values from the build script for one run:

$ ./gradlew checkStations --report=full --require=BOS --rerun
> Task :checkStations
1 file(s), 3 station(s): OK
BUILD SUCCESSFUL in 1s
5 actionable tasks: 1 executed, 4 up-to-date
$ cat build/reports/tidepool/checkStations.txt
stations=3
problems=0
ids=BOS,HNL,SF
$ ./gradlew checkStations --require=XYZ
> Task :checkStations FAILED
[Incubating] Problems report is available at: file:///work/tidepool/build/reports/problems/problems-report.html
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':checkStations' (registered by plugin 'tidepool.station-check').
> Station data check failed
    1 problem(s) found in station data
      required station XYZ is missing
      Possible solution: Fix the station files, or set failOnError = false to only report the problems.
BUILD FAILED in 835ms
5 actionable tasks: 1 executed, 4 up-to-date

Reacting to other plugins

The plugin did not apply base: it asked to be told when lifecycle-base is applied. The two ways of building on another plugin differ in what they impose. Applying a plugin (project.getPluginManager().apply(JavaPlugin.class)) gives the consuming project everything that plugin brings, whether it wants it or not; reacting (withPlugin) configures the extra behaviour only if the project already has the plugin. For the same reason the user guide suggests splitting a plugin into a base plugin that provides capabilities (tasks, extensions, no opinions) and a plugin that applies the base plugin and adds conventions; java-base and java are the model.

The java plugin applies lifecycle-base, so with it the plugin adds checkStations to the dependencies of check, next to the compile and test tasks. A dry run shows the graph:

Kotlin DSL: build.gradle.kts with the java plugin
plugins {
    java
    id("tidepool.station-check")
}
Groovy DSL: build.gradle with the java plugin
plugins {
    id 'java'
    id 'tidepool.station-check'
}
$ ./gradlew check --dry-run
:checkStations SKIPPED
:compileJava SKIPPED
:processResources SKIPPED
:classes SKIPPED
:compileTestJava SKIPPED
:processTestResources SKIPPED
:testClasses SKIPPED
:test SKIPPED
:check SKIPPED
BUILD SUCCESSFUL in 558ms
4 actionable tasks: 4 up-to-date

Nothing orders checkStations against the compile tasks, so their relative order in this list is not defined and can differ from run to run. Without any of those plugins, the project has no check lifecycle task at all, and the callback never fires. In that case Gradle’s task-name abbreviation resolves the typed name check to checkStations, the only task whose name starts with it:

$ ./gradlew check
> Task :checkStations
1 file(s), 3 station(s): OK
BUILD SUCCESSFUL in 760ms
5 actionable tasks: 3 executed, 2 up-to-date

which is a reminder that a plugin that adds a task named like a lifecycle task changes what a short command-line name means.

The same pattern applies to task types: tasks.withType(SomeTask.class).configureEach { ... } configures every task of that type that the build creates, now or later. BuildFeatures, injected into a plugin, tells it whether the configuration cache or Isolated Projects is requested or active, for the rare plugin that must disable a feature that is incompatible with them; the user guide treats that as a last resort.

Reporting problems with the Problems API

A task that fails with GradleException("6 problems") tells the user that something is wrong and nothing else. The Problems API lets a plugin attach structure to what it reports: an id and a group, a label, details, a solution, a documentation link and a file location. The same data reaches the console, the generated HTML report, Build Scans and IDEs through the Tooling API. The API is incubating (Problems since Gradle 8.4, getReporter() since 8.11, report and throwing with a ProblemId since 8.13), and its types are in org.gradle.api.problems. CheckStations uses both ways to report:

  • reporter.report(id, spec -> ...) reports a recoverable problem and the build goes on. The lenient data set reports one problem per row, with lineInFileLocation(path, line) so that a client can point at the row.

  • throw reporter.throwing(exception, id, spec -> ...) reports a fatal problem and builds the exception to throw. The display name of the id becomes the first line under "What went wrong", the contextual label and the details follow, and the solution becomes the "Possible solution" line, as in the failing run above.

A ProblemId is created from a name, a display name and a ProblemGroup (ProblemGroup.create("io.tidepool", "Tidepool")); the constants in CheckStations are what the report shows as "Invalid station data" and "Station data check failed". The service is injected with @Inject into the task class (an abstract getter of type Problems), or into the constructor of a plugin class. When any problem has been reported, Gradle writes build/reports/problems/problems-report.html and prints a link to it; with --no-problems-report the report is not written, and with --warning-mode=all the console shows the problems as they are reported. Repeated problems are summarised: after the first few instances of a problem, the rest arrive as a summary at the end of the build.

ProblemSpec.severity(Severity) is deprecated in the Javadoc of Gradle 9.8.1: the severity is now implied by the method used, report for a warning and throwing for an error. The sample in the user guide still calls severity(...), and a first version of this plugin did; the build then recorded a deprecation problem for the call in its own problems report. The plugin on this page does not call it.

The additional data (additionalData(Class, Action)) that a plugin can attach is meant for clients, appears in neither the CLI output nor the HTML report, and is not used here.

Using the plugin from a repository

An included build is for plugins that live with their consumer. To share a plugin, publish it: with maven-publish applied, java-gradle-plugin creates two publications for each build: the plugin’s JAR (pluginMaven) and, for every plugin declared in gradlePlugin {}, a plugin marker publication (stationCheckPluginMarkerMaven). The marker is an empty POM whose coordinates are derived from the id, with the group tidepool.station-check and the artifact tidepool.station-check.gradle.plugin, and whose only dependency is the real JAR. It exists so that Gradle can turn an id from the plugins {} block into coordinates it can resolve. The publishing page is Testing & Publishing Plugins; here the plugin is only published to a file:// repository below the plugin’s build directory, to apply it the way a user would:

$ ./gradlew -p tidepool-gradle-plugin publish
> Task :compileJava UP-TO-DATE
> Task :pluginDescriptors UP-TO-DATE
> Task :processResources UP-TO-DATE
> Task :classes UP-TO-DATE
> Task :jar UP-TO-DATE
> Task :generateMetadataFileForPluginMavenPublication
> Task :generatePomFileForPluginMavenPublication
> Task :publishPluginMavenPublicationToLocalRepository
> Task :generatePomFileForStationCheckPluginMarkerMavenPublication
> Task :publishStationCheckPluginMarkerMavenPublicationToLocalRepository
> Task :publish
BUILD SUCCESSFUL in 915ms
9 actionable tasks: 5 executed, 4 up-to-date
$ cd tidepool-gradle-plugin/build/repo && find . -type f    (checksum files left out)
./io/tidepool/tidepool-gradle-plugin/1.0.0/tidepool-gradle-plugin-1.0.0.jar
./io/tidepool/tidepool-gradle-plugin/1.0.0/tidepool-gradle-plugin-1.0.0.module
./io/tidepool/tidepool-gradle-plugin/1.0.0/tidepool-gradle-plugin-1.0.0.pom
./io/tidepool/tidepool-gradle-plugin/maven-metadata.xml
./tidepool/station-check/tidepool.station-check.gradle.plugin/1.0.0/tidepool.station-check.gradle.plugin-1.0.0.pom
./tidepool/station-check/tidepool.station-check.gradle.plugin/maven-metadata.xml

The consumer is a separate build. Its settings file lists the repository under pluginManagement, and the build file asks for the id with a version:

Kotlin DSL: consumer/settings.gradle.kts
pluginManagement {
    repositories {
        maven {
            url = uri("../tidepool-gradle-plugin/build/repo")
        }
    }
}

rootProject.name = "tidepool-from-repo"
Kotlin DSL: consumer/build.gradle.kts
plugins {
    base
    id("tidepool.station-check") version "1.0.0"
}
Groovy DSL: consumer/settings.gradle
pluginManagement {
    repositories {
        maven {
            url = uri('../tidepool-gradle-plugin/build/repo')
        }
    }
}

rootProject.name = 'tidepool-from-repo'
Groovy DSL: consumer/build.gradle
plugins {
    id 'base'
    id 'tidepool.station-check' version '1.0.0'
}
$ ./gradlew -p consumer check --configuration-cache
Calculating task graph as no cached configuration is available for tasks: check
> Task :checkStations
1 file(s), 3 station(s): OK
> Task :check
BUILD SUCCESSFUL in 768ms
1 actionable task: 1 executed
Configuration cache entry stored.

$ ./gradlew -p consumer check --configuration-cache
Reusing configuration cache.
> Task :checkStations UP-TO-DATE
> Task :check UP-TO-DATE
BUILD SUCCESSFUL in 1s
1 actionable task: 1 up-to-date
Configuration cache entry reused.

Both runs used the configuration cache; the second one reused the entry. Publishing to the Plugin Portal is not run here: it needs the com.gradle.plugin-publish plugin and credentials (the key and secret are properties such as gradle.publish.key=${PLACEHOLDER}, never committed), and is described on the publishing page. The marker publication is also the reason a plugin published to a Maven repository other than the Portal needs that repository listed in pluginManagement { repositories { } }: the plugins {} block does not search the build’s own repositories.

Classic mistakes

Each of these was produced by changing the working plugin as described and running the build.

A nested getter without @Nested. In an extension (or any managed type) an abstract getter whose type is itself a managed interface needs @Nested; without it Gradle cannot decide how to implement the getter and the plugin cannot even be applied. Removing @Nested from getLimits() of the extension gives (the Where: line is the build file that applies the plugin):

$ ./gradlew checkStations
FAILURE: Build failed with an exception.
* Where:
Build file '/work/tidepool/build.gradle.kts' line: 1
* What went wrong:
An exception occurred applying plugin request [id: 'tidepool.station-check']
> Failed to apply plugin 'tidepool.station-check'.
   > Could not create an instance of type io.tidepool.gradle.StationCheckExtension.
      > Could not generate a decorated class for type StationCheckExtension.
         > Cannot have abstract method StationCheckExtension.getLimits(): Limits.
BUILD FAILED in 983ms
4 actionable tasks: 2 executed, 2 up-to-date

A task property without an annotation. The build compiles, the plugin works, and the property is silently not part of the up-to-date check or the build cache key. validatePlugins is the tool that finds it, and java-gradle-plugin makes check of the plugin build depend on it:

$ ./gradlew -p tidepool-gradle-plugin check --dry-run
:compileJava SKIPPED
:pluginDescriptors SKIPPED
:processResources SKIPPED
:classes SKIPPED
:compileTestJava SKIPPED
:pluginUnderTestMetadata SKIPPED
:processTestResources SKIPPED
:testClasses SKIPPED
:test SKIPPED
:validatePlugins SKIPPED
:check SKIPPED
BUILD SUCCESSFUL in 537ms

Removing @Input from getReport() of the task:

$ ./gradlew -p tidepool-gradle-plugin validatePlugins
> Task :compileJava
> Task :pluginDescriptors UP-TO-DATE
> Task :processResources UP-TO-DATE
> Task :classes
> Task :validatePlugins FAILED
[Incubating] Problems report is available at: file:///work/tidepool/tidepool-gradle-plugin/build/reports/problems/problems-report.html
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':validatePlugins' (registered by plugin 'org.gradle.java-gradle-plugin').
> Plugin validation failed with 1 problem
  Missing annotation
    Type 'io.tidepool.gradle.CheckStations' property 'report' is missing an input or output annotation
      Properties must be annotated so that Gradle knows how to handle them during up-to-date checking
      For more information, please refer to https://docs.gradle.org/9.8.1/userguide/validation_problems.html#missing_annotation in the Gradle documentation.
      Possible solutions:
        1. Add an input or output annotation.
        2. Mark it as @Internal.
> For more on how to annotate task properties, please refer to https://docs.gradle.org/9.8.1/userguide/incremental_build.html#sec:task_input_output_annotations in the Gradle documentation.
BUILD FAILED in 1s
4 actionable tasks: 2 executed, 2 up-to-date

A wrong implementationClass. The plugin build compiles, and jar only prints a warning; the failure comes later, in the consumer, when Gradle tries to load the class (the path of the Gradle user home is shortened):

$ ./gradlew checkStations
:jar: A valid plugin descriptor was found for tidepool.station-check.properties but the implementation class io.tidepool.gradle.StationCheckPlugn was not found in the jar.
FAILURE: Build failed with an exception.
* Where:
Build file '/work/tidepool/build.gradle.kts' line: 1
* What went wrong:
An exception occurred applying plugin request [id: 'tidepool.station-check']
> Could not find implementation class 'io.tidepool.gradle.StationCheckPlugn' for plugin 'tidepool.station-check' specified in jar:file:/home/user/.gradle/caches/9.8.1/transforms/3e208f49553a769b6103e5fe7bc247d4/transformed/original/tidepool-gradle-plugin-1.0.0.jar!/META-INF/gradle-plugins/tidepool.station-check.properties.
BUILD FAILED in 1s
4 actionable tasks: 3 executed, 1 up-to-date

A wrong id. The error lists every place that Gradle searched, which makes it the quickest way to see whether the repository in pluginManagement is the one you think it is. Here the id was mistyped as tidepool.station-chek in the consumer of the previous section:

$ ./gradlew -p consumer check
FAILURE: Build failed with an exception.
* Where:
Build file '/work/tidepool/consumer/build.gradle.kts' line: 1
* What went wrong:
Plugin [id: 'tidepool.station-chek', version: '1.0.0'] was not found in any of the following sources:
- Gradle Core Plugins (plugin is not in 'org.gradle' namespace)
- Included Builds (No included builds contain this plugin)
- Plugin Repositories (could not resolve plugin artifact 'tidepool.station-chek:tidepool.station-chek.gradle.plugin:1.0.0')
  Searched in the following repositories:
    maven(file:/work/tidepool/tidepool-gradle-plugin/build/repo/)
BUILD FAILED in 542ms

configureEach where all is needed. Registering the per-data-set task inside dataSets.configureEach compiles and runs without a message, and the tasks simply do not exist when the command line is parsed:

$ ./gradlew checkBrokenStations
[Incubating] Problems report is available at: file:///work/tidepool/build/reports/problems/problems-report.html
FAILURE: Build failed with an exception.
* What went wrong:
Selection failed
  Task 'checkBrokenStations' not found in root project 'tidepool'.
> Run gradlew tasks to get a list of available tasks.
> For more on name expansion, please refer to https://docs.gradle.org/9.8.1/userguide/command_line_interface.html#sec:name_abbreviation in the Gradle documentation.
BUILD FAILED in 773ms
4 actionable tasks: 2 executed, 2 up-to-date

Reading a property in apply. Replacing the provider ext.getRequired() by a value read in apply (Set<String> requiredNow = ext.getRequired().get();, passed to the task’s convention) freezes the set at the moment of apply, before the build script has run. Nothing fails; the setting from the build script is ignored. With required.add("XYZ") in the build script, which makes the real plugin fail (see the --require=XYZ run above), the broken version reports success:

$ ./gradlew checkStations
> Task :checkStations
1 file(s), 3 station(s): OK
BUILD SUCCESSFUL in 1s
5 actionable tasks: 5 executed

The same applies to every other property read in apply. Pass providers, as the plugin does.

Applying instead of reacting. A plugin that calls getPluginManager().apply("java") to get check gives every project that uses it a Java build, including projects that have no Java code.

A task that disappears when its input does. With no data/stations.csv, @SkipWhenEmpty makes the task skip. The first run after the file was removed still executes the task once, to delete the stale report that it had written, and prints no outcome label; the next runs print NO-SOURCE:

$ ./gradlew checkStations
> Task :checkStations
BUILD SUCCESSFUL in 619ms
5 actionable tasks: 1 executed, 4 up-to-date
$ ./gradlew checkStations
> Task :checkStations NO-SOURCE
BUILD SUCCESSFUL in 591ms
4 actionable tasks: 4 up-to-date

Testing and publishing

A plugin deserves tests at three levels: unit tests with ProjectBuilder, functional tests that run a real build with the TestKit GradleRunner against the plugin under test (this is what pluginUnderTestMetadata is for), and integration tests against several Gradle versions. They, and publishing the plugin to the Plugin Portal or to a Maven repository, are the subject of Testing & Publishing Plugins. For several related plugins in one build, the structure of the build that contains them is on Multi-Project Builds and Composite Builds.

What changed from Gradle 8.14 and from the Gradle 1.x book

Nothing on this page was run on Gradle 8.14.6; the notes below come from the official documentation and are labelled "not run here". The book is Gradle in Action, written for Gradle 1.7.

Item Status

Problems API (getReporter(), report, throwing with a ProblemId)

Available from 8.11 (the reporter) and 8.13 (the id-based methods), incubating; so the plugin’s calls exist on 8.14. Not run here.

ProblemSpec.severity(Severity)

Deprecated in the Javadoc of 9.8.1, with the severity implied by report and throwing; still callable.

ConfigurableFileCollection.convention(...)

Incubating, since Gradle 8.8. On 8.7 and earlier the default would have to be applied with from(...) inside a condition.

registered by plugin 'tidepool.station-check' in task errors

Task provenance in failure messages is a 9.5 feature, so 8.14 prints the failure without that part of the line. Not run here.

Project.getConvention(), the Convention type and project.convention.plugins.x = ...

The Gradle 1.x way of exposing plugin configuration. Deprecated in 8.2 and removed in 9.0; use extensions (extensions.create), which have been available since the Gradle 1.x line.

apply plugin: 'name' and classes declared in the build script

Still work (the legacy apply and a plugin class in a build script), but a plugin that others use is a project of its own, applied from the plugins {} block.

Descriptor META-INF/gradle-plugins/name.properties written by hand

Generated by java-gradle-plugin from the gradlePlugin {} block.

Plugin tests with ProjectBuilder only

TestKit GradleRunner for functional tests; see the testing page.

Plugins written in Kotlin

Since 9.0 compiled with Kotlin language version 2.2 (it was 1.8 in 8.x, with an embedded Kotlin 2.0 since 8.11); not run here on 8.14.

The same idea in the other two tools

Concept Gradle Maven Ant

Unit of reusable build logic with a public id

A plugin (Plugin<Project> class) with a descriptor and, on a repository, a marker artifact.

A plugin JAR with META-INF/maven/plugin.xml and goals (Mojos).

An antlib or a taskdef of task classes.

Configuration surface

An extension (a DSL block of managed properties) and task properties.

The <configuration> element of the plugin or execution, mapped to @Parameter fields.

Attributes and nested elements of the tasks.

Hooking into the lifecycle

Register tasks and wire dependsOn; withPlugin to attach to check or any other task.

Bind a goal to a phase (defaultPhase, <phase>).

depends and extension points in the target graph.

Reacting to what else is applied

pluginManager.withPlugin(id).

Not available: a plugin runs what the POM binds.

Not available.

Writing it

This page, built on java-gradle-plugin.

Writing Maven Plugins

Writing Custom Ant Tasks