diff --git a/README.md b/README.md
index 8ec40f39..4f0fa5b9 100644
--- a/README.md
+++ b/README.md
@@ -123,3 +123,25 @@ micronautBuild {
```
You can use [the same DSL as in Gradle](https://docs.gradle.org/current/dsl/org.gradle.api.artifacts.ResolutionStrategy.html).
+
+## Gradle Problems API diagnostics
+
+On the Gradle 9 line, Micronaut Build reports selected internal validation failures through Gradle's Problems API. No extra configuration is required. The build still fails at the same point and keeps the existing failure message, but Gradle also records a structured problem in the generated report at `build/reports/problems/problems-report.html`. Gradle prints a link to that report unless the build runs with `--no-problems-report`.
+
+The stable Micronaut Build problem group is `micronaut-build > validation`. Covered problem IDs include:
+
+* `enforced-platform-not-supported`
+* `micronaut-version-mismatch`
+* `unsupported-test-framework`
+* `invalid-pom-coordinates`
+* `pom-verification-failed`
+* `asciidoc-output-validation-failed`
+* `maven-central-deployment-failed`
+
+For example, if a dependency upgrades `io.micronaut:micronaut-core` away from the Micronaut version declared by the build, the failure remains actionable and includes the existing diagnostic command:
+
+```shell
+./gradlew --dependencyInsight --configuration compileClasspath --dependency io.micronaut:micronaut-core
+```
+
+Problem details are intentionally bounded to validation context such as versions, configurations, and report paths. Credentials, signing data, Maven Central bearer tokens, raw environment dumps, and unbounded HTTP responses must not be added to problem details or reports.
diff --git a/micronaut-gradle-plugins/src/functionalTest/groovy/io/micronaut/build/ProblemsApiFunctionalTest.groovy b/micronaut-gradle-plugins/src/functionalTest/groovy/io/micronaut/build/ProblemsApiFunctionalTest.groovy
new file mode 100644
index 00000000..b762e732
--- /dev/null
+++ b/micronaut-gradle-plugins/src/functionalTest/groovy/io/micronaut/build/ProblemsApiFunctionalTest.groovy
@@ -0,0 +1,65 @@
+package io.micronaut.build
+
+import java.nio.file.Files
+
+class ProblemsApiFunctionalTest extends AbstractFunctionalTest {
+
+ void "reports Micronaut version mismatch as a Gradle problem"() {
+ given:
+ withSample("test-micronaut-module")
+
+ file("subproject1/build.gradle") << """
+ dependencies {
+ implementation("io.micronaut:micronaut-core:4.8.4")
+ }
+ """
+
+ when:
+ fails 'compileJava'
+
+ then:
+ errorOutputContains "Micronaut version mismatch: project declares 4.6.3 but resolved version is 4.8.4. You probably have a dependency which triggered an upgrade of micronaut-core. In order to determine where it comes from, you can run ./gradlew --dependencyInsight --configuration compileClasspath --dependency io.micronaut:micronaut-core"
+ problemsReportContains("micronaut-version-mismatch")
+ problemsReportContains("Micronaut Build validation")
+ }
+
+ void "reports generated Asciidoc output validation failure as a Gradle problem"() {
+ given:
+ settingsFile << """
+ pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ mavenCentral()
+ }
+ }
+
+ rootProject.name = "asciidoc-validation-problems"
+ """
+ buildFile << """
+ plugins {
+ id "io.micronaut.build.internal.docs"
+ }
+
+ tasks.register("validateBrokenDocs", io.micronaut.build.docs.ValidateAsciidocOutputTask) {
+ inputDirectory = layout.projectDirectory.dir("broken-docs")
+ report = layout.buildDirectory.file("reports/broken-docs.txt")
+ failOnError = true
+ }
+ """
+ file("broken-docs/index.html").text = "
Unresolved directive in index.adoc - include::missing.adoc[]"
+
+ when:
+ fails 'validateBrokenDocs'
+
+ then:
+ errorOutputContains "Validation of generated asciidoctor files failed. See the report at"
+ problemsReportContains("asciidoc-output-validation-failed")
+ problemsReportContains("Micronaut Build validation")
+ }
+
+ private void problemsReportContains(String text) {
+ def report = testDirectory.resolve("build/reports/problems/problems-report.html")
+ assert Files.exists(report): "Expected Gradle Problems report at $report"
+ assert report.text.contains(text): "Expected Gradle Problems report to contain '$text'"
+ }
+}
diff --git a/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MavenCentralPublishTask.java b/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MavenCentralPublishTask.java
index 1fc56e21..53249b44 100644
--- a/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MavenCentralPublishTask.java
+++ b/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MavenCentralPublishTask.java
@@ -1,9 +1,11 @@
package io.micronaut.build;
+import io.micronaut.build.problems.MicronautBuildProblems;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.Property;
+import org.gradle.api.problems.Problems;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.InputFile;
import org.gradle.api.tasks.Optional;
@@ -13,6 +15,7 @@
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
+import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@@ -22,8 +25,12 @@
import java.time.Duration;
import java.util.Base64;
import java.util.UUID;
+import java.util.regex.Pattern;
+
+import javax.inject.Inject;
public abstract class MavenCentralPublishTask extends DefaultTask {
+ private static final Pattern DEPLOYMENT_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._:-]{0,255}");
public enum PublishingType {
AUTOMATIC,
@@ -44,6 +51,9 @@ public enum PublishingType {
@Option(option = "publishing-type", description = "Configures the Maven Central publishing type.")
public abstract Property getPublishingType();
+ @Inject
+ public abstract Problems getProblems();
+
public MavenCentralPublishTask() {
super();
setDescription("Publishes a bundle using Maven Central's Publisher API");
@@ -90,23 +100,24 @@ public void uploadBundle() throws URISyntaxException, IOException, InterruptedEx
.build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
+ var sanitizedBody = MicronautBuildProblems.sanitizeDiagnosticText(response.body());
- getLogger().lifecycle("Upload response: {} {}", response.statusCode(), response.body());
+ getLogger().lifecycle("Upload response: {} {}", response.statusCode(), sanitizedBody);
if (response.statusCode() >= 200 && response.statusCode() < 300) {
- var deploymentId = response.body();
+ var deploymentId = extractDeploymentId(response.body());
if (deploymentId != null && !deploymentId.isEmpty()) {
verifyDeploymentStatus(client, deploymentId);
} else {
- throw new GradleException("Could not extract deploymentId from response: " + response.body());
+ throw deploymentFailure("Could not extract deploymentId from response: " + sanitizedBody, "Maven Central returned a successful upload response without a valid deployment id.");
}
} else {
- throw new GradleException("Unexpected status code: " + response.statusCode() + " (" + response.body() + ")");
+ throw deploymentFailure("Unexpected status code: " + response.statusCode() + " (" + sanitizedBody + ")", "Maven Central returned HTTP " + response.statusCode() + " while uploading the publication bundle.");
}
}
private void verifyDeploymentStatus(HttpClient client, String deploymentId) throws IOException, InterruptedException {
- var statusUrl = "https://central.sonatype.com/api/v1/publisher/status?id=" + deploymentId;
+ var statusUrl = buildStatusUrl(deploymentId);
getLogger().lifecycle("Checking deployment status for {}", deploymentId);
int maxLookups = 100;
while (--maxLookups >= 0) {
@@ -117,8 +128,9 @@ private void verifyDeploymentStatus(HttpClient client, String deploymentId) thro
.build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
+ var sanitizedBody = MicronautBuildProblems.sanitizeDiagnosticText(response.body());
- getLogger().lifecycle("Status check: {} {}", response.statusCode(), response.body());
+ getLogger().lifecycle("Status check: {} {}", response.statusCode(), sanitizedBody);
var body = response.body();
if (response.statusCode() == 200) {
@@ -127,14 +139,39 @@ private void verifyDeploymentStatus(HttpClient client, String deploymentId) thro
return;
}
if (body.contains("\"deploymentState\":\"FAILED\"")) {
- throw new GradleException("Deployment " + deploymentId + " failed: " + body);
+ throw deploymentFailure("Deployment " + deploymentId + " failed: " + sanitizedBody, "Maven Central reported a failed deployment state for deployment " + deploymentId + ".");
}
- } else if (response.statusCode() < 200 || response.statusCode() > 300) {
- getLogger().warn("Status check for deployment " + deploymentId + " failed with: " + body + ". This doesn't necessarily mean that deployment failed, please check status on https://central.sonatype.com/publishing");
+ } else if (response.statusCode() < 200 || response.statusCode() >= 300) {
+ getLogger().warn("Status check for deployment " + deploymentId + " failed with: " + sanitizedBody + ". This doesn't necessarily mean that deployment failed, please check status on https://central.sonatype.com/publishing");
break;
}
Thread.sleep(30_000);
}
}
+
+ private RuntimeException deploymentFailure(String message, String details) {
+ return MicronautBuildProblems.throwing(getProblems(), new GradleException(message), MicronautBuildProblems.MAVEN_CENTRAL_DEPLOYMENT_FAILED, spec -> spec
+ .contextualLabel("Maven Central deployment failed")
+ .details(details)
+ .solution("Check the sanitized Maven Central response and verify the publication bundle and deployment status at https://central.sonatype.com/publishing."));
+ }
+
+ static String extractDeploymentId(String responseBody) {
+ if (responseBody == null) {
+ return null;
+ }
+ var deploymentId = responseBody.trim();
+ if (!MicronautBuildProblems.sanitizeDiagnosticText(deploymentId).equals(deploymentId)) {
+ return null;
+ }
+ if (DEPLOYMENT_ID.matcher(deploymentId).matches()) {
+ return deploymentId;
+ }
+ return null;
+ }
+
+ static String buildStatusUrl(String deploymentId) {
+ return "https://central.sonatype.com/api/v1/publisher/status?id=" + URLEncoder.encode(deploymentId, StandardCharsets.UTF_8);
+ }
}
diff --git a/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MicronautBuildCommonPlugin.groovy b/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MicronautBuildCommonPlugin.groovy
index f5181801..9c2c0745 100644
--- a/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MicronautBuildCommonPlugin.groovy
+++ b/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MicronautBuildCommonPlugin.groovy
@@ -1,6 +1,7 @@
package io.micronaut.build
import com.diffplug.gradle.spotless.SpotlessTask
+import io.micronaut.build.problems.MicronautBuildProblems
import io.micronaut.build.utils.DefaultVersions
import org.gradle.api.GradleException
import org.gradle.api.Plugin
@@ -8,6 +9,7 @@ import org.gradle.api.Project
import org.gradle.api.artifacts.ResolvableDependencies
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
import org.gradle.api.artifacts.result.ResolvedComponentResult
+import org.gradle.api.problems.Problems
import org.gradle.api.tasks.compile.GroovyCompile
import org.gradle.api.tasks.compile.JavaCompile
import org.gradle.api.tasks.diagnostics.DependencyReportTask
@@ -15,12 +17,21 @@ import org.gradle.api.tasks.javadoc.Groovydoc
import org.gradle.api.tasks.testing.Test
import org.gradle.jvm.tasks.Jar
+import javax.inject.Inject
+
import static io.micronaut.build.BomSupport.coreBomArtifactId
import static io.micronaut.build.utils.VersionHandling.versionProviderOrDefault
/**
* Micronaut internal Gradle plugin. Not intended to be used in user's projects.
*/
class MicronautBuildCommonPlugin implements Plugin {
+ private final Problems problems
+
+ @Inject
+ MicronautBuildCommonPlugin(Problems problems) {
+ this.problems = problems
+ }
+
void apply(Project project) {
project.pluginManager.apply(MicronautBasePlugin)
project.pluginManager.apply(MicronautQualityChecksParticipantPlugin)
@@ -63,7 +74,12 @@ class MicronautBuildCommonPlugin implements Plugin {
project.configurations.globalBoms.dependencies.addAllLater(micronautBuild.enableBom.zip(micronautVersionProvider) { enabled, micronautVersion ->
if (enabled) {
if (micronautBuild.enforcedPlatform.get()) {
- throw new GradleException("Do not use enforcedPlatform. Please remove the micronautBuild.enforcedPlatform setting")
+ String message = "Do not use enforcedPlatform. Please remove the micronautBuild.enforcedPlatform setting"
+ throw MicronautBuildProblems.throwing(problems, new GradleException(message), MicronautBuildProblems.ENFORCED_PLATFORM_NOT_SUPPORTED) {
+ it.contextualLabel(message)
+ .details("micronautBuild.enforcedPlatform is not supported by the Micronaut Build plugins.")
+ .solution("Remove the micronautBuild.enforcedPlatform setting and let Micronaut Build add the standard platform dependency.")
+ }
}
String artifactId = coreBomArtifactId(micronautVersion)
[project.dependencies.platform("io.micronaut:$artifactId:$micronautVersion")]
@@ -154,7 +170,13 @@ class MicronautBuildCommonPlugin implements Plugin {
if (id.group == 'io.micronaut' && id.module == 'micronaut-core') {
def (resolvedMajor, resolvedMinor, resolvedPatch) = id.version.tokenize('.')
if (resolvedMajor != major || resolvedMinor != minor) {
- throw new GradleException("Micronaut version mismatch: project declares $micronautVersion but resolved version is ${id.version}. You probably have a dependency which triggered an upgrade of micronaut-core. In order to determine where it comes from, you can run ./gradlew --dependencyInsight --configuration $configName --dependency io.micronaut:micronaut-core")
+ String dependencyInsight = "./gradlew --dependencyInsight --configuration $configName --dependency io.micronaut:micronaut-core"
+ String message = "Micronaut version mismatch: project declares $micronautVersion but resolved version is ${id.version}. You probably have a dependency which triggered an upgrade of micronaut-core. In order to determine where it comes from, you can run $dependencyInsight"
+ throw MicronautBuildProblems.throwing(problems, new GradleException(message), MicronautBuildProblems.MICRONAUT_VERSION_MISMATCH) {
+ it.contextualLabel("Micronaut version mismatch on $configName")
+ .details("Project declares Micronaut $micronautVersion but $configName resolved io.micronaut:micronaut-core:${id.version}.")
+ .solution("Run $dependencyInsight to determine which dependency selected the resolved micronaut-core version.")
+ }
}
}
}
diff --git a/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MicronautModulePlugin.groovy b/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MicronautModulePlugin.groovy
index 1955cc7a..35552379 100644
--- a/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MicronautModulePlugin.groovy
+++ b/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MicronautModulePlugin.groovy
@@ -1,11 +1,17 @@
package io.micronaut.build
import groovy.transform.CompileStatic
+import io.micronaut.build.problems.MicronautBuildProblems
+import org.gradle.api.Action
import io.micronaut.build.utils.DefaultVersions
import org.gradle.api.GradleException
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.Dependency
+import org.gradle.api.problems.ProblemSpec
+import org.gradle.api.problems.Problems
+
+import javax.inject.Inject
import static io.micronaut.build.utils.VersionHandling.versionProviderOrDefault
@@ -16,6 +22,12 @@ import static io.micronaut.build.utils.VersionHandling.versionProviderOrDefault
*/
@CompileStatic
class MicronautModulePlugin implements Plugin {
+ private final Problems problems
+
+ @Inject
+ MicronautModulePlugin(Problems problems) {
+ this.problems = problems
+ }
@Override
void apply(Project project) {
@@ -23,7 +35,7 @@ class MicronautModulePlugin implements Plugin {
configureStandardDependencies(project)
}
- private static void configureStandardDependencies(Project project) {
+ private void configureStandardDependencies(Project project) {
var micronautBuild = project.extensions.getByType(MicronautBuildExtension)
var deps = project.dependencies
deps.with {
@@ -44,7 +56,7 @@ class MicronautModulePlugin implements Plugin {
deps.create(versionProviderOrDefault(project, 'micronaut-test', DefaultVersions.MICRONAUT_TEST_VERSION).map { "io.micronaut.test:micronaut-test-junit5:${it}" }.get())
)
} else {
- throw new GradleException("Unsupported test framework: $it")
+ return unsupportedTestFramework(it)
}
}
)
@@ -58,9 +70,19 @@ class MicronautModulePlugin implements Plugin {
deps.create(versionProviderOrDefault(project, 'junit6', DefaultVersions.JUNIT6_VERSION).map { "org.junit.jupiter:junit-jupiter-engine:${it}" }.get()),
)
} else {
- throw new GradleException("Unsupported test framework: $it")
+ return unsupportedTestFramework(it)
}
}
)
}
+
+ private List unsupportedTestFramework(TestFramework testFramework) {
+ String message = "Unsupported test framework: $testFramework"
+ throw MicronautBuildProblems.throwing(problems, new GradleException(message), MicronautBuildProblems.UNSUPPORTED_TEST_FRAMEWORK, {
+ ProblemSpec spec ->
+ spec.contextualLabel(message)
+ .details("The Micronaut Build module plugin only supports ${TestFramework.SPOCK} and ${TestFramework.JUNIT6} test framework defaults.")
+ .solution("Configure micronautBuild.testFramework to ${TestFramework.SPOCK} or ${TestFramework.JUNIT6}.")
+ } as Action super ProblemSpec>)
+ }
}
diff --git a/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/docs/ValidateAsciidocOutputTask.java b/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/docs/ValidateAsciidocOutputTask.java
index c9a1a8c8..5c9e350c 100644
--- a/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/docs/ValidateAsciidocOutputTask.java
+++ b/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/docs/ValidateAsciidocOutputTask.java
@@ -15,11 +15,13 @@
*/
package io.micronaut.build.docs;
+import io.micronaut.build.problems.MicronautBuildProblems;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.Property;
+import org.gradle.api.problems.Problems;
import org.gradle.api.tasks.CacheableTask;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.InputDirectory;
@@ -38,6 +40,8 @@
import java.util.List;
import java.util.Map;
+import javax.inject.Inject;
+
import static io.micronaut.build.utils.ConsoleUtils.clickableUrl;
@CacheableTask
@@ -55,6 +59,9 @@ public abstract class ValidateAsciidocOutputTask extends DefaultTask {
@OutputFile
public abstract RegularFileProperty getReport();
+ @Inject
+ public abstract Problems getProblems();
+
@TaskAction
void validate() throws IOException {
final Map> errors = new HashMap<>();
@@ -81,7 +88,12 @@ void validate() throws IOException {
}
}
if (getFailOnError().getOrElse(true)) {
- throw new GradleException("Validation of generated asciidoctor files failed. See the report at " + clickableUrl(getReport().getAsFile().get()));
+ var reportFile = getReport().getAsFile().get();
+ var message = "Validation of generated asciidoctor files failed. See the report at " + clickableUrl(reportFile);
+ throw MicronautBuildProblems.throwing(getProblems(), new GradleException(message), MicronautBuildProblems.ASCIIDOC_OUTPUT_VALIDATION_FAILED, spec -> spec
+ .contextualLabel("Generated Asciidoc output contains unresolved directives")
+ .details("Generated HTML files contain unresolved Asciidoc directives. See the validation report at " + reportFile + ".")
+ .solution("Fix the unresolved directives in the generated documentation source and rerun the docs validation task."));
}
}
}
diff --git a/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/pom/PomChecker.groovy b/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/pom/PomChecker.groovy
index 559cf7b1..1eaaa8da 100644
--- a/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/pom/PomChecker.groovy
+++ b/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/pom/PomChecker.groovy
@@ -1,12 +1,14 @@
package io.micronaut.build.pom
import groovy.transform.CompileStatic
+import io.micronaut.build.problems.MicronautBuildProblems
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property
+import org.gradle.api.problems.Problems
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
@@ -59,6 +61,9 @@ abstract class PomChecker extends DefaultTask {
@Inject
abstract WorkerExecutor getWorkerExecutor()
+ @Inject
+ abstract Problems getProblems()
+
PomChecker() {
description = "Verifies a POM file"
group = VERIFICATION_GROUP
@@ -78,7 +83,12 @@ abstract class PomChecker extends DefaultTask {
ErrorCollector errorCollector = new ErrorCollector(silencedDeps)
def coordinates = pomCoordinates.get().split(':')
if (coordinates.length != 3) {
- throw new GradleException("Incorrect POM coordinates '${pomCoordinates.get()}': should be of the form group:artifact:version ")
+ String message = "Incorrect POM coordinates '${pomCoordinates.get()}': should be of the form group:artifact:version"
+ throw MicronautBuildProblems.throwing(problems, new GradleException(message), MicronautBuildProblems.INVALID_POM_COORDINATES) {
+ it.contextualLabel("Invalid POM coordinates")
+ .details("The configured POM coordinates do not use the expected group:artifact:version format.")
+ .solution("Set pomCoordinates to a complete group:artifact:version coordinate.")
+ }
}
def queue = new ArrayDeque>()
queue.add([coordinates[0], coordinates[1], coordinates[2], pomFile.get().asFile, pomCoordinates.get()] as List