Opt-in backwards compatibility for Grails 7 command plugins on Grails 8 - #16011
Conversation
PR #15948 split the CLI command tier off the application runtime classpath. As part of that split the command contract moved from grails.dev.commands.* to org.apache.grails.core.cli.* (with no forwarding alias) and command registration moved from META-INF/grails.factories (key grails.dev.commands.ApplicationCommand) to META-INF/grails-cli.factories. The result is that an unchanged, already-published Grails 7 command plugin silently loses all of its application commands on Grails 8: the command classes no longer link and their factory entries are never read. This adds a deprecated compatibility layer that lives entirely in the grails-core-cli tier (so the runtime classpath split is preserved) and lets unchanged Grails 7 command plugins keep working without a re-release: - Restore the deprecated grails.dev.commands.* command contract (ApplicationCommand, GrailsApplicationCommand, ExecutionContext and the io/template helpers) so pre-compiled Grails 7 command classes link again. - Adapt legacy commands to the new contract via LegacyApplicationCommandAdapter / LegacyApplicationCommandAware, and discover them from the legacy META-INF/grails.factories key in ApplicationContextCommandRegistry. Each legacy command is instantiated in isolation so one faulty command cannot suppress the rest, and a new-contract command always wins a name clash. A one-time deprecation warning nudges authors to migrate. - Unwrap the adapter in GrailsApplicationContextCommandRunner so Spring autowiring and the skipBootstrap lookup target the real legacy command that is subsequently executed. - Register per-command Gradle tasks for legacy commands found on the runtime classpath, degrading gracefully when the classpath cannot be resolved at configuration time (the generic runCommand task always works). Separately, fix a Groovy 5 regression affecting create-script codegen scripts: a hyphenated script filename (for example audit-quickstart) produced a class name without the hyphen, so the command resolved under the wrong name. GroovyScriptCommandFactory now uses the filename-derived command name. Adds unit and integration tests (including exception-isolation and adapter/runner coverage), a real end-to-end functional example under grails-test-examples/legacy-commands that boots a Grails 8 application and runs a legacy plugin's ApplicationCommand, GrailsApplicationCommand and hyphenated script, and upgrade documentation describing the compatibility path and which command mechanisms are affected. Assisted-by: claude-code:claude-4.8-opus
There was a problem hiding this comment.
Pull request overview
This PR restores backwards compatibility for already-published Grails 7 command plugins when running on Grails 8, while preserving the CLI/runtime classpath split introduced in #15948. It reintroduces the deprecated grails.dev.commands.* contract inside the CLI tier, adds adapter/registry/runner support to load legacy commands from META-INF/grails.factories, and fixes a Groovy 5 regression where hyphenated script commands (e.g. audit-quickstart) could be discovered under the wrong name.
Changes:
- Add a deprecated Grails 7 command compatibility layer (legacy contract types + adapter + dual-load registry behavior + runner unwrap).
- Restore hyphen-preserving discovery for plugin-provided Groovy script commands.
- Add unit + functional test coverage and a new end-to-end test-example fixture for legacy command plugins; update Grails 8 upgrade documentation accordingly.
Reviewed changes
Copilot reviewed 32 out of 32 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| settings.gradle | Registers new legacy-commands test-example projects in the build. |
| grails-test-examples/legacy-commands/src/integration-test/groovy/legacycommands/LegacyCommandCompatibilityIntegrationSpec.groovy | End-to-end integration test exercising legacy command discovery/adaptation and legacy script packaging. |
| grails-test-examples/legacy-commands/grails-app/init/legacycommands/Application.groovy | Test-example application entry point. |
| grails-test-examples/legacy-commands/grails-app/controllers/legacycommands/UrlMappings.groovy | Test-example URL mappings (baseline web profile wiring). |
| grails-test-examples/legacy-commands/grails-app/conf/logback.xml | Test-example logging configuration. |
| grails-test-examples/legacy-commands/grails-app/conf/application.yml | Test-example app configuration for integration execution. |
| grails-test-examples/legacy-commands/build.gradle | Test-example app build with legacy-commands plugin dependency. |
| grails-test-examples/legacy-commands-plugin/src/main/scripts/hello-legacy-script.groovy | Legacy plugin script command fixture with a hyphenated command name. |
| grails-test-examples/legacy-commands-plugin/src/main/resources/META-INF/grails.factories | Legacy factories registration under grails.dev.commands.ApplicationCommand. |
| grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/LegacyCommandsPluginGrailsPlugin.groovy | Plugin descriptor for the legacy command fixture plugin. |
| grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/HelloLegacyGrailsCommand.groovy | Legacy GrailsApplicationCommand fixture. |
| grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/HelloLegacyAppCommand.groovy | Legacy ApplicationCommand fixture. |
| grails-test-examples/legacy-commands-plugin/build.gradle | Fixture plugin build (compiles against grails-core-cli without publishing a -cli companion). |
| grails-shell-cli/src/test/groovy/org/grails/cli/profile/commands/factory/LegacyPluginScriptCompatSpec.groovy | Unit test ensuring Grails 7-style plugin scripts remain discoverable with hyphenated names. |
| grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/factory/GroovyScriptCommandFactory.groovy | Ensures compiled script commands use the filename-derived command name (hyphen-preserving). |
| grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/commands/GrailsCliGradlePlugin.groovy | Adds legacy factories scanning on runtimeClasspath to keep per-command Gradle tasks for Grails 7 plugins. |
| grails-doc/src/en/guide/upgrading/upgrading80x.adoc | Documents the deprecated compatibility layer and the migration path for plugin authors. |
| grails-core/src/test/groovy/org/apache/grails/core/cli/LegacyCommandRegistryLoadingSpec.groovy | Unit tests for legacy factories loading, isolation on constructor failure, and collision preference rules. |
| grails-core/src/test/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapterSpec.groovy | Unit test verifying adapter forwarding into legacy execution context. |
| grails-core/src/cli/groovy/org/apache/grails/core/cli/LegacyApplicationCommandAware.groovy | Marker interface for adapters to allow runner autowire/inspection unwrap. |
| grails-core/src/cli/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapter.groovy | Adapter bridging legacy grails.dev.commands.ApplicationCommand into the Grails 8 CLI contract. |
| grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationContextCommandRegistry.groovy | Registry now dual-loads legacy factories and wraps legacy commands via adapter with one-time warning. |
| grails-core/src/cli/groovy/grails/dev/commands/template/TemplateRendererImpl.groovy | Restored deprecated template renderer implementation for legacy command plugins. |
| grails-core/src/cli/groovy/grails/dev/commands/template/TemplateRenderer.groovy | Restored deprecated template renderer API for legacy command plugins. |
| grails-core/src/cli/groovy/grails/dev/commands/template/TemplateException.groovy | Restored deprecated template exception for legacy command plugins. |
| grails-core/src/cli/groovy/grails/dev/commands/io/FileSystemInteractionImpl.groovy | Restored deprecated filesystem interaction implementation for legacy command plugins. |
| grails-core/src/cli/groovy/grails/dev/commands/io/FileSystemInteraction.groovy | Restored deprecated filesystem interaction API for legacy command plugins. |
| grails-core/src/cli/groovy/grails/dev/commands/GrailsApplicationCommand.groovy | Restored deprecated legacy GrailsApplicationCommand trait for Grails 7 commands. |
| grails-core/src/cli/groovy/grails/dev/commands/ExecutionContext.groovy | Restored deprecated legacy execution context type. |
| grails-core/src/cli/groovy/grails/dev/commands/ApplicationCommand.groovy | Restored deprecated legacy application command contract trait. |
| grails-console/src/test/groovy/grails/ui/command/GrailsApplicationContextCommandRunnerSpec.groovy | Tests that runner unwraps legacy adapter for autowiring/inspection. |
| grails-console/src/main/groovy/grails/ui/command/GrailsApplicationContextCommandRunner.groovy | Runner unwraps legacy adapters for autowiring and skipBootstrap inspection. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…mmand-compat # Conflicts: # grails-doc/src/en/guide/upgrading/upgrading80x.adoc
- Guard the thread-context classloader command scan so legacy commands are not re-instantiated when the context and registry classloaders are the same (their constructors may have side effects); the redundant pass was fully discarded by the name-collision check anyway. - Log the throwable (not just its message) when a legacy command fails to load through the compatibility layer, so the stack trace is available for diagnosis. - Add a Gradle TestKit test proving a legacy command shipped in a runtime jar (registered under grails.dev.commands.ApplicationCommand in META-INF/grails.factories) registers its per-command task in a consuming application. - Deepen the functional example: assert Spring autowiring reaches the wrapped legacy command through the runner's unwrap path (a real service is injected), and that the legacy GrailsApplicationCommand file() code-generation DSL produces output through the adapter. Assisted-by: claude-code:claude-4.8-opus
- Gather legacy command classes from the registry and thread-context classloaders into a set de-duplicated by Class identity before instantiating any, so a parent-visible legacy command reported through a delegating child classloader is not instantiated twice (its constructor may have side effects) only to be discarded on the name-collision check. - Correct whatsNew.adoc, which still stated a command-bearing Grails 7 plugin's commands are unavailable until it publishes a companion -cli artifact; they now remain available through the deprecated compatibility layer. Assisted-by: claude-code:claude-4.8-opus
Replace the manual `private static final Logger LOG = LoggerFactory.getLogger(...)` declaration with the codebase-standard `@Slf4j` annotation (generates a `log` field), matching the dominant convention across grails-core. Addresses review feedback on PR #16011. Assisted-by: claude-code:claude-opus-4-8
|
@jamesfredley if you can let me know today, I'm happy to add the proposed compatibility layer. I'd like to proceed with the M4. Are you wanting this done before the M4? |
…egacy The backwards-compatibility layer that keeps unchanged Grails 7 command plugins (grails.dev.commands.ApplicationCommand) working on Grails 8 was restored inside grails-core-cli, which put the deprecated contract on the compile ABI of every plugin that depends on the CLI tier. Move the deprecated types, the adapter, and the legacy loader into a new published subproject grails-core-cli-legacy and provision it execution-only. grails-core-cli now exposes a neutral SPI - ApplicationCommandProvider, ApplicationCommandRegistrar, and ApplicationCommandTargetAware - and ApplicationContextCommandRegistry discovers providers through META-INF/grails-cli.factories (dual-classloader, class-identity deduplicated, failure isolated) with modern commands winning name collisions. The legacy module ships LegacyApplicationCommandProvider, which performs the deprecated dual-classloader load, adapter-wrap, and one-time deprecation warning. grails-core-cli retains zero references to grails.dev.commands. The Grails Gradle plugin adds a non-resolvable grailsCliLegacy bucket that feeds only grailsCliClasspath and the test runtime classpaths - never any compile classpath - and is auto-provisioned by default via cliAutoProvision, so compatibility stays on by default with zero user action. A plugin that still authors legacy commands compiles against grails-core-cli-legacy explicitly. Assisted-by: claude-code:claude-opus-4-8
|
@jamesfredley I strongly disagree with the legacy compatibility by default. A single setting can be changed as part of the upgrade if people use legacy plugins - this matches previous decisions like indy, and many more. Making this a default incentivizes people to not adopt the new api and when we remove it, it will break outside of a major release lifecycle. |
…ge failures Address non-blocking review feedback on the Grails 7 command compatibility layer: - Resolve a contributed command's name exactly once. ApplicationCommandRegistrar now returns the installed command name (or null when the name is already registered) instead of a boolean, so LegacyApplicationCommandProvider no longer reads command.name a second time for the deprecation warning - a stateful or second-call-throwing legacy getter can no longer be installed without emitting the promised warning. - Surface binary-incompatible legacy plugins loudly. LegacyApplicationCommandProvider now catches LinkageError separately and logs it at error level naming the command class (a trait-woven Grails 7 binary that does not link against the restored contract), while still isolating the failure so other commands continue to load. Reflective construction wraps constructor throwables in InvocationTargetException, so linkage failures thrown from a constructor are unwrapped and classified correctly. - Add regression tests: single name resolution under a throwing getter, the linkage-failure error path, and class-identity deduplication of providers discovered through both the registry and context class loaders. Assisted-by: claude-code:claude-opus-4-8
|
@jamesfredley Please stop resolving comments when they're only half completed. It makes extremely hard to understand what's done and what isn't. |
Resolve GrailsCliGradlePlugin conflict by keeping both the legacy ApplicationCommand factories key and the Grails task group constant introduced on 8.0.x. Assisted-by: Sisyphus:xai/grok-4.5
Split control of the Grails 7 application-command bridge from modern CLI companion auto-provisioning. Both flags default to true; the legacy bridge provisions only when both are enabled. Version-pin framework CLI artifacts to the current Grails version, and derive external companion versions from the producer module or jar name. Assisted-by: Sisyphus:xai/grok-4.5
|
@jdaugherty Update after the latest push ( There is now a dedicated
I kept the default on so already-published G7 command plugins keep working with zero user action after the #15948 split. The separate flag is the opt-out for people who want the modern CLI tier without the compatibility bridge. If the project decision is still "default off + loud detect/warn when legacy factories are present", that is a policy call and I can flip the default. The wiring is already split so that change is one convention flip, not a redesign. |
|
@jdaugherty On M4 timing: yes, I want this compatibility path landed before M4 if we can. Current pushed state:
Still open for a stronger green light:
|
|
@jdaugherty Agreed. I will not resolve review threads unless the full ask is actually done and you are good with it. For the current open items I am only replying with status, not marking resolved. |
|
On the Forge failures ( That was auto-provision creating companion coordinates without a version. The latest push pins framework CLI artifacts ( Watching the post- |
Remove configuration-time runtimeClasspath scanning for grails.dev.commands ApplicationCommand entries. Legacy commands stay available through runCommand and the shell adapter once grails-core-cli-legacy is provisioned. Assisted-by: Sisyphus:xai/grok-4.5
Add a standalone fixture compiled against published grails-core 7.0.10 / Groovy 4.0.30, wire it into the legacy-commands example via GradleBuild, and assert the binary is discovered and executed through the Grails 8 adapter. Document that legacy commands use runCommand rather than named Gradle tasks. Assisted-by: Sisyphus:xai/grok-4.5
Use single-quoted SLF4J message templates, drop unnecessary safe-navigation on non-null loop variables, and rename the empty catch variable to ignored so EmptyCatchBlock accepts it. Assisted-by: Sisyphus:xai/grok-4.5
Latest push summary (
|
| Flag | Default | Scope |
|---|---|---|
cliAutoProvision |
on | modern CLI tier + companions |
legacyCommandSupport |
on | only grails-core-cli-legacy |
Legacy provisions only when both are true.
4. Code style CI fix
- Single-quoted SLF4J templates
- Dropped unnecessary
?.on non-null loop variables - Empty catch variable renamed to
ignored(EmptyCatchBlock) - Local:
:grails-core-cli-legacy:codenarcMain+:grails-core:codenarcCligreen withignoreFailures=false
Still open for reviewer preference
- Default-on vs default-off + detect/warn policy for
legacyCommandSupport(product call currently remains default-on; flip is one convention change if decided) - Remaining script/YAML packaging pollution tracked in Move src/main/scripts command resources out of runtime plugin artifacts #16035
The Grails 7 application-command compatibility bridge is now off by
default. Consumers that still need unchanged Grails 7 command plugins
must opt in with grails { legacyCommandSupport = true } or
-PgrailsLegacyCommandSupport=true. Modern CLI auto-provisioning
(cliAutoProvision) remains on by default and is independent.
Update upgrade/What's New docs, TestKit coverage (default false,
explicit false/true, master-switch override), and the legacy-commands
fixture to match.
Assisted-by: Sisyphus:grok-4.5
|
Pushed Code
Docs
Tests / fixtures
PR description updated to match (default-off bridge, opt-in migration note). |
Resolve CLI companions from Gradle component metadata, preserve command loading order and fatal error semantics, report unsupported Grails 7 command factories at runtime, and strengthen the real Grails 7 fixture coverage. Assisted-by: opencode:gpt-5.6-sol codegraph
jdaugherty
left a comment
There was a problem hiding this comment.
Re-reviewed the whole branch against my earlier rounds and resolved all nine of my open threads at 75ed39351b — the ABI isolation, the LinkageError/VirtualMachineError split with jar-named, framework-blaming wording, the removal of the nondeterministic named-task scan, the component-metadata companion version resolution, the runtime-only legacy detection with hints on both unknown-command paths, and the precompiled Grails 7 / Groovy 4 fixture actually executing through the registry. The last of those (the fixture tightenings) I resolved on the strength of three of its four asks being done; the remaining one is carried by a comment below rather than left buried in a thread about the build file. The legacyCommandSupport default is false, which matches where the dev list landed; I have nothing further on that.
Everything still open below is already implemented and verified on a branch: test/8.0.x-legacy-command-e2e (one commit on top of 75ed39351b, opened as a draft against this branch in #16059). Take it, cherry-pick from it, or ignore it — it exists so none of this has to block the merge on working out the shape. Nothing below is a request for you to go and write code.
The two I'd want fixed before merge
- The Grails 7 fixture's JDK 17 requirement is expressed as a Gradle toolchain that nothing in the repo provisions, and it sits in the core build's task graph — so
./gradlew build -PskipTestsfails on the documented developer JDK and in the reproducible-build container. Reproduced. The version is right; the placement isn't. loadFactoryDeclarationssilently discards a malformed factories resource — and it now backs moderngrails-cli.factoriesdiscovery, so one bad file loses a plugin's Grails 8 commands with no log line anywhere.
The other two
- The last part of my fixture tightening
#4: the trait's defaultgetName()/getDescription()derivation is shadowed by every legacy command in the tree, so the "command declares no name" variant — the shapecreate-commandgenerated, and therefore what most published Grails 7 commands rely on for their registration key — has zero coverage. - No test coverage for the
skipBootstrap-from-target behaviour, which decides whetherBootStrapruns during things likedbm-update.
What the branch does
The build half is the substantial part: the three legacy-command projects move to a new top-level end-to-end build, the toolchain block is gone, the Grails 7 fixture pins its own JDK and Gradle version (17 and 8.14.5, what Grails 7 uses) in its own .sdkmanrc with gradle-bootstrap generating that wrapper from it, and .github/workflows/end-to-end.yml provisions both JDKs by reading those files.
Rather than composing grails-core with includeBuild('..'), it resolves from the artifacts the core build publishes — the same build/local-maven grails-forge points its generated apps at. That is what makes them end-to-end, and it disposes of the CLI companion problem instead of working around it: grails-core-cli can't be reached by composite substitution (it's a capability of :grails-core, not a project) but is a first-class published module, so resolving from the repository just works.
The minor items — also done, except one
ThreadDeathremoved from all four fatal-error guards;VirtualMachineErrorhandling and the wrapped-cause walk untouched, and the specs now prove rethrow across twoVirtualMachineErrorsubtypes instead.rethrowIfFatal()left duplicated, deliberately. grails-shell-cli cannot see grails-core at compile time (verified: itscompileClasspathresolvesgrails-bootstrap, notgrails-core— which is why the factory loads the registry by string name). The only type visible to all four sites isExceptionUtilsin grails-bootstrap, and putting a CLI error-triage rule there means new public API on a general-purpose published utility. Worse trade than 13 duplicated lines. The byte-identicalinstantiate()/instantiateCommand()pair inside the one class was collapsed.- Commands are now deduplicated across the registry and context classloaders, matching the provider path. Nice bit of evidence: the existing assertion was
constructorCalls == registryClassLoaderConstructions + 1, and that+ 1was the double construction — it now asserts exactly one. - Legacy commands take part in ordering: the adapter projects the target's
Ordered/@Orderonto itself (plainOrderComparatorreads onlyOrdered, never annotations) and the provider sorts before registering. - The stale "composite-build / included by the monorepo root" comments and the duplicated
7.0.14pin are fixed. ApplicationCommandProviderSpec's misnamed feature: the dead branch is measured-dead (instrumented the handler; exactly oneopenConnection) and removed, and the feature renamed to what it actually verifies. Making the branch live would have broken the test — the fixture throwsAssertionError, whichloadFactoryDeclarationsdoes not catch, so the hint would come back null and fail the feature's own first assertion.AbstractProfile:488now has a spec driving the publicProfile.handleCommand, red-checked against the pre-PR line.
On my inheritance question
Your answer is right that my literal proposal (legacy trait extends the new trait) fails: the bridge method lands abstract on the interface and an unrecompiled Grails 7 binary gets AbstractMethodError rather than a clean failure, which is worse than today. Worth noting for the record that "inheritance does not adapt the argument type" isn't true in general — a Java interface carrying a real default handle(new ExecutionContext) that delegates to handle(legacy ExecutionContext) does work on an unrecompiled binary. But the adapter is still the better choice, and for a concrete reason worth writing down: with the bridge, adding any member to org.apache.grails.core.cli.ApplicationCommand breaks every precompiled Grails 7 binary with AbstractMethodError, whereas the adapter absorbs that evolution. No change requested.
Verification on the branch
:grails-core:test 487, :grails-core-cli-legacy:test 19, :grails-shell-cli:test 175, :grails-console:test 36 — zero failures; codenarc and checkstyle clean on all four; the end-to-end suite 6/6 green against freshly published artifacts, and green in CI on #16059 including the two-JDK setup and the fixture-provenance check.
|
|
||
| java { | ||
| toolchain { | ||
| languageVersion = JavaLanguageVersion.of(17) |
There was a problem hiding this comment.
Commenting on this line, though the Java version itself isn't the issue — 17 is correct and should stay. It's the minimum JDK for a Grails 7 app (7.0.x pins java=17.0.18-librca in .sdkmanrc and javaVersion=17 in gradle.properties), and matching what a Grails 7 app would actually be built with is the entire point of this fixture; anything newer would weaken what it proves. The issue is where that requirement is expressed.
Right now it's a Gradle toolchain, and this is the only production build in the repo that pins one — every other JavaLanguageVersion is a TestKit resource substituted with __CURRENT_JDK__ or a gated java-compat test project. We've deliberately stayed off toolchain configuration, and I don't want this fixture to be what introduces it into the core build.
As written it also can't be satisfied. Nothing provisions a JDK 17: there's no foojay-resolver or toolchainManagement block in this build's settings.gradle (which is just rootProject.name) or in the root settings.gradle, and no org.gradle.java.installations.* property anywhere. The root .sdkmanrc pins java=21.0.7-librca and nothing else, and etc/bin/Dockerfile is FROM bellsoft/liberica-openjdk-debian:21.0.7 with only a secondary JDK 25 at /opt/liberica-jdk25.
And it isn't confined to running the integration test. The fixture jar is an implementation dependency (legacy-commands/build.gradle:45), so buildLegacyG7CommandFixture is in the task graph for plain compilation:
$ ./gradlew :grails-test-examples-legacy-commands:compileGroovy --dry-run -PskipTests
:grails-test-examples-legacy-commands:buildLegacyG7CommandFixture SKIPPED
:grails-test-examples-legacy-commands:compileGroovy SKIPPED
That's reached by etc/bin/test-reproducible-builds.sh:42 (./gradlew build --rerun-tasks -PskipTests ...). With JDK 17 hidden:
$ ./gradlew -p grails-test-examples/legacy-g7-command-plugin jar \
-Dorg.gradle.java.installations.auto-detect=false
> Cannot find a Java installation on your machine matching:
{languageVersion=17, vendor=any vendor, ...}. Toolchain auto-provisioning is not enabled.
So a contributor whose only JDK is the one we document can't build the repo. CI can't catch it — it's green only because the GitHub runner images happen to ship a JDK 17 that Gradle auto-detects.
The fix isn't a different Java version or a resolver plugin. It's to stop wiring a second JDK requirement into the core build at all, and instead use the composition model we already use for every other build in this repo that has its own needs: grails-forge, grails-gradle, build-logic. Those are independent builds with their own settings.gradle and their own gradlew, and the dependency runs one way — grails-forge/settings.gradle:75 does includeBuild('..'), so Forge composes grails-core and grails-core's own ./gradlew build never reaches Forge. CI runs each as its own job (working-directory: 'grails-forge', working-directory: 'grails-gradle').
I'd like a new top-level end-to-end build to serve as the base for this kind of test, with the Grails 7 fixture and the legacy-commands example that consumes it as its first occupants:
- drop the
java { toolchain { ... } }block; - move the fixture and its consuming example into
end-to-end, out of the root build graph, so./gradlew build -PskipTestsnever triggersbuildLegacyG7CommandFixtureand the core build never needs a JDK it doesn't have; - declare the Java 17 requirement in a
.sdkmanrcnext to the Grails 7 half — same mechanism the root and7.0.xalready use, and it keeps 17 an explicit, greppable statement of intent rather than something Gradle has to go find; - run it from its own CI workflow, the way Forge and the Gradle plugins are run.
That also gives us an obvious home for the next end-to-end compatibility test instead of finding a new corner for each one.
Two things worth planning for in that layout, both of which a dedicated workflow handles cleanly and the core build can't:
- Two JDKs, explicitly. The Grails 7 fixture wants 17; the Grails 8 app consuming it needs 21+. In a dedicated workflow that's just two
setup-javasteps around two build invocations, which is more honest than asking one Gradle build to straddle both — and it's what removes the auto-detection dependency that's making CI pass by luck today. - Keep the Grails 7 half outside dependency substitution. If
includeBuild('..')reaches it,org.apache.grails:grails-core:7.0.14gets rewritten to the local Groovy 5 project and the proof evaporates — which is exactly why you choseGradleBuildoverincludeBuildoriginally, and I agreed with that reasoning. The Grails 8 consuming app can compose the root normally; the fixture can't.
I've put this together as a working branch rather than leave it as a sketch: test/8.0.x-legacy-command-e2e (single commit).
The three projects move to a top-level end-to-end build and the toolchain block is gone. Only the Grails 7 fixture carries a .sdkmanrc (17); the Grails 8 side tracks the repository's root .sdkmanrc rather than re-pinning 21, since it has to run on whatever the core build it consumes runs on. .github/workflows/end-to-end.yml reads both versions out of those two files, so it cannot drift from what sdk env gives a developer.
The part worth a look is how it resolves Grails. Rather than includeBuild('..'), it consumes the artifacts the core build publishes — the same build/local-maven that grails-forge points its generated applications at, populated by publishAllPublicationsToTestCaseMavenRepoRepository in the root and grails-gradle builds. That is what makes these end-to-end: they go through real poms and module metadata the way an application does. settings.gradle scopes the repository with exclusiveContent so a remote snapshot can't quietly satisfy an org.apache.grails request and leave the suite testing something other than the working tree.
It also disposes of the CLI companion problem instead of working around it. grails-core-cli is a secondary capability of :grails-core rather than a project, so composite substitution can't express it and dies on a capability self-conflict — but it's a first-class published module whose metadata CliPublishingSupport already rewrites for external consumers, so resolving from the repository just works. Verified byte-for-byte: the grails-core-cli jar the suite resolves is sha1-identical to the one the publish task had just written to build/local-maven.
Verified end to end: end-to-end/./gradlew check green with all six integration tests passing, the root ./gradlew configures with zero references to the moved projects, and the fixture builds under JDK 17 with -Dorg.gradle.java.installations.auto-detect=false. Take it as a proposal, not a demand.
| properties.load(input) | ||
| } | ||
| } | ||
| catch (IOException | IllegalArgumentException ignored) { |
There was a problem hiding this comment.
catch (IOException | IllegalArgumentException ignored) { continue } drops the entire resource with no log line at any level — the class has no logger.
That would be defensible if this only backed the legacy diagnostic, but it doesn't. loadFactoryDeclarations is now the reader for modern discovery too: ApplicationContextCommandRegistry:64 for commands and :159 for providers, both against CLI_FACTORIES_RESOURCE_LOCATION. So a single plugin shipping a META-INF/grails-cli.factories with a stray backslash — Properties.load throws IllegalArgumentException("Malformed \\uxxxx encoding") — loses every one of that plugin's Grails 8 commands and providers. The user sees commands that simply aren't there, with nothing to chase.
It's also a loudness regression against what it replaced. FactoriesLoaderSupport.loadFactoryNames (grails-gradle/model/.../FactoriesLoaderSupport.groovy:99-101) rethrows IOException as IllegalArgumentException and doesn't catch IllegalArgumentException at all, so a malformed factories file propagated instead of vanishing. ApplicationCommandProviderSpec:301 currently locks the new silence in — it asserts only missingCommandHint == null and noExceptionThrown().
This is the same failure mode you and I already agreed on for linkage errors: a command the user expects, silently absent, diagnosable by nobody. Give the class a logger and warn with the resource URL and the cause before continuing.
Implemented on test/8.0.x-legacy-command-e2e: the class gets @Slf4j and the catch warns with the resource URL and cause, keeping the per-resource continue. The existing malformed-resource test now asserts the warning instead of only noExceptionThrown(), and a new one proves a malformed grails-cli.factories in one plugin no longer costs a different plugin its modern commands. :grails-core:test --tests ApplicationCommandProviderSpec green (23 features).
| class HelloG7PrecompiledCommand implements ApplicationCommand { | ||
|
|
||
| @Override | ||
| String getName() { |
There was a problem hiding this comment.
Overriding getName() here is a legitimate Grails 7 shape and this command should keep it. The gap is that it's the only shape covered.
The deprecated trait ships two behaviours. One is "the command declares its own name", which this fixture exercises. The other is "the command declares no name and the trait derives one" — ApplicationCommand.getName() at grails-core-cli-legacy/.../grails/dev/commands/ApplicationCommand.groovy:50-57, which runs GrailsNameUtils.getScriptName(getLogicalName(...)) and, from a Groovy 4 binary, reaches it through ApplicationCommand$Trait$Helper. That second path has no coverage anywhere in the tree: both fixture commands override the getters (here at :35/:39, HelloG7PrecompiledGrailsCommand at :29/:33), LegacyApplicationCommandAdapterSpec:54-55 overrides them, and so do all eight classes in LegacyCommandRegistryLoadingSpec (:332, :354, :373, :395, :415, :440, :460, :484).
It matters because class FooBarCommand implements ApplicationCommand with no getName() is the shape create-command generated, so a large share of the published Grails 7 commands this bridge exists to keep working never declare a name at all. For those the derivation is the registration key — if the Groovy 5 recompile of the trait changes how that default is woven or dispatched into Groovy-4-compiled implementors, they register under the wrong key or not at all. That's precisely the class of failure this fixture was built to catch, and it's the one variant it currently can't see.
So: keep this command as-is, and add a third precompiled one alongside it that declares neither getter, letting the trait derive the name. The spec then looks it up under whatever the derivation produces, which only passes if the default survives the version boundary.
A unit test over in grails-core-cli-legacy wouldn't substitute for this — that would be a Groovy 5 implementor against a Groovy 5 trait, which isn't the cross-version question. It has to be a Groovy 4 binary to mean anything, which is why it belongs in this fixture.
There's a working version of this on test/8.0.x-legacy-command-e2e: a third precompiled HelloDerivedNameCommand matching the create-command template exactly - implements GrailsApplicationCommand, declares neither getter - which reads its own name inside handle() so the derivation is exercised from the Groovy 4 bytecode rather than only from the recompiled trait. It registers as hello-derived-name (confirmed by running the trait against the precompiled class), and the integration spec asserts both the registration key and the rendered output. Green.
| Object autowireTarget = resolveAutowireTarget(command) | ||
|
|
||
| Object skipBootstrap = command.hasProperty('skipBootstrap')?.getProperty(command) | ||
| Object skipBootstrap = autowireTarget.hasProperty('skipBootstrap')?.getProperty(autowireTarget) |
There was a problem hiding this comment.
Reading skipBootstrap off the unwrapped target is the right call — the adapter forwards handle, not arbitrary properties — but nothing tests it. grep -rn skipBootstrap across the repo returns only production code: this file, and the skipBootstrap = true declarations in the hibernate5/7 SchemaExportCommand and ApplicationContextDatabaseMigrationCommand, scaffolding, and spring-security. No spec mentions it.
That matters more than a coverage count, because the flag decides whether BootStrap.groovy runs. If a later change collapses this back to command.hasProperty('skipBootstrap'), a legacy command declaring Boolean skipBootstrap = true silently stops setting Settings.SETTING_SKIP_BOOTSTRAP and BootStrap executes during something like dbm-update against whatever datasource is configured — with the whole suite still green.
run() has the same hole: LegacyCommandCompatibilityIntegrationSpec performs the autowire unwrap by hand at :85-98 instead of driving the runner, so reverting line 79 back to command also leaves every test passing.
Small fix: extract the flag lookup into a static helper next to resolveAutowireTarget and cover adapter-target / plain-command / no-property in GrailsApplicationContextCommandRunnerSpec, which this commit already added.
Implemented on test/8.0.x-legacy-command-e2e: the lookup moves into static Boolean resolveSkipBootstrap(Object) beside resolveAutowireTarget, with run() behaviour unchanged (the System.getProperty guard stays put). Four features in GrailsApplicationContextCommandRunnerSpec cover plain command, no-property, adapter-target, and non-Boolean. Mutation-checked: pointing the helper back at command instead of the resolved target fails exactly the adapter-target case. :grails-console:test --tests GrailsApplicationContextCommandRunnerSpec green (34 features).
|
I think AI double posted my responses, but #16059 fixes all of my issues with this branch. If we merge that into this, we should merge this change. |
Addresses the outstanding review feedback on the Grails 7 command compatibility layer. Build placement. The Grails 7 fixture needs Java 17 - the minimum for a Grails 7 app, so the binary matches what a real Grails 7 plugin is built with - but expressing that as a Gradle toolchain put an unprovisionable JDK requirement into the core build's task graph. Nothing in the repo provisions a 17 (no foojay resolver, no toolchainManagement, no org.gradle.java.installations.*), and because the fixture jar was an implementation dependency, `./gradlew build -PskipTests` reached it - so the build failed on the JDK the project documents in .sdkmanrc and in the reproducible-build container. CI only passed because the runner images happen to ship a 17 that Gradle auto-detects. The three projects now live in a new top-level end-to-end build, so the root build no longer reaches them. The fixture declares its JDK and Gradle version in its own .sdkmanrc, matching what Grails 7 pins (17 and 8.14.5) rather than what this repository builds with, and is excluded from the gradle-bootstrap wrapper propagation for that reason; the end-to-end build itself is added to that propagation and tracks the root. A dedicated workflow provisions both JDKs, reading the versions out of those files. Resolution goes through published artifacts rather than project substitution, which is what makes these tests end-to-end: they consume grails-core the way an application does, through real poms and module metadata. The repository is the same build/local-maven that grails-forge points its generated applications at, populated by publishAllPublicationsToTestCaseMavenRepoRepository in both the root and grails-gradle builds. settings.gradle scopes it with exclusiveContent so a remote snapshot cannot quietly satisfy an org.apache.grails request and leave the suite testing something other than the working tree. That also disposes of the CLI companion problem rather than working around it. grails-core-cli is a secondary capability of :grails-core, not a project, so composite substitution cannot express it and hits a capability self-conflict - but it is a first-class published module whose metadata CliPublishingSupport already rewrites for external consumers, so resolving from the repository gets it for free. Trait-derived command names. Every legacy command in the tree overrode getName()/getDescription(), so the trait's default derivation - what create-command generated on Grails 7, and therefore what most published Grails 7 commands rely on for their registration key - had no coverage. Adds a third precompiled command declaring neither getter. Factory resource failures are no longer silent. loadFactoryDeclarations dropped a malformed resource with no log line at any level, and it backs modern grails-cli.factories discovery, so one bad file lost every one of a plugin's Grails 8 commands and providers. It now warns with the resource URL and cause, keeping the per-resource isolation. skipBootstrap resolution is covered. The lookup moves into a static helper so the adapter-target case - where reading the flag off the adapter instead of its target would let BootStrap run during dbm-update - is guarded by a test. Commands are now deduplicated across the registry and context classloaders the way providers already were. A command declared in a resource visible through both was constructed twice and the second instance discarded, which the surrounding comment says the code exists to avoid; an existing assertion encoded that double construction and now asserts a single one. Legacy commands take part in ordering. Modern commands are sorted before first-wins registration, but legacy ones were registered in factory scan order and the adapter carried none of the target's ordering, so two Grails 7 plugins declaring the same command name resolved by jar order. The adapter now projects the target's Ordered/@order onto itself and the provider sorts before registering, restoring Grails 7 semantics. ThreadDeath handling is removed from the four fatal-error guards. Thread.stop() was removed in JDK 20, so on the 21 baseline the only instances that branch ever saw were the ones the tests constructed, and the type is deprecated for removal. VirtualMachineError handling and the wrapped-cause walk are unchanged, and the specs now prove rethrow across two VirtualMachineError subtypes instead. The duplicated rethrowIfFatal is deliberately left in place: grails-shell-cli cannot see grails-core at compile time, so consolidating would mean new public API on a general-purpose utility for a CLI-internal concern. Test quality. A spec named for a plugin-origin resolution failure described a code path that does not exist and whose failure branch was unreachable; it is renamed to what it verifies, with the dead branch removed. AbstractProfile's unknown-command path, changed by this work but uncovered, gets a spec driving the public Profile.handleCommand. Also collapses two byte-identical instantiate helpers, drops a duplicated fixture version pin in the integration spec, and corrects comments describing the fixture as an included composite build. Raises the forge CLI spec polling budget from 240s to 600s. The specs that call executeGradleCommand('build') build a whole generated application - asset compilation, bootWar, test and integrationTest - so 240s was marginal rather than generous: on the Java 25 lane that build measured 217s when it passed, and a runner around 10% slower than average pushed it past the limit and failed the lane on timing alone, with the poll reporting a bare "condition not satisfied" because it only ever watches for the success string. Raises the forge CLI spec polling budget from 240s to 600s and stops the wait once the build process is gone. The specs that call executeGradleCommand('build') build a whole generated application - asset compilation, bootWar, test and integrationTest - so 240s was marginal rather than generous: on the Java 25 lane that build measured 217s when it passed, and a runner around 10% slower than average pushed it past the limit and failed the lane on timing alone. Raising the budget alone would have made a genuinely failing generated build take even longer to report, and report it only as a bare unsatisfied condition, which is what made a failed build look like a hang. testOutputContains therefore no longer uses PollingConditions, which retries on any Throwable and so cannot short-circuit once the build has finished; it polls directly and fails as soon as the process has exited without emitting the expected output, naming the exit code and including the build output. The early exit keys on process liveness rather than on a BUILD FAILED marker because several specs legitimately wait for text from a build that is expected to fail.
Move legacy command compatibility tests into an end-to-end build
Restore Apache RAT compliance for the end-to-end Gradle properties file. Assisted-by: opencode:gpt-5.6-sol
Use the shared LogCapture fixture so diagnostic assertions observe SLF4J events directly instead of relying on an unconfigured standard-error appender. Assisted-by: opencode:gpt-5.6-sol codegraph Assisted-by: opencode:gpt-5.6-terra
System.err redirection no longer observes DefaultStackTraceFilterer STACK_LOG output under the SLF4J/Logback test classpath, which left baos empty and failed the default-on positive-control specs in CI. Align GrailsUtilStackFiltererSpec and GrailsBootstrapRegistryInitializerSpec with StackTraceFiltererSpec by asserting against the StackTrace logger through the shared LogCapture fixture. Assisted-by: Sisyphus:xai/grok-4.5
|
The TestLens failures for Fix: #16067 (replaces the |
✅ All tests passed ✅🏷️ Commit: bdb98a6 Learn more about TestLens at testlens.app. |
Summary
PR #15948 split CLI command code off the application runtime classpath. The command contract moved from
grails.dev.commands.*toorg.apache.grails.core.cli.*, and registration moved fromMETA-INF/grails.factoriestoMETA-INF/grails-cli.factories. That split is preserved.The side effect is that an unchanged, already-published Grails 7 application-command plugin would otherwise lose those commands on Grails 8: the old types no longer link, and the old factory entries are never read.
This PR restores that path with a deprecated, execution-only compatibility layer so existing Grails 7 application commands can keep working when opted in, without a plugin re-release and without putting the deprecated contract on the compile ABI of
grails-core-cli.Final behavior
Compatibility layer
org.apache.grails:grails-core-cli-legacygrails.dev.commands.*command contractLegacyApplicationCommandAdapter/ provider wiringgrails-core-cli:ApplicationCommandProviderApplicationCommandRegistrarApplicationCommandTargetAwareApplicationContextCommandRegistrydiscovers providers fromMETA-INF/grails-cli.factoriesgrails-core-clihas zero compile/runtime dependency ongrails.dev.commands.*ApplicationCommandTargetAware.getTarget()so Spring autowiring andskipBootstraphit the real legacy commandAuto-provisioning flags
cliAutoProvisiontruegrails-core-cli,grails-console, and discovered companion-cliartifactslegacyCommandSupportfalsegrails-core-cli-legacyRules:
cliAutoProvision = truecliAutoProvision = trueandlegacyCommandSupport = truelegacyCommandSupport = false(the default) turns off only the Grails 7 bridge; modern companions still auto-provisioncliAutoProvision = falseturns off the whole auto-provisioned CLI tier, including the legacy bridgeExamples:
Project-property equivalents:
grailsCliAutoProvision=falsegrailsLegacyCommandSupport=true(opt in; default is off)Classpath shape
grailsCliLegacyis execution-onlygrailsCliClasspathand test runtime classpaths-clicoordinate still resolveHow legacy commands run
runtimeClasspathscans forMETA-INF/grails.factorieswere removed (nondeterministic in multi-project builds)runCommandtask, or the shell (which routes legacy adapters there):./gradlew runCommand "-Pargs=generate-controller example.Book"-cliartifactCommand mechanisms
create-command/grails.dev.commands.ApplicationCommandMETA-INF/grails.factoriesgrails-core-cli-legacywhen both flags are on (runCommand/ shell)create-script/META-INF/commands/*.groovyMETA-INF/commands/*.ymlCodegen script fix
Hyphenated script filenames (for example
audit-quickstart) now resolve under the filename-derived command name under Groovy 5.Testing
cliAutoProvision = falsedisables the whole tier (including when legacy is explicitly opted in)legacyCommandSupportdefaults tofalselegacyCommandSupport = false/trueproperty casesrunCommandremains availableend-to-endbuild, which is its own Gradle build. The root build never reaches it, so./gradlew buildat the repository root is unaffected, and it is run by its own.github/workflows/end-to-end.yml:end-to-end/legacy-commands- the Grails 8 application under test; opts in withgrails { legacyCommandSupport = true }end-to-end/legacy-commands-plugin- a Grails 8 plugin whose legacy commands are recompiled under Groovy 5build/local-maven(scoped withexclusiveContent) rather than by project substitution, so the tests consume grails-core through real poms and Gradle module metadata the way an application does - including the-clicompanion artifacts, which composite substitution cannot express becausegrails-core-cliis a secondary capability of:grails-corerather than a projectend-to-end/legacy-g7-command-pluginsettings.gradle, its ownmavenCentral()-only repository, and its own JDK 17 declared in.sdkmanrc(no Gradle toolchain, so no surrounding build inherits a second JDK requirement)enforcedPlatform('org.apache.grails:grails-bom:7.0.14'), resolving Groovy4.0.32includeBuild, so composite substitution cannot rewriteorg.apache.grails:grails-coreto this repository's Groovy 5 projects - which is exactly what the fixture exists to preventGrails-Compile-Version/Groovy-Compile-Versionare derived from the resolved compile classpath, not hand-typed, and are asserted by the integration testApplicationCommand(G7-RAN)GrailsApplicationCommandexercising the@Delegateforwarders (file(...),mkdir,render(...)) andapplicationContext(G7-CONTEXT-true)getName()norgetDescription()- the shapecreate-commandgenerated on Grails 7 - so the trait's default name derivation is proven, registering ashello-derived-namerunCommand-only legacy executionMigration
grails { legacyCommandSupport = true }(or-PgrailsLegacyCommandSupport=true), or declaregrailsCliLegacyexplicitly whencliAutoProvisionis disabledorg.apache.grails.core.cli.*and publish a companion-cliartifactgrails-core-cli-legacyexplicitly for compileRelated
end-to-endbuild relocation, the trait-derived-name fixture command, and the remaining review follow-ups land on this branch via Move legacy command compatibility tests into an end-to-end build #16059src/main/scriptsruntime packaging: Move src/main/scripts command resources out of runtime plugin artifacts #16035