This is an automated email from the ASF dual-hosted git repository.

jamesfredley pushed a commit to branch perf/8.0.x-ci-wall-clock-regression
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 8a3a79ffb689ed33e9639585fb6fd1ce10a992b0
Author: James Fredley <[email protected]>
AuthorDate: Fri Aug 7 21:56:50 2026 -0400

    Add deterministic test task sharding
    
    Introduce a root-only build-logic plugin that partitions Test tasks with
    stable SHA-256 assignments while preserving existing execution predicates.
    Cover lifecycle validation, late task registration, aggregate test facades,
    and disjoint shard manifests with TestKit.
    
    Assisted-by: opencode:gpt-5.6-sol
---
 build-logic/plugins/build.gradle                   |   4 +
 .../grails/buildsrc/TestTaskShardingPlugin.groovy  | 174 +++++++++
 .../buildsrc/TestTaskShardingPluginSpec.groovy     | 387 +++++++++++++++++++++
 build.gradle                                       |   3 +-
 4 files changed, 567 insertions(+), 1 deletion(-)

diff --git a/build-logic/plugins/build.gradle b/build-logic/plugins/build.gradle
index d90ef94b82..e6d687e1f8 100644
--- a/build-logic/plugins/build.gradle
+++ b/build-logic/plugins/build.gradle
@@ -123,5 +123,9 @@ gradlePlugin {
             id = 'org.apache.grails.buildsrc.autoconfiguration-imports'
             implementationClass = 
'org.apache.grails.buildsrc.AutoConfigurationImportsPlugin'
         }
+        register('testTaskShardingPlugin') {
+            id = 'org.apache.grails.buildsrc.test-task-sharding'
+            implementationClass = 
'org.apache.grails.buildsrc.TestTaskShardingPlugin'
+        }
     }
 }
diff --git 
a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/TestTaskShardingPlugin.groovy
 
b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/TestTaskShardingPlugin.groovy
new file mode 100644
index 0000000000..d5ad8b4c1c
--- /dev/null
+++ 
b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/TestTaskShardingPlugin.groovy
@@ -0,0 +1,174 @@
+/*
+ *  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 java.math.BigInteger
+import java.nio.charset.StandardCharsets
+import java.security.MessageDigest
+
+import org.gradle.api.GradleException
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.Task
+import org.gradle.api.execution.TaskExecutionGraph
+import org.gradle.api.tasks.testing.Test
+
+/**
+ * Deterministically partitions root-build {@link Test} tasks across CI shards.
+ */
+@CompileStatic
+class TestTaskShardingPlugin implements Plugin<Project> {
+
+    static final String SHARD_COUNT_PROPERTY = 'testShardCount'
+    static final String SHARD_INDEX_PROPERTY = 'testShardIndex'
+    static final String SHARD_TASK_NAME = 'testShard'
+    private static final String MANIFEST_PREFIX = 'TEST_SHARD_MANIFEST'
+
+    @Override
+    void apply(Project project) {
+        if (project != project.rootProject) {
+            throw new GradleException('TestTaskShardingPlugin must be applied 
to the root project only.')
+        }
+
+        ShardConfiguration configuration = readConfiguration(project)
+        if (configuration == null) {
+            return
+        }
+
+        Set<Test> candidateTasks = new LinkedHashSet<>()
+        project.allprojects { Project candidateProject ->
+            candidateProject.tasks.withType(Test).configureEach { Test task ->
+                candidateTasks.add(task)
+                task.onlyIf {
+                    isSelectedForShard(task, configuration)
+                }
+            }
+        }
+        registerShardTask(project, candidateTasks, configuration)
+        project.gradle.taskGraph.whenReady { TaskExecutionGraph taskGraph ->
+            emitManifest(project, candidateTasks, configuration)
+        }
+    }
+
+    private static ShardConfiguration readConfiguration(Project project) {
+        boolean hasCount = project.hasProperty(SHARD_COUNT_PROPERTY)
+        boolean hasIndex = project.hasProperty(SHARD_INDEX_PROPERTY)
+        if (!hasCount && !hasIndex) {
+            return null
+        }
+        if (hasCount != hasIndex) {
+            throw new GradleException("${SHARD_COUNT_PROPERTY} and 
${SHARD_INDEX_PROPERTY} must be supplied together")
+        }
+
+        int shardCount = parseInteger(project, SHARD_COUNT_PROPERTY)
+        int shardIndex = parseInteger(project, SHARD_INDEX_PROPERTY)
+        if (shardCount < 1) {
+            throw new GradleException("${SHARD_COUNT_PROPERTY} must be at 
least 1")
+        }
+        if (shardIndex < 0 || shardIndex >= shardCount) {
+            throw new GradleException("${SHARD_INDEX_PROPERTY} must be in the 
range [0, ${shardCount})")
+        }
+        new ShardConfiguration(shardCount, shardIndex)
+    }
+
+    private static int parseInteger(Project project, String propertyName) {
+        String value = project.findProperty(propertyName)?.toString()
+        try {
+            Integer.parseInt(value)
+        } catch (NumberFormatException ignored) {
+            throw new GradleException("${propertyName} must be an integer")
+        }
+    }
+
+    private static void registerShardTask(Project rootProject, Set<Test> 
candidateTasks, ShardConfiguration configuration) {
+        rootProject.tasks.register(SHARD_TASK_NAME) { Task task ->
+            task.group = 'verification'
+            task.description = 'Runs the Test tasks assigned to the current 
deterministic shard.'
+            task.dependsOn {
+                collectCandidateTasks(rootProject, candidateTasks).findAll { 
Test testTask ->
+                    isSelectedForShard(testTask, configuration)
+                }
+            }
+        }
+    }
+
+    private static void emitManifest(Project rootProject, Set<Test> 
candidateTasks, ShardConfiguration configuration) {
+        List<Test> candidates = collectCandidateTasks(rootProject, 
candidateTasks)
+        List<String> candidatePaths = candidates.collect { Test task -> 
normalizeTaskPath(task.path) }
+        validateUniqueTaskPaths(candidatePaths)
+        if (candidates.empty) {
+            throw new GradleException('No Test tasks were found for sharding')
+        }
+
+        List<String> selectedPaths = candidates.findAll { Test task ->
+            isSelectedForShard(task, configuration)
+        }.collect { Test task -> normalizeTaskPath(task.path) }.sort()
+        rootProject.logger.lifecycle("${MANIFEST_PREFIX} 
totalCandidates=${candidatePaths.size()} shardIndex=${configuration.shardIndex} 
shardCount=${configuration.shardCount} 
selectedTasks=${selectedPaths.join(',')}")
+    }
+
+    private static List<Test> collectCandidateTasks(Project rootProject, 
Set<Test> candidateTasks) {
+        rootProject.allprojects.each { Project project ->
+            project.tasks.withType(Test).each { Test task ->
+                candidateTasks.add(task)
+            }
+        }
+        candidateTasks.toList().sort { Test left, Test right ->
+            normalizeTaskPath(left.path) <=> normalizeTaskPath(right.path)
+        }
+    }
+
+    private static boolean isSelectedForShard(Test task, ShardConfiguration 
configuration) {
+        if (task.project.path == ':grails-test-report') {
+            return configuration.shardIndex == 0
+        }
+        shardFor(normalizeTaskPath(task.path), configuration.shardCount) == 
configuration.shardIndex
+    }
+
+    static void validateUniqueTaskPaths(Collection<String> taskPaths) {
+        Set<String> seen = new LinkedHashSet<>()
+        taskPaths.each { String taskPath ->
+            String normalizedPath = normalizeTaskPath(taskPath)
+            if (!seen.add(normalizedPath)) {
+                throw new IllegalArgumentException("Duplicate normalized 
Gradle Test task path: ${normalizedPath}")
+            }
+        }
+    }
+
+    private static String normalizeTaskPath(String taskPath) {
+        String normalizedPath = taskPath.replace('\\', '/')
+        normalizedPath.startsWith(':') ? normalizedPath : ":${normalizedPath}"
+    }
+
+    static int shardFor(String taskPath, int shardCount) {
+        byte[] digest = 
MessageDigest.getInstance('SHA-256').digest(taskPath.getBytes(StandardCharsets.UTF_8))
+        new BigInteger(1, 
digest).mod(BigInteger.valueOf(shardCount)).intValue()
+    }
+
+    private static final class ShardConfiguration {
+        final int shardCount
+        final int shardIndex
+
+        ShardConfiguration(int shardCount, int shardIndex) {
+            this.shardCount = shardCount
+            this.shardIndex = shardIndex
+        }
+    }
+}
diff --git 
a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/TestTaskShardingPluginSpec.groovy
 
b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/TestTaskShardingPluginSpec.groovy
new file mode 100644
index 0000000000..1a7ae38210
--- /dev/null
+++ 
b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/TestTaskShardingPluginSpec.groovy
@@ -0,0 +1,387 @@
+/*
+ *  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 org.gradle.testkit.runner.BuildResult
+import org.gradle.testkit.runner.GradleRunner
+import org.gradle.testkit.runner.TaskOutcome
+import spock.lang.Specification
+import spock.lang.TempDir
+
+import java.nio.file.Path
+
+class TestTaskShardingPluginSpec extends Specification {
+
+    @TempDir
+    Path testProjectDir
+
+    def setup() {
+        writeMultiProjectFixture(false)
+    }
+
+    def "does nothing when shard properties are absent"() {
+        when:
+        BuildResult result = run('build')
+
+        then:
+        !result.output.contains('TEST_SHARD_MANIFEST')
+        !result.output.contains('testShard')
+        result.task(':alpha:test').outcome == TaskOutcome.NO_SOURCE
+        result.task(':beta:test').outcome == TaskOutcome.NO_SOURCE
+        result.task(':gamma:test').outcome == TaskOutcome.NO_SOURCE
+        result.task(':disabled:test').outcome == TaskOutcome.SKIPPED
+    }
+
+    def "requires valid paired shard properties"() {
+        when:
+        BuildResult result = runFail(*arguments)
+
+        then:
+        result.output.contains(message)
+
+        where:
+        arguments                                                    | message
+        ['help', '-PtestShardCount=2']                              | 
'testShardCount and testShardIndex must be supplied together'
+        ['help', '-PtestShardIndex=0']                              | 
'testShardCount and testShardIndex must be supplied together'
+        ['help', '-PtestShardCount=0', '-PtestShardIndex=0']        | 
'testShardCount must be at least 1'
+        ['help', '-PtestShardCount=2', '-PtestShardIndex=2']        | 
'testShardIndex must be in the range [0, 2)'
+        ['help', '-PtestShardCount=two', '-PtestShardIndex=0']      | 
'testShardCount must be an integer'
+        ['help', '-PtestShardCount=2', '-PtestShardIndex=zero']     | 
'testShardIndex must be an integer'
+    }
+
+    def "rejects application to a non-root project"() {
+        given:
+        testProjectDir.resolve('settings.gradle').toFile().text = "include 
'child'"
+        testProjectDir.resolve('build.gradle').toFile().text = ''
+        def childDir = testProjectDir.resolve('child').toFile()
+        childDir.mkdirs()
+        new File(childDir, 'build.gradle').text = """
+            plugins {
+                id 'org.apache.grails.buildsrc.test-task-sharding'
+            }
+        """
+
+        when:
+        BuildResult result = runFail('help')
+
+        then:
+        result.output.contains('TestTaskShardingPlugin must be applied to the 
root project only.')
+    }
+
+    def "assigns Test task candidates deterministically, disjointly, and 
exhaustively"() {
+        given:
+        Set<String> baseline = [':alpha:test', ':beta:test', ':disabled:test', 
':gamma:test'] as Set
+
+        when:
+        Map<Integer, Set<String>> twoWayAssignments = assignmentsFor(2)
+        Map<Integer, Set<String>> threeWayAssignments = assignmentsFor(3)
+
+        then:
+        assignmentsAreDisjointAndExhaustive(twoWayAssignments, baseline)
+        assignmentsAreDisjointAndExhaustive(threeWayAssignments, baseline)
+
+        and: "repeated invocations select the same paths"
+        shardPaths(run('testShard', '-PtestShardCount=3', 
'-PtestShardIndex=1')) == threeWayAssignments[1]
+    }
+
+    def "preserves existing false onlyIf predicates and filters full builds to 
the current shard"() {
+        when:
+        BuildResult result = run('build', '-PtestShardCount=2', 
'-PtestShardIndex=0')
+        Set<String> selected = shardPaths(result)
+
+        then:
+        selected
+        result.task(':disabled:test').outcome == TaskOutcome.SKIPPED
+
+        and: "selected tasks remain enabled while the other eligible tasks are 
skipped"
+        [':alpha:test', ':beta:test', ':gamma:test'].each { String path ->
+            assert result.task(path).outcome == (selected.contains(path) ? 
TaskOutcome.NO_SOURCE : TaskOutcome.SKIPPED)
+        }
+    }
+
+    def "testShard depends only on selected Test task candidates and emits a 
manifest"() {
+        when:
+        BuildResult result = run('testShard', '-PtestShardCount=3', 
'-PtestShardIndex=2')
+        Set<String> selected = shardPaths(result)
+
+        then:
+        result.task(':testShard').outcome in [TaskOutcome.SUCCESS, 
TaskOutcome.UP_TO_DATE]
+        result.output.contains('TEST_SHARD_MANIFEST totalCandidates=4 
shardIndex=2 shardCount=3 selectedTasks=')
+
+        and:
+        [':alpha:test', ':beta:test', ':disabled:test', ':gamma:test'].each { 
String path ->
+            assert result.output.contains("> Task ${path}") == 
selected.contains(path)
+        }
+    }
+
+    def "fails when sharding is requested without Test tasks"() {
+        given:
+        writeEmptyFixture()
+
+        when:
+        BuildResult result = runFail('help', '-PtestShardCount=2', 
'-PtestShardIndex=0')
+
+        then:
+        result.output.contains('No Test tasks were found for sharding')
+    }
+
+    def "keeps existing onlyIf predicates for execution time"() {
+        given:
+        writeDynamicOnlyFixture()
+
+        when:
+        BuildResult result = run('testShard', '-PtestShardCount=1', 
'-PtestShardIndex=0')
+
+        then:
+        result.task(':dynamic:enableDynamic').outcome == TaskOutcome.SUCCESS
+        result.task(':dynamic:dynamicTest').outcome == TaskOutcome.NO_SOURCE
+    }
+
+    def "includes Test tasks registered by later projectsEvaluated 
callbacks"() {
+        given:
+        writeLifecycleFixture()
+
+        when:
+        BuildResult result = run('testShard', '-PtestShardCount=1', 
'-PtestShardIndex=0')
+
+        then:
+        shardPaths(result).contains(':late:lateTest')
+        result.task(':late:lateTest').outcome == TaskOutcome.NO_SOURCE
+    }
+
+    def "pins the aggregate Test facade to shard zero without scheduling its 
leaf closure in siblings"() {
+        given:
+        writeLifecycleFixture()
+
+        when:
+        BuildResult sibling = run('testShard', '-PtestShardCount=2', 
'-PtestShardIndex=1')
+        Set<String> selected = shardPaths(sibling)
+        String unselectedLeaf = [':alpha:leafTest', ':beta:leafTest', 
':dynamic:dynamicTest', ':late:lateTest'].find { String path ->
+            TestTaskShardingPlugin.shardFor(path, 2) != 1
+        }
+
+        then:
+        !selected.contains(':grails-test-report:test')
+        !sibling.output.contains('> Task :grails-test-report:test')
+        unselectedLeaf != null
+        !sibling.output.contains("> Task ${unselectedLeaf}")
+
+        when:
+        BuildResult shardZeroBuild = run('build', '-PtestShardCount=2', 
'-PtestShardIndex=0')
+
+        then:
+        shardPaths(shardZeroBuild).contains(':grails-test-report:test')
+        shardZeroBuild.task(':grails-test-report:test').outcome == 
TaskOutcome.NO_SOURCE
+        shardZeroBuild.task(':buildMarker').outcome == TaskOutcome.SUCCESS
+
+        and: "the non-Test build marker is not part of sibling testShard jobs"
+        !sibling.output.contains('> Task :buildMarker')
+    }
+
+    def "rejects duplicate normalized task paths"() {
+        when:
+        TestTaskShardingPlugin.validateUniqueTaskPaths([':alpha:test', 
':alpha:test'])
+
+        then:
+        def error = thrown(IllegalArgumentException)
+        error.message == 'Duplicate normalized Gradle Test task path: 
:alpha:test'
+    }
+
+    private Map<Integer, Set<String>> assignmentsFor(int shardCount) {
+        (0..<shardCount).collectEntries { int shardIndex ->
+            BuildResult result = run('testShard', 
"-PtestShardCount=${shardCount}", "-PtestShardIndex=${shardIndex}")
+            [(shardIndex): shardPaths(result)]
+        }
+    }
+
+    private static void assignmentsAreDisjointAndExhaustive(Map<Integer, 
Set<String>> assignments, Set<String> baseline) {
+        Set<String> union = assignments.values().flatten() as Set
+        assert union == baseline
+        assignments.each { int shardIndex, Set<String> paths ->
+            assignments.each { int otherShardIndex, Set<String> otherPaths ->
+                if (shardIndex < otherShardIndex) {
+                    assert paths.intersect(otherPaths).empty
+                }
+            }
+        }
+    }
+
+    private BuildResult run(String... arguments) {
+        GradleRunner.create()
+                .withProjectDir(testProjectDir.toFile())
+                .withArguments(arguments + ['--stacktrace'])
+                .withPluginClasspath()
+                .build()
+    }
+
+    private BuildResult runFail(String... arguments) {
+        GradleRunner.create()
+                .withProjectDir(testProjectDir.toFile())
+                .withArguments(arguments + ['--stacktrace'])
+                .withPluginClasspath()
+                .buildAndFail()
+    }
+
+    private static Set<String> shardPaths(BuildResult result) {
+        String manifest = result.output.readLines().find { 
it.startsWith('TEST_SHARD_MANIFEST ') }
+        assert manifest != null
+        String selected = 
manifest.substring(manifest.indexOf('selectedTasks=') + 
'selectedTasks='.length())
+        selected ? selected.split(',') as Set : [] as Set
+    }
+
+    private void writeMultiProjectFixture(boolean disableAllTests) {
+        testProjectDir.resolve('settings.gradle').toFile().text = "include 
'alpha', 'beta', 'gamma', 'disabled'"
+        testProjectDir.resolve('build.gradle').toFile().text = """
+            plugins {
+                id 'base'
+                id 'org.apache.grails.buildsrc.test-task-sharding'
+            }
+
+            tasks.named('build') {
+                dependsOn(':alpha:test', ':beta:test', ':gamma:test', 
':disabled:test')
+            }
+        """
+        ['alpha', 'beta', 'gamma', 'disabled'].each { String name ->
+            def projectDir = testProjectDir.resolve(name).toFile()
+            projectDir.mkdirs()
+            boolean disabled = disableAllTests || name == 'disabled'
+            new File(projectDir, 'build.gradle').text = """
+                plugins {
+                    id 'java'
+                }
+
+                tasks.named('test') {
+                    onlyIf { ${!disabled} }
+                }
+            """
+        }
+    }
+
+    private void writeEmptyFixture() {
+        testProjectDir.resolve('settings.gradle').toFile().text = ''
+        testProjectDir.resolve('build.gradle').toFile().text = """
+            plugins {
+                id 'base'
+                id 'org.apache.grails.buildsrc.test-task-sharding'
+            }
+        """
+    }
+
+    private void writeLifecycleFixture() {
+        testProjectDir.resolve('settings.gradle').toFile().text = "include 
'alpha', 'beta', 'dynamic', 'grails-test-report', 'late'"
+        testProjectDir.resolve('build.gradle').toFile().text = """
+            import org.gradle.api.tasks.testing.Test
+
+            plugins {
+                id 'base'
+                id 'org.apache.grails.buildsrc.test-task-sharding'
+            }
+
+            tasks.register('buildMarker') {
+                doLast {
+                    logger.lifecycle('BUILD_MARKER')
+                }
+            }
+            tasks.named('build') {
+                dependsOn('buildMarker', ':grails-test-report:test')
+            }
+            gradle.projectsEvaluated {
+                project(':late').tasks.register('lateTest', Test) {
+                    testClassesDirs = 
files(layout.buildDirectory.dir('late-test-classes'))
+                    classpath = files()
+                }
+            }
+        """
+        writeTestTask('alpha', 'leafTest')
+        writeTestTask('beta', 'leafTest')
+        def dynamicDir = testProjectDir.resolve('dynamic').toFile()
+        dynamicDir.mkdirs()
+        new File(dynamicDir, 'build.gradle').text = """
+            import org.gradle.api.tasks.testing.Test
+
+            tasks.register('enableDynamic') {
+                doLast {
+                    rootProject.file('dynamic-enabled').text = 'enabled'
+                }
+            }
+            tasks.register('dynamicTest', Test) {
+                dependsOn('enableDynamic')
+                onlyIf {
+                    rootProject.file('dynamic-enabled').exists()
+                }
+                testClassesDirs = 
files(layout.buildDirectory.dir('dynamic-test-classes'))
+                classpath = files()
+            }
+        """
+        def reportDir = testProjectDir.resolve('grails-test-report').toFile()
+        reportDir.mkdirs()
+        new File(reportDir, 'build.gradle').text = """
+            import org.gradle.api.tasks.testing.Test
+
+            tasks.register('test', Test) {
+                dependsOn(':alpha:leafTest', ':beta:leafTest', 
':dynamic:dynamicTest', ':late:lateTest')
+                testClassesDirs = 
files(layout.buildDirectory.dir('report-test-classes'))
+                classpath = files()
+            }
+        """
+        testProjectDir.resolve('late').toFile().mkdirs()
+    }
+
+    private void writeDynamicOnlyFixture() {
+        testProjectDir.resolve('settings.gradle').toFile().text = "include 
'dynamic'"
+        testProjectDir.resolve('build.gradle').toFile().text = """
+            plugins {
+                id 'base'
+                id 'org.apache.grails.buildsrc.test-task-sharding'
+            }
+        """
+        def dynamicDir = testProjectDir.resolve('dynamic').toFile()
+        dynamicDir.mkdirs()
+        new File(dynamicDir, 'build.gradle').text = """
+            import org.gradle.api.tasks.testing.Test
+
+            tasks.register('enableDynamic') {
+                doLast {
+                    rootProject.file('dynamic-enabled').text = 'enabled'
+                }
+            }
+            tasks.register('dynamicTest', Test) {
+                dependsOn('enableDynamic')
+                onlyIf {
+                    rootProject.file('dynamic-enabled').exists()
+                }
+                testClassesDirs = 
files(layout.buildDirectory.dir('dynamic-test-classes'))
+                classpath = files()
+            }
+        """
+    }
+
+    private void writeTestTask(String projectName, String taskName) {
+        def projectDir = testProjectDir.resolve(projectName).toFile()
+        projectDir.mkdirs()
+        new File(projectDir, 'build.gradle').text = """
+            import org.gradle.api.tasks.testing.Test
+
+            tasks.register('${taskName}', Test) {
+                testClassesDirs = 
files(layout.buildDirectory.dir('${taskName}-classes'))
+                classpath = files()
+            }
+        """
+    }
+}
diff --git a/build.gradle b/build.gradle
index 564acd6aa0..ce00bbd208 100644
--- a/build.gradle
+++ b/build.gradle
@@ -19,6 +19,7 @@ plugins {
     id 'org.apache.grails.gradle.grails-violation-aggregation'
     id 'org.apache.grails.gradle.grails-ij-formatter'
     id 'org.apache.grails.gradle.grails-validate-actions'
+    id 'org.apache.grails.buildsrc.test-task-sharding'
 }
 
 import java.time.Instant
@@ -141,4 +142,4 @@ apply {
 //            logger.lifecycle("\t- ${dep}")
 //        }
 //    }
-//}
\ No newline at end of file
+//}

Reply via email to