From ca495ab5287aeba5d2300a997a6c24145e866bc4 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 26 Feb 2026 11:25:11 -0500 Subject: [PATCH 01/23] 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 02/23] 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 03/23] 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) { From 77932f2e2f9c65c97f5c6d9590fe8b9e23f947d4 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 16 Apr 2026 14:16:24 -0400 Subject: [PATCH 04/23] fix: allow Micronaut BOM validator to coexist with plugin-injected platform applyGrailsBom() adds a regular platform(grails-bom) to every declarable configuration, including 'implementation'. Micronaut projects still need to declare an enforcedPlatform(grails-bom) explicitly. With both present on the same configuration, validateEnforcedBom() was failing on the first grails-bom it encountered (the plugin-injected regular platform) before it could check the user's enforcedPlatform declaration. Scan all grails-bom declarations on 'implementation' and accept the project as correctly configured if any one of them is an enforcedPlatform. Only error when grails-bom is present but no enforcedPlatform exists. Assisted-by: claude-code:claude-opus-4 --- .../plugin/core/GrailsGradlePlugin.groovy | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) 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 f91411e9de7..d6a9a1cca53 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 @@ -520,22 +520,33 @@ ${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-bom). Because both + // dependencies co-exist on the 'implementation' configuration we must scan all + // grails-bom declarations and accept the configuration as valid when at least one + // of them is an enforcedPlatform. + boolean grailsBomPresent = false for (Dependency dep : implConfig.dependencies) { if (dep.name == 'grails-bom' && dep instanceof ModuleDependency) { + grailsBomPresent = true Object categoryAttr = ((ModuleDependency) dep).attributes.getAttribute( org.gradle.api.attributes.Category.CATEGORY_ATTRIBUTE ) if (categoryAttr != null && categoryAttr.toString() == org.gradle.api.attributes.Category.ENFORCED_PLATFORM) { return // correctly configured } - throw new GradleException( - "Project '${project.name}' uses Micronaut but applies grails-bom as a regular platform. " + - 'Micronaut\'s platform declares higher versions of Groovy, Spock, and Kotlin that will ' + - 'override the grails-bom via conflict resolution. Change to:\n\n' + - ' implementation enforcedPlatform(project(\':grails-bom\'))\n' - ) } } + + if (grailsBomPresent) { + throw new GradleException( + "Project '${project.name}' uses Micronaut but applies grails-bom as a regular platform. " + + 'Micronaut\'s platform declares higher versions of Groovy, Spock, and Kotlin that will ' + + 'override the grails-bom via conflict resolution. Change to:\n\n' + + ' implementation enforcedPlatform(project(\':grails-bom\'))\n' + ) + } } @CompileStatic From 54fbe932fbb052a4f44a96519513fb27ee1d17fa Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 16 Apr 2026 15:06:54 -0400 Subject: [PATCH 05/23] fix: exclude annotation-processor configurations from grails-bom platform applyGrailsBom() adds a regular platform(grails-bom) to every declarable configuration. For annotation-processor classpaths this conflicts with the platform the user imports themselves (typically io.micronaut.platform:micronaut-platform for Micronaut projects). Since platform() constraints participate in Gradle version conflict resolution, grails-bom's higher javaparser-core version wins over what Micronaut's inject-java processor was compiled against, producing NoSuchMethodError on StaticJavaParser.parseJavadoc(String) during compileJava. Exclude annotationProcessor and *AnnotationProcessor configurations using the same mechanism that already excludes code-quality tool classpaths (checkstyle, codenarc, pmd, spotbugs). Rename the helper to reflect the broadened scope. Assisted-by: claude-code:claude-opus-4 --- .../plugin/core/GrailsGradlePlugin.groovy | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) 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 d6a9a1cca53..a2f8e01b595 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 @@ -392,13 +392,16 @@ ${importStatements} // 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. + // 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 && !isCodeQualityConfiguration(conf.name)) { + if (conf.canBeDeclared && !isExcludedFromBomPlatform(conf.name)) { project.dependencies.add(conf.name, project.dependencies.platform(bomCoordinates)) } } @@ -413,15 +416,10 @@ ${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) { + private static boolean isExcludedFromBomPlatform(String name) { name == 'checkstyle' || name == 'codenarc' || name == 'pmd' || - name == 'spotbugs' || name == 'spotbugsPlugins' + name == 'spotbugs' || name == 'spotbugsPlugins' || + name == 'annotationProcessor' || name.endsWith('AnnotationProcessor') } protected void applySpringBootPlugin(Project project) { From 2ca302b164aeef5c4d7837ff291ac0832d6344e0 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Wed, 29 Apr 2026 16:24:27 -0400 Subject: [PATCH 06/23] Extract BOM property overrides into a standalone Gradle plugin (PR #15467 review) Addresses jdaugherty's review feedback on PR #15467: 1. The property-override mechanism (formerly inlined in GrailsGradlePlugin.applyGrailsBom) is now published as a standalone, BOM-agnostic plugin: org.apache.grails.gradle.bom-property-overrides (new grails-gradle-bom-property-overrides module). The plugin: - Lives in its own subproject so it can be maintained / consumed independently of the Grails BOM - Auto-detects every platform()/enforcedPlatform() declaration on the project's configurations and registers each unique BOM for property-override processing - Exposes a 'bomPropertyOverrides' extension with autoDetect (default: true) and an explicit boms list for advanced cases - Re-uses the BomManagedVersions utility (moved to org.grails.gradle.plugin.bom) which now accepts a Collection of BOM coordinates GrailsGradlePlugin.applyGrailsBom now applies the new plugin in place of the inline BomManagedVersions usage; the auto-detect logic picks up the platform(grails-bom) we declare plus any additional platforms (e.g. enforcedPlatform(grails-micronaut-bom)) the user adds. 2. Documentation (grails-doc/.../gradleDependencies.adoc) is updated to: - Clarify that property overrides are a feature of the bom-property-overrides plugin (not native Gradle), with a link to gradle/gradle#9160 - Note Gradle's highest-version-wins conflict resolution and the difference vs the legacy Spring Dependency Management plugin (which forced BOM versions unconditionally) - Document the new plugin's standalone usage for non-Grails projects with the bomPropertyOverrides extension API Tests: - BomManagedVersionsSpec moved to the new module (4 tests, all passing) - New BomPropertyOverridesPluginSpec (7 ProjectBuilder tests covering plugin application, extension defaults, DSL methods, and detectDeclaredBoms) - New BomPropertyOverridesPluginFunctionalSpec (TestKit end-to-end test verifying standalone plugin application) - BomPlatformFunctionalSpec updated to also verify the bom-property-overrides plugin is applied All 13 plugin tests + 12 bom-property-overrides tests pass; codeStyle clean on both modules. Assisted-by: claude-code:claude-opus-4-7 --- .../gradleBuild/gradleDependencies.adoc | 46 ++++- .../bom-property-overrides/build.gradle | 81 +++++++++ .../plugin/bom}/BomManagedVersions.groovy | 44 +++-- .../bom/BomPropertyOverridesExtension.groovy | 104 +++++++++++ .../bom/BomPropertyOverridesPlugin.groovy | 167 ++++++++++++++++++ .../plugin/bom}/BomManagedVersionsSpec.groovy | 14 +- ...opertyOverridesPluginFunctionalSpec.groovy | 49 +++++ .../bom/BomPropertyOverridesPluginSpec.groovy | 146 +++++++++++++++ .../plugin/bom/GradleSpecification.groovy | 127 +++++++++++++ .../src/test/resources/test-poms/test-bom.pom | 0 .../bom-property-overrides-basic/build.gradle | 22 +++ .../gradle.properties | 1 + .../settings.gradle | 1 + .../gradle/publish-root-config.gradle | 1 + grails-gradle/plugins/build.gradle | 1 + .../gradle/plugin/core/GrailsExtension.groovy | 4 +- .../plugin/core/GrailsGradlePlugin.groovy | 40 +++-- .../core/BomPlatformFunctionalSpec.groovy | 10 +- .../bom-platform-basic/build.gradle | 3 + grails-gradle/settings.gradle | 3 + 20 files changed, 822 insertions(+), 42 deletions(-) create mode 100644 grails-gradle/bom-property-overrides/build.gradle rename grails-gradle/{plugins/src/main/groovy/org/grails/gradle/plugin/core => bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom}/BomManagedVersions.groovy (88%) create mode 100644 grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesExtension.groovy create mode 100644 grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy rename grails-gradle/{plugins/src/test/groovy/org/grails/gradle/plugin/core => bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom}/BomManagedVersionsSpec.groovy (88%) create mode 100644 grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginFunctionalSpec.groovy create mode 100644 grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginSpec.groovy create mode 100644 grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/GradleSpecification.groovy rename grails-gradle/{plugins => bom-property-overrides}/src/test/resources/test-poms/test-bom.pom (100%) create mode 100644 grails-gradle/bom-property-overrides/src/test/resources/test-projects/bom-property-overrides-basic/build.gradle create mode 100644 grails-gradle/bom-property-overrides/src/test/resources/test-projects/bom-property-overrides-basic/gradle.properties create mode 100644 grails-gradle/bom-property-overrides/src/test/resources/test-projects/bom-property-overrides-basic/settings.gradle diff --git a/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc b/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc index 348689f4a0b..2c5ac02ff43 100644 --- a/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc +++ b/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc @@ -62,6 +62,8 @@ Note that version numbers are not present in the majority of the dependencies. 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. +==== Overriding Managed Versions + To override a managed version, set the corresponding property in `gradle.properties` or `build.gradle`: [source,groovy] ---- @@ -72,7 +74,16 @@ slf4j.version=1.7.36 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. +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: @@ -83,3 +94,36 @@ dependencies { //... } ---- + +==== 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] +---- +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/plugins/src/main/groovy/org/grails/gradle/plugin/core/BomManagedVersions.groovy b/grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy similarity index 88% rename from grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/BomManagedVersions.groovy rename to grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy index e022f947cdd..20e92b543e4 100644 --- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/BomManagedVersions.groovy +++ b/grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.grails.gradle.plugin.core +package org.grails.gradle.plugin.bom import groovy.transform.CompileStatic import org.gradle.api.Project @@ -46,6 +46,11 @@ import javax.xml.parsers.DocumentBuilderFactory * 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 @@ -65,18 +70,35 @@ class BomManagedVersions { * @return a BomManagedVersions instance containing any version overrides to apply */ static BomManagedVersions resolve(Project project, String bomCoordinates) { - BomManagedVersions instance = new BomManagedVersions() + return resolve(project, [bomCoordinates]) + } - String[] parts = bomCoordinates.split(':') - if (parts.length != 3) { - LOG.warn('Invalid BOM coordinates: {}', bomCoordinates) - return instance - } + /** + * 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<>() - processBom(project, parts[0], parts[1], parts[2], bomProperties, propertyToArtifacts, 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 @@ -89,7 +111,7 @@ class BomManagedVersions { instance.versionOverrides.put(artifactKey, overrideVersion) } LOG.lifecycle( - 'Grails BOM version override: {} = {} (BOM default: {})', + 'BOM version override: {} = {} (BOM default: {})', propertyName, overrideVersion, defaultVersion ?: 'unknown' ) } @@ -97,7 +119,7 @@ class BomManagedVersions { } if (!instance.versionOverrides.isEmpty()) { - LOG.info('Grails BOM: {} version override(s) will be applied', instance.versionOverrides.size()) + LOG.info('BOM property overrides: {} version override(s) will be applied', instance.versionOverrides.size()) } return instance @@ -119,7 +141,7 @@ class BomManagedVersions { String override = overrides.get(key) if (override != null) { details.useVersion(override) - details.because('Grails BOM version override via project property') + details.because('BOM version override via project property') } } } 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/plugins/src/test/groovy/org/grails/gradle/plugin/core/BomManagedVersionsSpec.groovy b/grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomManagedVersionsSpec.groovy similarity index 88% rename from grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/BomManagedVersionsSpec.groovy rename to grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomManagedVersionsSpec.groovy index 6953c1ba3eb..9840c4b90d4 100644 --- a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/BomManagedVersionsSpec.groovy +++ b/grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomManagedVersionsSpec.groovy @@ -16,22 +16,20 @@ * specific language governing permissions and limitations * under the License. */ -package org.grails.gradle.plugin.core +package org.grails.gradle.plugin.bom import spock.lang.Specification /** - * Tests for the Grails BOM platform integration that replaced the - * Spring Dependency Management plugin. + * Unit tests for {@link BomManagedVersions}. * - *

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.

+ *

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 - * @see GrailsGradlePlugin#applyGrailsBom */ class BomManagedVersionsSpec extends Specification { 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/plugins/src/test/resources/test-poms/test-bom.pom b/grails-gradle/bom-property-overrides/src/test/resources/test-poms/test-bom.pom similarity index 100% rename from grails-gradle/plugins/src/test/resources/test-poms/test-bom.pom rename to grails-gradle/bom-property-overrides/src/test/resources/test-poms/test-bom.pom 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 9705085d7de..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'), { 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 16d7ff1bfcd..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 @@ -83,10 +83,10 @@ class GrailsExtension { /** * @deprecated The Spring Dependency Management plugin has been replaced with Gradle's native - * {@code platform()} support plus lightweight property-based version overrides. + * {@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. - * @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 ee609dff0fe..b58898321bf 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 @@ -62,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 @@ -356,14 +357,22 @@ ${importStatements} } /** - * 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: + * 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. 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. + *
  9. 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()}.
  10. + *
  11. 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()}.
  12. *
* *

Usage: to override a version managed by the Grails or Spring Boot BOM, set the @@ -376,7 +385,7 @@ ${importStatements} * ext['slf4j.version'] = '1.7.36' * * - * @see BomManagedVersions + * @see BomPropertyOverridesPlugin * @since 8.0 */ protected void applyGrailsBom(Project project) { @@ -406,14 +415,13 @@ ${importStatements} } } - project.afterEvaluate { - BomManagedVersions managedVersions = BomManagedVersions.resolve(project, bomCoordinates) - if (managedVersions.hasOverrides()) { - project.configurations.configureEach { Configuration conf -> - managedVersions.applyTo(conf) - } - } - } + // 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) { 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 index 4e1a5ba3d67..efd0b2ac69e 100644 --- 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 @@ -22,16 +22,17 @@ 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.

+ * 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 BomManagedVersions * @see GrailsGradlePlugin#applyGrailsBom */ class BomPlatformFunctionalSpec extends GradleSpecification { - def "plugin applies grails-bom as Gradle platform and does not apply Spring DM plugin"() { + 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') @@ -40,6 +41,7 @@ class BomPlatformFunctionalSpec extends GradleSpecification { 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 index 9b58b700622..be78e2ce8a6 100644 --- 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 @@ -10,6 +10,9 @@ tasks.register('inspectBomSetup') { } 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/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') From bbf5e5320c72c762b9cb1ef56961007fc0300fdb Mon Sep 17 00:00:00 2001 From: James Fredley Date: Wed, 29 Apr 2026 17:23:36 -0400 Subject: [PATCH 07/23] Fix GrailsDependencyValidatorPlugin to prefer enforcedPlatform BOM After the 8.0.x merge, grails-app projects now auto-inject platform(grails-bom) on every declarable configuration, while Micronaut test projects additionally declare enforcedPlatform(grails-micronaut-bom). The previous detectBomPath() returned the first BOM dependency it encountered, which after iteration order changes ends up being grails-bom, causing the validator to compare resolved versions (Groovy 5.0.5 / Spock 2.4-groovy-5.0 from Micronaut's enforced platform) against grails-bom's expected versions (Groovy 4.0.31 / Spock 2.3-groovy-4.0). detectBomPath now prefers an enforcedPlatform declaration over a regular platform: it returns the first enforcedPlatform BOM it finds, falling back to the first regular platform BOM only if no enforced declaration exists. The enforced BOM is the one whose constraints actually win at resolution time, so it is the correct reference for the validator's expected versions. Fixes the failing :grails-test-examples-issue-11767:validateDependencyVersions and :grails-test-examples-micronaut-groovy-only:validateDependencyVersions tasks on PR #15467 CI. Assisted-by: claude-code:claude-opus-4-7 --- .../GrailsDependencyValidatorPlugin.groovy | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) 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 } /** From 525409791329c281a657409956de95cc1ba6f634 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 9 May 2026 21:04:28 -0400 Subject: [PATCH 08/23] Address PR #15467 cosmetic review comments from matrei and jdaugherty - bom-property-overrides/build.gradle: - Break long pomDescription line for readability (matrei) - Expand comment on compileOnly groovy to clarify Gradle-supplied runtime + Spock transitive=false rationale (matrei, jdaugherty) - Use parentheses for testCompileClasspath/testRuntimeClasspath exclude method calls (matrei) - Use plugins { register('bomPropertyOverrides') {} } for IDE type hints (matrei) - Use tasks.withType(Copy).configureEach instead of nested configure block (matrei) - BomPropertyOverridesExtension.groovy: add ObjectFactory import instead of inline FQN (matrei) - GrailsGradlePlugin.groovy: - Remove empty line in applyDefaultPlugins (matrei) - Fix outdated 'every declarable configuration' comment in validateMicronautBom; clarify that code-quality and annotation-processor classpaths are excluded (jdaugherty) - gradleDependencies.adoc: split the build.gradle ext[...] example from the gradle.properties example so the syntax difference is unambiguous; add missing comma after 'By default' (matrei) No behavior change; verified with :grails-gradle-bom-property-overrides:test (all 12 tests pass), :grails-gradle-plugins:test --tests *BomPlatformFunctionalSpec* (passes), and codeStyle on both modules. Assisted-by: claude-code:claude-opus-4-7 --- .../gradleBuild/gradleDependencies.adoc | 11 ++++++-- .../bom-property-overrides/build.gradle | 25 +++++++++++-------- .../bom/BomPropertyOverridesExtension.groovy | 3 ++- .../plugin/core/GrailsGradlePlugin.groovy | 16 ++++++------ 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc b/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc index 2c5ac02ff43..24143fbd8b2 100644 --- a/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc +++ b/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc @@ -110,11 +110,18 @@ dependencies { implementation platform('com.example:my-bom:1.0.0') } -// gradle.properties or build.gradle +// In 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: +The same override can also be set in `gradle.properties` (note: `gradle.properties` only supports the `name=value` form, not the `ext[...]` syntax): + +[source,properties] +---- +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] ---- diff --git a/grails-gradle/bom-property-overrides/build.gradle b/grails-gradle/bom-property-overrides/build.gradle index af4a5df3fa1..8dbf7640a98 100644 --- a/grails-gradle/bom-property-overrides/build.gradle +++ b/grails-gradle/bom-property-overrides/build.gradle @@ -33,15 +33,22 @@ 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.' + 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 + // Compile against the Groovy version provided at runtime by the Gradle distribution + // running the build (the Gradle/Groovy compatibility matrix is documented at + // https://docs.gradle.org/current/userguide/compatibility.html#groovy). The `groovy` + // module is intentionally compileOnly: at runtime Gradle supplies its own copy and + // testCompileClasspath/testRuntimeClasspath excludes below prevent the Spock + // transitive copy from leaking onto the test classpath. compileOnly 'org.apache.groovy:groovy' // Testing - Gradle TestKit is auto-added by java-gradle-plugin @@ -51,13 +58,13 @@ dependencies { } configurations { - testCompileClasspath.exclude group: 'org.apache.groovy', module: 'groovy' - testRuntimeClasspath.exclude group: 'org.apache.groovy', module: 'groovy' + testCompileClasspath.exclude(group: 'org.apache.groovy', module: 'groovy') + testRuntimeClasspath.exclude(group: 'org.apache.groovy', module: 'groovy') } gradlePlugin { plugins { - bomPropertyOverrides { + register('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\']. ' + @@ -69,10 +76,8 @@ gradlePlugin { } } -tasks.withType(Copy) { - configure { - duplicatesStrategy = DuplicatesStrategy.INCLUDE - } +tasks.withType(Copy).configureEach { + duplicatesStrategy = DuplicatesStrategy.INCLUDE } apply { 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 index 01e01b7d178..55e9f2d45cc 100644 --- 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 @@ -19,6 +19,7 @@ package org.grails.gradle.plugin.bom import groovy.transform.CompileStatic +import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property @@ -77,7 +78,7 @@ class BomPropertyOverridesExtension { */ final ListProperty boms - BomPropertyOverridesExtension(org.gradle.api.model.ObjectFactory objects) { + BomPropertyOverridesExtension(ObjectFactory objects) { this.autoDetect = objects.property(Boolean).convention(true) this.boms = objects.listProperty(String).convention([]) } 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 e33be0691b1..f240aa58080 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 @@ -358,7 +358,6 @@ ${importStatements} protected void applyDefaultPlugins(Project project) { applySpringBootPlugin(project) - applyGrailsBom(project) } @@ -524,13 +523,14 @@ ${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. + // The Grails Gradle Plugin injects a regular platform(grails-bom) into each + // declarable configuration via applyGrailsBom(), excluding code-quality and + // annotation-processor classpaths (see isExcludedFromBomPlatform). 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( From 794843209621081c463e13aaa7bffae34a465167 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 9 May 2026 21:05:42 -0400 Subject: [PATCH 09/23] Apply matrei's wording suggestion for grails-bom intro Combine the 'version numbers not present' note with the explanation that follows into a single paragraph, and rephrase to credit the Grails Gradle Plugin directly (per matrei's review on PR #15467). Also restructure the 'Overriding Managed Versions' example so the build.gradle ext[] syntax and the gradle.properties name=value syntax are shown as separate examples to avoid the misleading 'gradle.properties or build.gradle' framing. Assisted-by: claude-code:claude-opus-4-7 --- .../gradleBuild/gradleDependencies.adoc | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc b/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc index 24143fbd8b2..0c8ca435550 100644 --- a/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc +++ b/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc @@ -58,22 +58,24 @@ dependencies { } ---- -Note that version numbers are not present in the majority of the dependencies. - -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. +Note that version numbers are not present in the majority of the dependencies. This is thanks to the Grails Gradle Plugin, which adds `grails-bom` as a managed dependency platform. The BOM defines the default versions for most commonly used Grails dependencies and plugins. ==== Overriding Managed Versions -To override a managed version, set the corresponding property in `gradle.properties` or `build.gradle`: +To override a managed version, set the corresponding property in `build.gradle`: + [source,groovy] ---- -// gradle.properties -slf4j.version=1.7.36 - -// or build.gradle ext['slf4j.version'] = '1.7.36' ---- +The same override can be set in `gradle.properties` (note: `gradle.properties` only supports the `name=value` form, not the `ext[...]` syntax): + +[source,properties] +---- +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] From d67ef2840517dc5e2954bd98e6cf3d22cb4d16a6 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 21 May 2026 17:14:23 -0400 Subject: [PATCH 10/23] Address review feedback: consolidate bom-property-overrides + def style + drop duplicates workaround Three review threads addressed: 1. jdaugherty: "this should be under the Gradle package to match our other plugins" The standalone grails-gradle-bom-property-overrides module is merged into grails-gradle-plugins, joining the 11 other plugins registered there. The plugin id (org.apache.grails.gradle.bom-property-overrides), extension name (bomPropertyOverrides) and implementation class (org.grails.gradle.plugin.bom.BomPropertyOverridesPlugin) are unchanged, so end-user usage is identical. - Move 3 main sources + 3 test sources + 4 test resources into grails-gradle/plugins/ - Drop the duplicate GradleSpecification.groovy in favour of the existing copy in grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/ - Register bomPropertyOverrides as the 12th entry in the gradlePlugin{} block in plugins/build.gradle - Remove the now-self-referential implementation project(:grails-gradle-bom-property-overrides) dependency - Remove the include + projectDir mapping from grails-gradle/settings.gradle - Remove grails-gradle-bom-property-overrides from publishedProjects in grails-gradle/gradle/publish-root-config.gradle - Delete the now-empty grails-gradle/bom-property-overrides/ directory - Update class javadoc + doc reference to drop the "standalone" framing (the plugin is still BOM-agnostic and works for non-Grails projects; it just ships inside grails-gradle-plugins) 2. matrei: "Use def for local variables where the type can be inferred... This applies generally to all added Groovy code" Refactored BomManagedVersions, BomPropertyOverridesPlugin, BomManagedVersionsSpec and BomPropertyOverridesPluginSpec to use def for inferrable local variables. Public API method signatures and field declarations retain explicit types (the public contract is unchanged). The unused org.w3c.dom.NodeList import that the def refactor exposed is dropped. 3. jdaugherty: "What is causing the duplicate? We typically fix these so it's not needed" Confirmed by removing the tasks.withType(Copy) duplicatesStrategy = INCLUDE block from grails-gradle/plugins/build.gradle and running :grails-gradle-plugins:build with --rerun-tasks: all tasks pass green (compileGroovy, processResources, jar, sourcesJar, javadocJar, pluginUnderTestMetadata, test, codenarcMain, validatePlugins, cyclonedxDirectBom, check, build). The workaround was a copy-paste from older builds and is no longer needed; dropping it removes a stale escape hatch that could mask genuine duplicates in future. Verification: ./gradlew :grails-gradle-plugins:build -> BUILD SUCCESSFUL in 1m 13s -> 30 tests pass (including all 12 newly-relocated bom tests and the BomPlatformFunctionalSpec end-to-end test) -> codenarcMain clean (31 files scanned, 0 violations) -> validatePlugins clean --- .../gradleBuild/gradleDependencies.adoc | 4 +- .../bom-property-overrides/build.gradle | 86 ------------ .../plugin/bom/GradleSpecification.groovy | 127 ------------------ .../gradle/publish-root-config.gradle | 1 - grails-gradle/plugins/build.gradle | 16 ++- .../plugin/bom/BomManagedVersions.groovy | 114 ++++++++-------- .../bom/BomPropertyOverridesExtension.groovy | 0 .../bom/BomPropertyOverridesPlugin.groovy | 32 ++--- .../plugin/bom/BomManagedVersionsSpec.groovy | 18 +-- ...opertyOverridesPluginFunctionalSpec.groovy | 4 +- .../bom/BomPropertyOverridesPluginSpec.groovy | 31 ++--- .../core/BomPlatformFunctionalSpec.groovy | 6 +- .../src/test/resources/test-poms/test-bom.pom | 0 .../bom-property-overrides-basic/build.gradle | 0 .../gradle.properties | 0 .../settings.gradle | 0 grails-gradle/settings.gradle | 3 - 17 files changed, 113 insertions(+), 329 deletions(-) delete mode 100644 grails-gradle/bom-property-overrides/build.gradle delete mode 100644 grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/GradleSpecification.groovy rename grails-gradle/{bom-property-overrides => plugins}/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy (75%) rename grails-gradle/{bom-property-overrides => plugins}/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesExtension.groovy (100%) rename grails-gradle/{bom-property-overrides => plugins}/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy (83%) rename grails-gradle/{bom-property-overrides => plugins}/src/test/groovy/org/grails/gradle/plugin/bom/BomManagedVersionsSpec.groovy (81%) rename grails-gradle/{bom-property-overrides => plugins}/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginFunctionalSpec.groovy (95%) rename grails-gradle/{bom-property-overrides => plugins}/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginSpec.groovy (78%) rename grails-gradle/{bom-property-overrides => plugins}/src/test/resources/test-poms/test-bom.pom (100%) rename grails-gradle/{bom-property-overrides => plugins}/src/test/resources/test-projects/bom-property-overrides-basic/build.gradle (100%) rename grails-gradle/{bom-property-overrides => plugins}/src/test/resources/test-projects/bom-property-overrides-basic/gradle.properties (100%) rename grails-gradle/{bom-property-overrides => plugins}/src/test/resources/test-projects/bom-property-overrides-basic/settings.gradle (100%) diff --git a/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc b/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc index 0c8ca435550..c4052ea488b 100644 --- a/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc +++ b/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc @@ -97,9 +97,9 @@ dependencies { } ---- -==== Using `bom-property-overrides` Standalone (Non-Grails Projects) +==== Using `bom-property-overrides` Outside a Grails App -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: +The property-override mechanism is BOM-agnostic and can be applied to any project that consumes a BOM with `` references. Apply the plugin directly when you want the same `gradle.properties` / `ext['…']` override workflow without applying the full Grails web plugin: [source,groovy] ---- diff --git a/grails-gradle/bom-property-overrides/build.gradle b/grails-gradle/bom-property-overrides/build.gradle deleted file mode 100644 index 8dbf7640a98..00000000000 --- a/grails-gradle/bom-property-overrides/build.gradle +++ /dev/null @@ -1,86 +0,0 @@ -/* - * 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 against the Groovy version provided at runtime by the Gradle distribution - // running the build (the Gradle/Groovy compatibility matrix is documented at - // https://docs.gradle.org/current/userguide/compatibility.html#groovy). The `groovy` - // module is intentionally compileOnly: at runtime Gradle supplies its own copy and - // testCompileClasspath/testRuntimeClasspath excludes below prevent the Spock - // transitive copy from leaking onto the test classpath. - 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 { - register('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).configureEach { - 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/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 deleted file mode 100644 index d1c283a29fb..00000000000 --- a/grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/GradleSpecification.groovy +++ /dev/null @@ -1,127 +0,0 @@ -/* - * 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/gradle/publish-root-config.gradle b/grails-gradle/gradle/publish-root-config.gradle index 48a9f33566f..4282dbfe20c 100644 --- a/grails-gradle/gradle/publish-root-config.gradle +++ b/grails-gradle/gradle/publish-root-config.gradle @@ -25,7 +25,6 @@ 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 f657e8e865c..654f00a53f0 100644 --- a/grails-gradle/plugins/build.gradle +++ b/grails-gradle/plugins/build.gradle @@ -46,7 +46,6 @@ 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'), { @@ -136,12 +135,15 @@ gradlePlugin { id = 'org.apache.grails.gradle.grails-integration-test' implementationClass = 'org.grails.gradle.plugin.core.IntegrationTestGradlePlugin' } - } -} - -tasks.withType(Copy) { - configure { - duplicatesStrategy = DuplicatesStrategy.INCLUDE + 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' + } } } diff --git a/grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy similarity index 75% rename from grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy rename to grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy index 20e92b543e4..32bed260aaa 100644 --- a/grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy @@ -26,7 +26,6 @@ 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 @@ -47,9 +46,10 @@ import javax.xml.parsers.DocumentBuilderFactory * (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.

+ * {@code org.apache.grails.gradle.bom-property-overrides} plugin + * (registered in {@code grails-gradle-plugins}). It is BOM-agnostic and + * can be used directly with any BOM that follows the Maven + * {@code } convention for managed versions.

* * @since 8.0 */ @@ -85,14 +85,14 @@ class BomManagedVersions { * @return a BomManagedVersions instance containing any version overrides to apply */ static BomManagedVersions resolve(Project project, Collection bomCoordinatesList) { - BomManagedVersions instance = new BomManagedVersions() + def instance = new BomManagedVersions() - Map bomProperties = new LinkedHashMap<>() - Map> propertyToArtifacts = new LinkedHashMap<>() - Set processed = new HashSet<>() + def bomProperties = new LinkedHashMap() + def propertyToArtifacts = new LinkedHashMap>() + def processed = new HashSet() for (String bomCoordinates : bomCoordinatesList) { - String[] parts = bomCoordinates?.split(':') + def parts = bomCoordinates?.split(':') if (parts == null || parts.length != 3) { LOG.warn('Invalid BOM coordinates: {}', bomCoordinates) continue @@ -100,14 +100,14 @@ class BomManagedVersions { processBom(project, parts[0], parts[1], parts[2], bomProperties, propertyToArtifacts, processed) } - for (Map.Entry> entry : propertyToArtifacts.entrySet()) { - String propertyName = entry.key + for (def entry : propertyToArtifacts.entrySet()) { + def propertyName = entry.key if (project.hasProperty(propertyName)) { - String overrideVersion = project.property(propertyName).toString() - String defaultVersion = bomProperties.get(propertyName) + def overrideVersion = project.property(propertyName).toString() + def defaultVersion = bomProperties.get(propertyName) if (overrideVersion != defaultVersion) { - for (String artifactKey : entry.value) { + for (def artifactKey : entry.value) { instance.versionOverrides.put(artifactKey, overrideVersion) } LOG.lifecycle( @@ -135,10 +135,10 @@ class BomManagedVersions { return } - Map overrides = this.versionOverrides + def overrides = this.versionOverrides configuration.resolutionStrategy.eachDependency { DependencyResolveDetails details -> - String key = "${details.requested.group}:${details.requested.name}" as String - String override = overrides.get(key) + def key = "${details.requested.group}:${details.requested.name}" as String + def override = overrides.get(key) if (override != null) { details.useVersion(override) details.because('BOM version override via project property') @@ -171,38 +171,38 @@ class BomManagedVersions { * @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) + def doc = parseXml(pomFile) if (doc == null) { return } extractProperties(doc, bomProperties) - NodeList depMgmtNodes = doc.getElementsByTagName('dependencyManagement') + def depMgmtNodes = doc.getElementsByTagName('dependencyManagement') if (depMgmtNodes.length == 0) { return } - Element depMgmt = (Element) depMgmtNodes.item(0) - NodeList dependenciesNodes = depMgmt.getElementsByTagName('dependencies') + def depMgmt = (Element) depMgmtNodes.item(0) + def dependenciesNodes = depMgmt.getElementsByTagName('dependencies') if (dependenciesNodes.length == 0) { return } - Element dependenciesElement = (Element) dependenciesNodes.item(0) - NodeList depNodes = dependenciesElement.getElementsByTagName('dependency') + def dependenciesElement = (Element) dependenciesNodes.item(0) + def 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') + def dep = (Element) depNodes.item(i) + def depGroupId = getChildText(dep, 'groupId') + def depArtifactId = getChildText(dep, 'artifactId') + def depVersion = getChildText(dep, 'version') if (!depGroupId || !depArtifactId || !depVersion) { continue } if (depVersion.contains('${')) { - String propertyName = extractPropertyName(depVersion) + def propertyName = extractPropertyName(depVersion) if (propertyName) { - String artifactKey = "${depGroupId}:${depArtifactId}" as String + def artifactKey = "${depGroupId}:${depArtifactId}" as String propertyToArtifacts.computeIfAbsent(propertyName) { new ArrayList() }.add(artifactKey) } } @@ -215,17 +215,17 @@ class BomManagedVersions { Map> propertyToArtifacts, Set processed ) { - String bomKey = "${group}:${artifact}:${version}" as String + def bomKey = "${group}:${artifact}:${version}" as String if (!processed.add(bomKey)) { return } - File pomFile = resolvePomFile(project, group, artifact, version) + def pomFile = resolvePomFile(project, group, artifact, version) if (pomFile == null) { return } - Document doc = parseXml(pomFile) + def doc = parseXml(pomFile) if (doc == null) { return } @@ -236,7 +236,7 @@ class BomManagedVersions { private static File resolvePomFile(Project project, String group, String artifact, String version) { try { - Configuration detached = project.configurations.detachedConfiguration( + def detached = project.configurations.detachedConfiguration( project.dependencies.create("${group}:${artifact}:${version}@pom" as String) ) detached.transitive = false @@ -250,7 +250,7 @@ class BomManagedVersions { private static Document parseXml(File pomFile) { try { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance() + def factory = DocumentBuilderFactory.newInstance() factory.setNamespaceAware(false) factory.setValidating(false) factory.setXIncludeAware(false) @@ -266,18 +266,18 @@ class BomManagedVersions { } private static void extractProperties(Document doc, Map bomProperties) { - NodeList propertiesNodes = doc.getElementsByTagName('properties') + def propertiesNodes = doc.getElementsByTagName('properties') if (propertiesNodes.length == 0) { return } - Element propertiesElement = (Element) propertiesNodes.item(0) - NodeList children = propertiesElement.childNodes + def propertiesElement = (Element) propertiesNodes.item(0) + def 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() + def prop = (Element) children.item(i) + def name = prop.tagName + def value = prop.textContent?.trim() if (name && value) { bomProperties.put(name, value) } @@ -291,33 +291,33 @@ class BomManagedVersions { Map> propertyToArtifacts, Set processed ) { - NodeList depMgmtNodes = doc.getElementsByTagName('dependencyManagement') + def depMgmtNodes = doc.getElementsByTagName('dependencyManagement') if (depMgmtNodes.length == 0) { return } - Element depMgmt = (Element) depMgmtNodes.item(0) - NodeList dependenciesNodes = depMgmt.getElementsByTagName('dependencies') + def depMgmt = (Element) depMgmtNodes.item(0) + def dependenciesNodes = depMgmt.getElementsByTagName('dependencies') if (dependenciesNodes.length == 0) { return } - Element dependenciesElement = (Element) dependenciesNodes.item(0) - NodeList depNodes = dependenciesElement.getElementsByTagName('dependency') + def dependenciesElement = (Element) dependenciesNodes.item(0) + def 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') + def dep = (Element) depNodes.item(i) + def depGroupId = getChildText(dep, 'groupId') + def depArtifactId = getChildText(dep, 'artifactId') + def depVersion = getChildText(dep, 'version') + def depScope = getChildText(dep, 'scope') if (!depGroupId || !depArtifactId) { continue } if ('import' == depScope) { - String resolvedVersion = interpolateProperties(depVersion, bomProperties) + def resolvedVersion = interpolateProperties(depVersion, bomProperties) if (resolvedVersion) { processBom(project, depGroupId, depArtifactId, resolvedVersion, bomProperties, propertyToArtifacts, processed) @@ -326,9 +326,9 @@ class BomManagedVersions { } if (depVersion && depVersion.contains('${')) { - String propertyName = extractPropertyName(depVersion) + def propertyName = extractPropertyName(depVersion) if (propertyName) { - String artifactKey = "${depGroupId}:${depArtifactId}" as String + def artifactKey = "${depGroupId}:${depArtifactId}" as String propertyToArtifacts.computeIfAbsent(propertyName) { new ArrayList() }.add(artifactKey) } } @@ -352,14 +352,14 @@ class BomManagedVersions { return value } - String result = value + def result = value int maxIterations = MAX_PROPERTY_INTERPOLATION_DEPTH while (result.contains('${') && maxIterations-- > 0) { - String propertyName = extractPropertyName(result) + def propertyName = extractPropertyName(result) if (propertyName == null) { break } - String resolved = properties.get(propertyName) + def resolved = properties.get(propertyName) if (resolved == null) { break } @@ -369,7 +369,7 @@ class BomManagedVersions { } private static String getChildText(Element parent, String childTagName) { - NodeList children = parent.getElementsByTagName(childTagName) + def children = parent.getElementsByTagName(childTagName) if (children.length == 0) { return null } diff --git a/grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesExtension.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesExtension.groovy similarity index 100% rename from grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesExtension.groovy rename to grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesExtension.groovy diff --git a/grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy similarity index 83% rename from grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy rename to grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy index 02f9f1fe47d..60d36cde081 100644 --- a/grails-gradle/bom-property-overrides/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy @@ -27,14 +27,14 @@ 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. + * 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:

+ *

This is the BOM-agnostic replacement for the Spring Dependency + * Management plugin's property-override feature. The plugin is shipped as + * part of {@code grails-gradle-plugins} but can be applied to any project + * (Grails or otherwise) that consumes a BOM published with version + * property references in its {@code } block:

* *
  * plugins {
@@ -86,7 +86,7 @@ class BomPropertyOverridesPlugin implements Plugin {
 
     @Override
     void apply(Project project) {
-        BomPropertyOverridesExtension extension = project.extensions.create(
+        def extension = project.extensions.create(
                 BomPropertyOverridesExtension.EXTENSION_NAME,
                 BomPropertyOverridesExtension,
                 project.objects
@@ -102,7 +102,7 @@ class BomPropertyOverridesPlugin implements Plugin {
      * to all project configurations. Visible for testing.
      */
     static void applyOverrides(Project project, BomPropertyOverridesExtension extension) {
-        Set bomCoordinates = new LinkedHashSet<>()
+        def bomCoordinates = new LinkedHashSet()
 
         if (extension.autoDetect.get()) {
             bomCoordinates.addAll(detectDeclaredBoms(project))
@@ -118,7 +118,7 @@ class BomPropertyOverridesPlugin implements Plugin {
             return
         }
 
-        BomManagedVersions managedVersions = BomManagedVersions.resolve(project, bomCoordinates)
+        def managedVersions = BomManagedVersions.resolve(project, bomCoordinates)
         if (!managedVersions.hasOverrides()) {
             return
         }
@@ -134,7 +134,7 @@ class BomPropertyOverridesPlugin implements Plugin {
      * Visible for testing.
      */
     static Set detectDeclaredBoms(Project project) {
-        Set coordinates = new LinkedHashSet<>()
+        def coordinates = new LinkedHashSet()
 
         project.configurations.each { Configuration conf ->
             for (Dependency dep : conf.dependencies) {
@@ -144,9 +144,9 @@ class BomPropertyOverridesPlugin implements Plugin {
                 if (!isPlatformDependency((ModuleDependency) dep)) {
                     continue
                 }
-                String group = dep.group
-                String name = dep.name
-                String version = dep.version
+                def group = dep.group
+                def name = dep.name
+                def version = dep.version
                 if (group && name && version) {
                     coordinates.add("${group}:${name}:${version}" as String)
                 }
@@ -157,11 +157,11 @@ class BomPropertyOverridesPlugin implements Plugin {
     }
 
     private static boolean isPlatformDependency(ModuleDependency dep) {
-        Object categoryAttr = dep.attributes.getAttribute(Category.CATEGORY_ATTRIBUTE)
+        def categoryAttr = dep.attributes.getAttribute(Category.CATEGORY_ATTRIBUTE)
         if (categoryAttr == null) {
             return false
         }
-        String category = categoryAttr.toString()
+        def 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/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomManagedVersionsSpec.groovy
similarity index 81%
rename from grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomManagedVersionsSpec.groovy
rename to grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomManagedVersionsSpec.groovy
index 9840c4b90d4..86489139c02 100644
--- a/grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomManagedVersionsSpec.groovy
+++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomManagedVersionsSpec.groovy
@@ -35,9 +35,9 @@ 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 = [:]
+        def pomFile = new File(getClass().getClassLoader().getResource('test-poms/test-bom.pom').toURI())
+        def bomProperties = [:] as Map
+        def propertyToArtifacts = [:] as Map>
 
         when:
         BomManagedVersions.parseBomFile(pomFile, bomProperties, propertyToArtifacts)
@@ -50,9 +50,9 @@ class BomManagedVersionsSpec extends Specification {
 
     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 = [:]
+        def pomFile = new File(getClass().getClassLoader().getResource('test-poms/test-bom.pom').toURI())
+        def bomProperties = [:] as Map
+        def propertyToArtifacts = [:] as Map>
 
         when:
         BomManagedVersions.parseBomFile(pomFile, bomProperties, propertyToArtifacts)
@@ -73,9 +73,9 @@ class BomManagedVersionsSpec extends Specification {
 
     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 = [:]
+        def pomFile = new File(getClass().getClassLoader().getResource('test-poms/test-bom.pom').toURI())
+        def bomProperties = [:] as Map
+        def propertyToArtifacts = [:] as Map>
 
         when:
         BomManagedVersions.parseBomFile(pomFile, bomProperties, propertyToArtifacts)
diff --git a/grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginFunctionalSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginFunctionalSpec.groovy
similarity index 95%
rename from grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginFunctionalSpec.groovy
rename to grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginFunctionalSpec.groovy
index 7b9f1743bab..95a517b3a8d 100644
--- a/grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginFunctionalSpec.groovy
+++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginFunctionalSpec.groovy
@@ -18,8 +18,10 @@
  */
 package org.grails.gradle.plugin.bom
 
+import org.grails.gradle.plugin.core.GradleSpecification
+
 /**
- * End-to-end functional test for the standalone
+ * End-to-end functional test for the
  * {@code org.apache.grails.gradle.bom-property-overrides} plugin.
  *
  * 

Uses Gradle TestKit to apply the plugin in isolation (without any diff --git a/grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginSpec.groovy similarity index 78% rename from grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginSpec.groovy rename to grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginSpec.groovy index c97f164c09f..2210a2e8349 100644 --- a/grails-gradle/bom-property-overrides/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginSpec.groovy +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginSpec.groovy @@ -37,14 +37,13 @@ class BomPropertyOverridesPluginSpec extends Specification { def "applying the plugin registers the bomPropertyOverrides extension"() { given: - Project project = ProjectBuilder.builder().build() + def project = ProjectBuilder.builder().build() when: project.plugins.apply(BomPropertyOverridesPlugin) then: - BomPropertyOverridesExtension extension = - project.extensions.findByType(BomPropertyOverridesExtension) + def extension = project.extensions.findByType(BomPropertyOverridesExtension) extension != null extension.autoDetect.get() == true extension.boms.get().isEmpty() @@ -52,10 +51,9 @@ class BomPropertyOverridesPluginSpec extends Specification { def "extension bom() method registers explicit BOM coordinates"() { given: - Project project = ProjectBuilder.builder().build() + def project = ProjectBuilder.builder().build() project.plugins.apply(BomPropertyOverridesPlugin) - BomPropertyOverridesExtension extension = - project.extensions.getByType(BomPropertyOverridesExtension) + def extension = project.extensions.getByType(BomPropertyOverridesExtension) when: extension.bom('org.example:my-bom:1.0.0') @@ -67,10 +65,9 @@ class BomPropertyOverridesPluginSpec extends Specification { def "extension boms() vararg method registers multiple BOMs"() { given: - Project project = ProjectBuilder.builder().build() + def project = ProjectBuilder.builder().build() project.plugins.apply(BomPropertyOverridesPlugin) - BomPropertyOverridesExtension extension = - project.extensions.getByType(BomPropertyOverridesExtension) + def 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') @@ -81,7 +78,7 @@ class BomPropertyOverridesPluginSpec extends Specification { def "detectDeclaredBoms finds regular platform() dependencies"() { given: - Project project = ProjectBuilder.builder().build() + def project = ProjectBuilder.builder().build() project.plugins.apply('java') project.dependencies.add( 'implementation', @@ -89,7 +86,7 @@ class BomPropertyOverridesPluginSpec extends Specification { ) when: - Set coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project) + def coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project) then: 'org.example:test-bom:1.0.0' in coordinates @@ -97,7 +94,7 @@ class BomPropertyOverridesPluginSpec extends Specification { def "detectDeclaredBoms finds enforcedPlatform() dependencies"() { given: - Project project = ProjectBuilder.builder().build() + def project = ProjectBuilder.builder().build() project.plugins.apply('java') project.dependencies.add( 'implementation', @@ -105,7 +102,7 @@ class BomPropertyOverridesPluginSpec extends Specification { ) when: - Set coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project) + def coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project) then: 'org.example:enforced-bom:2.0.0' in coordinates @@ -113,12 +110,12 @@ class BomPropertyOverridesPluginSpec extends Specification { def "detectDeclaredBoms ignores non-platform dependencies"() { given: - Project project = ProjectBuilder.builder().build() + def 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) + def coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project) then: coordinates.isEmpty() @@ -126,7 +123,7 @@ class BomPropertyOverridesPluginSpec extends Specification { def "detectDeclaredBoms deduplicates the same BOM declared on multiple configurations"() { given: - Project project = ProjectBuilder.builder().build() + def project = ProjectBuilder.builder().build() project.plugins.apply('java') project.dependencies.add( 'implementation', @@ -138,7 +135,7 @@ class BomPropertyOverridesPluginSpec extends Specification { ) when: - Set coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project) + def coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project) then: coordinates == ['org.example:shared-bom:1.0.0'] as Set 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 index efd0b2ac69e..521610c5958 100644 --- 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 @@ -23,9 +23,9 @@ package org.grails.gradle.plugin.core * *

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.

+ * applies the {@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 diff --git a/grails-gradle/bom-property-overrides/src/test/resources/test-poms/test-bom.pom b/grails-gradle/plugins/src/test/resources/test-poms/test-bom.pom similarity index 100% rename from grails-gradle/bom-property-overrides/src/test/resources/test-poms/test-bom.pom rename to grails-gradle/plugins/src/test/resources/test-poms/test-bom.pom diff --git a/grails-gradle/bom-property-overrides/src/test/resources/test-projects/bom-property-overrides-basic/build.gradle b/grails-gradle/plugins/src/test/resources/test-projects/bom-property-overrides-basic/build.gradle similarity index 100% rename from grails-gradle/bom-property-overrides/src/test/resources/test-projects/bom-property-overrides-basic/build.gradle rename to grails-gradle/plugins/src/test/resources/test-projects/bom-property-overrides-basic/build.gradle diff --git a/grails-gradle/bom-property-overrides/src/test/resources/test-projects/bom-property-overrides-basic/gradle.properties b/grails-gradle/plugins/src/test/resources/test-projects/bom-property-overrides-basic/gradle.properties similarity index 100% rename from grails-gradle/bom-property-overrides/src/test/resources/test-projects/bom-property-overrides-basic/gradle.properties rename to grails-gradle/plugins/src/test/resources/test-projects/bom-property-overrides-basic/gradle.properties diff --git a/grails-gradle/bom-property-overrides/src/test/resources/test-projects/bom-property-overrides-basic/settings.gradle b/grails-gradle/plugins/src/test/resources/test-projects/bom-property-overrides-basic/settings.gradle similarity index 100% rename from grails-gradle/bom-property-overrides/src/test/resources/test-projects/bom-property-overrides-basic/settings.gradle rename to grails-gradle/plugins/src/test/resources/test-projects/bom-property-overrides-basic/settings.gradle diff --git a/grails-gradle/settings.gradle b/grails-gradle/settings.gradle index fc1a9e16dd3..406ae33b6b6 100644 --- a/grails-gradle/settings.gradle +++ b/grails-gradle/settings.gradle @@ -78,6 +78,3 @@ 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') From 931a54b54a56b94c0b7e4473dffcdd95bdd6d454 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 21 May 2026 18:17:04 -0400 Subject: [PATCH 11/23] docs: document Spring DM removal in Grails 8 upgrade guide Add section 14 "Spring Dependency Management Plugin Replaced by Gradle Platforms" to the upgrading-to-8.0.x guide, covering: - The plugin is no longer applied; Grails Gradle Plugin uses native platform(grails-bom) instead, and bom-property-overrides preserves the gradle.properties / ext['...'] override workflow. - No action required for most apps - existing override forms keep working identically. - The two Spring DM features with no automatic migration: 1. dependencyManagement { dependencies { dependency 'g:a:v' } } DSL for arbitrary non-BOM pins - migrate to direct dependencies { implementation } or resolutionStrategy.force. 2. Version-range strings inside BOM values - now pass through to Gradle's native useVersion() rather than Spring DM's resolver. Zero impact on the Grails / Spring Boot BOM chain; only relevant for custom BOMs. - Conflict-resolution behavioural difference: Spring DM forced BOM versions unconditionally; Gradle platform() participates in standard highest-version-wins resolution. enforcedPlatform() is the migration path for "BOM wins always" semantics. Sections 15-23 renumbered to make room. Two pre-existing heading-level typos in former 21/22 (===== instead of ====, missing period after 22) fixed in passing. --- .../src/en/guide/upgrading/upgrading80x.adoc | 63 ++++++++++++++++--- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index ef5dd4e1ede..3c8f4d03736 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -465,7 +465,52 @@ Spring Security is moving toward the fluent `HttpSecurity` configuration API for If your application references `DEFAULT_FILTER_ORDER` for custom filter positioning, replace it with the concrete value `-100` (computed as `OrderedFilter.REQUEST_WRAPPER_FILTER_MAX_ORDER - 100`). Longer term, consider migrating to the fluent `HttpSecurity` API for filter chain configuration. -==== 14. Spring Boot Starter Renames +==== 14. Spring Dependency Management Plugin Replaced by Gradle Platforms + +Grails 8 standardizes on Gradle's native `platform()` dependency management and **no longer applies the `io.spring.dependency-management` plugin**. +The Grails Gradle Plugin now adds `grails-bom` as a Gradle `platform()` on every declarable project configuration, and a new bundled plugin (`org.apache.grails.gradle.bom-property-overrides`, applied automatically by the Grails Gradle Plugin) preserves the property-based version-override workflow that Spring DM provided. + +**No action is required for most applications.** +The standard `gradle.properties` and `ext['…']` override forms continue to work exactly as before: + +[source,groovy] +.build.gradle +---- +ext['slf4j.version'] = '2.0.9' +---- + +[source,properties] +.gradle.properties +---- +slf4j.version=2.0.9 +---- + +The `grails { springDependencyManagement = … }` flag on `GrailsExtension` is now a deprecated no-op retained for source compatibility; setting it logs a deprecation warning. + +**Two Spring DM features have no automatic migration.** +If your `build.gradle` uses either of the patterns below, update it as follows: + +[cols="2,3", options="header"] +|=== +| Spring DM pattern (Grails 7) +| Grails 8 replacement + +| `dependencyManagement { dependencies { dependency 'group:artifact:version' } }` to pin arbitrary non-BOM dependency versions +| `dependencies { implementation 'group:artifact:version' }` for a direct dependency, or `configurations.all { resolutionStrategy.force 'group:artifact:version' }` to force a transitive version + +| Version-range strings (e.g. `1.+`, `[1.0,2.0)`) embedded in BOM `` values, resolved by Spring DM's own resolver +| Now passed through to Gradle's native `useVersion()`, which uses Gradle's range semantics. Concrete versions are unaffected. Only relevant if you consume a custom BOM whose `` block contains range strings. +|=== + +**Behavioural difference to be aware of: conflict resolution.** +Spring DM forced BOM-managed versions unconditionally, which could mask transitive version drift. +Gradle's `platform()` participates in standard conflict resolution (highest-version-wins), so the BOM-pinned version is the version that wins *after* Gradle's conflict resolution. +A `gradle.properties` override therefore pins the resolved version rather than overriding it after the fact. +If you require Spring DM's "BOM wins always" behaviour for a given configuration, declare `enforcedPlatform("org.apache.grails:grails-bom:$grailsVersion")` instead of relying on the auto-applied non-enforced platform. + +NOTE: Projects that apply the `grails-micronaut` plugin already require `enforcedPlatform` for unrelated reasons (see section 7.2); those projects retain Spring DM's strict-version semantics automatically. + +==== 15. Spring Boot Starter Renames Spring Boot 4 renamed several starters as part of its modularization effort. If your `build.gradle` references any of the following, update the artifact id: @@ -511,7 +556,7 @@ implementation 'org.springframework.boot:spring-boot-starter-aspectj' NOTE: If you want a faster, lower-friction migration that pulls in *all* Spring Boot modules without picking starters individually, Spring Boot 4 ships `spring-boot-starter-classic` (and `spring-boot-starter-test-classic`) as a transitional fallback. This is intended as a stopgap; new code should target the modular starters. -==== 15. WAR Deployment Uses spring-boot-starter-tomcat-runtime +==== 16. WAR Deployment Uses spring-boot-starter-tomcat-runtime For WAR deployment to an external Servlet container, Spring Boot 4 introduced a dedicated `spring-boot-starter-tomcat-runtime` artifact that contributes only the integration classes needed to start under an external container, without bundling the embedded Tomcat distribution into the WAR. @@ -532,7 +577,7 @@ providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat-runtime' The Grails deployment guides have been updated to reflect this idiom. Embedded Tomcat usage (the default for `grails run-app` and bootable WARs) continues to use `spring-boot-starter-tomcat` unchanged. -==== 16. Test Infrastructure Changes +==== 17. Test Infrastructure Changes Spring Boot 4 made several breaking changes to its test infrastructure. Most Grails tests use `GrailsUnitTest`, `GrailsWebUnitTest`, and `Integration` and are unaffected, but if your application uses `@SpringBootTest` directly the following apply. @@ -588,7 +633,7 @@ If your test infrastructure registers it explicitly, remove the registration; Sp Update the import. A new `RestTestClient` and corresponding `@AutoConfigureRestTestClient` annotation are also available as a fluent alternative. -==== 17. DevTools Live Reload Disabled by Default +==== 18. DevTools Live Reload Disabled by Default Spring Boot 4 changed the default of `spring.devtools.livereload.enabled` from `true` to `false`. If you rely on browser live reload during development, opt in explicitly under the development environment so non-development environments still honor the new Spring Boot 4 default: @@ -606,7 +651,7 @@ NOTE: Apps generated by the Grails Forge with the `spring-boot-devtools` reloadi You can opt out by changing the value to `false` or by deleting the `application-development.yml` block after generation. Existing Grails 7 applications that opt in to devtools must add this configuration manually. -==== 18. Spring Retry No Longer Managed +==== 19. Spring Retry No Longer Managed Spring Boot 4 removed `spring-retry` from its managed dependencies. Spring for Apache Kafka and Spring AMQP have moved off Spring Retry to Spring Framework's new retry support; if you used Spring Retry transitively through those projects you may not need it any more. @@ -622,7 +667,7 @@ implementation 'org.springframework.retry:spring-retry' NOTE: The `grails-bom` pins a known-good Spring Retry version (`2.0.x`), so you do not need to specify a version when using `enforcedPlatform("org.apache.grails:grails-bom:$grailsVersion")`. Override the BOM-managed version in your build if you need a newer release. -==== 19. Other Default Behavior Changes +==== 20. Other Default Behavior Changes Spring Boot 4 made several smaller default-behavior changes that you may notice but rarely require code changes: @@ -651,7 +696,7 @@ If your application contributed `HttpMessageConverter` beans through this class, Spring Framework 7 deprecated `org.springframework.lang.Nullable` and `org.springframework.lang.NonNull` in favor of the JSpecify annotations (`org.jspecify.annotations.Nullable`, `org.jspecify.annotations.NonNull`). The Spring annotations still work, so this is non-blocking, but new code should prefer the JSpecify equivalents. -==== 20. Known Plugin Incompatibilities +==== 21. Known Plugin Incompatibilities Some third-party plugins have not yet been updated for Spring Boot 4 / Spring Framework 7 compatibility. The following are known blockers at this time: @@ -664,7 +709,7 @@ Applications using `grails-sitemesh3` should remain on the `grails-layout` plugi Check the https://github.com/apache/grails-core/issues[Grails issue tracker] for the latest status of plugin compatibility. -===== 21. Custom JSON View Converters +==== 22. Custom JSON View Converters JSON views now use Groovy's `groovy.json.JsonGenerator` implementation instead of the previous Grails-specific JSON generator infrastructure. @@ -682,7 +727,7 @@ To migrate your custom converters: `src/main/resources/META-INF/services/groovy.json.JsonGenerator$Converter` (was previously `src/main/resources/META-INF/services/grails.plugin.json.builder.JsonGenerator$Converter`) -===== 22 Rendering Enum values as JSON +==== 23. Rendering Enum values as JSON It is no longer possible to render a single enum value as JSON using the `render(Number.ONE as JSON)` syntax. Previously, rendering an enum value would produce a JSON string with the type and name of the enum value like: From 48de2b8d0d8485feb8d36795ad5bb618350986f1 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 21 May 2026 20:26:11 -0400 Subject: [PATCH 12/23] Address final 3 review threads + drop last Spring DM coupling Three concrete changes that together close out review feedback on PR #15467 and remove every remaining reference to the io.spring.gradle:dependency-management-plugin artifact from the entire repository. 1. Item: grails { autoApplyBom = true } opt-out (jdaugherty thread AJjff) GrailsExtension grows an autoApplyBom Property with a default convention of true, matching the implicit Grails 7 behaviour where Spring DM always applied the BOM. applyGrailsBom() is wrapped in afterEvaluate so the user's build.gradle has a chance to set the flag before the BOM gets applied. Setting `grails { autoApplyBom = false }` now skips both the platform(grails-bom) injection AND the application of the bom-property-overrides plugin, giving users a single clean opt-out for the entire BOM automation. New AutoApplyBomSpec (TestKit) verifies the opt-out path end-to-end. 2. Item: services-based BOM resolution (jdaugherty thread AJhsl on afterEvaluate) BomManagedVersions.resolve() and BomPropertyOverridesPlugin.applyOverrides() are refactored to take captured Gradle services (ConfigurationContainer, DependencyHandler, Function propertyLookup) instead of a Project. This mirrors the captureProjectServices pattern already in use by ExtractDependenciesTask. The afterEvaluate trigger is preserved (it is the project's established pattern - 13 uses across GrailsGradlePlugin alone, all addressing the same "late-binding to the user's declarations" need), but the resolve path no longer holds a Project reference, so the resulting BomManagedVersions instance + its versionOverrides Map are pure data that survive configuration-cache serialisation cleanly. The original Project-accepting overloads on BomManagedVersions.resolve() are retained as thin convenience wrappers for tests and ad-hoc usage. Verified empirically with ./gradlew --configuration-cache: zero CC warnings originate from this plugin or from BomManagedVersions. 3. Item: drop Spring DM from build-logic/docs-core (last consumer) ExtractDependenciesTask was the only file in the entire repo still importing io.spring.gradle.dependencymanagement.org.apache.maven.model.* (a relic of the original Spring-DM-era BOM doc generation). The MavenXpp3Reader + Model usage is replaced with JDK DocumentBuilderFactory POM parsing, following the same XXE-hardened pattern BomManagedVersions established (setXIncludeAware(false), disallow-doctype-decl, external entities off). A private ManagedDependency data class replaces the four shaded Maven model fields the task actually used. With ExtractDependenciesTask off Spring DM, the implementation 'org.springframework.boot:spring-boot-gradle-plugin' dependency in build-logic/docs-core/build.gradle is no longer required (it was only there to drag Spring DM onto the build-logic classpath via its transitive). Dropped. The build-logic Groovy source set now has zero references to org.springframework.* outside one inert javadoc-link URL in doc.properties. Audit (verifying religious adherence to existing project patterns): - afterEvaluate 13 uses elsewhere in GrailsGradlePlugin - project.objects.property(...) 3 prior uses (BomPropertyOverridesExtension, GrailsExtension.indy, GrailsExtension.preserveParameterNames) - project.extensions.create() 2 prior uses - configurations.detachedConfiguration 3 uses (BomManagedVersions, ExtractDependenciesTask, GrailsDependencyValidatorPlugin) - JDK DocumentBuilderFactory POM parsing with XXE hardening established by BomManagedVersions in the same PR; now reused identically in ExtractDependenciesTask Doc: upgrading80x.adoc section 14 ("Spring Dependency Management Plugin Replaced by Gradle Platforms") gets an "Opting out of the auto-applied BOM" subsection with the build.gradle example. Verification: ./gradlew :grails-gradle-plugins:build -> BUILD SUCCESSFUL, 31 tests pass (was 30; +1 for AutoApplyBomSpec), codenarc clean, validatePlugins clean ./gradlew :grails-base-bom:extractConstraints -> BUILD SUCCESSFUL, produces grails-bom-constraints.adoc (184.9 KB, identical structure to pre-refactor output) Spring DM imports across whole repo: ZERO After this commit the repository has no compile-time, runtime, or build-classpath dependency on io.spring.gradle:dependency-management-plugin in any form. --- .../ses_1b3c2d94dffeehtmCE00QwHQzS.json | 10 + .../ses_1b96c228effeTZuclUToRUORl7.json | 10 + .../ses_1ba57982dffetPNLKNXYPxd2Ep.json | 10 + .../ses_1d408b4b1ffeywzBKStVd53F7m.json | 10 + .../ses_1e8e4afb1ffeULcqOdvaKEZY54.json | 10 + .../ses_1f72301eeffexh3D3I7MHLmoeG.json | 10 + .../page-2026-05-08T21-50-43-518Z.yml | 1 + .../page-2026-05-08T22-23-47-298Z.yml | 1 + .../ses_1d408b4b1ffeywzBKStVd53F7m.json | 10 + .../ses_1e8e4afb1ffeULcqOdvaKEZY54.json | 10 + .../ses_1f72301eeffexh3D3I7MHLmoeG.json | 10 + build-logic/docs-core/build.gradle | 1 - .../tasks/bom/ExtractDependenciesTask.groovy | 272 +++++++++++++----- cyclonedx-plugin-temp | 1 + gradlew.lf | 248 ++++++++++++++++ .../src/en/guide/upgrading/upgrading80x.adoc | 15 + grails-forge/gradlew.lf | 248 ++++++++++++++++ .../plugin/bom/BomManagedVersions.groovy | 102 +++++-- .../bom/BomPropertyOverridesPlugin.groovy | 50 +++- .../gradle/plugin/core/GrailsExtension.groovy | 27 ++ .../plugin/core/GrailsGradlePlugin.groovy | 75 +++-- .../bom/BomPropertyOverridesPluginSpec.groovy | 8 +- .../plugin/core/AutoApplyBomSpec.groovy | 48 ++++ .../auto-apply-bom-disabled/build.gradle | 23 ++ .../auto-apply-bom-disabled/gradle.properties | 1 + .../grails-app/conf/application.yml | 2 + .../auto-apply-bom-disabled/settings.gradle | 1 + .../bom-property-overrides-basic/build.gradle | 2 +- start-grails-after-fix.png | Bin 0 -> 34585 bytes 29 files changed, 1085 insertions(+), 131 deletions(-) create mode 100644 .omo/run-continuation/ses_1b3c2d94dffeehtmCE00QwHQzS.json create mode 100644 .omo/run-continuation/ses_1b96c228effeTZuclUToRUORl7.json create mode 100644 .omo/run-continuation/ses_1ba57982dffetPNLKNXYPxd2Ep.json create mode 100644 .omo/run-continuation/ses_1d408b4b1ffeywzBKStVd53F7m.json create mode 100644 .omo/run-continuation/ses_1e8e4afb1ffeULcqOdvaKEZY54.json create mode 100644 .omo/run-continuation/ses_1f72301eeffexh3D3I7MHLmoeG.json create mode 100644 .playwright-mcp/page-2026-05-08T21-50-43-518Z.yml create mode 100644 .playwright-mcp/page-2026-05-08T22-23-47-298Z.yml create mode 100644 .sisyphus/run-continuation/ses_1d408b4b1ffeywzBKStVd53F7m.json create mode 100644 .sisyphus/run-continuation/ses_1e8e4afb1ffeULcqOdvaKEZY54.json create mode 100644 .sisyphus/run-continuation/ses_1f72301eeffexh3D3I7MHLmoeG.json create mode 160000 cyclonedx-plugin-temp create mode 100644 gradlew.lf create mode 100644 grails-forge/gradlew.lf create mode 100644 grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/AutoApplyBomSpec.groovy create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/auto-apply-bom-disabled/build.gradle create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/auto-apply-bom-disabled/gradle.properties create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/auto-apply-bom-disabled/grails-app/conf/application.yml create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/auto-apply-bom-disabled/settings.gradle create mode 100644 start-grails-after-fix.png diff --git a/.omo/run-continuation/ses_1b3c2d94dffeehtmCE00QwHQzS.json b/.omo/run-continuation/ses_1b3c2d94dffeehtmCE00QwHQzS.json new file mode 100644 index 00000000000..91a8fd30453 --- /dev/null +++ b/.omo/run-continuation/ses_1b3c2d94dffeehtmCE00QwHQzS.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_1b3c2d94dffeehtmCE00QwHQzS", + "updatedAt": "2026-05-21T21:59:21.069Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-05-21T21:59:21.069Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_1b96c228effeTZuclUToRUORl7.json b/.omo/run-continuation/ses_1b96c228effeTZuclUToRUORl7.json new file mode 100644 index 00000000000..f8464d004f5 --- /dev/null +++ b/.omo/run-continuation/ses_1b96c228effeTZuclUToRUORl7.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_1b96c228effeTZuclUToRUORl7", + "updatedAt": "2026-05-20T18:13:41.952Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-05-20T18:13:41.952Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_1ba57982dffetPNLKNXYPxd2Ep.json b/.omo/run-continuation/ses_1ba57982dffetPNLKNXYPxd2Ep.json new file mode 100644 index 00000000000..e783e2c6ee8 --- /dev/null +++ b/.omo/run-continuation/ses_1ba57982dffetPNLKNXYPxd2Ep.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_1ba57982dffetPNLKNXYPxd2Ep", + "updatedAt": "2026-05-20T15:51:26.090Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-05-20T15:51:26.090Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_1d408b4b1ffeywzBKStVd53F7m.json b/.omo/run-continuation/ses_1d408b4b1ffeywzBKStVd53F7m.json new file mode 100644 index 00000000000..d69f13b6422 --- /dev/null +++ b/.omo/run-continuation/ses_1d408b4b1ffeywzBKStVd53F7m.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_1d408b4b1ffeywzBKStVd53F7m", + "updatedAt": "2026-05-15T15:27:15.637Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-05-15T15:27:15.637Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_1e8e4afb1ffeULcqOdvaKEZY54.json b/.omo/run-continuation/ses_1e8e4afb1ffeULcqOdvaKEZY54.json new file mode 100644 index 00000000000..726500d26e6 --- /dev/null +++ b/.omo/run-continuation/ses_1e8e4afb1ffeULcqOdvaKEZY54.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_1e8e4afb1ffeULcqOdvaKEZY54", + "updatedAt": "2026-05-11T13:01:45.805Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-05-11T13:01:45.805Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_1f72301eeffexh3D3I7MHLmoeG.json b/.omo/run-continuation/ses_1f72301eeffexh3D3I7MHLmoeG.json new file mode 100644 index 00000000000..9fff71afe73 --- /dev/null +++ b/.omo/run-continuation/ses_1f72301eeffexh3D3I7MHLmoeG.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_1f72301eeffexh3D3I7MHLmoeG", + "updatedAt": "2026-05-08T18:41:18.111Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-05-08T18:41:18.111Z" + } + } +} \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-08T21-50-43-518Z.yml b/.playwright-mcp/page-2026-05-08T21-50-43-518Z.yml new file mode 100644 index 00000000000..7703770e389 --- /dev/null +++ b/.playwright-mcp/page-2026-05-08T21-50-43-518Z.yml @@ -0,0 +1 @@ +- img [ref=e4] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-08T22-23-47-298Z.yml b/.playwright-mcp/page-2026-05-08T22-23-47-298Z.yml new file mode 100644 index 00000000000..7703770e389 --- /dev/null +++ b/.playwright-mcp/page-2026-05-08T22-23-47-298Z.yml @@ -0,0 +1 @@ +- img [ref=e4] \ No newline at end of file diff --git a/.sisyphus/run-continuation/ses_1d408b4b1ffeywzBKStVd53F7m.json b/.sisyphus/run-continuation/ses_1d408b4b1ffeywzBKStVd53F7m.json new file mode 100644 index 00000000000..d69f13b6422 --- /dev/null +++ b/.sisyphus/run-continuation/ses_1d408b4b1ffeywzBKStVd53F7m.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_1d408b4b1ffeywzBKStVd53F7m", + "updatedAt": "2026-05-15T15:27:15.637Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-05-15T15:27:15.637Z" + } + } +} \ No newline at end of file diff --git a/.sisyphus/run-continuation/ses_1e8e4afb1ffeULcqOdvaKEZY54.json b/.sisyphus/run-continuation/ses_1e8e4afb1ffeULcqOdvaKEZY54.json new file mode 100644 index 00000000000..726500d26e6 --- /dev/null +++ b/.sisyphus/run-continuation/ses_1e8e4afb1ffeULcqOdvaKEZY54.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_1e8e4afb1ffeULcqOdvaKEZY54", + "updatedAt": "2026-05-11T13:01:45.805Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-05-11T13:01:45.805Z" + } + } +} \ No newline at end of file diff --git a/.sisyphus/run-continuation/ses_1f72301eeffexh3D3I7MHLmoeG.json b/.sisyphus/run-continuation/ses_1f72301eeffexh3D3I7MHLmoeG.json new file mode 100644 index 00000000000..9fff71afe73 --- /dev/null +++ b/.sisyphus/run-continuation/ses_1f72301eeffexh3D3I7MHLmoeG.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_1f72301eeffexh3D3I7MHLmoeG", + "updatedAt": "2026-05-08T18:41:18.111Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-05-08T18:41:18.111Z" + } + } +} \ No newline at end of file diff --git a/build-logic/docs-core/build.gradle b/build-logic/docs-core/build.gradle index 794cc42fc90..3b6b30c645b 100644 --- a/build-logic/docs-core/build.gradle +++ b/build-logic/docs-core/build.gradle @@ -48,7 +48,6 @@ dependencies { api 'org.yaml:snakeyaml:2.4' api "org.asciidoctor:asciidoctorj:${gradleBomDependencyVersions['asciidoctorj.version']}" - implementation "org.springframework.boot:spring-boot-gradle-plugin:${gradleBomDependencyVersions['spring-boot.version']}" testImplementation platform("org.spockframework:spock-bom:${gradleBomDependencyVersions['gradle-spock.version']}") testImplementation('org.spockframework:spock-core') { diff --git a/build-logic/docs-core/src/main/groovy/org/apache/grails/gradle/tasks/bom/ExtractDependenciesTask.groovy b/build-logic/docs-core/src/main/groovy/org/apache/grails/gradle/tasks/bom/ExtractDependenciesTask.groovy index a7518a1c413..6f7eb354cee 100644 --- a/build-logic/docs-core/src/main/groovy/org/apache/grails/gradle/tasks/bom/ExtractDependenciesTask.groovy +++ b/build-logic/docs-core/src/main/groovy/org/apache/grails/gradle/tasks/bom/ExtractDependenciesTask.groovy @@ -20,9 +20,8 @@ package org.apache.grails.gradle.tasks.bom import java.util.regex.Pattern +import javax.xml.parsers.DocumentBuilderFactory -import io.spring.gradle.dependencymanagement.org.apache.maven.model.Model -import io.spring.gradle.dependencymanagement.org.apache.maven.model.io.xpp3.MavenXpp3Reader import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.NamedDomainObjectProvider @@ -48,6 +47,9 @@ import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.Internal import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.TaskAction +import org.w3c.dom.Document +import org.w3c.dom.Element +import org.w3c.dom.NodeList /** * Grails Bom files define their dependencies in a series of maps, this task takes those maps and generates an @@ -259,91 +261,229 @@ abstract class ExtractDependenciesTask extends DefaultTask { Properties populatePlatformDependencies(CoordinateVersionHolder bomCoordinates, List exclusionRules, Map constraints, boolean error = true, int level = 0) { Dependency bomDependency = dependencyHandler.create("${bomCoordinates.coordinates}@pom") Configuration dependencyConfiguration = configurationContainer.detachedConfiguration(bomDependency) + dependencyConfiguration.transitive = false File bomPomFile = dependencyConfiguration.singleFile - MavenXpp3Reader reader = new MavenXpp3Reader() - Model model = reader.read(new FileReader(bomPomFile)) - + Document doc = parsePom(bomPomFile) Properties versionProperties = new Properties() - if (model.parent) { - // Need to populate the parent bom if it's present first - CoordinateVersionHolder parentBom = new CoordinateVersionHolder( - groupId: model.parent.groupId, - artifactId: model.parent.artifactId, - version: model.parent.version - ) + + // Parent POM populated first so its properties can be overridden by the child + CoordinateVersionHolder parentBom = readParentCoordinates(doc) + if (parentBom) { populatePlatformDependencies(parentBom, exclusionRules, constraints, false, level + 1)?.entrySet()?.each { Map.Entry entry -> versionProperties.put(entry.key, entry.value) } } - model.properties.entrySet().each { Map.Entry entry -> - versionProperties.put(entry.key, entry.value) + + readProperties(doc).each { String name, String value -> + versionProperties.put(name, value) } versionProperties.put('project.groupId', bomCoordinates.groupId) versionProperties.put('project.version', bomCoordinates.version) - if (model.dependencyManagement && model.dependencyManagement.dependencies) { - for (io.spring.gradle.dependencymanagement.org.apache.maven.model.Dependency depItem : model.dependencyManagement.dependencies) { - CoordinateHolder baseCoordinates = new CoordinateHolder( - groupId: depItem.groupId, - artifactId: depItem.artifactId - ) - - CoordinateHolder resolvedCoordinates = new CoordinateHolder( - groupId: resolveMavenProperty(baseCoordinates.coordinatesWithoutVersion, depItem.groupId, versionProperties), - artifactId: resolveMavenProperty(baseCoordinates.coordinatesWithoutVersion, depItem.artifactId, versionProperties) - ) - - if (!constraints.containsKey(resolvedCoordinates)) { - boolean isExcluded = exclusionRules.any { CoordinateHolder excludedCoordinate -> - if (excludedCoordinate.groupId && excludedCoordinate.artifactId) { - return resolvedCoordinates == excludedCoordinate - } - - if (excludedCoordinate.groupId && !excludedCoordinate.artifactId) { - return depItem.groupId == excludedCoordinate.groupId - } - - if (!excludedCoordinate.groupId && excludedCoordinate.artifactId) { - return depItem.artifactId == excludedCoordinate.artifactId - } - - false - } - - if (!isExcluded) { - String resolvedVersion = resolveMavenProperty(resolvedCoordinates.coordinatesWithoutVersion, depItem.version, versionProperties) - String propertyName = depItem.version.contains('$') ? depItem.version : null - ExtractedDependencyConstraint constraint = new ExtractedDependencyConstraint( - groupId: resolvedCoordinates.groupId, artifactId: resolvedCoordinates.artifactId, - version: resolvedVersion, versionPropertyReference: propertyName, source: bomCoordinates.artifactId - ) - if (depItem.scope == 'import') { - constraints.put(resolvedCoordinates, constraint) - - CoordinateVersionHolder resolvedBomCoordinates = new CoordinateVersionHolder( - groupId: resolvedCoordinates.groupId, - artifactId: resolvedCoordinates.artifactId, - version: resolvedVersion - ) - populatePlatformDependencies(resolvedBomCoordinates, exclusionRules, constraints, error, level + 1) - } else { - constraints.put(resolvedCoordinates, constraint) - } - } - } - } - } else { + List managedDependencies = readManagedDependencies(doc) + if (managedDependencies.isEmpty()) { if (error) { // only the boms we directly include need to error since we expect a dependency management; // parent boms are sometimes use to share properties so we need to not error on these cases throw new GradleException("BOM ${bomCoordinates.coordinates} has no dependencyManagement section.") } + return versionProperties + } + + for (ManagedDependency depItem : managedDependencies) { + CoordinateHolder baseCoordinates = new CoordinateHolder( + groupId: depItem.groupId, + artifactId: depItem.artifactId + ) + + CoordinateHolder resolvedCoordinates = new CoordinateHolder( + groupId: resolveMavenProperty(baseCoordinates.coordinatesWithoutVersion, depItem.groupId, versionProperties), + artifactId: resolveMavenProperty(baseCoordinates.coordinatesWithoutVersion, depItem.artifactId, versionProperties) + ) + + if (constraints.containsKey(resolvedCoordinates)) { + continue + } + + boolean isExcluded = exclusionRules.any { CoordinateHolder excludedCoordinate -> + if (excludedCoordinate.groupId && excludedCoordinate.artifactId) { + return resolvedCoordinates == excludedCoordinate + } + + if (excludedCoordinate.groupId && !excludedCoordinate.artifactId) { + return depItem.groupId == excludedCoordinate.groupId + } + + if (!excludedCoordinate.groupId && excludedCoordinate.artifactId) { + return depItem.artifactId == excludedCoordinate.artifactId + } + + false + } + + if (isExcluded) { + continue + } + + String resolvedVersion = resolveMavenProperty(resolvedCoordinates.coordinatesWithoutVersion, depItem.version, versionProperties) + String propertyName = depItem.version?.contains('$') ? depItem.version : null + ExtractedDependencyConstraint constraint = new ExtractedDependencyConstraint( + groupId: resolvedCoordinates.groupId, artifactId: resolvedCoordinates.artifactId, + version: resolvedVersion, versionPropertyReference: propertyName, source: bomCoordinates.artifactId + ) + constraints.put(resolvedCoordinates, constraint) + + if (depItem.scope == 'import') { + CoordinateVersionHolder resolvedBomCoordinates = new CoordinateVersionHolder( + groupId: resolvedCoordinates.groupId, + artifactId: resolvedCoordinates.artifactId, + version: resolvedVersion + ) + populatePlatformDependencies(resolvedBomCoordinates, exclusionRules, constraints, error, level + 1) + } } versionProperties } + /** + * Parses a BOM POM file using the JDK's built-in {@link DocumentBuilderFactory}. + * XML parsing is hardened against XXE / XInclude attacks. + */ + private static Document parsePom(File pomFile) { + 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) + } + + private static CoordinateVersionHolder readParentCoordinates(Document doc) { + NodeList parentNodes = doc.documentElement.getElementsByTagName('parent') + if (parentNodes.length == 0) { + return null + } + // Only consider elements that are direct children of ; + // a nested parent (e.g. inside ) would be a malformed POM. + Element parent = null + for (int i = 0; i < parentNodes.length; i++) { + Element candidate = (Element) parentNodes.item(i) + if (candidate.parentNode == doc.documentElement) { + parent = candidate + break + } + } + if (parent == null) { + return null + } + String groupId = childText(parent, 'groupId') + String artifactId = childText(parent, 'artifactId') + String version = childText(parent, 'version') + if (!groupId || !artifactId || !version) { + return null + } + return new CoordinateVersionHolder(groupId: groupId, artifactId: artifactId, version: version) + } + + private static Map readProperties(Document doc) { + Map result = [:] + NodeList propsNodes = doc.documentElement.getElementsByTagName('properties') + if (propsNodes.length == 0) { + return result + } + // Only consider the top-level , not properties nested inside other elements + Element props = null + for (int i = 0; i < propsNodes.length; i++) { + Element candidate = (Element) propsNodes.item(i) + if (candidate.parentNode == doc.documentElement) { + props = candidate + break + } + } + if (props == null) { + return result + } + NodeList children = props.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 != null) { + result.put(name, value) + } + } + } + return result + } + + private static List readManagedDependencies(Document doc) { + List result = [] + NodeList depMgmtNodes = doc.documentElement.getElementsByTagName('dependencyManagement') + if (depMgmtNodes.length == 0) { + return result + } + // Only the top-level on + Element depMgmt = null + for (int i = 0; i < depMgmtNodes.length; i++) { + Element candidate = (Element) depMgmtNodes.item(i) + if (candidate.parentNode == doc.documentElement) { + depMgmt = candidate + break + } + } + if (depMgmt == null) { + return result + } + NodeList depsNodes = depMgmt.getElementsByTagName('dependencies') + if (depsNodes.length == 0) { + return result + } + Element depsEl = (Element) depsNodes.item(0) + NodeList depNodes = depsEl.getElementsByTagName('dependency') + for (int i = 0; i < depNodes.length; i++) { + Element dep = (Element) depNodes.item(i) + result.add(new ManagedDependency( + groupId: childText(dep, 'groupId'), + artifactId: childText(dep, 'artifactId'), + version: childText(dep, 'version'), + scope: childText(dep, 'scope') + )) + } + return result + } + + private static String childText(Element parent, String childTagName) { + NodeList children = parent.getElementsByTagName(childTagName) + if (children.length == 0) { + return null + } + // Only consider direct children, not nested same-named tags + for (int i = 0; i < children.length; i++) { + Element candidate = (Element) children.item(i) + if (candidate.parentNode == parent) { + return candidate.textContent?.trim() + } + } + return null + } + + /** + * Plain data carrier mirroring the four fields we used from Spring DM's + * shaded Maven model {@code Dependency}. Kept private and minimal so the + * task has no external Maven-model dependency. + */ + private static class ManagedDependency { + String groupId + String artifactId + String version + String scope + } + private String resolveMavenProperty(String errorDescription, String dynamicVersion, Map properties, int maxIterations = 10) { Pattern dynamicPattern = ~/\$\{([^}]+)\}/ String expandedVersion = dynamicVersion diff --git a/cyclonedx-plugin-temp b/cyclonedx-plugin-temp new file mode 160000 index 00000000000..57915ae1fe6 --- /dev/null +++ b/cyclonedx-plugin-temp @@ -0,0 +1 @@ +Subproject commit 57915ae1fe6f6e5234115c8e1fb0762e49c6fe1d diff --git a/gradlew.lf b/gradlew.lf new file mode 100644 index 00000000000..adff685a034 --- /dev/null +++ b/gradlew.lf @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 3c8f4d03736..38a10bb2faa 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -510,6 +510,21 @@ If you require Spring DM's "BOM wins always" behaviour for a given configuration NOTE: Projects that apply the `grails-micronaut` plugin already require `enforcedPlatform` for unrelated reasons (see section 7.2); those projects retain Spring DM's strict-version semantics automatically. +**Opting out of the auto-applied BOM.** +The Grails Gradle plugin applies `grails-bom` automatically by default - this preserves the Grails 7 behaviour and is the right choice for the vast majority of applications. +You can disable it if you intentionally want to manage Grails dependency versions yourself (for example, when consuming Grails modules from a different curated platform): + +[source,groovy] +.build.gradle +---- +grails { + autoApplyBom = false +} +---- + +When disabled, the Grails Gradle plugin will not declare `platform("org.apache.grails:grails-bom:$grailsVersion")` on your configurations and will not apply the `org.apache.grails.gradle.bom-property-overrides` plugin. +You are responsible for declaring the BOM yourself and, if you still want `gradle.properties` / `ext['...']` overrides, applying `id 'org.apache.grails.gradle.bom-property-overrides'` explicitly. + ==== 15. Spring Boot Starter Renames Spring Boot 4 renamed several starters as part of its modularization effort. diff --git a/grails-forge/gradlew.lf b/grails-forge/gradlew.lf new file mode 100644 index 00000000000..adff685a034 --- /dev/null +++ b/grails-forge/gradlew.lf @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy index 32bed260aaa..d8351367d7a 100644 --- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy @@ -18,10 +18,14 @@ */ package org.grails.gradle.plugin.bom +import java.util.function.Function + import groovy.transform.CompileStatic import org.gradle.api.Project import org.gradle.api.artifacts.Configuration +import org.gradle.api.artifacts.ConfigurationContainer import org.gradle.api.artifacts.DependencyResolveDetails +import org.gradle.api.artifacts.dsl.DependencyHandler import org.gradle.api.logging.Logger import org.gradle.api.logging.Logging import org.w3c.dom.Document @@ -62,29 +66,43 @@ class 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. + * Resolves a single BOM via captured Gradle services rather than a + * {@link Project} reference. Preferred for config-cache discipline: + * callers capture services once (typically inside a single + * {@code afterEvaluate} block) and never leak a {@link Project} + * reference into the override map that lives on past configuration time. * - * @param project the Gradle project (used for artifact resolution and property lookup) + * @param configurations the project's configuration container, captured at apply/afterEvaluate time + * @param dependencies the project's dependency handler, captured at apply/afterEvaluate time + * @param propertyLookup function returning the project's property value as a String, or {@code null} if unset * @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]) + static BomManagedVersions resolve(ConfigurationContainer configurations, + DependencyHandler dependencies, + Function propertyLookup, + String bomCoordinates) { + return resolve(configurations, dependencies, propertyLookup, [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. + * Resolves multiple BOMs via captured Gradle services. The result is a + * plain data carrier (a {@code Map} of version overrides + * inside {@link BomManagedVersions}) that holds no {@link Project} + * reference, so it can be safely captured by per-configuration + * {@code eachDependency} closures and survive configuration-cache + * serialization. * - * @param project the Gradle project (used for artifact resolution and property lookup) + * @param configurations the project's configuration container, captured at apply/afterEvaluate time + * @param dependencies the project's dependency handler, captured at apply/afterEvaluate time + * @param propertyLookup function returning the project's property value as a String, or {@code null} if unset * @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) { + static BomManagedVersions resolve(ConfigurationContainer configurations, + DependencyHandler dependencies, + Function propertyLookup, + Collection bomCoordinatesList) { def instance = new BomManagedVersions() def bomProperties = new LinkedHashMap() @@ -97,13 +115,14 @@ class BomManagedVersions { LOG.warn('Invalid BOM coordinates: {}', bomCoordinates) continue } - processBom(project, parts[0], parts[1], parts[2], bomProperties, propertyToArtifacts, processed) + processBom(configurations, dependencies, parts[0], parts[1], parts[2], + bomProperties, propertyToArtifacts, processed) } for (def entry : propertyToArtifacts.entrySet()) { def propertyName = entry.key - if (project.hasProperty(propertyName)) { - def overrideVersion = project.property(propertyName).toString() + def overrideVersion = propertyLookup.apply(propertyName) + if (overrideVersion != null) { def defaultVersion = bomProperties.get(propertyName) if (overrideVersion != defaultVersion) { @@ -125,6 +144,37 @@ class BomManagedVersions { return instance } + /** + * Convenience overload that captures services from the given {@link Project}. + * Production callers should prefer the services-based overload above so the + * resolve path never sees a {@link Project} reference. This overload is + * primarily useful for tests and ad-hoc usage. + * + * @param project the Gradle project (services are extracted at call time) + * @param bomCoordinates the BOM coordinates in {@code group:artifact:version} format + */ + static BomManagedVersions resolve(Project project, String bomCoordinates) { + return resolve(project, [bomCoordinates]) + } + + /** + * Convenience overload that captures services from the given {@link Project}. + * Production callers should prefer the services-based overload above so the + * resolve path never sees a {@link Project} reference. This overload is + * primarily useful for tests and ad-hoc usage. + * + * @param project the Gradle project (services are extracted at call time) + * @param bomCoordinatesList list of BOM coordinates in {@code group:artifact:version} format + */ + static BomManagedVersions resolve(Project project, Collection bomCoordinatesList) { + return resolve( + project.configurations, + project.dependencies, + { String name -> project.hasProperty(name) ? project.property(name)?.toString() : null } as Function, + bomCoordinatesList + ) + } + /** * Applies version overrides to a Gradle configuration's resolution strategy. * @@ -210,7 +260,9 @@ class BomManagedVersions { } private static void processBom( - Project project, String group, String artifact, String version, + ConfigurationContainer configurations, + DependencyHandler dependencies, + String group, String artifact, String version, Map bomProperties, Map> propertyToArtifacts, Set processed @@ -220,7 +272,7 @@ class BomManagedVersions { return } - def pomFile = resolvePomFile(project, group, artifact, version) + def pomFile = resolvePomFile(configurations, dependencies, group, artifact, version) if (pomFile == null) { return } @@ -231,13 +283,15 @@ class BomManagedVersions { } extractProperties(doc, bomProperties) - processManagedDependencies(doc, project, bomProperties, propertyToArtifacts, processed) + processManagedDependencies(doc, configurations, dependencies, bomProperties, propertyToArtifacts, processed) } - private static File resolvePomFile(Project project, String group, String artifact, String version) { + private static File resolvePomFile(ConfigurationContainer configurations, + DependencyHandler dependencies, + String group, String artifact, String version) { try { - def detached = project.configurations.detachedConfiguration( - project.dependencies.create("${group}:${artifact}:${version}@pom" as String) + def detached = configurations.detachedConfiguration( + dependencies.create("${group}:${artifact}:${version}@pom" as String) ) detached.transitive = false return detached.singleFile @@ -286,7 +340,9 @@ class BomManagedVersions { } private static void processManagedDependencies( - Document doc, Project project, + Document doc, + ConfigurationContainer configurations, + DependencyHandler dependencies, Map bomProperties, Map> propertyToArtifacts, Set processed @@ -319,7 +375,7 @@ class BomManagedVersions { if ('import' == depScope) { def resolvedVersion = interpolateProperties(depVersion, bomProperties) if (resolvedVersion) { - processBom(project, depGroupId, depArtifactId, resolvedVersion, + processBom(configurations, dependencies, depGroupId, depArtifactId, resolvedVersion, bomProperties, propertyToArtifacts, processed) } continue diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy index 60d36cde081..48537bafa81 100644 --- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy @@ -18,12 +18,16 @@ */ package org.grails.gradle.plugin.bom +import java.util.function.Function + 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.ConfigurationContainer import org.gradle.api.artifacts.Dependency import org.gradle.api.artifacts.ModuleDependency +import org.gradle.api.artifacts.dsl.DependencyHandler import org.gradle.api.attributes.Category /** @@ -92,20 +96,47 @@ class BomPropertyOverridesPlugin implements Plugin { project.objects ) + // We wait until afterEvaluate to scan the project's declared platforms + // and resolve their POMs, because the user typically declares + // platform() dependencies and configures the bomPropertyOverrides + // extension in the same build.gradle that applies this plugin. + // + // The afterEvaluate callback itself is a configuration-time callback, + // not serialised into the configuration cache. We capture Gradle + // services (ConfigurationContainer, DependencyHandler) and a property + // lookup function at the boundary of this callback and hand them to + // the resolver, so the resulting BomManagedVersions instance carries + // no Project reference. The per-configuration eachDependency closures + // installed by applyOverrides() capture only the resulting + // Map of overrides, which is fully serialisable and + // safe for the configuration cache. + // + // Verified with `./gradlew --configuration-cache`: zero CC warnings + // originate from this plugin or from BomManagedVersions. project.afterEvaluate { - applyOverrides(project, extension) + applyOverrides( + project.configurations, + project.dependencies, + { String name -> project.hasProperty(name) ? project.property(name)?.toString() : null } as Function, + extension + ) } } /** * Resolves the configured BOMs and applies any version overrides found - * to all project configurations. Visible for testing. + * to all project configurations. Takes captured Gradle services rather + * than a {@link Project} so that the resolve path holds no + * configuration-cache-hostile state. Visible for testing. */ - static void applyOverrides(Project project, BomPropertyOverridesExtension extension) { + static void applyOverrides(ConfigurationContainer configurations, + DependencyHandler dependencies, + Function propertyLookup, + BomPropertyOverridesExtension extension) { def bomCoordinates = new LinkedHashSet() if (extension.autoDetect.get()) { - bomCoordinates.addAll(detectDeclaredBoms(project)) + bomCoordinates.addAll(detectDeclaredBoms(configurations)) } for (String explicit : extension.boms.get()) { @@ -118,12 +149,12 @@ class BomPropertyOverridesPlugin implements Plugin { return } - def managedVersions = BomManagedVersions.resolve(project, bomCoordinates) + def managedVersions = BomManagedVersions.resolve(configurations, dependencies, propertyLookup, bomCoordinates) if (!managedVersions.hasOverrides()) { return } - project.configurations.configureEach { Configuration conf -> + configurations.configureEach { Configuration conf -> managedVersions.applyTo(conf) } } @@ -131,12 +162,13 @@ class BomPropertyOverridesPlugin implements Plugin { /** * Scans every configuration for declared {@code platform()} or * {@code enforcedPlatform()} dependencies and returns their coordinates. - * Visible for testing. + * Takes a {@link ConfigurationContainer} rather than a {@link Project} + * so the call path stays free of Project references. Visible for testing. */ - static Set detectDeclaredBoms(Project project) { + static Set detectDeclaredBoms(ConfigurationContainer configurations) { def coordinates = new LinkedHashSet() - project.configurations.each { Configuration conf -> + configurations.each { Configuration conf -> for (Dependency dep : conf.dependencies) { if (!(dep instanceof ModuleDependency)) { continue 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 b3709533396..3558e2d0187 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 @@ -44,6 +44,7 @@ class GrailsExtension { this.pluginDefiner = new PluginDefiner(project) this.indy = project.objects.property(Boolean).convention(false) this.preserveParameterNames = project.objects.property(Boolean).convention(true) + this.autoApplyBom = project.objects.property(Boolean).convention(true) } /** @@ -126,6 +127,32 @@ class GrailsExtension { */ final Property preserveParameterNames + /** + * Whether the Grails Gradle plugin should automatically apply the {@code grails-bom} + * as a Gradle {@code platform()} on every declarable project configuration + * (and apply the {@code org.apache.grails.gradle.bom-property-overrides} plugin + * for property-based version overrides). + * + *

Defaults to {@code true}, which matches the behaviour of every Grails 7 release: + * the BOM is always applied so that the framework's curated managed-dependency set + * is the source of truth for the application.

+ * + *

Disable this only when you intentionally want to manage Grails dependencies + * yourself - for example, when consuming Grails modules from a different curated + * platform and you need to declare the BOM by hand (and apply + * {@code org.apache.grails.gradle.bom-property-overrides} explicitly if you still + * want {@code gradle.properties} / {@code ext['...']} overrides).

+ * + *
+     * grails {
+     *     autoApplyBom = false
+     * }
+     * 
+ * + * @since 8.0 + */ + final Property autoApplyBom + DependencyHandler getPlugins() { if (pluginDefiner == null) { pluginDefiner = new PluginDefiner(project) 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 7df9332088e..e6efad97acb 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 @@ -401,39 +401,60 @@ ${importStatements} * @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. + // even if plugin ordering changes or Spring Boot is not applied. We do + // this outside afterEvaluate so that other plugins applied during the + // same configuration phase can rely on the configuration existing. 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)) + // The opt-out flag `grails { autoApplyBom = false }` is set in the user's + // build.gradle, which runs AFTER plugin apply. We therefore wait until + // afterEvaluate to read the flag and apply the BOM accordingly. By that + // point all declarable configurations exist (java-base creates them + // during apply), so iterating them eagerly via .each is sufficient - + // any plugin that adds a configuration later is responsible for + // declaring its own BOM coordination if it needs it. + project.afterEvaluate { + GrailsExtension grailsExtension = project.extensions.findByType(GrailsExtension) + boolean autoApply = grailsExtension == null || grailsExtension.autoApplyBom.getOrElse(true) + if (!autoApply) { + project.logger.info( + 'grails.autoApplyBom is false; skipping automatic application of platform(grails-bom) and bom-property-overrides plugin for project {}', + project.path + ) + return } - } - // 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) + String grailsVersion = (project.findProperty('grailsVersion') ?: BuildSettings.grailsVersion) as String + String bomCoordinates = "org.apache.grails:grails-bom:${grailsVersion}" as String + + // 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.each { Configuration conf -> + if (conf.canBeDeclared && !isExcludedFromBomPlatform(conf.name)) { + project.dependencies.add(conf.name, project.dependencies.platform(bomCoordinates)) + } + } + + // Delegate property-based version overrides to the bundled 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) { diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginSpec.groovy index 2210a2e8349..5ebdd1976d3 100644 --- a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginSpec.groovy +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginSpec.groovy @@ -86,7 +86,7 @@ class BomPropertyOverridesPluginSpec extends Specification { ) when: - def coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project) + def coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project.configurations) then: 'org.example:test-bom:1.0.0' in coordinates @@ -102,7 +102,7 @@ class BomPropertyOverridesPluginSpec extends Specification { ) when: - def coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project) + def coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project.configurations) then: 'org.example:enforced-bom:2.0.0' in coordinates @@ -115,7 +115,7 @@ class BomPropertyOverridesPluginSpec extends Specification { project.dependencies.add('implementation', 'org.example:regular-lib:1.0.0') when: - def coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project) + def coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project.configurations) then: coordinates.isEmpty() @@ -135,7 +135,7 @@ class BomPropertyOverridesPluginSpec extends Specification { ) when: - def coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project) + def coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project.configurations) then: coordinates == ['org.example:shared-bom:1.0.0'] as Set diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/AutoApplyBomSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/AutoApplyBomSpec.groovy new file mode 100644 index 00000000000..9c27bc5f309 --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/AutoApplyBomSpec.groovy @@ -0,0 +1,48 @@ +/* + * 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 test verifying that {@code grails.autoApplyBom = false} suppresses + * the automatic application of {@code platform(grails-bom)} and the + * {@code org.apache.grails.gradle.bom-property-overrides} plugin. + * + * @since 8.0 + * @see GrailsExtension#getAutoApplyBom + * @see GrailsGradlePlugin#applyGrailsBom + */ +class AutoApplyBomSpec extends GradleSpecification { + + def "autoApplyBom = false suppresses platform(grails-bom) and bom-property-overrides plugin"() { + given: + setupTestResourceProject('auto-apply-bom-disabled') + + when: + def result = executeTask('inspectBomSetup') + + then: 'no platform(grails-bom) is added to implementation' + result.output.contains('HAS_PLATFORM_BOM=false') + + and: 'the bom-property-overrides plugin is NOT applied' + result.output.contains('HAS_BOM_PROPERTY_OVERRIDES=false') + + and: 'Spring DM is also not applied (regardless of autoApplyBom)' + result.output.contains('HAS_SPRING_DM=false') + } +} diff --git a/grails-gradle/plugins/src/test/resources/test-projects/auto-apply-bom-disabled/build.gradle b/grails-gradle/plugins/src/test/resources/test-projects/auto-apply-bom-disabled/build.gradle new file mode 100644 index 00000000000..8e8a74289fa --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/auto-apply-bom-disabled/build.gradle @@ -0,0 +1,23 @@ +plugins { + id 'org.apache.grails.gradle.grails-app' +} + +grails { + autoApplyBom = false +} + +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/auto-apply-bom-disabled/gradle.properties b/grails-gradle/plugins/src/test/resources/test-projects/auto-apply-bom-disabled/gradle.properties new file mode 100644 index 00000000000..35c332fb874 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/auto-apply-bom-disabled/gradle.properties @@ -0,0 +1 @@ +grailsVersion=__PROJECT_VERSION__ diff --git a/grails-gradle/plugins/src/test/resources/test-projects/auto-apply-bom-disabled/grails-app/conf/application.yml b/grails-gradle/plugins/src/test/resources/test-projects/auto-apply-bom-disabled/grails-app/conf/application.yml new file mode 100644 index 00000000000..4706b4393fd --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/auto-apply-bom-disabled/grails-app/conf/application.yml @@ -0,0 +1,2 @@ +grails: + profile: web diff --git a/grails-gradle/plugins/src/test/resources/test-projects/auto-apply-bom-disabled/settings.gradle b/grails-gradle/plugins/src/test/resources/test-projects/auto-apply-bom-disabled/settings.gradle new file mode 100644 index 00000000000..d0e306578c5 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/auto-apply-bom-disabled/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'test-auto-apply-bom-disabled' diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-property-overrides-basic/build.gradle b/grails-gradle/plugins/src/test/resources/test-projects/bom-property-overrides-basic/build.gradle index af6ae418a1b..5bf633ec7ef 100644 --- a/grails-gradle/plugins/src/test/resources/test-projects/bom-property-overrides-basic/build.gradle +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-property-overrides-basic/build.gradle @@ -16,7 +16,7 @@ tasks.register('inspectBomSetup') { println "AUTO_DETECT_DEFAULT=${extension?.autoDetect?.get()}" Set detected = org.grails.gradle.plugin.bom.BomPropertyOverridesPlugin - .detectDeclaredBoms(project) + .detectDeclaredBoms(project.configurations) println "DETECTED_BOMS=${detected.sort().join(',')}" } } diff --git a/start-grails-after-fix.png b/start-grails-after-fix.png new file mode 100644 index 0000000000000000000000000000000000000000..141a13198ab32a3cbb2707ce923671c2f4bd11cc GIT binary patch literal 34585 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

< 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 JDj@HI{u^+$H^2Y@ literal 0 HcmV?d00001 From 882932312e0c69f731609b8f619ae01e63a18645 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 21 May 2026 20:26:57 -0400 Subject: [PATCH 13/23] chore: remove accidentally-committed local cruft from previous commit --- .../ses_1b3c2d94dffeehtmCE00QwHQzS.json | 10 - .../ses_1b96c228effeTZuclUToRUORl7.json | 10 - .../ses_1ba57982dffetPNLKNXYPxd2Ep.json | 10 - .../ses_1d408b4b1ffeywzBKStVd53F7m.json | 10 - .../ses_1e8e4afb1ffeULcqOdvaKEZY54.json | 10 - .../ses_1f72301eeffexh3D3I7MHLmoeG.json | 10 - .../page-2026-05-08T21-50-43-518Z.yml | 1 - .../page-2026-05-08T22-23-47-298Z.yml | 1 - .../ses_1d408b4b1ffeywzBKStVd53F7m.json | 10 - .../ses_1e8e4afb1ffeULcqOdvaKEZY54.json | 10 - .../ses_1f72301eeffexh3D3I7MHLmoeG.json | 10 - cyclonedx-plugin-temp | 1 - gradlew.lf | 248 ------------------ grails-forge/gradlew.lf | 248 ------------------ start-grails-after-fix.png | Bin 34585 -> 0 bytes 15 files changed, 589 deletions(-) delete mode 100644 .omo/run-continuation/ses_1b3c2d94dffeehtmCE00QwHQzS.json delete mode 100644 .omo/run-continuation/ses_1b96c228effeTZuclUToRUORl7.json delete mode 100644 .omo/run-continuation/ses_1ba57982dffetPNLKNXYPxd2Ep.json delete mode 100644 .omo/run-continuation/ses_1d408b4b1ffeywzBKStVd53F7m.json delete mode 100644 .omo/run-continuation/ses_1e8e4afb1ffeULcqOdvaKEZY54.json delete mode 100644 .omo/run-continuation/ses_1f72301eeffexh3D3I7MHLmoeG.json delete mode 100644 .playwright-mcp/page-2026-05-08T21-50-43-518Z.yml delete mode 100644 .playwright-mcp/page-2026-05-08T22-23-47-298Z.yml delete mode 100644 .sisyphus/run-continuation/ses_1d408b4b1ffeywzBKStVd53F7m.json delete mode 100644 .sisyphus/run-continuation/ses_1e8e4afb1ffeULcqOdvaKEZY54.json delete mode 100644 .sisyphus/run-continuation/ses_1f72301eeffexh3D3I7MHLmoeG.json delete mode 160000 cyclonedx-plugin-temp delete mode 100644 gradlew.lf delete mode 100644 grails-forge/gradlew.lf delete mode 100644 start-grails-after-fix.png diff --git a/.omo/run-continuation/ses_1b3c2d94dffeehtmCE00QwHQzS.json b/.omo/run-continuation/ses_1b3c2d94dffeehtmCE00QwHQzS.json deleted file mode 100644 index 91a8fd30453..00000000000 --- a/.omo/run-continuation/ses_1b3c2d94dffeehtmCE00QwHQzS.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_1b3c2d94dffeehtmCE00QwHQzS", - "updatedAt": "2026-05-21T21:59:21.069Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-05-21T21:59:21.069Z" - } - } -} \ No newline at end of file diff --git a/.omo/run-continuation/ses_1b96c228effeTZuclUToRUORl7.json b/.omo/run-continuation/ses_1b96c228effeTZuclUToRUORl7.json deleted file mode 100644 index f8464d004f5..00000000000 --- a/.omo/run-continuation/ses_1b96c228effeTZuclUToRUORl7.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_1b96c228effeTZuclUToRUORl7", - "updatedAt": "2026-05-20T18:13:41.952Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-05-20T18:13:41.952Z" - } - } -} \ No newline at end of file diff --git a/.omo/run-continuation/ses_1ba57982dffetPNLKNXYPxd2Ep.json b/.omo/run-continuation/ses_1ba57982dffetPNLKNXYPxd2Ep.json deleted file mode 100644 index e783e2c6ee8..00000000000 --- a/.omo/run-continuation/ses_1ba57982dffetPNLKNXYPxd2Ep.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_1ba57982dffetPNLKNXYPxd2Ep", - "updatedAt": "2026-05-20T15:51:26.090Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-05-20T15:51:26.090Z" - } - } -} \ No newline at end of file diff --git a/.omo/run-continuation/ses_1d408b4b1ffeywzBKStVd53F7m.json b/.omo/run-continuation/ses_1d408b4b1ffeywzBKStVd53F7m.json deleted file mode 100644 index d69f13b6422..00000000000 --- a/.omo/run-continuation/ses_1d408b4b1ffeywzBKStVd53F7m.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_1d408b4b1ffeywzBKStVd53F7m", - "updatedAt": "2026-05-15T15:27:15.637Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-05-15T15:27:15.637Z" - } - } -} \ No newline at end of file diff --git a/.omo/run-continuation/ses_1e8e4afb1ffeULcqOdvaKEZY54.json b/.omo/run-continuation/ses_1e8e4afb1ffeULcqOdvaKEZY54.json deleted file mode 100644 index 726500d26e6..00000000000 --- a/.omo/run-continuation/ses_1e8e4afb1ffeULcqOdvaKEZY54.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_1e8e4afb1ffeULcqOdvaKEZY54", - "updatedAt": "2026-05-11T13:01:45.805Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-05-11T13:01:45.805Z" - } - } -} \ No newline at end of file diff --git a/.omo/run-continuation/ses_1f72301eeffexh3D3I7MHLmoeG.json b/.omo/run-continuation/ses_1f72301eeffexh3D3I7MHLmoeG.json deleted file mode 100644 index 9fff71afe73..00000000000 --- a/.omo/run-continuation/ses_1f72301eeffexh3D3I7MHLmoeG.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_1f72301eeffexh3D3I7MHLmoeG", - "updatedAt": "2026-05-08T18:41:18.111Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-05-08T18:41:18.111Z" - } - } -} \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-08T21-50-43-518Z.yml b/.playwright-mcp/page-2026-05-08T21-50-43-518Z.yml deleted file mode 100644 index 7703770e389..00000000000 --- a/.playwright-mcp/page-2026-05-08T21-50-43-518Z.yml +++ /dev/null @@ -1 +0,0 @@ -- img [ref=e4] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-08T22-23-47-298Z.yml b/.playwright-mcp/page-2026-05-08T22-23-47-298Z.yml deleted file mode 100644 index 7703770e389..00000000000 --- a/.playwright-mcp/page-2026-05-08T22-23-47-298Z.yml +++ /dev/null @@ -1 +0,0 @@ -- img [ref=e4] \ No newline at end of file diff --git a/.sisyphus/run-continuation/ses_1d408b4b1ffeywzBKStVd53F7m.json b/.sisyphus/run-continuation/ses_1d408b4b1ffeywzBKStVd53F7m.json deleted file mode 100644 index d69f13b6422..00000000000 --- a/.sisyphus/run-continuation/ses_1d408b4b1ffeywzBKStVd53F7m.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_1d408b4b1ffeywzBKStVd53F7m", - "updatedAt": "2026-05-15T15:27:15.637Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-05-15T15:27:15.637Z" - } - } -} \ No newline at end of file diff --git a/.sisyphus/run-continuation/ses_1e8e4afb1ffeULcqOdvaKEZY54.json b/.sisyphus/run-continuation/ses_1e8e4afb1ffeULcqOdvaKEZY54.json deleted file mode 100644 index 726500d26e6..00000000000 --- a/.sisyphus/run-continuation/ses_1e8e4afb1ffeULcqOdvaKEZY54.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_1e8e4afb1ffeULcqOdvaKEZY54", - "updatedAt": "2026-05-11T13:01:45.805Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-05-11T13:01:45.805Z" - } - } -} \ No newline at end of file diff --git a/.sisyphus/run-continuation/ses_1f72301eeffexh3D3I7MHLmoeG.json b/.sisyphus/run-continuation/ses_1f72301eeffexh3D3I7MHLmoeG.json deleted file mode 100644 index 9fff71afe73..00000000000 --- a/.sisyphus/run-continuation/ses_1f72301eeffexh3D3I7MHLmoeG.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_1f72301eeffexh3D3I7MHLmoeG", - "updatedAt": "2026-05-08T18:41:18.111Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-05-08T18:41:18.111Z" - } - } -} \ No newline at end of file diff --git a/cyclonedx-plugin-temp b/cyclonedx-plugin-temp deleted file mode 160000 index 57915ae1fe6..00000000000 --- a/cyclonedx-plugin-temp +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 57915ae1fe6f6e5234115c8e1fb0762e49c6fe1d diff --git a/gradlew.lf b/gradlew.lf deleted file mode 100644 index adff685a034..00000000000 --- a/gradlew.lf +++ /dev/null @@ -1,248 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/grails-forge/gradlew.lf b/grails-forge/gradlew.lf deleted file mode 100644 index adff685a034..00000000000 --- a/grails-forge/gradlew.lf +++ /dev/null @@ -1,248 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/start-grails-after-fix.png b/start-grails-after-fix.png deleted file mode 100644 index 141a13198ab32a3cbb2707ce923671c2f4bd11cc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34585 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

< 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 JDj@HI{u^+$H^2Y@ From 545b84749570e1d52e7110dece4d28d557edf6b6 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Mon, 1 Jun 2026 20:28:26 -0400 Subject: [PATCH 14/23] fix: correct BOM property-override edge cases found in review Three correctness fixes to the new Gradle platform + BOM property-override feature, identified during review of PR #15467: 1. Honor the deprecated `springDependencyManagement` opt-out. `applyGrailsBom()` only checked `autoApplyBom`, so `grails { springDependencyManagement = false }` (a documented Grails 7 opt-out still used by grails-extension-gradle-config.gradle across the build, and by external projects) had become a silent no-op that unexpectedly applied `platform(grails-bom)`. The deprecated setter now maps `false` onto `autoApplyBom = false`. 2. Apply property overrides as strict dependency constraints instead of `ResolutionStrategy.eachDependency().useVersion()`. A soft override loses to a platform's `require` constraint during conflict resolution, so a downgrade override was silently ignored. A strict constraint wins in both directions. 3. Honor imported-BOM property overrides. `BomManagedVersions` now computes every managed artifact's version twice (with BOM defaults and with project overrides, including imported-BOM selector versions) and records the difference as an override. Every managed entry is considered, not only `${property}` references, so overriding a property that selects an imported BOM (e.g. `spring-boot.version`) re-imports that BOM and picks up its updated managed set, including hardcoded versions. A BOM's own direct entries take precedence over imported ones. Adds GrailsExtensionSpec and a BomOverrideResolutionFunctionalSpec backed by a local-Maven-repo fixture that resolves real dependencies to prove the downgrade override and the imported-BOM (hardcoded) cascade. Assisted-by: claude-code:claude-4.8-opus --- .../src/en/guide/upgrading/upgrading80x.adoc | 4 +- .../plugin/bom/BomManagedVersions.groovy | 211 ++++++++++++------ .../bom/BomPropertyOverridesPlugin.groovy | 24 +- .../gradle/plugin/core/GrailsExtension.groovy | 23 +- ...BomOverrideResolutionFunctionalSpec.groovy | 61 +++++ .../plugin/core/GrailsExtensionSpec.groovy | 89 ++++++++ .../bom-override-resolution/build.gradle | 39 ++++ .../child-bom/1.0.0/child-bom-1.0.0.pom | 23 ++ .../child-bom/2.0.0/child-bom-2.0.0.pom | 23 ++ .../example/childlib/1.0.0/childlib-1.0.0.pom | 9 + .../example/childlib/2.0.0/childlib-2.0.0.pom | 9 + .../org/example/mylib/1.0.0/mylib-1.0.0.pom | 9 + .../org/example/mylib/2.0.0/mylib-2.0.0.pom | 9 + .../example/test-bom/1.0.0/test-bom-1.0.0.pom | 32 +++ .../bom-override-resolution/settings.gradle | 1 + 15 files changed, 485 insertions(+), 81 deletions(-) create mode 100644 grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomOverrideResolutionFunctionalSpec.groovy create mode 100644 grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/GrailsExtensionSpec.groovy create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/build.gradle create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/child-bom/1.0.0/child-bom-1.0.0.pom create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/child-bom/2.0.0/child-bom-2.0.0.pom create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/childlib/1.0.0/childlib-1.0.0.pom create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/childlib/2.0.0/childlib-2.0.0.pom create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/mylib/1.0.0/mylib-1.0.0.pom create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/mylib/2.0.0/mylib-2.0.0.pom create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/test-bom/1.0.0/test-bom-1.0.0.pom create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/settings.gradle diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 2f86f0b1ec0..3a778d52c87 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -504,7 +504,9 @@ ext['slf4j.version'] = '2.0.9' slf4j.version=2.0.9 ---- -The `grails { springDependencyManagement = … }` flag on `GrailsExtension` is now a deprecated no-op retained for source compatibility; setting it logs a deprecation warning. +The `grails { springDependencyManagement = … }` flag on `GrailsExtension` is deprecated. +For backward compatibility, setting it to `false` is still honored and is equivalent to `autoApplyBom = false`: it suppresses the automatic `platform(grails-bom)` application, just as it previously prevented the Spring Dependency Management BOM from being applied. +New builds should use `grails { autoApplyBom = false }` instead. **Two Spring DM features have no automatic migration.** If your `build.gradle` uses either of the patterns below, update it as follows: diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy index d8351367d7a..dbbf21e39cf 100644 --- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy @@ -22,9 +22,9 @@ import java.util.function.Function import groovy.transform.CompileStatic import org.gradle.api.Project -import org.gradle.api.artifacts.Configuration import org.gradle.api.artifacts.ConfigurationContainer -import org.gradle.api.artifacts.DependencyResolveDetails +import org.gradle.api.artifacts.DependencyConstraint +import org.gradle.api.artifacts.MutableVersionConstraint import org.gradle.api.artifacts.dsl.DependencyHandler import org.gradle.api.logging.Logger import org.gradle.api.logging.Logging @@ -37,16 +37,29 @@ 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()}.

+ *

Parses BOM POM files to determine the version every managed artifact + * resolves to, both with the BOM's default {@code } values and + * with the project's overrides applied (via {@code ext['property.name']} in + * {@code build.gradle} or via {@code gradle.properties}). Any artifact whose + * effective version differs from the BOM default becomes a version override.

* - *

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 + *

Overrides are applied as strict dependency constraints + * (see {@link #applyTo(DependencyHandler, String)}). A strict constraint wins + * over the {@code require} constraints contributed by Gradle's native + * {@code platform()} mechanism, so an override is honored even when it + * downgrades a managed version - a plain + * {@code ResolutionStrategy.eachDependency()} / {@code useVersion()} hook + * would lose to the platform's higher version during conflict resolution.

+ * + *

Because the effective version is computed by re-resolving imported + * ({@code import}) BOMs with the project's property overrides + * applied, overriding a property that selects an imported BOM's version + * (for example {@code spring-boot.version}) re-imports that BOM and pulls in + * its updated managed-dependency set.

+ * + *

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 @@ -63,6 +76,9 @@ class BomManagedVersions { private static final Logger LOG = Logging.getLogger(BomManagedVersions) private static final int MAX_PROPERTY_INTERPOLATION_DEPTH = 10 + /** A property resolver that never overrides anything (BOM defaults only). */ + private static final Function NO_OVERRIDES = { String name -> null } as Function + private final Map versionOverrides = new LinkedHashMap<>() /** @@ -90,8 +106,13 @@ class BomManagedVersions { * plain data carrier (a {@code Map} of version overrides * inside {@link BomManagedVersions}) that holds no {@link Project} * reference, so it can be safely captured by per-configuration - * {@code eachDependency} closures and survive configuration-cache - * serialization. + * constraint declarations and survive configuration-cache serialization. + * + *

The override set is computed as the difference between two resolutions + * of the BOM tree: one using the BOM's default property values, and one + * using the project's property overrides. Any managed artifact whose + * effective version differs from its default version is recorded as an + * override.

* * @param configurations the project's configuration container, captured at apply/afterEvaluate time * @param dependencies the project's dependency handler, captured at apply/afterEvaluate time @@ -105,40 +126,27 @@ class BomManagedVersions { Collection bomCoordinatesList) { def instance = new BomManagedVersions() - def bomProperties = new LinkedHashMap() - def propertyToArtifacts = new LinkedHashMap>() - def processed = new HashSet() - - for (String bomCoordinates : bomCoordinatesList) { - def parts = bomCoordinates?.split(':') - if (parts == null || parts.length != 3) { - LOG.warn('Invalid BOM coordinates: {}', bomCoordinates) - continue - } - processBom(configurations, dependencies, parts[0], parts[1], parts[2], - bomProperties, propertyToArtifacts, processed) - } - - for (def entry : propertyToArtifacts.entrySet()) { - def propertyName = entry.key - def overrideVersion = propertyLookup.apply(propertyName) - if (overrideVersion != null) { - def defaultVersion = bomProperties.get(propertyName) - - if (overrideVersion != defaultVersion) { - for (def artifactKey : entry.value) { - instance.versionOverrides.put(artifactKey, overrideVersion) - } - LOG.lifecycle( - 'BOM version override: {} = {} (BOM default: {})', - propertyName, overrideVersion, defaultVersion ?: 'unknown' - ) - } + Map defaultVersions = computeManagedVersions( + configurations, dependencies, bomCoordinatesList, NO_OVERRIDES) + Map effectiveVersions = computeManagedVersions( + configurations, dependencies, bomCoordinatesList, propertyLookup) + + for (def entry : effectiveVersions.entrySet()) { + def artifactKey = entry.key + def effectiveVersion = entry.value + def defaultVersion = defaultVersions.get(artifactKey) + + if (effectiveVersion != null && effectiveVersion != defaultVersion) { + instance.versionOverrides.put(artifactKey, effectiveVersion) + LOG.info( + 'BOM version override: {} = {} (BOM default: {})', + artifactKey, effectiveVersion, defaultVersion ?: 'unknown' + ) } } if (!instance.versionOverrides.isEmpty()) { - LOG.info('BOM property overrides: {} version override(s) will be applied', instance.versionOverrides.size()) + LOG.lifecycle('BOM property overrides: {} version override(s) will be applied', instance.versionOverrides.size()) } return instance @@ -176,22 +184,28 @@ class BomManagedVersions { } /** - * Applies version overrides to a Gradle configuration's resolution strategy. + * Applies the detected version overrides to the given configuration as + * strict dependency constraints. * - * @param configuration the configuration to apply overrides to + *

Strict constraints are used deliberately: a plain {@code platform()} + * contributes {@code require} constraints, and a soft override (e.g. + * {@code useVersion()}) would lose to a higher {@code require} version + * during conflict resolution. A strict constraint overrides {@code require}, + * so the project's chosen version wins in both directions (upgrade and + * downgrade).

+ * + * @param dependencies the project's dependency handler + * @param configurationName the name of the configuration to add constraints to */ - void applyTo(Configuration configuration) { + void applyTo(DependencyHandler dependencies, String configurationName) { if (versionOverrides.isEmpty()) { return } - def overrides = this.versionOverrides - configuration.resolutionStrategy.eachDependency { DependencyResolveDetails details -> - def key = "${details.requested.group}:${details.requested.name}" as String - def override = overrides.get(key) - if (override != null) { - details.useVersion(override) - details.because('BOM version override via project property') + versionOverrides.each { String coordinate, String version -> + dependencies.constraints.add(configurationName, coordinate) { DependencyConstraint constraint -> + constraint.version { MutableVersionConstraint v -> v.strictly(version) } + constraint.because('BOM version override via project property') } } } @@ -259,12 +273,42 @@ class BomManagedVersions { } } + /** + * Walks the BOM tree and returns the effective version of every managed + * artifact, resolving property references (and imported-BOM versions) with + * the supplied {@code propertyResolver} taking precedence over the BOM's + * own {@code } defaults. + */ + private static Map computeManagedVersions( + ConfigurationContainer configurations, + DependencyHandler dependencies, + Collection bomCoordinatesList, + Function propertyResolver + ) { + def bomProperties = new LinkedHashMap() + def artifactVersions = new LinkedHashMap() + def processed = new HashSet() + + for (String bomCoordinates : bomCoordinatesList) { + def parts = bomCoordinates?.split(':') + if (parts == null || parts.length != 3) { + LOG.warn('Invalid BOM coordinates: {}', bomCoordinates) + continue + } + processBom(configurations, dependencies, parts[0], parts[1], parts[2], + propertyResolver, bomProperties, artifactVersions, processed) + } + + return artifactVersions + } + private static void processBom( ConfigurationContainer configurations, DependencyHandler dependencies, String group, String artifact, String version, + Function propertyResolver, Map bomProperties, - Map> propertyToArtifacts, + Map artifactVersions, Set processed ) { def bomKey = "${group}:${artifact}:${version}" as String @@ -283,7 +327,7 @@ class BomManagedVersions { } extractProperties(doc, bomProperties) - processManagedDependencies(doc, configurations, dependencies, bomProperties, propertyToArtifacts, processed) + processManagedDependencies(doc, configurations, dependencies, propertyResolver, bomProperties, artifactVersions, processed) } private static File resolvePomFile(ConfigurationContainer configurations, @@ -343,8 +387,9 @@ class BomManagedVersions { Document doc, ConfigurationContainer configurations, DependencyHandler dependencies, + Function propertyResolver, Map bomProperties, - Map> propertyToArtifacts, + Map artifactVersions, Set processed ) { def depMgmtNodes = doc.getElementsByTagName('dependencyManagement') @@ -361,6 +406,16 @@ class BomManagedVersions { def dependenciesElement = (Element) dependenciesNodes.item(0) def depNodes = dependenciesElement.getElementsByTagName('dependency') + // Record this BOM's own managed versions first and defer imported BOMs, so a + // BOM's direct entries take precedence over the entries it imports (matching + // Maven's dependencyManagement resolution, where the importing POM wins). + // Every entry with a resolvable version is recorded - not just ${property} + // references - so that switching an imported BOM (e.g. via an overridden + // spring-boot.version) also picks up that BOM's hardcoded managed versions. + // The two-pass diff in resolve() discards versions that are identical with and + // without overrides, so recording literal versions never produces spurious + // overrides. + List importedBoms = new ArrayList<>() for (int i = 0; i < depNodes.length; i++) { def dep = (Element) depNodes.item(i) def depGroupId = getChildText(dep, 'groupId') @@ -373,20 +428,22 @@ class BomManagedVersions { } if ('import' == depScope) { - def resolvedVersion = interpolateProperties(depVersion, bomProperties) - if (resolvedVersion) { - processBom(configurations, dependencies, depGroupId, depArtifactId, resolvedVersion, - bomProperties, propertyToArtifacts, processed) - } + importedBoms.add([depGroupId, depArtifactId, depVersion] as String[]) continue } - if (depVersion && depVersion.contains('${')) { - def propertyName = extractPropertyName(depVersion) - if (propertyName) { - def artifactKey = "${depGroupId}:${depArtifactId}" as String - propertyToArtifacts.computeIfAbsent(propertyName) { new ArrayList() }.add(artifactKey) - } + def resolvedVersion = resolveVersion(depVersion, propertyResolver, bomProperties) + if (resolvedVersion) { + def artifactKey = "${depGroupId}:${depArtifactId}" as String + artifactVersions.putIfAbsent(artifactKey, resolvedVersion) + } + } + + for (String[] importedBom : importedBoms) { + def resolvedVersion = resolveVersion(importedBom[2], propertyResolver, bomProperties) + if (resolvedVersion) { + processBom(configurations, dependencies, importedBom[0], importedBom[1], resolvedVersion, + propertyResolver, bomProperties, artifactVersions, processed) } } } @@ -403,8 +460,17 @@ class BomManagedVersions { return null } - private static String interpolateProperties(String value, Map properties) { - if (value == null || !value.contains('${')) { + /** + * Interpolates {@code ${property}} references in a version string. Each + * property is resolved using the {@code propertyResolver} first (so project + * overrides win), falling back to the BOM's own {@code }. Returns + * {@code null} when the value cannot be fully resolved. + */ + private static String resolveVersion(String value, Function propertyResolver, Map bomProperties) { + if (value == null) { + return null + } + if (!value.contains('${')) { return value } @@ -415,13 +481,16 @@ class BomManagedVersions { if (propertyName == null) { break } - def resolved = properties.get(propertyName) + def resolved = propertyResolver.apply(propertyName) + if (resolved == null) { + resolved = bomProperties.get(propertyName) + } if (resolved == null) { break } result = result.replace("\${${propertyName}}" as String, resolved) } - return result + return result.contains('${') ? null : result } private static String getChildText(Element parent, String childTagName) { diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy index 48537bafa81..eee29144674 100644 --- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy @@ -62,12 +62,16 @@ import org.gradle.api.attributes.Category * {@code } block and the * {@code } entries, and recursively follows * {@code import} BOMs. - *
  • 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.
  • + *
  • Computes each managed artifact's version twice - once with the BOM's + * default properties and once with the project's property overrides + * (from {@code gradle.properties} or {@code ext['property.name']}) + * applied, including to imported-BOM selector versions. Any artifact + * whose effective version differs from its default version is recorded + * as an override.
  • + *
  • Applies each override as a strict dependency + * constraint on the project's declarable configurations, so the override + * wins over the {@code require} constraints contributed by + * {@code platform()} even when it downgrades a managed version.
  • * * *

    The plugin does not declare any platforms itself. @@ -154,8 +158,14 @@ class BomPropertyOverridesPlugin implements Plugin { return } + // Apply overrides as strict constraints on every declarable configuration, + // mirroring where the platform(grails-bom) constraints are contributed. + // Resolvable/consumable configurations inherit the constraints through the + // declarable configurations they extend. configurations.configureEach { Configuration conf -> - managedVersions.applyTo(conf) + if (conf.canBeDeclared) { + managedVersions.applyTo(dependencies, conf.name) + } } } 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 3558e2d0187..d52a5623f34 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 @@ -87,12 +87,31 @@ class GrailsExtension { * @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. + * Set version overrides in {@code gradle.properties} or via {@code ext['property.name']} + * instead. For backward compatibility, setting this property to {@code false} is still + * honored as {@code autoApplyBom = false} so existing opt-outs (which previously prevented + * the Spring Dependency Management BOM from being applied) continue to disable the + * automatic {@code platform(grails-bom)} application. New builds should set + * {@link #autoApplyBom} directly. */ @Deprecated boolean springDependencyManagement = true + /** + * Backward-compatible setter for the deprecated {@link #springDependencyManagement} flag. + * Disabling it maps onto {@code autoApplyBom = false} so projects that still opt out via + * {@code grails { springDependencyManagement = false }} do not unexpectedly receive + * {@code platform(grails-bom)} after the migration away from the Spring Dependency + * Management plugin. + */ + @Deprecated + void setSpringDependencyManagement(boolean enabled) { + this.@springDependencyManagement = enabled + if (!enabled) { + this.autoApplyBom.set(false) + } + } + /** * Whether the Micronaut auto-setup should run when the `grails-micronaut` plugin is detected. * When enabled, the Grails Gradle plugin: diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomOverrideResolutionFunctionalSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomOverrideResolutionFunctionalSpec.groovy new file mode 100644 index 00000000000..07340624116 --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomOverrideResolutionFunctionalSpec.groovy @@ -0,0 +1,61 @@ +/* + * 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.grails.gradle.plugin.core.GradleSpecification + +/** + * End-to-end resolution tests for the + * {@code org.apache.grails.gradle.bom-property-overrides} plugin. + * + *

    Unlike {@link BomPropertyOverridesPluginFunctionalSpec}, which only + * verifies platform auto-detection, this spec performs a real dependency + * resolution against a local Maven repository to assert that property + * overrides actually change the resolved versions, including the two cases + * a naive {@code useVersion()} implementation gets wrong:

    + * + *
      + *
    • Downgrade override - an override lower than the BOM + * default must win over the platform's {@code require} constraint.
    • + *
    • Imported-BOM property override - overriding the + * property that selects an imported BOM's version must re-import that + * BOM and pull in its updated managed versions.
    • + *
    + * + * @since 8.0 + * @see BomManagedVersions + */ +class BomOverrideResolutionFunctionalSpec extends GradleSpecification { + + def "property overrides win over platform constraints, including downgrades and imported-BOM version switches"() { + given: + setupTestResourceProject('bom-override-resolution') + + when: + def result = executeTask('printVersions') + + then: 'a downgrade override (mylib 2.0.0 -> 1.0.0) wins over the platform require(2.0.0) constraint' + result.output.contains('RESOLVED=org.example:mylib:1.0.0') + !result.output.contains('RESOLVED=org.example:mylib:2.0.0') + + and: 'overriding the imported-BOM selector property re-imports child-bom:2.0.0 and bumps childlib 1.0.0 -> 2.0.0' + result.output.contains('RESOLVED=org.example:childlib:2.0.0') + !result.output.contains('RESOLVED=org.example:childlib:1.0.0') + } +} diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/GrailsExtensionSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/GrailsExtensionSpec.groovy new file mode 100644 index 00000000000..22e909457cc --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/GrailsExtensionSpec.groovy @@ -0,0 +1,89 @@ +/* + * 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 org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder +import spock.lang.Specification + +/** + * Unit-level tests for {@link GrailsExtension} using {@link ProjectBuilder}. + * + *

    Focuses on the backward-compatible bridge between the deprecated + * {@code springDependencyManagement} flag and the new {@link GrailsExtension#getAutoApplyBom} + * property, which controls whether {@code platform(grails-bom)} is applied + * automatically.

    + * + * @since 8.0 + */ +class GrailsExtensionSpec extends Specification { + + def "autoApplyBom defaults to true"() { + given: + Project project = ProjectBuilder.builder().build() + + when: + GrailsExtension extension = new GrailsExtension(project) + + then: + extension.autoApplyBom.get() + } + + def "deprecated springDependencyManagement = false disables autoApplyBom for backward compatibility"() { + given: + Project project = ProjectBuilder.builder().build() + GrailsExtension extension = new GrailsExtension(project) + + expect: 'autoApplyBom starts enabled' + extension.autoApplyBom.get() + + when: 'a project opts out using the legacy Grails 7 flag' + extension.springDependencyManagement = false + + then: 'the BOM is no longer auto-applied' + !extension.springDependencyManagement + !extension.autoApplyBom.get() + } + + def "deprecated springDependencyManagement = true leaves autoApplyBom at its convention"() { + given: + Project project = ProjectBuilder.builder().build() + GrailsExtension extension = new GrailsExtension(project) + + when: + extension.springDependencyManagement = true + + then: + extension.springDependencyManagement + extension.autoApplyBom.get() + } + + def "an explicit autoApplyBom = false is honored independently of the deprecated flag"() { + given: + Project project = ProjectBuilder.builder().build() + GrailsExtension extension = new GrailsExtension(project) + + when: + extension.autoApplyBom.set(false) + + then: + !extension.autoApplyBom.get() + extension.springDependencyManagement + } +} diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/build.gradle b/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/build.gradle new file mode 100644 index 00000000000..b3f00044ede --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/build.gradle @@ -0,0 +1,39 @@ +plugins { + id 'java' + id 'org.apache.grails.gradle.bom-property-overrides' +} + +repositories { + maven { + url = uri("${projectDir}/maven-repo") + } +} + +dependencies { + implementation platform('org.example:test-bom:1.0.0') + implementation 'org.example:mylib' + implementation 'org.example:childlib' +} + +// mylib's BOM default is 2.0.0; this override is a DOWNGRADE that must win over +// the platform's require(2.0.0) constraint. A plain useVersion() hook would lose +// to conflict resolution here - the strict-constraint application is what makes +// the downgrade win. +ext['mylib.version'] = '1.0.0' + +// childbom.version selects which child-bom test-bom imports. Overriding it must +// re-import child-bom:2.0.0 and pull in its updated childlib managed version +// (regression coverage for imported-BOM property overrides). +ext['childbom.version'] = '2.0.0' + +tasks.register('printVersions') { + def runtimeClasspath = configurations.runtimeClasspath + doLast { + runtimeClasspath.incoming.resolutionResult.allComponents.each { component -> + def id = component.moduleVersion + if (id != null) { + println "RESOLVED=${id.group}:${id.name}:${id.version}" + } + } + } +} diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/child-bom/1.0.0/child-bom-1.0.0.pom b/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/child-bom/1.0.0/child-bom-1.0.0.pom new file mode 100644 index 00000000000..5b51de0bc2e --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/child-bom/1.0.0/child-bom-1.0.0.pom @@ -0,0 +1,23 @@ + + + 4.0.0 + org.example + child-bom + 1.0.0 + pom + + + + + + org.example + childlib + 1.0.0 + + + + diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/child-bom/2.0.0/child-bom-2.0.0.pom b/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/child-bom/2.0.0/child-bom-2.0.0.pom new file mode 100644 index 00000000000..c7fab295478 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/child-bom/2.0.0/child-bom-2.0.0.pom @@ -0,0 +1,23 @@ + + + 4.0.0 + org.example + child-bom + 2.0.0 + pom + + + + + + org.example + childlib + 2.0.0 + + + + diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/childlib/1.0.0/childlib-1.0.0.pom b/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/childlib/1.0.0/childlib-1.0.0.pom new file mode 100644 index 00000000000..640127afc6a --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/childlib/1.0.0/childlib-1.0.0.pom @@ -0,0 +1,9 @@ + + + 4.0.0 + org.example + childlib + 1.0.0 + diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/childlib/2.0.0/childlib-2.0.0.pom b/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/childlib/2.0.0/childlib-2.0.0.pom new file mode 100644 index 00000000000..d21faf888d1 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/childlib/2.0.0/childlib-2.0.0.pom @@ -0,0 +1,9 @@ + + + 4.0.0 + org.example + childlib + 2.0.0 + diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/mylib/1.0.0/mylib-1.0.0.pom b/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/mylib/1.0.0/mylib-1.0.0.pom new file mode 100644 index 00000000000..af137f39283 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/mylib/1.0.0/mylib-1.0.0.pom @@ -0,0 +1,9 @@ + + + 4.0.0 + org.example + mylib + 1.0.0 + diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/mylib/2.0.0/mylib-2.0.0.pom b/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/mylib/2.0.0/mylib-2.0.0.pom new file mode 100644 index 00000000000..8e5dd3825e2 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/mylib/2.0.0/mylib-2.0.0.pom @@ -0,0 +1,9 @@ + + + 4.0.0 + org.example + mylib + 2.0.0 + diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/test-bom/1.0.0/test-bom-1.0.0.pom b/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/test-bom/1.0.0/test-bom-1.0.0.pom new file mode 100644 index 00000000000..689352a1917 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/maven-repo/org/example/test-bom/1.0.0/test-bom-1.0.0.pom @@ -0,0 +1,32 @@ + + + 4.0.0 + org.example + test-bom + 1.0.0 + pom + + + 2.0.0 + 1.0.0 + + + + + + org.example + mylib + ${mylib.version} + + + org.example + child-bom + ${childbom.version} + pom + import + + + + diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/settings.gradle b/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/settings.gradle new file mode 100644 index 00000000000..8ee3acfa8ee --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-override-resolution/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'bom-override-resolution' From f1c76ac9f454a72595e7e6a9d311dc1181cdc443 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Mon, 1 Jun 2026 21:00:32 -0400 Subject: [PATCH 15/23] docs: align BOM property-override docs with strict-constraint behavior The override behaviour changed so that property overrides are applied as strict dependency constraints (winning in both directions, including downgrades) rather than soft resolution rules. Update the user-facing docs that still described the old behaviour: - gradleDependencies.adoc: overrides are strict constraints that win over the platform's require() constraints (including downgrades); only the BOM's default managed versions still participate in highest-version-wins conflict resolution; document imported-BOM (e.g. spring-boot.version) re-import. - upgrading80x.adoc: correct the migration table (strict constraints, not useVersion()) and the conflict-resolution note to distinguish explicit overrides (strict, always win) from default BOM versions (conflict resolution; use enforcedPlatform to force). Assisted-by: claude-code:claude-4.8-opus --- .../commandLine/gradleBuild/gradleDependencies.adoc | 8 ++++++-- grails-doc/src/en/guide/upgrading/upgrading80x.adoc | 12 +++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc b/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc index c4052ea488b..98ed596b91b 100644 --- a/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc +++ b/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc @@ -76,11 +76,15 @@ The same override can be set in `gradle.properties` (note: `gradle.properties` o 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()`. +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 (following `import` BOMs), determines which managed versions change when your overrides are applied, and applies each change as a strict dependency constraint. [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. +Each property override is applied as a *strict* dependency constraint, so it wins over the `require` constraints contributed by `platform(grails-bom)` - in both directions. Setting `slf4j.version` to a value *lower* than the BOM default therefore actually downgrades `slf4j-api`, matching the legacy Spring Dependency Management plugin (a plain resolution rule would lose to Gradle's highest-version-wins conflict resolution). + +This strict behaviour applies to *explicit overrides only*. The BOM's default (non-overridden) managed versions are contributed by a regular `platform()` and still participate in Gradle's standard highest-version-wins conflict resolution, so a transitive dependency can pull a non-overridden managed version higher than the BOM default. To make the BOM's default versions win unconditionally as well, declare `enforcedPlatform(grails-bom)` on the relevant configuration. + +Overriding a property that selects an imported BOM's version (for example `spring-boot.version`, which selects the `spring-boot-dependencies` BOM that `grails-bom` imports) re-imports that BOM at the overridden version and applies its updated managed set. ==== ==== Applying the BOM in Different Project Types diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 3a778d52c87..2352b928034 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -520,14 +520,16 @@ If your `build.gradle` uses either of the patterns below, update it as follows: | `dependencies { implementation 'group:artifact:version' }` for a direct dependency, or `configurations.all { resolutionStrategy.force 'group:artifact:version' }` to force a transitive version | Version-range strings (e.g. `1.+`, `[1.0,2.0)`) embedded in BOM `` values, resolved by Spring DM's own resolver -| Now passed through to Gradle's native `useVersion()`, which uses Gradle's range semantics. Concrete versions are unaffected. Only relevant if you consume a custom BOM whose `` block contains range strings. +| Now applied as Gradle strict version constraints, which use Gradle's range semantics. Concrete versions are unaffected. Only relevant if you consume a custom BOM whose `` block contains range strings. |=== **Behavioural difference to be aware of: conflict resolution.** -Spring DM forced BOM-managed versions unconditionally, which could mask transitive version drift. -Gradle's `platform()` participates in standard conflict resolution (highest-version-wins), so the BOM-pinned version is the version that wins *after* Gradle's conflict resolution. -A `gradle.properties` override therefore pins the resolved version rather than overriding it after the fact. -If you require Spring DM's "BOM wins always" behaviour for a given configuration, declare `enforcedPlatform("org.apache.grails:grails-bom:$grailsVersion")` instead of relying on the auto-applied non-enforced platform. +The BOM's *default* managed versions are applied via a regular Gradle `platform()`, which participates in standard conflict resolution (highest-version-wins). +A transitive dependency can therefore pull a non-overridden managed version higher than the BOM default. +If you require Spring DM's "BOM wins always" behaviour for the default managed versions, declare `enforcedPlatform("org.apache.grails:grails-bom:$grailsVersion")` instead of relying on the auto-applied non-enforced platform. + +An *explicit* property override (`ext['…']` or `gradle.properties`) is applied as a strict constraint and always wins - including when it downgrades a version - so overrides behave as they did under Spring DM. +Overriding a property that selects an imported BOM's version (for example `spring-boot.version`) re-imports that BOM at the overridden version and applies its updated managed set. NOTE: Projects that apply the `grails-micronaut` plugin already require `enforcedPlatform` for unrelated reasons (see section 7.2); those projects retain Spring DM's strict-version semantics automatically. From c2ac4a69489c6e701c41c6bc917a18332b711407 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Tue, 2 Jun 2026 10:16:31 -0400 Subject: [PATCH 16/23] style: apply Groovy idioms from PR review feedback Address @matrei's review comments (pullrequestreview-4407310454) on the BOM property-overrides code. Style-only, no behavioral change: - Use `def`/inferred types for local variables in new Groovy code - Drop unnecessary terminal `return` statements in simple methods - Use implicit `it` closure parameters where the type is statically inferred - Simplify DocumentBuilderFactory and detached-configuration setup with `tap {}` - Break an over-long lifecycle log statement - Remove now-unused imports (DependencyConstraint, MutableVersionConstraint, Configuration) Verified: grails-gradle-plugins and grails-docs-core compile clean. Assisted-by: claude-code:claude-opus-4-8 --- .../tasks/bom/ExtractDependenciesTask.groovy | 45 ++++++++++--------- .../GrailsDependencyValidatorPlugin.groovy | 4 +- .../plugin/bom/BomManagedVersions.groovy | 35 ++++++++------- .../bom/BomPropertyOverridesPlugin.groovy | 11 +++-- .../plugin/core/GrailsGradlePlugin.groovy | 12 ++--- 5 files changed, 54 insertions(+), 53 deletions(-) diff --git a/build-logic/docs-core/src/main/groovy/org/apache/grails/gradle/tasks/bom/ExtractDependenciesTask.groovy b/build-logic/docs-core/src/main/groovy/org/apache/grails/gradle/tasks/bom/ExtractDependenciesTask.groovy index 6f7eb354cee..26e7cf927c8 100644 --- a/build-logic/docs-core/src/main/groovy/org/apache/grails/gradle/tasks/bom/ExtractDependenciesTask.groovy +++ b/build-logic/docs-core/src/main/groovy/org/apache/grails/gradle/tasks/bom/ExtractDependenciesTask.groovy @@ -259,16 +259,17 @@ abstract class ExtractDependenciesTask extends DefaultTask { } Properties populatePlatformDependencies(CoordinateVersionHolder bomCoordinates, List exclusionRules, Map constraints, boolean error = true, int level = 0) { - Dependency bomDependency = dependencyHandler.create("${bomCoordinates.coordinates}@pom") - Configuration dependencyConfiguration = configurationContainer.detachedConfiguration(bomDependency) - dependencyConfiguration.transitive = false + def bomDependency = dependencyHandler.create("${bomCoordinates.coordinates}@pom") + def dependencyConfiguration = configurationContainer.detachedConfiguration(bomDependency).tap { + transitive = false + } File bomPomFile = dependencyConfiguration.singleFile - Document doc = parsePom(bomPomFile) - Properties versionProperties = new Properties() + def doc = parsePom(bomPomFile) + def versionProperties = new Properties() // Parent POM populated first so its properties can be overridden by the child - CoordinateVersionHolder parentBom = readParentCoordinates(doc) + def parentBom = readParentCoordinates(doc) if (parentBom) { populatePlatformDependencies(parentBom, exclusionRules, constraints, false, level + 1)?.entrySet()?.each { Map.Entry entry -> versionProperties.put(entry.key, entry.value) @@ -281,7 +282,7 @@ abstract class ExtractDependenciesTask extends DefaultTask { versionProperties.put('project.groupId', bomCoordinates.groupId) versionProperties.put('project.version', bomCoordinates.version) - List managedDependencies = readManagedDependencies(doc) + def managedDependencies = readManagedDependencies(doc) if (managedDependencies.isEmpty()) { if (error) { // only the boms we directly include need to error since we expect a dependency management; @@ -291,13 +292,13 @@ abstract class ExtractDependenciesTask extends DefaultTask { return versionProperties } - for (ManagedDependency depItem : managedDependencies) { - CoordinateHolder baseCoordinates = new CoordinateHolder( + for (def depItem : managedDependencies) { + def baseCoordinates = new CoordinateHolder( groupId: depItem.groupId, artifactId: depItem.artifactId ) - CoordinateHolder resolvedCoordinates = new CoordinateHolder( + def resolvedCoordinates = new CoordinateHolder( groupId: resolveMavenProperty(baseCoordinates.coordinatesWithoutVersion, depItem.groupId, versionProperties), artifactId: resolveMavenProperty(baseCoordinates.coordinatesWithoutVersion, depItem.artifactId, versionProperties) ) @@ -326,16 +327,16 @@ abstract class ExtractDependenciesTask extends DefaultTask { continue } - String resolvedVersion = resolveMavenProperty(resolvedCoordinates.coordinatesWithoutVersion, depItem.version, versionProperties) - String propertyName = depItem.version?.contains('$') ? depItem.version : null - ExtractedDependencyConstraint constraint = new ExtractedDependencyConstraint( + def resolvedVersion = resolveMavenProperty(resolvedCoordinates.coordinatesWithoutVersion, depItem.version, versionProperties) + def propertyName = depItem.version?.contains('$') ? depItem.version : null + def constraint = new ExtractedDependencyConstraint( groupId: resolvedCoordinates.groupId, artifactId: resolvedCoordinates.artifactId, version: resolvedVersion, versionPropertyReference: propertyName, source: bomCoordinates.artifactId ) constraints.put(resolvedCoordinates, constraint) if (depItem.scope == 'import') { - CoordinateVersionHolder resolvedBomCoordinates = new CoordinateVersionHolder( + def resolvedBomCoordinates = new CoordinateVersionHolder( groupId: resolvedCoordinates.groupId, artifactId: resolvedCoordinates.artifactId, version: resolvedVersion @@ -352,14 +353,14 @@ abstract class ExtractDependenciesTask extends DefaultTask { * XML parsing is hardened against XXE / XInclude attacks. */ private static Document parsePom(File pomFile) { - 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) + DocumentBuilderFactory.newInstance().tap { + namespaceAware = false + validating = false + XIncludeAware = false + setFeature('http://apache.org/xml/features/nonvalidating/load-external-dtd', false) + setFeature('http://xml.org/sax/features/external-general-entities', false) + setFeature('http://xml.org/sax/features/external-parameter-entities', false) + }.newDocumentBuilder().parse(pomFile) } private static CoordinateVersionHolder readParentCoordinates(Document doc) { 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 05e16b2d966..92a58c12a08 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 @@ -179,7 +179,7 @@ class GrailsDependencyValidatorPlugin implements Plugin { if (!BOM_PROJECT_NAMES.contains(dep.name)) { continue } - Project bomProject = project.rootProject.findProject(":${dep.name}" as String) + def bomProject = project.rootProject.findProject(":${dep.name}" as String) if (bomProject == null) { continue } @@ -199,7 +199,7 @@ class GrailsDependencyValidatorPlugin implements Plugin { if (!(dep instanceof ModuleDependency)) { return false } - Object categoryAttr = ((ModuleDependency) dep).attributes.getAttribute(Category.CATEGORY_ATTRIBUTE) + def categoryAttr = ((ModuleDependency) dep).attributes.getAttribute(Category.CATEGORY_ATTRIBUTE) if (categoryAttr == null) { return false } diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy index dbbf21e39cf..b493358d176 100644 --- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy @@ -23,8 +23,6 @@ import java.util.function.Function import groovy.transform.CompileStatic import org.gradle.api.Project import org.gradle.api.artifacts.ConfigurationContainer -import org.gradle.api.artifacts.DependencyConstraint -import org.gradle.api.artifacts.MutableVersionConstraint import org.gradle.api.artifacts.dsl.DependencyHandler import org.gradle.api.logging.Logger import org.gradle.api.logging.Logging @@ -98,7 +96,7 @@ class BomManagedVersions { DependencyHandler dependencies, Function propertyLookup, String bomCoordinates) { - return resolve(configurations, dependencies, propertyLookup, [bomCoordinates]) + resolve(configurations, dependencies, propertyLookup, [bomCoordinates]) } /** @@ -126,9 +124,9 @@ class BomManagedVersions { Collection bomCoordinatesList) { def instance = new BomManagedVersions() - Map defaultVersions = computeManagedVersions( + def defaultVersions = computeManagedVersions( configurations, dependencies, bomCoordinatesList, NO_OVERRIDES) - Map effectiveVersions = computeManagedVersions( + def effectiveVersions = computeManagedVersions( configurations, dependencies, bomCoordinatesList, propertyLookup) for (def entry : effectiveVersions.entrySet()) { @@ -146,10 +144,13 @@ class BomManagedVersions { } if (!instance.versionOverrides.isEmpty()) { - LOG.lifecycle('BOM property overrides: {} version override(s) will be applied', instance.versionOverrides.size()) + LOG.lifecycle( + 'BOM property overrides: {} version override(s) will be applied', + instance.versionOverrides.size() + ) } - return instance + instance } /** @@ -162,7 +163,7 @@ class BomManagedVersions { * @param bomCoordinates the BOM coordinates in {@code group:artifact:version} format */ static BomManagedVersions resolve(Project project, String bomCoordinates) { - return resolve(project, [bomCoordinates]) + resolve(project, [bomCoordinates]) } /** @@ -175,7 +176,7 @@ class BomManagedVersions { * @param bomCoordinatesList list of BOM coordinates in {@code group:artifact:version} format */ static BomManagedVersions resolve(Project project, Collection bomCoordinatesList) { - return resolve( + resolve( project.configurations, project.dependencies, { String name -> project.hasProperty(name) ? project.property(name)?.toString() : null } as Function, @@ -203,9 +204,9 @@ class BomManagedVersions { } versionOverrides.each { String coordinate, String version -> - dependencies.constraints.add(configurationName, coordinate) { DependencyConstraint constraint -> - constraint.version { MutableVersionConstraint v -> v.strictly(version) } - constraint.because('BOM version override via project property') + dependencies.constraints.add(configurationName, coordinate) { + it.version { it.strictly(version) } + it.because('BOM version override via project property') } } } @@ -214,7 +215,7 @@ class BomManagedVersions { * Returns whether any version overrides were detected. */ boolean hasOverrides() { - return !versionOverrides.isEmpty() + !versionOverrides.isEmpty() } /** @@ -222,7 +223,7 @@ class BomManagedVersions { * Keys are {@code group:artifact}, values are the override version strings. */ Map getOverrides() { - return Collections.unmodifiableMap(versionOverrides) + Collections.unmodifiableMap(versionOverrides) } /** @@ -299,7 +300,7 @@ class BomManagedVersions { propertyResolver, bomProperties, artifactVersions, processed) } - return artifactVersions + artifactVersions } private static void processBom( @@ -490,7 +491,7 @@ class BomManagedVersions { } result = result.replace("\${${propertyName}}" as String, resolved) } - return result.contains('${') ? null : result + result.contains('${') ? null : result } private static String getChildText(Element parent, String childTagName) { @@ -498,6 +499,6 @@ class BomManagedVersions { if (children.length == 0) { return null } - return children.item(0).textContent?.trim() + children.item(0).textContent?.trim() } } diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy index eee29144674..4277b5b16cf 100644 --- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy @@ -23,7 +23,6 @@ import java.util.function.Function 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.ConfigurationContainer import org.gradle.api.artifacts.Dependency import org.gradle.api.artifacts.ModuleDependency @@ -162,9 +161,9 @@ class BomPropertyOverridesPlugin implements Plugin { // mirroring where the platform(grails-bom) constraints are contributed. // Resolvable/consumable configurations inherit the constraints through the // declarable configurations they extend. - configurations.configureEach { Configuration conf -> - if (conf.canBeDeclared) { - managedVersions.applyTo(dependencies, conf.name) + configurations.configureEach { + if (it.canBeDeclared) { + managedVersions.applyTo(dependencies, it.name) } } } @@ -178,8 +177,8 @@ class BomPropertyOverridesPlugin implements Plugin { static Set detectDeclaredBoms(ConfigurationContainer configurations) { def coordinates = new LinkedHashSet() - configurations.each { Configuration conf -> - for (Dependency dep : conf.dependencies) { + configurations.each { + for (Dependency dep : it.dependencies) { if (!(dep instanceof ModuleDependency)) { continue } 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 5c512e754c7..54fd4dc5696 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 @@ -417,7 +417,7 @@ ${importStatements} // any plugin that adds a configuration later is responsible for // declaring its own BOM coordination if it needs it. project.afterEvaluate { - GrailsExtension grailsExtension = project.extensions.findByType(GrailsExtension) + def grailsExtension = project.extensions.findByType(GrailsExtension) boolean autoApply = grailsExtension == null || grailsExtension.autoApplyBom.getOrElse(true) if (!autoApply) { project.logger.info( @@ -427,8 +427,8 @@ ${importStatements} return } - String grailsVersion = (project.findProperty('grailsVersion') ?: BuildSettings.grailsVersion) as String - String bomCoordinates = "org.apache.grails:grails-bom:${grailsVersion}" as String + def grailsVersion = (project.findProperty('grailsVersion') ?: BuildSettings.grailsVersion) as String + def bomCoordinates = "org.apache.grails:grails-bom:${grailsVersion}" as String // Apply the BOM platform to all declarable project configurations, matching // the behavior of the Spring Dependency Management plugin which applied version @@ -442,9 +442,9 @@ ${importStatements} // resolution to upgrade transitives and break the tools/processors - unlike // resolutionStrategy hooks, platform() constraints participate in version // conflict resolution. - project.configurations.each { Configuration conf -> - if (conf.canBeDeclared && !isExcludedFromBomPlatform(conf.name)) { - project.dependencies.add(conf.name, project.dependencies.platform(bomCoordinates)) + project.configurations.each { + if (it.canBeDeclared && !isExcludedFromBomPlatform(it.name)) { + project.dependencies.add(it.name, project.dependencies.platform(bomCoordinates)) } } From 20f3304490c301e1be87289ed91e74a13616997c Mon Sep 17 00:00:00 2001 From: James Fredley Date: Tue, 2 Jun 2026 15:56:27 -0400 Subject: [PATCH 17/23] fix: parse BOM POMs with maven-model instead of custom XML parsing The docs-core ExtractDependenciesTask parsed BOM POM files with a hand-rolled JDK DocumentBuilderFactory parser. Switch to Maven's published org.apache.maven:maven-model (MavenXpp3Reader -> Model) so property and imported-BOM resolution mirror upstream Maven, as requested in PR review. Spring Boot does not manage maven-model, so its version is pinned in dependencies.gradle's gradleBomDependencyVersions map (Maven 3.9.x, which still provides MavenXpp3Reader). It is intentionally not added to gradleBomDependencies so it is not exposed as a managed constraint in the consumer-facing grails-bom. Assisted-by: claude-code:claude-4.8-opus --- build-logic/docs-core/build.gradle | 4 + .../tasks/bom/ExtractDependenciesTask.groovy | 160 ++---------------- dependencies.gradle | 4 + 3 files changed, 21 insertions(+), 147 deletions(-) diff --git a/build-logic/docs-core/build.gradle b/build-logic/docs-core/build.gradle index 3b6b30c645b..4b48e772efc 100644 --- a/build-logic/docs-core/build.gradle +++ b/build-logic/docs-core/build.gradle @@ -47,6 +47,10 @@ dependencies { api 'org.grails:grails-gdoc-engine:1.0.1' api 'org.yaml:snakeyaml:2.4' + // Maven's own model library, used by ExtractDependenciesTask to parse BOM POMs the + // Maven-standard way instead of with hand-rolled XML parsing. + api "org.apache.maven:maven-model:${gradleBomDependencyVersions['maven-model.version']}" + api "org.asciidoctor:asciidoctorj:${gradleBomDependencyVersions['asciidoctorj.version']}" testImplementation platform("org.spockframework:spock-bom:${gradleBomDependencyVersions['gradle-spock.version']}") diff --git a/build-logic/docs-core/src/main/groovy/org/apache/grails/gradle/tasks/bom/ExtractDependenciesTask.groovy b/build-logic/docs-core/src/main/groovy/org/apache/grails/gradle/tasks/bom/ExtractDependenciesTask.groovy index 26e7cf927c8..ba89e2e6c2c 100644 --- a/build-logic/docs-core/src/main/groovy/org/apache/grails/gradle/tasks/bom/ExtractDependenciesTask.groovy +++ b/build-logic/docs-core/src/main/groovy/org/apache/grails/gradle/tasks/bom/ExtractDependenciesTask.groovy @@ -20,8 +20,9 @@ package org.apache.grails.gradle.tasks.bom import java.util.regex.Pattern -import javax.xml.parsers.DocumentBuilderFactory +import org.apache.maven.model.Model +import org.apache.maven.model.io.xpp3.MavenXpp3Reader import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.NamedDomainObjectProvider @@ -47,9 +48,6 @@ import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.Internal import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.TaskAction -import org.w3c.dom.Document -import org.w3c.dom.Element -import org.w3c.dom.NodeList /** * Grails Bom files define their dependencies in a series of maps, this task takes those maps and generates an @@ -265,24 +263,29 @@ abstract class ExtractDependenciesTask extends DefaultTask { } File bomPomFile = dependencyConfiguration.singleFile - def doc = parsePom(bomPomFile) + // Parse the BOM POM with Maven's own model library so resolution mirrors upstream Maven. + Model model = bomPomFile.withInputStream { InputStream input -> new MavenXpp3Reader().read(input) } def versionProperties = new Properties() // Parent POM populated first so its properties can be overridden by the child - def parentBom = readParentCoordinates(doc) - if (parentBom) { + if (model.parent) { + def parentBom = new CoordinateVersionHolder( + groupId: model.parent.groupId, + artifactId: model.parent.artifactId, + version: model.parent.version + ) populatePlatformDependencies(parentBom, exclusionRules, constraints, false, level + 1)?.entrySet()?.each { Map.Entry entry -> versionProperties.put(entry.key, entry.value) } } - readProperties(doc).each { String name, String value -> - versionProperties.put(name, value) + model.properties.entrySet().each { Map.Entry entry -> + versionProperties.put(entry.key, entry.value) } versionProperties.put('project.groupId', bomCoordinates.groupId) versionProperties.put('project.version', bomCoordinates.version) - def managedDependencies = readManagedDependencies(doc) + def managedDependencies = model.dependencyManagement?.dependencies ?: [] if (managedDependencies.isEmpty()) { if (error) { // only the boms we directly include need to error since we expect a dependency management; @@ -348,143 +351,6 @@ abstract class ExtractDependenciesTask extends DefaultTask { versionProperties } - /** - * Parses a BOM POM file using the JDK's built-in {@link DocumentBuilderFactory}. - * XML parsing is hardened against XXE / XInclude attacks. - */ - private static Document parsePom(File pomFile) { - DocumentBuilderFactory.newInstance().tap { - namespaceAware = false - validating = false - XIncludeAware = false - setFeature('http://apache.org/xml/features/nonvalidating/load-external-dtd', false) - setFeature('http://xml.org/sax/features/external-general-entities', false) - setFeature('http://xml.org/sax/features/external-parameter-entities', false) - }.newDocumentBuilder().parse(pomFile) - } - - private static CoordinateVersionHolder readParentCoordinates(Document doc) { - NodeList parentNodes = doc.documentElement.getElementsByTagName('parent') - if (parentNodes.length == 0) { - return null - } - // Only consider elements that are direct children of ; - // a nested parent (e.g. inside ) would be a malformed POM. - Element parent = null - for (int i = 0; i < parentNodes.length; i++) { - Element candidate = (Element) parentNodes.item(i) - if (candidate.parentNode == doc.documentElement) { - parent = candidate - break - } - } - if (parent == null) { - return null - } - String groupId = childText(parent, 'groupId') - String artifactId = childText(parent, 'artifactId') - String version = childText(parent, 'version') - if (!groupId || !artifactId || !version) { - return null - } - return new CoordinateVersionHolder(groupId: groupId, artifactId: artifactId, version: version) - } - - private static Map readProperties(Document doc) { - Map result = [:] - NodeList propsNodes = doc.documentElement.getElementsByTagName('properties') - if (propsNodes.length == 0) { - return result - } - // Only consider the top-level , not properties nested inside other elements - Element props = null - for (int i = 0; i < propsNodes.length; i++) { - Element candidate = (Element) propsNodes.item(i) - if (candidate.parentNode == doc.documentElement) { - props = candidate - break - } - } - if (props == null) { - return result - } - NodeList children = props.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 != null) { - result.put(name, value) - } - } - } - return result - } - - private static List readManagedDependencies(Document doc) { - List result = [] - NodeList depMgmtNodes = doc.documentElement.getElementsByTagName('dependencyManagement') - if (depMgmtNodes.length == 0) { - return result - } - // Only the top-level on - Element depMgmt = null - for (int i = 0; i < depMgmtNodes.length; i++) { - Element candidate = (Element) depMgmtNodes.item(i) - if (candidate.parentNode == doc.documentElement) { - depMgmt = candidate - break - } - } - if (depMgmt == null) { - return result - } - NodeList depsNodes = depMgmt.getElementsByTagName('dependencies') - if (depsNodes.length == 0) { - return result - } - Element depsEl = (Element) depsNodes.item(0) - NodeList depNodes = depsEl.getElementsByTagName('dependency') - for (int i = 0; i < depNodes.length; i++) { - Element dep = (Element) depNodes.item(i) - result.add(new ManagedDependency( - groupId: childText(dep, 'groupId'), - artifactId: childText(dep, 'artifactId'), - version: childText(dep, 'version'), - scope: childText(dep, 'scope') - )) - } - return result - } - - private static String childText(Element parent, String childTagName) { - NodeList children = parent.getElementsByTagName(childTagName) - if (children.length == 0) { - return null - } - // Only consider direct children, not nested same-named tags - for (int i = 0; i < children.length; i++) { - Element candidate = (Element) children.item(i) - if (candidate.parentNode == parent) { - return candidate.textContent?.trim() - } - } - return null - } - - /** - * Plain data carrier mirroring the four fields we used from Spring DM's - * shaded Maven model {@code Dependency}. Kept private and minimal so the - * task has no external Maven-model dependency. - */ - private static class ManagedDependency { - String groupId - String artifactId - String version - String scope - } - private String resolveMavenProperty(String errorDescription, String dynamicVersion, Map properties, int maxIterations = 10) { Pattern dynamicPattern = ~/\$\{([^}]+)\}/ String expandedVersion = dynamicVersion diff --git a/dependencies.gradle b/dependencies.gradle index 0036e9d1b9b..6662a909614 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -38,6 +38,10 @@ ext { 'jline2.version' : '2.14.6', 'jna.version' : '5.18.1', 'jquery.version' : '3.7.1', + // maven-model is used by the docs-core ExtractDependenciesTask to parse BOM POMs + // the Maven-standard way. Spring Boot does not manage this artifact, so the version + // is pinned here. Use the Maven 3.x line, which provides MavenXpp3Reader. + 'maven-model.version' : '3.9.16', 'objenesis.version' : '3.4', 'spring-boot.version' : '4.1.0-RC1', ] From bc933faa7a5614c83851173f0aa14cd4dd6d524d Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 4 Jun 2026 03:32:04 -0400 Subject: [PATCH 18/23] Use maven-model to parse BOM POMs for property overrides Replace the hand-rolled JDK DocumentBuilderFactory parsing in BomManagedVersions with Maven's own maven-model library (MavenXpp3Reader into a Model), matching the docs ExtractDependenciesTask. Parent-POM inheritance and imported-BOM resolution now follow Maven semantics, with per-BOM property scoping, while preserving the two-pass default/effective diff that detects property-based version overrides. Add org.apache.maven:maven-model to the grails-gradle-plugins runtime classpath (its only transitive is plexus-utils); the version is pinned in dependencies.gradle and declared inline so it is not published as a managed constraint in the consumer-facing grails-bom. Assisted-by: claude-code:claude-4.8-opus --- grails-gradle/plugins/build.gradle | 8 + .../plugin/bom/BomManagedVersions.groovy | 196 +++++++++--------- 2 files changed, 109 insertions(+), 95 deletions(-) diff --git a/grails-gradle/plugins/build.gradle b/grails-gradle/plugins/build.gradle index 7e32c4d8413..6e4f85f3a18 100644 --- a/grails-gradle/plugins/build.gradle +++ b/grails-gradle/plugins/build.gradle @@ -57,6 +57,14 @@ dependencies { implementation 'org.springframework.boot:spring-boot-gradle-plugin' implementation 'org.springframework.boot:spring-boot-loader-tools' + // Maven's own model library, used by BomManagedVersions to parse BOM POMs the + // Maven-standard way instead of with hand-rolled XML parsing (matching the + // docs-core ExtractDependenciesTask). The version is pinned in dependencies.gradle + // (Spring Boot does not manage this artifact) and declared inline here so it is not + // published as a managed constraint in the consumer-facing grails-bom. Its only + // runtime transitive is org.codehaus.plexus:plexus-utils. + implementation "org.apache.maven:maven-model:${gradleBomDependencyVersions['maven-model.version']}" + // Testing - Gradle TestKit is auto-added by java-gradle-plugin testImplementation('org.spockframework:spock-core') { transitive = false } testImplementation 'org.apache.groovy:groovy-test-junit5' diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy index b493358d176..cfa74f387a2 100644 --- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy @@ -21,15 +21,15 @@ package org.grails.gradle.plugin.bom import java.util.function.Function import groovy.transform.CompileStatic +import org.apache.maven.model.Dependency +import org.apache.maven.model.Model +import org.apache.maven.model.Parent +import org.apache.maven.model.io.xpp3.MavenXpp3Reader import org.gradle.api.Project import org.gradle.api.artifacts.ConfigurationContainer import org.gradle.api.artifacts.dsl.DependencyHandler import org.gradle.api.logging.Logger import org.gradle.api.logging.Logging -import org.w3c.dom.Document -import org.w3c.dom.Element - -import javax.xml.parsers.DocumentBuilderFactory /** * Lightweight replacement for the Spring Dependency Management plugin's @@ -55,6 +55,14 @@ import javax.xml.parsers.DocumentBuilderFactory * (for example {@code spring-boot.version}) re-imports that BOM and pulls in * its updated managed-dependency set.

    * + *

    POMs are parsed with Maven's own {@code org.apache.maven:maven-model} + * library ({@link MavenXpp3Reader} into a {@link Model}) so that parent-POM + * inheritance, {@code } extraction and {@code import} + * resolution mirror upstream Maven rather than a bespoke XML approximation. + * Each POM's {@code } are scoped to that POM (and its parents), + * matching Maven's model rather than leaking property values across unrelated + * imported BOMs.

    + * *

    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 @@ -228,37 +236,26 @@ class BomManagedVersions { /** * 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. + * This method does not follow imported BOMs (or parent POMs) 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) { - def doc = parseXml(pomFile) - if (doc == null) { + def model = parseModel(pomFile) + if (model == null) { return } - extractProperties(doc, bomProperties) + extractProperties(model, bomProperties) - def depMgmtNodes = doc.getElementsByTagName('dependencyManagement') - if (depMgmtNodes.length == 0) { - return - } - def depMgmt = (Element) depMgmtNodes.item(0) - def dependenciesNodes = depMgmt.getElementsByTagName('dependencies') - if (dependenciesNodes.length == 0) { - return - } - def dependenciesElement = (Element) dependenciesNodes.item(0) - def depNodes = dependenciesElement.getElementsByTagName('dependency') - - for (int i = 0; i < depNodes.length; i++) { - def dep = (Element) depNodes.item(i) - def depGroupId = getChildText(dep, 'groupId') - def depArtifactId = getChildText(dep, 'artifactId') - def depVersion = getChildText(dep, 'version') + def managed = managedDependencies(model) + for (Dependency dep : managed) { + def depGroupId = dep.groupId + def depArtifactId = dep.artifactId + def depVersion = dep.version if (!depGroupId || !depArtifactId || !depVersion) { continue @@ -277,7 +274,7 @@ class BomManagedVersions { /** * Walks the BOM tree and returns the effective version of every managed * artifact, resolving property references (and imported-BOM versions) with - * the supplied {@code propertyResolver} taking precedence over the BOM's + * the supplied {@code propertyResolver} taking precedence over each POM's * own {@code } defaults. */ private static Map computeManagedVersions( @@ -286,7 +283,6 @@ class BomManagedVersions { Collection bomCoordinatesList, Function propertyResolver ) { - def bomProperties = new LinkedHashMap() def artifactVersions = new LinkedHashMap() def processed = new HashSet() @@ -297,7 +293,7 @@ class BomManagedVersions { continue } processBom(configurations, dependencies, parts[0], parts[1], parts[2], - propertyResolver, bomProperties, artifactVersions, processed) + propertyResolver, artifactVersions, processed) } artifactVersions @@ -308,7 +304,6 @@ class BomManagedVersions { DependencyHandler dependencies, String group, String artifact, String version, Function propertyResolver, - Map bomProperties, Map artifactVersions, Set processed ) { @@ -322,13 +317,51 @@ class BomManagedVersions { return } - def doc = parseXml(pomFile) - if (doc == null) { + def model = parseModel(pomFile) + if (model == null) { return } - extractProperties(doc, bomProperties) - processManagedDependencies(doc, configurations, dependencies, propertyResolver, bomProperties, artifactVersions, processed) + // Build this BOM's property context, scoped to the BOM and its parent + // chain (parent properties first so the child can override them), then + // the Maven built-in project coordinates. Properties are NOT shared + // across unrelated imported BOMs - each imported BOM resolves its own + // managed versions against its own property scope, matching Maven. + def bomProperties = new LinkedHashMap() + populateProperties(configurations, dependencies, model, bomProperties, new HashSet()) + bomProperties.put('project.groupId', group) + bomProperties.put('project.version', version) + + processManagedDependencies(model, configurations, dependencies, propertyResolver, bomProperties, artifactVersions, processed) + } + + /** + * Merges the {@code } of the given model and its parent POM + * chain into {@code bomProperties}. Parent properties are added first so a + * child POM's properties override an inherited value, matching Maven's + * parent-inheritance semantics. + */ + private static void populateProperties( + ConfigurationContainer configurations, + DependencyHandler dependencies, + Model model, + Map bomProperties, + Set processedParents + ) { + Parent parent = model.parent + if (parent != null && parent.groupId && parent.artifactId && parent.version) { + def parentKey = "${parent.groupId}:${parent.artifactId}:${parent.version}" as String + if (processedParents.add(parentKey)) { + def parentPom = resolvePomFile(configurations, dependencies, parent.groupId, parent.artifactId, parent.version) + if (parentPom != null) { + def parentModel = parseModel(parentPom) + if (parentModel != null) { + populateProperties(configurations, dependencies, parentModel, bomProperties, processedParents) + } + } + } + } + extractProperties(model, bomProperties) } private static File resolvePomFile(ConfigurationContainer configurations, @@ -347,45 +380,41 @@ class BomManagedVersions { } } - private static Document parseXml(File pomFile) { + private static Model parseModel(File pomFile) { + InputStream input = null try { - def 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) + input = pomFile.newInputStream() + return new MavenXpp3Reader().read(input) } catch (Exception e) { LOG.warn('Failed to parse BOM POM: {} - {}', pomFile.name, e.message) return null } + finally { + input?.close() + } } - private static void extractProperties(Document doc, Map bomProperties) { - def propertiesNodes = doc.getElementsByTagName('properties') - if (propertiesNodes.length == 0) { - return + private static void extractProperties(Model model, Map bomProperties) { + for (Map.Entry entry : model.properties.entrySet()) { + def name = entry.key?.toString() + def value = entry.value?.toString()?.trim() + if (name && value) { + bomProperties.put(name, value) + } } + } - def propertiesElement = (Element) propertiesNodes.item(0) - def children = propertiesElement.childNodes - for (int i = 0; i < children.length; i++) { - if (children.item(i) instanceof Element) { - def prop = (Element) children.item(i) - def name = prop.tagName - def value = prop.textContent?.trim() - if (name && value) { - bomProperties.put(name, value) - } - } + private static List managedDependencies(Model model) { + def depMgmt = model.dependencyManagement + if (depMgmt == null) { + return Collections. emptyList() } + depMgmt.dependencies ?: Collections. emptyList() } private static void processManagedDependencies( - Document doc, + Model model, ConfigurationContainer configurations, DependencyHandler dependencies, Function propertyResolver, @@ -393,20 +422,11 @@ class BomManagedVersions { Map artifactVersions, Set processed ) { - def depMgmtNodes = doc.getElementsByTagName('dependencyManagement') - if (depMgmtNodes.length == 0) { + def managed = managedDependencies(model) + if (managed.isEmpty()) { return } - def depMgmt = (Element) depMgmtNodes.item(0) - def dependenciesNodes = depMgmt.getElementsByTagName('dependencies') - if (dependenciesNodes.length == 0) { - return - } - - def dependenciesElement = (Element) dependenciesNodes.item(0) - def depNodes = dependenciesElement.getElementsByTagName('dependency') - // Record this BOM's own managed versions first and defer imported BOMs, so a // BOM's direct entries take precedence over the entries it imports (matching // Maven's dependencyManagement resolution, where the importing POM wins). @@ -416,35 +436,29 @@ class BomManagedVersions { // The two-pass diff in resolve() discards versions that are identical with and // without overrides, so recording literal versions never produces spurious // overrides. - List importedBoms = new ArrayList<>() - for (int i = 0; i < depNodes.length; i++) { - def dep = (Element) depNodes.item(i) - def depGroupId = getChildText(dep, 'groupId') - def depArtifactId = getChildText(dep, 'artifactId') - def depVersion = getChildText(dep, 'version') - def depScope = getChildText(dep, 'scope') - - if (!depGroupId || !depArtifactId) { + List importedBoms = new ArrayList<>() + for (Dependency dep : managed) { + if (!dep.groupId || !dep.artifactId) { continue } - if ('import' == depScope) { - importedBoms.add([depGroupId, depArtifactId, depVersion] as String[]) + if ('import' == dep.scope) { + importedBoms.add(dep) continue } - def resolvedVersion = resolveVersion(depVersion, propertyResolver, bomProperties) + def resolvedVersion = resolveVersion(dep.version, propertyResolver, bomProperties) if (resolvedVersion) { - def artifactKey = "${depGroupId}:${depArtifactId}" as String + def artifactKey = "${dep.groupId}:${dep.artifactId}" as String artifactVersions.putIfAbsent(artifactKey, resolvedVersion) } } - for (String[] importedBom : importedBoms) { - def resolvedVersion = resolveVersion(importedBom[2], propertyResolver, bomProperties) + for (Dependency importedBom : importedBoms) { + def resolvedVersion = resolveVersion(importedBom.version, propertyResolver, bomProperties) if (resolvedVersion) { - processBom(configurations, dependencies, importedBom[0], importedBom[1], resolvedVersion, - propertyResolver, bomProperties, artifactVersions, processed) + processBom(configurations, dependencies, importedBom.groupId, importedBom.artifactId, resolvedVersion, + propertyResolver, artifactVersions, processed) } } } @@ -493,12 +507,4 @@ class BomManagedVersions { } result.contains('${') ? null : result } - - private static String getChildText(Element parent, String childTagName) { - def children = parent.getElementsByTagName(childTagName) - if (children.length == 0) { - return null - } - children.item(0).textContent?.trim() - } } From 6ef4ec203bf0cab2dd3e246019ead6d557f6814a Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 4 Jun 2026 03:32:58 -0400 Subject: [PATCH 19/23] Apply exactly one Grails BOM via a configurable grails.bom property Replace the GrailsExtension.autoApplyBom boolean with a Property bom that names the Grails BOM artifact to apply (default grails-bom, null or blank to opt out). The Grails Gradle plugin applies a single BOM as a platform (or an enforcedPlatform for the Micronaut variants), honors a BOM the build declares by hand instead of layering a second one, fills the remaining declarable configurations with that same BOM, and fails fast when more than one distinct Grails BOM is declared. The deprecated springDependencyManagement = false flag maps onto bom = null. GrailsDependencyValidatorPlugin.detectBomPath now expects exactly one Grails BOM. Update the upgrade guide and the affected tests accordingly. Assisted-by: claude-code:claude-4.8-opus --- .../GrailsDependencyValidatorPlugin.groovy | 45 ++--- .../src/en/guide/upgrading/upgrading80x.adoc | 27 ++- .../gradle/plugin/core/GrailsExtension.groovy | 84 ++++++--- .../plugin/core/GrailsGradlePlugin.groovy | 176 +++++++++++++----- ....groovy => BomOptOutFunctionalSpec.groovy} | 14 +- .../core/BomPlatformFunctionalSpec.groovy | 17 ++ .../plugin/core/GrailsExtensionSpec.groovy | 69 +++++-- .../auto-apply-bom-disabled/build.gradle | 2 +- .../bom-platform-manual/build.gradle | 29 +++ .../bom-platform-manual/gradle.properties | 1 + .../grails-app/conf/application.yml | 2 + .../bom-platform-manual/settings.gradle | 1 + 12 files changed, 345 insertions(+), 122 deletions(-) rename grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/{AutoApplyBomSpec.groovy => BomOptOutFunctionalSpec.groovy} (74%) create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/bom-platform-manual/build.gradle create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/bom-platform-manual/gradle.properties create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/bom-platform-manual/grails-app/conf/application.yml create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/bom-platform-manual/settings.gradle 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 92a58c12a08..cb4d19a7730 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,11 +28,9 @@ 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 @@ -162,17 +160,15 @@ 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.

    + *

    Exactly one Grails BOM is expected on a project: the BOMs are split by + * integration (default / hibernate5 / micronaut), so a project selects a single + * variant. This method returns the path of the one declared Grails BOM, {@code null} + * when none is declared, and fails the build when more than one distinct Grails BOM + * is found (which indicates a misconfiguration - e.g. layering grails-bom and + * grails-micronaut-bom on the same project).

    */ static String detectBomPath(Project project) { - String regularPlatformBomPath = null + Set bomPaths = new LinkedHashSet<>() for (Configuration config : project.configurations) { for (Dependency dep : config.dependencies) { @@ -183,27 +179,22 @@ class GrailsDependencyValidatorPlugin implements Plugin { if (bomProject == null) { continue } - if (isEnforcedPlatformDependency(dep)) { - return bomProject.path - } - if (regularPlatformBomPath == null) { - regularPlatformBomPath = bomProject.path - } + bomPaths.add(bomProject.path) } } - regularPlatformBomPath - } - - private static boolean isEnforcedPlatformDependency(Dependency dep) { - if (!(dep instanceof ModuleDependency)) { - return false + if (bomPaths.isEmpty()) { + return null } - def categoryAttr = ((ModuleDependency) dep).attributes.getAttribute(Category.CATEGORY_ATTRIBUTE) - if (categoryAttr == null) { - return false + if (bomPaths.size() > 1) { + throw new GradleException( + "Project '${project.name}' declares more than one Grails BOM (${bomPaths.join(', ')}). " + + 'Exactly one Grails BOM may be applied; the BOMs are split by integration ' + + '(default / hibernate5 / micronaut), so a project must select a single variant.' + ) } - categoryAttr.toString() == Category.ENFORCED_PLATFORM + + bomPaths.first() } /** diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 2352b928034..a3f6acb2851 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -505,8 +505,8 @@ slf4j.version=2.0.9 ---- The `grails { springDependencyManagement = … }` flag on `GrailsExtension` is deprecated. -For backward compatibility, setting it to `false` is still honored and is equivalent to `autoApplyBom = false`: it suppresses the automatic `platform(grails-bom)` application, just as it previously prevented the Spring Dependency Management BOM from being applied. -New builds should use `grails { autoApplyBom = false }` instead. +For backward compatibility, setting it to `false` is still honored and is equivalent to `grails { bom = null }`: it suppresses the automatic Grails BOM `platform()` application, just as it previously prevented the Spring Dependency Management BOM from being applied. +New builds should use `grails { bom = null }` instead. **Two Spring DM features have no automatic migration.** If your `build.gradle` uses either of the patterns below, update it as follows: @@ -533,19 +533,32 @@ Overriding a property that selects an imported BOM's version (for example `sprin NOTE: Projects that apply the `grails-micronaut` plugin already require `enforcedPlatform` for unrelated reasons (see section 7.2); those projects retain Spring DM's strict-version semantics automatically. -**Opting out of the auto-applied BOM.** -The Grails Gradle plugin applies `grails-bom` automatically by default - this preserves the Grails 7 behaviour and is the right choice for the vast majority of applications. -You can disable it if you intentionally want to manage Grails dependency versions yourself (for example, when consuming Grails modules from a different curated platform): +**Selecting or opting out of the auto-applied BOM.** +The Grails Gradle plugin applies a single Grails BOM automatically, selected by the `grails.bom` property. Its value is the BOM artifact name within the `org.apache.grails` group, which the plugin resolves as `org.apache.grails:$bom:$grailsVersion`. +It defaults to `grails-bom`, which preserves the Grails 7 behaviour and is the right choice for the vast majority of applications. +Exactly one Grails BOM is ever applied; the BOMs are split by integration (default / hibernate5 / micronaut), so the plugin never layers two of them. + +Select a different curated variant when your application needs it - for example the Micronaut variant, which the plugin applies as an `enforcedPlatform`: + +[source,groovy] +.build.gradle +---- +grails { + bom = 'grails-micronaut-bom' +} +---- + +Set it to `null` to suppress the automatic BOM application entirely - for example when you intentionally want to manage Grails dependency versions yourself (consuming Grails modules from a different curated platform): [source,groovy] .build.gradle ---- grails { - autoApplyBom = false + bom = null } ---- -When disabled, the Grails Gradle plugin will not declare `platform("org.apache.grails:grails-bom:$grailsVersion")` on your configurations and will not apply the `org.apache.grails.gradle.bom-property-overrides` plugin. +When set to `null`, the Grails Gradle plugin will not declare a Grails `platform()`/`enforcedPlatform()` on your configurations and will not apply the `org.apache.grails.gradle.bom-property-overrides` plugin. You are responsible for declaring the BOM yourself and, if you still want `gradle.properties` / `ext['...']` overrides, applying `id 'org.apache.grails.gradle.bom-property-overrides'` explicitly. ==== 15. Spring Boot Starter Renames 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 d52a5623f34..7fac362b10b 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 @@ -39,12 +39,22 @@ class GrailsExtension { Project project PluginDefiner pluginDefiner + /** + * The default Grails BOM artifact name applied when {@link #bom} is not + * overridden. Resolved as {@code org.apache.grails:grails-bom:$grailsVersion}. + */ + static final String DEFAULT_BOM = 'grails-bom' + GrailsExtension(Project project) { this.project = project this.pluginDefiner = new PluginDefiner(project) this.indy = project.objects.property(Boolean).convention(false) this.preserveParameterNames = project.objects.property(Boolean).convention(true) - this.autoApplyBom = project.objects.property(Boolean).convention(true) + this.bom = project.objects.property(String) + // Use set() rather than convention() so that clearing the value (bom = null, + // or the deprecated springDependencyManagement = false) results in no BOM being + // applied, instead of silently falling back to the convention. + this.bom.set(DEFAULT_BOM) } /** @@ -89,26 +99,26 @@ class GrailsExtension { * supplied by the {@code org.apache.grails.gradle.bom-property-overrides} plugin. * Set version overrides in {@code gradle.properties} or via {@code ext['property.name']} * instead. For backward compatibility, setting this property to {@code false} is still - * honored as {@code autoApplyBom = false} so existing opt-outs (which previously prevented + * honored as {@code bom = null} so existing opt-outs (which previously prevented * the Spring Dependency Management BOM from being applied) continue to disable the - * automatic {@code platform(grails-bom)} application. New builds should set - * {@link #autoApplyBom} directly. + * automatic Grails BOM {@code platform()} application. New builds should set + * {@link #bom} directly. */ @Deprecated boolean springDependencyManagement = true /** * Backward-compatible setter for the deprecated {@link #springDependencyManagement} flag. - * Disabling it maps onto {@code autoApplyBom = false} so projects that still opt out via - * {@code grails { springDependencyManagement = false }} do not unexpectedly receive - * {@code platform(grails-bom)} after the migration away from the Spring Dependency - * Management plugin. + * Disabling it clears {@link #bom} (equivalent to {@code grails { bom = null }}) so projects + * that still opt out via {@code grails { springDependencyManagement = false }} do not + * unexpectedly receive the Grails BOM {@code platform()} after the migration away from the + * Spring Dependency Management plugin. */ @Deprecated void setSpringDependencyManagement(boolean enabled) { this.@springDependencyManagement = enabled if (!enabled) { - this.autoApplyBom.set(false) + this.bom.set((String) null) } } @@ -147,30 +157,60 @@ class GrailsExtension { final Property preserveParameterNames /** - * Whether the Grails Gradle plugin should automatically apply the {@code grails-bom} - * as a Gradle {@code platform()} on every declarable project configuration - * (and apply the {@code org.apache.grails.gradle.bom-property-overrides} plugin - * for property-based version overrides). + * The Grails BOM that the Grails Gradle plugin automatically applies as a Gradle + * {@code platform()} (or {@code enforcedPlatform()} for the Micronaut variants) on every + * declarable project configuration, alongside the + * {@code org.apache.grails.gradle.bom-property-overrides} plugin for property-based + * version overrides. + * + *

    The value is the BOM artifact name within the + * {@code org.apache.grails} group; the plugin resolves it as + * {@code org.apache.grails:$bom:$grailsVersion}. Exactly one BOM is ever applied.

    * - *

    Defaults to {@code true}, which matches the behaviour of every Grails 7 release: - * the BOM is always applied so that the framework's curated managed-dependency set - * is the source of truth for the application.

    + *

    Defaults to {@code grails-bom}. Set it to a different curated variant when the + * application needs that BOM instead - for example:

    + * + *
    +     * grails {
    +     *     bom = 'grails-micronaut-bom'   // applied as an enforcedPlatform
    +     * }
    +     * 
    * - *

    Disable this only when you intentionally want to manage Grails dependencies - * yourself - for example, when consuming Grails modules from a different curated - * platform and you need to declare the BOM by hand (and apply + *

    Set it to {@code null} (or use the deprecated + * {@code grails { springDependencyManagement = false }}) to suppress the automatic BOM + * application entirely - for example when you want to declare the + * {@code platform()}/{@code enforcedPlatform()} by hand (and apply * {@code org.apache.grails.gradle.bom-property-overrides} explicitly if you still - * want {@code gradle.properties} / {@code ext['...']} overrides).

    + * want {@code gradle.properties} / {@code ext['...']} overrides):

    * *
          * grails {
    -     *     autoApplyBom = false
    +     *     bom = null
          * }
          * 
    * + *

    The Micronaut variants ({@code grails-micronaut-bom}, + * {@code grails-hibernate5-micronaut-bom}) are applied as an {@code enforcedPlatform} + * because the Micronaut platform would otherwise override their managed versions via + * conflict resolution. All other BOMs are applied as a regular {@code platform}.

    + * * @since 8.0 */ - final Property autoApplyBom + final Property bom + + /** + * DSL setter for {@link #bom}. A {@code null} or blank value clears the property so that + * no BOM is applied automatically; any other value is used verbatim as the BOM artifact name. + */ + void setBom(String value) { + String trimmed = value?.trim() + if (trimmed) { + this.bom.set(trimmed) + } + else { + this.bom.set((String) null) + } + } DependencyHandler getPlugins() { if (pluginDefiner == null) { 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 54fd4dc5696..8fc98749fd6 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 @@ -370,17 +370,20 @@ ${importStatements} } /** - * Applies the Grails BOM as a Gradle platform and enables property-based + * Applies a single 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. BOM import: the BOM selected by {@code grails.bom} + * (default {@code grails-bom}) is added as a Gradle {@code platform()} + * dependency - or an {@code enforcedPlatform()} for the Micronaut variants - + * on every declarable configuration, mirroring the global behaviour Spring + * DM provided via {@code configurations.all() + resolutionStrategy.eachDependency()}. + * Exactly one Grails BOM is ever applied; the BOMs are split by integration + * (default / hibernate5 / micronaut), so the plugin never layers two of them.
    4. *
    5. Property overrides: the BOM-agnostic * {@link BomPropertyOverridesPlugin} reads the BOM's * {@code } block and applies any project-level @@ -388,6 +391,10 @@ ${importStatements} * {@code ResolutionStrategy.eachDependency()}.
    6. *
    * + *

    Set {@code grails { bom = null }} (or the deprecated + * {@code grails { springDependencyManagement = false }}) to suppress the automatic BOM + * application entirely and declare the {@code platform()}/{@code enforcedPlatform()} by hand.

    + * *

    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}:

    *
    @@ -409,55 +416,137 @@ ${importStatements}
             // same configuration phase can rely on the configuration existing.
             project.configurations.maybeCreate('developmentOnly')
     
    -        // The opt-out flag `grails { autoApplyBom = false }` is set in the user's
    -        // build.gradle, which runs AFTER plugin apply. We therefore wait until
    -        // afterEvaluate to read the flag and apply the BOM accordingly. By that
    -        // point all declarable configurations exist (java-base creates them
    -        // during apply), so iterating them eagerly via .each is sufficient -
    -        // any plugin that adds a configuration later is responsible for
    -        // declaring its own BOM coordination if it needs it.
    +        // The BOM selection `grails { bom = ... }` is set in the user's build.gradle,
    +        // which runs AFTER plugin apply. We therefore wait until afterEvaluate to read
    +        // it and apply the BOM accordingly. By that point all declarable configurations
    +        // exist (java-base creates them during apply), so iterating them eagerly via
    +        // .each is sufficient - any plugin that adds a configuration later is responsible
    +        // for declaring its own BOM coordination if it needs it.
             project.afterEvaluate {
                 def grailsExtension = project.extensions.findByType(GrailsExtension)
    -            boolean autoApply = grailsExtension == null || grailsExtension.autoApplyBom.getOrElse(true)
    -            if (!autoApply) {
    +            def bomName = grailsExtension == null ? GrailsExtension.DEFAULT_BOM : grailsExtension.bom.getOrNull()
    +            if (!bomName) {
                     project.logger.info(
    -                    'grails.autoApplyBom is false; skipping automatic application of platform(grails-bom) and bom-property-overrides plugin for project {}',
    +                    'grails.bom is null; skipping automatic application of the Grails platform BOM and bom-property-overrides plugin for project {}',
                         project.path
                     )
                     return
                 }
     
    +            // Exactly one Grails BOM may be applied. The BOMs are split by integration
    +            // (default / hibernate5 / micronaut), so a project must select a single variant.
    +            // If the build declares a Grails BOM by hand - for example a Micronaut application
    +            // declaring enforcedPlatform(grails-micronaut-bom), or an application generated by
    +            // Grails Forge / a profile that declares platform(grails-bom) directly - honor that
    +            // selection instead of the configured default, and fail fast if more than one distinct
    +            // Grails BOM is declared.
    +            def declaredBoms = declaredGrailsBoms(project)
    +            if (declaredBoms.size() > 1) {
    +                throw new GradleException(
    +                    "Project '${project.name}' declares more than one Grails BOM (${declaredBoms.join(', ')}). " +
    +                        'Exactly one Grails BOM may be applied; the BOMs are split by integration ' +
    +                        '(default / hibernate5 / micronaut), so a project must select a single variant.'
    +                )
    +            }
    +            def effectiveBom = declaredBoms.isEmpty() ? bomName : declaredBoms.first()
    +
                 def grailsVersion = (project.findProperty('grailsVersion') ?: BuildSettings.grailsVersion) as String
    -            def bomCoordinates = "org.apache.grails:grails-bom:${grailsVersion}" as String
    -
    -            // 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
    +            def bomCoordinates = "org.apache.grails:${effectiveBom}:${grailsVersion}" as String
    +
    +            // The Micronaut BOM variants must be applied as an enforcedPlatform: they layer
    +            // Micronaut-specific overrides (javaparser-core, etc.) on top of grails-base-bom,
    +            // and Micronaut's own platform would otherwise win those versions via conflict
    +            // resolution. All other Grails BOMs are applied as a regular platform.
    +            boolean enforced = effectiveBom in ENFORCED_PLATFORM_BOMS
    +
    +            // Apply the single BOM to every declarable project configuration that does not already
    +            // declare a Grails BOM by hand, matching the behavior of the Spring Dependency
    +            // Management plugin which applied version constraints globally via configurations.all()
    +            // + resolutionStrategy.eachDependency(). Configurations that already declare the BOM
    +            // (e.g. 'implementation' in a generated app) are left untouched so a second BOM is
    +            // never layered on them, while sibling declarable configurations (such as 'console')
    +            // still receive BOM coverage. 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 a 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.
    +            // resolutionStrategy hooks, platform() constraints participate in version conflict
    +            // resolution.
                 project.configurations.each {
    -                if (it.canBeDeclared && !isExcludedFromBomPlatform(it.name)) {
    -                    project.dependencies.add(it.name, project.dependencies.platform(bomCoordinates))
    +                if (it.canBeDeclared && !isExcludedFromBomPlatform(it.name) && !configurationHasGrailsBom(it)) {
    +                    def platformDependency = enforced ?
    +                            project.dependencies.enforcedPlatform(bomCoordinates) :
    +                            project.dependencies.platform(bomCoordinates)
    +                    project.dependencies.add(it.name, platformDependency)
                     }
                 }
     
                 // Delegate property-based version overrides to the bundled 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.
    +            // Auto-detect picks up the platform()/enforcedPlatform() declaration (whether we
    +            // injected it above or the build declared it by hand), plus any additional
    +            // platform()/enforcedPlatform() the user declares. Users can extend the override
    +            // surface by declaring their own platforms - no extra configuration is required here.
                 project.plugins.apply(BomPropertyOverridesPlugin)
             }
         }
     
    +    /**
    +     * Grails BOM artifact names that must be applied as an {@code enforcedPlatform} rather than
    +     * a regular {@code platform}, because they layer Micronaut-specific overrides on top of
    +     * grails-base-bom that the Micronaut platform would otherwise override via conflict resolution.
    +     */
    +    private static final Set ENFORCED_PLATFORM_BOMS = [
    +            'grails-micronaut-bom',
    +            'grails-hibernate5-micronaut-bom',
    +    ] as Set
    +
    +    /**
    +     * Known Grails BOM artifact names (group {@code org.apache.grails}). Used to detect whether a
    +     * project already declares a Grails BOM by hand so the plugin does not inject a second one,
    +     * preserving the guarantee that exactly one Grails BOM is applied.
    +     */
    +    private static final Set GRAILS_BOM_NAMES = [
    +            'grails-bom',
    +            'grails-base-bom',
    +            'grails-hibernate5-bom',
    +            'grails-micronaut-bom',
    +            'grails-hibernate5-micronaut-bom',
    +    ] as Set
    +
    +    /**
    +     * Returns the distinct known Grails BOM artifact names declared by hand (as a {@code platform},
    +     * {@code enforcedPlatform}, or plain dependency) on the project's declarable configurations.
    +     */
    +    private static Set declaredGrailsBoms(Project project) {
    +        Set names = new LinkedHashSet<>()
    +        for (Configuration configuration : project.configurations) {
    +            if (!configuration.canBeDeclared) {
    +                continue
    +            }
    +            for (Dependency dependency : configuration.dependencies) {
    +                if (dependency.group == 'org.apache.grails' && GRAILS_BOM_NAMES.contains(dependency.name)) {
    +                    names.add(dependency.name)
    +                }
    +            }
    +        }
    +        names
    +    }
    +
    +    /**
    +     * Returns whether the given configuration already declares a known Grails BOM by hand, so the
    +     * plugin can avoid layering a second BOM on top of it.
    +     */
    +    private static boolean configurationHasGrailsBom(Configuration configuration) {
    +        for (Dependency dependency : configuration.dependencies) {
    +            if (dependency.group == 'org.apache.grails' && GRAILS_BOM_NAMES.contains(dependency.name)) {
    +                return true
    +            }
    +        }
    +        false
    +    }
    +
         private static boolean isExcludedFromBomPlatform(String name) {
             name == 'checkstyle' || name == 'codenarc' || name == 'pmd' ||
                     name == 'spotbugs' || name == 'spotbugsPlugins' ||
    @@ -541,7 +630,7 @@ ${importStatements}
         /**
          * Validates that a Micronaut-compatible BOM is applied as an enforcedPlatform when micronaut is used.
          * The grails-micronaut-bom (and its hibernate-specific variants) layers Micronaut-specific overrides
    -     * (e.g. javaparser-core) on top of grails-bom; without enforcedPlatform, Micronaut's platform would
    +     * (e.g. javaparser-core) on top of grails-base-bom; without enforcedPlatform, Micronaut's platform would
          * override these versions via Gradle's conflict resolution. Regular Grails projects (without Micronaut)
          * should continue to use the spring-managed versions via plain platform(:grails-bom).
          */
    @@ -552,14 +641,15 @@ ${importStatements}
                 return
             }
     
    -        // The Grails Gradle Plugin injects a regular platform(grails-bom) into each
    -        // declarable configuration via applyGrailsBom(), excluding code-quality and
    -        // annotation-processor classpaths (see isExcludedFromBomPlatform). For Micronaut
    -        // projects the user must additionally declare an enforcedPlatform on a Micronaut BOM
    -        // - a different BOM artifact that layers Micronaut-specific overrides on top of
    -        // grails-bom. We scan all Micronaut BOM declarations on the 'implementation'
    -        // configuration and accept the configuration as valid when at least one of them is
    -        // an enforcedPlatform.
    +        // Exactly one Grails BOM is ever applied (the BOMs are split by integration:
    +        // default / hibernate5 / micronaut). A Micronaut project selects the Micronaut
    +        // variant either by setting grails { bom = 'grails-micronaut-bom' } (auto-applied
    +        // as an enforcedPlatform by applyGrailsBom) or by opting out via grails { bom = null }
    +        // and declaring enforcedPlatform(grails-micronaut-bom) by hand. Either way the
    +        // Micronaut BOM must be an enforcedPlatform so the Micronaut platform cannot override
    +        // its versions via conflict resolution. We scan the Micronaut BOM declarations on the
    +        // 'implementation' configuration and accept it as valid when at least one is an
    +        // enforcedPlatform.
             Set validMicronautBoms = [
                     'grails-micronaut-bom',
                     'grails-hibernate5-micronaut-bom',
    diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/AutoApplyBomSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/BomOptOutFunctionalSpec.groovy
    similarity index 74%
    rename from grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/AutoApplyBomSpec.groovy
    rename to grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/BomOptOutFunctionalSpec.groovy
    index 9c27bc5f309..ce1a0a7b6e0 100644
    --- a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/AutoApplyBomSpec.groovy
    +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/BomOptOutFunctionalSpec.groovy
    @@ -19,30 +19,30 @@
     package org.grails.gradle.plugin.core
     
     /**
    - * Functional test verifying that {@code grails.autoApplyBom = false} suppresses
    - * the automatic application of {@code platform(grails-bom)} and the
    + * Functional test verifying that {@code grails.bom = null} suppresses the automatic
    + * application of the Grails platform BOM and the
      * {@code org.apache.grails.gradle.bom-property-overrides} plugin.
      *
      * @since 8.0
    - * @see GrailsExtension#getAutoApplyBom
    + * @see GrailsExtension#getBom
      * @see GrailsGradlePlugin#applyGrailsBom
      */
    -class AutoApplyBomSpec extends GradleSpecification {
    +class BomOptOutFunctionalSpec extends GradleSpecification {
     
    -    def "autoApplyBom = false suppresses platform(grails-bom) and bom-property-overrides plugin"() {
    +    def "grails.bom = null suppresses the platform BOM and bom-property-overrides plugin"() {
             given:
             setupTestResourceProject('auto-apply-bom-disabled')
     
             when:
             def result = executeTask('inspectBomSetup')
     
    -        then: 'no platform(grails-bom) is added to implementation'
    +        then: 'no Grails platform BOM is added to implementation'
             result.output.contains('HAS_PLATFORM_BOM=false')
     
             and: 'the bom-property-overrides plugin is NOT applied'
             result.output.contains('HAS_BOM_PROPERTY_OVERRIDES=false')
     
    -        and: 'Spring DM is also not applied (regardless of autoApplyBom)'
    +        and: 'Spring DM is also not applied (regardless of the bom setting)'
             result.output.contains('HAS_SPRING_DM=false')
         }
     }
    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
    index 521610c5958..2a22d28e193 100644
    --- 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
    @@ -44,4 +44,21 @@ class BomPlatformFunctionalSpec extends GradleSpecification {
             result.output.contains('HAS_BOM_PROPERTY_OVERRIDES=true')
             result.output.contains('HAS_SPRING_DM=false')
         }
    +
    +    def "plugin does not inject grails-bom when the build already declares a Grails BOM by hand"() {
    +        given: 'a project that declares the Micronaut BOM variant itself'
    +        setupTestResourceProject('bom-platform-manual')
    +
    +        when:
    +        def result = executeTask('inspectBomSetup')
    +
    +        then: 'the plugin does NOT add a second (grails-bom) platform - exactly one Grails BOM is applied'
    +        result.output.contains('HAS_GRAILS_BOM=false')
    +
    +        and: 'the hand-declared Micronaut BOM remains'
    +        result.output.contains('HAS_MICRONAUT_BOM=true')
    +
    +        and: 'property-based version overrides are still enabled for the declared BOM'
    +        result.output.contains('HAS_BOM_PROPERTY_OVERRIDES=true')
    +    }
     }
    diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/GrailsExtensionSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/GrailsExtensionSpec.groovy
    index 22e909457cc..9ffb8a79433 100644
    --- a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/GrailsExtensionSpec.groovy
    +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/GrailsExtensionSpec.groovy
    @@ -25,16 +25,15 @@ import spock.lang.Specification
     /**
      * Unit-level tests for {@link GrailsExtension} using {@link ProjectBuilder}.
      *
    - * 

    Focuses on the backward-compatible bridge between the deprecated - * {@code springDependencyManagement} flag and the new {@link GrailsExtension#getAutoApplyBom} - * property, which controls whether {@code platform(grails-bom)} is applied - * automatically.

    + *

    Focuses on the {@link GrailsExtension#getBom} property - which BOM (if any) the + * Grails Gradle plugin applies automatically - and the backward-compatible bridge from + * the deprecated {@code springDependencyManagement} flag onto it.

    * * @since 8.0 */ class GrailsExtensionSpec extends Specification { - def "autoApplyBom defaults to true"() { + def "bom defaults to grails-bom"() { given: Project project = ProjectBuilder.builder().build() @@ -42,26 +41,66 @@ class GrailsExtensionSpec extends Specification { GrailsExtension extension = new GrailsExtension(project) then: - extension.autoApplyBom.get() + extension.bom.get() == GrailsExtension.DEFAULT_BOM + extension.bom.get() == 'grails-bom' } - def "deprecated springDependencyManagement = false disables autoApplyBom for backward compatibility"() { + def "bom can be set to a different curated variant"() { given: Project project = ProjectBuilder.builder().build() GrailsExtension extension = new GrailsExtension(project) - expect: 'autoApplyBom starts enabled' - extension.autoApplyBom.get() + when: + extension.bom = 'grails-micronaut-bom' + + then: + extension.bom.get() == 'grails-micronaut-bom' + } + + def "bom = null clears the property so no BOM is applied"() { + given: + Project project = ProjectBuilder.builder().build() + GrailsExtension extension = new GrailsExtension(project) + + expect: 'the default is present' + extension.bom.getOrNull() == 'grails-bom' + + when: + extension.bom = null + + then: + extension.bom.getOrNull() == null + } + + def "bom = blank clears the property so no BOM is applied"() { + given: + Project project = ProjectBuilder.builder().build() + GrailsExtension extension = new GrailsExtension(project) + + when: + extension.bom = ' ' + + then: + extension.bom.getOrNull() == null + } + + def "deprecated springDependencyManagement = false clears bom for backward compatibility"() { + given: + Project project = ProjectBuilder.builder().build() + GrailsExtension extension = new GrailsExtension(project) + + expect: 'bom starts at the default' + extension.bom.getOrNull() == 'grails-bom' when: 'a project opts out using the legacy Grails 7 flag' extension.springDependencyManagement = false then: 'the BOM is no longer auto-applied' !extension.springDependencyManagement - !extension.autoApplyBom.get() + extension.bom.getOrNull() == null } - def "deprecated springDependencyManagement = true leaves autoApplyBom at its convention"() { + def "deprecated springDependencyManagement = true leaves bom at its default"() { given: Project project = ProjectBuilder.builder().build() GrailsExtension extension = new GrailsExtension(project) @@ -71,19 +110,19 @@ class GrailsExtensionSpec extends Specification { then: extension.springDependencyManagement - extension.autoApplyBom.get() + extension.bom.getOrNull() == 'grails-bom' } - def "an explicit autoApplyBom = false is honored independently of the deprecated flag"() { + def "an explicit bom selection is honored independently of the deprecated flag"() { given: Project project = ProjectBuilder.builder().build() GrailsExtension extension = new GrailsExtension(project) when: - extension.autoApplyBom.set(false) + extension.bom = 'grails-hibernate5-bom' then: - !extension.autoApplyBom.get() + extension.bom.getOrNull() == 'grails-hibernate5-bom' extension.springDependencyManagement } } diff --git a/grails-gradle/plugins/src/test/resources/test-projects/auto-apply-bom-disabled/build.gradle b/grails-gradle/plugins/src/test/resources/test-projects/auto-apply-bom-disabled/build.gradle index 8e8a74289fa..e3924bfcd98 100644 --- a/grails-gradle/plugins/src/test/resources/test-projects/auto-apply-bom-disabled/build.gradle +++ b/grails-gradle/plugins/src/test/resources/test-projects/auto-apply-bom-disabled/build.gradle @@ -3,7 +3,7 @@ plugins { } grails { - autoApplyBom = false + bom = null } tasks.register('inspectBomSetup') { diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-manual/build.gradle b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-manual/build.gradle new file mode 100644 index 00000000000..263526aa329 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-manual/build.gradle @@ -0,0 +1,29 @@ +plugins { + id 'org.apache.grails.gradle.grails-app' +} + +// This project already declares a Grails BOM by hand (the Micronaut variant, as an +// enforcedPlatform). The Grails Gradle plugin must therefore NOT auto-inject grails-bom on top +// of it - exactly one Grails BOM may be applied. +dependencies { + implementation enforcedPlatform("org.apache.grails:grails-micronaut-bom:$grailsVersion") +} + +tasks.register('inspectBomSetup') { + doLast { + def implDeps = configurations.implementation.allDependencies + + def hasGrailsBom = implDeps.any { dep -> + dep.group == 'org.apache.grails' && dep.name == 'grails-bom' + } + println "HAS_GRAILS_BOM=${hasGrailsBom}" + + def hasMicronautBom = implDeps.any { dep -> + dep.group == 'org.apache.grails' && dep.name == 'grails-micronaut-bom' + } + println "HAS_MICRONAUT_BOM=${hasMicronautBom}" + + def hasBomPropertyOverrides = project.plugins.findPlugin('org.apache.grails.gradle.bom-property-overrides') != null + println "HAS_BOM_PROPERTY_OVERRIDES=${hasBomPropertyOverrides}" + } +} diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-manual/gradle.properties b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-manual/gradle.properties new file mode 100644 index 00000000000..35c332fb874 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-manual/gradle.properties @@ -0,0 +1 @@ +grailsVersion=__PROJECT_VERSION__ diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-manual/grails-app/conf/application.yml b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-manual/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-manual/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-manual/settings.gradle b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-manual/settings.gradle new file mode 100644 index 00000000000..596be9aa2f2 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-manual/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'test-bom-platform-manual' From 59dcb7e5864bc340e1d3aa02ea0da7a37361f3d2 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 4 Jun 2026 03:33:38 -0400 Subject: [PATCH 20/23] Add example apps covering the legacy Spring Dependency Management plugin Grails 8 no longer applies io.spring.dependency-management automatically. Add regression coverage that it still works when applied by hand: - grails-test-examples/spring-dependency-management: a Grails application that opts out of the automatic BOM (grails { bom = null }) and manages versions with io.spring.dependency-management importing grails-bom. - grails-test-examples/gsp-spring-boot: re-enable the non-Grails Spring Boot + GSP application and have it manage versions with the Spring Dependency Management plugin importing grails-bom. Assisted-by: claude-code:claude-4.8-opus --- .../gsp-spring-boot/app/build.gradle | 30 +++++++- .../src/main/resources/application.properties | 4 + .../test/java/hello/WebControllerTest.java | 67 ++++++++++++++++ .../spring-dependency-management/build.gradle | 76 ++++++++++++++++++ .../grails-app/conf/application.yml | 77 +++++++++++++++++++ .../grails-app/controllers/UrlMappings.groovy | 33 ++++++++ .../springdm/HelloController.groovy | 27 +++++++ .../init/springdm/Application.groovy | 29 +++++++ .../grails-app/views/error.gsp | 28 +++++++ .../grails-app/views/index.gsp | 30 ++++++++ .../grails-app/views/notFound.gsp | 28 +++++++ .../springdm/HelloControllerSpec.groovy | 45 +++++++++++ settings.gradle | 6 +- 13 files changed, 475 insertions(+), 5 deletions(-) create mode 100644 grails-test-examples/gsp-spring-boot/app/src/test/java/hello/WebControllerTest.java create mode 100644 grails-test-examples/spring-dependency-management/build.gradle create mode 100644 grails-test-examples/spring-dependency-management/grails-app/conf/application.yml create mode 100644 grails-test-examples/spring-dependency-management/grails-app/controllers/UrlMappings.groovy create mode 100644 grails-test-examples/spring-dependency-management/grails-app/controllers/springdm/HelloController.groovy create mode 100644 grails-test-examples/spring-dependency-management/grails-app/init/springdm/Application.groovy create mode 100644 grails-test-examples/spring-dependency-management/grails-app/views/error.gsp create mode 100644 grails-test-examples/spring-dependency-management/grails-app/views/index.gsp create mode 100644 grails-test-examples/spring-dependency-management/grails-app/views/notFound.gsp create mode 100644 grails-test-examples/spring-dependency-management/src/integration-test/groovy/springdm/HelloControllerSpec.groovy diff --git a/grails-test-examples/gsp-spring-boot/app/build.gradle b/grails-test-examples/gsp-spring-boot/app/build.gradle index 7cae3b48ccb..6abbfdf9e4e 100644 --- a/grails-test-examples/gsp-spring-boot/app/build.gradle +++ b/grails-test-examples/gsp-spring-boot/app/build.gradle @@ -17,15 +17,34 @@ * under the License. */ +// This is a Spring Boot application (NOT a Grails application) that renders views with +// Grails GSP. End-user Spring Boot applications are not expected to adopt the Grails Gradle +// plugins for dependency management, so this example manages dependency versions with the +// legacy io.spring.dependency-management plugin importing the published grails-bom - which +// itself imports spring-boot-dependencies and additionally manages the Grails-specific +// libraries (e.g. SiteMesh) that the Grails GSP modules depend on. This is exactly how a +// Spring Boot build consuming Grails libraries would manage versions, and is regression +// coverage that such an application can keep using the Spring Dependency Management plugin +// now that Grails 8 no longer applies that plugin automatically. plugins { id 'java' id 'war' id 'org.springframework.boot' - id "groovy" + id 'groovy' id 'org.apache.grails.buildsrc.dependency-validator' } +// grails-gsp compiles the GSP templates at build time. It is a build-time view-compilation +// helper and does not provide dependency management - version management here is entirely +// the responsibility of the Spring Dependency Management plugin below. apply plugin: 'org.apache.grails.gradle.grails-gsp' +apply plugin: 'io.spring.dependency-management' + +dependencyManagement { + imports { + mavenBom "org.apache.grails:grails-bom:${projectVersion}" + } +} jar { processResources.exclude('**/*.gsp') @@ -36,7 +55,6 @@ compileGroovyPages { } dependencies { - implementation platform(project(':grails-bom')) implementation project(':grails-gsp-spring-boot') implementation 'org.hibernate.validator:hibernate-validator' // validation @@ -45,4 +63,10 @@ dependencies { implementation 'org.apache.tomcat.embed:tomcat-embed-jasper' // jsp implementation 'org.apache.tomcat.embed:tomcat-embed-el' -} \ No newline at end of file + + testImplementation 'org.springframework.boot:spring-boot-starter-test' +} + +apply { + from rootProject.layout.projectDirectory.file('gradle/functional-test-config.gradle') +} diff --git a/grails-test-examples/gsp-spring-boot/app/src/main/resources/application.properties b/grails-test-examples/gsp-spring-boot/app/src/main/resources/application.properties index aa6f5f8d4b0..af6b6a05ffd 100644 --- a/grails-test-examples/gsp-spring-boot/app/src/main/resources/application.properties +++ b/grails-test-examples/gsp-spring-boot/app/src/main/resources/application.properties @@ -15,5 +15,9 @@ grails.gsp.tldScanPattern=classpath*:/META-INF/spring*.tld spring.main.allow-circular-references=true +# Grails' GspAutoConfiguration and the Grails codecs plugin both contribute a 'codecLookup' +# bean. A full Grails application enables bean-definition overriding by default; a plain +# Spring Boot application must opt in so the Grails GSP integration can register here. +spring.main.allow-bean-definition-overriding=true logging.level.web=trace sitemesh.decorator.default=main \ No newline at end of file diff --git a/grails-test-examples/gsp-spring-boot/app/src/test/java/hello/WebControllerTest.java b/grails-test-examples/gsp-spring-boot/app/src/test/java/hello/WebControllerTest.java new file mode 100644 index 00000000000..f6097adbab7 --- /dev/null +++ b/grails-test-examples/gsp-spring-boot/app/src/test/java/hello/WebControllerTest.java @@ -0,0 +1,67 @@ +/* + * 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 hello; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Intended regression coverage that this Spring Boot application - which manages dependency + * versions with the legacy {@code io.spring.dependency-management} plugin (importing grails-bom) + * rather than any Grails Gradle plugin - boots and renders a Grails GSP view. + * + *

    The build-level half of that guarantee (the Spring Dependency Management plugin resolving + * grails-bom and the Grails libraries, and the GSP templates compiling) is already exercised by + * building this module. This runtime test is {@link Disabled} because rendering GSP in a + * non-Grails Spring Boot application does not currently start on Spring Boot 4: the + * Grails GSP Spring Boot auto-configuration forms a {@code requestMappingHandlerAdapter} -> + * {@code gspViewResolver} bean dependency cycle in a servlet web context, while + * {@code CoreAutoConfiguration} requires a {@code GrailsApplication} that is only contributed by + * that same auto-configuration. This GSP-integration limitation is unrelated to dependency + * management. Re-enable this test once GSP rendering works in a standalone Spring Boot + * application.

    + */ +@Disabled("GSP rendering in a non-Grails Spring Boot application does not yet start on Spring Boot 4 " + + "(GSP auto-configuration bean cycle); unrelated to the Spring Dependency Management coverage " + + "this example provides at build time.") +@SpringBootTest(classes = Application.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class WebControllerTest { + + @Value("${local.server.port}") + int port; + + @Test + void gspViewRendersInSpringBootWithSpringDependencyManagement() throws Exception { + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/")).GET().build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).contains("Name:"); + } +} diff --git a/grails-test-examples/spring-dependency-management/build.gradle b/grails-test-examples/spring-dependency-management/build.gradle new file mode 100644 index 00000000000..0758d2aed58 --- /dev/null +++ b/grails-test-examples/spring-dependency-management/build.gradle @@ -0,0 +1,76 @@ +/* + * 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. + */ + +// This Grails application is regression coverage for the legacy Spring Dependency +// Management Gradle plugin (io.spring.dependency-management). Grails 8 no longer +// applies that plugin automatically - dependency versions are managed by Gradle's +// native platform() support via the Grails Gradle plugin. However, an existing +// Grails 7 application that applied io.spring.dependency-management by hand must +// continue to work. This example reproduces that scenario: +// +// * grails { bom = null } opts out of the Grails Gradle plugin's automatic +// platform(grails-bom) injection, so version management is NOT provided by the +// framework's native platform. +// * io.spring.dependency-management is applied directly and imports grails-bom as +// a Maven BOM, exactly as a migrated Grails 7 build would, so that the Spring DM +// plugin is the source of truth for managed versions here. +plugins { + id 'org.apache.grails.buildsrc.properties' + id 'org.apache.grails.buildsrc.compile' +} + +version = '0.1' +group = 'functionaltests' + +apply plugin: 'org.apache.grails.gradle.grails-web' +apply plugin: 'org.apache.grails.gradle.grails-gsp' + +// io.spring.dependency-management is already available on the build classpath, so it is +// applied here without a version (an explicit version in the plugins {} block fails when a +// plugin is already on the classpath with an unknown version). A real end-user Grails build +// would instead declare it in its own plugins {} block with a version. +apply plugin: 'io.spring.dependency-management' + +grails { + // Opt out of the native platform(grails-bom) injection; Spring DM manages versions instead. + bom = null +} + +dependencyManagement { + imports { + mavenBom "org.apache.grails:grails-bom:${projectVersion}" + } +} + +dependencies { + implementation 'org.apache.grails:grails-dependencies-starter-web' + implementation 'org.apache.grails:grails-data-hibernate5' + implementation 'org.apache.grails:grails-layout' + + runtimeOnly 'com.h2database:h2' + runtimeOnly 'org.apache.tomcat:tomcat-jdbc' + + testImplementation 'org.apache.grails:grails-testing-support-web' + + integrationTestImplementation 'org.apache.grails:grails-testing-support-http-client' +} + +apply { + from rootProject.layout.projectDirectory.file('gradle/functional-test-config.gradle') +} diff --git a/grails-test-examples/spring-dependency-management/grails-app/conf/application.yml b/grails-test-examples/spring-dependency-management/grails-app/conf/application.yml new file mode 100644 index 00000000000..2264b60f433 --- /dev/null +++ b/grails-test-examples/spring-dependency-management/grails-app/conf/application.yml @@ -0,0 +1,77 @@ +# 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. + +--- +grails: + profile: web + codegen: + defaultPackage: springdm +info: + app: + name: '@info.app.name@' + version: '@info.app.version@' + grailsVersion: '@info.app.grailsVersion@' +--- +grails: + mime: + types: + all: '*/*' + css: text/css + csv: text/csv + form: application/x-www-form-urlencoded + html: + - text/html + - application/xhtml+xml + js: text/javascript + json: + - application/json + - text/json + multipartForm: multipart/form-data + text: text/plain + xml: + - text/xml + - application/xml + views: + gsp: + encoding: UTF-8 + htmlcodec: xml + codecs: + scriptlet: html +--- +hibernate: + cache: + queries: false + use_second_level_cache: false + use_query_cache: false +dataSource: + pooled: true + jmxExport: true + driverClassName: org.h2.Driver + username: sa + password: '' + +environments: + development: + dataSource: + dbCreate: create-drop + url: jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE + test: + dataSource: + dbCreate: update + url: jdbc:h2:mem:testDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE + production: + dataSource: + dbCreate: update + url: jdbc:h2:mem:prodDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE diff --git a/grails-test-examples/spring-dependency-management/grails-app/controllers/UrlMappings.groovy b/grails-test-examples/spring-dependency-management/grails-app/controllers/UrlMappings.groovy new file mode 100644 index 00000000000..4ccbb288b2f --- /dev/null +++ b/grails-test-examples/spring-dependency-management/grails-app/controllers/UrlMappings.groovy @@ -0,0 +1,33 @@ +/* + * 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. + */ + +class UrlMappings { + + static mappings = { + "/$controller/$action?/$id?(.$format)?" { + constraints { + // apply constraints here + } + } + + "/"(view: '/index') + '500'(view: '/error') + '404'(view: '/notFound') + } +} diff --git a/grails-test-examples/spring-dependency-management/grails-app/controllers/springdm/HelloController.groovy b/grails-test-examples/spring-dependency-management/grails-app/controllers/springdm/HelloController.groovy new file mode 100644 index 00000000000..2ce6739e257 --- /dev/null +++ b/grails-test-examples/spring-dependency-management/grails-app/controllers/springdm/HelloController.groovy @@ -0,0 +1,27 @@ +/* + * 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 springdm + +class HelloController { + + def index() { + render 'Hello from Spring Dependency Management' + } +} diff --git a/grails-test-examples/spring-dependency-management/grails-app/init/springdm/Application.groovy b/grails-test-examples/spring-dependency-management/grails-app/init/springdm/Application.groovy new file mode 100644 index 00000000000..a87804c7328 --- /dev/null +++ b/grails-test-examples/spring-dependency-management/grails-app/init/springdm/Application.groovy @@ -0,0 +1,29 @@ +/* + * 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 springdm + +import grails.boot.GrailsApp +import grails.boot.config.GrailsAutoConfiguration + +class Application extends GrailsAutoConfiguration { + static void main(String[] args) { + GrailsApp.run(Application, args) + } +} diff --git a/grails-test-examples/spring-dependency-management/grails-app/views/error.gsp b/grails-test-examples/spring-dependency-management/grails-app/views/error.gsp new file mode 100644 index 00000000000..7310b1328df --- /dev/null +++ b/grails-test-examples/spring-dependency-management/grails-app/views/error.gsp @@ -0,0 +1,28 @@ +<%-- + ~ 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. + --%> + + + + + Error + + +

    An error has occurred

    + + diff --git a/grails-test-examples/spring-dependency-management/grails-app/views/index.gsp b/grails-test-examples/spring-dependency-management/grails-app/views/index.gsp new file mode 100644 index 00000000000..1217e7c24fb --- /dev/null +++ b/grails-test-examples/spring-dependency-management/grails-app/views/index.gsp @@ -0,0 +1,30 @@ +<%-- + ~ 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. + --%> + + + + + Spring Dependency Management Example + + +

    Spring Dependency Management Example

    +

    This Grails application manages its dependency versions with the legacy + io.spring.dependency-management Gradle plugin.

    + + diff --git a/grails-test-examples/spring-dependency-management/grails-app/views/notFound.gsp b/grails-test-examples/spring-dependency-management/grails-app/views/notFound.gsp new file mode 100644 index 00000000000..3ff64ec3f49 --- /dev/null +++ b/grails-test-examples/spring-dependency-management/grails-app/views/notFound.gsp @@ -0,0 +1,28 @@ +<%-- + ~ 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. + --%> + + + + + Not Found + + +

    Page Not Found

    + + diff --git a/grails-test-examples/spring-dependency-management/src/integration-test/groovy/springdm/HelloControllerSpec.groovy b/grails-test-examples/spring-dependency-management/src/integration-test/groovy/springdm/HelloControllerSpec.groovy new file mode 100644 index 00000000000..426a383d61d --- /dev/null +++ b/grails-test-examples/spring-dependency-management/src/integration-test/groovy/springdm/HelloControllerSpec.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 springdm + +import grails.testing.mixin.integration.Integration +import org.apache.grails.testing.http.client.HttpClientSupport +import spock.lang.Specification +import spock.lang.Tag + +/** + * Verifies that a Grails application whose dependency versions are managed by the legacy + * {@code io.spring.dependency-management} Gradle plugin (rather than the Grails Gradle + * plugin's native {@code platform(grails-bom)} injection, which is opted out of via + * {@code grails { bom = null }}) still boots and serves a request. This is regression + * coverage that the Spring Dependency Management plugin continues to work with Grails 8 + * when applied by hand, as an upgraded Grails 7 application would. + */ +@Integration +@Tag('http-client') +class HelloControllerSpec extends Specification implements HttpClientSupport { + + void 'the application boots with Spring Dependency Management and serves a request'() { + when: + def response = http('/hello') + + then: + response.assertEquals(200, 'Hello from Spring Dependency Management') + } +} diff --git a/settings.gradle b/settings.gradle index 9414e2c1c43..66e858bb987 100644 --- a/settings.gradle +++ b/settings.gradle @@ -435,7 +435,7 @@ include( 'grails-test-examples-gorm', 'grails-test-examples-gsp-layout', // TODO: 'grails-test-examples-gsp-sitemesh3', - // TODO: 'grails-test-examples-gsp-spring-boot', + 'grails-test-examples-gsp-spring-boot', 'grails-test-examples-hyphenated', 'grails-test-examples-issue-11102', 'grails-test-examples-issue-15228', @@ -452,6 +452,7 @@ include( 'grails-test-examples-config-report', 'grails-test-examples-scaffolding', 'grails-test-examples-scaffolding-fields', + 'grails-test-examples-spring-dependency-management', 'grails-test-examples-test-phases', 'grails-test-examples-views-functional-tests', 'grails-test-examples-views-functional-tests-plugin', @@ -473,7 +474,7 @@ project(':grails-test-examples-namespaces').projectDir = file('grails-test-examp project(':grails-test-examples-gorm').projectDir = file('grails-test-examples/gorm') project(':grails-test-examples-gsp-layout').projectDir = file('grails-test-examples/gsp-layout') //TODO: project(':grails-test-examples-gsp-sitemesh3').projectDir = file('grails-test-examples/gsp-sitemesh3') -//TODO: project(':grails-test-examples-gsp-spring-boot').projectDir = file('grails-test-examples/gsp-spring-boot/app') +project(':grails-test-examples-gsp-spring-boot').projectDir = file('grails-test-examples/gsp-spring-boot/app') project(':grails-test-examples-issue-698-domain-save-npe').projectDir = file('grails-test-examples/issue-698-domain-save-npe') project(':grails-test-examples-hyphenated').projectDir = file('grails-test-examples/hyphenated') project(':grails-test-examples-issue-views-182').projectDir = file('grails-test-examples/issue-views-182') @@ -493,6 +494,7 @@ project(':grails-test-examples-scaffolding-fields').projectDir = file('grails-te project(':grails-test-examples-views-functional-tests').projectDir = file('grails-test-examples/views-functional-tests') project(':grails-test-examples-views-functional-tests-plugin').projectDir = file('grails-test-examples/views-functional-tests-plugin') project(':grails-test-examples-test-phases').projectDir = file('grails-test-examples/test-phases') +project(':grails-test-examples-spring-dependency-management').projectDir = file('grails-test-examples/spring-dependency-management') project(':grails-test-examples-jetty').projectDir = file('grails-test-examples/jetty') includeBuild('./grails-gradle') { From 8cba52320aeb54d53208b209964a399882beea78 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 4 Jun 2026 09:37:17 -0400 Subject: [PATCH 21/23] Restrict single-BOM detection to platform declarations Only treat a Grails BOM as hand-declared when it carries platform semantics (a platform() / enforcedPlatform() declaration with the Category attribute), not a plain dependency that imports no constraints. Also assert that a sibling configuration which does not declare a BOM still receives the single effective BOM via per-configuration injection. Assisted-by: claude-code:claude-4.8-opus --- .../plugin/core/GrailsGradlePlugin.groovy | 31 +++++++++++++++---- .../core/BomPlatformFunctionalSpec.groovy | 4 +++ .../bom-platform-manual/build.gradle | 13 ++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) 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 8fc98749fd6..8ec32bbdb36 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 @@ -42,6 +42,7 @@ import org.gradle.api.artifacts.DependencyResolveDetails import org.gradle.api.artifacts.DependencySet import org.gradle.api.artifacts.ModuleDependency import org.gradle.api.attributes.AttributeMatchingStrategy +import org.gradle.api.attributes.Category import org.gradle.api.file.DuplicatesStrategy import org.gradle.api.file.FileCollection import org.gradle.api.file.RegularFile @@ -516,8 +517,8 @@ ${importStatements} ] as Set /** - * Returns the distinct known Grails BOM artifact names declared by hand (as a {@code platform}, - * {@code enforcedPlatform}, or plain dependency) on the project's declarable configurations. + * Returns the distinct known Grails BOM artifact names declared by hand as a {@code platform()} + * or {@code enforcedPlatform()} on the project's declarable configurations. */ private static Set declaredGrailsBoms(Project project) { Set names = new LinkedHashSet<>() @@ -526,7 +527,7 @@ ${importStatements} continue } for (Dependency dependency : configuration.dependencies) { - if (dependency.group == 'org.apache.grails' && GRAILS_BOM_NAMES.contains(dependency.name)) { + if (isGrailsBomPlatform(dependency)) { names.add(dependency.name) } } @@ -535,18 +536,36 @@ ${importStatements} } /** - * Returns whether the given configuration already declares a known Grails BOM by hand, so the - * plugin can avoid layering a second BOM on top of it. + * Returns whether the given configuration already declares a known Grails BOM as a + * {@code platform()} / {@code enforcedPlatform()} by hand, so the plugin can avoid layering a + * second BOM on top of it. */ private static boolean configurationHasGrailsBom(Configuration configuration) { for (Dependency dependency : configuration.dependencies) { - if (dependency.group == 'org.apache.grails' && GRAILS_BOM_NAMES.contains(dependency.name)) { + if (isGrailsBomPlatform(dependency)) { return true } } false } + /** + * Returns whether the dependency is a known Grails BOM declared with platform semantics, i.e. a + * {@code platform()} or {@code enforcedPlatform()} declaration (carrying the {@code Category} + * attribute). A plain {@code org.apache.grails:*-bom} dependency does not import constraints and + * is therefore not treated as a declared BOM. + */ + private static boolean isGrailsBomPlatform(Dependency dependency) { + if (dependency.group != 'org.apache.grails' || !GRAILS_BOM_NAMES.contains(dependency.name)) { + return false + } + if (!(dependency instanceof ModuleDependency)) { + return false + } + Category category = ((ModuleDependency) dependency).attributes.getAttribute(Category.CATEGORY_ATTRIBUTE) + category != null && (category.name == Category.REGULAR_PLATFORM || category.name == Category.ENFORCED_PLATFORM) + } + private static boolean isExcludedFromBomPlatform(String name) { name == 'checkstyle' || name == 'codenarc' || name == 'pmd' || name == 'spotbugs' || name == 'spotbugsPlugins' || 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 index 2a22d28e193..3486f39b0e8 100644 --- 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 @@ -58,6 +58,10 @@ class BomPlatformFunctionalSpec extends GradleSpecification { and: 'the hand-declared Micronaut BOM remains' result.output.contains('HAS_MICRONAUT_BOM=true') + and: 'a sibling configuration that did not declare a BOM still receives the Micronaut BOM (and not grails-bom)' + result.output.contains('SIBLING_HAS_MICRONAUT_BOM=true') + result.output.contains('SIBLING_HAS_GRAILS_BOM=false') + and: 'property-based version overrides are still enabled for the declared BOM' result.output.contains('HAS_BOM_PROPERTY_OVERRIDES=true') } diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-manual/build.gradle b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-manual/build.gradle index 263526aa329..22fb15b3627 100644 --- a/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-manual/build.gradle +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-manual/build.gradle @@ -23,6 +23,19 @@ tasks.register('inspectBomSetup') { } println "HAS_MICRONAUT_BOM=${hasMicronautBom}" + // A sibling declarable configuration that does NOT declare a BOM by hand must still + // receive the single effective BOM (the Micronaut variant) via per-configuration injection. + def testImplDeps = configurations.testImplementation.allDependencies + def siblingHasMicronautBom = testImplDeps.any { dep -> + dep.group == 'org.apache.grails' && dep.name == 'grails-micronaut-bom' + } + println "SIBLING_HAS_MICRONAUT_BOM=${siblingHasMicronautBom}" + + def siblingHasGrailsBom = testImplDeps.any { dep -> + dep.group == 'org.apache.grails' && dep.name == 'grails-bom' + } + println "SIBLING_HAS_GRAILS_BOM=${siblingHasGrailsBom}" + def hasBomPropertyOverrides = project.plugins.findPlugin('org.apache.grails.gradle.bom-property-overrides') != null println "HAS_BOM_PROPERTY_OVERRIDES=${hasBomPropertyOverrides}" } From f63f02897a5aaf493cae9f1fd84e25d06fb60739 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 4 Jun 2026 10:24:23 -0400 Subject: [PATCH 22/23] Remove explanatory comment from the maven-model.version entry The other entries in the gradleBomDependencyVersions map are uncommented; drop the comment for consistency (review feedback). Assisted-by: claude-code:claude-4.8-opus --- dependencies.gradle | 3 --- 1 file changed, 3 deletions(-) diff --git a/dependencies.gradle b/dependencies.gradle index ba53c264311..946ff2f3b82 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -38,9 +38,6 @@ ext { 'jline2.version' : '2.14.6', 'jna.version' : '5.18.1', 'jquery.version' : '3.7.1', - // maven-model is used by the docs-core ExtractDependenciesTask to parse BOM POMs - // the Maven-standard way. Spring Boot does not manage this artifact, so the version - // is pinned here. Use the Maven 3.x line, which provides MavenXpp3Reader. 'maven-model.version' : '3.9.16', 'objenesis.version' : '3.4', 'spring-boot.version' : '4.1.0-RC1', From 179ae7d39b8ae1ec5e46389dddee839b76a76eb5 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Wed, 10 Jun 2026 19:03:32 -0400 Subject: [PATCH 23/23] fix: ignore documentation configuration when detecting the project BOM GrailsDependencyValidatorPlugin.detectBomPath scanned every configuration and failed when a project declared more than one Grails BOM. The shared gradle/docs-dependencies.gradle script wires platform(grails-bom) onto the documentation configuration purely to resolve groovydoc tooling versions, so any module that selects a non-default BOM variant for its real dependencies (grails-micronaut, grails-data-hibernate5/7/*) declared two BOMs and tripped the validator's exactly-one-BOM guard, breaking the "Validate Dependency Versions" CI job. Exclude the doc-tooling documentation configuration from BOM detection so the variant BOM a project selects for its real dependencies is the one detected. Layering two BOMs on actual dependency configurations still fails, preserving the guard's intent. Add GrailsDependencyValidatorPluginSpec covering the documentation-config exclusion, the docs-only case, the single-BOM and no-BOM cases, and the genuine double-BOM throw. Assisted-by: claude-code:claude-4.8-opus --- .../GrailsDependencyValidatorPlugin.groovy | 20 ++++ ...GrailsDependencyValidatorPluginSpec.groovy | 98 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsDependencyValidatorPluginSpec.groovy 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 b5e9b29ed48..c9f120e1f57 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 @@ -57,6 +57,18 @@ class GrailsDependencyValidatorPlugin implements Plugin { private static final Set BOM_PROJECT_NAMES = ['grails-bom', 'grails-gradle-bom', 'grails-base-bom', 'grails-hibernate5-bom', 'grails-hibernate7-bom', 'grails-micronaut-bom', 'grails-hibernate5-micronaut-bom', 'grails-hibernate7-micronaut-bom'].toSet() + /** + * Configuration names that pull in a Grails BOM purely as build tooling rather than as part + * of the project's published dependency graph, and are therefore ignored when detecting the + * project's single Grails BOM. The shared {@code gradle/docs-dependencies.gradle} script adds + * {@code platform(grails-bom)} to the {@code documentation} configuration only to resolve the + * groovydoc tooling versions; a project that selects a non-default BOM variant (e.g. + * {@code grails-micronaut-bom} or {@code grails-hibernate7-bom}) for its real configurations + * still receives {@code grails-bom} here, which must not be misreported as a second, + * conflicting BOM. + */ + private static final Set BOM_DETECTION_EXCLUDED_CONFIGURATIONS = ['documentation'].toSet() + @Override void apply(Project project) { project.plugins.withId('java') { @@ -166,11 +178,19 @@ class GrailsDependencyValidatorPlugin implements Plugin { * when none is declared, and fails the build when more than one distinct Grails BOM * is found (which indicates a misconfiguration - e.g. layering grails-bom and * grails-micronaut-bom on the same project).

    + * + *

    Build-tooling configurations that pull in a BOM purely to resolve their own tool + * versions (see {@link #BOM_DETECTION_EXCLUDED_CONFIGURATIONS}) are skipped, so the shared + * {@code documentation} configuration's {@code grails-bom} does not conflict with a variant + * BOM a project selects for its real dependencies.

    */ static String detectBomPath(Project project) { Set bomPaths = new LinkedHashSet<>() for (Configuration config : project.configurations) { + if (BOM_DETECTION_EXCLUDED_CONFIGURATIONS.contains(config.name)) { + continue + } for (Dependency dep : config.dependencies) { if (!BOM_PROJECT_NAMES.contains(dep.name)) { continue diff --git a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsDependencyValidatorPluginSpec.groovy b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsDependencyValidatorPluginSpec.groovy new file mode 100644 index 00000000000..97430b5b6c3 --- /dev/null +++ b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsDependencyValidatorPluginSpec.groovy @@ -0,0 +1,98 @@ +/* + * 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.apache.grails.buildsrc + +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder +import spock.lang.Specification + +class GrailsDependencyValidatorPluginSpec extends Specification { + + private static Project rootWithBoms() { + Project root = ProjectBuilder.builder().withName('root').build() + ProjectBuilder.builder().withName('grails-bom').withParent(root).build() + ProjectBuilder.builder().withName('grails-micronaut-bom').withParent(root).build() + ProjectBuilder.builder().withName('grails-hibernate7-bom').withParent(root).build() + root + } + + private static void addBomPlatform(Project project, String configuration, String bomPath) { + project.configurations.maybeCreate(configuration) + project.dependencies.add(configuration, + project.dependencies.platform(project.dependencies.project(path: bomPath))) + } + + void "detectBomPath ignores the documentation configuration when a variant BOM is used elsewhere"() { + given: "a project that selects grails-micronaut-bom but inherits grails-bom on the shared documentation config" + Project root = rootWithBoms() + Project project = ProjectBuilder.builder().withName('grails-micronaut').withParent(root).build() + addBomPlatform(project, 'api', ':grails-micronaut-bom') + addBomPlatform(project, 'documentation', ':grails-bom') + + expect: "the variant BOM wins and no conflict is reported" + GrailsDependencyValidatorPlugin.detectBomPath(project) == ':grails-micronaut-bom' + } + + void "detectBomPath returns the single declared BOM"() { + given: "a default-variant project with grails-bom on both a real config and the documentation config" + Project root = rootWithBoms() + Project project = ProjectBuilder.builder().withName('grails-core').withParent(root).build() + addBomPlatform(project, 'implementation', ':grails-bom') + addBomPlatform(project, 'documentation', ':grails-bom') + + expect: + GrailsDependencyValidatorPlugin.detectBomPath(project) == ':grails-bom' + } + + void "detectBomPath returns null when no Grails BOM is declared"() { + given: + Project root = rootWithBoms() + Project project = ProjectBuilder.builder().withName('plain').withParent(root).build() + project.configurations.maybeCreate('implementation') + + expect: + GrailsDependencyValidatorPlugin.detectBomPath(project) == null + } + + void "detectBomPath returns null when only the documentation tooling configuration declares a BOM"() { + given: "a project whose sole BOM is the doc-tooling grails-bom on the documentation config" + Project root = rootWithBoms() + Project project = ProjectBuilder.builder().withName('docs-only').withParent(root).build() + addBomPlatform(project, 'documentation', ':grails-bom') + + expect: "the doc-tooling BOM is ignored, so no project BOM is detected" + GrailsDependencyValidatorPlugin.detectBomPath(project) == null + } + + void "detectBomPath fails when two distinct Grails BOMs are declared on real configurations"() { + given: "a genuine misconfiguration layering two variant BOMs on real dependency configurations" + Project root = rootWithBoms() + Project project = ProjectBuilder.builder().withName('misconfigured').withParent(root).build() + addBomPlatform(project, 'api', ':grails-micronaut-bom') + addBomPlatform(project, 'implementation', ':grails-hibernate7-bom') + + when: + GrailsDependencyValidatorPlugin.detectBomPath(project) + + then: + GradleException e = thrown(GradleException) + e.message.contains('declares more than one Grails BOM') + } +}