Skip to content

Fix Groovy compilation on Gradle 9.7 and upgrade to 9.7.0 - #16114

Open
codeconsole wants to merge 4 commits into
apache:8.0.xfrom
codeconsole:fix/gradle-97-groovy-config-script
Open

Fix Groovy compilation on Gradle 9.7 and upgrade to 9.7.0#16114
codeconsole wants to merge 4 commits into
apache:8.0.xfrom
codeconsole:fix/gradle-97-groovy-config-script

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Problem

GrailsGradlePlugin.configureGroovyCompiler assigns groovyOptions.configurationScript from a doFirst on each GroovyCompile task. Gradle finalizes task properties before any task action runs, so from Gradle 9.7 — where GroovyCompileOptions became a lazy property — every Groovy compilation in every Grails project fails:

* What went wrong:
Execution failed for task ':compileGroovy' (registered by plugin class 'org.gradle.api.plugins.GroovyBasePlugin').
> The value for task ':compileGroovy' property 'groovyOptions.configurationScriptFile' is final and cannot be changed any further.
at org.gradle.api.tasks.compile.GroovyCompileOptions.setConfigurationScript(GroovyCompileOptions.java:216)
at org.grails.gradle.plugin.core.GrailsGradlePlugin$_configureGroovyCompiler_closure4$_closure37.doCall(GrailsGradlePlugin.groovy:233)

This blocks Grails 8 on Gradle 9.7 entirely — there is no user-side workaround, and no Gradle opt-out flag.

Fix

The combined compiler configuration script is now produced by a dedicated task that the compile task depends on.

Assigning the property during configuration is necessary but not sufficient: Gradle then treats the script as an input file that must exist before the compile task runs, which fails on a clean build because doFirst runs after input validation. A producing task is what makes the file exist at the right moment.

The generator is marked doNotTrackState. Generating the script needs the resolved compile classpath, and declaring that as an input would pull the runtimeClasspath into the task's up-to-date check. The script is cheap to build, so it is regenerated on every build instead.

Lifecycle guarantees

Moving off doFirst trades execution-time reads for configuration-time wiring, which is narrower in three ways. Each is handled explicitly:

  • Wiring is live. Generator tasks are registered from sourceSets.configureEach, so a GroovyCompile created after the project is evaluated — by a source set added from projectsEvaluated, or from another plugin's afterEvaluate — is still covered. They cannot be registered from a GroovyCompile configuration action: mutating the task container while it is being configured throws TaskCreationException.
  • The generator is ordered against the classpath it reads. Its action resolves compileTask.classpath and reads jar entries from it, so it depends on that classpath's own build dependencies. dependsOn rather than an input declaration, to keep the runtimeClasspath out of the up-to-date check.
  • A configurationScript set by the build is folded in, not replaced. Capture and assignment happen once the task graph is ready — the last point before execution, after every configuration callback has run — so an assignment from a later callback is merged rather than silently dropping the Grails imports.

GrailsGroovyCompilerConfigSpec covers all three.

Gradle 9.7.0

Grails itself now builds on 9.7.0: .sdkmanrc, gradleToolingApiVersion, and the wrapper for every Gradle build in the repository (root, build-logic, grails-gradle, grails-forge, end-to-end), plus the grails-shell-cli gradle-sample fixture. end-to-end/legacy-g7-command-plugin deliberately stays on Gradle 8.14.5, the version Grails 7 pins for that fixture.

Applications created by the forge and by the profile CLIs get a 9.7.0 wrapper as well — the gradleWrapperProperties template and the wrapper binaries the forge copies onto a generated project, and the base and profile skeletons that every other profile inherits from. Generated applications can only move to 9.7 together with the compiler config fix above; without it, the first compileGroovy fails.

Notes

  • Verified against a multi-project Grails 8 application (~20 subprojects, Groovy + GSP + plugins) on both Gradle 9.6.1 and 9.7.0: full bootJar, and clean plus compilation in a single invocation — the case a doFirst cannot satisfy.
  • The classpath probes that add grails.gorm.annotation / grails.plugin.scaffolding.annotation star imports only run when an application opts in via grails { importGrailsCommonAnnotations = true } (or importJavaTime / starImports). These default to off, so most builds generate a script with no Grails section.
  • The graph-ready capture has not been exercised under the configuration cache, which is off in this repository.

`configureGroovyCompiler` assigned `groovyOptions.configurationScript` from a
`doFirst` on each GroovyCompile task. Gradle finalizes task properties before
any task action runs, so from Gradle 9.7 — where GroovyCompileOptions became a
lazy property — every Groovy compilation fails:

    Execution failed for task ':compileGroovy'.
    > The value for task ':compileGroovy' property
      'groovyOptions.configurationScriptFile' is final and cannot be changed
      any further.

Assigning the property during configuration is not enough on its own: Gradle
then treats the script as an input file that must exist before the compile task
runs, which fails on a clean build because `doFirst` runs after input
validation. So the combined script is now produced by a dedicated task that the
compile task depends on.

That task is marked `doNotTrackState`, which keeps the property that motivated
the original `doFirst`: generating the script needs the resolved compile
classpath, and declaring it as an input would pull the runtimeClasspath into the
task's up-to-date check. The script is cheap to build, so it is simply
regenerated on every build.

Wiring moves to `afterEvaluate` so a `configurationScript` set by the build
script is already in place and gets folded into the combined script rather than
clobbered — the merge the old execution-time read performed. Task names are read
via `TaskCollection.names`, which does not realize the tasks.

Verified against a multi-project Grails 8 application on both Gradle 9.6.1 and
9.7.0: full `bootJar`, and `clean` plus compilation in a single invocation.
@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 49 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.3204%. Comparing base (a1e526f) to head (8b8d063).
⚠️ Report is 19 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...rails/gradle/plugin/core/GrailsGradlePlugin.groovy 0.0000% 49 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16114        +/-   ##
==================================================
- Coverage     52.3431%   52.3204%   -0.0227%     
  Complexity      18296      18296                
==================================================
  Files            2036       2036                
  Lines           96347      96406        +59     
  Branches        16829      16840        +11     
==================================================
+ Hits            50431      50440         +9     
- Misses          38492      38540        +48     
- Partials         7424       7426         +2     
Files with missing lines Coverage Δ
...rails/gradle/plugin/core/GrailsGradlePlugin.groovy 0.0000% <0.0000%> (ø)

... and 5 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.

@codeconsole codeconsole changed the title Generate the Groovy compiler config script in its own task (fixes Gradle 9.7) Gradle 9.7.0 Upgrade - Generate the Groovy compiler config script in its own task (fixes Gradle 9.7) Aug 7, 2026
Moves grails-core's own build onto 9.7.0: `.sdkmanrc`, `gradleToolingApiVersion`,
and the wrapper for every Gradle build in the repository — root, build-logic,
grails-gradle, grails-forge, end-to-end — plus the grails-shell-cli gradle-sample
fixture.

The shared wrappers were regenerated with `gradle -p gradle-bootstrap`; the
gradle-sample fixture, which bootstrap does not reach, was refreshed from the
same output so the launcher scripts and wrapper jar stay byte-identical
everywhere.

`end-to-end/legacy-g7-command-plugin` deliberately stays on Gradle 8.14.5, the
version Grails 7 pins for that fixture.

The wrapper task regenerates the properties files from scratch, so the
"keep this synced" checklist comments were restored afterwards.
Applications created by the forge and by the profile CLIs now get a 9.7.0
wrapper instead of 9.6.0.

For the forge, that means the `gradleWrapperProperties` template plus the three
binaries `Gradle.java` copies onto the generated project — `gradlew`,
`gradlew.bat`, and `gradle/wrapper/gradle-wrapper.jar`.

For the profile CLIs, only the `base` and `profile` skeletons carry wrapper
assets; `web`, `rest-api`, and `plugin` inherit them from `base` through
`profileRuntimeApi`, and `web-plugin` / `rest-api-plugin` chain through those,
so every application type picks the new wrapper up.

Generated applications can only move to 9.7 together with the compiler config
script fix earlier in this branch — without it every Groovy compilation in a
Grails project fails on Gradle 9.7.
@codeconsole
codeconsole requested review from borinquenkid, jamesfredley, jdaugherty, matrei and sbglasius and removed request for jdaugherty and matrei August 7, 2026 21:41

@jamesfredley jamesfredley 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.

I reproduced three lifecycle failures against this exact head. Inline comments include the commands/observable failures and focus only on correctness gaps.

// Wiring happens after evaluation so a configurationScript set by the build script is already
// in place and gets folded into the combined file rather than clobbered. Names are read via
// TaskCollection.names, which does not realize the tasks.
project.afterEvaluate {

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 one-time snapshot regresses the previous live configureEach behavior. A GroovyCompile registered from a later projectsEvaluated callback is absent from names and receives no generator/configuration script; I reproduced this against this head (LATE_GROOVY_UNCONFIGURED=true, with no generateLateGroovyGrailsCompilerConfig task). Please retain live wiring without registering a generator from inside the task-container callback (for example, a deferred task rule plus string dependsOn) and add a late-task TestKit case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 8b8d063. TaskCollection.names is an immutable snapshot, so a GroovyCompile registered after the afterEvaluate got no generator and no configuration script, silently.

Generator tasks are now registered from sourceSets.configureEach, which is live — source sets are what create GroovyCompile tasks, so a source set added from projectsEvaluated is covered.

One correction on the suggested remedy: a task rule plus string dependsOn does not work. Rules are not consulted when resolving a dependsOn name — the build fails at graph construction with Task with name 'generateCompileGroovyGrailsCompilerConfig' not found. And registering the generator from inside the task-container callback is what the snapshot was avoiding in the first place: it throws TaskCreationException: Could not create task ':compileGroovy'. Hooking the source-set container sidesteps both.

Test: GrailsGroovyCompilerConfigSpeca GroovyCompile registered after the project is evaluated still gets a generator.

// the inputs to this task would effectively be the runtimeClasspath, dependency
// problems can arise if another task changes the runtimeClasspath. Generating the
// script is cheap, so skip state tracking and regenerate on every build instead.
t.doNotTrackState('Depends on the resolved compile classpath; cheap to regenerate')

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.

doNotTrackState suppresses validation but does not establish execution ordering. The doLast calls getGroovyCompilerScript, which scans compileTask.classpath, while this generator has no dependency on tasks producing those classpath entries. I forced a classpath producer to run after the generator on a clean build; the generator omitted grails.gorm.annotation, then compileGroovy failed with unable to resolve class CreatedDate. Please model the classpath/base script as inputs and depend on their producer tasks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right that doNotTrackState establishes no ordering — the generator declared no dependencies at all while its action resolves compileTask.classpath and reads jar entries from it. Fixed in 8b8d063: it now depends on the classpath's own build dependencies.

I used dependsOn rather than declaring the classpath as an input, because modelling it as an input reintroduces exactly what doNotTrackState is there to prevent — it drags the runtimeClasspath into the up-to-date check. dependsOn gets the ordering without the tracking.

For the record on severity: I could reproduce the missing edge, but not the failure. In the natural task graph Gradle schedules the producing jar before the generator, and the two probed classes normally come from external jars already in the module cache. That matches your note that you had to force the ordering. Still worth closing — it was a real latent hazard.

Test: GrailsGroovyCompilerConfigSpecthe generator runs after the tasks that produce the compile classpath.

combinedFile.write(combinedScripts)
c.groovyOptions.configurationScript = combinedFile
compileTask.configure { GroovyCompile c ->
userConfigurationScript[0] = c.groovyOptions.configurationScript

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 capture/replace is not stable for later configuration. When a user assigns configurationScript from a later projectsEvaluated callback, that assignment overwrites the combined script after this action. Reproduced result: the user's LocalDate import worked, but the generated Grails CreatedDate import disappeared and compilation failed. Please capture the final configured script at a graph-safe point (or wire it provider-first) and cover a later-callback assignment in TestKit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 8b8d063. Capturing during afterEvaluate meant an assignment from any later callback simply overwrote the combined file and the Grails imports disappeared with no error.

Capture and assignment now happen at taskGraph.whenReady — the last point before execution and after every configuration callback has run, so the user's final value is what gets folded in. The property is still assignable there on 9.7.

Scope note for anyone reading later: the ordinary path — assigning configurationScript directly in the build script — was already merging correctly. Only assignment from a later callback was affected.

Test: GrailsGroovyCompilerConfigSpeca configurationScript assigned from a later callback is folded in, not clobbered.

Moving the compiler configuration script off a `doFirst` traded three
execution-time guarantees for configuration-time snapshots. Each one is restored:

Late-registered compile tasks are wired again. `TaskCollection.names` read inside
`afterEvaluate` is an immutable snapshot, so a `GroovyCompile` created later — by a
source set added from `projectsEvaluated`, or from another plugin's `afterEvaluate` —
got no generator and no configuration script, silently. Generator tasks are now
registered from `sourceSets.configureEach`, which is live. They cannot be registered
from a `GroovyCompile` configuration action: mutating the task container while it is
being configured throws `TaskCreationException`, which is what the snapshot was
working around.

The generator is ordered against the compile classpath it reads. `doNotTrackState`
suppresses up-to-date checking but establishes no ordering, and the generator
declared no dependencies at all while its action resolves `compileTask.classpath`
and reads jar entries from it. It now depends on the classpath's own build
dependencies, so the probes see the artifacts. `dependsOn` rather than an input
declaration, to keep the runtimeClasspath out of the up-to-date check — the property
`doNotTrackState` was added to protect.

A `configurationScript` assigned after wiring is folded in rather than dropped. The
user's script was captured during `afterEvaluate`, so an assignment from any later
callback simply overwrote the combined file and the Grails imports disappeared with
no error. Capture and assignment now happen once the task graph is ready, the last
point before execution, after every configuration callback has run.

The capture map is a per-project instance field, not static, so a script cannot leak
into a later build in the same daemon.
@codeconsole

Copy link
Copy Markdown
Contributor Author

Thanks — all three were real. Fixed in 8b8d063 with a regression test each in GrailsGroovyCompilerConfigSpec; each fails against the previous commit.

Common root cause: the fix had to move from execution-time reads to configuration-time snapshots, because Gradle 9.7 forbids assigning groovyOptions.configurationScript from doFirst. The snapshots were narrower than the doFirst they replaced. The wiring is now live (sourceSets.configureEach), ordered against the classpath it reads, and captures the user's script at taskGraph.whenReady instead of during afterEvaluate.

:grails-gradle:test, codeStyle, and a full root build -PskipTests are green on 9.7.0.

@codeconsole codeconsole changed the title Gradle 9.7.0 Upgrade - Generate the Groovy compiler config script in its own task (fixes Gradle 9.7) Fix Groovy compilation on Gradle 9.7 and upgrade to 9.7.0 Aug 8, 2026
@testlens-app

testlens-app Bot commented Aug 9, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 8b8d063
▶️ Tests: 59151 executed
⚪️ Checks: 60/60 completed


Learn more about TestLens at testlens.app.

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

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants