Chapter 27Lesson 05~365 minutes

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.

CheckpointFunctional testsCompatibility matrixPublicationUpgrade policy

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?

What exactly should fail in the injected side-effect experiment?

Why inspect the plugin marker in the disposable repository?

Why record the plugin implementation JAR SHA-256?

What is the rollback for a bad published plugin release?

What does Chapter 28 add next?

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.