From ca495ab5287aeba5d2300a997a6c24145e866bc4 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 26 Feb 2026 11:25:11 -0500 Subject: [PATCH 1/3] feat: replace Spring Dependency Management plugin with Gradle platform and lightweight BOM property overrides Replace the Spring Dependency Management Gradle plugin with Gradle's native platform() support plus a lightweight BomManagedVersions utility that preserves the ability to override BOM-managed dependency versions via project properties (ext[] or gradle.properties). This allows Grails to standardize on Gradle platforms - the modern dependency management solution - while retaining the one feature Gradle platforms lack: property-based version overrides from BOMs. Changes: - Add BomManagedVersions: parses BOM POM XML to extract property-to- artifact mappings, applies version overrides via eachDependency() - Update GrailsGradlePlugin to use platform() + BomManagedVersions instead of Spring DM plugin - Deprecate GrailsExtension.springDependencyManagement flag - Remove Spring DM plugin from plugins/build.gradle dependency - Remove Spring DM plugin from example projects - Update documentation to reflect Gradle platform approach - Add unit tests (BomManagedVersionsSpec) and functional test (BomPlatformFunctionalSpec) Note: build-logic/docs-core/ExtractDependenciesTask still uses Spring DM's shaded Maven model classes and should be addressed in a follow-up. Assisted-by: Claude Code --- grails-bom/build.gradle | 2 +- .../examples/spring-boot-app/build.gradle | 1 - .../gradleBuild/gradleDependencies.adoc | 26 +- grails-gradle/plugins/build.gradle | 1 - .../plugin/core/BomManagedVersions.groovy | 354 ++++++++++++++++++ .../gradle/plugin/core/GrailsExtension.groovy | 7 +- .../plugin/core/GrailsGradlePlugin.groovy | 55 ++- .../plugin/core/BomManagedVersionsSpec.groovy | 97 +++++ .../core/BomPlatformFunctionalSpec.groovy | 45 +++ .../src/test/resources/test-poms/test-bom.pom | 51 +++ .../bom-platform-basic/build.gradle | 16 + .../bom-platform-basic/gradle.properties | 1 + .../grails-app/conf/application.yml | 2 + .../bom-platform-basic/settings.gradle | 1 + .../templates/grailsCentralPublishing.gradle | 2 +- .../gsp-spring-boot/app/build.gradle | 1 - 16 files changed, 624 insertions(+), 38 deletions(-) create mode 100644 grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/BomManagedVersions.groovy create mode 100644 grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/BomManagedVersionsSpec.groovy create mode 100644 grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/BomPlatformFunctionalSpec.groovy create mode 100644 grails-gradle/plugins/src/test/resources/test-poms/test-bom.pom create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/build.gradle create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/gradle.properties create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/grails-app/conf/application.yml create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/settings.gradle diff --git a/grails-bom/build.gradle b/grails-bom/build.gradle index 260239fcedd..cd38ef236e6 100644 --- a/grails-bom/build.gradle +++ b/grails-bom/build.gradle @@ -210,7 +210,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 6c113e1be82..0c64d390e2c 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' dependencies { implementation platform(project(':grails-bom')) diff --git a/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc b/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc index 31a1ffe6b3e..348689f4a0b 100644 --- a/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc +++ b/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc @@ -60,13 +60,22 @@ 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. + +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' +---- For a Grails App, applying `org.apache.grails.gradle.grails-web` will automatically configure the `grails-bom`. No other steps required. -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. +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 { @@ -74,14 +83,3 @@ dependencies { //... } ---- - -build.gradle, using Spring dependency management plugin: -[source,groovy] ----- -dependencyManagement { - imports { - mavenBom 'org.apache.grails:grails-bom:{GrailsVersion}' - } - applyMavenExclusions false -} ----- diff --git a/grails-gradle/plugins/build.gradle b/grails-gradle/plugins/build.gradle index cedf995b7aa..88dff223b86 100644 --- a/grails-gradle/plugins/build.gradle +++ b/grails-gradle/plugins/build.gradle @@ -55,7 +55,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/BomManagedVersions.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/BomManagedVersions.groovy new file mode 100644 index 00000000000..b1f708478c7 --- /dev/null +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/BomManagedVersions.groovy @@ -0,0 +1,354 @@ +/* + * 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 + +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).

+ * + * @since 8.0 + */ +@CompileStatic +class BomManagedVersions { + + private static final Logger LOG = Logging.getLogger(BomManagedVersions) + + 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) { + BomManagedVersions instance = new BomManagedVersions() + + String[] parts = bomCoordinates.split(':') + if (parts.length != 3) { + LOG.warn('Invalid BOM coordinates: {}', bomCoordinates) + return instance + } + + Map bomProperties = new LinkedHashMap<>() + Map> propertyToArtifacts = new LinkedHashMap<>() + + processBom(project, parts[0], parts[1], parts[2], bomProperties, propertyToArtifacts, new HashSet()) + + 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( + 'Grails BOM version override: {} = {} (BOM default: {})', + propertyName, overrideVersion, defaultVersion ?: 'unknown' + ) + } + } + } + + if (!instance.versionOverrides.isEmpty()) { + LOG.info('Grails BOM: {} 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('Grails 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.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 = 10 + 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/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 c216885913d..82ce70830cf 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 @@ -83,8 +83,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. + * This property is no longer used. Set version overrides in {@code gradle.properties} + * or via {@code ext['property.name']} instead. + * @see BomManagedVersions */ + @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 bdfcc62c318..72ac323722c 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 @@ -355,17 +353,45 @@ ${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) - } + applyGrailsBom(project) + } - DependencyManagementExtension dme = project.extensions.findByType(DependencyManagementExtension) + /** + * Applies the Grails BOM as a Gradle platform and configures property-based + * version overrides. This replaces the Spring Dependency Management plugin with + * a lightweight mechanism that: + *
    + *
  1. Imports {@code grails-bom} via Gradle's native {@code platform()} support
  2. + *
  3. Parses the BOM POM chain to discover which Maven properties control which artifact versions
  4. + *
  5. Checks project properties ({@code gradle.properties} or {@code ext['property.name']}) for overrides
  6. + *
  7. Applies any overrides via {@code ResolutionStrategy.eachDependency()}
  8. + *
+ * + *

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 BomManagedVersions + * @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 + + project.dependencies.add('implementation', project.dependencies.platform(bomCoordinates)) - applyBomImport(dme, project) + project.afterEvaluate { + BomManagedVersions managedVersions = BomManagedVersions.resolve(project, bomCoordinates) + if (managedVersions.hasOverrides()) { + project.configurations.configureEach { Configuration conf -> + managedVersions.applyTo(conf) + } } } } @@ -377,13 +403,6 @@ ${importStatements} } } - @CompileDynamic - private void applyBomImport(DependencyManagementExtension dme, project) { - dme.imports({ - mavenBom("org.apache.grails:grails-bom:${project.properties['grailsVersion']}") - }) - } - protected String getDefaultProfile() { 'web' } diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/BomManagedVersionsSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/BomManagedVersionsSpec.groovy new file mode 100644 index 00000000000..6953c1ba3eb --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/BomManagedVersionsSpec.groovy @@ -0,0 +1,97 @@ +/* + * 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 + +import spock.lang.Specification + +/** + * Tests for the Grails BOM platform integration that replaced the + * Spring Dependency Management plugin. + * + *

Verifies that {@link BomManagedVersions} correctly parses BOM POM + * files, extracts property-to-artifact mappings, and that the Grails + * Gradle plugin applies {@code grails-bom} as a Gradle + * {@code platform()} dependency.

+ * + * @since 8.0 + * @see BomManagedVersions + * @see GrailsGradlePlugin#applyGrailsBom + */ +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/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..4e1a5ba3d67 --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/BomPlatformFunctionalSpec.groovy @@ -0,0 +1,45 @@ +/* + * 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 + * and no longer depends on the Spring Dependency Management plugin.

+ * + * @since 8.0 + * @see BomManagedVersions + * @see GrailsGradlePlugin#applyGrailsBom + */ +class BomPlatformFunctionalSpec extends GradleSpecification { + + def "plugin applies grails-bom as Gradle platform 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_SPRING_DM=false') + } +} diff --git a/grails-gradle/plugins/src/test/resources/test-poms/test-bom.pom b/grails-gradle/plugins/src/test/resources/test-poms/test-bom.pom new file mode 100644 index 00000000000..cda1a266adb --- /dev/null +++ b/grails-gradle/plugins/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/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..9b58b700622 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/build.gradle @@ -0,0 +1,16 @@ +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 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-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 c3187fecfaf..81338eab00d 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" } From 531041ba9271db91310b82b710961fc695fb4fbe Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 26 Feb 2026 11:57:11 -0500 Subject: [PATCH 2/3] Address review: add XInclude hardening and extract interpolation depth constant Add factory.setXIncludeAware(false) for explicit XML security hardening and extract magic number 10 to MAX_PROPERTY_INTERPOLATION_DEPTH constant. Assisted-by: Claude Code --- .../org/grails/gradle/plugin/core/BomManagedVersions.groovy | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/BomManagedVersions.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/BomManagedVersions.groovy index b1f708478c7..e022f947cdd 100644 --- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/BomManagedVersions.groovy +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/BomManagedVersions.groovy @@ -52,6 +52,7 @@ import javax.xml.parsers.DocumentBuilderFactory 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<>() @@ -230,6 +231,7 @@ class BomManagedVersions { 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) @@ -329,7 +331,7 @@ class BomManagedVersions { } String result = value - int maxIterations = 10 + int maxIterations = MAX_PROPERTY_INTERPOLATION_DEPTH while (result.contains('${') && maxIterations-- > 0) { String propertyName = extractPropertyName(result) if (propertyName == null) { From 5e89656e46e92b5fc8573162598557cb3c4dcb6c Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 26 Feb 2026 15:22:45 -0500 Subject: [PATCH 3/3] fix: apply grails-bom platform to all declarable configurations The Spring Dependency Management plugin applied version constraints globally to every configuration via configurations.all() and resolutionStrategy.eachDependency(). With the switch to Gradle's native platform(), version constraints must be added explicitly. Apply the grails-bom platform to all declarable configurations using configureEach, matching the previous global behavior. Non-declarable configurations (apiElements, runtimeElements, etc.) inherit constraints through their parent configurations. Code quality tool configurations (checkstyle, codenarc, etc.) are excluded because platform() constraints participate in version conflict resolution and can upgrade transitive dependencies, breaking the tools. Also ensure the developmentOnly configuration always exists via maybeCreate. Assisted-by: Claude Code --- .../plugin/core/GrailsGradlePlugin.groovy | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) 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 72ac323722c..4f065286f37 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 @@ -384,7 +384,25 @@ ${importStatements} String grailsVersion = (project.findProperty('grailsVersion') ?: BuildSettings.grailsVersion) as String String bomCoordinates = "org.apache.grails:grails-bom:${grailsVersion}" as String - project.dependencies.add('implementation', project.dependencies.platform(bomCoordinates)) + // 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. Code quality tool + // configurations (checkstyle, codenarc, etc.) are excluded because adding BOM + // constraints to tool classpaths can upgrade transitive dependencies and break + // the tools - unlike resolutionStrategy hooks, platform() constraints + // participate in version conflict resolution. + project.configurations.configureEach { Configuration conf -> + if (conf.canBeDeclared && !isCodeQualityConfiguration(conf.name)) { + project.dependencies.add(conf.name, project.dependencies.platform(bomCoordinates)) + } + } project.afterEvaluate { BomManagedVersions managedVersions = BomManagedVersions.resolve(project, bomCoordinates) @@ -396,6 +414,17 @@ ${importStatements} } } + /** + * Returns {@code true} if the given configuration name belongs to a code quality + * tool (Checkstyle, CodeNarc, PMD, SpotBugs). These configurations hold tool + * classpaths and must not receive the BOM platform because {@code platform()} + * constraints can upgrade transitive dependencies and break the tools. + */ + private static boolean isCodeQualityConfiguration(String name) { + name == 'checkstyle' || name == 'codenarc' || name == 'pmd' || + name == 'spotbugs' || name == 'spotbugsPlugins' + } + protected void applySpringBootPlugin(Project project) { def springBoot = project.extensions.findByType(SpringBootExtension) if (!springBoot) {