diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsDependencyValidatorPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsDependencyValidatorPlugin.groovy index 74b1a7c0751..d66e0c2fc40 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsDependencyValidatorPlugin.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsDependencyValidatorPlugin.groovy @@ -28,9 +28,11 @@ import org.gradle.api.Task import org.gradle.api.artifacts.Configuration import org.gradle.api.artifacts.Dependency import org.gradle.api.artifacts.DependencyConstraint +import org.gradle.api.artifacts.ModuleDependency import org.gradle.api.artifacts.VersionConstraint import org.gradle.api.artifacts.component.ModuleComponentIdentifier import org.gradle.api.artifacts.result.ResolvedComponentResult +import org.gradle.api.attributes.Category /** * Validates that transitive dependencies do not replace versions what the @@ -159,19 +161,49 @@ class GrailsDependencyValidatorPlugin implements Plugin { /** * Scans the project's configurations to find which BOM project is in use. + * + *

When multiple known BOMs are declared on the same project (for example, + * the {@code grails-app} plugin auto-injects {@code platform(grails-bom)} on + * every declarable configuration while a Micronaut project additionally + * declares {@code enforcedPlatform(grails-micronaut-bom)}), this method + * prefers an {@code enforcedPlatform} declaration over a regular + * {@code platform}. The enforced BOM is the one whose constraints actually + * win at resolution time, so it is the correct reference for the + * "expected" versions reported by the validator.

*/ static String detectBomPath(Project project) { + String regularPlatformBomPath = null + for (Configuration config : project.configurations) { for (Dependency dep : config.dependencies) { - if (BOM_PROJECT_NAMES.contains(dep.name)) { - Project bomProject = project.rootProject.findProject(":${dep.name}" as String) - if (bomProject != null) { - return bomProject.path - } + if (!BOM_PROJECT_NAMES.contains(dep.name)) { + continue + } + Project bomProject = project.rootProject.findProject(":${dep.name}" as String) + if (bomProject == null) { + continue + } + if (isEnforcedPlatformDependency(dep)) { + return bomProject.path + } + if (regularPlatformBomPath == null) { + regularPlatformBomPath = bomProject.path } } } - null + + regularPlatformBomPath + } + + private static boolean isEnforcedPlatformDependency(Dependency dep) { + if (!(dep instanceof ModuleDependency)) { + return false + } + Object categoryAttr = ((ModuleDependency) dep).attributes.getAttribute(Category.CATEGORY_ATTRIBUTE) + if (categoryAttr == null) { + return false + } + categoryAttr.toString() == Category.ENFORCED_PLATFORM } /** diff --git a/grails-bom/base/build.gradle b/grails-bom/base/build.gradle index 7f974acd298..eba26511529 100644 --- a/grails-bom/base/build.gradle +++ b/grails-bom/base/build.gradle @@ -209,7 +209,7 @@ ext { ExtractedDependencyConstraint extractedConstraint = propertyNameCalculator.calculate(groupId, artifactId, inlineVersion, isBom) if (extractedConstraint?.versionPropertyReference) { // use the property reference instead of the hard coded version so that it can be - // overriden by the spring boot dependency management plugin + // overridden by project properties (gradle.properties or ext['property.name']) dep.version[0].value = extractedConstraint.versionPropertyReference // Add an entry in the node with the actual version number diff --git a/grails-data-graphql/examples/spring-boot-app/build.gradle b/grails-data-graphql/examples/spring-boot-app/build.gradle index 20359dc0997..0b943a02bc8 100644 --- a/grails-data-graphql/examples/spring-boot-app/build.gradle +++ b/grails-data-graphql/examples/spring-boot-app/build.gradle @@ -30,7 +30,6 @@ buildscript { apply plugin: 'groovy' apply plugin: 'idea' apply plugin: 'org.springframework.boot' -apply plugin: 'io.spring.dependency-management' apply plugin: 'org.apache.grails.buildsrc.dependency-validator' dependencies { diff --git a/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc b/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc index 31a1ffe6b3e..2c5ac02ff43 100644 --- a/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc +++ b/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc @@ -60,13 +60,33 @@ dependencies { Note that version numbers are not present in the majority of the dependencies. -This is thanks to the Spring dependency management plugin which automatically configures `grails-bom` as a Maven BOM via the Grails Gradle Plugin. This defines the default dependency versions for most commonly used dependencies and plugins. +This is thanks to Gradle's platform support which automatically imports `grails-bom` as a managed dependency platform via the Grails Gradle Plugin. This defines the default dependency versions for most commonly used dependencies and plugins. -For a Grails App, applying `org.apache.grails.gradle.grails-web` will automatically configure the `grails-bom`. No other steps required. +==== Overriding Managed Versions -For Plugins and Projects which do not use `org.apache.grails.gradle.grails-web`, you can apply the `grails-bom` in one of the following two ways. +To override a managed version, set the corresponding property in `gradle.properties` or `build.gradle`: +[source,groovy] +---- +// gradle.properties +slf4j.version=1.7.36 + +// or build.gradle +ext['slf4j.version'] = '1.7.36' +---- + +The property override mechanism is a feature of the **Grails BOM Property Overrides Gradle plugin** (`org.apache.grails.gradle.bom-property-overrides`); standalone Gradle does not natively read `` from BOM POMs (see https://github.com/gradle/gradle/issues/9160[Gradle issue #9160]). The plugin parses the BOM POM, builds a property→artifact mapping, and applies overrides via Gradle's `ResolutionStrategy.eachDependency()`. + +[NOTE] +==== +The plugin participates in Gradle's standard dependency resolution. When the same artifact is requested at multiple versions across a build (for example, when both `grails-bom` and a transitive dependency declare `slf4j-api`), Gradle's default *highest-version-wins* conflict resolution applies _before_ the property override is consulted. Setting `slf4j.version` therefore pins the version that will be used after conflict resolution rather than overriding the resolved version after the fact. This differs from the legacy Spring Dependency Management plugin, which forced BOM versions to win unconditionally and could mask transitive version drift. If you need stricter behaviour, declare `enforcedPlatform(grails-bom)` on the relevant configuration. +==== + +==== Applying the BOM in Different Project Types + +For a Grails App, applying `org.apache.grails.gradle.grails-web` will automatically configure the `grails-bom` _and_ apply the `bom-property-overrides` plugin. No other steps required. + +For Plugins and Projects which do not use `org.apache.grails.gradle.grails-web`, you can apply the `grails-bom` using Gradle Platforms: -build.gradle, using Gradle Platforms: [source,groovy] ---- dependencies { @@ -75,13 +95,35 @@ dependencies { } ---- -build.gradle, using Spring dependency management plugin: +==== Using `bom-property-overrides` Standalone (Non-Grails Projects) + +The property-override mechanism is published as a standalone, BOM-agnostic Gradle plugin so it can be reused with any BOM that follows the Maven `` convention. Apply it directly when you want the same `gradle.properties` / `ext['…']` override workflow without applying any Grails plugin: + +[source,groovy] +---- +plugins { + id 'java-library' + id 'org.apache.grails.gradle.bom-property-overrides' version '{GrailsVersion}' +} + +dependencies { + implementation platform('com.example:my-bom:1.0.0') +} + +// gradle.properties or build.gradle +ext['slf4j.version'] = '2.0.13' +---- + +By default the plugin auto-detects every `platform()` and `enforcedPlatform()` dependency declared on the project's configurations and registers each one for property-override processing. You can disable auto-detection or register additional BOMs explicitly via the `bomPropertyOverrides` extension: + [source,groovy] ---- -dependencyManagement { - imports { - mavenBom 'org.apache.grails:grails-bom:{GrailsVersion}' - } - applyMavenExclusions false +bomPropertyOverrides { + // Disable scanning declared platforms (default: true) + autoDetect = false + + // Register specific BOMs (e.g. ones referenced indirectly) + bom 'com.example:my-bom:1.0.0' + bom 'com.example:other-bom:2.0.0' } ---- diff --git a/grails-gradle/bom-property-overrides/build.gradle b/grails-gradle/bom-property-overrides/build.gradle new file mode 100644 index 00000000000..af4a5df3fa1 --- /dev/null +++ b/grails-gradle/bom-property-overrides/build.gradle @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +plugins { + id 'groovy' + id 'java-gradle-plugin' + id 'org.apache.grails.buildsrc.properties' + id 'org.apache.grails.buildsrc.dependency-validator' + id 'org.apache.grails.buildsrc.compile' + id 'org.apache.grails.buildsrc.publish' + id 'org.apache.grails.buildsrc.sbom' + id 'org.apache.grails.gradle.grails-code-style' +} + +version = projectVersion +group = 'org.apache.grails' + +ext { + pomTitle = 'Grails BOM Property Overrides Gradle Plugin' + pomDescription = 'A standalone Gradle plugin that enables Maven-style property-based version overrides for any Gradle platform() BOM. Reads the BOM POM block and lets consumers override versions via gradle.properties or ext[\'property.name\']. Reusable independently of Grails.' + pomMavenPublicationName = 'pluginMaven' +} + +dependencies { + implementation platform(project(':grails-gradle-bom')) + + // compile with the Groovy version provided by Gradle + // see: https://docs.gradle.org/current/userguide/compatibility.html#groovy + compileOnly 'org.apache.groovy:groovy' + + // Testing - Gradle TestKit is auto-added by java-gradle-plugin + testImplementation('org.spockframework:spock-core') { transitive = false } + testImplementation 'org.apache.groovy:groovy-test-junit5' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' +} + +configurations { + testCompileClasspath.exclude group: 'org.apache.groovy', module: 'groovy' + testRuntimeClasspath.exclude group: 'org.apache.groovy', module: 'groovy' +} + +gradlePlugin { + plugins { + bomPropertyOverrides { + displayName = 'Grails BOM Property Overrides Plugin' + description = 'Enables Maven-style property-based version overrides for any Gradle platform() BOM. ' + + 'Apply this plugin and override versions via gradle.properties or ext[\'property.name\']. ' + + 'Auto-detects declared platform() BOMs by default, or accepts an explicit list via the ' + + 'bomPropertyOverrides extension.' + id = 'org.apache.grails.gradle.bom-property-overrides' + implementationClass = 'org.grails.gradle.plugin.bom.BomPropertyOverridesPlugin' + } + } +} + +tasks.withType(Copy) { + configure { + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } +} + +apply { + from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') +} diff --git a/grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy b/grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy new file mode 100644 index 00000000000..20e92b543e4 --- /dev/null +++ b/grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy @@ -0,0 +1,378 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.gradle.plugin.bom + +import groovy.transform.CompileStatic +import org.gradle.api.Project +import org.gradle.api.artifacts.Configuration +import org.gradle.api.artifacts.DependencyResolveDetails +import org.gradle.api.logging.Logger +import org.gradle.api.logging.Logging +import org.w3c.dom.Document +import org.w3c.dom.Element +import org.w3c.dom.NodeList + +import javax.xml.parsers.DocumentBuilderFactory + +/** + * Lightweight replacement for the Spring Dependency Management plugin's + * version property override feature. + * + *

Parses BOM POM files to build a mapping of Maven property names + * (e.g., {@code slf4j.version}) to the artifacts they control. At + * dependency resolution time, checks whether the user has overridden + * any of these properties via {@code ext['property.name']} in + * {@code build.gradle} or via {@code gradle.properties}, and applies + * those overrides using Gradle's {@code ResolutionStrategy.eachDependency()}.

+ * + *

Gradle's native {@code platform()} mechanism handles the base + * BOM import and default version management. This class only adds the + * one feature Gradle lacks: property-based version customization + * (see Gradle #9160).

+ * + *

This is the underlying utility used by the + * {@code org.apache.grails.gradle.bom-property-overrides} plugin. It is + * BOM-agnostic and can be used directly with any BOM that follows the + * Maven {@code } convention for managed versions.

+ * + * @since 8.0 + */ +@CompileStatic +class BomManagedVersions { + + private static final Logger LOG = Logging.getLogger(BomManagedVersions) + private static final int MAX_PROPERTY_INTERPOLATION_DEPTH = 10 + + private final Map versionOverrides = new LinkedHashMap<>() + + /** + * Resolves a BOM, parses its POM chain, and determines which managed + * dependency versions need to be overridden based on project properties. + * + * @param project the Gradle project (used for artifact resolution and property lookup) + * @param bomCoordinates the BOM coordinates in {@code group:artifact:version} format + * @return a BomManagedVersions instance containing any version overrides to apply + */ + static BomManagedVersions resolve(Project project, String bomCoordinates) { + return resolve(project, [bomCoordinates]) + } + + /** + * Resolves multiple BOMs, parses their POM chains, and determines which + * managed dependency versions need to be overridden based on project + * properties. Useful when a project applies several platforms (e.g., a + * Grails BOM plus a Micronaut BOM) and any of them may declare overridable + * properties. + * + * @param project the Gradle project (used for artifact resolution and property lookup) + * @param bomCoordinatesList list of BOM coordinates in {@code group:artifact:version} format + * @return a BomManagedVersions instance containing any version overrides to apply + */ + static BomManagedVersions resolve(Project project, Collection bomCoordinatesList) { + BomManagedVersions instance = new BomManagedVersions() + + Map bomProperties = new LinkedHashMap<>() + Map> propertyToArtifacts = new LinkedHashMap<>() + Set processed = new HashSet<>() + + for (String bomCoordinates : bomCoordinatesList) { + String[] parts = bomCoordinates?.split(':') + if (parts == null || parts.length != 3) { + LOG.warn('Invalid BOM coordinates: {}', bomCoordinates) + continue + } + processBom(project, parts[0], parts[1], parts[2], bomProperties, propertyToArtifacts, processed) + } + + for (Map.Entry> entry : propertyToArtifacts.entrySet()) { + String propertyName = entry.key + if (project.hasProperty(propertyName)) { + String overrideVersion = project.property(propertyName).toString() + String defaultVersion = bomProperties.get(propertyName) + + if (overrideVersion != defaultVersion) { + for (String artifactKey : entry.value) { + instance.versionOverrides.put(artifactKey, overrideVersion) + } + LOG.lifecycle( + 'BOM version override: {} = {} (BOM default: {})', + propertyName, overrideVersion, defaultVersion ?: 'unknown' + ) + } + } + } + + if (!instance.versionOverrides.isEmpty()) { + LOG.info('BOM property overrides: {} version override(s) will be applied', instance.versionOverrides.size()) + } + + return instance + } + + /** + * Applies version overrides to a Gradle configuration's resolution strategy. + * + * @param configuration the configuration to apply overrides to + */ + void applyTo(Configuration configuration) { + if (versionOverrides.isEmpty()) { + return + } + + Map overrides = this.versionOverrides + configuration.resolutionStrategy.eachDependency { DependencyResolveDetails details -> + String key = "${details.requested.group}:${details.requested.name}" as String + String override = overrides.get(key) + if (override != null) { + details.useVersion(override) + details.because('BOM version override via project property') + } + } + } + + /** + * Returns whether any version overrides were detected. + */ + boolean hasOverrides() { + return !versionOverrides.isEmpty() + } + + /** + * Returns an unmodifiable view of the version overrides. + * Keys are {@code group:artifact}, values are the override version strings. + */ + Map getOverrides() { + return Collections.unmodifiableMap(versionOverrides) + } + + /** + * Parses a BOM POM file and extracts the property-to-artifact mapping. + * This method does not follow imported BOMs recursively - it only processes + * the given file. Intended for testing and direct POM inspection. + * + * @param pomFile the BOM POM file to parse + * @param bomProperties output map to receive property name to default value mappings + * @param propertyToArtifacts output map to receive property name to artifact coordinate mappings + */ + static void parseBomFile(File pomFile, Map bomProperties, Map> propertyToArtifacts) { + Document doc = parseXml(pomFile) + if (doc == null) { + return + } + extractProperties(doc, bomProperties) + + NodeList depMgmtNodes = doc.getElementsByTagName('dependencyManagement') + if (depMgmtNodes.length == 0) { + return + } + Element depMgmt = (Element) depMgmtNodes.item(0) + NodeList dependenciesNodes = depMgmt.getElementsByTagName('dependencies') + if (dependenciesNodes.length == 0) { + return + } + Element dependenciesElement = (Element) dependenciesNodes.item(0) + NodeList depNodes = dependenciesElement.getElementsByTagName('dependency') + + for (int i = 0; i < depNodes.length; i++) { + Element dep = (Element) depNodes.item(i) + String depGroupId = getChildText(dep, 'groupId') + String depArtifactId = getChildText(dep, 'artifactId') + String depVersion = getChildText(dep, 'version') + + if (!depGroupId || !depArtifactId || !depVersion) { + continue + } + + if (depVersion.contains('${')) { + String propertyName = extractPropertyName(depVersion) + if (propertyName) { + String artifactKey = "${depGroupId}:${depArtifactId}" as String + propertyToArtifacts.computeIfAbsent(propertyName) { new ArrayList() }.add(artifactKey) + } + } + } + } + + private static void processBom( + Project project, String group, String artifact, String version, + Map bomProperties, + Map> propertyToArtifacts, + Set processed + ) { + String bomKey = "${group}:${artifact}:${version}" as String + if (!processed.add(bomKey)) { + return + } + + File pomFile = resolvePomFile(project, group, artifact, version) + if (pomFile == null) { + return + } + + Document doc = parseXml(pomFile) + if (doc == null) { + return + } + + extractProperties(doc, bomProperties) + processManagedDependencies(doc, project, bomProperties, propertyToArtifacts, processed) + } + + private static File resolvePomFile(Project project, String group, String artifact, String version) { + try { + Configuration detached = project.configurations.detachedConfiguration( + project.dependencies.create("${group}:${artifact}:${version}@pom" as String) + ) + detached.transitive = false + return detached.singleFile + } + catch (Exception e) { + LOG.info('Could not resolve BOM POM: {}:{}:{} - {}', group, artifact, version, e.message) + return null + } + } + + private static Document parseXml(File pomFile) { + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance() + factory.setNamespaceAware(false) + factory.setValidating(false) + factory.setXIncludeAware(false) + factory.setFeature('http://apache.org/xml/features/nonvalidating/load-external-dtd', false) + factory.setFeature('http://xml.org/sax/features/external-general-entities', false) + factory.setFeature('http://xml.org/sax/features/external-parameter-entities', false) + return factory.newDocumentBuilder().parse(pomFile) + } + catch (Exception e) { + LOG.warn('Failed to parse BOM POM: {} - {}', pomFile.name, e.message) + return null + } + } + + private static void extractProperties(Document doc, Map bomProperties) { + NodeList propertiesNodes = doc.getElementsByTagName('properties') + if (propertiesNodes.length == 0) { + return + } + + Element propertiesElement = (Element) propertiesNodes.item(0) + NodeList children = propertiesElement.childNodes + for (int i = 0; i < children.length; i++) { + if (children.item(i) instanceof Element) { + Element prop = (Element) children.item(i) + String name = prop.tagName + String value = prop.textContent?.trim() + if (name && value) { + bomProperties.put(name, value) + } + } + } + } + + private static void processManagedDependencies( + Document doc, Project project, + Map bomProperties, + Map> propertyToArtifacts, + Set processed + ) { + NodeList depMgmtNodes = doc.getElementsByTagName('dependencyManagement') + if (depMgmtNodes.length == 0) { + return + } + + Element depMgmt = (Element) depMgmtNodes.item(0) + NodeList dependenciesNodes = depMgmt.getElementsByTagName('dependencies') + if (dependenciesNodes.length == 0) { + return + } + + Element dependenciesElement = (Element) dependenciesNodes.item(0) + NodeList depNodes = dependenciesElement.getElementsByTagName('dependency') + + for (int i = 0; i < depNodes.length; i++) { + Element dep = (Element) depNodes.item(i) + String depGroupId = getChildText(dep, 'groupId') + String depArtifactId = getChildText(dep, 'artifactId') + String depVersion = getChildText(dep, 'version') + String depScope = getChildText(dep, 'scope') + + if (!depGroupId || !depArtifactId) { + continue + } + + if ('import' == depScope) { + String resolvedVersion = interpolateProperties(depVersion, bomProperties) + if (resolvedVersion) { + processBom(project, depGroupId, depArtifactId, resolvedVersion, + bomProperties, propertyToArtifacts, processed) + } + continue + } + + if (depVersion && depVersion.contains('${')) { + String propertyName = extractPropertyName(depVersion) + if (propertyName) { + String artifactKey = "${depGroupId}:${depArtifactId}" as String + propertyToArtifacts.computeIfAbsent(propertyName) { new ArrayList() }.add(artifactKey) + } + } + } + } + + private static String extractPropertyName(String versionStr) { + if (versionStr == null) { + return null + } + int start = versionStr.indexOf('${') + int end = versionStr.indexOf('}', start) + if (start >= 0 && end > start) { + return versionStr.substring(start + 2, end) + } + return null + } + + private static String interpolateProperties(String value, Map properties) { + if (value == null || !value.contains('${')) { + return value + } + + String result = value + int maxIterations = MAX_PROPERTY_INTERPOLATION_DEPTH + while (result.contains('${') && maxIterations-- > 0) { + String propertyName = extractPropertyName(result) + if (propertyName == null) { + break + } + String resolved = properties.get(propertyName) + if (resolved == null) { + break + } + result = result.replace("\${${propertyName}}" as String, resolved) + } + return result + } + + private static String getChildText(Element parent, String childTagName) { + NodeList children = parent.getElementsByTagName(childTagName) + if (children.length == 0) { + return null + } + return children.item(0).textContent?.trim() + } +} diff --git a/grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesExtension.groovy b/grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesExtension.groovy new file mode 100644 index 00000000000..01e01b7d178 --- /dev/null +++ b/grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesExtension.groovy @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.gradle.plugin.bom + +import groovy.transform.CompileStatic +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property + +/** + * Configuration for the + * {@code org.apache.grails.gradle.bom-property-overrides} plugin. + * + *

Exposed on the project as {@code bomPropertyOverrides}:

+ * + *
+ * apply plugin: 'org.apache.grails.gradle.bom-property-overrides'
+ *
+ * bomPropertyOverrides {
+ *     // Disable scanning declared platform() dependencies (default: true)
+ *     autoDetect = false
+ *
+ *     // Add explicit BOM coordinates - useful when a BOM is referenced
+ *     // indirectly (e.g., through a parent plugin) and not declared
+ *     // directly via platform() in the consumer's build.gradle
+ *     bom 'org.example:my-bom:1.0.0'
+ *     bom 'org.example:other-bom:2.0.0'
+ * }
+ *
+ * // Override versions via gradle.properties or ext[]
+ * ext['slf4j.version'] = '2.0.13'
+ * 
+ * + *

By default ({@code autoDetect = true}) the plugin scans every project + * configuration for declared {@code platform()} / {@code enforcedPlatform()} + * dependencies and registers each unique BOM for property-override + * processing. Explicit entries added via {@link #bom(String)} are always + * processed in addition to auto-detected ones, regardless of the + * {@code autoDetect} flag.

+ * + * @since 8.0 + */ +@CompileStatic +class BomPropertyOverridesExtension { + + /** + * The name of the project extension exposed on every project. + */ + static final String EXTENSION_NAME = 'bomPropertyOverrides' + + /** + * Whether to auto-detect BOMs from declared {@code platform()} / + * {@code enforcedPlatform()} dependencies on the project's + * configurations. Defaults to {@code true}. + */ + final Property autoDetect + + /** + * Explicit list of BOM coordinates ({@code group:artifact:version}) + * that should be processed for property overrides regardless of + * whether they are declared as platforms on the project. + */ + final ListProperty boms + + BomPropertyOverridesExtension(org.gradle.api.model.ObjectFactory objects) { + this.autoDetect = objects.property(Boolean).convention(true) + this.boms = objects.listProperty(String).convention([]) + } + + /** + * Adds a BOM coordinate to the explicit override list. + * + * @param coordinates the BOM coordinates in {@code group:artifact:version} format + */ + void bom(String coordinates) { + boms.add(coordinates) + } + + /** + * Adds multiple BOM coordinates to the explicit override list. + * + * @param coordinates the BOM coordinates in {@code group:artifact:version} format + */ + void boms(String... coordinates) { + for (String coord : coordinates) { + boms.add(coord) + } + } +} diff --git a/grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy b/grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy new file mode 100644 index 00000000000..02f9f1fe47d --- /dev/null +++ b/grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy @@ -0,0 +1,167 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.gradle.plugin.bom + +import groovy.transform.CompileStatic +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.Configuration +import org.gradle.api.artifacts.Dependency +import org.gradle.api.artifacts.ModuleDependency +import org.gradle.api.attributes.Category + +/** + * Standalone Gradle plugin that enables Maven-style property-based version + * overrides for {@code platform()} BOMs. + * + *

This is the BOM-agnostic, generically reusable extraction of the + * property-override mechanism that historically lived inside the Spring + * Dependency Management plugin. Apply it to any project that consumes a + * BOM published with version property references in its + * {@code } block:

+ * + *
+ * plugins {
+ *     id 'org.apache.grails.gradle.bom-property-overrides'
+ * }
+ *
+ * dependencies {
+ *     implementation platform('com.example:my-bom:1.0.0')
+ * }
+ *
+ * // gradle.properties or build.gradle
+ * ext['slf4j.version'] = '2.0.13'
+ * 
+ * + *

How it works

+ *
    + *
  1. Auto-detects all {@code platform()} / {@code enforcedPlatform()} + * dependencies declared on the project's configurations (configurable + * via {@link BomPropertyOverridesExtension#autoDetect}).
  2. + *
  3. Resolves each BOM POM in a detached configuration, parses the + * {@code } block and the + * {@code } entries, and recursively follows + * {@code import} BOMs.
  4. + *
  5. For every property the BOM declares, checks whether the project + * has a property with the same name (via {@code gradle.properties} + * or {@code ext['property.name']}). If so, applies the override at + * resolution time using + * {@link Configuration#getResolutionStrategy()}'s + * {@code eachDependency} hook.
  6. + *
+ * + *

The plugin does not declare any platforms itself. + * Consumers (or other plugins like {@code grails-app}) remain responsible + * for declaring the {@code platform()} dependencies; this plugin only + * adds the property-override layer on top.

+ * + * @since 8.0 + * @see BomManagedVersions + * @see BomPropertyOverridesExtension + */ +@CompileStatic +class BomPropertyOverridesPlugin implements Plugin { + + /** + * The plugin id, exposed as a constant for programmatic application + * (e.g. {@code project.plugins.apply(BomPropertyOverridesPlugin.PLUGIN_ID)}). + */ + static final String PLUGIN_ID = 'org.apache.grails.gradle.bom-property-overrides' + + @Override + void apply(Project project) { + BomPropertyOverridesExtension extension = project.extensions.create( + BomPropertyOverridesExtension.EXTENSION_NAME, + BomPropertyOverridesExtension, + project.objects + ) + + project.afterEvaluate { + applyOverrides(project, extension) + } + } + + /** + * Resolves the configured BOMs and applies any version overrides found + * to all project configurations. Visible for testing. + */ + static void applyOverrides(Project project, BomPropertyOverridesExtension extension) { + Set bomCoordinates = new LinkedHashSet<>() + + if (extension.autoDetect.get()) { + bomCoordinates.addAll(detectDeclaredBoms(project)) + } + + for (String explicit : extension.boms.get()) { + if (explicit) { + bomCoordinates.add(explicit) + } + } + + if (bomCoordinates.isEmpty()) { + return + } + + BomManagedVersions managedVersions = BomManagedVersions.resolve(project, bomCoordinates) + if (!managedVersions.hasOverrides()) { + return + } + + project.configurations.configureEach { Configuration conf -> + managedVersions.applyTo(conf) + } + } + + /** + * Scans every configuration for declared {@code platform()} or + * {@code enforcedPlatform()} dependencies and returns their coordinates. + * Visible for testing. + */ + static Set detectDeclaredBoms(Project project) { + Set coordinates = new LinkedHashSet<>() + + project.configurations.each { Configuration conf -> + for (Dependency dep : conf.dependencies) { + if (!(dep instanceof ModuleDependency)) { + continue + } + if (!isPlatformDependency((ModuleDependency) dep)) { + continue + } + String group = dep.group + String name = dep.name + String version = dep.version + if (group && name && version) { + coordinates.add("${group}:${name}:${version}" as String) + } + } + } + + return coordinates + } + + private static boolean isPlatformDependency(ModuleDependency dep) { + Object categoryAttr = dep.attributes.getAttribute(Category.CATEGORY_ATTRIBUTE) + if (categoryAttr == null) { + return false + } + String category = categoryAttr.toString() + return category == Category.REGULAR_PLATFORM || category == Category.ENFORCED_PLATFORM + } +} diff --git a/grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomManagedVersionsSpec.groovy b/grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomManagedVersionsSpec.groovy new file mode 100644 index 00000000000..9840c4b90d4 --- /dev/null +++ b/grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomManagedVersionsSpec.groovy @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.gradle.plugin.bom + +import spock.lang.Specification + +/** + * Unit tests for {@link BomManagedVersions}. + * + *

Verifies that the utility correctly parses BOM POM files, + * extracts {@code }, builds property-to-artifact mappings + * from {@code } entries, and skips dependencies + * with hardcoded versions.

+ * + * @since 8.0 + * @see BomManagedVersions + */ +class BomManagedVersionsSpec extends Specification { + + def "parseBomFile extracts properties from BOM POM"() { + given: + File pomFile = new File(getClass().getClassLoader().getResource('test-poms/test-bom.pom').toURI()) + Map bomProperties = [:] + Map> propertyToArtifacts = [:] + + when: + BomManagedVersions.parseBomFile(pomFile, bomProperties, propertyToArtifacts) + + then: + bomProperties['jackson.version'] == '2.15.0' + bomProperties['slf4j.version'] == '2.0.9' + bomProperties['groovy.version'] == '4.0.30' + } + + def "parseBomFile maps property references to artifact coordinates"() { + given: + File pomFile = new File(getClass().getClassLoader().getResource('test-poms/test-bom.pom').toURI()) + Map bomProperties = [:] + Map> propertyToArtifacts = [:] + + when: + BomManagedVersions.parseBomFile(pomFile, bomProperties, propertyToArtifacts) + + then: "jackson.version maps to all three jackson artifacts" + propertyToArtifacts['jackson.version'].containsAll([ + 'com.fasterxml.jackson.core:jackson-databind', + 'com.fasterxml.jackson.core:jackson-core', + 'com.fasterxml.jackson.core:jackson-annotations' + ]) + + and: "slf4j.version maps to slf4j-api" + propertyToArtifacts['slf4j.version'] == ['org.slf4j:slf4j-api'] + + and: "groovy.version maps to groovy" + propertyToArtifacts['groovy.version'] == ['org.apache.groovy:groovy'] + } + + def "parseBomFile ignores dependencies with hardcoded versions"() { + given: + File pomFile = new File(getClass().getClassLoader().getResource('test-poms/test-bom.pom').toURI()) + Map bomProperties = [:] + Map> propertyToArtifacts = [:] + + when: + BomManagedVersions.parseBomFile(pomFile, bomProperties, propertyToArtifacts) + + then: "hardcoded-version artifact is not in any property mapping" + !propertyToArtifacts.values().flatten().contains('org.example:hardcoded-version') + } + + def "BomManagedVersions with no overrides reports hasOverrides false"() { + given: + def instance = new BomManagedVersions() + + expect: + !instance.hasOverrides() + instance.overrides.isEmpty() + } +} diff --git a/grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginFunctionalSpec.groovy b/grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginFunctionalSpec.groovy new file mode 100644 index 00000000000..7b9f1743bab --- /dev/null +++ b/grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginFunctionalSpec.groovy @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.gradle.plugin.bom + +/** + * End-to-end functional test for the standalone + * {@code org.apache.grails.gradle.bom-property-overrides} plugin. + * + *

Uses Gradle TestKit to apply the plugin in isolation (without any + * Grails plugins) to verify that the plugin can be used generically with + * any BOM. Confirms the plugin registers its extension, auto-detects + * declared {@code platform()} / {@code enforcedPlatform()} dependencies, + * and skips non-platform dependencies.

+ * + * @since 8.0 + */ +class BomPropertyOverridesPluginFunctionalSpec extends GradleSpecification { + + def "plugin registers extension, autoDetect defaults to true, and identifies declared platforms"() { + given: + setupTestResourceProject('bom-property-overrides-basic') + + when: + def result = executeTask('inspectBomSetup') + + then: 'extension is registered with default autoDetect=true' + result.output.contains('HAS_EXTENSION=true') + result.output.contains('AUTO_DETECT_DEFAULT=true') + + and: 'auto-detect identifies both regular and enforced platforms but skips non-platform dependencies' + result.output.contains('DETECTED_BOMS=org.example:enforced-bom:2.0.0,org.example:test-bom:1.0.0') + } +} diff --git a/grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginSpec.groovy b/grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginSpec.groovy new file mode 100644 index 00000000000..c97f164c09f --- /dev/null +++ b/grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginSpec.groovy @@ -0,0 +1,146 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.gradle.plugin.bom + +import org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder +import spock.lang.Specification + +/** + * Unit-level tests for {@link BomPropertyOverridesPlugin} using + * {@link ProjectBuilder}. + * + *

Verifies plugin application creates the {@code bomPropertyOverrides} + * extension with sensible defaults, that the extension's DSL methods + * register explicit BOM coordinates, and that auto-detect identifies + * declared {@code platform()} / {@code enforcedPlatform()} dependencies.

+ * + * @since 8.0 + */ +class BomPropertyOverridesPluginSpec extends Specification { + + def "applying the plugin registers the bomPropertyOverrides extension"() { + given: + Project project = ProjectBuilder.builder().build() + + when: + project.plugins.apply(BomPropertyOverridesPlugin) + + then: + BomPropertyOverridesExtension extension = + project.extensions.findByType(BomPropertyOverridesExtension) + extension != null + extension.autoDetect.get() == true + extension.boms.get().isEmpty() + } + + def "extension bom() method registers explicit BOM coordinates"() { + given: + Project project = ProjectBuilder.builder().build() + project.plugins.apply(BomPropertyOverridesPlugin) + BomPropertyOverridesExtension extension = + project.extensions.getByType(BomPropertyOverridesExtension) + + when: + extension.bom('org.example:my-bom:1.0.0') + extension.bom('org.example:other-bom:2.0.0') + + then: + extension.boms.get() == ['org.example:my-bom:1.0.0', 'org.example:other-bom:2.0.0'] + } + + def "extension boms() vararg method registers multiple BOMs"() { + given: + Project project = ProjectBuilder.builder().build() + project.plugins.apply(BomPropertyOverridesPlugin) + BomPropertyOverridesExtension extension = + project.extensions.getByType(BomPropertyOverridesExtension) + + when: + extension.boms('org.example:a:1.0.0', 'org.example:b:2.0.0', 'org.example:c:3.0.0') + + then: + extension.boms.get() == ['org.example:a:1.0.0', 'org.example:b:2.0.0', 'org.example:c:3.0.0'] + } + + def "detectDeclaredBoms finds regular platform() dependencies"() { + given: + Project project = ProjectBuilder.builder().build() + project.plugins.apply('java') + project.dependencies.add( + 'implementation', + project.dependencies.platform('org.example:test-bom:1.0.0') + ) + + when: + Set coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project) + + then: + 'org.example:test-bom:1.0.0' in coordinates + } + + def "detectDeclaredBoms finds enforcedPlatform() dependencies"() { + given: + Project project = ProjectBuilder.builder().build() + project.plugins.apply('java') + project.dependencies.add( + 'implementation', + project.dependencies.enforcedPlatform('org.example:enforced-bom:2.0.0') + ) + + when: + Set coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project) + + then: + 'org.example:enforced-bom:2.0.0' in coordinates + } + + def "detectDeclaredBoms ignores non-platform dependencies"() { + given: + Project project = ProjectBuilder.builder().build() + project.plugins.apply('java') + project.dependencies.add('implementation', 'org.example:regular-lib:1.0.0') + + when: + Set coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project) + + then: + coordinates.isEmpty() + } + + def "detectDeclaredBoms deduplicates the same BOM declared on multiple configurations"() { + given: + Project project = ProjectBuilder.builder().build() + project.plugins.apply('java') + project.dependencies.add( + 'implementation', + project.dependencies.platform('org.example:shared-bom:1.0.0') + ) + project.dependencies.add( + 'testImplementation', + project.dependencies.platform('org.example:shared-bom:1.0.0') + ) + + when: + Set coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project) + + then: + coordinates == ['org.example:shared-bom:1.0.0'] as Set + } +} diff --git a/grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/GradleSpecification.groovy b/grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/GradleSpecification.groovy new file mode 100644 index 00000000000..d1c283a29fb --- /dev/null +++ b/grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/GradleSpecification.groovy @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.gradle.plugin.bom + +import org.gradle.testkit.runner.BuildResult +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import spock.lang.Specification + +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes + +/** + * Base class for Gradle plugin functional tests using TestKit. + * + *

Handles temp directory management, GradleRunner setup, test + * resource project copying, and common build assertions. Mirrors the + * helper used by the {@code grails-gradle-plugins} module.

+ * + * @since 8.0 + */ +abstract class GradleSpecification extends Specification { + + private static Path basePath + private static GradleRunner gradleRunner + + /** Project version injected by Gradle test config. */ + protected static final String PROJECT_VERSION = System.getProperty('projectVersion') + + void setupSpec() { + basePath = Files.createTempDirectory('bom-property-overrides-projects') + Path testKitDir = Files.createDirectories(basePath.resolve('.gradle')) + gradleRunner = GradleRunner.create() + .withPluginClasspath() + .withTestKitDir(testKitDir.toFile()) + } + + void cleanup() { + basePath?.toFile()?.listFiles()?.each { + if (it.name == '.gradle') { + return + } + it.deleteDir() + } + } + + void cleanupSpec() { + basePath?.toFile()?.deleteDir() + } + + /** + * Sets up a test project from resource files under + * {@code src/test/resources/test-projects/{projectName}}. + * + *

Files are copied to a temp directory. Any occurrence of + * {@code __PROJECT_VERSION__} in {@code .gradle} files is replaced + * with the actual project version.

+ */ + protected GradleRunner setupTestResourceProject(String projectName) { + Path destination = basePath.resolve(projectName) + Files.createDirectories(destination) + + Path source = Path.of("src/test/resources/test-projects/${projectName}") + copyDirectory(source, destination) + + gradleRunner.withProjectDir(destination.toFile()) + } + + /** + * Executes a Gradle task and returns the build result. + */ + protected BuildResult executeTask(String taskName, List otherArgs = []) { + List args = [taskName, '--stacktrace'] + args.addAll(otherArgs) + gradleRunner.withArguments(args).forwardOutput().build() + } + + /** + * Asserts that the given task succeeded. + */ + protected void assertTaskSuccess(String taskName, BuildResult result) { + def task = result.tasks.find { it.path.endsWith(":${taskName}") } + assert task != null : "Task '${taskName}' not found in build result" + assert task.outcome == TaskOutcome.SUCCESS : "Task '${taskName}' outcome was ${task.outcome}" + } + + private void copyDirectory(Path source, Path destination) { + Files.walkFileTree(source, new SimpleFileVisitor() { + @Override + FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { + Files.createDirectories(destination.resolve(source.relativize(dir))) + FileVisitResult.CONTINUE + } + + @Override + FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + Path target = destination.resolve(source.relativize(file)) + if (file.toString().endsWith('.gradle') || file.toString().endsWith('.properties')) { + String content = Files.readString(file).replace('__PROJECT_VERSION__', PROJECT_VERSION) + Files.writeString(target, content) + } else { + Files.copy(file, target) + } + FileVisitResult.CONTINUE + } + }) + } +} diff --git a/grails-gradle/bom-property-overrides/src/test/resources/test-poms/test-bom.pom b/grails-gradle/bom-property-overrides/src/test/resources/test-poms/test-bom.pom new file mode 100644 index 00000000000..cda1a266adb --- /dev/null +++ b/grails-gradle/bom-property-overrides/src/test/resources/test-poms/test-bom.pom @@ -0,0 +1,51 @@ + + + 4.0.0 + org.test + test-bom + 1.0.0 + pom + + + 2.15.0 + 2.0.9 + 4.0.30 + + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + com.fasterxml.jackson.core + jackson-core + ${jackson.version} + + + com.fasterxml.jackson.core + jackson-annotations + ${jackson.version} + + + org.slf4j + slf4j-api + ${slf4j.version} + + + org.apache.groovy + groovy + ${groovy.version} + + + org.example + hardcoded-version + 1.0.0 + + + + diff --git a/grails-gradle/bom-property-overrides/src/test/resources/test-projects/bom-property-overrides-basic/build.gradle b/grails-gradle/bom-property-overrides/src/test/resources/test-projects/bom-property-overrides-basic/build.gradle new file mode 100644 index 00000000000..af6ae418a1b --- /dev/null +++ b/grails-gradle/bom-property-overrides/src/test/resources/test-projects/bom-property-overrides-basic/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'java' + id 'org.apache.grails.gradle.bom-property-overrides' +} + +dependencies { + implementation platform('org.example:test-bom:1.0.0') + implementation enforcedPlatform('org.example:enforced-bom:2.0.0') + implementation 'org.example:regular-lib:1.0.0' +} + +tasks.register('inspectBomSetup') { + doLast { + def extension = project.extensions.findByName('bomPropertyOverrides') + println "HAS_EXTENSION=${extension != null}" + println "AUTO_DETECT_DEFAULT=${extension?.autoDetect?.get()}" + + Set detected = org.grails.gradle.plugin.bom.BomPropertyOverridesPlugin + .detectDeclaredBoms(project) + println "DETECTED_BOMS=${detected.sort().join(',')}" + } +} diff --git a/grails-gradle/bom-property-overrides/src/test/resources/test-projects/bom-property-overrides-basic/gradle.properties b/grails-gradle/bom-property-overrides/src/test/resources/test-projects/bom-property-overrides-basic/gradle.properties new file mode 100644 index 00000000000..fad0c094005 --- /dev/null +++ b/grails-gradle/bom-property-overrides/src/test/resources/test-projects/bom-property-overrides-basic/gradle.properties @@ -0,0 +1 @@ +org.gradle.jvmargs=-Xmx1g diff --git a/grails-gradle/bom-property-overrides/src/test/resources/test-projects/bom-property-overrides-basic/settings.gradle b/grails-gradle/bom-property-overrides/src/test/resources/test-projects/bom-property-overrides-basic/settings.gradle new file mode 100644 index 00000000000..06c97ce7c22 --- /dev/null +++ b/grails-gradle/bom-property-overrides/src/test/resources/test-projects/bom-property-overrides-basic/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'test-bom-property-overrides' diff --git a/grails-gradle/gradle/publish-root-config.gradle b/grails-gradle/gradle/publish-root-config.gradle index 4282dbfe20c..48a9f33566f 100644 --- a/grails-gradle/gradle/publish-root-config.gradle +++ b/grails-gradle/gradle/publish-root-config.gradle @@ -25,6 +25,7 @@ group = 'this.will.be.overridden' def publishedProjects = [ 'grails-gradle-bom', + 'grails-gradle-bom-property-overrides', 'grails-gradle-common', 'grails-gradle-model', 'grails-gradle-plugins', diff --git a/grails-gradle/plugins/build.gradle b/grails-gradle/plugins/build.gradle index 405b5d4462e..f657e8e865c 100644 --- a/grails-gradle/plugins/build.gradle +++ b/grails-gradle/plugins/build.gradle @@ -46,6 +46,7 @@ dependencies { implementation project(':grails-gradle-common') implementation project(':grails-gradle-tasks') + implementation project(':grails-gradle-bom-property-overrides') // spock is leaking from the grails-gradle-bom through grails-gradle-model implementation project(':grails-gradle-model'), { @@ -55,7 +56,6 @@ dependencies { implementation "${gradleBomDependencies['grails-publish-plugin']}" implementation 'org.springframework.boot:spring-boot-gradle-plugin' implementation 'org.springframework.boot:spring-boot-loader-tools' - implementation 'io.spring.gradle:dependency-management-plugin' // Testing - Gradle TestKit is auto-added by java-gradle-plugin testImplementation('org.spockframework:spock-core') { transitive = false } diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsExtension.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsExtension.groovy index 418efcf6514..bb1885e4ed6 100644 --- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsExtension.groovy +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsExtension.groovy @@ -82,8 +82,13 @@ class GrailsExtension { List starImports = [] /** - * Whether the spring dependency management plugin should be applied by default + * @deprecated The Spring Dependency Management plugin has been replaced with Gradle's native + * {@code platform()} support plus lightweight property-based version overrides + * supplied by the {@code org.apache.grails.gradle.bom-property-overrides} plugin. + * This property is no longer used. Set version overrides in {@code gradle.properties} + * or via {@code ext['property.name']} instead. */ + @Deprecated boolean springDependencyManagement = true /** diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy index 5e88d856567..e33be0691b1 100644 --- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy @@ -26,8 +26,6 @@ import grails.util.GrailsNameUtils import grails.util.Metadata import groovy.transform.CompileDynamic import groovy.transform.CompileStatic -import io.spring.gradle.dependencymanagement.DependencyManagementPlugin -import io.spring.gradle.dependencymanagement.dsl.DependencyManagementExtension import org.apache.grails.gradle.common.PropertyFileUtils import org.apache.tools.ant.filters.EscapeUnicode import org.apache.tools.ant.filters.ReplaceTokens @@ -64,6 +62,7 @@ import org.gradle.language.jvm.tasks.ProcessResources import org.gradle.process.JavaForkOptions import org.gradle.tooling.provider.model.ToolingModelBuilderRegistry import org.grails.build.parsing.CommandLineParser +import org.grails.gradle.plugin.bom.BomPropertyOverridesPlugin import org.grails.gradle.plugin.commands.ApplicationContextCommandTask import org.grails.gradle.plugin.commands.ApplicationContextScriptTask import org.grails.gradle.plugin.exploded.ExplodedCompatibilityRule @@ -360,19 +359,81 @@ ${importStatements} protected void applyDefaultPlugins(Project project) { applySpringBootPlugin(project) - project.afterEvaluate { - GrailsExtension ge = project.extensions.getByType(GrailsExtension) - if (ge.springDependencyManagement) { - Plugin dependencyManagementPlugin = project.plugins.findPlugin(DependencyManagementPlugin) - if (dependencyManagementPlugin == null) { - project.plugins.apply(DependencyManagementPlugin) - } - - DependencyManagementExtension dme = project.extensions.findByType(DependencyManagementExtension) + applyGrailsBom(project) + } - applyBomImport(dme, project) + /** + * Applies the Grails BOM as a Gradle platform and enables property-based + * version overrides via the standalone + * {@code org.apache.grails.gradle.bom-property-overrides} plugin. + * + *

This replaces the Spring Dependency Management plugin with two + * orthogonal pieces:

+ *
    + *
  1. BOM import: {@code grails-bom} is added as a + * Gradle {@code platform()} dependency on every declarable + * configuration, mirroring the global behaviour Spring DM provided + * via {@code configurations.all() + resolutionStrategy.eachDependency()}.
  2. + *
  3. Property overrides: the BOM-agnostic + * {@link BomPropertyOverridesPlugin} reads the BOM's + * {@code } block and applies any project-level + * overrides via Gradle's + * {@code ResolutionStrategy.eachDependency()}.
  4. + *
+ * + *

Usage: to override a version managed by the Grails or Spring Boot BOM, set the + * corresponding property in {@code gradle.properties} or {@code build.gradle}:

+ *
+     * // gradle.properties
+     * slf4j.version=1.7.36
+     *
+     * // or build.gradle
+     * ext['slf4j.version'] = '1.7.36'
+     * 
+ * + * @see BomPropertyOverridesPlugin + * @since 8.0 + */ + protected void applyGrailsBom(Project project) { + String grailsVersion = (project.findProperty('grailsVersion') ?: BuildSettings.grailsVersion) as String + String bomCoordinates = "org.apache.grails:grails-bom:${grailsVersion}" as String + + // Ensure the developmentOnly configuration exists. Spring Boot's plugin + // normally creates this, but using maybeCreate guarantees it is available + // even if plugin ordering changes or Spring Boot is not applied. + project.configurations.maybeCreate('developmentOnly') + + // Apply the BOM platform to all declarable project configurations, matching + // the behavior of the Spring Dependency Management plugin which applied version + // constraints globally via configurations.all() + resolutionStrategy.eachDependency(). + // Non-declarable configurations (e.g. apiElements, runtimeElements) inherit + // constraints through their parent configurations. Tool/annotation-processor + // configurations are excluded because they hold independent classpaths that + // already use their own platforms (e.g. Micronaut's annotation processors + // import io.micronaut.platform:micronaut-platform). Adding grails-bom as a + // second non-enforced platform on those configurations causes version conflict + // resolution to upgrade transitives and break the tools/processors - unlike + // resolutionStrategy hooks, platform() constraints participate in version + // conflict resolution. + project.configurations.configureEach { Configuration conf -> + if (conf.canBeDeclared && !isExcludedFromBomPlatform(conf.name)) { + project.dependencies.add(conf.name, project.dependencies.platform(bomCoordinates)) } } + + // Delegate property-based version overrides to the standalone plugin. + // Auto-detect picks up the platform(grails-bom) declarations we just + // added, plus any additional platform()/enforcedPlatform() the user + // declares (e.g. grails-micronaut-bom). Users can extend the override + // surface by declaring their own platforms - no extra configuration + // is required here. + project.plugins.apply(BomPropertyOverridesPlugin) + } + + private static boolean isExcludedFromBomPlatform(String name) { + name == 'checkstyle' || name == 'codenarc' || name == 'pmd' || + name == 'spotbugs' || name == 'spotbugsPlugins' || + name == 'annotationProcessor' || name.endsWith('AnnotationProcessor') } protected void applySpringBootPlugin(Project project) { @@ -382,13 +443,6 @@ ${importStatements} } } - @CompileDynamic - private void applyBomImport(DependencyManagementExtension dme, project) { - dme.imports({ - mavenBom("org.apache.grails:grails-bom:${project.properties['grailsVersion']}") - }) - } - protected String getDefaultProfile() { 'web' } @@ -470,6 +524,13 @@ ${importStatements} return } + // The Grails Gradle Plugin injects a regular platform(grails-bom) into every + // declarable configuration via applyGrailsBom(). For Micronaut projects the user + // must additionally declare an enforcedPlatform(grails-micronaut-bom) - a different + // BOM artifact that layers Micronaut-specific overrides on top of grails-bom. We + // scan all grails-micronaut-bom declarations on the 'implementation' configuration + // and accept the configuration as valid when at least one of them is an + // enforcedPlatform. for (Dependency dep : implConfig.dependencies) { if (dep.name == 'grails-micronaut-bom' && dep instanceof ModuleDependency) { Object categoryAttr = ((ModuleDependency) dep).attributes.getAttribute( diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/BomPlatformFunctionalSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/BomPlatformFunctionalSpec.groovy new file mode 100644 index 00000000000..efd0b2ac69e --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/BomPlatformFunctionalSpec.groovy @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.gradle.plugin.core + +/** + * Functional tests for the Gradle platform-based BOM integration. + * + *

Uses Gradle TestKit to verify that the Grails Gradle plugin correctly + * applies {@code grails-bom} as a Gradle {@code platform()} dependency, + * applies the standalone + * {@code org.apache.grails.gradle.bom-property-overrides} plugin, and no + * longer depends on the Spring Dependency Management plugin.

+ * + * @since 8.0 + * @see GrailsGradlePlugin#applyGrailsBom + */ +class BomPlatformFunctionalSpec extends GradleSpecification { + + def "plugin applies grails-bom as Gradle platform, applies bom-property-overrides plugin, and does not apply Spring DM plugin"() { + given: + setupTestResourceProject('bom-platform-basic') + + when: + def result = executeTask('inspectBomSetup') + + then: + result.output.contains('HAS_PLATFORM_BOM=true') + result.output.contains('HAS_BOM_PROPERTY_OVERRIDES=true') + result.output.contains('HAS_SPRING_DM=false') + } +} diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/build.gradle b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/build.gradle new file mode 100644 index 00000000000..be78e2ce8a6 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/build.gradle @@ -0,0 +1,19 @@ +plugins { + id 'org.apache.grails.gradle.grails-app' +} + +tasks.register('inspectBomSetup') { + doLast { + def implDeps = configurations.implementation.allDependencies + def hasPlatform = implDeps.any { dep -> + dep.group == 'org.apache.grails' && dep.name == 'grails-bom' + } + println "HAS_PLATFORM_BOM=${hasPlatform}" + + def hasBomPropertyOverrides = project.plugins.findPlugin('org.apache.grails.gradle.bom-property-overrides') != null + println "HAS_BOM_PROPERTY_OVERRIDES=${hasBomPropertyOverrides}" + + def hasSpringDm = project.plugins.findPlugin('io.spring.dependency-management') != null + println "HAS_SPRING_DM=${hasSpringDm}" + } +} diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/gradle.properties b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/gradle.properties new file mode 100644 index 00000000000..35c332fb874 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/gradle.properties @@ -0,0 +1 @@ +grailsVersion=__PROJECT_VERSION__ diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/grails-app/conf/application.yml b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/grails-app/conf/application.yml new file mode 100644 index 00000000000..4706b4393fd --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/grails-app/conf/application.yml @@ -0,0 +1,2 @@ +grails: + profile: web diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/settings.gradle b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/settings.gradle new file mode 100644 index 00000000000..b2a1c27a425 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'test-bom-platform' diff --git a/grails-gradle/settings.gradle b/grails-gradle/settings.gradle index 406ae33b6b6..fc1a9e16dd3 100644 --- a/grails-gradle/settings.gradle +++ b/grails-gradle/settings.gradle @@ -78,3 +78,6 @@ project(':grails-gradle-model').projectDir = file('model') include 'grails-gradle-tasks' project(':grails-gradle-tasks').projectDir = file('tasks') + +include 'grails-gradle-bom-property-overrides' +project(':grails-gradle-bom-property-overrides').projectDir = file('bom-property-overrides') diff --git a/grails-profiles/plugin/templates/grailsCentralPublishing.gradle b/grails-profiles/plugin/templates/grailsCentralPublishing.gradle index e9f5c8cafec..e7cbc1abf44 100644 --- a/grails-profiles/plugin/templates/grailsCentralPublishing.gradle +++ b/grails-profiles/plugin/templates/grailsCentralPublishing.gradle @@ -7,7 +7,7 @@ publishing { // simply remove dependencies without a version // version-less dependencies are handled with dependencyManagement - // see https://github.com/spring-gradle-plugins/dependency-management-plugin/issues/8 for more complete solutions + // remove version-less dependencies since versions are managed by the Grails BOM platform pomNode.dependencies.dependency.findAll { it.version.text().isEmpty() }.each { diff --git a/grails-test-examples/gsp-spring-boot/app/build.gradle b/grails-test-examples/gsp-spring-boot/app/build.gradle index 3236d089a37..7cae3b48ccb 100644 --- a/grails-test-examples/gsp-spring-boot/app/build.gradle +++ b/grails-test-examples/gsp-spring-boot/app/build.gradle @@ -21,7 +21,6 @@ plugins { id 'java' id 'war' id 'org.springframework.boot' - id 'io.spring.dependency-management' id "groovy" id 'org.apache.grails.buildsrc.dependency-validator' }