Chapter 27Lesson 02~335 minutes

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.

build-logicTyped extensionTaskProviderGradleRunnerPlugin marker

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?

Why is the task registered with register rather than create?

What should you inspect if TestKit says the plugin ID is not found?

What does the marker module do for a published plugin?

Why is Maven Central not the authoritative plugin repository in this mandatory lab?

Official references and version notes

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.