diff --git a/build-plugin/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/JavaPluginAction.java b/build-plugin/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/JavaPluginAction.java index a78263c8b8da..724cb3471df6 100644 --- a/build-plugin/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/JavaPluginAction.java +++ b/build-plugin/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/JavaPluginAction.java @@ -22,6 +22,7 @@ import java.util.concurrent.Callable; import org.gradle.api.Action; +import org.gradle.api.NamedDomainObjectProvider; import org.gradle.api.Plugin; import org.gradle.api.Project; import org.gradle.api.Task; @@ -36,7 +37,6 @@ import org.gradle.api.plugins.JavaPlugin; import org.gradle.api.plugins.JavaPluginExtension; import org.gradle.api.provider.Provider; -import org.gradle.api.provider.ProviderFactory; import org.gradle.api.tasks.SourceSet; import org.gradle.api.tasks.SourceSetContainer; import org.gradle.api.tasks.TaskProvider; @@ -44,6 +44,7 @@ import org.gradle.api.tasks.compile.JavaCompile; import org.gradle.jvm.toolchain.JavaToolchainService; import org.gradle.jvm.toolchain.JavaToolchainSpec; +import org.gradle.util.GradleVersion; import org.jspecify.annotations.Nullable; import org.springframework.boot.gradle.dsl.SpringBootExtension; @@ -281,48 +282,63 @@ private void configureAdditionalMetadataLocations(JavaCompile compile) { .ifPresent((locations) -> compile.doFirst(new AdditionalMetadataLocationsConfigurer(locations))); } - @SuppressWarnings({ "rawtypes", "unchecked" }) - private void configureProductionRuntimeClasspathConfiguration(Project project) { - Configuration productionRuntimeClasspath = project.getConfigurations() - .create(SpringBootPlugin.PRODUCTION_RUNTIME_CLASSPATH_CONFIGURATION_NAME); - Configuration runtimeClasspath = project.getConfigurations() - .getByName(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME); - productionRuntimeClasspath.attributes((attributes) -> { - ProviderFactory providers = project.getProviders(); - AttributeContainer sourceAttributes = runtimeClasspath.getAttributes(); + static void copyAttributes(Provider from, Configuration to) { + if (GradleVersion.current().compareTo(GradleVersion.version("9.1.0")) < 0) { + copyAttributesLegacy(from, to); + } + else { + to.getAttributes().addAllLater(from.map(Configuration::getAttributes).get()); + } + } + + @SuppressWarnings({ "rawtypes", "unchecked", "NullAway" }) + static void copyAttributesLegacy(Provider from, Configuration to) { + to.attributes((attributes) -> { + AttributeContainer sourceAttributes = from.get().getAttributes(); for (Attribute attribute : sourceAttributes.keySet()) { attributes.attributeProvider(attribute, - providers.provider(() -> sourceAttributes.getAttribute(attribute))); + from.map((source) -> source.getAttributes().getAttribute(attribute))); } }); - productionRuntimeClasspath.setExtendsFrom(runtimeClasspath.getExtendsFrom()); - productionRuntimeClasspath.setCanBeResolved(runtimeClasspath.isCanBeResolved()); - productionRuntimeClasspath.setCanBeConsumed(runtimeClasspath.isCanBeConsumed()); - productionRuntimeClasspath.shouldResolveConsistentlyWith(runtimeClasspath); } - private void configureDevelopmentOnlyConfiguration(Project project) { + static void configureProductionRuntimeClasspathConfiguration(Project project) { + Configuration productionRuntimeClasspath = project.getConfigurations() + .create(SpringBootPlugin.PRODUCTION_RUNTIME_CLASSPATH_CONFIGURATION_NAME); + NamedDomainObjectProvider runtimeClasspath = project.getConfigurations() + .named(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME); + copyAttributes(runtimeClasspath, productionRuntimeClasspath); + productionRuntimeClasspath.setExtendsFrom(runtimeClasspath.get().getExtendsFrom()); + productionRuntimeClasspath.setCanBeResolved(runtimeClasspath.get().isCanBeResolved()); + productionRuntimeClasspath.setCanBeConsumed(runtimeClasspath.get().isCanBeConsumed()); + productionRuntimeClasspath.shouldResolveConsistentlyWith(runtimeClasspath.get()); + } + + static void configureDevelopmentOnlyConfiguration(Project project) { Configuration developmentOnly = project.getConfigurations() - .create(SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME); + .create(SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME); developmentOnly - .setDescription("Configuration for development-only dependencies such as Spring Boot's DevTools."); - Configuration runtimeClasspath = project.getConfigurations() - .getByName(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME); - - runtimeClasspath.extendsFrom(developmentOnly); + .setDescription("Configuration for development-only dependencies such as Spring Boot's DevTools."); + NamedDomainObjectProvider runtimeClasspath = project.getConfigurations() + .named(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME); + developmentOnly.setCanBeConsumed(false); + copyAttributes(runtimeClasspath, developmentOnly); + runtimeClasspath.configure((r) -> r.extendsFrom(developmentOnly)); } - private void configureTestAndDevelopmentOnlyConfiguration(Project project) { + static void configureTestAndDevelopmentOnlyConfiguration(Project project) { Configuration testAndDevelopmentOnly = project.getConfigurations() - .create(SpringBootPlugin.TEST_AND_DEVELOPMENT_ONLY_CONFIGURATION_NAME); + .create(SpringBootPlugin.TEST_AND_DEVELOPMENT_ONLY_CONFIGURATION_NAME); testAndDevelopmentOnly - .setDescription("Configuration for test and development-only dependencies such as Spring Boot's DevTools."); - Configuration runtimeClasspath = project.getConfigurations() - .getByName(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME); - runtimeClasspath.extendsFrom(testAndDevelopmentOnly); - Configuration testImplementation = project.getConfigurations() - .getByName(JavaPlugin.TEST_IMPLEMENTATION_CONFIGURATION_NAME); - testImplementation.extendsFrom(testAndDevelopmentOnly); + .setDescription("Configuration for test and development-only dependencies such as Spring Boot's DevTools."); + NamedDomainObjectProvider runtimeClasspath = project.getConfigurations() + .named(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME); + testAndDevelopmentOnly.setCanBeConsumed(false); + copyAttributes(runtimeClasspath, testAndDevelopmentOnly); + runtimeClasspath.configure((it) -> it.extendsFrom(testAndDevelopmentOnly)); + project.getConfigurations() + .named(JavaPlugin.TEST_IMPLEMENTATION_CONFIGURATION_NAME) + .configure((testImplementation) -> testImplementation.extendsFrom(testAndDevelopmentOnly)); } private void configureSpringBootStarterTestToDependOnJUnitPlatformLauncher(Project project) { diff --git a/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests.java b/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests.java index 826797565bb3..8816d8c23573 100644 --- a/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests.java +++ b/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests.java @@ -213,6 +213,24 @@ void productionRuntimeClasspathIsConfiguredWithAttributesThatMatchRuntimeClasspa assertThat(output).contains("productionRuntimeClasspath: " + attributes); } + @TestTemplate + void developmentOnlyIsConfiguredWithAttributesThatMatchRuntimeClasspath() { + String output = this.gradleBuild.build("build").getOutput(); + Matcher matcher = Pattern.compile("runtimeClasspath: (\\[.*])").matcher(output); + assertThat(matcher.find()).as("%s found in %s", matcher, output).isTrue(); + String attributes = matcher.group(1); + assertThat(output).contains("developmentOnly: " + attributes); + } + + @TestTemplate + void testAndDevelopmentOnlyIsConfiguredWithAttributesThatMatchRuntimeClasspath() { + String output = this.gradleBuild.build("build").getOutput(); + Matcher matcher = Pattern.compile("runtimeClasspath: (\\[.*])").matcher(output); + assertThat(matcher.find()).as("%s found in %s", matcher, output).isTrue(); + String attributes = matcher.group(1); + assertThat(output).contains("testAndDevelopmentOnly: " + attributes); + } + @TestTemplate void productionRuntimeClasspathIsConfiguredWithResolvabilityAndConsumabilityThatMatchesRuntimeClasspath() { String output = this.gradleBuild.build("build").getOutput(); diff --git a/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/JavaPluginActionTests.java b/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/JavaPluginActionTests.java new file mode 100644 index 000000000000..40072cde3392 --- /dev/null +++ b/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/JavaPluginActionTests.java @@ -0,0 +1,144 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.gradle.plugin; + +import org.gradle.api.NamedDomainObjectProvider; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.attributes.Attribute; +import org.gradle.testfixtures.ProjectBuilder; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class JavaPluginActionTests { + + private static final Attribute testAttribute = + Attribute.of("test", String.class); + + @Test + public void productionRuntimeClasspathCopiesRuntimeClasspathAttributes() { + Project project = ProjectBuilder.builder().build(); + project.getPluginManager().apply("application"); + JavaPluginAction.configureProductionRuntimeClasspathConfiguration(project); + + Configuration runtimeClasspath = project.getConfigurations().getByName("runtimeClasspath"); + Configuration productionRuntimeClasspath = + project.getConfigurations().getByName(SpringBootPlugin.PRODUCTION_RUNTIME_CLASSPATH_CONFIGURATION_NAME); + runtimeClasspath.getAttributes().keySet().forEach(attribute -> + assertThat(productionRuntimeClasspath.getAttributes().getAttribute(attribute)) + .as(attribute.getName() + " is copied from runtime") + .isEqualTo(runtimeClasspath.getAttributes().getAttribute(attribute))); + } + + @Test + public void productionRuntimeClasspathHasProperUsage() { + Project project = ProjectBuilder.builder().build(); + project.getPluginManager().apply("application"); + JavaPluginAction.configureProductionRuntimeClasspathConfiguration(project); + Configuration productionRuntimeClasspath = + project.getConfigurations().getByName(SpringBootPlugin.PRODUCTION_RUNTIME_CLASSPATH_CONFIGURATION_NAME); + assertThat(productionRuntimeClasspath.isCanBeResolved()).isTrue(); + assertThat(productionRuntimeClasspath.isCanBeConsumed()).isFalse(); + assertThat(productionRuntimeClasspath.isCanBeDeclared()) + .as("is declarable (dependencyScope)") + .isTrue(); + } + + @Test + public void developmentOnlyCopiesRuntimeClasspathAttributes() { + Project project = ProjectBuilder.builder().build(); + project.getPluginManager().apply("application"); + JavaPluginAction.configureDevelopmentOnlyConfiguration(project); + + Configuration runtimeClasspath = project.getConfigurations().getByName("runtimeClasspath"); + Configuration developmentOnly = + project.getConfigurations().getByName(SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME); + runtimeClasspath.getAttributes().keySet().forEach(attribute -> + assertThat(developmentOnly.getAttributes().getAttribute(attribute)) + .as(attribute.getName() + " is copied from runtime") + .isEqualTo(runtimeClasspath.getAttributes().getAttribute(attribute))); + } + + @Test + public void developmentOnlyHasProperUsage() { + Project project = ProjectBuilder.builder().build(); + project.getPluginManager().apply("application"); + JavaPluginAction.configureDevelopmentOnlyConfiguration(project); + Configuration productionRuntimeClasspath = + project.getConfigurations().getByName(SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME); + assertThat(productionRuntimeClasspath.isCanBeResolved()).isTrue(); + assertThat(productionRuntimeClasspath.isCanBeConsumed()).isFalse(); + assertThat(productionRuntimeClasspath.isCanBeDeclared()) + .as("is declarable (dependencyScope)") + .isTrue(); + } + + @Test + public void testAndDevelopmentOnlyCopiesRuntimeClasspathAttributes() { + Project project = ProjectBuilder.builder().build(); + project.getPluginManager().apply("application"); + JavaPluginAction.configureTestAndDevelopmentOnlyConfiguration(project); + + Configuration runtimeClasspath = project.getConfigurations().getByName("runtimeClasspath"); + Configuration testAndDevelopmentOnly = + project.getConfigurations().getByName(SpringBootPlugin.TEST_AND_DEVELOPMENT_ONLY_CONFIGURATION_NAME); + runtimeClasspath.getAttributes().keySet().forEach(attribute -> + assertThat(testAndDevelopmentOnly.getAttributes().getAttribute(attribute)) + .as(attribute.getName() + " is copied from runtime") + .isEqualTo(runtimeClasspath.getAttributes().getAttribute(attribute))); + } + + @Test + public void testAndDevelopmentOnlyHasProperUsage() { + Project project = ProjectBuilder.builder().build(); + project.getPluginManager().apply("application"); + JavaPluginAction.configureTestAndDevelopmentOnlyConfiguration(project); + Configuration productionRuntimeClasspath = + project.getConfigurations().getByName(SpringBootPlugin.TEST_AND_DEVELOPMENT_ONLY_CONFIGURATION_NAME); + assertThat(productionRuntimeClasspath.isCanBeResolved()).isTrue(); + assertThat(productionRuntimeClasspath.isCanBeConsumed()).isFalse(); + assertThat(productionRuntimeClasspath.isCanBeDeclared()) + .as("is declarable (dependencyScope)") + .isTrue(); + } + + @Test + public void copyAttributesIsLazy() { + Project project = ProjectBuilder.builder().build(); + Configuration a = project.getConfigurations().create("a"); + Configuration b = project.getConfigurations().create("b"); + JavaPluginAction.copyAttributes(project.getConfigurations().named("a"), b); + a.getAttributes().attribute(testAttribute, "value"); + assertThat(b.getAttributes().getAttribute(testAttribute)) + .as("Ideal implementation is always lazy, even if configuration is resolved early") + .isEqualTo("value"); + } + + @Test + public void copyAttributesLegacyIsNotLazy() { + Project project = ProjectBuilder.builder().build(); + NamedDomainObjectProvider a = project.getConfigurations().register("a"); + Configuration b = project.getConfigurations().create("b"); + JavaPluginAction.copyAttributesLegacy(a, b); + a.configure( it -> it.getAttributes().attribute(testAttribute, "value")); + + assertThat(b.getAttributes().getAttribute(testAttribute)) + .as("implementation for Gradle < 9 is not properly lazy (known limitation)") + .isNull(); + } +} diff --git a/build-plugin/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-developmentOnlyIsConfiguredWithAttributesThatMatchRuntimeClasspath.gradle b/build-plugin/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-developmentOnlyIsConfiguredWithAttributesThatMatchRuntimeClasspath.gradle new file mode 100644 index 000000000000..b92efe3679c1 --- /dev/null +++ b/build-plugin/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-developmentOnlyIsConfiguredWithAttributesThatMatchRuntimeClasspath.gradle @@ -0,0 +1,38 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +def collectAttributes(String configurationName) { + def attributes = configurations.findByName(configurationName).attributes + def keys = new TreeSet<>((a1, a2) -> a1.name.compareTo(a2.name)) + keys.addAll(attributes.keySet()) + keys.collect { key -> "${key}: ${attributes.getAttribute(key)}" } +} + +plugins { + id 'org.springframework.boot' version '{version}' + id 'java' +} + +springBoot { + mainClass = "com.example.Main" +} + +gradle.taskGraph.whenReady { + def runtimeClasspathAttributes = collectAttributes("runtimeClasspath") + def developmentOnlyAttributes = collectAttributes("developmentOnly") + println("runtimeClasspath: ${runtimeClasspathAttributes}") + println("developmentOnly: ${developmentOnlyAttributes}") +} \ No newline at end of file diff --git a/build-plugin/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testAndDevelopmentOnlyIsConfiguredWithAttributesThatMatchRuntimeClasspath.gradle b/build-plugin/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testAndDevelopmentOnlyIsConfiguredWithAttributesThatMatchRuntimeClasspath.gradle new file mode 100644 index 000000000000..fed71c5c5ea6 --- /dev/null +++ b/build-plugin/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testAndDevelopmentOnlyIsConfiguredWithAttributesThatMatchRuntimeClasspath.gradle @@ -0,0 +1,38 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +def collectAttributes(String configurationName) { + def attributes = configurations.findByName(configurationName).attributes + def keys = new TreeSet<>((a1, a2) -> a1.name.compareTo(a2.name)) + keys.addAll(attributes.keySet()) + keys.collect { key -> "${key}: ${attributes.getAttribute(key)}" } +} + +plugins { + id 'org.springframework.boot' version '{version}' + id 'java' +} + +springBoot { + mainClass = "com.example.Main" +} + +gradle.taskGraph.whenReady { + def runtimeClasspathAttributes = collectAttributes("runtimeClasspath") + def testAndDevelopmentOnlyAttributes = collectAttributes("testAndDevelopmentOnly") + println("runtimeClasspath: ${runtimeClasspathAttributes}") + println("testAndDevelopmentOnly: ${testAndDevelopmentOnlyAttributes}") +} \ No newline at end of file