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 14 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.6311% 52.6284% -0.0027%
- Complexity 18436 18439 +3
==================================================
Files 2037 2038 +1
Lines 96500 96524 +24
Branches 16860 16858 -2
==================================================
+ Hits 50789 50799 +10
- Misses 38284 38297 +13
- Partials 7427 7428 +1
🚀 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
|
|
@codeconsole Looking back the code history: we used a separate task previously and the commit message where we reverted this was: How does this change then solve this? |
|
Do we know why the compile configuration can no longer be changed at execution? This seems like an artificial limitation by Gradle and I'm wondering if we should instead open a bug ticket on this. |
…scope
`GroovyCompileOptions.getConfigurationScript()` is annotated
`@ReplacedBy("configurationScriptFile")`: the `File` accessor is superseded by a
`RegularFileProperty`. Assign through that property instead. The eager setter
delegates to it, so a script a build assigns either way is still picked up.
The ordering test now also asserts what the generator must *not* be coupled to. An
earlier task-based design declared `configurations.runtimeClasspath` as a task input,
which pulled the runtime classpath into the generator's up-to-date check and produced
task dependency errors when it changed (9a529d1). This generator declares no
inputs at all and depends only on the compile classpath's build dependencies, which
`compileGroovy` already depends on, so the runtime classpath enters neither the
dependency chain nor an up-to-date check.
…asspath `isClassOnClasspath` opened jars on the compile classpath to decide whether to add the `grails.gorm.annotation` and `grails.plugin.scaffolding.annotation` star imports. That single probe is what forced every other awkward property of this wiring: the script could only be built at execution time, which is why it was computed in a task action, why the task opted out of state tracking, and why it needed an ordering edge to whatever produced the classpath. The probe bought nothing. A star import of a package that is not on the classpath contributes no classes and is not an error in Groovy — verified on 5.0.8 for both dynamic and static compilation — so the imports are now added unconditionally when `importGrailsCommonAnnotations` is set, exactly as `jakarta.validation.constraints` already was. With the classpath out of the picture: - The generator takes the finished script as a single input property and writes it. It is up-to-date checked and cacheable rather than `doNotTrackState`, and needs no dependency on the compile classpath, so no configuration enters its dependency chain or its up-to-date check. - The script is built while the compile task is configured, which restores an input declaration lost when it moved into a task action: `GrailsPluginGradlePlugin` bakes the project version and name into compiled classes as AST metadata and registers them as inputs of the compile task. Since 7950482 that ran too late to take effect, so changing a plugin's version left the stamped metadata stale. Regression tests cover all four properties; each fails against 7950482.
A build may point `configurationScript` at a file another task generates. Taking the property over meant that producer was no longer ordered against the generator, so on a clean build the generator read the file before it existed and the build's own script was silently dropped from the combined output — present only from the second build onwards, off a stale file. The generator now depends on that producer. Task graph construction runs before the property is taken over, so the property still carries whatever the build set, and with it the producing task. Wrapping it in a FileCollection is what asks Gradle for the producer: depending on the property directly tries to convert the file itself into a task, and the collection has to be guarded on `present` because building one from a property with no value fails while dependencies are being resolved.
The script was written by an ad-hoc task configured with `inputs.property` and `outputs.file` in the plugin. It is now a `GrailsCompilerConfigScriptTask` with an `@Input Property<String>` and an `@OutputFile RegularFileProperty`, matching how the rest of this plugin's tasks are written and making the contract legible from the task rather than from its registration site. Behaviour is unchanged; the input property is now named for the task's own property. Tests added for two properties that were not covered: one generator per source set with a real up-to-date check (unchanged build is UP-TO-DATE, changed imports are not), and that the wiring stores and reuses a configuration cache entry.
…pt' into fix/gradle-97-groovy-config-script
|
runtimeClasspath. The generator reads no classpath and declares no inputs: 9a529d1 declared Setting it at execution. Not artificial: Behaviour changes, both in the star imports — opt-in, and the flags default to off:
|
`grails { importJavaTime = true }` star-imported `java.time`. Groovy 5 imports that
package by default, so on Grails 8 the flag added an import the compiler already had.
Measured against the Groovy this repository builds with:
Groovy 4.0.28, no configuration script -> unable to resolve class LocalDate
Groovy 5.0.8, no configuration script -> compiles
with `java.nio.file.Path` as a control to confirm the probe discriminates, and
confirmed end to end by compiling a Grails domain class that uses LocalDate,
LocalDateTime and Duration with no import and no flag set.
Removed rather than deprecated: 8.0.0 is unreleased, so there is no compatibility to
keep, and a flag that silently does nothing is worse than one that is gone. The
upgrade guide says so and tells anyone who set it to drop the line.
Also adds the first test of what the imports feature actually generates. It had none,
and this is the second change to that block.
The removal note was in the 7.1 upgrade guide, which is the wrong place twice over: that guide describes upgrading to 7.1, where `importJavaTime` genuinely worked because Grails 7 is Groovy 4, and someone hitting `Could not set unknown property 'importJavaTime'` on the way to Grails 8 would not be reading it. The 7.1 guide is restored to what it said, and upgrading80x gains a section covering both changes to the star imports: the removal of the flag, and that `importGrailsCommonAnnotations` no longer probes the compile classpath before adding the two annotation packages.
`validatePlugins` requires every task type to state whether it is cacheable, and the
new task type said nothing, so `Build Gradle Plugins` failed on both JDK 21 and 25:
Type 'org.grails.gradle.plugin.core.GrailsCompilerConfigScriptTask' must be
annotated either with @CacheableTask or with @DisableCachingByDefault
Caching is not worth it here — the task writes a few hundred bytes from a string it
already holds, which is cheaper than a cache round trip — so it declares
`@DisableCachingByDefault` with that reason.
✅ All tests passed ✅🏷️ Commit: 88538f5 Learn more about TestLens at testlens.app. |
Gradle 9.7 made GroovyCompileOptions a lazy Property, and Gradle now
finalizes task properties before any task action runs. GrailsGradlePlugin
assigns groovyOptions.configurationScript from a doFirst, which breaks
every Groovy compile on Gradle 9.7+:
> The value for task ':compileGroovy' property
'groovyOptions.configurationScriptFile' is final and cannot be
changed any further.
Tracked upstream at apache/grails-core#16114 (still open, targets 8.0.x,
not yet released). Revert just the wrapper bump; keep the other
dependency updates from this group.
Problem
GrailsGradlePluginassignedgroovyOptions.configurationScriptfrom adoFirst. 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:There is no user-side workaround and no Gradle opt-out flag.
Fix
A
GrailsCompilerConfigScriptTaskproduces the combined script, and the compile task depends on it. The script is built entirely fromgrails { }state and reads no classpath, so the task is a single@Inputstring in and a file out: up-to-date checked, and it stores and reuses a configuration cache entry.GrailsGroovyCompilerConfigSpeccovers the lifecycle: aGroovyCompileregistered after the project is evaluated, aconfigurationScriptthe build assigns from any configuration callback, one produced by another task, a generator per source set, and a plugin's version and name staying inputs of its compile task.Behaviour changes
Both are in the star imports, which stay opt-in —
importGrailsCommonAnnotationsandstarImportsdefault to off.importGrailsCommonAnnotationsnow addsgrails.gorm.annotationandgrails.plugin.scaffolding.annotationwithout first probing the compile classpath. A star import of a package that is not on the classpath contributes no classes and is not an error in Groovy.importJavaTimeis removed. Groovy 5 importsjava.timeby default, so it did nothing on Grails 8. A build that sets it fails withCould not set unknown property 'importJavaTime'; drop the line.Both are documented in the Grails 8 upgrade guide, section 45.
Gradle 9.7.0
Grails builds on 9.7.0:
.sdkmanrc,gradleToolingApiVersion, and the wrapper for root,build-logic,grails-gradle,grails-forge,end-to-end, plus thegrails-shell-cligradle-sample fixture.end-to-end/legacy-g7-command-pluginstays on Gradle 8.14.5, the version Grails 7 pins for that fixture.Applications created by the forge and the profile CLIs get a 9.7.0 wrapper as well. They can only move to 9.7 together with the fix above; without it the first
compileGroovyfails.