Custom Gradle Plugins, Build Logic, Precompiled Script Plugins, TestKit, and Plugin Development: Guided Hands-On Workflow and Core Operations
Build a small binary convention plugin in an isolated build-logic included build, wire a typed extension to a cacheable task, prove it with TestKit, apply it to a separate sample project, and inspect optional local plugin publication metadata.
Learning objectives
- Create a disposable build-logic included build with one binary convention plugin.
- Expose a typed extension and lazily register a cacheable report task.
- Apply the plugin by ID in a separate sample project without publishing it first.
- Write a TestKit functional test that runs a real build and checks task/output state.
- Inspect plugin descriptors, task groups, generated POM/marker metadata, and optional local publication.
- Use an isolated Gradle User Home and clean up only lab state.
Safety boundary. The mandatory workflow never
writes to a real plugin repository or normal ~/.gradle.
The optional publication target is
build-logic/build/plugin-repo. No real credentials are
used.
1. Create the disposable workspace
Start from a trusted copy of the already-verified Gradle 9.7.1
Wrapper. A new empty directory does not magically contain
gradlew; copy the wrapper files from a trusted
course/lab project or initialize them before going offline.
mkdir ch27-plugin-lab
cd ch27-plugin-lab
# Copy trusted gradlew, gradlew.bat and gradle/wrapper/ from the Chapter 15+ fixture.
chmod +x gradlew
export GRADLE_USER_HOME="$PWD/.lab-gradle-home"
./gradlew --version
java -version
mkdir -p build-logic/src/main/java/dev/academy/buildlogic
mkdir -p build-logic/src/test/java/dev/academy/buildlogic
mkdir -p app
2. Define the independent build-logic build
The included build owns plugin compilation, testing, repositories, and publication metadata. It is not a subproject of the main application build.
rootProject.name = "academy-build-logic"
plugins {
`java-gradle-plugin`
`maven-publish`
}
group = "dev.academy.buildlogic"
version = "1.0.0"
repositories {
mavenCentral()
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.withType<JavaCompile>().configureEach {
options.release.set(17)
}
dependencies {
testImplementation(platform("org.junit:junit-bom:6.1.3"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform()
}
gradlePlugin {
plugins {
create("academyPolicy") {
id = "dev.academy.policy"
implementationClass = "dev.academy.buildlogic.AcademyPolicyPlugin"
displayName = "Academy Policy Convention Plugin"
description = "Small reviewed convention plugin used by DevOps Academy Chapter 27"
}
}
}
publishing {
repositories {
maven {
name = "labPluginRepo"
url = uri(layout.buildDirectory.dir("plugin-repo"))
}
}
}
java-gradle-plugin supplies Gradle API/TestKit
conventions and plugin metadata validation. JUnit 6.1.3 is the only
small external test dependency in this lab. If Maven Central is
unavailable and that dependency is not cached, record the
functional-test lane as blocked rather than changing repositories or
disabling tests.
3. Create the typed extension and task
The extension contains only declarative managed properties. The task declares one input and one output so up-to-date/build-cache reasoning remains visible.
package dev.academy.buildlogic;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.Property;
public abstract class AcademyPolicyExtension {
public abstract Property<String> getBanner();
public abstract RegularFileProperty getReportFile();
}
package dev.academy.buildlogic;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import org.gradle.api.DefaultTask;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.Property;
import org.gradle.api.tasks.CacheableTask;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.OutputFile;
import org.gradle.api.tasks.TaskAction;
@CacheableTask
public abstract class PolicyReportTask extends DefaultTask {
@Input
public abstract Property<String> getBanner();
@OutputFile
public abstract RegularFileProperty getOutputFile();
@TaskAction
public void writeReport() throws IOException {
var output = getOutputFile().get().getAsFile().toPath();
Files.createDirectories(output.getParent());
Files.writeString(
output,
"policy=" + getBanner().get() + System.lineSeparator(),
StandardCharsets.UTF_8
);
}
}
4. Implement the binary convention plugin
The plugin applies the core java-library plugin,
creates the extension, supplies conventions, and lazily registers
policyReport. The plugin performs no file I/O during
configuration.
package dev.academy.buildlogic;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
public final class AcademyPolicyPlugin implements Plugin<Project> {
@Override
public void apply(Project project) {
project.getPluginManager().apply("java-library");
AcademyPolicyExtension extension = project.getExtensions().create(
"academyPolicy",
AcademyPolicyExtension.class
);
extension.getBanner().convention("reviewed");
extension.getReportFile().convention(
project.getLayout().getBuildDirectory().file("reports/academy-policy.txt")
);
project.getTasks().register("policyReport", PolicyReportTask.class, task -> {
task.setGroup("verification");
task.setDescription("Writes the reviewed Academy build-policy report.");
task.getBanner().set(extension.getBanner());
task.getOutputFile().set(extension.getReportFile());
});
}
}
5. Make the plugin available through pluginManagement includeBuild
pluginManagement { includeBuild("build-logic") } tells
the main build that plugin requests may be satisfied by that
included build. The consumer needs the plugin ID but no version
because the implementation is supplied directly by the included
build.
pluginManagement {
includeBuild("build-logic")
}
rootProject.name = "chapter27-workspace"
include("app")
plugins {
id("dev.academy.policy")
}
academyPolicy {
banner.set("sample-reviewed")
}
6. Inspect the model before executing the custom task
Confirm that Gradle sees the included build and that the plugin actually contributed the Java/library model and verification task.
./gradlew projects
./gradlew :app:tasks --group verification
./gradlew :app:tasks --group build
./gradlew -p build-logic tasks --all
./gradlew -p build-logic validatePlugins
Expected evidence includes policyReport in the
verification group and normal Java-library tasks in
:app. If the plugin ID cannot resolve, stop and inspect
pluginManagement, the plugin ID, and
gradlePlugin {} before changing repositories.
7. Execute and inspect the extension → task mapping
Run only the custom task. The configured extension value should flow into the declared task input and the output should be project-relative.
./gradlew :app:policyReport --info
cat app/build/reports/academy-policy.txt
./gradlew :app:policyReport --info
The first run should write policy=sample-reviewed. The
repeat can become UP-TO-DATE because the input and
output state are unchanged. The task is cacheable, but a build-cache
hit is a different outcome covered in Chapter 24.
8. Add a TestKit functional test
The functional test writes an entirely separate temporary build,
applies the public plugin ID, runs the task, checks the output, then
runs with configuration cache again. The test never depends on the
repository root's app directory.
package dev.academy.buildlogic;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.gradle.testkit.runner.GradleRunner;
import org.gradle.testkit.runner.TaskOutcome;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class AcademyPolicyPluginFunctionalTest {
@TempDir
Path projectDir;
@Test
void pluginWritesConfiguredReportAndReusesConfigurationCache() throws IOException {
Files.writeString(projectDir.resolve("settings.gradle.kts"),
"rootProject.name = \"test-build\"\n");
Files.writeString(projectDir.resolve("build.gradle.kts"), """
plugins {
id("dev.academy.policy")
}
academyPolicy {
banner.set("functional-reviewed")
}
""");
var first = runner("policyReport", "--configuration-cache").build();
assertEquals(TaskOutcome.SUCCESS, first.task(":policyReport").getOutcome());
assertEquals(
"policy=functional-reviewed" + System.lineSeparator(),
Files.readString(projectDir.resolve("build/reports/academy-policy.txt"))
);
var second = runner("policyReport", "--configuration-cache").build();
assertNotNull(second.task(":policyReport"));
assertFalse(Files.exists(projectDir.resolve(".policy-applied")));
}
private GradleRunner runner(String... arguments) {
return GradleRunner.create()
.withProjectDir(projectDir.toFile())
.withArguments(arguments)
.withPluginClasspath();
}
}
@TempDir prevents developer-machine paths from becoming
part of the contract. withPluginClasspath() is
critical: it lets the temporary build resolve the plugin-under-test
through the normal plugins {} syntax.
9. Run the plugin tests and inspect reports
Execute through the build-logic build, not the application build.
./gradlew -p build-logic clean test --stacktrace
find build-logic/build/test-results/test -type f -maxdepth 1 -print 2>/dev/null || true
find build-logic/build/reports/tests/test -type f -print | head -20
./gradlew -p build-logic jar
jar tf build-logic/build/libs/academy-build-logic-1.0.0.jar \
| grep 'META-INF/gradle-plugins/dev.academy.policy.properties'
On shells where find -maxdepth is unavailable, inspect
the directories directly; the lesson does not depend on that
convenience flag. The important evidence is a passing functional
test and the plugin descriptor inside the JAR.
10. Compare a small precompiled convention plugin
For repository-internal conventions that are mostly Gradle DSL, the
same build-logic build can use the
kotlin-dsl plugin and host precompiled scripts. This
example is explanatory; the binary plugin remains the checkpoint
artifact because it has an explicit implementation class/publication
boundary.
// build-logic/src/main/kotlin/dev.academy.java-conventions.gradle.kts
plugins {
`java-library`
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.withType<JavaCompile>().configureEach {
options.release.set(17)
}
The ID would be dev.academy.java-conventions, derived
from the filename. A precompiled script can create extensions/tasks
too, but when the plugin is intended for publication Gradle's
current guidance favors converting it into a binary plugin.
11. Optional: publish the plugin to a disposable Maven repository
Because maven-publish is applied with
java-gradle-plugin, Gradle configures a main
pluginMaven publication and a marker publication for
dev.academy.policy. Inspect tasks before publishing.
./gradlew -p build-logic tasks --group publishing
./gradlew -p build-logic generatePomFileForPluginMavenPublication
./gradlew -p build-logic publishAllPublicationsToLabPluginRepoRepository
find build-logic/build/plugin-repo -type f -print | sort
cat build-logic/build/publications/pluginMaven/pom-default.xml
The marker module coordinate follows
plugin.id:plugin.id.gradle.plugin:version. The marker
points at the implementation module. This indirection is what lets
the plugins {} DSL resolve a published plugin ID.
pluginManagement {
repositories {
maven {
url = uri("../build-logic/build/plugin-repo")
}
}
}
rootProject.name = "published-consumer"
plugins {
id("dev.academy.policy") version "1.0.0"
}
academyPolicy {
banner.set("published-reviewed")
}
12. Challenge: choose the boundary
Your organization has a Java convention used by one repository today
but likely ten repositories next quarter. Choose one: precompiled
plugin in build-logic, binary plugin in
build-logic, or published binary plugin. Write down
what event would trigger moving to the next boundary. A defensible
answer names ownership/release cadence and compatibility testing,
not only “less duplication.”
13. Verify and clean up only lab state
Before cleanup, verify the main consumer, TestKit report, descriptor, and optional repository tree. Then delete the disposable workspace.
test -f app/build/reports/academy-policy.txt
grep 'policy=sample-reviewed' app/build/reports/academy-policy.txt
test -f build-logic/build/libs/academy-build-logic-1.0.0.jar
cd ..
rm -rf ch27-plugin-lab
Knowledge check
Why does the included-build consumer omit a plugin version?
The plugin implementation is supplied directly by pluginManagement includeBuild, not selected from a versioned external repository.
Why is the task registered with register rather
than create?
register returns a TaskProvider and supports configuration avoidance, so unused tasks need not be realized during configuration.
What should you inspect if TestKit says the plugin ID is not found?
First confirm withPluginClasspath(), plugin ID, generated plugin metadata, and the test build plugins block before changing repositories.
What does the marker module do for a published plugin?
It maps the plugin ID/version requested by the plugins DSL to the actual plugin implementation module.
Why is Maven Central not the authoritative plugin repository in this mandatory lab?
It is only used for the small JUnit test dependency; the plugin itself is supplied by an included build or disposable file repository.
Official references and version notes
- Gradle 9.7.1 release notes — pinned Gradle baseline; released 2026-08-19 and recommended over 9.7.0.
- Introduction to Plugins — plugin sources/types and custom-plugin model.
- Implementation options for plugins — script, precompiled script, and binary plugin tradeoffs.
-
Binary Plugins
—
Plugin<Project>, extensions, managed properties, and lazy task wiring. - Precompiled Script Plugins — plugin IDs, convention defaults, and external-plugin classpaths.
-
Convention Plugins
— reusable project standards and preference over broad
allprojects/subprojectsconfiguration. - Best practices for structuring builds — current guidance favors a dedicated build-logic included build for scalable build logic.
- Gradle Plugin Development Plugin — plugin descriptors, metadata validation, TestKit integration, and plugin-marker publications.
- Testing Plugins — unit/integration/functional testing and GradleRunner examples.
- Gradle TestKit — real build-under-test execution, Gradle version selection, plugin classpath injection.
- Configuration Cache Requirements — task/build-logic restrictions, external inputs, Project-at-execution guidance, and secret handling.
- Preparing to Publish Plugins — plugin IDs, implementation classes, marker modules and publication metadata.
Version-sensitive behavior was rechecked against current Gradle primary documentation on 2026-08-24. Mandatory work remains local/free: a supported JDK, the verified project Wrapper, a disposable workspace, and a small JUnit dependency for TestKit tests. Publishing to the Gradle Plugin Portal, public Maven repositories, paid CI, and external analytics are optional only.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.