diff --git a/build-logic/docs-core/build.gradle b/build-logic/docs-core/build.gradle index 61dc882d66b..caf603be762 100644 --- a/build-logic/docs-core/build.gradle +++ b/build-logic/docs-core/build.gradle @@ -47,8 +47,11 @@ 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']}" - 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..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 @@ -21,8 +21,8 @@ package org.apache.grails.gradle.tasks.bom import java.util.regex.Pattern -import io.spring.gradle.dependencymanagement.org.apache.maven.model.Model -import io.spring.gradle.dependencymanagement.org.apache.maven.model.io.xpp3.MavenXpp3Reader +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 @@ -257,17 +257,19 @@ 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) + def bomDependency = dependencyHandler.create("${bomCoordinates.coordinates}@pom") + def dependencyConfiguration = configurationContainer.detachedConfiguration(bomDependency).tap { + transitive = false + } File bomPomFile = dependencyConfiguration.singleFile - MavenXpp3Reader reader = new MavenXpp3Reader() - Model model = reader.read(new FileReader(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() - Properties versionProperties = new Properties() + // Parent POM populated first so its properties can be overridden by the child if (model.parent) { - // Need to populate the parent bom if it's present first - CoordinateVersionHolder parentBom = new CoordinateVersionHolder( + def parentBom = new CoordinateVersionHolder( groupId: model.parent.groupId, artifactId: model.parent.artifactId, version: model.parent.version @@ -276,69 +278,74 @@ abstract class ExtractDependenciesTask extends DefaultTask { versionProperties.put(entry.key, entry.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) - 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 { + 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; // 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 (def depItem : managedDependencies) { + def baseCoordinates = new CoordinateHolder( + groupId: depItem.groupId, + artifactId: depItem.artifactId + ) + + def 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 + } + + 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') { + def resolvedBomCoordinates = new CoordinateVersionHolder( + groupId: resolvedCoordinates.groupId, + artifactId: resolvedCoordinates.artifactId, + version: resolvedVersion + ) + populatePlatformDependencies(resolvedBomCoordinates, exclusionRules, constraints, error, level + 1) + } } versionProperties 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 d8a104511a7..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') { @@ -159,19 +171,50 @@ class GrailsDependencyValidatorPlugin implements Plugin { /** * Scans the project's configurations to find which BOM project is in use. + * + *

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).

+ * + *

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)) { - Project bomProject = project.rootProject.findProject(":${dep.name}" as String) - if (bomProject != null) { - return bomProject.path - } + if (!BOM_PROJECT_NAMES.contains(dep.name)) { + continue } + def bomProject = project.rootProject.findProject(":${dep.name}" as String) + if (bomProject == null) { + continue + } + bomPaths.add(bomProject.path) } } - null + + if (bomPaths.isEmpty()) { + return null + } + 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.' + ) + } + + bomPaths.first() } /** 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') + } +} diff --git a/dependencies.gradle b/dependencies.gradle index 66b83c67e91..08ee72213f2 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -38,6 +38,7 @@ ext { 'jline2.version' : '2.14.6', 'jna.version' : '5.18.1', 'jquery.version' : '3.7.1', + 'maven-model.version' : '3.9.16', 'objenesis.version' : '3.4', 'spring-boot.version' : '4.1.0-RC1', ] diff --git a/grails-bom/base/build.gradle b/grails-bom/base/build.gradle index 7f974acd298..eba26511529 100644 --- a/grails-bom/base/build.gradle +++ b/grails-bom/base/build.gradle @@ -209,7 +209,7 @@ ext { ExtractedDependencyConstraint extractedConstraint = propertyNameCalculator.calculate(groupId, artifactId, inlineVersion, isBom) if (extractedConstraint?.versionPropertyReference) { // use the property reference instead of the hard coded version so that it can be - // overriden by the spring boot dependency management plugin + // overridden by project properties (gradle.properties or ext['property.name']) dep.version[0].value = extractedConstraint.versionPropertyReference // Add an entry in the node with the actual version number diff --git a/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc b/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc index 31a1ffe6b3e..98ed596b91b 100644 --- a/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc +++ b/grails-doc/src/en/guide/commandLine/gradleBuild/gradleDependencies.adoc @@ -58,15 +58,41 @@ dependencies { } ---- -Note that version numbers are not present in the majority of the dependencies. +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. -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. +==== Overriding Managed Versions -For a Grails App, applying `org.apache.grails.gradle.grails-web` will automatically configure the `grails-bom`. No other steps required. +To override a managed version, set the corresponding property in `build.gradle`: -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. +[source,groovy] +---- +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 (following `import` BOMs), determines which managed versions change when your overrides are applied, and applies each change as a strict dependency constraint. + +[NOTE] +==== +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 + +For a Grails App, applying `org.apache.grails.gradle.grails-web` will automatically configure the `grails-bom` _and_ apply the `bom-property-overrides` plugin. No other steps required. + +For Plugins and Projects which do not use `org.apache.grails.gradle.grails-web`, you can apply the `grails-bom` using Gradle Platforms: -build.gradle, using Gradle Platforms: [source,groovy] ---- dependencies { @@ -75,13 +101,42 @@ dependencies { } ---- -build.gradle, using Spring dependency management plugin: +==== Using `bom-property-overrides` Outside a Grails App + +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] ---- -dependencyManagement { - imports { - mavenBom 'org.apache.grails:grails-bom:{GrailsVersion}' - } - applyMavenExclusions false +plugins { + id 'java-library' + id 'org.apache.grails.gradle.bom-property-overrides' version '{GrailsVersion}' +} + +dependencies { + implementation platform('com.example:my-bom:1.0.0') +} + +// In build.gradle +ext['slf4j.version'] = '2.0.13' +---- + +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] +---- +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-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 4e72931f015..dbc5efdf986 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -494,7 +494,84 @@ 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 deprecated. +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: + +[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 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.** +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. + +**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 { + bom = null +} +---- + +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 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: @@ -540,7 +617,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. @@ -561,7 +638,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. @@ -617,7 +694,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: @@ -635,7 +712,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. @@ -651,7 +728,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: @@ -680,7 +757,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. Tag Library Test Cleanup Changes +==== 21. Tag Library Test Cleanup Changes Grails 8 removes the `purgeTagLibMetaClass` test hook used by some web and TagLib unit tests. TagLib metaclass cleanup now happens automatically as part of the web test infrastructure, so test specs should delete any custom `purgeTagLibMetaClass` property or getter. @@ -706,7 +783,7 @@ class MyTagLibSpec extends Specification implements TagLibUnitTest { } ---- -==== 21. Method-based TagLib Handlers +==== 22. Method-based TagLib Handlers Grails 8 introduces method-based tag handler syntax as the recommended way to define tags. Closure-based handlers are still supported for backward compatibility. @@ -730,7 +807,7 @@ class DemoTagLib { Method tags with the conventional signatures `def tag(Map attrs)`, `def tag(Closure body)`, and `def tag(Map attrs, Closure body)` are discovered automatically. Zero-argument method tags and method tags that bind named attributes directly to method parameters must use `@grails.gsp.Tag`; this avoids exposing ordinary public helper methods as tags. -Grails Gradle application builds preserve Java and Groovy method parameter names by default (see §21.3 below). If you compile TagLib classes outside those plugins, enable Java `-parameters` and Groovy `groovyOptions.parameters = true` so `@Tag` methods can bind attributes by parameter name. +Grails Gradle application builds preserve Java and Groovy method parameter names by default (see §22.3 below). If you compile TagLib classes outside those plugins, enable Java `-parameters` and Groovy `groovyOptions.parameters = true` so `@Tag` methods can bind attributes by parameter name. To exclude a conventional public method from being exposed as a tag (for example, a helper that must remain public to satisfy an interface), annotate it with `@grails.gsp.NotATag`: @@ -750,7 +827,7 @@ class DemoTagLib { The compile-time deprecation warning emitted for closure-based tag fields can be silenced by setting the system property `grails.taglib.warnDeprecatedClosures=false`. -===== 21.1 Behavior change: tag method registration +===== 22.1 Behavior change: tag method registration In Grails 7, the metaclass tag dispatchers registered for the taglib's own namespace did not override existing methods. Because method-based tags are now real methods on the TagLib class, those dispatchers must override methods of the same name so that function-style invocation (for example `tagLib.myTag(attrs)`) returns captured tag output rather than writing directly to `out`. @@ -765,7 +842,7 @@ This shadowing is not reported at runtime by default. To audit which TagLib meth Each override is logged as `Registering tag dispatcher : over existing method .()`. Inspect the output after application startup; any entry whose `` is one of your own TagLibs identifies a method that has been replaced by the dispatcher. -===== 21.2 Behavior change: parameter-name binding for `Map` and `Closure` parameters +===== 22.2 Behavior change: parameter-name binding for `Map` and `Closure` parameters Method-based tag handlers bind attributes to method parameters by parameter name. Two reserved names participate in this resolution: @@ -796,9 +873,9 @@ class DemoTagLib { } ---- -If parameter names are not preserved at compile time (see §21.3 below for the `preserveParameterNames` Gradle extension property), the first `Map` parameter is bound to `attrs` and the first `Closure` parameter is bound to `body` regardless of declared names, matching the behavior of closure-based tag handlers. +If parameter names are not preserved at compile time (see §22.3 below for the `preserveParameterNames` Gradle extension property), the first `Map` parameter is bound to `attrs` and the first `Closure` parameter is bound to `body` regardless of declared names, matching the behavior of closure-based tag handlers. -===== 21.3 Grails Gradle Extension Preserves Parameter Names By Default +===== 22.3 Grails Gradle Extension Preserves Parameter Names By Default In Grails 8, the Grails Gradle extension defaults `preserveParameterNames` to `true`. This configures Groovy compilation to retain method and constructor parameter names in generated class files. @@ -843,7 +920,7 @@ tasks.withType(GroovyCompile).configureEach { Without these flags, `Parameter.getName()` returns synthetic names such as `arg0`, the attribute lookup misses, and the call surfaces as `MissingMethodException` at runtime. The `@Tag`-annotated method itself produces no compile-time warning, because preservation is a property of the build, not of the source file. -==== 22. Known Plugin Incompatibilities +==== 23. 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: @@ -856,7 +933,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. -===== 23. Custom JSON View Converters +==== 24. Custom JSON View Converters JSON views now use Groovy's `groovy.json.JsonGenerator` implementation instead of the previous Grails-specific JSON generator infrastructure. @@ -874,7 +951,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`) -===== 23 Rendering Enum values as JSON +==== 25. 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: diff --git a/grails-gradle/plugins/build.gradle b/grails-gradle/plugins/build.gradle index 41930cb38c3..6e4f85f3a18 100644 --- a/grails-gradle/plugins/build.gradle +++ b/grails-gradle/plugins/build.gradle @@ -56,7 +56,14 @@ 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' + + // 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 } @@ -137,12 +144,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/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 new file mode 100644 index 00000000000..cfa74f387a2 --- /dev/null +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomManagedVersions.groovy @@ -0,0 +1,510 @@ +/* + * 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 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 + +/** + * Lightweight replacement for the Spring Dependency Management plugin's + * version property override feature. + * + *

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.

+ * + *

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.

+ * + *

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 + * (see Gradle #9160).

+ * + *

This is the underlying utility used by the + * {@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 + */ +@CompileStatic +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<>() + + /** + * 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 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(ConfigurationContainer configurations, + DependencyHandler dependencies, + Function propertyLookup, + String bomCoordinates) { + resolve(configurations, dependencies, propertyLookup, [bomCoordinates]) + } + + /** + * 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 + * 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 + * @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(ConfigurationContainer configurations, + DependencyHandler dependencies, + Function propertyLookup, + Collection bomCoordinatesList) { + def instance = new BomManagedVersions() + + def defaultVersions = computeManagedVersions( + configurations, dependencies, bomCoordinatesList, NO_OVERRIDES) + def 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.lifecycle( + 'BOM property overrides: {} version override(s) will be applied', + instance.versionOverrides.size() + ) + } + + 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) { + 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) { + resolve( + project.configurations, + project.dependencies, + { String name -> project.hasProperty(name) ? project.property(name)?.toString() : null } as Function, + bomCoordinatesList + ) + } + + /** + * Applies the detected version overrides to the given configuration as + * strict dependency constraints. + * + *

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(DependencyHandler dependencies, String configurationName) { + if (versionOverrides.isEmpty()) { + return + } + + versionOverrides.each { String coordinate, String version -> + dependencies.constraints.add(configurationName, coordinate) { + it.version { it.strictly(version) } + it.because('BOM version override via project property') + } + } + } + + /** + * Returns whether any version overrides were detected. + */ + boolean hasOverrides() { + !versionOverrides.isEmpty() + } + + /** + * Returns an unmodifiable view of the version overrides. + * Keys are {@code group:artifact}, values are the override version strings. + */ + Map getOverrides() { + Collections.unmodifiableMap(versionOverrides) + } + + /** + * Parses a BOM POM file and extracts the property-to-artifact mapping. + * 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 model = parseModel(pomFile) + if (model == null) { + return + } + extractProperties(model, bomProperties) + + 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 + } + + if (depVersion.contains('${')) { + def propertyName = extractPropertyName(depVersion) + if (propertyName) { + def artifactKey = "${depGroupId}:${depArtifactId}" as String + propertyToArtifacts.computeIfAbsent(propertyName) { new ArrayList() }.add(artifactKey) + } + } + } + } + + /** + * 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 each POM's + * own {@code } defaults. + */ + private static Map computeManagedVersions( + ConfigurationContainer configurations, + DependencyHandler dependencies, + Collection bomCoordinatesList, + Function propertyResolver + ) { + 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, artifactVersions, processed) + } + + artifactVersions + } + + private static void processBom( + ConfigurationContainer configurations, + DependencyHandler dependencies, + String group, String artifact, String version, + Function propertyResolver, + Map artifactVersions, + Set processed + ) { + def bomKey = "${group}:${artifact}:${version}" as String + if (!processed.add(bomKey)) { + return + } + + def pomFile = resolvePomFile(configurations, dependencies, group, artifact, version) + if (pomFile == null) { + return + } + + def model = parseModel(pomFile) + if (model == null) { + return + } + + // 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, + DependencyHandler dependencies, + String group, String artifact, String version) { + try { + def detached = configurations.detachedConfiguration( + 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 Model parseModel(File pomFile) { + InputStream input = null + try { + 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(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) + } + } + } + + private static List managedDependencies(Model model) { + def depMgmt = model.dependencyManagement + if (depMgmt == null) { + return Collections. emptyList() + } + depMgmt.dependencies ?: Collections. emptyList() + } + + private static void processManagedDependencies( + Model model, + ConfigurationContainer configurations, + DependencyHandler dependencies, + Function propertyResolver, + Map bomProperties, + Map artifactVersions, + Set processed + ) { + def managed = managedDependencies(model) + if (managed.isEmpty()) { + return + } + + // 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 (Dependency dep : managed) { + if (!dep.groupId || !dep.artifactId) { + continue + } + + if ('import' == dep.scope) { + importedBoms.add(dep) + continue + } + + def resolvedVersion = resolveVersion(dep.version, propertyResolver, bomProperties) + if (resolvedVersion) { + def artifactKey = "${dep.groupId}:${dep.artifactId}" as String + artifactVersions.putIfAbsent(artifactKey, resolvedVersion) + } + } + + for (Dependency importedBom : importedBoms) { + def resolvedVersion = resolveVersion(importedBom.version, propertyResolver, bomProperties) + if (resolvedVersion) { + processBom(configurations, dependencies, importedBom.groupId, importedBom.artifactId, resolvedVersion, + propertyResolver, artifactVersions, processed) + } + } + } + + 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 + } + + /** + * 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 + } + + def result = value + int maxIterations = MAX_PROPERTY_INTERPOLATION_DEPTH + while (result.contains('${') && maxIterations-- > 0) { + def propertyName = extractPropertyName(result) + if (propertyName == null) { + break + } + def resolved = propertyResolver.apply(propertyName) + if (resolved == null) { + resolved = bomProperties.get(propertyName) + } + if (resolved == null) { + break + } + result = result.replace("\${${propertyName}}" as String, resolved) + } + result.contains('${') ? null : result + } +} diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesExtension.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesExtension.groovy new file mode 100644 index 00000000000..55e9f2d45cc --- /dev/null +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesExtension.groovy @@ -0,0 +1,105 @@ +/* + * 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.model.ObjectFactory +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(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/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 new file mode 100644 index 00000000000..4277b5b16cf --- /dev/null +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPlugin.groovy @@ -0,0 +1,208 @@ +/* + * 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 java.util.function.Function + +import groovy.transform.CompileStatic +import org.gradle.api.Plugin +import org.gradle.api.Project +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 + +/** + * Gradle plugin that enables Maven-style property-based version overrides + * for {@code platform()} BOMs. + * + *

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 {
+ *     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. 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.
  6. + *
  7. 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.
  8. + *
+ * + *

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) { + def extension = project.extensions.create( + BomPropertyOverridesExtension.EXTENSION_NAME, + BomPropertyOverridesExtension, + 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.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. 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(ConfigurationContainer configurations, + DependencyHandler dependencies, + Function propertyLookup, + BomPropertyOverridesExtension extension) { + def bomCoordinates = new LinkedHashSet() + + if (extension.autoDetect.get()) { + bomCoordinates.addAll(detectDeclaredBoms(configurations)) + } + + for (String explicit : extension.boms.get()) { + if (explicit) { + bomCoordinates.add(explicit) + } + } + + if (bomCoordinates.isEmpty()) { + return + } + + def managedVersions = BomManagedVersions.resolve(configurations, dependencies, propertyLookup, bomCoordinates) + if (!managedVersions.hasOverrides()) { + 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 { + if (it.canBeDeclared) { + managedVersions.applyTo(dependencies, it.name) + } + } + } + + /** + * Scans every configuration for declared {@code platform()} or + * {@code enforcedPlatform()} dependencies and returns their coordinates. + * Takes a {@link ConfigurationContainer} rather than a {@link Project} + * so the call path stays free of Project references. Visible for testing. + */ + static Set detectDeclaredBoms(ConfigurationContainer configurations) { + def coordinates = new LinkedHashSet() + + configurations.each { + for (Dependency dep : it.dependencies) { + if (!(dep instanceof ModuleDependency)) { + continue + } + if (!isPlatformDependency((ModuleDependency) dep)) { + continue + } + def group = dep.group + def name = dep.name + def version = dep.version + if (group && name && version) { + coordinates.add("${group}:${name}:${version}" as String) + } + } + } + + return coordinates + } + + private static boolean isPlatformDependency(ModuleDependency dep) { + def categoryAttr = dep.attributes.getAttribute(Category.CATEGORY_ATTRIBUTE) + if (categoryAttr == null) { + return false + } + def category = categoryAttr.toString() + return category == Category.REGULAR_PLATFORM || category == Category.ENFORCED_PLATFORM + } +} 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 3fa447b0bce..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,11 +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.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) } /** @@ -83,10 +94,34 @@ class GrailsExtension { List starImports = [] /** - * Whether the spring dependency management plugin should be applied by default + * @deprecated The Spring Dependency Management plugin has been replaced with Gradle's native + * {@code platform()} support plus lightweight property-based version overrides + * supplied by the {@code org.apache.grails.gradle.bom-property-overrides} plugin. + * 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 bom = null} so existing opt-outs (which previously prevented + * the Spring Dependency Management BOM from being applied) continue to disable the + * 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 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.bom.set((String) null) + } + } + /** * Whether the Micronaut auto-setup should run when the `grails-micronaut` plugin is detected. * When enabled, the Grails Gradle plugin: @@ -121,6 +156,62 @@ class GrailsExtension { */ final Property preserveParameterNames + /** + * 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 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
+     * }
+     * 
+ * + *

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

+ * + *
+     * grails {
+     *     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 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) { 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 aa7cac51f47..d8866e6e621 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 @@ -44,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 @@ -64,6 +63,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 @@ -367,20 +367,209 @@ ${importStatements} protected void applyDefaultPlugins(Project project) { applySpringBootPlugin(project) + applyGrailsBom(project) + } + /** + * 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: 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.
  2. + *
  3. Property overrides: the BOM-agnostic + * {@link BomPropertyOverridesPlugin} reads the BOM's + * {@code } block and applies any project-level + * overrides via Gradle's + * {@code ResolutionStrategy.eachDependency()}.
  4. + *
+ * + *

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

+ *
+     * // gradle.properties
+     * slf4j.version=1.7.36
+     *
+     * // or build.gradle
+     * ext['slf4j.version'] = '1.7.36'
+     * 
+ * + * @see BomPropertyOverridesPlugin + * @since 8.0 + */ + protected void applyGrailsBom(Project project) { + // 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. 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') + + // 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 { - GrailsExtension ge = project.extensions.getByType(GrailsExtension) - if (ge.springDependencyManagement) { - Plugin dependencyManagementPlugin = project.plugins.findPlugin(DependencyManagementPlugin) - if (dependencyManagementPlugin == null) { - project.plugins.apply(DependencyManagementPlugin) + def grailsExtension = project.extensions.findByType(GrailsExtension) + def bomName = grailsExtension == null ? GrailsExtension.DEFAULT_BOM : grailsExtension.bom.getOrNull() + if (!bomName) { + project.logger.info( + '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:${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. + project.configurations.each { + 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) } + } - DependencyManagementExtension dme = project.extensions.findByType(DependencyManagementExtension) + // Delegate property-based version overrides to the bundled plugin. + // 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) + } + } - applyBomImport(dme, project) + /** + * 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()} + * or {@code enforcedPlatform()} 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 (isGrailsBomPlatform(dependency)) { + names.add(dependency.name) + } + } + } + names + } + + /** + * 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 (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' || + name == 'annotationProcessor' || name.endsWith('AnnotationProcessor') } protected void applySpringBootPlugin(Project project) { @@ -390,13 +579,6 @@ ${importStatements} } } - @CompileDynamic - private void applyBomImport(DependencyManagementExtension dme, project) { - dme.imports({ - mavenBom("org.apache.grails:grails-bom:${project.properties['grailsVersion']}") - }) - } - protected String getDefaultProfile() { 'web' } @@ -467,7 +649,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). */ @@ -478,6 +660,15 @@ ${importStatements} return } + // 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/bom/BomManagedVersionsSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomManagedVersionsSpec.groovy new file mode 100644 index 00000000000..86489139c02 --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomManagedVersionsSpec.groovy @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.gradle.plugin.bom + +import spock.lang.Specification + +/** + * Unit tests for {@link BomManagedVersions}. + * + *

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

+ * + * @since 8.0 + * @see BomManagedVersions + */ +class BomManagedVersionsSpec extends Specification { + + def "parseBomFile extracts properties from BOM POM"() { + given: + 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) + + 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: + 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) + + 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: + 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) + + 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/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/bom/BomPropertyOverridesPluginFunctionalSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginFunctionalSpec.groovy new file mode 100644 index 00000000000..95a517b3a8d --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginFunctionalSpec.groovy @@ -0,0 +1,51 @@ +/* + * 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 functional test for the + * {@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/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 new file mode 100644 index 00000000000..5ebdd1976d3 --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/bom/BomPropertyOverridesPluginSpec.groovy @@ -0,0 +1,143 @@ +/* + * 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: + def project = ProjectBuilder.builder().build() + + when: + project.plugins.apply(BomPropertyOverridesPlugin) + + then: + def extension = project.extensions.findByType(BomPropertyOverridesExtension) + extension != null + extension.autoDetect.get() == true + extension.boms.get().isEmpty() + } + + def "extension bom() method registers explicit BOM coordinates"() { + given: + def project = ProjectBuilder.builder().build() + project.plugins.apply(BomPropertyOverridesPlugin) + def 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: + def project = ProjectBuilder.builder().build() + project.plugins.apply(BomPropertyOverridesPlugin) + 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') + + 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: + def project = ProjectBuilder.builder().build() + project.plugins.apply('java') + project.dependencies.add( + 'implementation', + project.dependencies.platform('org.example:test-bom:1.0.0') + ) + + when: + def coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project.configurations) + + then: + 'org.example:test-bom:1.0.0' in coordinates + } + + def "detectDeclaredBoms finds enforcedPlatform() dependencies"() { + given: + def project = ProjectBuilder.builder().build() + project.plugins.apply('java') + project.dependencies.add( + 'implementation', + project.dependencies.enforcedPlatform('org.example:enforced-bom:2.0.0') + ) + + when: + def coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project.configurations) + + then: + 'org.example:enforced-bom:2.0.0' in coordinates + } + + def "detectDeclaredBoms ignores non-platform dependencies"() { + given: + def project = ProjectBuilder.builder().build() + project.plugins.apply('java') + project.dependencies.add('implementation', 'org.example:regular-lib:1.0.0') + + when: + def coordinates = BomPropertyOverridesPlugin.detectDeclaredBoms(project.configurations) + + then: + coordinates.isEmpty() + } + + def "detectDeclaredBoms deduplicates the same BOM declared on multiple configurations"() { + given: + def 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: + 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/BomOptOutFunctionalSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/BomOptOutFunctionalSpec.groovy new file mode 100644 index 00000000000..ce1a0a7b6e0 --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/BomOptOutFunctionalSpec.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.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#getBom + * @see GrailsGradlePlugin#applyGrailsBom + */ +class BomOptOutFunctionalSpec extends GradleSpecification { + + 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 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 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 new file mode 100644 index 00000000000..3486f39b0e8 --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/BomPlatformFunctionalSpec.groovy @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.gradle.plugin.core + +/** + * Functional tests for the Gradle platform-based BOM integration. + * + *

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

+ * + * @since 8.0 + * @see GrailsGradlePlugin#applyGrailsBom + */ +class BomPlatformFunctionalSpec extends GradleSpecification { + + def "plugin applies grails-bom as Gradle platform, applies bom-property-overrides plugin, and does not apply Spring DM plugin"() { + given: + setupTestResourceProject('bom-platform-basic') + + when: + def result = executeTask('inspectBomSetup') + + then: + result.output.contains('HAS_PLATFORM_BOM=true') + result.output.contains('HAS_BOM_PROPERTY_OVERRIDES=true') + result.output.contains('HAS_SPRING_DM=false') + } + + 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: '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/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..9ffb8a79433 --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/GrailsExtensionSpec.groovy @@ -0,0 +1,128 @@ +/* + * 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 {@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 "bom defaults to grails-bom"() { + given: + Project project = ProjectBuilder.builder().build() + + when: + GrailsExtension extension = new GrailsExtension(project) + + then: + extension.bom.get() == GrailsExtension.DEFAULT_BOM + extension.bom.get() == 'grails-bom' + } + + def "bom can be set to a different curated variant"() { + given: + Project project = ProjectBuilder.builder().build() + GrailsExtension extension = new GrailsExtension(project) + + 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.bom.getOrNull() == null + } + + def "deprecated springDependencyManagement = true leaves bom at its default"() { + given: + Project project = ProjectBuilder.builder().build() + GrailsExtension extension = new GrailsExtension(project) + + when: + extension.springDependencyManagement = true + + then: + extension.springDependencyManagement + extension.bom.getOrNull() == 'grails-bom' + } + + 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.bom = 'grails-hibernate5-bom' + + then: + extension.bom.getOrNull() == 'grails-hibernate5-bom' + extension.springDependencyManagement + } +} 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/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..e3924bfcd98 --- /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 { + bom = null +} + +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-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' diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/build.gradle b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/build.gradle new file mode 100644 index 00000000000..be78e2ce8a6 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/build.gradle @@ -0,0 +1,19 @@ +plugins { + id 'org.apache.grails.gradle.grails-app' +} + +tasks.register('inspectBomSetup') { + doLast { + def implDeps = configurations.implementation.allDependencies + def hasPlatform = implDeps.any { dep -> + dep.group == 'org.apache.grails' && dep.name == 'grails-bom' + } + println "HAS_PLATFORM_BOM=${hasPlatform}" + + def hasBomPropertyOverrides = project.plugins.findPlugin('org.apache.grails.gradle.bom-property-overrides') != null + println "HAS_BOM_PROPERTY_OVERRIDES=${hasBomPropertyOverrides}" + + def hasSpringDm = project.plugins.findPlugin('io.spring.dependency-management') != null + println "HAS_SPRING_DM=${hasSpringDm}" + } +} diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/gradle.properties b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/gradle.properties new file mode 100644 index 00000000000..35c332fb874 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/gradle.properties @@ -0,0 +1 @@ +grailsVersion=__PROJECT_VERSION__ diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/grails-app/conf/application.yml b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/grails-app/conf/application.yml new file mode 100644 index 00000000000..4706b4393fd --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/grails-app/conf/application.yml @@ -0,0 +1,2 @@ +grails: + profile: web diff --git a/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/settings.gradle b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/settings.gradle new file mode 100644 index 00000000000..b2a1c27a425 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-basic/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'test-bom-platform' diff --git a/grails-gradle/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..22fb15b3627 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-platform-manual/build.gradle @@ -0,0 +1,42 @@ +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}" + + // 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}" + } +} 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' 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 new file mode 100644 index 00000000000..5bf633ec7ef --- /dev/null +++ b/grails-gradle/plugins/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.configurations) + println "DETECTED_BOMS=${detected.sort().join(',')}" + } +} diff --git a/grails-gradle/plugins/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 new file mode 100644 index 00000000000..fad0c094005 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-property-overrides-basic/gradle.properties @@ -0,0 +1 @@ +org.gradle.jvmargs=-Xmx1g diff --git a/grails-gradle/plugins/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 new file mode 100644 index 00000000000..06c97ce7c22 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/bom-property-overrides-basic/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'test-bom-property-overrides' diff --git a/grails-profiles/plugin/templates/grailsCentralPublishing.gradle b/grails-profiles/plugin/templates/grailsCentralPublishing.gradle index e9f5c8cafec..e7cbc1abf44 100644 --- a/grails-profiles/plugin/templates/grailsCentralPublishing.gradle +++ b/grails-profiles/plugin/templates/grailsCentralPublishing.gradle @@ -7,7 +7,7 @@ publishing { // simply remove dependencies without a version // version-less dependencies are handled with dependencyManagement - // see https://github.com/spring-gradle-plugins/dependency-management-plugin/issues/8 for more complete solutions + // remove version-less dependencies since versions are managed by the Grails BOM platform pomNode.dependencies.dependency.findAll { it.version.text().isEmpty() }.each { diff --git a/grails-test-examples/gsp-spring-boot/app/build.gradle b/grails-test-examples/gsp-spring-boot/app/build.gradle index 3236d089a37..6abbfdf9e4e 100644 --- a/grails-test-examples/gsp-spring-boot/app/build.gradle +++ b/grails-test-examples/gsp-spring-boot/app/build.gradle @@ -17,16 +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 'io.spring.dependency-management' - 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') @@ -37,7 +55,6 @@ compileGroovyPages { } dependencies { - implementation platform(project(':grails-bom')) implementation project(':grails-gsp-spring-boot') implementation 'org.hibernate.validator:hibernate-validator' // validation @@ -46,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 08b68a7feb2..4e4693f02eb 100644 --- a/settings.gradle +++ b/settings.gradle @@ -438,7 +438,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', @@ -455,6 +455,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', @@ -476,7 +477,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') @@ -496,6 +497,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') {