Chapter 27Lesson 04~300 minutes

Custom Gradle Plugins, Build Logic, Precompiled Script Plugins, TestKit, and Plugin Development: Diagnostics, Failure Modes, Security, and Performance

Diagnose plugin-resolution failures, missing TestKit classpaths, eager configuration, internal-API coupling, machine-state leakage, and configuration-cache side effects with preserved evidence and the least destructive repair.

DiagnosticsConfiguration cacheInternal APIResolutionIsolation

Learning objectives

  • Use a repeatable diagnostic sequence for plugin/build-logic failures.
  • Diagnose missing TestKit plugin classpath and plugin ID/version repository errors.
  • Recognize eager task realization and configuration-time side effects.
  • Explain why internal APIs can break on upgrades even when compilation once succeeded.
  • Keep functional tests independent of developer home, secrets, and mutable global caches.
  • Use configuration-cache reports and isolated homes before deleting any normal cache.

1. Preserve evidence before editing the plugin

Plugin failures often appear far from the cause because build logic runs during configuration and can affect every project. Preserve the first error, exact Wrapper/JDK identity, plugin ID/version request, configured plugin repositories, and task/configuration-cache report before changing anything.

export GRADLE_USER_HOME="$PWD/.diag-gradle-home"
./gradlew --version
java -version
./gradlew -p build-logic validatePlugins --stacktrace
./gradlew :app:tasks --all --stacktrace
./gradlew :app:policyReport --configuration-cache --stacktrace

Use the isolated home to distinguish a real model problem from stale machine state. Do not respond to plugin resolution errors by deleting normal ~/.gradle.

2. Broken TestKit example: plugin classpath was never injected

A functional test writes plugins { id("dev.academy.policy") } but omits withPluginClasspath(). The temp build does not know where the plugin-under-test lives, so Gradle reports that the plugin cannot be found. That is not evidence that the implementation class is wrong.

// Broken functional test: plugin-under-test classpath is never injected.
GradleRunner.create()
    .withProjectDir(projectDir.toFile())
    .withArguments("policyReport")
    .build();

// Expected failure shape:
// Plugin [id: 'dev.academy.policy'] was not found ...

// Repair:
GradleRunner.create()
    .withProjectDir(projectDir.toFile())
    .withArguments("policyReport")
    .withPluginClasspath()
    .build();

Repair the test harness, not plugin repositories. The java-gradle-plugin plugin generates plugin-under-test-metadata.properties, and withPluginClasspath() consumes it.

3. Plugin ID/version resolution conflict

An included build and a published plugin are different resolution modes. If a clean consumer requests dev.academy.policy version 2.0.0 but the configured file repository contains only 1.0.0 marker/implementation artifacts, resolution must fail. Do not “fix” that by adding the Plugin Portal or Maven Local unless that repository is part of the intended trust policy.

pluginManagement {
    repositories {
        maven { url = uri("../build-logic/build/plugin-repo") }
    }
}

// Broken if only 1.0.0 is published.
plugins {
    id("dev.academy.policy") version "2.0.0"
}

Inspect the repository tree and marker coordinate first. Then request the actually published version or publish a reviewed new version.

4. Eager task realization is a performance and coupling smell

getByName() realizes a task immediately. A plugin that repeatedly realizes tasks during application expands configuration work even when those tasks are never requested. It can also create ordering hazards when other plugins have not yet contributed their model.

// Avoid forcing task realization during plugin application.
PolicyReportTask task = (PolicyReportTask) project.getTasks().getByName("policyReport");
task.getBanner().set("eager");

// Prefer a provider/lazy configuration path.
project.getTasks().named("policyReport", PolicyReportTask.class).configure(task -> {
    task.getBanner().set("lazy");
});

Use register, named, withType(...).configureEach, and provider/property wiring so model elements remain lazy.

5. Configuration-time side effect creates false state

The following plugin writes .policy-applied as soon as the plugin is applied. Running help now mutates the source tree even though no work task was selected. The side effect can also be replayed inconsistently with configuration cache.

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);
    }
}

Repair by moving file I/O into a task action with a declared @OutputFile, as PolicyReportTask does. A functional test should explicitly run help and assert that no output/source file appears.

6. Internal API break after Gradle upgrade

Internal API usage may compile and work on one Gradle release. A later release may remove or alter the type without the compatibility guarantees given to documented public APIs. When an upgrade fails, compare the import stack and current public API before pinning old Gradle indefinitely.

Evidence to preserve:
- first NoClassDefFoundError / NoSuchMethodError / compilation error
- plugin version + source commit
- old and new Gradle versions
- runtime JDK
- deprecation warnings from the previous supported Gradle line

Repair order:
1. identify org.gradle.api.internal / implementation-specific dependency
2. map the needed behavior to a public API/service
3. add a TestKit regression test
4. run the supported-version matrix
5. only then raise the minimum Gradle version if required

7. Functional test accidentally uses developer-machine state

A TestKit build should not inherit hidden repositories, init scripts, credentials, or source fixtures from the developer's project. Use @TempDir, write every required file, use fake/local repositories, and set test-specific environment only when that input is part of the contract. If a test passes only because a dependency or plugin exists in Maven Local, the evidence is not portable.

8. Secrets and configuration cache

Configuration cache serializes scheduled task state and tracks configuration inputs. Do not read broad environment maps or copy credentials into ordinary extension/task strings during plugin configuration. Use named provider APIs and defer secret access to execution-time code that actually needs it. Never print the value in diagnostics. Treat the project configuration-cache directory and its encryption key as sensitive state.

// Non-secret example: lazy provider wiring for an execution-time input.
Provider<String> mode = project.getProviders()
    .environmentVariable("ACADEMY_POLICY_MODE")
    .orElse("standard");

// Wire the provider to a task Property; do not enumerate System.getenv().

9. Performance diagnosis: separate plugin configuration from task execution

If applying the plugin makes help slow, investigate configuration-time logic, eager task realization, dependency resolution during configuration, and build-logic compilation first. If policyReport is slow only when selected, profile the task action separately. Do not disable tests, input declarations, or configuration-cache validation to manufacture a faster result.

10. Controlled rebuild and verification

After the least destructive fix, rerun validation in a clean TestKit temp build and one isolated Gradle User Home. Preserve both the failure and repaired outputs so the root cause remains reviewable.

./gradlew -p build-logic clean test --stacktrace
./gradlew :app:policyReport --configuration-cache --stacktrace
./gradlew :app:policyReport --configuration-cache --stacktrace
grep 'policy=sample-reviewed' app/build/reports/academy-policy.txt

Knowledge check

A TestKit build says the plugin ID is not found. What should you check first?

Why is getByName() suspicious in plugin apply code?

Why is writing a file inside apply() a correctness problem?

What does a successful build on one Gradle version prove about an internal API?

Should a plugin-resolution failure be repaired by adding Maven Local automatically?

Why should configuration-cache state be treated as sensitive?

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.