Skip to content

Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) - #16094

Draft
codeconsole wants to merge 67 commits into
apache:8.0.xfrom
codeconsole:fix/aot-live-instance-beans-8.0.x
Draft

Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support)#16094
codeconsole wants to merge 67 commits into
apache:8.0.xfrom
codeconsole:fix/aot-live-instance-beans-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Depends on
wondrify/asset-pipeline#465

Makes a Grails application processable by Spring AOT. A stock web application now completes processAot and starts with spring.aot.enabled=true:

$ ./gradlew bootJar
$ java -Dspring.aot.enabled=true -jar build/libs/app.jar
Starting AOT-processed Application v0.1 using Java 21.0.9
Started Application in 1.475 seconds

Apply the Spring Boot AOT plugin to opt in:

apply plugin: 'org.springframework.boot.aot'

AOT is also the gate on GraalVM native images: Boot refuses to start a native image without a build-time generated initializer, so nothing native was reachable before this.

What was blocking it

Four independent mechanisms, each only visible once the previous was cleared.

Live object instances in bean definitions. GroovyPagesGrailsPlugin passed the live GrailsApplication as a constructor argument and a new GroovyPagesServlet() instance. A bean definition is a recipe; ValueCodeGenerator cannot emit source that reconstructs an arbitrary object:

UnsupportedTypeValueCodeGenerationException:
    Code generation does not support grails.core.DefaultGrailsApplication

Both now use forms the generator understands — a reference by bean name and an inner bean definition. This was the first processAot failure on 7.2.1, 8.0.0-M4 and 8.0.0-SNAPSHOT alike.

A second ConfigurationClassPostProcessor. CoreGrailsPlugin registers one so that @Configuration beans contributed by plugins through doWithSpring — which arrive after Spring's own processor has finished — still get parsed. An AOT context has none of its own by design, and adding one back parses the configuration classes a second time, colliding with what AOT already emitted:

BeanDefinitionStoreException: Invalid bean definition with name
'propertySourcesPlaceholderConfigurer' defined in org.grails.plugins.CoreAutoConfiguration:
Bean name derived from @Bean method 'propertySourcesPlaceholderConfigurer' clashes with
bean name for containing configuration class

For a static @Bean method AOT emits the definition with the configuration class as its bean class, so the re-parse rediscovers CoreAutoConfiguration under the bean's own name and collides with itself.

Abstract bean definitions. BeanDefinitionPropertiesCodeGenerator emits lazyInit, primary, scope, role and synthetic but not the abstract flag, and nothing filters abstract definitions out beforehand — a template is silently regenerated as a concrete Object bean still carrying the template's properties. CoreGrailsPlugin contributes exactly such a template (abstractGrailsResourceLocator), and it is public surface: third-party plugins inherit from it with bean.parent, asset-pipeline's assetResourceLocator among them. A BeanRegistrationExcludeFilter keeps them out of generation; children are unaffected because AOT generates them from their merged definition.

A servlet context wired as a bean reference. SiteMesh's bean-definition wrap referenced the servletContext bean, which the container registers only once the web server starts and which has no bean definition at all.

Requires a SiteMesh release

The last item is fixed upstream in spring-webmvc-sitemeshSiteMeshViewResolver now implements ServletContextAware instead of taking the context as a constructor argument. dependencies.gradle therefore moves to 3.3.0-SNAPSHOT, and must be pinned to a released version before this merges.

Limitations

  • processAot must run in production mode. In development reloadEnabled is true and the URL mappings holder becomes a ProxyFactoryBean whose lazy object type cannot be predicted without instantiating it. Set it on the task:
    tasks.named('processAot') { systemProperty 'grails.env', 'production' }
  • This makes an application AOT-processable, not native-image ready. Native images additionally need reachability metadata for plugin descriptor scanning and Groovy class loading.
  • 13 beanRegistrar()-contributed beans are still excluded from generation by Spring's own aotProcessingIgnoreRegistration flag and re-registered at runtime when GrailsApplicationPostProcessor re-runs. Correct, but it repeats the plugin scan on every boot — the work AOT exists to precompute.
  • GroovyPagesServlet is now created by the container rather than with new, so it passes through bean post-processing. Its pluginManager is unaffected (initFrameworkServlet already autowires by type), but a post-processor whose pointcut matched it would hand ServletRegistrationBean a proxy. No pointcut in the framework does.

Verification

An AOT-processed application's bean definitions differ from a normal boot only by the annotation-processing infrastructure AOT replaces — 410 beans against 405, the difference being the configuration, autowired and common-annotation processors, Boot's shared metadata reader factory, and grailsConfigurationClassPostProcessor. No application bean is missing and none is added.

filteringCodecsByContentTypeSettings took the live GrailsApplication as a
constructor argument, and groovyPagesServlet took a new GroovyPagesServlet().
Spring AOT's ValueCodeGenerator cannot emit code for an arbitrary object, so
processAot aborted the entire run:

    UnsupportedTypeValueCodeGenerationException:
        Code generation does not support grails.core.DefaultGrailsApplication

Both now use forms the generator understands: a reference by bean name, as
errorsViewStackTracePrinter directly above already does, and an inner bean
definition. This was the first processAot failure for a stock web application
on 7.2.1, 8.0.0-M4 and 8.0.0-SNAPSHOT alike. Further blockers remain behind it,
so this does not by itself make an application AOT-processable.

Constructing the servlet through the registry rather than with new means it now
passes through bean post-processing. Its pluginManager is unaffected -
initFrameworkServlet already autowires the servlet's properties by type, which
covers that setter - but a post-processor whose pointcut matched the servlet
would hand ServletRegistrationBean a proxy in its place. No pointcut in the
framework does.
CoreGrailsPlugin registers a second ConfigurationClassPostProcessor so that
@configuration beans contributed by plugins through doWithSpring - which
arrive after Spring's own processor has finished - still get parsed.

An AOT-optimized context has no ConfigurationClassPostProcessor at all:
the configuration classes were parsed at build time and their bean
definitions are in the generated initializer. Registering one there parses
them a second time, and the re-parse collides with what AOT already emitted:

    BeanDefinitionStoreException: Invalid bean definition with name
    'propertySourcesPlaceholderConfigurer' defined in
    org.grails.plugins.CoreAutoConfiguration: Bean name derived from @bean
    method 'propertySourcesPlaceholderConfigurer' clashes with bean name for
    containing configuration class

For a static @bean method AOT emits the bean definition with the
configuration class as its bean class, so the re-parse rediscovers
CoreAutoConfiguration under the bean name propertySourcesPlaceholderConfigurer
and then collides with itself. The processor is skipped when
AotDetector.useGeneratedArtifacts() reports generated artifacts are in use;
behaviour without AOT is unchanged.

This was reachable only after the GSP live-instance beans were fixed, since
processAot did not previously get far enough to produce an initializer.
An abstract bean definition is a template: it carries property values for
children to inherit and is never instantiated. Spring's bean-definition code
generator has no representation for one - BeanDefinitionPropertiesCodeGenerator
emits lazyInit, primary, scope, role and synthetic, but not the abstract flag,
and nothing filters abstract definitions out beforehand. The definition is
regenerated as a concrete bean of type Object still carrying the template's
properties, and the context fails applying them:

    BeanCreationException: Error creating bean with name
    'abstractGrailsResourceLocator': Invalid property 'searchLocations' of bean
    class [java.lang.Object]

CoreGrailsPlugin contributes exactly such a template through
AbstractResourceLocatorPostProcessor, and it is public surface - third-party
plugins inherit from it with bean.parent, asset-pipeline's assetResourceLocator
among them - so it cannot simply be removed.

A BeanRegistrationExcludeFilter registered in META-INF/spring/aot.factories
keeps every abstract definition out of generation. Children are unaffected,
because AOT generates them from their merged definition with inherited values
already folded in; a definition contributed dynamically still finds its parent,
since the post-processor that registers the template runs during refresh in an
AOT context as it does in any other.

With this an AOT-processed application starts. Its bean definitions differ from
a normal boot only by the annotation-processing infrastructure AOT replaces:
the configuration, autowired and common-annotation processors, Boot's shared
metadata reader factory, and grailsConfigurationClassPostProcessor.
SiteMeshViewResolver now implements ServletContextAware, so the bean-definition
wrap no longer passes the servlet context as a constructor argument. That
reference was to a bean the container only registers once the web server has
started, which has no bean definition for AOT to resolve, and it failed
processAot for every application with GSP on the classpath:

    AotBeanProcessingException: Error processing bean with name 'jspViewResolver'
    Caused by: NoSuchBeanDefinitionException: No bean named 'servletContext' available

GrailsSiteMeshViewResolver gains the matching three-argument constructor and
reads the context through the inherited accessor rather than keeping its own
copy. The four-argument constructor stays for callers that build the resolver
directly, which is how the instance-level post-processor still creates it.

This was the last blocker: a stock Grails web application now completes
processAot and starts with spring.aot.enabled=true. Its bean definitions differ
from a normal boot only by the annotation-processing infrastructure AOT
replaces - the configuration, autowired and common-annotation processors,
Boot's shared metadata reader factory, and grailsConfigurationClassPostProcessor.

The sitemesh version is moved to 3.3.0-SNAPSHOT because the change it depends on
is not in 3.3.0-M3. It must be pinned to a released version before this merges.
AOT support is opt-in and needs configuration that is not guessable - the Spring
Boot AOT plugin, and generation in production mode, without which the url
mappings holder takes its reload-mode proxy shape and generation fails naming a
bean unrelated to anything in the application. The deployment guide now covers
enabling it, what changes at runtime, and the limitations: registrar-contributed
beans are not generated, definitions holding live objects cannot be generated,
abstract definitions are excluded, and AOT alone does not make an application
native-image ready.

Two layers of coverage, because the failures differ in kind.

CoreGrailsPluginAotSpec runs the real generator over the core plugin's bean
definitions, so a definition holding a live instance fails the build. It also
covers the configuration class post-processor being registered normally and
withheld when generated artifacts are in use, which no existing test reached. A
fourth case registers a definition holding a live instance and asserts
generation rejects it, so the check above cannot pass vacuously.

Generation succeeding does not mean the application starts, and the
post-processor condition is a runtime-only behaviour. The grails-test-examples
aot application therefore runs processAot and then starts the packaged jar with
spring.aot.enabled=true, asserting the beans an AOT context must still contain.
Its check task fails the build if either half regresses.
@codeconsole
codeconsole marked this pull request as draft August 5, 2026 04:21
Spring AOT generates the bean definitions for a context at build time, and it can
only do so for beans the container knows how to build. A bean registered as an
already-constructed object has nothing to generate from, so the MongoDB datastore
could not be processed ahead of time.

The MongoDB initializer built the event publisher itself and passed the instance to
the datastore constructor. It now registers the publisher as a definition and refers
to it by name, so the container builds both.

ConfigurableApplicationContextEventPublisher could only be built by passing a context
to its constructor, which is the thing a definition cannot do. It now also takes the
context from the container through ApplicationContextAware, leaving the existing
constructor in place for callers outside a container.

Which publisher gets registered still depends on where GORM is being bootstrapped:
outside an application context the no-op publisher is used, as before, because the
context-aware one would never be given a context there and would fail on the first
event it published.
The welcome page shows the Spring Security version only when the dependency is
present, and reached it with Class.getMethod('getVersion').invoke(null). Calling
Method.invoke from a GSP expression goes through Groovy's dynamic dispatch, which
resolves to the private caller-sensitive overload the JDK added for core reflection.
A native image cannot supply the caller argument that overload requires and aborts
the process rather than raising an exception.

Spring's ReflectionUtils performs the invocation from Java, so Groovy never
dispatches on Method.invoke itself. The presence check is unchanged, which keeps the
page compiling for generated applications that did not select Spring Security.

Both copies of the page are updated: the one the profiles CLI writes into a new
application and the one grails-forge serves.
The file was added without one, which fails the release audit.
@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 67.57941% with 296 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.5509%. Comparing base (4bc5bd8) to head (1848212).
⚠️ Report is 41 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...rails/gradle/plugin/core/GrailsGradlePlugin.groovy 0.0000% 89 Missing ⚠️
...ls/gradle/plugin/views/gsp/GroovyPagePlugin.groovy 0.0000% 41 Missing ⚠️
...gin/scaffolding/GenerateScaffoldedViewsTask.groovy 63.5135% 8 Missing and 19 partials ⚠️
...c/main/groovy/org/grails/aot/RegistrableTypes.java 84.7059% 8 Missing and 5 partials ⚠️
...radle/plugin/aot/GenerateNativeMetadataTask.groovy 77.9661% 4 Missing and 9 partials ⚠️
.../grails/gradle/plugin/aot/TrainAotCacheTask.groovy 85.8696% 5 Missing and 8 partials ⚠️
...ls/spring/beans/aot/GrailsClosureRuntimeHints.java 66.6667% 8 Missing and 3 partials ⚠️
...g/beans/aot/GroovyExtensionModuleRuntimeHints.java 74.4186% 7 Missing and 4 partials ⚠️
...rg/grails/datastore/gorm/aot/GormRuntimeHints.java 68.0000% 6 Missing and 2 partials ⚠️
...ils/plugins/web/taglib/aot/TagLibRuntimeHints.java 75.0000% 6 Missing and 2 partials ⚠️
... and 17 more
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16094        +/-   ##
==================================================
+ Coverage     52.3426%   52.5509%   +0.2083%     
- Complexity      18293      18549       +256     
==================================================
  Files            2036       2059        +23     
  Lines           96346      97239       +893     
  Branches        16831      16980       +149     
==================================================
+ Hits            50430      51100       +670     
- Misses          38492      38638       +146     
- Partials         7424       7501        +77     
Files with missing lines Coverage Δ
...ns/web/controllers/aot/ControllerRuntimeHints.java 100.0000% <100.0000%> (ø)
.../groovy/org/grails/plugins/CoreGrailsPlugin.groovy 71.2500% <100.0000%> (+2.3311%) ⬆️
...ng/beans/AbstractResourceLocatorPostProcessor.java 100.0000% <100.0000%> (+16.6667%) ⬆️
...beans/aot/AbstractBeanDefinitionExcludeFilter.java 100.0000% <100.0000%> (ø)
.../aot/AutowireModeBeanRegistrationAotProcessor.java 100.0000% <100.0000%> (ø)
...ls/spring/beans/aot/BeanRegistrarRuntimeHints.java 100.0000% <100.0000%> (ø)
...grails/spring/beans/aot/GrailsApiRuntimeHints.java 100.0000% <100.0000%> (ø)
...ils/spring/beans/aot/GrailsBannerRuntimeHints.java 100.0000% <100.0000%> (ø)
...s/spring/beans/aot/GrailsResourceRuntimeHints.java 100.0000% <100.0000%> (ø)
...onfigurableApplicationContextEventPublisher.groovy 84.6154% <100.0000%> (+6.8376%) ⬆️
... and 30 more

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

A scaffolded controller had no views of its own: the resolver expanded a template
into GSP source and compiled the result the first time each view was asked for.
That costs the first request, and a native image cannot do it at all, because
defining a class at run time is what an ahead-of-time image gives up.

The templates are now expanded during the build and compiled with the rest of the
views, so at run time they are found rather than produced. Only naming is
substituted, and the templates defer everything about the domain class to the field
tag libraries at render time, so this needs no GORM, no application context and no
loading of application classes: the controllers are read with ASM and the domain
class name is enough.

The generated pages are staged with the application's own before a single
compilation. Compiling them separately would produce a second gsp/views.properties,
and the archive tasks discard duplicates, so one of the two manifests would be lost
and the views it listed would never be found. A view the application declares is not
generated over, which keeps a hand-written page ahead of a scaffolded one as it is
at run time.
Handling a request reaches the controller, URL mapping, data binding and content
negotiation APIs through Groovy's dynamic dispatch, which reads a type's declared
methods to choose an overload. An ahead-of-time image keeps only the members
something asks for, so these were stripped and dispatch failed at the point of use.

Leaving this to the tracing agent does not work, because the agent records only the
paths that were exercised. Each of these failed on a path an ordinary check misses:
content negotiation runs only for a request that states what it accepts, the method
check only for POST, PUT and DELETE, binding only for a request carrying data, and
the parameter accessor only once a mapping carries parameters. All four passed a
page walk and failed for a real visitor.

Each registrar sits in the module owning the API and names its types as strings,
registering only those present, so an application that does not use a given plugin
is unaffected. None of this varies between applications, which is why it belongs
with the framework rather than in every application's metadata.
Calling a closure goes through doCall, and Groovy reads its parameter types
reflectively to choose an overload. In an ahead-of-time image those are stripped
unless something asks for them, and the call then fails where the closure is used
rather than at start-up. The framework ships thousands across its plugins, so naming
them would be neither complete nor stable, and leaving it to each application means
every one of them rediscovers the same list.

They are found while the hints are written instead. That happens during the build,
on an ordinary JVM with the whole classpath, so scanning is available; it is only
the image that cannot do it.

Two kinds of closure are deliberately left out, both of which otherwise fail the
build rather than degrading at run time. One is a closure whose declaring class does
not resolve: the GSP compiler's task extends an Ant type absent at run time, and its
closures reach it through invokedynamic, so nothing in their own bytecode reveals
the dependency. The other is a closure naming an absent class in a method body: the
JSP closures link happily against a runtime with no JSP API, because loading a class
resolves its signatures and not the bodies the image analysis goes on to parse.
Grails reaches an application's artefacts reflectively, and a precompiled page is
looked up by the name recorded for its view, so a native image that keeps only the
members something asked for leaves both present but unusable. Until now an
application had to run under the tracing agent to discover them, which records only
the paths that were exercised: a page nobody visited during the trace is a page
missing from the image.

The build already knows the answer. The compiled classes are on disk and the pages
are named in the manifest the GSP compiler writes, so both are read directly. Pages a
plugin contributes are read from the manifest inside its artifact as well, because an
application renders those as readily as its own and they are not in its own build
output.

The closure registration is widened at the same time. A plugin declares its
descriptor in whatever package it chooses, and the bean definitions in that
descriptor are closures the container calls while the context is built, so scanning
only the framework's own packages missed them.
A rendered page and a persisted entity are both reached through Groovy, so an image
that keeps only the members something asked for serves part of an application and
fails on the rest. What fails depends on what was exercised: a page renders until it
reaches a tag that was stripped, and a datastore reads until something writes.

Three registrars cover the runtimes an application does not own:

Tag libraries and the page runtime, found by name wherever a plugin declares them.
A flash message only exists after a redirect that set one, and a field is only
rendered by a form, so a walk of an application's pages exercises neither and both
fail for the first person who edits a record.

The scaffolded resource controller, including the protected methods it defines for
the write operations, which serving its pages never reaches.

The persistence runtime, registered by package so that a datastore's own persisters
and query support are covered without the shared module naming any of them. Fields
are registered as well as methods: a persister hands work to anonymous inner classes
that read the state they captured as properties, which is how removing an entity
reaches its session.

Each skips a type that does not resolve. The JSP integration and the datastores are
compiled against APIs an application need not have, and registering a type the image
analysis then cannot parse fails the build rather than degrading at run time.

The two build tasks state their dependency on the compilation rather than inferring
it from the classes directory, which more than one task writes to.
Matching only the library by name left the classes it declares inside it stripped,
so a page rendered until a tag reached one. The fields plugin keeps the bean stack
that a nested tag reads its subject from in such a class, which is why a list or a
form failed while the pages around them were fine.

Found by driving the pages in a browser. An automated check that asks for a URL and
looks at the status never nests a tag deeply enough to reach it.
Whether the pages compiled at build time are used is decided by whether a
development environment is available, which comes down to whether the application
looks like a project on disk. That is the right question for a running JVM, where
skipping them lets an edit take effect without a restart.

It is the wrong question for an ahead-of-time image. Such an image is a single
executable that may be run from anywhere, including the directory it was built in,
where the sources are still present and the answer is yes. It then read those
sources and tried to compile them, which is the one thing it cannot do, so every
page failed. Running the executable from somewhere else worked, which made this look
like a property of the working directory rather than of the check.

Both the plugin that loads the compiled page registry and the locator that reads it
now also accept an image, which cannot compile a page whatever its surroundings
suggest. Development is unchanged.

The registry is loaded from a plugin descriptor, and a descriptor is Groovy, so the
call that asks whether this is an image is itself dispatched dynamically and the
class it is made on has to survive into the image.
Each registrar decided for itself whether a type it found could be kept, and the two
checks that decision needs were rediscovered a piece at a time, wrongly, three times
over. Registering a type that cannot be loaded does not degrade at run time: it fails
the image build, because the analysis parses what it is asked to keep.

Either check alone lets one case through, which is what made this easy to get wrong.
Whether the type and the class declaring it load covers a closure whose enclosing
class extends something absent, which the closure never names itself because it
reaches it through invokedynamic. Whether the types its bytecode names load covers
the opposite, a class that loads cleanly while a method body names an absent one,
because loading resolves signatures and not bodies.

Both now live with the reason each exists, where a registrar for a new part of the
framework will find them.
The task had no tests. Two of these cover what went wrong while it was being written:
the pages a plugin contributes, whose absence stopped an application starting because
it renders those as readily as its own, and the rule that the manifest rather than the
directory listing decides which pages exist, so a class left behind by an earlier
build is not recorded.
The registrars were written without the style checks having been run, and they
indented array initialisers one level too far and put their imports in the wrong
group. Behaviour is unchanged.
The expansion staged the views into a directory and pointed the compilation there,
for every project. A project with no scaffolded controller then copied its views on
every build and compiled them from somewhere other than where they are, for nothing.

Whether anything is scaffolded is read from the controller sources, because the
decision is needed before anything has been compiled. A project that does not
scaffold is left exactly as it was.
Generating a context ahead of time normally makes the injection annotations
unnecessary: the generator reads them and writes the field and method access into the
code it emits. Two kinds of injection did not survive that, and neither failed at
start-up -- the first request that reached the bean did.

A bean's autowire mode was not written out at all. Grails registers much of what it
contributes as autowired by name, so a tag library arrived holding null where it
expected a message source. The mode is now carried into the generated definition, for
whatever set it.

An annotated member is only generated for when the generator can see the class
declaring it. A bean contributed as an interface built by a supplier hides that class
-- the link generator is declared as LinkGenerator and built by a closure -- so its
annotated field was injected by nobody. The two processors that read those
annotations are registered when running on generated artifacts.

Only those two: registering the whole set brings back the processor that reads
configuration classes, and reading them again in a context whose configuration is
already generated makes a second definition for beans the generated code has
contributed, which fails the context outright.

(cherry picked from commit dbe0b2f9ff1f205266e0f1b2c9202baf708ad616)
The GSP plugin registered a bean for every tag library from doWithSpring(), which
meant registering them again on every start, over whatever was already there. On a
running JVM that only made noise. On a context generated ahead of time it replaced
definitions that carried the injection the generator had worked out, leaving a tag
library holding null where it expected a collaborator, and by-name autowiring did not
stand in for it because those collaborators are fields.

The definitions are contributed by a post-processor instead, which leaves an existing
definition alone. The artefacts are read from the application rather than named, so a
tag library still belongs to whoever declared it: the application, another plugin, or
one supplied through providedArtefacts.

Where a definition is already there but has lost its autowire mode, the mode is
raised back to by-name and never lowered, so a definition asking for something else
keeps it.

(cherry picked from commit 03c353953382fbf50a7924dcb5151f9618bb2591)
A bean declared through the plugin DSL passes its arguments positionally, and a
constructor ending in a variable-argument parameter is called the way the language
allows: one value where the parameter is an array, or a collection where it is an
array of that element type. Building the bean, Spring adapts the argument to the
parameter. Reading the definition to generate code for it, Spring does not -- it
looks the argument up by the parameter's type, and a lone String does not answer
to String[].

The argument is then missed and resolved as a dependency instead, and an array of
a type nobody publishes as a bean resolves to an empty array rather than failing.
So the bean is built, and built wrong: a datastore that maps no classes, or a
servlet registration with no URL mapping, which then falls back to mapping
everything and swallows every request. Nothing is logged, and the bean that goes
wrong is rarely the one that reports it.

Gathering the argument ahead of time means the generator writes out
new String[] {"*.gsp"}, which the lookup does find. An argument already usable as
the array is left alone, and one whose elements would need converting is left to
the resolution that exists today rather than guessed at here.

The tests read the code that is actually generated, because the failure this
guards against is one where every bean is still registered and still built.

(cherry picked from commit b91d09ee1c3b8dcd1ef97e9b025c642705f13366)
A datastore was given the configuration object itself to hold, and a definition
holding a property resolver is a definition holding everything that resolver can
reach. Generating code for it wrote those values out: the environment of whatever
machine ran the build, 223 entries of it in the case at hand, credentials among
them, committed into generated source. Worse, the application then read its
settings from there rather than from where it runs, so a value that is meant to
differ between build and run -- a host, a port, a password -- did not.

While code is being generated, and only then, the context's environment is named
instead, so the lookup is made where the application runs. Every other time the
resolver is held as it always was, which matters for a datastore brought up on its
own: its configuration is whatever the caller passed, and there is no environment
holding it.

The classes a datastore maps are also collected as the array its constructor
takes, rather than as a collection that only becomes one because Spring adapts it.

Verified on an ahead-of-time application: nothing of the build machine appears in
the generated sources, and the datastore resolves its address at startup.

(cherry picked from commit f437fdec01ccc40da7b6aa0ef787c336039bbd63)
Running on generated artifacts, the plugins whose doWithSpring() produced these
definitions have already run: they ran while the artifacts were being generated,
and what they registered was written out as code and registered again from that
code, ahead of this phase. Registering over it discarded the instance supplier the
generator wrote -- which resolves the constructor, the fields and the injection
methods up front -- and replaced it with a definition that has to find all of that
by reflection, which is what a generated image does not carry.

So the generated definition stands and the one contributed here is dropped.
Anything the generator did not produce a definition for is registered as usual, so
a bean a plugin contributes conditionally is unaffected, and on a normal start
nothing is skipped: a plugin overrides what came before it exactly as it did.

This is one guard where the definitions land rather than one at each call site, so
it holds for the early registration phase and the post-processor alike.

An ahead-of-time application went from 27 overridden definitions to none. The one
remaining message is Spring replacing its own import-aware post-processor with an
equivalent definition, which comes from two configuration class post-processors
each contributing the same registration.

grails-spring had no test task wired, having had no tests; it now applies the
shared test configuration.

(cherry picked from commit f21062e981349f7eb4962fd95b4214f0add5bc47)
The core plugin registers a ConfigurationClassPostProcessor so that @configuration
beans contributed by plugins get parsed. A context that annotation configuration
has already been set up on has one of its own, and the plugin definitions are
registered ahead of it, so it sees them: the second processor parses the same
registry over again.

While code is being generated the two of them each contribute a registration of
the import-aware post-processor, so the generated initializer registers it twice
under one name and the second replaces the first on every start -- the last
overriding definition message an ahead-of-time application reported.

It is now registered only where there is none: a context assembled without that
step, such as a test slice registering this plugin's beans on a bare registry,
which is what it was for.

An ahead-of-time application now starts with no overridden bean definitions at all.

(cherry picked from commit fa92bd33942f3ebdf5d76ae200e044cd3ab95374)
Both decorate the same generated block of definition properties, one after the
other, and no bean in the application under test happens to draw on both, so
nothing was proving they compose. A bean that is autowired by name and takes a
variable-argument constructor argument now shows both contributions surviving.

(cherry picked from commit 65ce6fbe9e52b7bc197c896118891aec08085296)
A plugin declares them in a closure, and Groovy resolves each call in a closure
where the call is written rather than when the closure is compiled, so every call
on the registry it is handed is made reflectively. An image keeps a method for
that only when something has asked it to, and nothing did: the registry belongs to
Spring, an application never names it, and the closures that call it are
registered as closures rather than for what they call.

So the image refused the first call and the context did not start, reporting a
method of an interface that appears nowhere in the application. It stood only as
long as an application's own traced metadata happened to cover the framework's
API, which is not the application's to know.

Verified by a native image, which now starts.

(cherry picked from commit c4cfdaf7e8997fe71b56720c61497ce2b0374228)
…ings

Generating the native metadata was wired into processResources, so a build that
only wanted to write a resource compiled its sources and resolved its whole
runtime classpath first -- which a project that had declared no repositories could
not do. Four of the Gradle plugin's own tests failed on it. The metadata is read
by nothing but a native image, so it is generated only where one is being built.

A tag library the application had already declared was kept but still altered: any
existing definition with no autowiring had by-name autowiring put back on it,
which cannot tell a generated definition from one an application deliberately
declared that way. What a generated definition needs is carried into it while it
is generated, so nothing has to be put back afterwards, and an existing definition
is now left exactly as it was found.

A banner colour given as a number was passed on whatever it was, so -1 or 999
wrote an escape the terminal does not understand and then showed it in the banner.
Numbers are held to the 256 a terminal has, and anything else falls back.

grails.banner.art.color is documented beside the rest of the banner configuration
and declared in the configuration metadata, so an IDE offers it and says what it
takes.

The Hibernate datastore held its event publisher rather than referencing one, and
its connection-source definitions declared what they produce in a way that reads
back as a mismatch: a supplier typed for the DataSource that builds the factory
bean which produces it. Neither could be expressed as generated code, so a
Hibernate application could not be built ahead of time at all -- the first fails
generation, the second fails to compile what generation wrote.

(cherry picked from commit 4f8c2c99cee33bbf32f0c586349fe31da1a32909)
The core plugin registers a proxy handler for an application that has none, and
these registrars run after the doWithSpring drain -- so it was registered over the
one a GORM implementation had already declared, which is the one that knows how to
unwrap that datastore's proxies. A Hibernate application was left unwrapping
Hibernate proxies with the general case, and said so on every start by reporting
proxyHandler as an overridden definition.

It is registered only where nothing has declared one, the way the resource locator
already backs off to the one GSP declares.

(cherry picked from commit 2476b1b5b012964f817e34c30dca672c0984aea2)
A plugin descriptor is Groovy written largely without static compilation, so
reading a setting, asking the application about its artefacts, or asking the
plugin manager about a plugin are all resolved where the call is written rather
than when the descriptor is compiled -- and are therefore made reflectively.

An image keeps a method for that only when asked, and an application never names
these itself: they are the framework's, called from the framework's own
descriptors. So the image refused the call and reported a method of an interface
appearing nowhere in the application. The one that stopped a context from starting
was ConfigMap.getProperty, which is how nearly every plugin reads its settings.

Only the interfaces are named. What implements them is reached through them, and
registering the implementations would be registering most of the framework.

(cherry picked from commit 3d18e3417f543c99aa6d8a056c5fc678d5e5ad7d)
A closure loads without the class it was written inside -- it extends Closure and
nothing else -- so asking only whether the closure loads lets one through whose
surroundings are absent. Registering it makes the image analyse it, and analysing
a closure means reading the method it was written in.

The Hibernate plugin ships a Spock specification for applications to write their
tests against, and ships it in its main jar, so it is on the runtime classpath of
every application that uses Hibernate whether or not that application tests with
Spock. One that does not has no spock.lang.Specification for the image to read,
and the build failed on a class the application never asked for:

    Error encountered while parsing grails.test.hibernate.HibernateSpec$_setupSpec_closure5
    Caused by: NoClassDefFoundError: spock/lang/Specification

The class the closure was written inside now has to load as well.

The test asks the guard directly. A class loader that hides Spock does not
reproduce this, because loading the enclosing class delegates to a parent that can
still see it -- which is worth knowing, since such a test passes while proving
nothing.

(cherry picked from commit 7d63e65b7aeb390e58aeb60b9fafd2305e6bb5fc)
A plugin descriptor is given the environment, the context and the resource loader,
and calls them the same dynamic way it calls the framework's own interfaces -- so
each call is made reflectively, and an application names none of them either.

Reading a setting from the environment stopped a Hibernate application from
starting in exactly the way reading one from the configuration stopped every
application: PropertyResolver.getProperty, from the datastore initializer.

(cherry picked from commit 6a30b0c96aac2dedde0f739d5573a780ebc1cf00)
The aligned colons were a style violation, and nothing reads the file in a
way the alignment helped.

(cherry picked from commit 28c32101e9ac0dd61e91bd0483972b4db3422e65)
Spring's AOT processing removes the work of deciding what the beans are,
which turns out to be the smaller half. What remains is loading and linking
the classes and interpreting the methods until they compile, and JDK 25 can
record a run of an application and read that record back on the next start
instead of doing it again.

An application asks for it and says which of its pages matter:

    grails {
        aotCache {
            enabled = true
            paths = ['/', '/login', '/book/index']
        }
    }

That extracts the executable jar -- a cache is only usable against the layout
it was trained on, and a nested-jar classloader is not one -- runs what it
extracted, asks for each path, and stops it. Startup falls from 2.5s to 1.0s
on an application with GORM, security and asset pipeline; asking for paths
does not move startup further, but halves the first hit of a cold path.

The cache is written as the training JVM exits, so a run that never started
leaves nothing behind. That fails the build rather than shipping an
application whose cache is silently absent: a JVM given a cache it cannot use
declines it and starts as it would have anyway, so the only symptom of a
missing or stale cache is the speed it was built for not being there.

Beside the cache goes aot-cache.properties, recording the JDK build, the
archive digest and the arguments it was trained with, so a deployment can
tell whether the cache it has still applies before it finds out by being slow.

(cherry picked from commit 743206bd2b07bfd40af58cc8462f8658a334edd3)
A page under Deployment covering what the cache is, how an application asks
for one, what the training run should be asked to do, and why a cache stops
applying -- with the measured figures, because the interesting part is which
of the two AOT steps actually moves the number.

Also warns about the training run being a real run of the application: it
connects to whatever the configuration points at, in the environment the
cache is for, which is production by default.

(cherry picked from commit ea966fdb61706be18ea46383cff52ec77bb0279b)
The asset pipeline puts them at the root of whatever archive is built. That is
where a war serves its web content from, so a war is right. An executable jar
has no web content: it serves assets by reading them off the classpath, and its
classpath is BOOT-INF/classes -- so the same bytes at the same place, in a jar
rather than a war, are packaged but unreachable. The page renders and every
asset on it is a 404, including the manifest, so the pages do not even ask for
the digest names the pipeline produced.

Adding them under the classpath directory is what makes them found. Only for
bootJar: a war already serves them from the root, and putting them on its
classpath as well would ship the same bytes twice.

Keyed on the assetCompile task rather than the plugin that registers it -- the
asset pipeline's plugin id has changed once already and the task name has not.

(cherry picked from commit c027ce77a80973ed9051b3aaa81019995976e97b)
Re-measured on a quiet machine, against the jar that now carries its assets on
the classpath, and with both caches trained from the same archive so the two
rows are comparable.

Two claims were wrong. Naming paths does move startup, by about a tenth, rather
than not at all; and it takes about a third off the first requests rather than
half. The first figures were taken with the refresh-only cache trained from an
earlier build, and some of them while the machine was busy.

(cherry picked from commit f9706aa3989cf3b32cba335b4d1a716651366af0)
Say that training does not work on Windows, before the run rather than after
it. The cache is written as the training JVM exits normally, so the run has to
be asked to stop -- and Process.destroy() asks on POSIX but kills on Windows,
where it is TerminateProcess and no shutdown runs. The run was being exercised
in full, killed, and the build then failed saying the cache was not written,
which is true and useless.

Skip a path that is not a URI rather than failing the build on one. A path
written without its leading slash makes a URI with no valid authority, and
URI.create throws IllegalArgumentException, which the IOException catch did not
cover -- so a typo in a list whose purpose is to make the next start quicker
failed the build with a stack trace, against this method's stated contract that
nothing in it fails a build.

Declare the task's caching intent, which this build requires of every custom
task type, and say what the extract task reads and writes so a second run skips
it rather than unpacking the same archive again.

The spec now drives a training run that really starts, is asked for its paths
and stops -- so the start detection, the exercise loop, the graceful stop, and
the metadata that is written all have a test that fails if they break. It runs
through a stand-in for the JVM rather than java -jar, because a real one would
have to understand -XX:AOTCacheOutput and that would test which JDK the build
runs on. Replaces a test that asserted a Gradle ListProperty returns what was
set on it, which could not have failed.

(cherry picked from commit c0ec7d18d390021053727c5cf36a992aad009f59)
The definitions declare InstanceFactoryBean<T> rather than T, so that the bean
a definition stands for is known from the definition alone -- which is what
generating bean definitions as code requires, and what asking the factory would
have prevented, since asking it means creating it.

The spec still asserted the older shape and had been failing since the change.

(cherry picked from commit a6cf3dd2329346e9a0e4c698ae7c22fa7cca6780)
This decides whether a scanned type can be registered for reflection, and gets
it wrong in one direction only: a type it wrongly accepts fails the image
build, which is what it exists to prevent.

It read supertypes, method parameters, and the owners of the calls and field
accesses in a method body. So a class naming an absent type as a field's type,
a return type, something it throws or catches, an argument of a call, or a
class literal was accepted -- and an array of an absent type was discarded
outright, since the name of an array was skipped rather than reduced to the
element type that has to exist.

Now also read fields, return types, exceptions, the descriptors of calls and
field accesses, constants, and invokedynamic -- which for Groovy is not an edge
case but how a call site names what it dispatches on.

The spec writes a class naming an absent type in one place and nowhere else,
once per place, and the same class naming a type that loads: so a rejection is
the absent type rather than the shape it was named in.

(cherry picked from commit 0a67cd8e679bb9da32a2d44694d3b967bd645c45)
Three things, all about which JDK and which directory.

Training ran on whichever JDK was running Gradle. A project on the Java 21
baseline with a Java 25 toolchain compiles with 25 and would have trained with
21, where the cache options do not exist -- and what it reported was that the
training run ended before it started serving. It now runs on the toolchain
where one is declared, and on the JDK running the build where one is not, which
is then also the JDK that compiled the application.

The metadata recorded that same JDK, so it described the build's JVM rather
than the one that wrote the cache. That file exists to tell a deployment
whether the cache it has still applies, and a cache is read only by the JDK
build that wrote it -- so naming the wrong one is worse than naming none. It is
now read from the launcher that will run the training.

The cache and its metadata were written into the extracted application, which
is the directory the training task declares as its input. Writing them changed
that input, so the task could never be up to date and every build ran the
application again to record what the last run had already recorded. They now go
beside the extracted application rather than inside it.

Readiness no longer rests only on Spring Boot's startup message, which is an
INFO log an application may reword or switch off; a port that accepts a
connection now counts too.

(cherry picked from commit b32df5c6c343f250a44924e2035a87de245de4ae)
@codeconsole codeconsole changed the title Make Grails applications processable by Spring AOT Make Grails applications processable by Spring AOT (Leyden AOT cache + Graalvm Native Image Support) Aug 7, 2026
@codeconsole codeconsole changed the title Make Grails applications processable by Spring AOT (Leyden AOT cache + Graalvm Native Image Support) Make Grails applications processable by Spring AOT (Leyden AOT cache + GraalVM Native Image Support) Aug 7, 2026
@codeconsole codeconsole changed the title Make Grails applications processable by Spring AOT (Leyden AOT cache + GraalVM Native Image Support) Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) Aug 7, 2026
An image, a JVM handed a cache to read, and one running bean definitions that
were generated all start differently from an ordinary one, and until now all
looked the same. The banner now says which, centred under the art and above the
versions, strongest first: NATIVE, AOT CACHE, AOT. An ordinary start says
nothing, so the line appears only where there is something to say.

In its own colour, brighter than the art, so that what an application was
started as does not read as the last line of the drawing above it.

Written plainly rather than drawn over time. A banner is printed on the thread
that is starting the application, which cannot get on until this returns, so
anything animated here is time the application is not starting -- and an image
that starts in two thirds of a second should not spend a third of it announcing
itself.

The cache is detected from the arguments the JVM was started with rather than
from the cache itself: a JVM handed one it cannot use declines it and starts as
it would have anyway, so the mark says what was asked for, not what was
obtained.
Turning on grails.banner.versions.include stopped a native image from starting:

    Could not find matching constructor for: ...OptionalVersionOption(String, Integer)

A Groovy enum builds its constants through a synthetic $INIT(Object[]) whose body
is this(*para) -- a spread constructor call that cannot be resolved at compile
time, so it goes through the metaclass and enumerates constructors reflectively.
@CompileStatic does not reach it: the call site is already static (GROOVY-10845),
the generated body is not. An image keeps a constructor only when asked, and the
optional enum is asked for only when an application includes a version -- so an
image traced with none carries no record of it, and turning a version on turns
the application off. The three enums are registered here rather than left to a
trace, so what an application configures does not decide whether it starts.
The banner reads a library's version by loading the class that carries it. It
was loading it the initialising way, so the library got to do whatever it does
on the way -- and Spring Security logs a line of its own from its static
initialiser. That line arrived in the middle of the banner, between the mark
and the very versions it was being read for:

    >                          https://grails.apache.org                         <
                                     N A T I V E
    ... INFO ... : You are running with Spring Security Core 7.1.0
           nativedemo2: 0.1 | JVM: GraalVM Community 25.0.4

A version is read about a library rather than from it. The manifest is attached
to the package when the class is loaded, and loading is all this needs, so the
class is now loaded without being initialised. The version still reads; the
library is left to start when something actually wants it.

Note for anyone tempted by the other direction: reading the version through the
library's own accessor -- SpringSecurityCoreVersion.getVersion() -- does not
avoid this. Calling a static method initialises the class, so it logs too.
Spring Security was something an application had to ask for, because a version
that could not be found was shown as unknown and nobody wants a banner that
says it does not know. Now a version that cannot be determined is left out
altogether, which is what lets it be on by default: an application with Spring
Security shows it without configuring anything, and one without it says nothing
rather than saying unknown. Turn it off with

    grails.banner.versions.exclude: [spring-security]

Tomcat is read from the resource it ships its version in rather than from the
manifest. The manifest route only works while a jar is a plain entry on the
classpath: repackaged into an executable jar its attributes are no longer
attached to the package, and an image has no jars at all -- so the container
version read as unknown in exactly the two places it is most worth having. It
now reads 11.0.22.0 in a native image, where it used to read nothing.

findVersion returns null rather than the string unknown, so there is one way to
say "no version" and the caller decides what to do about it.
Every application runs on a servlet container, so the banner shows it without
being asked to. One key, container, covers whichever one it is: an application
does not have to know what it is on to be told about it, and moving between
them needs no configuration change.

An application serves on one container -- choosing another means excluding the
starter for this one, so two are not on the classpath together. They are
therefore tried in the order they are commonly used, and the first one found is
the answer: an application on Tomcat never goes looking for Jetty.

The specific names stay available to include, for an application that wants to
be told about a particular container whether or not it is the one serving. A
container that records no version, or an application on none of these, leaves
the line out rather than saying it does not know.
The pinned JDK moves in four places at once. A release is built to be
reproducible, which means the JDK is part of what is being pinned: the version
in .sdkmanrc, the one the release workflows install, and the one the
verification container is built from all have to be the same, or a rebuild of a
release does not produce the release.

grails-test-examples/gsp-spring-boot keeps its own, older toolchain and is not
part of this set.
Two things a Grails application needs that neither Spring nor GraalVM can work
out for it, and that every application building an image or a cache had to write
for itself.

An image includes what it can prove is reached. Resources are reached by name at
run time -- a compiled asset by request path, a message bundle by locale -- so
nothing proves they are needed and the image is built without them. The
application starts and then serves every page with a missing stylesheet and an
untranslated string.

Generating bean definitions reads the ones the application declares, and an
application declares different ones in different environments: development
declares reloadable beans, which cannot be written out as code. Left at the
default, what is generated is development's, and what is built is not the
application that was asked for.

Both are reactions to a plugin the application applied. Applying GraalVM's
plugin is already how an application says it wants an image, and Spring Boot's
AOT plugin is how it asks for generated definitions -- so neither needs a
setting of its own, and an application that applies neither reaches none of
this and sees no change at all.

Invokedynamic is a convention rather than a decision: an image cannot define the
class a classic call site defines as it runs, so it is on by default where an
image is being built, and an application that has said otherwise keeps what it
said.
The mark was upper cased on its way out, which the three it works out for itself
did not need -- they are written that way -- and which took something away from
the one an application supplies: a name whose case is part of it, CRaC, came out
as CRAC, and an application asking for Leyden was answered LEYDEN.
An image includes what it can prove is reached. Resources are reached by name at
run time -- a compiled asset by request path, a message bundle by locale -- so
nothing proves they are needed and an image is built without them: it starts and
then serves every page with a missing stylesheet and an untranslated string.

They were named with -H:IncludeResources, which GraalVM now warns about twice on
every build: the option is experimental and will have to be unlocked. Hints are
the supported way, and are what Spring writes an image's resource configuration
from -- so this moves out of the Gradle plugin, which was passing build
arguments, and into the framework beside the hints already registered there.
Compiling pages is forked, and what comes out depends on which Groovy and which
Java did it. Neither is described by this task's inputs, so Gradle handed one
build's pages to another that wanted different ones.

An application building a native image resolves Groovy 6 and builds for JDK 25;
one training an AOT cache resolves Groovy 5 and builds for a later JDK. Cached,
the first build's pages were restored into the second and failed at the moment a
page was first rendered:

    BUG! your call tried to do a property set, which is not supported
        at gsp_appindex_gsp.init(gsp_appindex_gsp.groovy)

and, before that, an UnsupportedClassVersionError -- both long after the build
said it had succeeded, and both surviving a clean, which is what made them hard
to place.

Declaring the launcher an input was not enough: it says which Java runs the
fork, not which Groovy the fork compiles against, and the evidence is that it
did not reach the key either. Compiling the pages again costs seconds.
Gradle refuses to validate a task whose directory input does not say how its
path is to be treated, and validatePlugins fails on this branch for that alone
-- an @InputDirectory with no normalization strategy.

The extracted application is compared by what is in it and where each file
sits within it, not by where the directory itself is. Left unsaid, the
absolute path counts as part of the input, so the same application checked out
under another name, or built on CI, would agree about everything and still
share nothing.
A training run that cannot start fails the build with "The training run ended
before it started serving" and the path to a log. The log is eighty lines of
banner, plugin list and class names, and the reason is four of them somewhere
in the middle.

The reason is what someone reading a failed build needs. One of these turned
out to be an environment variable that Spring binds over the application's own
configuration -- GRAILS_MONGODB_URL, empty, reaching the run because the
training JVM inherits the Gradle daemon's environment and the daemon's
environment is not the one the build was started from. Nothing in the build
output said so; it said a run had ended.

Spring Boot already prints why it could not start. This puts those lines in
the failure that reports it, without the box they were printed in, and reads
the log defensively: a log that cannot be read is no reason to lose the
failure already being reported.
@testlens-app

testlens-app Bot commented Aug 8, 2026

Copy link
Copy Markdown

🚨 TestLens detected 1 failed test 🚨

Here is what you can do:

  1. Inspect the test failures carefully.
  2. If you are convinced that some of the tests are flaky, you can mute them below.
  3. Finally, trigger a rerun by checking the rerun checkbox.

Test Summary

CI / Build Grails-Core (windows-latest, 25) > :grails-core:test

Test Runs Flakiness
GrailsBannerNativeMarkSpec > nothing is drawn over, wherever it is read ❌ ❌ ❌ 34% 🔴

🏷️ Commit: 1848212
▶️ Tests: 31527 executed
⚪️ Checks: 63/63 completed

Test Failures

GrailsBannerNativeMarkSpec > nothing is drawn over, wherever it is read (:grails-core:test in CI / Build Grails-Core (windows-latest, 25))
Condition not satisfied:

!output.contains('\r')
||      |
||      true
| 
|>                                                                            <
|>                            ____           _ _                              <
|>                           / ___|_ __ __ _(_) |___                          <
|>                          | |  _| '__/ _` | | / __|                         <
|>                          | |_| | | | (_| | | \__ \                         <
|>                           \____|_|  \__,_|_|_|___/                         <
|>                          https://grails.apache.org                         <
|>                                                                            <
|                                 N A T I V E
|             app: unknown | JVM: BellSoft 25.0.4 | Groovy: 5.0.8              
|                      Spring Boot: 4.1.0 | Spring: 7.0.8                      
false

	at grails.boot.GrailsBannerNativeMarkSpec.nothing is drawn over, wherever it is read(GrailsBannerNativeMarkSpec.groovy:134)

Muted Tests

Select tests to mute in this pull request:

  • GrailsBannerNativeMarkSpec > nothing is drawn over, wherever it is read

Reuse successful test results:

  • ♻️ Only rerun the tests that failed or were muted before

Click the checkbox to trigger a rerun:

  • Rerun jobs

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.

1 participant