Skip to content

Propagate Java toolchain to JavaExec tasks#15403

Merged
jamesfredley merged 3 commits intoapache:7.0.xfrom
jamesfredley:fix/javaexec-toolchain-inheritance
Feb 19, 2026
Merged

Propagate Java toolchain to JavaExec tasks#15403
jamesfredley merged 3 commits intoapache:7.0.xfrom
jamesfredley:fix/javaexec-toolchain-inheritance

Conversation

@jamesfredley
Copy link
Contributor

@jamesfredley jamesfredley commented Feb 17, 2026

Summary

GrailsGradlePlugin.configureForkSettings() configures all JavaExec tasks with system properties, heap sizes, and JVM args, but does not propagate the project's Java toolchain. Gradle's JavaPlugin sets toolchain conventions on JavaCompile, Javadoc, and Test tasks but not on JavaExec tasks. This means forked JVM processes spawned by Grails (dbm-* migration tasks, console, shell, and application context commands) use the JDK running Gradle instead of the project's configured toolchain.

Bug Description

When a Grails project configures a Java toolchain:

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

JavaCompile and Test tasks correctly use JDK 17, but all JavaExec-based tasks (including ApplicationContextCommandTask subclasses like dbm-* migration commands, console, and shell) silently use whatever JDK is running the Gradle daemon.

This causes UnsupportedClassVersionError when the project targets a newer JDK than the one running Gradle, or silent runtime failures when targeting an older JDK with different API behavior.

Reproduction

# With JDK 21 running Gradle, but project configured for JDK 17:
./gradlew checkToolchain
# Output:
# java.version=21.0.x
# matchesToolchain=false

Affected Tasks

All JavaExec-based tasks registered by Grails plugins:

Plugin Task Type Examples
GrailsGradlePlugin JavaExec console, shell
GrailsWebGradlePlugin ApplicationContextCommandTask urlMappingsReport
Database Migration Plugin ApplicationContextCommandTask All 30+ dbm-* tasks
Spring Security Plugin ApplicationContextCommandTask s2-quickstart

Root Cause

Gradle's JavaPlugin (via JavaPluginExtension.configureToolchain()) only calls configureToolchain() on:

  • JavaCompile tasks - javaCompiler.convention(compiler)
  • Javadoc tasks - javadocTool.convention(javadocTool)
  • Test tasks - javaLauncher.convention(launcher)

JavaExec tasks are not included, despite also having a javaLauncher property. This is a known Gradle behavior (not a Gradle bug per se, but a gap that framework plugins need to fill).

GrailsGradlePlugin.configureForkSettings() already iterates over all JavaExec tasks to set system properties and heap sizes, making it the natural place to also propagate the toolchain.

Fix

Added configureToolchainForForkTasks(Project project) method to GrailsGradlePlugin, called at the end of configureForkSettings():

protected void configureToolchainForForkTasks(Project project) {
    project.afterEvaluate {
        def javaExtension = project.extensions.findByType(JavaPluginExtension)
        if (javaExtension?.toolchain?.languageVersion?.isPresent()) {
            def toolchainService = project.extensions.getByType(JavaToolchainService)
            def launcher = toolchainService.launcherFor(javaExtension.toolchain)

            project.tasks.withType(JavaExec).configureEach { JavaExec task ->
                task.javaLauncher.convention(launcher)
            }
        }
    }
}

Design decisions

  1. Only targets JavaExec tasks - Test tasks are already handled by Gradle's JavaPlugin
  2. Uses convention() not set() - allows individual tasks to override via javaLauncher.set(...) if needed
  3. Guarded by languageVersion?.isPresent() - only activates when the user explicitly configures a toolchain; no-toolchain projects get zero behavior change
  4. Uses afterEvaluate - ensures the toolchain configuration is fully resolved before reading it
  5. Gets JavaToolchainService from project extensions - avoids changing the plugin constructor signature

Files changed

File Change
grails-gradle/plugins/.../GrailsGradlePlugin.groovy Added configureToolchainForForkTasks() method (35 lines including Javadoc), called from configureForkSettings()
grails-gradle/plugins/build.gradle Added JUnit Jupiter test dependencies (BOM-managed versions)
grails-gradle/plugins/src/test/.../GrailsGradlePluginToolchainTest.java New test class (8 tests)

Test Coverage

8 JUnit Jupiter tests via Gradle TestKit (cannot use Spock due to Groovy 3/4 capability conflict in the plugins module - grails-gradle-tasks transitively pulls org.apache.groovy:groovy:4.0.30 which conflicts with Gradle's built-in org.codehaus.groovy:groovy:3.0.25):

Toolchain propagation (4 tests)

Test Verifies
javaExecInheritsToolchain Custom JavaExec task gets javaLauncher convention from project toolchain
testTaskInheritsToolchain Test task toolchain still works (regression check for Gradle's built-in behavior)
applicationContextCommandTaskInheritsToolchain urlMappingsReport (ApplicationContextCommandTask extends JavaExec) inherits toolchain via grails-web plugin
conventionAllowsOverride javaLauncher.set(...) on individual task overrides the convention

Backwards compatibility (2 tests)

Test Verifies
javaExecWorksWithoutToolchain Plugin applies without errors when no toolchain is configured
webPluginWorksWithoutToolchain GrailsWebGradlePlugin applies without errors when no toolchain is configured

Fork settings preservation (2 tests)

Test Verifies
forkSettingsApplyDefaults grails.env system property and 768m heap defaults still applied to JavaExec tasks
customHeapSizesPreserved Custom minHeapSize/maxHeapSize on individual tasks not overridden by fork settings

Example Application

Repository: https://github.com/jamesfredley/grails-javaexec-toolchain-bug

A minimal Grails 7 app with a checkToolchain task that prints the JDK version and whether it matches the configured toolchain. Without the fix, matchesToolchain=false when the Gradle daemon JDK differs from the toolchain target.

Run instructions

git clone https://github.com/jamesfredley/grails-javaexec-toolchain-bug.git
cd grails-javaexec-toolchain-bug
./gradlew checkToolchain

Environment Information

Component Version
Grails 7.0.8-SNAPSHOT
GORM 7.0.8-SNAPSHOT
Spring Boot 3.5.10
Groovy 4.0.30 (app) / 3.0.25 (Gradle plugin compilation)
JDK 17 (Amazon Corretto 17.0.18)
Gradle 8.14.4

Version

7.0.x

Gradle's JavaPlugin sets toolchain conventions on JavaCompile, Javadoc,
and Test tasks but not on JavaExec tasks. This means forked JVM processes
(dbm-* migration tasks, console, shell, application context commands) use
the JDK running Gradle instead of the project's configured toolchain.

Add configureToolchainForForkTasks() to GrailsGradlePlugin that propagates
the project toolchain to all JavaExec tasks via javaLauncher.convention().
Uses convention() so individual tasks can still override via set().
Only activates when java.toolchain.languageVersion is explicitly configured,
preserving existing behavior for users without toolchains.

Test coverage: 8 JUnit Jupiter tests via Gradle TestKit covering toolchain
propagation (JavaExec, Test, ApplicationContextCommandTask, convention
override), backwards compatibility (no-toolchain), and fork settings
preservation (system properties, heap sizes).
Copilot AI review requested due to automatic review settings February 17, 2026 15:30
@jamesfredley jamesfredley self-assigned this Feb 17, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the Grails Gradle plugin so JavaExec-based tasks (console/shell/dbm/application context commands) inherit the project’s configured Java toolchain, avoiding mismatches between the Gradle daemon JDK and the project toolchain.

Changes:

  • Add configureToolchainForForkTasks(Project) to apply the project toolchain as a javaLauncher convention for all JavaExec tasks when a toolchain languageVersion is configured.
  • Add Gradle TestKit JUnit Jupiter tests validating toolchain propagation and preserving existing fork settings behavior.
  • Update the plugins module build to support JUnit Jupiter tests (and apply shared test Gradle configuration).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy Propagates Java toolchain to JavaExec tasks via javaLauncher.convention(...).
grails-gradle/plugins/src/test/java/org/grails/gradle/plugin/core/GrailsGradlePluginToolchainTest.java Adds TestKit coverage for toolchain propagation + fork settings preservation.
grails-gradle/plugins/build.gradle Adds JUnit Jupiter dependencies and applies shared test configuration script.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

* Reads {@code projectVersion} from the root {@code gradle.properties} so the
* test stays in sync with the actual build and is not tied to a hardcoded version.
*/
private static String resolveGrailsVersion() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's pass this value via the gradle configuration of the Test itself? No reason to be dynamically looking this up when it's available at the time of configuration in the test task.

Copy link
Contributor Author

@jamesfredley jamesfredley Feb 19, 2026

Choose a reason for hiding this comment

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

Done. projectVersion and currentJdk are now passed as system properties from the test task configuration in build.gradle, and read via System.getProperty() in the GradleSpecification base class. No more runtime discovery.

Files.writeString(projectDir.resolve("gradle.properties"), "grailsVersion=" + GRAILS_VERSION + "\n");
}

private BuildResult runBuild(String... args) {
Copy link
Contributor

Choose a reason for hiding this comment

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

There's a pretty good base class in the grails publish project that is meant to handle a lot of this boilerplate. Let's just copy it to grails-core

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. Added GradleSpecification base class adapted from grails-gradle-publish. It handles temp dir setup, GradleRunner configuration, and copying test project fixtures from src/test/resources/test-projects/ with placeholder replacement for __PROJECT_VERSION__ and __CURRENT_JDK__.

@Test
@DisplayName("JavaExec tasks inherit project toolchain")
void javaExecInheritsToolchain() throws IOException {
Files.writeString(projectDir.resolve("build.gradle"),
Copy link
Contributor

Choose a reason for hiding this comment

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

If you copy the spec from grails publish, these can be actual files checked in that are easy to modify over time instead of being hard coded as strings

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. All 8 test scenarios are now file-based fixtures under src/test/resources/test-projects/ (toolchain-javaexec, toolchain-test, toolchain-command, toolchain-override, no-toolchain-javaexec, no-toolchain-web, fork-settings-defaults, fork-settings-custom). Each has its own build.gradle, settings.gradle, gradle.properties, and grails-app/conf/application.yml.

*
* @since 7.0.8
*/
class GrailsGradlePluginToolchainTest {
Copy link
Contributor

Choose a reason for hiding this comment

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

Why not use spock?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. Rewrote the entire test as GrailsGradlePluginToolchainSpec extending GradleSpecification. Resolved the Groovy conflict by excluding org.apache.groovy from the test classpath (see comment on build.gradle). All 8 tests pass as Spock specs.


// Testing - Gradle TestKit is auto-added by java-gradle-plugin
// NOTE: Cannot use Spock here because grails-gradle-tasks transitively
// pulls org.apache.groovy:groovy:4.0.30 which conflicts with
Copy link
Contributor

Choose a reason for hiding this comment

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

why is grails-gradle-tasks using groovy 4? just exclude it since we target groovy 3 with gradle.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

grails-gradle-tasks/build.gradle line 42 explicitly declares implementation 'org.apache.groovy:groovy:4.0.30' because its source code uses Groovy 4 APIs (it's not a Gradle plugin itself, just utility classes consumed by the plugins module). Added an exclusion of the org.apache.groovy group from the test configurations in plugins/build.gradle so Spock runs against Gradle's bundled Groovy 3 without conflicts.

@jamesfredley jamesfredley marked this pull request as draft February 17, 2026 19:58
Address reviewer feedback: migrate from JUnit Jupiter to Spock,
replace inline build scripts with file-based test project fixtures,
add GradleSpecification base class for Gradle TestKit tests, pass
projectVersion via Gradle test config, and exclude Groovy 4 from
test classpath to resolve the Spock/Gradle Groovy conflict.

Assisted-by: Claude Code <Claude@Claude.ai>
@jamesfredley jamesfredley marked this pull request as ready for review February 19, 2026 00:24
@jamesfredley jamesfredley dismissed jdaugherty’s stale review February 19, 2026 00:25

All comments addressed in latest commit

@jamesfredley jamesfredley moved this to In Progress in Apache Grails Feb 19, 2026
@jamesfredley jamesfredley added this to the grails:7.0.8 milestone Feb 19, 2026
}
}

tasks.withType(Test).configureEach {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd suggest we move this to test-config. The additional properties don't hurt and it' sbest to be consistent.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. Moved projectVersion and currentJdk system properties into test-config.gradle and removed the standalone block from plugins/build.gradle. All 8 tests pass.

Copy link
Contributor

@jdaugherty jdaugherty left a comment

Choose a reason for hiding this comment

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

Minor comment but otherwise LGTM

Move projectVersion and currentJdk system properties from the plugins
build.gradle into the shared test-config.gradle for consistency across
all grails-gradle modules.

Assisted-by: Claude Code <Claude@Claude.ai>
@jamesfredley jamesfredley merged commit 63beaf3 into apache:7.0.x Feb 19, 2026
32 checks passed
@jamesfredley jamesfredley deleted the fix/javaexec-toolchain-inheritance branch February 19, 2026 20:27
@github-project-automation github-project-automation bot moved this from In Progress to Done in Apache Grails Feb 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants

Comments