Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
0760a7c
test: add an end-to-end module verifying message bundles resolve in a…
codeconsole Sep 11, 2026
df49873
Merge branch '9.0.x' into feature/native-i18n-verification-8.0.x
codeconsole Sep 16, 2026
e9acc03
Merge branch '9.0.x' into feature/native-i18n-verification-8.0.x
codeconsole Sep 25, 2026
767c950
Merge branch '9.0.x' into feature/native-i18n-verification-8.0.x
codeconsole Oct 2, 2026
ef4d2ed
Merge branch '9.0.x' into feature/native-i18n-verification-8.0.x
codeconsole Oct 8, 2026
868975f
Merge branch '9.0.x' into feature/native-i18n-verification-8.0.x
codeconsole Oct 8, 2026
25321b7
Merge branch '9.0.x' into feature/native-i18n-verification-8.0.x
codeconsole Oct 9, 2026
709b8ae
Merge branch '9.0.x' into feature/native-i18n-verification-8.0.x
codeconsole Oct 9, 2026
0c5bdc0
Merge branch '9.0.x' into feature/native-i18n-verification-8.0.x
codeconsole Oct 12, 2026
55e96ba
Merge branch '9.0.x' into feature/native-i18n-verification-8.0.x
codeconsole Oct 12, 2026
13d88c8
Merge branch '9.0.x' into feature/native-i18n-verification-8.0.x
codeconsole Oct 12, 2026
e382b3e
Merge branch '9.0.x' into feature/native-i18n-verification-8.0.x
codeconsole Oct 13, 2026
e59381a
Drop the explicit indy setting from native-i18n
codeconsole Oct 13, 2026
6e272a0
Declare the GraalVM plugin in the end-to-end root build
codeconsole Oct 13, 2026
152d097
Bring the native-i18n README up to date with 9.0.x
codeconsole Oct 13, 2026
06756cb
Merge branch '9.0.x' into feature/native-i18n-verification-8.0.x
codeconsole Oct 14, 2026
17940d4
Merge branch '9.0.x' into feature/native-i18n-verification-8.0.x
codeconsole Oct 14, 2026
98f9684
Merge branch '9.0.x' into feature/native-i18n-verification-8.0.x
codeconsole Oct 15, 2026
8b85719
Merge branch '9.0.x' into feature/native-i18n-verification-8.0.x
codeconsole Oct 16, 2026
470f9f1
Merge branch '9.0.x' into feature/native-i18n-verification-8.0.x
codeconsole Oct 16, 2026
b82eb7f
Merge branch '9.0.x' into feature/native-i18n-verification-8.0.x
codeconsole Oct 16, 2026
7457422
Drop the README's caveat about #16165
codeconsole Oct 17, 2026
5e9c52b
Compile the native-i18n Application class statically
codeconsole Oct 17, 2026
c00fd30
Record the main class without dynamic dispatch
codeconsole Oct 17, 2026
0e92483
Compile the Settings interface statically
codeconsole Oct 17, 2026
74932d6
Compile MultiTenancySettings statically
codeconsole Oct 17, 2026
5172bc8
Create the default stack trace filterer directly
codeconsole Oct 17, 2026
d87e830
Carry the build information into a native image
codeconsole Oct 17, 2026
743521b
Register the default validation messages as a bundle
codeconsole Oct 17, 2026
e309277
Register the plugin descriptors and the plugins they name
codeconsole Oct 17, 2026
3ec4808
Register the classes grails.factories names
codeconsole Oct 17, 2026
9d564f7
Register the Caffeine classes the framework's caches use
codeconsole Oct 17, 2026
3310a65
Register the reflection compiled Groovy implies, read from its bytecode
codeconsole Oct 17, 2026
9e449d0
Register the artefacts and the classes that represent them
codeconsole Oct 17, 2026
7bee820
Register the beans the plugins declare as the application starts
codeconsole Oct 17, 2026
8bfca2b
Inject the beans generated ahead of time only once
codeconsole Oct 17, 2026
9ccbe2e
Register the framework classes dynamic descriptors reach
codeconsole Oct 17, 2026
e73d7ad
Register the property editors the core plugin installs
codeconsole Oct 17, 2026
1866ffa
Register the JDK types dynamically compiled framework code calls
codeconsole Oct 17, 2026
44ef78e
Register the builder URL mappings are evaluated against
codeconsole Oct 17, 2026
94dfacd
Register the servlet context holder Holders creates by name
codeconsole Oct 17, 2026
35c42e1
Register the promise factories the event bus reads its executor from
codeconsole Oct 17, 2026
ee97957
Document what the framework registers for a native image
codeconsole Oct 17, 2026
b10f7e7
Say the native half of native-i18n needs no recorded metadata
codeconsole Oct 17, 2026
b997032
Find each plugin's own configuration in a native image
codeconsole Oct 17, 2026
07e325c
Read the plugin's configuration in native-i18n
codeconsole Oct 17, 2026
f7ce9ee
Scan the native-i18n plugin's dependencies
codeconsole Oct 17, 2026
f2a1277
Inject a generated bean whose class its definition does not declare
codeconsole Oct 17, 2026
df2ba38
Merge branch '9.0.x' into feature/native-i18n-verification-8.0.x
codeconsole Oct 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions end-to-end/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ applications at via `GRAILS_REPO_URL`.
| `legacy-g7-command-plugin` | A **standalone build**, not part of this one. Compiles against published Grails 7 / Groovy 4 to produce a genuine precompiled `grails.dev.commands.ApplicationCommand` binary. |
| `legacy-commands-plugin` | A Grails 8 plugin whose legacy commands are recompiled under Groovy 5. |
| `legacy-commands` | A Grails 8 application that consumes both and runs their commands through the registry. |
| `native-i18n` | An application that resolves messages from three kinds of bundle, on a JVM always and inside a GraalVM binary when asked. |
| `native-i18n-plugin` | A plugin shipping a namespaced message bundle (`native-messages.properties`) and a `plugin.yml`, with a multi-word plugin name. |
| `spring-dependency-management` | A Grails 8 application that manages its versions with the legacy `io.spring.dependency-management` plugin instead of the Grails Gradle plugin's native `platform(grails-bom)`, as an upgraded Grails 7 application does. |
| `taglib-index-incremental` | Builds a Grails 8 application **twice, without a clean**, to prove a renamed or deleted tag library cannot survive in the published tag library index. Incremental behaviour is the whole point, so it cannot be expressed by a project the core build builds once for itself. |

Expand All @@ -53,6 +55,78 @@ published yet (a reproducible release build, or a fresh release branch whose ver
published), and otherwise silently fell back to whatever the Apache snapshot repository happened to
hold rather than the working tree.

## Native image verification

`native-i18n` exists because AOT processing on a JVM cannot falsify a resource hint. Every bundle on
a JVM classpath is readable whether it was registered or not, so a missing hint shows up only in a
compiled image — as a code resolving to itself, for a message that resolved perfectly in every test.

The application resolves one code from each way a bundle reaches the message source, and only the
first is covered by Spring Boot's own hints, which register the two hardcoded patterns
`messages.properties` and `messages_*.properties` and derive nothing from the configured base names:

| Bundle | Base name | Why it is here |
|---|---|---|
| the application's own | `messages` | The case Boot already covers, as a control. |
| the plugin's | `native-messages` | Namespaced, so Boot's patterns never reach it. Its file is at the root of the classpath, where Grails registers every properties file, so in an image this proves the plugin is found and its base name reaches the message source. The plugin name is multi-word, so this also covers the descriptor's hyphenated spelling being matched against the plugin's camel-case one. |
| one the application configured | `config.i18n.custom` | Outside `grails-app/i18n` and the root, so the only one carried into an image by the hints derived from the base names alone, and written dotted, so it also proves the base name is converted to `config/i18n/custom` before being registered. |

Each is resolved in English and in a locale variant. The locale half is the assertion that would
fail if the hints registered *bundles* rather than resource *patterns* and the image were built with
a narrower locale set than the bundles cover — the open question recorded in
https://github.com/apache/grails-core/issues/16176.

It also reads a value from the plugin's `plugin.yml`, because the image is built anyway and nothing
else checks that a plugin's configuration reaches one. On a JVM the file is found beside the plugin's
classes, in its JAR; an image has no JARs, so there it is found only through the copy the build makes
of it under the plugin's name.

```bash
cd end-to-end
./gradlew :native-i18n:check # the JVM half, part of `check`
./gradlew :native-i18n:check -PnativeTests # adds the native half
```

The JVM half runs in `check` and needs nothing special. It cannot falsify a hint; its value is as a
fixture guard, keeping the bundles and the assertions from drifting apart. The native half is opt-in
because it needs a GraalVM toolchain and minutes of CPU.

The native half needs no recorded metadata. Nothing traced by an agent is checked in or generated: the
image is built from what the framework registers while the code is generated -- finding the plugins,
creating their beans and artefacts, what Groovy does reflectively -- and from the hints derived from the
configured base names. So a failure here is a gap in the framework, not in this fixture.

### Why this runs here and not on 8.0.x

Dynamic Groovy in a native image needs two things this branch has and 8.0.x deliberately does not:

| | 8.0.x | here |
|---|---|---|
| Groovy | 5, which has no AOT link mode | 6.0.0, which carries [GROOVY-12234](https://issues.apache.org/jira/browse/GROOVY-12234)'s AOT link mode for indy dispatch and ships its own native-image configuration |
| framework invokedynamic | off by default in `CompilePlugin`, because Groovy 5's indy default is a large runtime regression for dynamic Groovy (#15293) | on: Groovy 6's default, which `CompilePlugin` and the Grails Gradle plugin keep unless a build turns it off |

Without the AOT link mode, indy-compiled Groovy cannot link in an image: the runtime invokes
`IndyInterface`'s bootstrap method without its `<clinit>` having run, so a `static final` handle is
still null and it fails as `BootstrapMethodError: NullPointerException` at
`IndyInterface.makeBootHandle`. Without invokedynamic at all, Groovy classes carry a `CallSiteArray`
and define call-site classes as they run, which an image forbids: `UnsupportedFeatureError: Tried to
define class`, naming a class nobody wrote. Either one on its own is fatal, which is why native
image is not a Grails 8 capability and this module is not on that branch.

### What it needs

**A GraalVM JDK, and not necessarily the one that published the framework.** `GRAALVM_HOME` and
`JAVA_HOME` must point at a GraalVM before the image is built; `toolchainDetection = false` makes the
plugin honour it rather than resolving a toolchain, so the publish JDK and the image JDK do not have
to agree. Use GraalVM 25 or later: on the 21 line `nativeCompile` fails during "Initializing" with
`Could not find target method: ...IndyInterface.invalidateSwitchPoints()`, because GraalVM's bundled
Groovy substitution targets a method Groovy 4+ removed
([oracle/graal#10200](https://github.com/oracle/graal/issues/10200), fixed in the 25 line). What must
not happen is publishing on a JDK *newer* than the one the image is built with: `GroovyCompile` has
no `release` option, so the Groovy sources in `grails-gradle` are compiled for the running JDK, and a
newer class file version makes the image fail at *configuration* time, naming a framework class as
though the framework had regressed.

## JDKs

The Grails 7 half declares the JDK it needs in a `.sdkmanrc`, rather than a Gradle toolchain, so that
Expand Down
4 changes: 4 additions & 0 deletions end-to-end/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ plugins {
// workflow bumps, so the suite always resolves the version the core build just published.
// Every subproject inherits it from here.
id 'org.apache.grails.buildsrc.properties'
// Applied only by native-i18n, and only with -PnativeTests. Declared here rather than in that
// project, because a plugin only one project declares gives it a classloader of its own, and the
// vulnerability scan's shared build service cannot then be handed to its task there.
id 'org.graalvm.buildtools.native' version "$graalvmBuildtoolsVersion" apply false
}

def localMavenRepo = rootProject.layout.projectDirectory.dir('../build/local-maven')
Expand Down
5 changes: 5 additions & 0 deletions end-to-end/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,8 @@ org.gradle.jvmargs=-Dfile.encoding=UTF-8 -Xmx5G
org.gradle.configuration-cache=true
org.gradle.caching=true
org.gradle.parallel=true

# GraalVM Gradle plugin, used only by the opt-in native verification in native-i18n. It lives here
# rather than in dependencies.gradle because nothing the framework publishes depends on it - it is a
# build plugin for one fixture in this build, not a managed coordinate of the BOM.
graalvmBuildtoolsVersion=1.1.12
38 changes: 38 additions & 0 deletions end-to-end/native-i18n-plugin/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

plugins {
id 'org.apache.grails.buildsrc.properties'
id 'org.apache.grails.buildsrc.dependency-validator'
id 'org.apache.grails.buildsrc.compile'
id 'org.apache.grails.buildsrc.vulnerability-scan'
id 'org.apache.grails.gradle.grails-plugin'
}

version = '0.0.1'
group = 'nativei18n.plugin'

dependencies {
implementation platform("org.apache.grails:grails-bom:$projectVersion")
implementation 'org.apache.grails:grails-core'
}

apply {
from rootProject.layout.projectDirectory.file('../gradle/grails-extension-gradle-config.gradle')
}
23 changes: 23 additions & 0 deletions end-to-end/native-i18n-plugin/grails-app/conf/plugin.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
# Found beside the plugin's classes on a JVM. An image has no such place, so the application reading this
# proves the copy the build makes of it, under the plugin's name, was carried into the image.
native:
plugin:
configuration: from the plugin configuration
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
native.plugin.greeting=hello from the plugin bundle
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
native.plugin.greeting=bonjour depuis le paquet du plugin
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

package nativei18n.plugin

import grails.plugins.Plugin

/**
* A plugin whose Grails name is multi-word, so its bundle exercises both halves of the naming:
* the descriptor records the hyphenated {@code native-messages}, while the plugin the application
* discovers reports the logical {@code nativeMessages}.
*/
class NativeMessagesGrailsPlugin extends Plugin {

def grailsVersion = '9.0.0-SNAPSHOT > *'
}
94 changes: 94 additions & 0 deletions end-to-end/native-i18n/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

plugins {
id 'org.apache.grails.buildsrc.properties'
id 'org.apache.grails.buildsrc.dependency-validator'
id 'org.apache.grails.buildsrc.compile'
id 'org.apache.grails.buildsrc.vulnerability-scan'
id 'org.apache.grails.gradle.grails-web'
}

version = '0.1'
group = 'nativei18n'

dependencies {
implementation platform("org.apache.grails:grails-bom:$projectVersion")
implementation project(':native-i18n-plugin')
implementation 'org.apache.grails:grails-dependencies-starter-web'
}

apply {
from rootProject.layout.projectDirectory.file('../gradle/grails-extension-gradle-config.gradle')
}

// Runs on any JDK, and proves only that the fixture and its assertions are self-consistent - on a
// JVM every bundle is readable whether it was registered or not. Cheap enough to keep in `check` so
// the assertions cannot drift from the bundles while the native job is not being run.
tasks.register('i18nCheckOnJvm', JavaExec) {
group = 'verification'
description = 'Resolves every message bundle from the packaged application on a JVM'
dependsOn tasks.named('bootJar')
classpath = files(tasks.named('bootJar').flatMap { it.archiveFile })
mainClass = 'org.springframework.boot.loader.launch.JarLauncher'
args = ['--i18n-check', '--server.port=0', '--spring.main.banner-mode=off']
}

tasks.named('check') {
dependsOn tasks.named('i18nCheckOnJvm')
}

// The native half is opt-in: it needs a GraalVM toolchain and several minutes of CPU, which is too
// much to ask of every run of this build. Enable with -PnativeTests.
if (project.hasProperty('nativeTests')) {
pluginManager.apply('org.graalvm.buildtools.native')

graalvmNative {
binaries {
main {
imageName = 'native-i18n'
mainClass = 'nativei18n.Application'
// Measured on a 32GB machine with GraalVM CE 25.2.4: the default 16 threads
// exhausted the heap, 4 built in about six minutes.
buildArgs.add('--parallelism=4')
}
}
// Build the image with GRAALVM_HOME/JAVA_HOME rather than letting Gradle select a toolchain.
// The framework is published on the JDK the repository pins and the image is built on a
// GraalVM, so there is no one toolchain that could be detected for both.
toolchainDetection = false
}

tasks.register('i18nCheckOnNative', Exec) {
group = 'verification'
description = 'Resolves every message bundle from a compiled GraalVM binary'
// The path comes from the task that produces the binary, so the location and the dependency
// on its producer stay in one place rather than repeating the layout convention here.
Provider<RegularFile> binary = tasks.named('nativeCompile').flatMap { it.outputFile }
inputs.file(binary)
doFirst {
commandLine binary.get().asFile.absolutePath,
'--i18n-check', '--server.port=0', '--spring.main.banner-mode=off'
}
}

tasks.named('check') {
dependsOn tasks.named('i18nCheckOnNative')
}
}
27 changes: 27 additions & 0 deletions end-to-end/native-i18n/grails-app/conf/application.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
spring:
messages:
# Dotted, so the run also proves a base name is converted to a resource path
# (config.i18n.custom -> config/i18n/custom.properties) before it is used.
basename: config.i18n.custom
fallback-to-system-locale: false
# Not a base name, so resolving an unknown code to itself proves the rest of
# MessageSourceProperties was bound too - they all arrive together or not at all.
use-code-as-default-message: true
19 changes: 19 additions & 0 deletions end-to-end/native-i18n/grails-app/i18n/messages.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
native.app.greeting=hello from the application bundle
Loading
Loading