From 83684a25c897a4370c599a8250b60de03ae88657 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Wed, 2 Sep 2026 20:21:25 -0400 Subject: [PATCH 01/15] deps: bump Groovy to 6.0.0-beta-3 The BOM and every Groovy module follow groovy.version, including groovy-callsite. --- dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies.gradle b/dependencies.gradle index 35c11ad1756..4f40e7b3c0e 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -84,7 +84,7 @@ ext { // extended-scalars compatibility. See https://github.com/apache/grails-core/issues/15674 'graphql-java.version' : '25.0', 'graphql-java-extended-scalars.version': '24.0', - 'groovy.version' : '6.0.0-beta-2', + 'groovy.version' : '6.0.0-beta-3', 'guava.version' : '33.7.1-jre', // Security: overrides spring-boot-dependencies (5.4.2). 5.4.3 fixes CVE-2026-54399 (httpcore5) and CVE-2026-54428 (httpcore5-h2). 'httpcore5.version' : '5.4.3', From 19be0dd580b39234045b1d1537e84a29260aa2cf Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 3 Sep 2026 21:42:41 -0400 Subject: [PATCH 02/15] fix: Groovy 6.0.0-beta-3 STC, taglib Map subscript, and snapshot canary Hoist remaining `||` collection-binding guards so static type checking no longer infers void on ArrayList#leftShift. Document that Groovy 6 routes Map subscript assignment through Map.put. Parse prerelease groovy.version pins in the snapshot canary so it targets Groovy 6 instead of the Micronaut island's Groovy 5 pin. Update Jackson pin comments from beta-2 to beta-3. --- .github/workflows/groovy-snapshot-canary.yml | 16 ++++++++++------ dependencies.gradle | 4 ++-- .../grails/util/AbstractTypeConvertingMap.java | 8 +++++--- .../taglib/GroovyPageAttributesTests.groovy | 16 +++++++++++----- .../web/databinding/GrailsWebDataBinder.groovy | 8 +++++--- 5 files changed, 33 insertions(+), 19 deletions(-) diff --git a/.github/workflows/groovy-snapshot-canary.yml b/.github/workflows/groovy-snapshot-canary.yml index c968658834b..7d3155da26e 100644 --- a/.github/workflows/groovy-snapshot-canary.yml +++ b/.github/workflows/groovy-snapshot-canary.yml @@ -59,14 +59,18 @@ jobs: - name: "📝 Derive matching Apache Groovy branch from dependencies.gradle" id: groovy-branch run: | - # Extract the major and minor version of `groovy.version` declared in this - # branch's dependencies.gradle (e.g. '5.0.5' -> '5_0', '4.0.31' -> '4_0') - # and use it to pick the matching Apache Groovy development branch + # Extract the major and minor version of the main BOM `groovy.version` + # pin (e.g. '5.0.5' -> '5_0', '6.0.0-beta-3' -> '6_0') and use it to + # pick the matching Apache Groovy development branch # (GROOVY___X), or master for the current Groovy development # line before a maintenance branch exists. - # This keeps `7.0.x` PRs validating against Groovy 4 and `8.0.x` PRs - # validating against Groovy 5 without hard-coding the branch here. - GROOVY_MAJOR_MINOR=$(sed -nE "s/.*'groovy\.version'.*'([0-9]+)\.([0-9]+)\.[0-9]+'.*/\1_\2/p" dependencies.gradle | head -n 1) + # Allow prerelease suffixes: a pattern that required a quote immediately + # after the patch number skipped '6.0.0-beta-3' and head -n 1 then landed + # on the Micronaut island's Groovy 5 pin, so this job resolved + # groovy-callsite:5.0.9-SNAPSHOT. + # This keeps `7.0.x` PRs validating against Groovy 4, `8.0.x` against + # Groovy 5, and `9.0.x` against Groovy 6 without hard-coding the branch. + GROOVY_MAJOR_MINOR=$(sed -nE "s/^[[:space:]]*'groovy\.version'[[:space:]]*:[[:space:]]*'([0-9]+)\.([0-9]+)\.[0-9]+.*/\1_\2/p" dependencies.gradle | head -n 1) if [ -z "$GROOVY_MAJOR_MINOR" ]; then echo "::error::Could not determine Apache Groovy major/minor version from dependencies.gradle" exit 1 diff --git a/dependencies.gradle b/dependencies.gradle index 4f40e7b3c0e..1cef2927290 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -88,7 +88,7 @@ ext { 'guava.version' : '33.7.1-jre', // Security: overrides spring-boot-dependencies (5.4.2). 5.4.3 fixes CVE-2026-54399 (httpcore5) and CVE-2026-54428 (httpcore5-h2). 'httpcore5.version' : '5.4.3', - // Groovy YAML 6.0.0-beta-2 requires Jackson 2.22.x, and 2.22.2 also overrides the + // Groovy YAML 6.0.0-beta-3 requires Jackson 2.22.x, and 2.22.2 also overrides the // 2.21.5 Spring Boot 4.1 manages: it adds the StreamReadConstraints length limit to // GregorianCalendar/Duration (CVE-2026-68497) and restricts the URI schemes // java.nio.file.Path deserialization accepts (CVE-2026-19032). Jackson 2 only releases @@ -194,7 +194,7 @@ ext { 'httpcore5' : "org.apache.httpcomponents.core5:httpcore5:${bomDependencyVersions['httpcore5.version']}", 'httpcore5-h2' : "org.apache.httpcomponents.core5:httpcore5-h2:${bomDependencyVersions['httpcore5.version']}", // Security override of the 2.21.5 spring-boot-dependencies imports through - // com.fasterxml.jackson:jackson-bom - see jackson2.version. groovy-yaml 6.0.0-beta-2 + // com.fasterxml.jackson:jackson-bom - see jackson2.version. groovy-yaml 6.0.0-beta-3 // also pulls jackson-dataformat-yaml, so the BOM must manage it at >= the resolved version. 'jackson2-annotations' : "com.fasterxml.jackson.core:jackson-annotations:${bomDependencyVersions['jackson2-annotations.version']}", 'jackson2-core' : "com.fasterxml.jackson.core:jackson-core:${bomDependencyVersions['jackson2.version']}", diff --git a/grails-core/src/main/groovy/grails/util/AbstractTypeConvertingMap.java b/grails-core/src/main/groovy/grails/util/AbstractTypeConvertingMap.java index 70c2cb4cd6a..20e39a5f7a4 100644 --- a/grails-core/src/main/groovy/grails/util/AbstractTypeConvertingMap.java +++ b/grails-core/src/main/groovy/grails/util/AbstractTypeConvertingMap.java @@ -51,9 +51,11 @@ * Expose such a value under a name that is not a JavaBean accessor - for example * {@code GrailsParameterMap.request()} - or under a method that takes an argument. * - * A setter is a weaker case and is allowed: it leaves reads addressing the map, but assignment to - * that one name invokes the setter instead of storing an entry, so such an entry must be written - * with {@link Map#put}. {@code GroovyPageAttributes.setGspTagSyntaxCall(boolean)} is the only one. + * A setter is a weaker case and is allowed: it leaves reads addressing the map, but dotted + * assignment to that one name invokes the setter instead of storing an entry. Groovy 6 + * routes Map subscript assignment through {@link Map#put}, so {@code map['x'] = v} stores + * an entry rather than invoking the setter. Such an entry can also be written with + * {@link Map#put}. {@code GroovyPageAttributes.setGspTagSyntaxCall(boolean)} is the only one. * * @author Graeme Rocher * @author Lari Hotari diff --git a/grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/GroovyPageAttributesTests.groovy b/grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/GroovyPageAttributesTests.groovy index 4f00ccab0c4..c75fdcee8c6 100644 --- a/grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/GroovyPageAttributesTests.groovy +++ b/grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/GroovyPageAttributesTests.groovy @@ -119,20 +119,26 @@ class GroovyPageAttributesTests { } // https://github.com/apache/grails-core/issues/16280 - // gspTagSyntaxCall keeps a real setter, so assigning that one name invokes the setter rather - // than storing an entry - both in dotted and subscript form. That is the Grails 7 behaviour, - // and TagOutput and GroovyPage rely on it. Use put() to store an attribute of that name. + // gspTagSyntaxCall keeps a real setter, so dotted assignment to that one name invokes the + // setter rather than storing an entry. Groovy 6 routes Map subscript assignment through + // Map.put, so attrs['gspTagSyntaxCall'] = v stores an attribute. TagOutput and GroovyPage + // call setGspTagSyntaxCall(boolean) directly. Use put() or subscript to store an attribute + // of that name. @Test void testAssigningGspTagSyntaxCallInvokesTheSetter() { def dotted = toGroovyPageAttributes([:]) dotted.gspTagSyntaxCall = false assertFalse dotted.gspTagSyntaxCall() assertFalse dotted.containsKey('gspTagSyntaxCall') + } + @Test + void testSubscriptAssignmentToGspTagSyntaxCallStoresAnEntry() { def subscript = toGroovyPageAttributes([:]) subscript['gspTagSyntaxCall'] = false - assertFalse subscript.gspTagSyntaxCall() - assertFalse subscript.containsKey('gspTagSyntaxCall') + assertTrue subscript.gspTagSyntaxCall() + assertTrue subscript.containsKey('gspTagSyntaxCall') + assertEquals false, subscript['gspTagSyntaxCall'] } @Test diff --git a/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy b/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy index 987e5f6425b..2763118f0e2 100644 --- a/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy +++ b/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy @@ -472,7 +472,7 @@ class GrailsWebDataBinder extends SimpleDataBinder { Class componentType = metaProperty.type.componentType List boundItems = [] ((Collection) val).each { item -> - // Groovy 6.0.0-beta-2: static type checking merges the flow state of a `||` + // Groovy 6.0.0-beta-3: static type checking merges the flow state of a `||` // inside a closure to void, so the guard is hoisted into a boolean local. boolean matchesComponentType = item == null || componentType.isAssignableFrom(item.getClass()) if (matchesComponentType) { @@ -548,7 +548,9 @@ class GrailsWebDataBinder extends SimpleDataBinder { } } if (persistentInstance == null) { - if (item == null || referencedType.isAssignableFrom(item.getClass())) { + // Groovy 6.0.0-beta-3: see the `||` flow-state note on the array branch above. + boolean matchesReferencedType = item == null || referencedType.isAssignableFrom(item.getClass()) + if (matchesReferencedType) { // Already of the element type, so there is nothing to instantiate // and nothing to bind into. A raw collection always lands here: // Basic#componentType falls back to Object.class when a property @@ -589,7 +591,7 @@ class GrailsWebDataBinder extends SimpleDataBinder { try { Map boundMap = new LinkedHashMap() ((Map) val).each { key, item -> - // Groovy 6.0.0-beta-2: see the `||` flow-state note on the array branch above. + // Groovy 6.0.0-beta-3: see the `||` flow-state note on the array branch above. boolean matchesReferencedType = item == null || referencedType.isAssignableFrom(item.getClass()) if (matchesReferencedType) { boundMap[key] = item From 60a6366da4b857cec11d94be24a816acc31e5efc Mon Sep 17 00:00:00 2001 From: James Fredley Date: Wed, 9 Sep 2026 12:33:56 -0400 Subject: [PATCH 03/15] deps: bump Groovy to 6.0.0-RC-1 The BOM and every Groovy module follow groovy.version, including groovy-callsite. GROOVY-12319 no longer treats parameterized types as class literals, so GrailsBanner, StartMongoGrailsUnitExtension, and the OAuth2 roleNames lookup pass the raw List/Map types. GROOVY-12303 stops groovydoc from aborting on missing jboss-logging during ClassNodeResolver lookup. --- dependencies.gradle | 4 ++-- grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy | 3 ++- .../oauth2/SpringSecurityOauth2BaseService.groovy | 2 +- .../testing/mongo/StartMongoGrailsUnitExtension.groovy | 3 ++- .../grails/web/databinding/GrailsWebDataBinder.groovy | 6 +++--- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/dependencies.gradle b/dependencies.gradle index 1cef2927290..dbc537c67e1 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -84,11 +84,11 @@ ext { // extended-scalars compatibility. See https://github.com/apache/grails-core/issues/15674 'graphql-java.version' : '25.0', 'graphql-java-extended-scalars.version': '24.0', - 'groovy.version' : '6.0.0-beta-3', + 'groovy.version' : '6.0.0-RC-1', 'guava.version' : '33.7.1-jre', // Security: overrides spring-boot-dependencies (5.4.2). 5.4.3 fixes CVE-2026-54399 (httpcore5) and CVE-2026-54428 (httpcore5-h2). 'httpcore5.version' : '5.4.3', - // Groovy YAML 6.0.0-beta-3 requires Jackson 2.22.x, and 2.22.2 also overrides the + // Groovy YAML 6.0.0-RC-1 requires Jackson 2.22.x, and 2.22.2 also overrides the // 2.21.5 Spring Boot 4.1 manages: it adds the StreamReadConstraints length limit to // GregorianCalendar/Duration (CVE-2026-68497) and restricts the URI schemes // java.nio.file.Path deserialization accepts (CVE-2026-19032). Jackson 2 only releases diff --git a/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy b/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy index b6bfbbeaa31..d18516b4ca7 100644 --- a/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy +++ b/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy @@ -511,7 +511,8 @@ class GrailsBanner implements Banner { /** What an application wrote under the given property, or nothing where it wrote none. */ private static List readVersionOptions(Environment env, String propertyName) { - env.getProperty(propertyName, List, [] as List) + // Groovy 6.0.0-RC-1 (GROOVY-12319): parameterized types are not class literals. + env.getProperty(propertyName, List, [] as List) } /** diff --git a/grails-spring-security/oauth2/plugin/grails-app/services/grails/plugin/springsecurity/oauth2/SpringSecurityOauth2BaseService.groovy b/grails-spring-security/oauth2/plugin/grails-app/services/grails/plugin/springsecurity/oauth2/SpringSecurityOauth2BaseService.groovy index d72aa857e97..7e6d64e0ca0 100644 --- a/grails-spring-security/oauth2/plugin/grails-app/services/grails/plugin/springsecurity/oauth2/SpringSecurityOauth2BaseService.groovy +++ b/grails-spring-security/oauth2/plugin/grails-app/services/grails/plugin/springsecurity/oauth2/SpringSecurityOauth2BaseService.groovy @@ -301,7 +301,7 @@ class SpringSecurityOauth2BaseService { * @return The role names for a newly registered user */ def getRoleNames() { - def roleNames = grailsApplication.config.getProperty('grails.plugin.springsecurity.oauth2.registration.roleNames', List, ['ROLE_USER']) + def roleNames = grailsApplication.config.getProperty('grails.plugin.springsecurity.oauth2.registration.roleNames', List, ['ROLE_USER']) return roleNames } } diff --git a/grails-testing-support-mongodb/src/main/groovy/org/apache/grails/testing/mongo/StartMongoGrailsUnitExtension.groovy b/grails-testing-support-mongodb/src/main/groovy/org/apache/grails/testing/mongo/StartMongoGrailsUnitExtension.groovy index 0007914d761..b1db6d01876 100644 --- a/grails-testing-support-mongodb/src/main/groovy/org/apache/grails/testing/mongo/StartMongoGrailsUnitExtension.groovy +++ b/grails-testing-support-mongodb/src/main/groovy/org/apache/grails/testing/mongo/StartMongoGrailsUnitExtension.groovy @@ -100,7 +100,8 @@ class StartMongoGrailsUnitExtension extends AbstractMongoGrailsExtension impleme Package[] packagesArray = packages.toArray(new Package[packages.size()]) Map configuration = ['grails.mongodb.url': createConnectionString(container.getHost(), container.getMappedPort(DEFAULT_MONGO_PORT))] - def datastore = mongoDatastoreClass.getDeclaredConstructor(Map, Package[]).newInstance(configuration, packagesArray) + // Groovy 6.0.0-RC-1 (GROOVY-12319): parameterized types are not class literals. + def datastore = mongoDatastoreClass.getDeclaredConstructor(Map, Package[]).newInstance(configuration, packagesArray) mongoDatastoreField.writeValue(invocation.sharedInstance, datastore) } } diff --git a/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy b/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy index 2763118f0e2..0511260b085 100644 --- a/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy +++ b/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy @@ -472,7 +472,7 @@ class GrailsWebDataBinder extends SimpleDataBinder { Class componentType = metaProperty.type.componentType List boundItems = [] ((Collection) val).each { item -> - // Groovy 6.0.0-beta-3: static type checking merges the flow state of a `||` + // Groovy 6.0.0-RC-1: static type checking merges the flow state of a `||` // inside a closure to void, so the guard is hoisted into a boolean local. boolean matchesComponentType = item == null || componentType.isAssignableFrom(item.getClass()) if (matchesComponentType) { @@ -548,7 +548,7 @@ class GrailsWebDataBinder extends SimpleDataBinder { } } if (persistentInstance == null) { - // Groovy 6.0.0-beta-3: see the `||` flow-state note on the array branch above. + // Groovy 6.0.0-RC-1: see the `||` flow-state note on the array branch above. boolean matchesReferencedType = item == null || referencedType.isAssignableFrom(item.getClass()) if (matchesReferencedType) { // Already of the element type, so there is nothing to instantiate @@ -591,7 +591,7 @@ class GrailsWebDataBinder extends SimpleDataBinder { try { Map boundMap = new LinkedHashMap() ((Map) val).each { key, item -> - // Groovy 6.0.0-beta-3: see the `||` flow-state note on the array branch above. + // Groovy 6.0.0-RC-1: see the `||` flow-state note on the array branch above. boolean matchesReferencedType = item == null || referencedType.isAssignableFrom(item.getClass()) if (matchesReferencedType) { boundMap[key] = item From a430a213f6e39d0f9ca89c8b1e9811be887740b4 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 14 Sep 2026 17:36:44 -0700 Subject: [PATCH 04/15] Fix Groovy 6 class values in unit testing support Pass the resolved class under test to Spring bean lookup and declare the SiteMesh decorator mock with a typed variable. Groovy 6 RC-1 rejects generic type parameters and parameterized types used as class values. Add ServiceUnitTest regression coverage for bean identity, autowiring, and instance reuse. Testing-support core passed 11 tests; SiteMesh passed 82 tests; all four violation reports are clean. The repository-wide local run was stopped at the requester's direction; full validation is delegated to GitHub Actions. --- .../Sitemesh3RenderViewMutatorSpec.groovy | 3 +- .../ParameterizedGrailsUnitTest.groovy | 2 +- .../services/ServiceUnitTestSpec.groovy | 68 +++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 grails-testing-support-core/src/test/groovy/grails/testing/services/ServiceUnitTestSpec.groovy diff --git a/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/Sitemesh3RenderViewMutatorSpec.groovy b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/Sitemesh3RenderViewMutatorSpec.groovy index e8751917d5d..8cd72a7be4b 100644 --- a/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/Sitemesh3RenderViewMutatorSpec.groovy +++ b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/Sitemesh3RenderViewMutatorSpec.groovy @@ -37,8 +37,9 @@ class Sitemesh3RenderViewMutatorSpec extends Specification { View innerView = Mock(View) GrailsSiteMeshView siteMeshView() { + DecoratorSelector decoratorSelector = Mock() new GrailsSiteMeshView(innerView, Mock(ContentProcessor), - Mock(DecoratorSelector), Mock(ServletContext), Mock(ViewResolver)) + decoratorSelector, Mock(ServletContext), Mock(ViewResolver)) } void 'unwraps the SiteMesh view for partial renders without an explicit layout'() { diff --git a/grails-testing-support-core/src/main/groovy/org/grails/testing/ParameterizedGrailsUnitTest.groovy b/grails-testing-support-core/src/main/groovy/org/grails/testing/ParameterizedGrailsUnitTest.groovy index 60e1e7e8ee4..866018fbfaf 100755 --- a/grails-testing-support-core/src/main/groovy/org/grails/testing/ParameterizedGrailsUnitTest.groovy +++ b/grails-testing-support-core/src/main/groovy/org/grails/testing/ParameterizedGrailsUnitTest.groovy @@ -49,7 +49,7 @@ trait ParameterizedGrailsUnitTest extends GrailsUnitTest { mockArtefact(cutType) final String beanName = getBeanName(cutType) if (beanName != null && applicationContext.containsBean(beanName)) { - _artefactInstance = applicationContext.getBean(beanName, T) + _artefactInstance = applicationContext.getBean(beanName, cutType) } else { _artefactInstance = cutType.newInstance() applicationContext.autowireCapableBeanFactory.autowireBeanProperties(_artefactInstance, AutowireCapableBeanFactory.AUTOWIRE_BY_NAME, false) diff --git a/grails-testing-support-core/src/test/groovy/grails/testing/services/ServiceUnitTestSpec.groovy b/grails-testing-support-core/src/test/groovy/grails/testing/services/ServiceUnitTestSpec.groovy new file mode 100644 index 00000000000..0244fbb51bd --- /dev/null +++ b/grails-testing-support-core/src/test/groovy/grails/testing/services/ServiceUnitTestSpec.groovy @@ -0,0 +1,68 @@ +/* + * 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 grails.testing.services + +import spock.lang.Specification + +import grails.compiler.GrailsCompileStatic + +class ServiceUnitTestSpec extends Specification implements ServiceUnitTest { + + void "service returns the registered bean of the concrete type under test"() { + when: + TypedLookupService instance = service + + then: + instance.is(applicationContext.getBean('typedLookupService', TypedLookupService)) + service.is(instance) + } + + void "service is autowired and retains its state across repeated lookups"() { + given: + defineBeans { + lookupCollaborator(LookupCollaborator) + } + + when: + service.recordCall() + service.recordCall() + + then: + service.lookupCollaborator.is(applicationContext.getBean('lookupCollaborator')) + service.calls == 2 + service.lookupCollaborator.calls == 2 + } +} + +@GrailsCompileStatic +class TypedLookupService { + + LookupCollaborator lookupCollaborator + int calls + + void recordCall() { + calls++ + lookupCollaborator.calls++ + } +} + +class LookupCollaborator { + + int calls +} From 043cdba92b5edd6b1aaeccc16309852b9b4448b2 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 14 Sep 2026 19:47:38 -0700 Subject: [PATCH 05/15] deps: bump Groovy to 6.0.0-RC-2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the existing Groovy 6 compatibility fixes and align the dependency comments. Full validation runs in GitHub Actions at the requester’s direction; local diff checks pass. --- dependencies.gradle | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dependencies.gradle b/dependencies.gradle index 9b7d643ec8d..ae48c237c57 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -84,11 +84,11 @@ ext { // extended-scalars compatibility. See https://github.com/apache/grails-core/issues/15674 'graphql-java.version' : '25.0', 'graphql-java-extended-scalars.version': '24.0', - 'groovy.version' : '6.0.0-RC-1', + 'groovy.version' : '6.0.0-RC-2', 'guava.version' : '33.7.1-jre', // Security: overrides spring-boot-dependencies (5.4.2). 5.4.3 fixes CVE-2026-54399 (httpcore5) and CVE-2026-54428 (httpcore5-h2). 'httpcore5.version' : '5.4.3', - // Groovy YAML 6.0.0-RC-1 requires Jackson 2.22.x, and 2.22.2 also overrides the + // Groovy YAML 6.0.0-RC-2 requires Jackson 2.22.x, and 2.22.2 also overrides the // 2.21.5 Spring Boot 4.1 manages: it adds the StreamReadConstraints length limit to // GregorianCalendar/Duration (CVE-2026-68497) and restricts the URI schemes // java.nio.file.Path deserialization accepts (CVE-2026-19032). Jackson 2 only releases @@ -194,7 +194,7 @@ ext { 'httpcore5' : "org.apache.httpcomponents.core5:httpcore5:${bomDependencyVersions['httpcore5.version']}", 'httpcore5-h2' : "org.apache.httpcomponents.core5:httpcore5-h2:${bomDependencyVersions['httpcore5.version']}", // Security override of the 2.21.5 spring-boot-dependencies imports through - // com.fasterxml.jackson:jackson-bom - see jackson2.version. groovy-yaml 6.0.0-beta-3 + // com.fasterxml.jackson:jackson-bom - see jackson2.version. groovy-yaml 6.0.0-RC-2 // also pulls jackson-dataformat-yaml, so the BOM must manage it at >= the resolved version. 'jackson2-annotations' : "com.fasterxml.jackson.core:jackson-annotations:${bomDependencyVersions['jackson2-annotations.version']}", 'jackson2-core' : "com.fasterxml.jackson.core:jackson-core:${bomDependencyVersions['jackson2.version']}", From 3f1f0c33bd6bf5fd6d0f17f95c65f3d106cee8a2 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 14 Sep 2026 19:53:39 -0700 Subject: [PATCH 06/15] refactor: remove the databinding guard workaround fixed in Groovy RC-2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GROOVY-12393 fixes static type inference after inline logical-or guards. Restore the direct conditions in the array, collection, and map branches. Add public binding tests for existing instances, nested maps, and null elements. Verified the original guard fails on RC-1 and passes on RC-2. All 61 grails-web-databinding tests and its codeStyle task pass. Broad validation is delegated to CI at the requester’s direction. --- .../databinding/GrailsWebDataBinder.groovy | 13 +-- .../CollectionElementBindingSpec.groovy | 84 +++++++++++++++++++ 2 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 grails-web-databinding/src/test/groovy/grails/web/databinding/CollectionElementBindingSpec.groovy diff --git a/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy b/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy index 61c08b7ac2a..a36cc37072c 100644 --- a/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy +++ b/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy @@ -472,10 +472,7 @@ class GrailsWebDataBinder extends SimpleDataBinder { Class componentType = metaProperty.type.componentType List boundItems = [] ((Collection) val).each { item -> - // Groovy 6.0.0-RC-1: static type checking merges the flow state of a `||` - // inside a closure to void, so the guard is hoisted into a boolean local. - boolean matchesComponentType = item == null || componentType.isAssignableFrom(item.getClass()) - if (matchesComponentType) { + if (item == null || componentType.isAssignableFrom(item.getClass())) { boundItems << item } else if (item instanceof Map || item instanceof DataBindingSource) { DataBindingSource itemBindingSource = item instanceof DataBindingSource ? @@ -548,9 +545,7 @@ class GrailsWebDataBinder extends SimpleDataBinder { } } if (persistentInstance == null) { - // Groovy 6.0.0-RC-1: see the `||` flow-state note on the array branch above. - boolean matchesReferencedType = item == null || referencedType.isAssignableFrom(item.getClass()) - if (matchesReferencedType) { + if (item == null || referencedType.isAssignableFrom(item.getClass())) { // Already of the element type, so there is nothing to instantiate // and nothing to bind into. A raw collection always lands here: // Basic#componentType falls back to Object.class when a property @@ -591,9 +586,7 @@ class GrailsWebDataBinder extends SimpleDataBinder { try { Map boundMap = new LinkedHashMap() ((Map) val).each { key, item -> - // Groovy 6.0.0-RC-1: see the `||` flow-state note on the array branch above. - boolean matchesReferencedType = item == null || referencedType.isAssignableFrom(item.getClass()) - if (matchesReferencedType) { + if (item == null || referencedType.isAssignableFrom(item.getClass())) { boundMap[key] = item } else if (item instanceof Map || item instanceof DataBindingSource) { def instance diff --git a/grails-web-databinding/src/test/groovy/grails/web/databinding/CollectionElementBindingSpec.groovy b/grails-web-databinding/src/test/groovy/grails/web/databinding/CollectionElementBindingSpec.groovy new file mode 100644 index 00000000000..7819018bc98 --- /dev/null +++ b/grails-web-databinding/src/test/groovy/grails/web/databinding/CollectionElementBindingSpec.groovy @@ -0,0 +1,84 @@ +/* + * 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 grails.web.databinding + +import spock.lang.Specification + +import grails.databinding.SimpleMapDataBindingSource + +class CollectionElementBindingSpec extends Specification { + + void 'list binding preserves existing elements and binds map elements'() { + given: + def existing = new BindingElement(name: 'existing') + def target = new ElementContainer() + + when: + new GrailsWebDataBinder(null).bind(target, new SimpleMapDataBindingSource( + [elements: [existing, [name: 'created']]]), ['elements']) + + then: + target.elements.size() == 2 + target.elements[0].is(existing) + target.elements[1].name == 'created' + } + + void 'array binding preserves null and existing elements while binding maps'() { + given: + def existing = new BindingElement(name: 'existing') + def target = new ElementContainer() + + when: + new GrailsWebDataBinder(null).bind(target, new SimpleMapDataBindingSource( + [array: [existing, null, [name: 'created']]]), ['array']) + + then: + target.array.length == 3 + target.array[0].is(existing) + target.array[1] == null + target.array[2].name == 'created' + } + + void 'map binding preserves null and existing values while binding nested maps'() { + given: + def existing = new BindingElement(name: 'existing') + def target = new ElementContainer() + + when: + new GrailsWebDataBinder(null).bind(target, new SimpleMapDataBindingSource( + [mapped: [first: existing, second: null, third: [name: 'created']]]), ['mapped']) + + then: + target.mapped.size() == 3 + target.mapped.first.is(existing) + target.mapped.containsKey('second') + target.mapped.second == null + target.mapped.third.name == 'created' + } +} + +class ElementContainer { + List elements + BindingElement[] array + Map mapped +} + +class BindingElement { + String name +} From 8ca59ca583c97168f2d9bc65150bcbac59447e59 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 14 Sep 2026 20:09:42 -0700 Subject: [PATCH 07/15] deps: align Ant with Groovy 6 RC-2 RC-2 resolves ant and ant-junit 1.10.18, exceeding the BOM-managed 1.10.17 and failing CI dependency validation. Bump the shared Ant pin to the resolved version. grails-core:validateDependencyVersions passes locally; full validation runs in CI. --- dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies.gradle b/dependencies.gradle index ae48c237c57..9c788a57da3 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -22,7 +22,7 @@ // These files are split to facilitate separation of build vs application dependencies. These are the application dependencies. ext { gradleBomDependencyVersions = [ - 'ant.version' : '1.10.17', + 'ant.version' : '1.10.18', 'asciidoctor-gradle-jvm.version': '4.0.5', 'asciidoctorj.version' : '3.0.1', 'asm.version' : '9.10.1', From d9900920b63a44752cc91dccdc1a22de2012f2dd Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 14 Sep 2026 21:32:04 -0700 Subject: [PATCH 08/15] fix: leave servlet request cleanup to the async lifecycle An async worker can finish after dispatch or completion has transferred the request to another thread or recycled it. Unbind only the worker thread instead of removing the servlet request attribute. Always unbind even if requestCompleted throws. Both new lifecycle tests fail before the fix; all 14 grails-async tests and module codeStyle pass afterward. This addresses the recycled RequestFacade trace seen alongside the SiteMesh async rendering failure. Full functional verification runs in CI. --- .../web/async/GrailsAsyncContext.groovy | 11 ++- .../web/async/GrailsAsyncContextSpec.groovy | 95 +++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 grails-async/plugin/src/test/groovy/org/grails/plugins/web/async/GrailsAsyncContextSpec.groovy diff --git a/grails-async/plugin/src/main/groovy/org/grails/plugins/web/async/GrailsAsyncContext.groovy b/grails-async/plugin/src/main/groovy/org/grails/plugins/web/async/GrailsAsyncContext.groovy index 5f6a0edaa11..e11418a1273 100644 --- a/grails-async/plugin/src/main/groovy/org/grails/plugins/web/async/GrailsAsyncContext.groovy +++ b/grails-async/plugin/src/main/groovy/org/grails/plugins/web/async/GrailsAsyncContext.groovy @@ -23,6 +23,8 @@ import jakarta.servlet.AsyncListener import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import org.springframework.web.context.request.RequestContextHolder + import grails.async.web.AsyncGrailsWebRequest import grails.persistence.support.PersistenceContextInterceptor import org.grails.web.servlet.mvc.GrailsWebRequest @@ -67,8 +69,13 @@ class GrailsAsyncContext implements AsyncContext { for (PersistenceContextInterceptor i in interceptors) { i.destroy() } - webRequest.requestCompleted() - WebUtils.clearGrailsWebRequest() + try { + webRequest.requestCompleted() + } finally { + // Dispatch or completion may already have handed the servlet request back + // to the container. Only unbind this worker; do not change request attributes. + RequestContextHolder.resetRequestAttributes() + } } } } diff --git a/grails-async/plugin/src/test/groovy/org/grails/plugins/web/async/GrailsAsyncContextSpec.groovy b/grails-async/plugin/src/test/groovy/org/grails/plugins/web/async/GrailsAsyncContextSpec.groovy new file mode 100644 index 00000000000..536d104d70f --- /dev/null +++ b/grails-async/plugin/src/test/groovy/org/grails/plugins/web/async/GrailsAsyncContextSpec.groovy @@ -0,0 +1,95 @@ +/* + * 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 org.grails.plugins.web.async + +import jakarta.servlet.AsyncContext + +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.web.context.request.RequestContextHolder + +import spock.lang.Specification + +import org.grails.web.servlet.mvc.GrailsWebRequest +import org.grails.web.util.GrailsApplicationAttributes + +class GrailsAsyncContextSpec extends Specification { + + void cleanup() { + RequestContextHolder.resetRequestAttributes() + } + + void 'worker cleanup does not access a request recycled during completion'() { + given: + def request = new RecyclableRequest() + def response = new MockHttpServletResponse() + AsyncContext delegate = Stub() { + getRequest() >> request + getResponse() >> response + start(_ as Runnable) >> { Runnable worker -> worker.run() } + } + AsyncContext context = new GrailsAsyncContext(delegate, + new GrailsWebRequest(request, response, request.servletContext)) + + when: + context.start { + assert GrailsWebRequest.lookup() != null + request.recycled = true + } + + then: + noExceptionThrown() + RequestContextHolder.requestAttributes == null + } + + void 'worker cleanup preserves the web request installed by an async dispatch'() { + given: + def request = new MockHttpServletRequest() + def response = new MockHttpServletResponse() + def dispatchedRequest = new GrailsWebRequest(request, response, request.servletContext) + AsyncContext delegate = Stub() { + getRequest() >> request + getResponse() >> response + start(_ as Runnable) >> { Runnable worker -> worker.run() } + } + AsyncContext context = new GrailsAsyncContext(delegate, + new GrailsWebRequest(request, response, request.servletContext)) + + when: + context.start { + request.setAttribute(GrailsApplicationAttributes.WEB_REQUEST, dispatchedRequest) + } + + then: + request.getAttribute(GrailsApplicationAttributes.WEB_REQUEST).is(dispatchedRequest) + RequestContextHolder.requestAttributes == null + } + + private static class RecyclableRequest extends MockHttpServletRequest { + boolean recycled + + @Override + void removeAttribute(String name) { + if (recycled) { + throw new IllegalStateException('The request object has been recycled') + } + super.removeAttribute(name) + } + } +} From 7a4ec09969dbaf14a2fd0e5dafb58eb4d690f09c Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 14 Sep 2026 21:37:13 -0700 Subject: [PATCH 09/15] fix: avoid Groovy recursion in browser system property overrides --- .../geb/WebDriverContainerHolder.groovy | 43 +---------- .../plugin/geb/ThreadLocalPropertyScope.java | 77 +++++++++++++++++++ .../org/demo/spock/GebConfigSpec.groovy | 12 +++ 3 files changed, 90 insertions(+), 42 deletions(-) create mode 100644 grails-geb/src/testFixtures/java/grails/plugin/geb/ThreadLocalPropertyScope.java diff --git a/grails-geb/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy b/grails-geb/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy index 014e61ab176..fcbd83dc713 100644 --- a/grails-geb/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy +++ b/grails-geb/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy @@ -465,7 +465,7 @@ class WebDriverContainerHolder { */ static Closure withSystemProperty(Closure target, String key, Object value) { Closure wrapped = { Object... args -> - SysPropScope.withProperty(key, value.toString()) { + ThreadLocalPropertyScope.withProperty(key, value.toString()) { InvokerHelper.invokeClosure(target, args) } } @@ -476,46 +476,5 @@ class WebDriverContainerHolder { } } - @CompileStatic - private static class SysPropScope { - - private static final ThreadLocal> OVERRIDDEN_SYSTEM_PROPERTIES = - ThreadLocal.withInitial { [:] as Map } - - @Lazy // Thread-safe wrapping of system properties - private static Properties propertiesWrappedOnFirstAccess = { - new InterceptingProperties().tap { - putAll(System.getProperties()) - System.setProperties(it) - } - }() - - // Helper method for Groovy 5 static type checking compatibility - private static Map getOverriddenProperties() { - OVERRIDDEN_SYSTEM_PROPERTIES.get() - } - - static T withProperty(String key, String value, Closure body) { - propertiesWrappedOnFirstAccess // Access property to trigger property wrapping - def map = OVERRIDDEN_SYSTEM_PROPERTIES.get() - def prev = map.put(key, value) - try { - return body.call() - } finally { - if (prev == null) map.remove(key) else map[key] = prev - if (map.isEmpty()) OVERRIDDEN_SYSTEM_PROPERTIES.remove() - } - } - - @CompileStatic - private static class InterceptingProperties extends Properties { - @Override - String getProperty(String key) { - Map overrides = getOverriddenProperties() - def v = overrides.get(key) - v != null ? v : super.getProperty(key) - } - } - } } } diff --git a/grails-geb/src/testFixtures/java/grails/plugin/geb/ThreadLocalPropertyScope.java b/grails-geb/src/testFixtures/java/grails/plugin/geb/ThreadLocalPropertyScope.java new file mode 100644 index 00000000000..cdbb4ec8534 --- /dev/null +++ b/grails-geb/src/testFixtures/java/grails/plugin/geb/ThreadLocalPropertyScope.java @@ -0,0 +1,77 @@ +/* + * 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 grails.plugin.geb; + +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import java.util.function.Supplier; + +/** + * Overrides a system property for a custom browser factory on its calling thread. + * Property lookup must stay in Java: Groovy call-site initialization itself reads + * system properties and would recursively enter a Groovy-backed property lookup. + */ +final class ThreadLocalPropertyScope { + + private static final ThreadLocal> OVERRIDES = ThreadLocal.withInitial(HashMap::new); + private static boolean installed; + + private ThreadLocalPropertyScope() { + } + + static T withProperty(String key, String value, Supplier body) { + installProperties(); + Map overrides = OVERRIDES.get(); + String previous = overrides.put(key, value); + try { + return body.get(); + } + finally { + if (previous == null) { + overrides.remove(key); + } + else { + overrides.put(key, previous); + } + if (overrides.isEmpty()) { + OVERRIDES.remove(); + } + } + } + + private static synchronized void installProperties() { + if (!installed) { + Properties properties = new InterceptingProperties(); + properties.putAll(System.getProperties()); + System.setProperties(properties); + installed = true; + } + } + + private static final class InterceptingProperties extends Properties { + private static final long serialVersionUID = 1L; + + @Override + public String getProperty(String key) { + String value = OVERRIDES.get().get(key); + return value != null ? value : super.getProperty(key); + } + } +} diff --git a/grails-test-examples/geb-gebconfig/src/integration-test/groovy/org/demo/spock/GebConfigSpec.groovy b/grails-test-examples/geb-gebconfig/src/integration-test/groovy/org/demo/spock/GebConfigSpec.groovy index 830a716deac..40276cb57cf 100644 --- a/grails-test-examples/geb-gebconfig/src/integration-test/groovy/org/demo/spock/GebConfigSpec.groovy +++ b/grails-test-examples/geb-gebconfig/src/integration-test/groovy/org/demo/spock/GebConfigSpec.groovy @@ -19,6 +19,9 @@ package org.demo.spock +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit + import org.openqa.selenium.remote.RemoteWebDriver import org.demo.spock.pages.HomePage @@ -50,5 +53,14 @@ class GebConfigSpec extends ContainerGebSpec { then: 'the session should be active' driver.sessionId != null + + when: 'a fresh thread reads a system property after the custom driver factory has run' + def propertyRead = new CompletableFuture() + Thread.startDaemon { + propertyRead.complete(System.getProperty('java.version')) + } + + then: 'property lookup does not recursively initialize Groovy call sites' + propertyRead.get(10, TimeUnit.SECONDS) == System.getProperty('java.version') } } From c6d8c3df29d1c6df5d496e06f15f4a540552d4bb Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 14 Sep 2026 22:41:03 -0700 Subject: [PATCH 10/15] ci: follow the Groovy 6 maintenance branch in the canary --- .github/workflows/groovy-snapshot-canary.yml | 21 ++++---------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/.github/workflows/groovy-snapshot-canary.yml b/.github/workflows/groovy-snapshot-canary.yml index a63103edde2..9538c5376ab 100644 --- a/.github/workflows/groovy-snapshot-canary.yml +++ b/.github/workflows/groovy-snapshot-canary.yml @@ -59,28 +59,15 @@ jobs: - name: "📝 Derive matching Apache Groovy branch from dependencies.gradle" id: groovy-branch run: | - # Extract the major and minor version of the main BOM `groovy.version` - # pin (e.g. '5.0.5' -> '5_0', '6.0.0-beta-3' -> '6_0') and use it to - # pick the matching Apache Groovy development branch - # (GROOVY___X), or master for the current Groovy development - # line before a maintenance branch exists. - # Allow prerelease suffixes: a pattern that required a quote immediately - # after the patch number skipped '6.0.0-beta-3' and head -n 1 then landed - # on the Micronaut island's Groovy 5 pin, so this job resolved - # groovy-callsite:5.0.9-SNAPSHOT. - # This keeps `7.0.x` PRs validating against Groovy 4, `8.0.x` against - # Groovy 5, and `9.0.x` against Groovy 6 without hard-coding the branch. + # Extract the main BOM's Groovy release line, ignoring prerelease suffixes + # (e.g. '6.0.0-RC-2' -> '6_0'), and build its matching maintenance branch. + # Groovy master has moved to 7.x; Grails 9 still validates Groovy 6 here. GROOVY_MAJOR_MINOR=$(sed -nE "s/^[[:space:]]*'groovy\.version'[[:space:]]*:[[:space:]]*'([0-9]+)\.([0-9]+)\.[0-9]+.*/\1_\2/p" dependencies.gradle | head -n 1) if [ -z "$GROOVY_MAJOR_MINOR" ]; then echo "::error::Could not determine Apache Groovy major/minor version from dependencies.gradle" exit 1 fi - GROOVY_MAJOR=${GROOVY_MAJOR_MINOR%%_*} - if [ "$GROOVY_MAJOR" = "6" ]; then - GROOVY_BRANCH="master" - else - GROOVY_BRANCH="GROOVY_${GROOVY_MAJOR_MINOR}_X" - fi + GROOVY_BRANCH="GROOVY_${GROOVY_MAJOR_MINOR}_X" echo "Validating against Apache Groovy branch: $GROOVY_BRANCH" echo "value=$GROOVY_BRANCH" >> $GITHUB_OUTPUT rm dependencies.gradle From 900dff8160525fc61c12ed532ad489ffc9f345c4 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 15 Sep 2026 01:08:11 -0700 Subject: [PATCH 11/15] build: serialize Groovydoc generation within the build JVM --- .../buildsrc/GroovydocEnhancerPlugin.groovy | 7 +++ .../buildsrc/GroovydocMemoryThrottle.groovy | 33 +++++++++++ .../GroovydocEnhancerPluginSpec.groovy | 56 +++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocMemoryThrottle.groovy diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy index 8c87a887ac3..e3530e6c331 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy @@ -40,6 +40,13 @@ class GroovydocEnhancerPlugin implements Plugin { GroovydocEnhancerExtension, project ) + Provider throttle = project.gradle.sharedServices.registerIfAbsent( + 'groovydocMemoryThrottle', GroovydocMemoryThrottle) { + it.maxParallelUsages.set(1) + } + project.tasks.withType(Groovydoc).configureEach { + it.usesService(throttle) + } registerDocumentationConfiguration(project) configureGroovydocDefaults(project, extension) configureAntBuilderExecution(project, extension) diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocMemoryThrottle.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocMemoryThrottle.groovy new file mode 100644 index 00000000000..835e97c36de --- /dev/null +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocMemoryThrottle.groovy @@ -0,0 +1,33 @@ +/* + * 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 org.apache.grails.buildsrc + +import groovy.transform.CompileStatic + +import org.gradle.api.services.BuildService +import org.gradle.api.services.BuildServiceParameters + +/** + * Limits Groovydoc to one task at a time in the build JVM. Large documentation + * trees otherwise compete for the same heap during parallel project execution. + */ +@CompileStatic +abstract class GroovydocMemoryThrottle implements BuildService { +} diff --git a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPluginSpec.groovy b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPluginSpec.groovy index c0c29cb7686..c622c4870d5 100644 --- a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPluginSpec.groovy +++ b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPluginSpec.groovy @@ -21,6 +21,8 @@ package org.apache.grails.buildsrc import org.gradle.api.Project import org.gradle.api.tasks.javadoc.Groovydoc import org.gradle.testfixtures.ProjectBuilder +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome import spock.lang.Specification import spock.lang.TempDir @@ -51,4 +53,58 @@ class GroovydocEnhancerPluginSpec extends Specification { groovydocFiles.any { it.name == runtimeOnlyJar.name } groovydocFiles.any { it.name == compileOnlyJar.name } } + + void 'Groovydoc tasks in different projects do not overlap in a parallel build'() { + given: 'two documentation tasks competing for the build JVM heap' + new File(projectDir, 'settings.gradle').text = "include 'one', 'two'" + ['one', 'two'].each { name -> + File directory = new File(projectDir, name) + directory.mkdirs() + new File(directory, 'Sample.groovy').text = 'class Sample {}' + } + new File(projectDir, 'build.gradle').text = """ + plugins { + id 'org.apache.grails.buildsrc.groovydoc-enhancer' apply false + } + subprojects { + apply plugin: 'groovy' + ext.javaVersion = 21 + apply plugin: 'org.apache.grails.buildsrc.groovydoc-enhancer' + tasks.register('apiDocs', org.gradle.api.tasks.javadoc.Groovydoc) { + source file('Sample.groovy') + classpath = files() + groovyClasspath = files() + destinationDir = layout.buildDirectory.dir('docs').get().asFile + // Observe scheduling without generating a large documentation tree. + actions.clear() + doLast { + File active = rootProject.file('active-documentation-task') + assert active.createNewFile(): 'Groovydoc tasks overlapped' + try { + Thread.sleep(1000) + destinationDir.mkdirs() + new File(destinationDir, 'index.html').text = project.name + } finally { + active.delete() + } + } + } + } + """ + + when: 'Gradle can run both subprojects concurrently' + def result = GradleRunner.create() + .withProjectDir(projectDir) + .withPluginClasspath() + .withArguments('apiDocs', '--parallel', '--max-workers=2', + '-Dorg.gradle.jvmargs=-Xmx512m', '--stacktrace') + .build() + + then: 'both tasks finish without simultaneous documentation work' + result.task(':one:apiDocs').outcome == TaskOutcome.SUCCESS + result.task(':two:apiDocs').outcome == TaskOutcome.SUCCESS + new File(projectDir, 'one/build/docs/index.html').text == 'one' + new File(projectDir, 'two/build/docs/index.html').text == 'two' + } + } From 1b2cab5ffdd5a8f849da7255e5b0beb6ca3946ae Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 15 Sep 2026 03:10:07 -0700 Subject: [PATCH 12/15] build: release Groovydoc memory between documentation tasks --- .../GroovydocEnhancerExtension.groovy | 10 ++-- .../buildsrc/GroovydocEnhancerPlugin.groovy | 49 +++++++++++++---- .../buildsrc/GroovydocMemoryThrottle.groovy | 4 +- .../GroovydocEnhancerPluginSpec.groovy | 55 ++++++++++++++++++- gradle.properties | 3 +- 5 files changed, 99 insertions(+), 22 deletions(-) diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerExtension.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerExtension.groovy index 15d61ca98d6..db008503e39 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerExtension.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerExtension.groovy @@ -30,8 +30,8 @@ import org.gradle.api.provider.Property * Extension for configuring the Groovydoc Enhancer convention plugin. * *

This plugin replaces Gradle's built-in Groovydoc task execution with - * a direct AntBuilder invocation of the Groovy {@code org.codehaus.groovy.ant.Groovydoc} - * Ant task. This enables the {@code javaVersion} parameter (added in Groovy 4.0.27, + * an isolated JVM invocation of the Groovy {@code org.codehaus.groovy.ant.Groovydoc} + * Ant task. Each invocation releases its parser memory when the process exits. This enables the {@code javaVersion} parameter (added in Groovy 4.0.27, * GROOVY-11668) which controls the JavaParser language level used when parsing * Java source files.

* @@ -67,9 +67,9 @@ class GroovydocEnhancerExtension { /** * Whether to replace Gradle's built-in Groovydoc task execution with - * AntBuilder invocation. When {@code true} (default), the plugin clears - * the task's actions and replaces them with a {@code doLast} that uses - * AntBuilder. When {@code false}, the plugin only applies property + * an isolated Ant invocation. When {@code true} (default), the plugin clears + * the task's actions and replaces them with a {@code doLast} that runs + * Ant in a separate JVM, honoring the task's Java launcher and maximum memory. When {@code false}, the plugin only applies property * defaults (footer, etc.) and lets Gradle's built-in task run normally. * *

Set to {@code false} when Gradle adds native {@code javaVersion} diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy index e3530e6c331..3f23be950b0 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy @@ -18,8 +18,11 @@ */ package org.apache.grails.buildsrc +import javax.inject.Inject + import groovy.transform.CompileDynamic import groovy.transform.CompileStatic +import groovy.xml.MarkupBuilder import org.gradle.api.Plugin import org.gradle.api.Project @@ -29,9 +32,13 @@ import org.gradle.api.attributes.Usage import org.gradle.api.provider.Provider import org.gradle.api.tasks.SourceSetContainer import org.gradle.api.tasks.javadoc.Groovydoc +import org.gradle.process.ExecOperations @CompileStatic -class GroovydocEnhancerPlugin implements Plugin { +abstract class GroovydocEnhancerPlugin implements Plugin { + + @Inject + protected abstract ExecOperations getExecOperations() @Override void apply(Project project) { @@ -49,7 +56,7 @@ class GroovydocEnhancerPlugin implements Plugin { } registerDocumentationConfiguration(project) configureGroovydocDefaults(project, extension) - configureAntBuilderExecution(project, extension) + configureAntBuilderExecution(project, extension, execOperations) } private static void registerDocumentationConfiguration(Project project) { @@ -94,7 +101,8 @@ class GroovydocEnhancerPlugin implements Plugin { } @CompileDynamic - private static void configureAntBuilderExecution(Project project, GroovydocEnhancerExtension extension) { + private static void configureAntBuilderExecution(Project project, GroovydocEnhancerExtension extension, + ExecOperations execOperations) { project.tasks.withType(Groovydoc).configureEach { gdoc -> if (!extension.useAntBuilder.get()) { return @@ -103,6 +111,7 @@ class GroovydocEnhancerPlugin implements Plugin { // The external javadoc mapping changes the generated HTML, so a change to it has to // invalidate the task's output. gdoc.inputs.property('groovydocLinks', project.provider { resolveLinks(gdoc) }) + gdoc.maxMemory.convention('3g') gdoc.actions.clear() gdoc.doLast { @@ -131,12 +140,6 @@ class GroovydocEnhancerPlugin implements Plugin { // those types into external javadoc URLs. def antClasspath = gdoc.classpath ? classpath.plus(gdoc.classpath) : classpath - project.ant.taskdef( - name: 'groovydoc', - classname: 'org.codehaus.groovy.ant.Groovydoc', - classpath: antClasspath.asPath - ) - def links = resolveLinks(gdoc) def sourcepath = sourceDirs .collect { it.absolutePath } @@ -161,11 +164,33 @@ class GroovydocEnhancerPlugin implements Plugin { antArgs.put('javaVersion', extension.javaVersion.get()) } - project.ant.groovydoc(antArgs) { - for (var l in links) { - link(packages: l.packages, href: l.href) + // A fresh process releases parser trees and classloaders after each task. + // Running sequentially inside Gradle still retains enough state to exhaust + // its heap when the aggregate documentation follows the module docs. + File buildFile = new File(gdoc.temporaryDir, 'groovydoc.xml') + buildFile.withWriter('UTF-8') { writer -> + new MarkupBuilder(writer).project(name: 'groovydoc', default: 'docs') { + taskdef(name: 'groovydoc', classname: 'org.codehaus.groovy.ant.Groovydoc') + target(name: 'docs') { + groovydoc(antArgs) { + for (var l in links) { + link(packages: l.packages, href: l.href) + } + } + } } } + execOperations.javaexec { spec -> + spec.executable = gdoc.javaLauncher.get().executablePath.asFile.absolutePath + spec.classpath(antClasspath) + spec.mainClass.set('org.apache.tools.ant.Main') + String spockCheck = 'spock.iKnowWhatImDoing.disableGroovyVersionCheck' + if (System.getProperty(spockCheck) != null) { + spec.systemProperty(spockCheck, System.getProperty(spockCheck)) + } + spec.maxHeapSize = gdoc.maxMemory.get() + spec.args('-f', buildFile.absolutePath) + }.assertNormalExitValue() } } } diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocMemoryThrottle.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocMemoryThrottle.groovy index 835e97c36de..84dfc33c741 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocMemoryThrottle.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocMemoryThrottle.groovy @@ -25,8 +25,8 @@ import org.gradle.api.services.BuildService import org.gradle.api.services.BuildServiceParameters /** - * Limits Groovydoc to one task at a time in the build JVM. Large documentation - * trees otherwise compete for the same heap during parallel project execution. + * Limits Groovydoc to one task at a time. Its isolated JVMs must not compete + * for runner memory during parallel project execution. */ @CompileStatic abstract class GroovydocMemoryThrottle implements BuildService { diff --git a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPluginSpec.groovy b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPluginSpec.groovy index c622c4870d5..1745f25ef03 100644 --- a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPluginSpec.groovy +++ b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPluginSpec.groovy @@ -54,8 +54,61 @@ class GroovydocEnhancerPluginSpec extends Specification { groovydocFiles.any { it.name == compileOnlyJar.name } } + void 'generates documentation with links and markup in a separate JVM'() { + given: 'a source file and documentation options containing XML-sensitive characters' + new File(projectDir, 'settings.gradle').text = "rootProject.name = 'docs-fixture'" + File source = new File(projectDir, 'src/main/groovy/example/Sample.groovy') + source.parentFile.mkdirs() + source.text = '''package example + /** A documented class. */ + class Sample { + /** Returns a string. */ + String text() { 'example' } + } + ''' + new File(projectDir, 'build.gradle').text = ''' + plugins { + id 'groovy' + id 'org.apache.grails.buildsrc.groovydoc-enhancer' + } + ext.javaVersion = 21 + repositories { mavenCentral() } + dependencies { + implementation 'org.apache.groovy:groovy:6.0.0-RC-2' + runtimeOnly 'org.spockframework:spock-core:2.4-groovy-5.0' + documentation 'org.apache.groovy:groovy-groovydoc:6.0.0-RC-2' + documentation 'org.apache.groovy:groovy-ant:6.0.0-RC-2' + documentation 'org.apache.groovy:groovy-templates:6.0.0-RC-2' + documentation 'com.github.javaparser:javaparser-core:3.28.2' + } + groovydocEnhancer.footer = 'Docs & examples' + tasks.named('groovydoc') { + windowTitle = 'API & examples' + ext.groovydocLinks = [[packages: 'java.', href: 'https://example.org/java/']] + maxMemory = '256m' + } + ''' + + when: 'the public documentation task runs' + def result = GradleRunner.create() + .withProjectDir(projectDir) + .withPluginClasspath() + .withArguments('groovydoc', '--info', '--max-workers=1', + '-Dspock.iKnowWhatImDoing.disableGroovyVersionCheck=true', + '-Dorg.gradle.jvmargs=-Xmx512m', '--stacktrace') + .build() + + then: 'the isolated Ant invocation generates the configured HTML and external links' + result.task(':groovydoc').outcome == TaskOutcome.SUCCESS + result.output.contains('org.apache.tools.ant.Main -f') + String html = new File(projectDir, 'build/docs/groovydoc/example/Sample.html').text + html.contains('Docs & examples') + html.contains('https://example.org/java/java/lang/String.html') + html.contains('A documented class.') + } + void 'Groovydoc tasks in different projects do not overlap in a parallel build'() { - given: 'two documentation tasks competing for the build JVM heap' + given: 'two documentation tasks competing for runner memory' new File(projectDir, 'settings.gradle').text = "include 'one', 'two'" ['one', 'two'].each { name -> File directory = new File(projectDir, name) diff --git a/gradle.properties b/gradle.properties index e22934ede67..99b7b1868ab 100644 --- a/gradle.properties +++ b/gradle.properties @@ -85,8 +85,7 @@ org.gradle.parallel=true org.gradle.daemon=true # Do NOT turn on due to https://github.com/gradle/gradle/issues/9489 #org.gradle.configureondemand=true -# Note: groovydoc requires almost a doubling of this memory; if it could run in a process isolation, we could reduce this -# This is a future TODO see groovydoc-tool-rewrite branch for experiementations with this +# Groovydoc runs in a separate JVM with its own maximum memory setting. # grails8-groovy6-canary: carry Spock's compile-time Groovy version-check opt-out on the build JVM so # the forked gson/gsp view compiler (AbstractGroovyTemplateCompileTask) can propagate it; Spock's global # AST transform otherwise aborts view compilation under Groovy 6. Remove once Spock ships a groovy-6.0 build. From d49beb12cc3273a2e4800a72251b844cb9a4cdb9 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 15 Sep 2026 03:45:36 -0700 Subject: [PATCH 13/15] build: configure Spock for documentation in included builds --- .../apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy | 6 ++---- .../grails/buildsrc/GroovydocEnhancerPluginSpec.groovy | 1 - 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy index 3f23be950b0..f7c2511ce73 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy @@ -184,10 +184,8 @@ abstract class GroovydocEnhancerPlugin implements Plugin { spec.executable = gdoc.javaLauncher.get().executablePath.asFile.absolutePath spec.classpath(antClasspath) spec.mainClass.set('org.apache.tools.ant.Main') - String spockCheck = 'spock.iKnowWhatImDoing.disableGroovyVersionCheck' - if (System.getProperty(spockCheck) != null) { - spec.systemProperty(spockCheck, System.getProperty(spockCheck)) - } + // Included builds (such as Forge) do not inherit the root JVM settings. + spec.systemProperty('spock.iKnowWhatImDoing.disableGroovyVersionCheck', 'true') spec.maxHeapSize = gdoc.maxMemory.get() spec.args('-f', buildFile.absolutePath) }.assertNormalExitValue() diff --git a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPluginSpec.groovy b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPluginSpec.groovy index 1745f25ef03..9f25ee64b44 100644 --- a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPluginSpec.groovy +++ b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPluginSpec.groovy @@ -94,7 +94,6 @@ class GroovydocEnhancerPluginSpec extends Specification { .withProjectDir(projectDir) .withPluginClasspath() .withArguments('groovydoc', '--info', '--max-workers=1', - '-Dspock.iKnowWhatImDoing.disableGroovyVersionCheck=true', '-Dorg.gradle.jvmargs=-Xmx512m', '--stacktrace') .build() From 3ecd2477e6fd29929bb3407917a20d6081e287b2 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 15 Sep 2026 08:11:03 -0700 Subject: [PATCH 14/15] ci: bound Neo4j teardown after successful task completion --- .github/ci-run-neo4j-tests.sh | 37 ++++++++++++ .github/tests/test_ci_run_neo4j.py | 91 ++++++++++++++++++++++++++++++ .github/workflows/gradle.yml | 12 ++-- 3 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 .github/ci-run-neo4j-tests.sh create mode 100644 .github/tests/test_ci_run_neo4j.py diff --git a/.github/ci-run-neo4j-tests.sh b/.github/ci-run-neo4j-tests.sh new file mode 100644 index 00000000000..14ff9c01f08 --- /dev/null +++ b/.github/ci-run-neo4j-tests.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# 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. + +set -euo pipefail + +# The marker is written only after every task has completed, with task failures +# included. Never accept a marker left by an earlier invocation. +rm -f build/ci-build-finished.txt build/ci-build-finished.txt.tmp +set +e +timeout --kill-after=30s 90m ./gradlew bootJar check \ + --init-script .github/ci-exit-after-build.init.gradle \ + --no-daemon --continue --rerun-tasks --stacktrace \ + -PonlyNeo4jTests -PskipCodeStyle "$@" +code=$? +set -e + +if [[ $code == 124 && -f build/ci-build-finished.txt ]] \ + && [[ "$(cat build/ci-build-finished.txt)" == SUCCESS ]]; then + echo 'All Gradle tasks passed; stopped stalled build teardown.' + exit 0 +fi +exit "$code" diff --git a/.github/tests/test_ci_run_neo4j.py b/.github/tests/test_ci_run_neo4j.py new file mode 100644 index 00000000000..5ffb3924520 --- /dev/null +++ b/.github/tests/test_ci_run_neo4j.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +# 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. + +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +SCRIPT = Path(__file__).resolve().parents[1] / 'ci-run-neo4j-tests.sh' + + +class Neo4jExitTests(unittest.TestCase): + def test_exit_status_requires_success_from_this_invocation(self): + cases = [ + (0, 'SUCCESS', None, 0), + (0, None, None, 0), + (1, 'FAILURE', None, 1), + (1, 'SUCCESS', None, 1), + (124, 'SUCCESS', None, 0), + (124, 'FAILURE', None, 124), + (124, None, None, 124), + (124, None, 'SUCCESS', 124), + (124, 'OTHER', None, 124), + (137, 'SUCCESS', None, 137), + ] + for code, marker, stale, expected in cases: + with self.subTest(code=code, marker=marker, stale=stale): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'build').mkdir() + if stale: + (root / 'build/ci-build-finished.txt').write_text(stale) + timeout = root / 'timeout' + timeout.write_text( + '#!/usr/bin/env python3\n' + 'import os\n' + 'import subprocess\n' + 'import sys\n' + 'subprocess.run(sys.argv[3:], check=True)\n' + 'sys.exit(int(os.environ["GRAILS_CI_TEST_TIMEOUT_EXIT"]))\n' + ) + timeout.chmod(0o755) + gradle = root / 'gradlew' + gradle.write_text( + '#!/usr/bin/env python3\n' + 'import os\n' + 'import sys\n' + 'from pathlib import Path\n' + 'Path("args.txt").write_text("\\n".join(sys.argv[1:]))\n' + 'if "GRAILS_CI_TEST_MARKER" in os.environ:\n' + ' Path("build/ci-build-finished.txt").write_text(' + 'os.environ["GRAILS_CI_TEST_MARKER"])\n' + ) + gradle.chmod(0o755) + env = dict( + os.environ, + PATH=f'{root}:{os.environ["PATH"]}', + GRAILS_CI_TEST_TIMEOUT_EXIT=str(code), + ) + env.pop('GRAILS_CI_TEST_MARKER', None) + if marker is not None: + env['GRAILS_CI_TEST_MARKER'] = marker + result = subprocess.run( + ['bash', str(SCRIPT), '-PgrailsIndy=false'], + cwd=root, env=env, capture_output=True, text=True, + ) + self.assertEqual(result.returncode, expected, result.stderr) + args = (root / 'args.txt').read_text().splitlines() + self.assertIn('--init-script', args) + self.assertIn('-PonlyNeo4jTests', args) + self.assertIn('-PgrailsIndy=false', args) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 36058c92d7d..8a3b2ab4452 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -870,6 +870,7 @@ jobs: if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }} name: "Neo4j Functional Tests (Java ${{ matrix.java }}, indy=${{ matrix.indy }})" runs-on: ubuntu-24.04 + timeout-minutes: 105 strategy: fail-fast: false matrix: @@ -896,14 +897,9 @@ jobs: - name: "🏃 Run Functional Tests" env: GITHUB_MAVEN_PASSWORD: ${{ secrets.GITHUB_TOKEN }} - run: > - ./gradlew bootJar check - --continue - --rerun-tasks - --stacktrace - -PgrailsIndy=${{ matrix.indy }} - -PonlyNeo4jTests - -PskipCodeStyle + run: | + python3 .github/tests/test_ci_run_neo4j.py + bash .github/ci-run-neo4j-tests.sh -PgrailsIndy=${{ matrix.indy }} publishGradle: if: github.repository_owner == 'apache' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') needs: [ buildGradle ] From 7bc137f8f7c0d3ea4a986223a3fb8acd77553eae Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 15 Sep 2026 13:47:00 -0700 Subject: [PATCH 15/15] test: retain the existing tag attribute regression name --- .../groovy/org/grails/taglib/GroovyPageAttributesTests.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/GroovyPageAttributesTests.groovy b/grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/GroovyPageAttributesTests.groovy index c75fdcee8c6..75409c83f56 100644 --- a/grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/GroovyPageAttributesTests.groovy +++ b/grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/GroovyPageAttributesTests.groovy @@ -133,7 +133,7 @@ class GroovyPageAttributesTests { } @Test - void testSubscriptAssignmentToGspTagSyntaxCallStoresAnEntry() { + void testSubscriptAssignmentOfGspTagSyntaxCallStoresAnAttribute() { def subscript = toGroovyPageAttributes([:]) subscript['gspTagSyntaxCall'] = false assertTrue subscript.gspTagSyntaxCall()