Skip to content

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

Open
codeconsole wants to merge 14 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 14 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 assigned groovyOptions.configurationScript from a doFirst. 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:

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

There is no user-side workaround and no Gradle opt-out flag.

Fix

A GrailsCompilerConfigScriptTask produces the combined script, and the compile task depends on it. The script is built entirely from grails { } state and reads no classpath, so the task is a single @Input string in and a file out: up-to-date checked, and it stores and reuses a configuration cache entry.

GrailsGroovyCompilerConfigSpec covers the lifecycle: a GroovyCompile registered after the project is evaluated, a configurationScript the 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 — importGrailsCommonAnnotations and starImports default to off.

  • importGrailsCommonAnnotations now adds grails.gorm.annotation and grails.plugin.scaffolding.annotation without 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.
  • importJavaTime is removed. Groovy 5 imports java.time by default, so it did nothing on Grails 8. A build that sets it fails with Could 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 the grails-shell-cli gradle-sample fixture. end-to-end/legacy-g7-command-plugin stays 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 compileGroovy fails.

`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 55 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.6284%. Comparing base (a83f874) to head (88538f5).

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

Impacted file tree graph

@@                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     
Files with missing lines Coverage Δ
...g/grails/gradle/plugin/core/GrailsExtension.groovy 54.3478% <ø> (ø)
.../plugin/core/GrailsCompilerConfigScriptTask.groovy 0.0000% <0.0000%> (ø)
...rails/gradle/plugin/core/GrailsGradlePlugin.groovy 0.0000% <0.0000%> (ø)

... and 4 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
@codeconsole codeconsole added this to the grails:8.0.0-RC1 milestone Aug 13, 2026
@jdaugherty

Copy link
Copy Markdown
Contributor

@codeconsole Looking back the code history: we used a separate task previously and the commit message where we reverted this was:

        fix: rework the configuration for the compileGroovy task to prevent task dependency errors if the runtimeclasspath is changed

How does this change then solve this?

@jdaugherty

Copy link
Copy Markdown
Contributor

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

codeconsole commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

runtimeClasspath. The generator reads no classpath and declares no inputs:

GENERATOR_DEPENDS_ON_COMPILE_CLASSPATH=false
GENERATOR_DEPENDS_ON_RUNTIME_CLASSPATH=false
GENERATOR_DECLARED_INPUT_FILES=0

9a529d1 declared configurations.runtimeClasspath as a task input, so it was resolved and fingerprinted for the up-to-date check. This one takes the finished script as a single @Input string, built from grails { } state rather than by probing jars, so no configuration reaches its dependency chain or its up-to-date check. Unchanged build is UP-TO-DATE; changing the imports regenerates.

Setting it at execution. Not artificial: getConfigurationScript() is annotated @ReplacedBy("configurationScriptFile"), and the replacement is a RegularFileProperty. Lazy task properties finalize before execution so inputs can be fingerprinted for up-to-date checks and the configuration cache. The branch uses that property.

Behaviour changes, both in the star imports — opt-in, and the flags default to off:

  • importGrailsCommonAnnotations adds grails.gorm.annotation and grails.plugin.scaffolding.annotation without probing the compile classpath first. A star import of an absent package contributes no classes and is not an error in Groovy.
  • importJavaTime is removed. Groovy 5 imports java.time by default, so it did nothing on Grails 8. Upgrade guide section 45.

`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.
@testlens-app

testlens-app Bot commented Aug 14, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 88538f5
▶️ Tests: 59433 executed
⚪️ Checks: 75/75 completed


Learn more about TestLens at testlens.app.

sbglasius added a commit to gpc/greenmail that referenced this pull request Aug 18, 2026
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.
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.

4 participants