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.
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?
Whether the test uses withPluginClasspath(), the plugin ID matches gradlePlugin metadata, and the test build applies the plugin through the plugins DSL.
Why is getByName() suspicious in plugin apply
code?
It realizes the task immediately; named/register/configureEach preserve lazy configuration and reduce unnecessary work/coupling.
Why is writing a file inside apply() a correctness
problem?
Configuration should build the model, not perform task work. It mutates state even for unrelated commands and can conflict with configuration-cache semantics.
What does a successful build on one Gradle version prove about an internal API?
Almost nothing about forward compatibility; internal APIs are not the supported public compatibility surface.
Should a plugin-resolution failure be repaired by adding Maven Local automatically?
No. That can hide repository/marker/version defects with mutable machine-local state and broadens the trust boundary.
Why should configuration-cache state be treated as sensitive?
Scheduled task state and configuration inputs can include credentials/secrets; Gradle encrypts it, but access to both cache and key can expose content.
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.