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: 12342 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.

@codeconsole codeconsole left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right architecture for the CLI split, and most of the execution is excellent for a 260-file change: the renames are mechanically clean (h5/h7 command twins verified line-identical modulo package, no grails.dev.commands stragglers outside migration docs, license headers preserved), the BOM enumerates companion coordinates automatically via the lazy cliArtifactId sweep so there is nothing to keep in sync by hand, the AST transform ordering is verifiably correct with the ConfigReportCommand bootstrap problem cleanly solved, the docs match the implemented behavior including an honest upgrade note on the clean break, and the grailsCli configuration scoping is right (compile/test visible, absent from runtimeClasspath/bootJar/bootWar). The long-standing Groovy-conflict TODO in the dbmigration builds is genuinely resolved rather than papered over.

I left inline comments on the issues I think need resolution before merge — the two blocking ones are the project-dependency companion wiring in GrailsCliGradlePlugin and the 1.0.0-SNAPSHOT of grails-publish flowing into the published BOM. Details and suggested fixes are on the relevant lines.

A few minor items not worth their own threads:

  • GrailsApplicationContextCommandRunner's pre-existing Command not found for name: ... message is now the symptom users will see when a companion was not provisioned; mentioning the grailsCli configuration / -cli artifact in that message would save support traffic.
  • FactoriesFileWriter.addToProps carries over the old substring-based dedup check (existing.contains(classNodeName)), which miscounts when one FQCN is a prefix of another. Pre-existing, not a regression, but this refactor was the natural moment to fix it.
  • Four spring-security command files lost their @author Puneet Behl javadoc tags during the rename (e.g. S2QuickstartCommand) — worth confirming that was intentional.

if (componentIdentifier instanceof ProjectComponentIdentifier) {
Project target = project.rootProject.findProject(((ProjectComponentIdentifier) componentIdentifier).projectPath)
def cliArtifactId = target?.findProperty('cliArtifactId')
return cliArtifactId ? "${target.group}:${cliArtifactId}" as String : null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: companions advertised by a same-build project dependency are wired as unversioned external module dependencies, so auto-discovery is effectively broken for the plugin-development workflow.

Both branches of findAdvertisedCliArtifact return a bare group:artifactId string, and autoProvisionCliDependencies always adds it via dependencies.create(companion). For a ProjectComponentIdentifier result this means Gradle tries to resolve the companion from a repository instead of binding to the sibling project's cli feature variant. Concretely: a multi-project build with include 'app', 'my-plugin' where my-plugin applies grails-plugin-cli and is unpublished — resolving app's classpath fails (or silently picks up a stale previously-published artifact of the same coordinate).

The framework's own build corroborates the gap: root build.gradle disables auto-provisioning for every framework module, and gradle/functional-test-config.gradle adds a bespoke dependencySubstitution rule that performs exactly the project+capability wiring this method should produce itself.

Composite builds (includeBuild) hit a related edge: the included build's component is a ProjectComponentIdentifier that project.rootProject.findProject(projectPath) either fails to resolve (companion silently skipped) or resolves to a same-named project in the wrong build.

Suggested fix: when the component is a project of the current build, add project.dependencies.project(path) with capabilities { requireCapability(...) }; keep the coordinate path only for ModuleComponentIdentifier results. Alternatively, if in-build discovery is intentionally out of scope, document that and point at a substitution recipe.

if (cliClasspath == null) {
return names
}
for (File file : cliClasspath.incoming.artifactView { it.lenient(true) }.files) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This forces full resolution of grailsCliClasspath (and, transitively, the grailsCliDetect probe via withDependencies) inside project.afterEvaluate — i.e. at configuration time, on every invocation. ./gradlew help or an unrelated test run pays for resolving grails-core-cli, grails-console, and every discovered companion, and an offline/registry failure silently drops per-command task registration rather than failing loudly. It is also likely hostile to the configuration cache.

I realize task names must be known at configuration time to tasks.register(...), so some of this is a genuine design tension — but the resolution result should at least be cached/scoped so it is not repeated for builds that never touch a CLI task, and the lenient-swallow failure mode deserves a warning log at minimum.


JavaPluginExtension java = project.extensions.getByType(JavaPluginExtension)
java.registerFeature(CLI_SOURCE_SET_NAME) { spec ->
// no explicit capability: the default (`${group}:${project.name}-cli:${version}`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Latent contract mismatch: registerFeature here keeps the Gradle-computed default capability (${group}:${project.name}-cli), but CliArtifactExtension.artifactId is documented as customizable and renames the published jar and advertised coordinate. A consumer who sets cliArtifact { artifactId = 'custom-name' } and then requests requireCapability("$group:custom-name") in-build will fail to resolve, because the in-build capability never followed the extension.

Nothing in the framework customizes artifactId today, so this is unexercised — but either wire spec.capability(...) from the extension value or document the limitation on CliArtifactExtension.artifactId.

Comment thread dependencies.gradle
'gradle-groovy.version' : '4.0.32',
'gradle-spock.version' : '2.4-groovy-4.0',
'grails-publish-plugin.version' : '1.0.0-M1',
'grails-publish-plugin.version' : '1.0.0-SNAPSHOT',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking for release: this 1.0.0-SNAPSHOT flows into gradleBomDependencies and from there into the constraints { api ... } block of grails-bom/base, i.e. into the published <dependencyManagement> of org.apache.grails:grails-bom that every application consumes. A staged 8.0.x release would ship a BOM recommending a SNAPSHOT of org.apache.grails.gradle:grails-publish.

I understand this is pending apache/grails-gradle-publish#34 shipping — but the PR should be explicitly marked blocked on that release, with the version reverted to a GA coordinate before merge (or at latest before the first 8.0.x release candidate).

def cliArtifactId = subproject.findProperty('cliArtifactId')
if (cliArtifactId) {
apiConfiguration.dependencyConstraints.add(
project.dependencies.constraints.create("${subproject.group}:${cliArtifactId}:${projectVersion}"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to the grails-publish SNAPSHOT in dependencies.gradle: note that validateNoSnapshotDependencies (below in this file) only iterates config.allDependencies — constraints created here via dependencies.constraints.create(...) (and the constraints { api ... } block above) live in allDependencyConstraints, so the release guard has a blind spot for exactly this kind of leak and would let a SNAPSHOT constraint through a release build unflagged. Worth extending the guard to scan constraints as part of this PR, since the new lazy constraint mechanism widens that surface.

* owner's cli variant and its {@code available-at} redirect target both provide the capability,
* so consumers fail with a capability self-conflict (the grails-forge generated-app failure).
*/
class CliCompanionPublishingSpec extends GradleSpecification {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The publishing side is well covered here (publish → parse .module/.pom → resolve from a fresh repo — nice), but the discovery/wiring half of the feature has no coverage at all: nothing tests autoProvisionCliDependencies/findAdvertisedCliArtifact, the guarantee that grailsCli never leaks into runtimeClasspath/bootJar/bootWar, per-command task registration, or the cliAutoProvision opt-out.

That gap is not hypothetical — the one scenario that would have caught the project-dependency wiring bug (an app resolving a companion advertised by a project dependency, with cliAutoProvision at its default) is never exercised anywhere; the framework build globally disables the feature for its own modules. A functional test with an in-build plugin + consuming app would cover both the bug and the regression.

* ({@code src/cli/groovy}), which is not a standard project-source location — the transformation
* must still register them in {@code META-INF/grails-cli.factories}.
*/
class CommandFactoriesTransformationSpec extends Specification {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This spec only exercises the isCliSource(URL) helper. Nothing drives the transformation end-to-end: compiling a direct ApplicationCommand implementor, an indirect one via the GrailsApplicationCommand trait, and an abstract base class (which must be excluded), then asserting what lands in META-INF/grails-cli.factories. ApplicationContextCommandRegistry also has no spec pinning the intentional clean-break behavior — that a stale ApplicationCommand entry left in a legacy jar's grails.factories is silently ignored rather than loaded or erroring. Since the clean break is the headline breaking change, it deserves a test that locks it in.

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.

2 participants