Skip to content

Split CLI dependencies from the runtime classpath#15948

Open
jdaugherty wants to merge 11 commits into
8.0.xfrom
fix/split-cli-dependencies
Open

Split CLI dependencies from the runtime classpath#15948
jdaugherty wants to merge 11 commits into
8.0.xfrom
fix/split-cli-dependencies

Conversation

@jdaugherty

Copy link
Copy Markdown
Contributor

Description

Grails commands (ApplicationCommand implementations) have always shipped inside runtime plugin
artifacts, dragging CLI-only dependencies onto application runtime classpaths. This causes real
failures: grails-shell-cli leaks a conflicting Groovy onto the app classpath (see the
long-standing exclusion + TODO in grails-data-hibernate{5,7}/dbmigration/build.gradle), and a
CLI-only Spring Boot WebApplicationInitializer breaks classic WAR deployment with an NPE
(#15377) unless users hand-filter the bootWar classpath.

(no classifiers or capability syntax for consumers), fully consumable by Maven as well as Gradle.

Framework changes

  • grails-core-cli: the command contract moves from grails.dev.commands.* to
    org.apache.grails.core.cli.*. Command registrations move from META-INF/grails.factories to a
    dedicated META-INF/grails-cli.factories, written by a new CommandFactoriesTransformation
    that ships in the cli artifact (so it is active exactly when a command can compile). The default
    grails-core jar contains no command code and no command contract.
  • Companion artifacts for all command-bearing modules, each with a unique package and
    Automatic-Module-Name: dbmigration (h5/h7, 29 dbm-* commands each), the hibernate plugins
    (schema-export), scaffolding (9 generate-* commands), web-url-mappings
    (url-mappings-report), spring-security, and spring-security-oauth2. grails-shell-cli is gone
    from every runtime dependency graph.
  • BOM: the base BOM enumerates and manages every companion coordinate.

Build/tooling changes

  • New org.apache.grails.gradle.grails-cli plugin (applied automatically by the Grails
    application/plugin plugins): registers the grailsCli configuration — compile-visible for
    grails-app/commands sources and on the command-runner and test classpaths, but never on the
    main runtimeClasspath, so commands and their CLI-only dependencies are excluded from
    bootRun/bootJar/bootWar. It auto-provisions grails-core-cli + grails-console, and
    discovers plugin companions automatically: a command-bearing plugin advertises its companion
    through a Grails-Cli-Artifact manifest attribute on its runtime jar, and the plugin walks the
    resolved dependency graph (including transitive plugins) and adds each advertised companion to
    grailsCli. Per-command Gradle tasks (dbmUpdate, …) register from the discovered companions —
    no buildscript { classpath } entry needed. Opt out with grails { cliAutoProvision = false }.
    The console/shell tasks draw from the same provisioned tier, so generated apps no longer
    declare console "org.apache.grails:grails-console".
  • New org.apache.grails.gradle.grails-plugin-cli plugin for plugin authors (dogfooded by the
    framework's own modules): one apply sets up the cli source set as a feature variant, the
    command contract on its compile classpath, the manifest advertisement, and the companion
    publication.
  • grails-publish: builds on the additional-publications support added in
    Support additional source sets and custom publishing grails-gradle-publish#34 (requires grails-publish1.0.0-SNAPSHOT; the version is
    bumped in dependencies.gradle).
  • Forge/profiles: generated apps declare only runtime plugins; all CLI wiring is automatic.

Consumer impact

Most applications upgrade with no build changes: declaring a command-bearing plugin is enough,
and its commands (and Gradle tasks) follow automatically. Application commands in
grails-app/commands only need the import rename (grails.dev.commands.*

  • This PR contains a single, focused change.
  • This PR targets the correct branch for the type of change (8.0.x — breaking changes are part of the major release).

Code Quality

  • I have added or updated tests that cover the changes introduced in this PR (module unit
    suites, dbmigration integration tests, the config-report end-to-end integration test
    exercising the full discovery → factories → runner chain, forge feature specs, and
    grails-gradle plugin tests).
  • I have verified that all existing tests pass by running ./gradlew build --rerun-tasks.
  • My code follows the project's code style guidelines (aggregateViolations reports zero
    Checkstyle/CodeNarc/PMD/SpotBugs findings).
  • This PR does not include mass reformatting, style-only changes, or large-scale refactoring
    beyond the approved scope.
  • If generative AI tooling was used in preparing this contribution, a quality model was used to ensure contributions are consistent with the project's quality standards.

Licensing and Attribution

  • All contributed code is provided under the Apache License 2.0, and new source files include the Apache license header.
  • I have the necessary rights to submit this contribution.
  • If generative AI tooling was used in preparing this contribution, I have followed the ASF policy on generative tooling and have properly attributed its use.

Documentation

  • User-facing changes are documented (Creating Custom Commands, Providing Basic Artefacts,
    Gradle plugins catalog, create-command reference).
  • The What's New section covers the companion -cli artifacts, automatic discovery, and the new plugins.
  • The Upgrade Notes (section 33) cover the package renames, the grails-cli.factories
    clean break, the grailsCli configuration, and the third-party plugin migration path.
  • The PR description clearly explains what was changed and why.

@jdaugherty jdaugherty force-pushed the fix/split-cli-dependencies branch from a7d2537 to 01ddd85 Compare July 9, 2026 19:59
@jdaugherty

Copy link
Copy Markdown
Contributor Author

These were resolving locally due to having maven local search on; I need to fix the dependency substitution to fix these resolution issues.

@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 2.96496% with 360 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.4157%. Comparing base (b5e583a) to head (c6739f4).
⚠️ Report is 5 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...radle/plugin/commands/GrailsCliGradlePlugin.groovy 0.0000% 178 Missing ⚠️
...ugin/commands/GrailsCliArtifactGradlePlugin.groovy 0.0000% 69 Missing ⚠️
...ails/compiler/injection/FactoriesFileWriter.groovy 15.2174% 35 Missing and 4 partials ⚠️
...gradle/plugin/commands/CliPublishingSupport.groovy 0.0000% 39 Missing ⚠️
...rg/grails/gradle/plugin/util/ClasspathUtils.groovy 0.0000% 11 Missing ⚠️
...rails/core/io/support/GrailsFactoriesLoader.groovy 12.5000% 7 Missing ⚠️
...rg/grails/io/support/FactoriesLoaderSupport.groovy 0.0000% 7 Missing ⚠️
...lugin/commands/GrailsCliLibraryGradlePlugin.groovy 0.0000% 4 Missing ⚠️
...g/grails/gradle/plugin/core/GrailsExtension.groovy 33.3333% 2 Missing ⚠️
...ds/factory/ApplicationContextCommandFactory.groovy 0.0000% 2 Missing ⚠️
... and 2 more
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #15948        +/-   ##
==================================================
+ Coverage     49.3909%   49.4157%   +0.0248%     
+ Complexity      16808      16781        -27     
==================================================
  Files            1993       1984         -9     
  Lines           93495      93191       -304     
  Branches        16360      16295        -65     
==================================================
- Hits            46178      46051       -127     
+ Misses          40180      40033       -147     
+ Partials         7137       7107        -30     
Files with missing lines Coverage Δ
...mmand/GrailsApplicationContextCommandRunner.groovy 2.7778% <ø> (ø)
...ion/GlobalGrailsClassInjectorTransformation.groovy 64.0000% <100.0000%> (+11.6923%) ⬆️
...rails/gradle/plugin/core/GrailsGradlePlugin.groovy 0.0000% <0.0000%> (ø)
...ils/gradle/plugin/web/GrailsWebGradlePlugin.groovy 0.0000% <0.0000%> (ø)
...g/grails/gradle/plugin/core/GrailsExtension.groovy 55.8140% <33.3333%> (-1.6861%) ⬇️
...ds/factory/ApplicationContextCommandFactory.groovy 0.0000% <0.0000%> (ø)
...lugin/commands/GrailsCliLibraryGradlePlugin.groovy 0.0000% <0.0000%> (ø)
...rails/core/io/support/GrailsFactoriesLoader.groovy 47.5000% <12.5000%> (-10.0758%) ⬇️
...rg/grails/io/support/FactoriesLoaderSupport.groovy 0.0000% <0.0000%> (ø)
...rg/grails/gradle/plugin/util/ClasspathUtils.groovy 0.0000% <0.0000%> (ø)
... and 4 more

... and 24 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jdaugherty

Copy link
Copy Markdown
Contributor Author

Turns out there's a real build issue separate from this change, micronaut-singleton isn't including our functional test app so it was pulling from upstream instead of testing with the local copy.

@jdaugherty

Copy link
Copy Markdown
Contributor Author

The published Gradle Module Metadata still contains capability-style dependencies (org.apache.grails:grails-core + requireCapability('org.apache.grails:grails-core-cli')) — violating the split's design rule that external consumers must see the plain -cli coordinate, never capabilities. When Gradle resolves that from a Maven repo, it selects grails-core's cliRuntimeElements variant, follows its available-at redirect to the standalone grails-core-cli module, and then sees both nodes providing the same capability — a self-conflict. I reproduced it with a one-dependency project against build/local-maven, and it fails with both timestamped and non-unique snapshots, so it's not a snapshot-timestamp issue.

There are two distinct leak paths:

  1. Every -cli companion (grails-scaffolding-cli, grails-data-hibernate5-cli, grails-web-url-mappings-cli, dbmigration, spring-security plugins): configureDependencyMapping (publishResolvedCoordinates in GrailsCliArtifactGradlePlugin) rewrites the POM correctly to grails-core-cli, but the GMM still records grails-core + requestedCapabilities — and Gradle consumers prefer the GMM.
  2. grails-console declares the capability dep in its main api (grails-console/build.gradle:43). Its main java component has no dependency mapping at all, so its GMM leaks the capability dep — and worse, its POM collapses it to plain grails-core, meaning Maven consumers silently lose the cli dependency entirely.

I'm still researching possible solutions for this one.

@jdaugherty

Copy link
Copy Markdown
Contributor Author

This is now fixed. We have to support cases where the library is a regular library imported by another project that uses the requireCapbility - this is only when the projects are defined in the same build. I've created a dedicated plugin for this scenario.

@testlens-app

testlens-app Bot commented Jul 10, 2026

Copy link
Copy Markdown

🚨 TestLens detected 1 failed test 🚨

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

CI - Groovy Joint Validation Build / build_grails > :grails-test-examples-gsp-sitemesh3:integrationTest

Test Runs Flakiness
EndToEndSpec > async multiple levels of layouts 1% 🟡

🏷️ Commit: c6739f4
▶️ Tests: 27387 executed
⚪️ Checks: 61/61 completed

Test Failures

EndToEndSpec > async multiple levels of layouts (:grails-test-examples-gsp-sitemesh3:integrationTest in CI - Groovy Joint Validation Build / build_grails)
org.openqa.selenium.TimeoutException: java.util.concurrent.TimeoutException
Build info: version: '4.43.0', revision: 'dd0f534'
System info: os.name: 'Linux', os.arch: 'amd64', os.version: '6.17.0-1018-azure', java.version: '21.0.11'
Driver info: org.openqa.selenium.remote.RemoteWebDriver
Command: [2e56d013782943b6bcc37da593fc4518, get {url=http://host.testcontainers.internal:35703/endToEnd/asyncMultipleLevelsOfLayouts}]
Capabilities {acceptInsecureCerts: false, browserName: chrome, browserVersion: 147.0.7727.55, chrome: {chromedriverVersion: 147.0.7727.56 (b28eac7a1a97..., userDataDir: /tmp/org.chromium.Chromium....}, fedcm:accounts: true, goog:chromeOptions: {debuggerAddress: localhost:41371}, goog:processID: 185, networkConnectionEnabled: false, pageLoadStrategy: normal, platformName: linux, proxy: Proxy(), se:bidiEnabled: false, se:cdp: ws://172.17.0.7:4444/sessio..., se:cdpVersion: 147.0.7727.55, se:containerName: 8e2543c953d7, se:deleteSessionOnUi: true, se:downloadsEnabled: true, se:noVncPort: 7900, se:vnc: ws://172.17.0.7:4444/sessio..., se:vncEnabled: true, se:vncLocalAddress: ws://172.17.0.7:7900, setWindowRect: true, strictFileInteractability: false, timeouts: {implicit: 0, pageLoad: 300000, script: 30000}, unhandledPromptBehavior: dismiss and notify, webauthn:extension:credBlob: true, webauthn:extension:largeBlob: true, webauthn:extension:minPinLength: true, webauthn:extension:prf: true, webauthn:virtualAuthenticators: true}
Session ID: 2e56d013782943b6bcc37da593fc4518
	at org.openqa.selenium.remote.http.jdk.JdkHttpClient.execute(JdkHttpClient.java:429)
	at org.openqa.selenium.remote.tracing.TracedHttpClient.execute(TracedHttpClient.java:54)
	at org.openqa.selenium.remote.HttpCommandExecutor.execute(HttpCommandExecutor.java:220)
	at org.openqa.selenium.remote.TracedCommandExecutor.execute(TracedCommandExecutor.java:53)
	at org.openqa.selenium.remote.RemoteWebDriver.execute(RemoteWebDriver.java:606)
	at org.openqa.selenium.remote.RemoteWebDriver.get(RemoteWebDriver.java:374)
	at geb.Browser.go(Browser.groovy:515)
	at geb.Browser.go(Browser.groovy:492)
	at grails.plugin.geb.support.delegate.BrowserDelegate$Trait$Helper.go(BrowserDelegate.groovy:132)
	at EndToEndSpec.async multiple levels of layouts(EndToEndSpec.groovy:104)
Caused by: java.util.concurrent.TimeoutException
	at java.base/java.util.concurrent.CompletableFuture$Timeout.run(CompletableFuture.java:2920)
	at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572)
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
	at java.base/java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:304)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
	at java.base/java.lang.Thread.run(Thread.java:1583)

Muted Tests

Select tests to mute in this pull request:

  • EndToEndSpec > async multiple levels of layouts

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.

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

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant