Skip to content

Stop leaking build-only dependencies into the application Grails BOM (DRAFT - possible solution)#15652

Draft
jamesfredley wants to merge 1 commit into
8.0.xfrom
refactor/grails-bom-decouple-build-deps
Draft

Stop leaking build-only dependencies into the application Grails BOM (DRAFT - possible solution)#15652
jamesfredley wants to merge 1 commit into
8.0.xfrom
refactor/grails-bom-decouple-build-deps

Conversation

@jamesfredley
Copy link
Copy Markdown
Contributor

Status: DRAFT - posted as a possible solution / discussion starter for the BOM-leak class of CI failures we keep absorbing into the Groovy 5 upgrade. Happy to redirect, narrow, or split further based on feedback before marking ready.

Summary

Stop leaking Grails-build-only artefacts (Gradle plugins, JavaParser-as-build-tool) into the application Grails BOMs. Those entries belong in grails-gradle-bom (which manages the Grails build classpath); republishing them in grails-bom / grails-base-bom / grails-hibernate5-bom / grails-micronaut-bom adds no value to consumers and creates a class of CI failures that we keep paying off manually.

The recurring problem

The Validate Dependency Versions job has been catching transitive drift between Grails BOM constraints and the resolved runtime classpath whenever Apache Groovy bumps an internal dependency. The most recent instance, on PR #15557 against 8.0.x:

Dependency version validation failed for project 'grails-async-gpars'.
The following dependencies resolved to versions different from the BOM (:grails-bom):
  com.github.javaparser:javaparser-core - resolved 3.28.1, expected 3.28.0
A transitive dependency is upgrading these versions.

The mechanism, every time:

  1. dependencies.gradle pins a Grails-build-only artefact (e.g. javaparser-core: 3.28.0) inside gradleBomDependencyVersions because the build itself uses it (here: build-logic's GroovydocEnhancerExtension).
  2. grails-bom/{base,default,hibernate5,micronaut}/build.gradle iterates all of gradleBomDependencies into constraints {}, so the build-only pin gets republished as a managed-version constraint of the application BOM.
  3. Apache Groovy ships a patch that bumps the same artefact internally - e.g. GROOVY-11989 bumped javaparser-core 3.28.0 → 3.28.1 on GROOVY_5_0_X HEAD - and groovy-groovysh / groovy-ginq link against the new version.
  4. Any Grails subproject that resolves Groovy gets javaparser-core 3.28.1 transitively, which now contradicts the 3.28.0 constraint the Grails BOM is republishing -> :validateDependencyVersions fails on every module.
  5. We bump the version in dependencies.gradle to match (PR Groovy 5.0.x support for Grails 8 + Spring Boot 4 #15557 did 813b131607).
  6. Groovy ships another patch some weeks later. Goto 1.

These artefacts are not used by user-facing Grails artefacts and have no business in the consumer BOM. Gradle plugins like asciidoctor-gradle-jvm or asset-pipeline-gradle are normally applied via the plugins {} block with a literal version, not via BOM resolution. javaparser-core reaches end users only as a transitive of groovy-groovysh (which is the version that should win, per Apache Groovy's release).

Proposal

Introduce a gradleBomBuildOnlyDependencyKeys set in dependencies.gradle:

gradleBomBuildOnlyDependencyKeys = [
    'asciidoctor-gradle-jvm',   // Gradle plugin
    'asset-pipeline-gradle',    // Gradle plugin
    'grails-publish-plugin',    // Gradle plugin
    'javaparser-core',          // build-time GroovydocEnhancer; transitive via groovy-groovysh for end users
    'spring-boot-gradle',       // Gradle plugin
] as Set

The four application BOM projects (grails-bom/{base,default,hibernate5,micronaut}/build.gradle) skip these keys when iterating gradleBomDependencies into their constraints {}. grails-gradle-bom (in grails-gradle/bom/) is unchanged and continues to manage the build classpath, so Grails-plugin developers are unaffected.

What stays in the application BOM

I deliberately left these in gradleBomDependencies -> consumer BOM, because they are or may be used at app runtime / test runtime:

Entry Why it stays
ant, ant-junit Ant runtime artefacts (some Grails apps still use Ant tasks)
byte-buddy Spock 2 / Mockito need this at test runtime
commons-text Used by Grails CLI runtime + likely by app code
directory-watcher Grails dev-mode file watcher (runtime)
jansi, jline, jline2, jna Grails shell/console runtime
jquery Webjar (asset runtime)
objenesis Spock / Mockito test runtime
asciidoctorj Borderline - kept conservatively in case any user docs pipeline depends on it
spring-boot-cli, spring-boot-loader-tools Borderline - left in pending discussion

Three of those last items (asciidoctorj, spring-boot-cli, spring-boot-loader-tools) are arguably build-only too. I left them for a separate decision so the diff stays minimal and focused.

Verification

Verified locally on origin/8.0.x HEAD (b47917c1fe) without the manual javaparser-core 3.28.0 -> 3.28.1 bump that PR #15557 had to apply:

./gradlew validateDependencyVersions
> 184 actionable tasks, BUILD SUCCESSFUL

Without this fix, that exact command on the same source tree fails with the javaparser-core - resolved 3.28.1, expected 3.28.0 error.

Generated BOM POMs confirm the leak is gone:

grails-base-bom / grails-bom / grails-hibernate5-bom / grails-micronaut-bom POMs:
  ABSENT  javaparser-core, asciidoctor-gradle-jvm, asset-pipeline-gradle,
          grails-publish, spring-boot-gradle-plugin   <-- the leak
  PRESENT jansi, jline, byte-buddy, commons-text,
          directory-watcher, ant-junit                <-- legitimate runtime/test deps

grails-gradle-bom POM:
  PRESENT all of the above (this is the build-time BOM, unchanged)

Why a draft

A few open questions worth resolving before this lands:

  1. Scope of "build-only": asciidoctorj, spring-boot-cli, spring-boot-loader-tools are arguably also build-only - I left them in the application BOM conservatively, but if anyone has clear opinions about whether end-user apps consume them via the BOM today I am happy to expand the set.

  2. Backwards compatibility: any Grails 7.x / 8.0.0-M1 app that was relying on grails-bom to manage javaparser-core etc. will need to either pin directly or import grails-gradle-bom. I surveyed grails-core and only grails-data-graphql/plugin/build.gradle line 38 directly references javaparser-core (and it pins 3.25.7 explicitly, ignoring the BOM-managed version anyway), so internally this is a no-op. External consumers may want a release note.

  3. Whether to also publish grails-gradle-bom cross-referenced from the application BOM POMs so any consumer that does want the Gradle classpath versions can opt in with one extra platform(...).

Context

Surfaced while burning down workarounds on PR #15557 (Groovy 5 / Spring Boot 4 upgrade). PR #15557 worked around the same javaparser leak by bumping javaparser-core.version to 3.28.1 (commit 813b131607); this PR removes the underlying class of failure rather than playing whack-a-mole each time Groovy bumps an internal.

Problem
=======

The Apache Groovy joint validation build has been catching transitive
dependency version drift between Grails BOM constraints and the resolved
runtime classpath. Most recently:

    Dependency version validation failed for project 'grails-async-gpars'.
    The following dependencies resolved to versions different from the BOM (:grails-bom):
      com.github.javaparser:javaparser-core - resolved 3.28.1, expected 3.28.0
    A transitive dependency is upgrading these versions.

Root cause: the application-facing BOMs (grails-bom, grails-base-bom,
grails-hibernate5-bom, grails-micronaut-bom) were iterating
gradleBomDependencies into their constraints, which leaked Grails-build-only
artefacts (Gradle plugins, the JavaParser library used by the build's
GroovydocEnhancer) into the published consumer BOM. When Apache Groovy
internally bumped one of these (e.g. GROOVY-11989 javaparser-core 3.28.0 -> 3.28.1
on GROOVY_5_0_X HEAD), the resolved transitive version no longer matched the
Grails BOM's constraint, breaking :validateDependencyVersions across most
modules.

The Grails-internal Gradle plugins, AST tooling, and asciidoctor-gradle
plugin are not used by user-facing Grails artefacts and have no business
appearing as managed-version constraints in the consumer BOM. They are
already managed by grails-gradle-bom, which is exactly the BOM intended
for Grails build classpath consumers.

Fix
===

Introduce a gradleBomBuildOnlyDependencyKeys set in dependencies.gradle
listing the artefacts that exist solely for the Grails build's own
classpath. The four application-facing BOM projects skip these keys when
iterating gradleBomDependencies into their constraints. grails-gradle-bom
(the build-time platform) is unchanged and continues to manage them.

Initial set (all five are unambiguous build-only artefacts):

  * asciidoctor-gradle-jvm  (Gradle plugin)
  * asset-pipeline-gradle   (Gradle plugin)
  * grails-publish-plugin   (Gradle plugin)
  * javaparser-core         (used by GroovydocEnhancerExtension; comes via
                            groovy-groovysh transitively for end users)
  * spring-boot-gradle      (Gradle plugin)

Other gradleBomDependencies entries (jansi, jline, jline2, byte-buddy,
commons-text, directory-watcher, jna, ant, ant-junit, asciidoctorj,
objenesis, spring-boot-cli, spring-boot-loader-tools, jquery) remain in
the application BOM because they are or may be used at runtime by Grails
applications.

Verification
============

  ./gradlew validateDependencyVersions
  -> 184 actionable tasks, BUILD SUCCESSFUL

against javaparser-core.version = 3.28.0 (the value that previously failed
on 8.0.x because Groovy 5.0.6-SNAPSHOT pulled in 3.28.1 transitively).
After the fix the application BOM no longer publishes a javaparser-core
constraint at all, so transitive resolution is free to pick whichever
version Apache Groovy was built against.

Verified that the published BOMs no longer list build-only artefacts:

  base/default/hibernate5/micronaut POMs   ABSENT: javaparser-core,
                                                   asciidoctor-gradle-jvm,
                                                   asset-pipeline-gradle,
                                                   grails-publish,
                                                   spring-boot-gradle-plugin
  base/default/hibernate5/micronaut POMs   PRESENT: jansi, jline, byte-buddy,
                                                    commons-text,
                                                    directory-watcher, ant-junit
                                                    (legitimate runtime/test deps)

Verified grails-gradle-bom still manages the build-only artefacts so
Grails-internal Gradle plugin development is unaffected.

Backwards compatibility
=======================

End-user impact: any Grails application that was relying on grails-bom to
manage javaparser-core, asciidoctor-gradle-jvm, asset-pipeline-gradle,
grails-publish-plugin, or spring-boot-gradle-plugin will need to either pin
the version directly or import grails-gradle-bom. We surveyed grails-core
itself and only grails-data-graphql/plugin/build.gradle directly references
javaparser-core (line 38: pinned at 3.25.7 explicitly, doesn't use the
BOM-managed version), so this is unlikely to affect downstream consumers
in practice. Gradle plugins are normally applied via the plugins {} block
with a literal version anyway, not via BOM resolution.

Assisted-by: claude-code:claude-opus-4-7
@testlens-app
Copy link
Copy Markdown

testlens-app Bot commented May 9, 2026

🚨 TestLens detected 3 failed tests 🚨

Here is what you can do:

  1. Inspect the test failures carefully.
  2. If you are convinced that some of the tests are flaky, you can mute them below.
  3. Finally, trigger a rerun by checking the rerun checkbox.

Test Summary

Check Project/Task Test Runs
CI - Groovy Joint Validation Build / build_grails :grails-data-hibernate5-dbmigration:test GroovyChangeLogSpec > outputs a warning message by calling the warn method
CI - Groovy Joint Validation Build / build_grails :grails-data-hibernate5-dbmigration:test GroovyChangeLogSpec > updates a database with Groovy Change
CI / Functional Tests (Java 21, indy=true) :grails-test-examples-scaffolding:integrationTest UserControllerSpec > User list

🏷️ Commit: 20913cb
▶️ Tests: 38195 executed
⚪️ Checks: 35/35 completed

Test Failures

UserControllerSpec > User list (:grails-test-examples-scaffolding:integrationTest in CI / Functional Tests (Java 21, indy=true))
geb.waiting.WaitTimeoutException: condition did not pass in 10 seconds (failed with exception)
	at geb.waiting.Wait.waitFor(Wait.groovy:128)
	at geb.waiting.DefaultWaitingSupport.doWaitFor(DefaultWaitingSupport.groovy:55)
	at geb.waiting.DefaultWaitingSupport.waitFor(DefaultWaitingSupport.groovy:41)
	at geb.Page.waitFor(Page.groovy:120)
	at com.example.pages.LoginPage.login(LoginPage.groovy:39)
	at com.example.UserControllerSpec.setup(UserControllerSpec.groovy:33)
Caused by: Assertion failed: 

title != pageTitle
|     |  |
|     |  'Please sign in'
|     false
'Please sign in'

	at com.example.pages.LoginPage.login_closure1(LoginPage.groovy:39)
	at com.example.pages.LoginPage.login_closure1(LoginPage.groovy)
	at geb.waiting.Wait.waitFor(Wait.groovy:117)
	... 5 more
GroovyChangeLogSpec > outputs a warning message by calling the warn method (:grails-data-hibernate5-dbmigration:test in CI - Groovy Joint Validation Build / build_grails)
Condition not satisfied:

output.toString().contains('warn message')
|      |          |
|      |          false
|      00:53:25.850 [Test worker] INFO org.hibernate.dialect.Dialect -- HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
|      Running Changeset: changelog.groovy::2::John Smith
|       
|      UPDATE SUMMARY
|      Run:                          1
|      Previously run:               0
|      Filtered out:                 0
|      -------------------------------
|      Total change sets:            1
|       
|      Liquibase: Update has been successful. Rows affected: 1
00:53:25.850 [Test worker] INFO org.hibernate.dialect.Dialect -- HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
Running Changeset: changelog.groovy::2::John Smith
 
UPDATE SUMMARY
Run:                          1
Previously run:               0
Filtered out:                 0
-------------------------------
Total change sets:            1
 
Liquibase: Update has been successful. Rows affected: 1

	at org.grails.plugins.databasemigration.liquibase.GroovyChangeLogSpec.outputs a warning message by calling the warn method(GroovyChangeLogSpec.groovy:87)
GroovyChangeLogSpec > updates a database with Groovy Change (:grails-data-hibernate5-dbmigration:test in CI - Groovy Joint Validation Build / build_grails)
Condition not satisfied:

output.toString().contains('confirmation message')
|      |          |
|      |          false
|      00:53:25.470 [Test worker] INFO org.hibernate.dialect.Dialect -- HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
|      Running Changeset: changelog.groovy::1::John Smith
|       
|      UPDATE SUMMARY
|      Run:                          1
|      Previously run:               0
|      Filtered out:                 0
|      -------------------------------
|      Total change sets:            1
|       
|      Liquibase: Update has been successful. Rows affected: 1
00:53:25.470 [Test worker] INFO org.hibernate.dialect.Dialect -- HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
Running Changeset: changelog.groovy::1::John Smith
 
UPDATE SUMMARY
Run:                          1
Previously run:               0
Filtered out:                 0
-------------------------------
Total change sets:            1
 
Liquibase: Update has been successful. Rows affected: 1

	at org.grails.plugins.databasemigration.liquibase.GroovyChangeLogSpec.updates a database with Groovy Change(GroovyChangeLogSpec.groovy:61)

Muted Tests

Select tests to mute in this pull request:

  • GroovyChangeLogSpec > outputs a warning message by calling the warn method
  • GroovyChangeLogSpec > updates a database with Groovy Change
  • UserControllerSpec > User list

Reuse successful test results:

  • ♻️ Only rerun the tests that failed or were muted before

Click the checkbox to trigger a rerun:

  • Rerun jobs

Learn more about TestLens at testlens.app.

@jdaugherty
Copy link
Copy Markdown
Contributor

As long as shell requires the build classpath and we have plugins also be able to be included on the build path, I don't think we can make this change. I think this is the right solution but the core issue is grails itself mixes these classpaths.

@jdaugherty
Copy link
Copy Markdown
Contributor

We should talk about the feasibility of splitting this, it 100% makes sense to do. Maybe we can discuss solutions for each issue:

  1. require grails shell to be build classpath only (easy)
  2. require plugins publish separate libraries for build classpaths (dbmigration, hibernate, etc). this is the show stopper i think
  3. do not allow the gradle model to be accessed inside of grails (harder, but maybe there's a reason split here). this means we need to finally remove buildsettings

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants