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

jdaugherty pushed a commit to branch feature/codecoverage
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 5c592efeed8b567d5af398f6e3d835572b8186ed
Author: James Daugherty <[email protected]>
AuthorDate: Tue May 5 10:19:32 2026 -0400

    Add code coverage configuration
---
 .github/workflows/coverage.yml                     | 117 +++++++++++++++++++++
 .../grails/buildsrc/GrailsJacocoPlugin.groovy      |  54 ++++++++++
 .../grails/buildsrc/GrailsJacocoPluginSpec.groovy  | 109 ++++++++++++++++++-
 codecov.yml                                        |  37 +++++++
 4 files changed, 315 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
new file mode 100644
index 0000000000..d3aea1995f
--- /dev/null
+++ b/.github/workflows/coverage.yml
@@ -0,0 +1,117 @@
+# 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.
+
+name: "Coverage"
+
+on:
+  push:
+    branches:
+      - '[0-9]+.[0-9]+.x'
+      - '8.0.x-hibernate7.*'
+  pull_request:
+  workflow_dispatch:
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+  coverage-core:
+    name: "Coverage - grails-core (${{ matrix.os }})"
+    if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [ubuntu-24.04, macos-latest, windows-latest]
+    runs-on: ${{ matrix.os }}
+    steps:
+      - name: "📥 Checkout repository"
+        uses: actions/checkout@v6
+      - name: "☕️ Setup JDK"
+        uses: actions/setup-java@v4
+        with:
+          distribution: liberica
+          java-version: 21
+      - name: "🐘 Setup Gradle"
+        uses: 
gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
+        with:
+          develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
+      - name: "🌡️ Run tests with coverage"
+        run: >
+          ./gradlew jacocoAggregateReport
+          --continue
+          --stacktrace
+          -PskipCodeStyle
+      - name: "📤 Upload coverage artifact"
+        uses: actions/[email protected]
+        with:
+          name: coverage-core-${{ matrix.os }}
+          path: build/reports/jacoco/aggregate/jacocoAggregateReport.xml
+          if-no-files-found: warn
+
+  coverage-gradle:
+    name: "Coverage - grails-gradle (${{ matrix.os }})"
+    if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [ubuntu-24.04, macos-latest, windows-latest]
+    runs-on: ${{ matrix.os }}
+    steps:
+      - name: "📥 Checkout repository"
+        uses: actions/checkout@v6
+      - name: "☕️ Setup JDK"
+        uses: actions/setup-java@v4
+        with:
+          distribution: liberica
+          java-version: 21
+      - name: "🐘 Setup Gradle"
+        uses: 
gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
+        with:
+          develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
+      - name: "🌡️ Run tests with coverage"
+        working-directory: grails-gradle
+        run: >
+          ./gradlew jacocoAggregateReport
+          --continue
+          --stacktrace
+          -PskipCodeStyle
+      - name: "📤 Upload coverage artifact"
+        uses: actions/[email protected]
+        with:
+          name: coverage-gradle-${{ matrix.os }}
+          path: 
grails-gradle/build/reports/jacoco/aggregate/jacocoAggregateReport.xml
+          if-no-files-found: warn
+
+  upload-coverage:
+    name: "Upload Coverage to Codecov"
+    needs: [coverage-core, coverage-gradle]
+    # Run even if some matrix legs fail so partial coverage is still uploaded
+    if: always()
+    runs-on: ubuntu-24.04
+    steps:
+      - name: "📥 Checkout repository"
+        uses: actions/checkout@v6
+      - name: "📥 Download all coverage artifacts"
+        uses: actions/[email protected]
+        with:
+          path: coverage-reports
+      - name: "📊 Upload coverage to Codecov"
+        continue-on-error: true
+        uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 
# v6.0.0
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
+          directory: coverage-reports
+          verbose: true
diff --git 
a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsJacocoPlugin.groovy
 
b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsJacocoPlugin.groovy
index c716ea57e8..d8d9facd12 100644
--- 
a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsJacocoPlugin.groovy
+++ 
b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsJacocoPlugin.groovy
@@ -22,6 +22,7 @@ import groovy.transform.CompileDynamic
 
 import org.gradle.api.Plugin
 import org.gradle.api.Project
+import org.gradle.api.plugins.JavaPlugin
 import org.gradle.api.tasks.testing.Test
 import org.gradle.testing.jacoco.plugins.JacocoPlugin
 import org.gradle.testing.jacoco.plugins.JacocoPluginExtension
@@ -29,10 +30,17 @@ import org.gradle.testing.jacoco.tasks.JacocoReport
 
 /**
  * Convention plugin for JaCoCo code coverage. Apply to each subproject that 
compiles code.
+ *
+ * In addition to configuring per-subproject coverage, this plugin lazily 
registers a
+ * jacocoAggregateReport task on the root project the first time it is 
applied, then wires
+ * each subproject's exec data into that task. The aggregate produces a single 
XML report
+ * at build/reports/jacoco/aggregate/jacocoAggregateReport.xml suitable for 
Codecov upload.
  */
 @CompileDynamic
 class GrailsJacocoPlugin implements Plugin<Project> {
 
+    static final String AGGREGATE_TASK_NAME = 'jacocoAggregateReport'
+
     @Override
     void apply(Project project) {
         project.logger.info("Configuring JaCoCo for project: ${project.name}")
@@ -54,5 +62,51 @@ class GrailsJacocoPlugin implements Plugin<Project> {
                 it.csv.required = true
             }
         }
+
+        contributeToRootAggregateReport(project)
+    }
+
+    private static void contributeToRootAggregateReport(Project project) {
+        Project root = project.rootProject
+
+        // Ensure JacocoPlugin is on the root so its JacocoReport task has 
tooling available.
+        // pluginManager.apply is idempotent — safe to call from every 
subproject.
+        root.pluginManager.apply(JacocoPlugin)
+
+        // Register the aggregate task once on the first apply; subsequent 
subprojects find it by name.
+        def aggregateTask
+        if (root.tasks.names.contains(AGGREGATE_TASK_NAME)) {
+            aggregateTask = root.tasks.named(AGGREGATE_TASK_NAME, JacocoReport)
+        } else {
+            aggregateTask = root.tasks.register(AGGREGATE_TASK_NAME, 
JacocoReport) { JacocoReport task ->
+                task.group = 'verification'
+                task.description = 'Aggregates JaCoCo coverage from all 
subprojects into a single XML report for Codecov.'
+                task.reports {
+                    it.xml.required = true
+                    it.xml.outputLocation = root.layout.buildDirectory.file(
+                        'reports/jacoco/aggregate/jacocoAggregateReport.xml'
+                    )
+                    it.html.required = false
+                    it.csv.required = false
+                }
+                task.onlyIf { JacocoReport t -> 
!t.executionData.files.isEmpty() }
+            }
+        }
+
+        // Wire this subproject's test exec data into the aggregate.
+        aggregateTask.configure { JacocoReport task ->
+            task.dependsOn project.tasks.withType(Test)
+            task.executionData.from(
+                project.fileTree(project.file('build/jacoco')) { include 
'*.exec' }
+            )
+        }
+
+        // Add source and class directories once the Java plugin is confirmed 
present.
+        project.plugins.withType(JavaPlugin) {
+            aggregateTask.configure { JacocoReport task ->
+                
task.sourceDirectories.from(project.sourceSets.main.allSource.srcDirs)
+                
task.classDirectories.from(project.sourceSets.main.output.classesDirs)
+            }
+        }
     }
 }
diff --git 
a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsJacocoPluginSpec.groovy
 
b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsJacocoPluginSpec.groovy
index d32427e5d8..5ee0417495 100644
--- 
a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsJacocoPluginSpec.groovy
+++ 
b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsJacocoPluginSpec.groovy
@@ -59,7 +59,6 @@ class GrailsJacocoPluginSpec extends Specification {
     }
 
     def "jacocoTestReport generates xml html and csv reports"() {
-        given: "no aggregateJacocoCoverage task on a non-root project"
         when: "listing all tasks"
         def result = GradleRunner.create()
                 .withProjectDir(testProjectDir.toFile())
@@ -67,7 +66,113 @@ class GrailsJacocoPluginSpec extends Specification {
                 .withPluginClasspath()
                 .build()
 
-        then: "aggregateJacocoCoverage is not registered (aggregation is 
root-only via grails-violation-aggregation)"
+        then: "aggregateJacocoCoverage is not registered (that task belongs to 
grails-violation-aggregation)"
         !result.output.contains('aggregateJacocoCoverage')
     }
+
+    def "jacocoAggregateReport is registered on the root project in a 
multi-project build"() {
+        given: "a multi-project build where a subproject applies grails-jacoco"
+        testProjectDir.resolve('settings.gradle').toFile().text = "include 
'app-module'"
+        testProjectDir.resolve('build.gradle').toFile().text = ''
+        def moduleDir = testProjectDir.resolve('app-module')
+        moduleDir.toFile().mkdirs()
+        moduleDir.resolve('build.gradle').toFile().text = """
+            plugins {
+                id 'groovy'
+                id 'org.apache.grails.gradle.grails-jacoco'
+            }
+            repositories { mavenCentral() }
+        """
+
+        when: "listing verification tasks on the root"
+        def result = GradleRunner.create()
+                .withProjectDir(testProjectDir.toFile())
+                .withArguments('tasks', '--group=verification')
+                .withPluginClasspath()
+                .build()
+
+        then: "jacocoAggregateReport appears on the root"
+        result.output.contains('jacocoAggregateReport')
+    }
+
+    def "jacocoAggregateReport includes the subproject test task as a 
dependency"() {
+        given: "a multi-project build"
+        testProjectDir.resolve('settings.gradle').toFile().text = "include 
'app-module'"
+        testProjectDir.resolve('build.gradle').toFile().text = ''
+        def moduleDir = testProjectDir.resolve('app-module')
+        moduleDir.toFile().mkdirs()
+        moduleDir.resolve('build.gradle').toFile().text = """
+            plugins {
+                id 'groovy'
+                id 'org.apache.grails.gradle.grails-jacoco'
+            }
+            repositories { mavenCentral() }
+        """
+
+        when: "doing a dry run"
+        def result = GradleRunner.create()
+                .withProjectDir(testProjectDir.toFile())
+                .withArguments('jacocoAggregateReport', '--dry-run')
+                .withPluginClasspath()
+                .build()
+
+        then: "the subproject test task is in the execution plan"
+        result.output.contains(':app-module:test')
+    }
+
+    def "jacocoAggregateReport is skipped when no exec files exist"() {
+        given: "a multi-project build with tests excluded so no exec files are 
produced"
+        testProjectDir.resolve('settings.gradle').toFile().text = "include 
'app-module'"
+        testProjectDir.resolve('build.gradle').toFile().text = ''
+        def moduleDir = testProjectDir.resolve('app-module')
+        moduleDir.toFile().mkdirs()
+        moduleDir.resolve('build.gradle').toFile().text = """
+            plugins {
+                id 'groovy'
+                id 'org.apache.grails.gradle.grails-jacoco'
+            }
+            repositories { mavenCentral() }
+        """
+
+        when: "running jacocoAggregateReport with tests excluded"
+        def result = GradleRunner.create()
+                .withProjectDir(testProjectDir.toFile())
+                .withArguments('jacocoAggregateReport', '-x', 'test', 
'--stacktrace')
+                .withPluginClasspath()
+                .build()
+
+        then: "the task is skipped because executionData is empty"
+        result.task(':jacocoAggregateReport').outcome == TaskOutcome.SKIPPED
+    }
+
+    def "each additional subproject with grails-jacoco wires itself into the 
same aggregate task"() {
+        given: "two subprojects both applying grails-jacoco"
+        testProjectDir.resolve('settings.gradle').toFile().text = "include 
'module-a', 'module-b'"
+        testProjectDir.resolve('build.gradle').toFile().text = ''
+        ['module-a', 'module-b'].each { name ->
+            def dir = testProjectDir.resolve(name)
+            dir.toFile().mkdirs()
+            dir.resolve('build.gradle').toFile().text = """
+                plugins {
+                    id 'groovy'
+                    id 'org.apache.grails.gradle.grails-jacoco'
+                }
+                repositories { mavenCentral() }
+            """
+        }
+
+        when: "doing a dry run"
+        def result = GradleRunner.create()
+                .withProjectDir(testProjectDir.toFile())
+                .withArguments('jacocoAggregateReport', '--dry-run')
+                .withPluginClasspath()
+                .build()
+
+        then: "both subproject test tasks appear as dependencies"
+        result.output.contains(':module-a:test')
+        result.output.contains(':module-b:test')
+
+        and: "only one aggregate task is registered on the root"
+        result.output.count('jacocoAggregateReport') == 1
+    }
 }
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 0000000000..6c9d67761c
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,37 @@
+# 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
+#
+#     http://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.
+
+codecov:
+  require_ci_to_pass: yes
+
+comment:
+  layout: "reach, diff, flags, files"
+  behavior: default
+  require_changes: false
+  require_base: no
+  require_head: yes
+
+coverage:
+  precision: 4
+  round: nearest
+  status:
+    patch:
+      default:
+        target: auto
+        informational: true
+    project:
+      default:
+        target: auto
+        informational: true

Reply via email to