Checkpoint Lab — Custom Gradle Plugins, Build Logic, Precompiled Script Plugins, TestKit, and Plugin Development
Build and TestKit-test a convention plugin, apply it in an independent sample build, inject a configuration-time side effect and prove the functional test catches it, then document a compatibility matrix and inspect plugin marker/publication metadata.
Learning objectives
- Build one versioned binary convention plugin with a stable ID and Java 17 plugin bytecode.
- Run TestKit functional tests in disposable projects and prove configuration-cache-safe behavior.
- Apply the plugin in a separate consuming project through an included-build boundary.
- Inject a configuration-time source-tree side effect and use a functional test to expose the regression.
- Create a minimal Gradle/JDK compatibility matrix and distinguish executed from simulated cells.
- Generate local plugin publication/marker metadata without using a real external repository.
Checkpoint contract. A passing
build is insufficient. The evidence set must include
plugin descriptor/ID, TestKit task outcome, consumer output, no
configuration-time source mutation, compatibility notes, publication
metadata, and isolated cleanup.
1. Preflight and exact assumptions
Use a trusted Gradle 9.7.1 Wrapper and JDK 21. Plugin compilation targets Java 17 bytecode. JUnit 6.1.3 is pinned for the functional-test source set. Network access is needed only if JUnit or an optional alternate Gradle distribution is not already cached.
mkdir ch27-checkpoint
cd ch27-checkpoint
# Copy the trusted Gradle 9.7.1 wrapper files into this directory.
chmod +x gradlew
export GRADLE_USER_HOME="$PWD/.checkpoint-gradle-home"
./gradlew --version | tee gradle-version.txt
java -version 2>&1 | tee java-version.txt
2. Predict before building
Write these predictions into predictions.md before
executing Gradle:
| Change | Prediction to verify |
|---|---|
Apply dev.academy.policy |
Consumer gains Java-library tasks and one lazy
policyReport task.
|
| Set extension banner | Only the task input/output content changes; plugin ID/implementation metadata does not. |
| Run the same TestKit case twice with configuration cache |
Second build can reuse configuration state; no
.policy-applied file should appear.
|
Inject file write into apply() |
A functional test that runs help should detect
unexpected source-tree mutation.
|
| Publish to lab file repository | Both implementation module and plugin marker metadata appear; no external repository changes. |
3. Create the plugin build and consumer
Create the same build-logic, plugin source, task,
extension, root settings, and app consumer used in
Lesson 2.
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"))
}
}
}
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
);
}
}
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());
});
}
}
pluginManagement {
includeBuild("build-logic")
}
rootProject.name = "chapter27-workspace"
include("app")
plugins {
id("dev.academy.policy")
}
academyPolicy {
banner.set("sample-reviewed")
}
4. Add the functional test and run the baseline
Add the TestKit test. Its temporary project is the primary proof that the public plugin ID/extension/task work independently of the repository's consumer fixture.
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();
}
}
./gradlew -p build-logic clean test --stacktrace | tee baseline-test.log
./gradlew :app:policyReport --configuration-cache --stacktrace | tee consumer-first.log
./gradlew :app:policyReport --configuration-cache --stacktrace | tee consumer-second.log
cat app/build/reports/academy-policy.txt
Record the task outcome and the output value. Do not infer TestKit
success from a successful :app run; they are separate
evidence paths.
5. Inspect plugin identity and implementation metadata
Build the plugin JAR and inspect the generated descriptor. Then generate publication metadata without contacting a remote repository.
./gradlew -p build-logic jar validatePlugins
jar tf build-logic/build/libs/academy-build-logic-1.0.0.jar \
| grep 'META-INF/gradle-plugins/dev.academy.policy.properties'
./gradlew -p build-logic generatePomFileForPluginMavenPublication
./gradlew -p build-logic tasks --group publishing
cat build-logic/build/publications/pluginMaven/pom-default.xml
The plugin descriptor binds the ID to the implementation class inside the JAR. The publication metadata represents the implementation module; when published, the generated marker publication supplies the ID/version → implementation mapping used by plugin resolution.
6. Produce a compatibility matrix
Create COMPATIBILITY.md and distinguish
executed from
expected/not executed. Do not claim support from a
simulated cell.
| Gradle | Runtime JDK | Plugin bytecode | Status | Evidence |
|---|---:|---:|---|---|
| 9.7.1 | 21 | 17 | EXECUTED | Wrapper + TestKit baseline |
| 9.7.1 | 17 | 17 | NOT EXECUTED if JDK 17 unavailable | Gradle supports JVM 17+, run on CI before declaring |
| 9.6.1 | 21 | 17 | OPTIONAL | TestKit LAB_GRADLE_VERSION=9.6.1 if distribution available |
| next supported release | supported JDK | 17 | PLANNED | upgrade lane before rollout |
For an optional alternate Gradle lane, add this test method and set
LAB_GRADLE_VERSION. TestKit may need to download that
exact Gradle distribution.
@Test
void optionalCompatibilityLane() throws IOException {
String requested = System.getenv("LAB_GRADLE_VERSION");
org.junit.jupiter.api.Assumptions.assumeTrue(
requested != null && !requested.isBlank(),
"Set LAB_GRADLE_VERSION only when that Gradle distribution is available or may be downloaded."
);
Files.writeString(projectDir.resolve("settings.gradle.kts"), "rootProject.name = \"compat\"\n");
Files.writeString(projectDir.resolve("build.gradle.kts"), "plugins { id(\"dev.academy.policy\") }\n");
GradleRunner.create()
.withProjectDir(projectDir.toFile())
.withGradleVersion(requested)
.withArguments("policyReport", "--stacktrace")
.withPluginClasspath()
.build();
}
# Optional only; skip if the distribution is unavailable/offline.
LAB_GRADLE_VERSION=9.6.1 ./gradlew -p build-logic test --tests '*optionalCompatibilityLane' \
| tee optional-compat.log
7. Inject a configuration-time side effect
Temporarily add the broken file-write block to
AcademyPolicyPlugin.apply(). Then add a functional
assertion that applying the plugin and running
help must not create .policy-applied.
public void apply(Project project) {
// Broken: mutates the consuming source tree during configuration, even for `help`.
try {
Files.writeString(
project.getLayout().getProjectDirectory().file(".policy-applied").getAsFile().toPath(),
"configured"
);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Test
void applyingPluginDoesNotMutateProjectDuringConfiguration() throws IOException {
Files.writeString(projectDir.resolve("settings.gradle.kts"), "rootProject.name = \"side-effect\"\n");
Files.writeString(projectDir.resolve("build.gradle.kts"),
"plugins { id(\"dev.academy.policy\") }\n");
GradleRunner.create()
.withProjectDir(projectDir.toFile())
.withArguments("help", "--configuration-cache")
.withPluginClasspath()
.build();
assertFalse(Files.exists(projectDir.resolve(".policy-applied")));
}
set +e
./gradlew -p build-logic test \
--tests '*applyingPluginDoesNotMutateProjectDuringConfiguration' \
> broken-side-effect.log 2>&1
rc=$?
set -e
printf 'broken regression-test rc=%s\n' "$rc"
test "$rc" -ne 0
Preserve broken-side-effect.log. The failure is
valuable evidence: the plugin performed work during configuration
that the public contract never authorized.
8. Repair the plugin and prove the regression test
Remove the configuration-time write. Keep all file creation inside
PolicyReportTask, where the output is declared. Rerun
the focused regression and the full suite.
./gradlew -p build-logic test \
--tests '*applyingPluginDoesNotMutateProjectDuringConfiguration' \
| tee repaired-side-effect.log
./gradlew -p build-logic clean test --stacktrace | tee repaired-full-suite.log
./gradlew :app:policyReport --configuration-cache --stacktrace
test ! -e app/.policy-applied
grep 'policy=sample-reviewed' app/build/reports/academy-policy.txt
9. Publish only to a disposable repository and inspect marker state
The checkpoint's publication is local filesystem state. It exists to prove the plugin's versioned boundary and marker metadata, not to teach Plugin Portal administration.
./gradlew -p build-logic publishAllPublicationsToLabPluginRepoRepository
find build-logic/build/plugin-repo -type f -print | sort | tee publication-files.txt
# Expected marker coordinate path contains the plugin ID twice:
find build-logic/build/plugin-repo -path '*dev.academy.policy.gradle.plugin*' -print
sha256sum build-logic/build/libs/academy-build-logic-1.0.0.jar \
| tee plugin-jar.sha256
Keep the exact plugin coordinate/version and implementation JAR digest in the checkpoint record. A future external release should promote this reviewed artifact through an approved repository workflow rather than rebuilding it under the same version.
10. Write the upgrade strategy
Add a short UPGRADE-POLICY.md:
1. Keep the Wrapper and runtime JDK baseline explicit.
2. Before raising Gradle, run functional tests on current and candidate versions.
3. Treat deprecation warnings as migration work, not noise.
4. Do not rely on org.gradle.api.internal.*.
5. Compare plugin descriptor/publication metadata and task outputs after upgrade.
6. Document minimum Gradle/JDK changes as consumer-visible compatibility changes.
7. Roll back by restoring the previous plugin version/commit; never overwrite a released version.
11. Verification checklist
| Evidence | Pass condition |
|---|---|
| Plugin identity |
ID dev.academy.policy maps to the expected
implementation class and JAR descriptor.
|
| Lazy model |
Plugin uses register/managed properties; no
configuration-time file write.
|
| Functional evidence |
TestKit temp build runs
policyReport successfully and validates output.
|
| Configuration cache |
Baseline TestKit/consumer lane runs with
--configuration-cache without side-effect
regression.
|
| Independent consumer |
:app applies the ID through included-build
pluginManagement and writes expected report.
|
| Compatibility | Only executed cells are labeled supported/evidence-backed; optional cells are clearly marked. |
| Publication | Disposable repo contains implementation + marker metadata; plugin JAR SHA-256 recorded. |
| Security | No real credentials, home-cache deletion, production repository URL, or secret logging. |
12. Cleanup and rollback
Delete only the checkpoint directory after retaining any evidence you intentionally want outside it.
cd ..
rm -rf ch27-checkpoint
Do not delete normal ~/.gradle, Maven Local, or user
credential/configuration files. The checkpoint never needed them.
13. What Chapter 27 adds to the production operating model
You can now treat Gradle build logic itself as a tested software product: stable plugin IDs, typed configuration, lazy tasks, compatibility evidence, repository-owned source, functional tests, and versioned publication metadata. Chapter 28 takes the next trust-boundary step: verifying the dependencies and plugins that build logic consumes through checksums, signatures, repository content filtering, and supply-chain policy.
Knowledge check
Why must the compatibility table distinguish EXECUTED from NOT EXECUTED?
A documented expectation is not test evidence. Support claims should name the Gradle/JDK cells actually exercised.
What exactly should fail in the injected side-effect experiment?
The functional assertion that running help/applying the plugin must not create .policy-applied. The failure proves unauthorized configuration-time mutation.
Why inspect the plugin marker in the disposable repository?
It proves the plugins DSL can map the stable plugin ID/version to the implementation module when the plugin is externally resolved.
Why record the plugin implementation JAR SHA-256?
It gives the release/upgrade process an exact artifact identity and helps prove later promotion/consumption refers to the reviewed bytes.
What is the rollback for a bad published plugin release?
Restore/request the previous immutable plugin version; do not overwrite the bad version with different bytes.
What does Chapter 28 add next?
Verification of dependency/plugin provenance and repository scope using checksums, signatures, verification metadata, and content filtering.
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.