Fix Groovy compilation on Gradle 9.7 and upgrade to 9.7.0 - #16114
Fix Groovy compilation on Gradle 9.7 and upgrade to 9.7.0#16114codeconsole wants to merge 4 commits into
Conversation
`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 Report❌ Patch coverage is
Additional details and impacted files@@ 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
🚀 New features to boost your workflow:
|
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.
jamesfredley
left a comment
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: GrailsGroovyCompilerConfigSpec — a 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') |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: GrailsGroovyCompilerConfigSpec — the 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: GrailsGroovyCompilerConfigSpec — a 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.
|
Thanks — all three were real. Fixed in 8b8d063 with a regression test each in Common root cause: the fix had to move from execution-time reads to configuration-time snapshots, because Gradle 9.7 forbids assigning
|
✅ All tests passed ✅🏷️ Commit: 8b8d063 Learn more about TestLens at testlens.app. |
Problem
GrailsGradlePlugin.configureGroovyCompilerassignsgroovyOptions.configurationScriptfrom adoFirston eachGroovyCompiletask. Gradle finalizes task properties before any task action runs, so from Gradle 9.7 — whereGroovyCompileOptionsbecame a lazy property — every Groovy compilation in every Grails project fails: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
doFirstruns 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 theruntimeClasspathinto 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
doFirsttrades execution-time reads for configuration-time wiring, which is narrower in three ways. Each is handled explicitly:sourceSets.configureEach, so aGroovyCompilecreated after the project is evaluated — by a source set added fromprojectsEvaluated, or from another plugin'safterEvaluate— is still covered. They cannot be registered from aGroovyCompileconfiguration action: mutating the task container while it is being configured throwsTaskCreationException.compileTask.classpathand reads jar entries from it, so it depends on that classpath's own build dependencies.dependsOnrather than an input declaration, to keep theruntimeClasspathout of the up-to-date check.configurationScriptset 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.GrailsGroovyCompilerConfigSpeccovers 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 thegrails-shell-cligradle-sample fixture.end-to-end/legacy-g7-command-plugindeliberately 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
gradleWrapperPropertiestemplate and the wrapper binaries the forge copies onto a generated project, and thebaseandprofileskeletons that every other profile inherits from. Generated applications can only move to 9.7 together with the compiler config fix above; without it, the firstcompileGroovyfails.Notes
bootJar, andcleanplus compilation in a single invocation — the case adoFirstcannot satisfy.grails.gorm.annotation/grails.plugin.scaffolding.annotationstar imports only run when an application opts in viagrails { importGrailsCommonAnnotations = true }(orimportJavaTime/starImports). These default to off, so most builds generate a script with no Grails section.