Skip to content

Commit 5c592ef

Browse files
committed
Add code coverage configuration
1 parent 49c55bf commit 5c592ef

4 files changed

Lines changed: 315 additions & 2 deletions

File tree

.github/workflows/coverage.yml

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one or more
2+
# contributor license agreements. See the NOTICE file distributed with
3+
# this work for additional information regarding copyright ownership.
4+
# The ASF licenses this file to You under the Apache License, Version 2.0
5+
# (the "License"); you may not use this file except in compliance with
6+
# the License. You may obtain a copy of the License at
7+
#
8+
# https://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
name: "Coverage"
17+
18+
on:
19+
push:
20+
branches:
21+
- '[0-9]+.[0-9]+.x'
22+
- '8.0.x-hibernate7.*'
23+
pull_request:
24+
workflow_dispatch:
25+
26+
concurrency:
27+
group: ${{ github.workflow }}-${{ github.ref }}
28+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
29+
30+
jobs:
31+
coverage-core:
32+
name: "Coverage - grails-core (${{ matrix.os }})"
33+
if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
34+
strategy:
35+
fail-fast: false
36+
matrix:
37+
os: [ubuntu-24.04, macos-latest, windows-latest]
38+
runs-on: ${{ matrix.os }}
39+
steps:
40+
- name: "📥 Checkout repository"
41+
uses: actions/checkout@v6
42+
- name: "☕️ Setup JDK"
43+
uses: actions/setup-java@v4
44+
with:
45+
distribution: liberica
46+
java-version: 21
47+
- name: "🐘 Setup Gradle"
48+
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
49+
with:
50+
develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
51+
- name: "🌡️ Run tests with coverage"
52+
run: >
53+
./gradlew jacocoAggregateReport
54+
--continue
55+
--stacktrace
56+
-PskipCodeStyle
57+
- name: "📤 Upload coverage artifact"
58+
uses: actions/upload-artifact@v7.0.1
59+
with:
60+
name: coverage-core-${{ matrix.os }}
61+
path: build/reports/jacoco/aggregate/jacocoAggregateReport.xml
62+
if-no-files-found: warn
63+
64+
coverage-gradle:
65+
name: "Coverage - grails-gradle (${{ matrix.os }})"
66+
if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
67+
strategy:
68+
fail-fast: false
69+
matrix:
70+
os: [ubuntu-24.04, macos-latest, windows-latest]
71+
runs-on: ${{ matrix.os }}
72+
steps:
73+
- name: "📥 Checkout repository"
74+
uses: actions/checkout@v6
75+
- name: "☕️ Setup JDK"
76+
uses: actions/setup-java@v4
77+
with:
78+
distribution: liberica
79+
java-version: 21
80+
- name: "🐘 Setup Gradle"
81+
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
82+
with:
83+
develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
84+
- name: "🌡️ Run tests with coverage"
85+
working-directory: grails-gradle
86+
run: >
87+
./gradlew jacocoAggregateReport
88+
--continue
89+
--stacktrace
90+
-PskipCodeStyle
91+
- name: "📤 Upload coverage artifact"
92+
uses: actions/upload-artifact@v7.0.1
93+
with:
94+
name: coverage-gradle-${{ matrix.os }}
95+
path: grails-gradle/build/reports/jacoco/aggregate/jacocoAggregateReport.xml
96+
if-no-files-found: warn
97+
98+
upload-coverage:
99+
name: "Upload Coverage to Codecov"
100+
needs: [coverage-core, coverage-gradle]
101+
# Run even if some matrix legs fail so partial coverage is still uploaded
102+
if: always()
103+
runs-on: ubuntu-24.04
104+
steps:
105+
- name: "📥 Checkout repository"
106+
uses: actions/checkout@v6
107+
- name: "📥 Download all coverage artifacts"
108+
uses: actions/download-artifact@v7.0.0
109+
with:
110+
path: coverage-reports
111+
- name: "📊 Upload coverage to Codecov"
112+
continue-on-error: true
113+
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
114+
with:
115+
token: ${{ secrets.CODECOV_TOKEN }}
116+
directory: coverage-reports
117+
verbose: true

build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsJacocoPlugin.groovy

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,25 @@ import groovy.transform.CompileDynamic
2222

2323
import org.gradle.api.Plugin
2424
import org.gradle.api.Project
25+
import org.gradle.api.plugins.JavaPlugin
2526
import org.gradle.api.tasks.testing.Test
2627
import org.gradle.testing.jacoco.plugins.JacocoPlugin
2728
import org.gradle.testing.jacoco.plugins.JacocoPluginExtension
2829
import org.gradle.testing.jacoco.tasks.JacocoReport
2930

3031
/**
3132
* Convention plugin for JaCoCo code coverage. Apply to each subproject that compiles code.
33+
*
34+
* In addition to configuring per-subproject coverage, this plugin lazily registers a
35+
* jacocoAggregateReport task on the root project the first time it is applied, then wires
36+
* each subproject's exec data into that task. The aggregate produces a single XML report
37+
* at build/reports/jacoco/aggregate/jacocoAggregateReport.xml suitable for Codecov upload.
3238
*/
3339
@CompileDynamic
3440
class GrailsJacocoPlugin implements Plugin<Project> {
3541

42+
static final String AGGREGATE_TASK_NAME = 'jacocoAggregateReport'
43+
3644
@Override
3745
void apply(Project project) {
3846
project.logger.info("Configuring JaCoCo for project: ${project.name}")
@@ -54,5 +62,51 @@ class GrailsJacocoPlugin implements Plugin<Project> {
5462
it.csv.required = true
5563
}
5664
}
65+
66+
contributeToRootAggregateReport(project)
67+
}
68+
69+
private static void contributeToRootAggregateReport(Project project) {
70+
Project root = project.rootProject
71+
72+
// Ensure JacocoPlugin is on the root so its JacocoReport task has tooling available.
73+
// pluginManager.apply is idempotent — safe to call from every subproject.
74+
root.pluginManager.apply(JacocoPlugin)
75+
76+
// Register the aggregate task once on the first apply; subsequent subprojects find it by name.
77+
def aggregateTask
78+
if (root.tasks.names.contains(AGGREGATE_TASK_NAME)) {
79+
aggregateTask = root.tasks.named(AGGREGATE_TASK_NAME, JacocoReport)
80+
} else {
81+
aggregateTask = root.tasks.register(AGGREGATE_TASK_NAME, JacocoReport) { JacocoReport task ->
82+
task.group = 'verification'
83+
task.description = 'Aggregates JaCoCo coverage from all subprojects into a single XML report for Codecov.'
84+
task.reports {
85+
it.xml.required = true
86+
it.xml.outputLocation = root.layout.buildDirectory.file(
87+
'reports/jacoco/aggregate/jacocoAggregateReport.xml'
88+
)
89+
it.html.required = false
90+
it.csv.required = false
91+
}
92+
task.onlyIf { JacocoReport t -> !t.executionData.files.isEmpty() }
93+
}
94+
}
95+
96+
// Wire this subproject's test exec data into the aggregate.
97+
aggregateTask.configure { JacocoReport task ->
98+
task.dependsOn project.tasks.withType(Test)
99+
task.executionData.from(
100+
project.fileTree(project.file('build/jacoco')) { include '*.exec' }
101+
)
102+
}
103+
104+
// Add source and class directories once the Java plugin is confirmed present.
105+
project.plugins.withType(JavaPlugin) {
106+
aggregateTask.configure { JacocoReport task ->
107+
task.sourceDirectories.from(project.sourceSets.main.allSource.srcDirs)
108+
task.classDirectories.from(project.sourceSets.main.output.classesDirs)
109+
}
110+
}
57111
}
58112
}

build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsJacocoPluginSpec.groovy

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,120 @@ class GrailsJacocoPluginSpec extends Specification {
5959
}
6060

6161
def "jacocoTestReport generates xml html and csv reports"() {
62-
given: "no aggregateJacocoCoverage task on a non-root project"
6362
when: "listing all tasks"
6463
def result = GradleRunner.create()
6564
.withProjectDir(testProjectDir.toFile())
6665
.withArguments('tasks', '--all')
6766
.withPluginClasspath()
6867
.build()
6968

70-
then: "aggregateJacocoCoverage is not registered (aggregation is root-only via grails-violation-aggregation)"
69+
then: "aggregateJacocoCoverage is not registered (that task belongs to grails-violation-aggregation)"
7170
!result.output.contains('aggregateJacocoCoverage')
7271
}
72+
73+
def "jacocoAggregateReport is registered on the root project in a multi-project build"() {
74+
given: "a multi-project build where a subproject applies grails-jacoco"
75+
testProjectDir.resolve('settings.gradle').toFile().text = "include 'app-module'"
76+
testProjectDir.resolve('build.gradle').toFile().text = ''
77+
def moduleDir = testProjectDir.resolve('app-module')
78+
moduleDir.toFile().mkdirs()
79+
moduleDir.resolve('build.gradle').toFile().text = """
80+
plugins {
81+
id 'groovy'
82+
id 'org.apache.grails.gradle.grails-jacoco'
83+
}
84+
repositories { mavenCentral() }
85+
"""
86+
87+
when: "listing verification tasks on the root"
88+
def result = GradleRunner.create()
89+
.withProjectDir(testProjectDir.toFile())
90+
.withArguments('tasks', '--group=verification')
91+
.withPluginClasspath()
92+
.build()
93+
94+
then: "jacocoAggregateReport appears on the root"
95+
result.output.contains('jacocoAggregateReport')
96+
}
97+
98+
def "jacocoAggregateReport includes the subproject test task as a dependency"() {
99+
given: "a multi-project build"
100+
testProjectDir.resolve('settings.gradle').toFile().text = "include 'app-module'"
101+
testProjectDir.resolve('build.gradle').toFile().text = ''
102+
def moduleDir = testProjectDir.resolve('app-module')
103+
moduleDir.toFile().mkdirs()
104+
moduleDir.resolve('build.gradle').toFile().text = """
105+
plugins {
106+
id 'groovy'
107+
id 'org.apache.grails.gradle.grails-jacoco'
108+
}
109+
repositories { mavenCentral() }
110+
"""
111+
112+
when: "doing a dry run"
113+
def result = GradleRunner.create()
114+
.withProjectDir(testProjectDir.toFile())
115+
.withArguments('jacocoAggregateReport', '--dry-run')
116+
.withPluginClasspath()
117+
.build()
118+
119+
then: "the subproject test task is in the execution plan"
120+
result.output.contains(':app-module:test')
121+
}
122+
123+
def "jacocoAggregateReport is skipped when no exec files exist"() {
124+
given: "a multi-project build with tests excluded so no exec files are produced"
125+
testProjectDir.resolve('settings.gradle').toFile().text = "include 'app-module'"
126+
testProjectDir.resolve('build.gradle').toFile().text = ''
127+
def moduleDir = testProjectDir.resolve('app-module')
128+
moduleDir.toFile().mkdirs()
129+
moduleDir.resolve('build.gradle').toFile().text = """
130+
plugins {
131+
id 'groovy'
132+
id 'org.apache.grails.gradle.grails-jacoco'
133+
}
134+
repositories { mavenCentral() }
135+
"""
136+
137+
when: "running jacocoAggregateReport with tests excluded"
138+
def result = GradleRunner.create()
139+
.withProjectDir(testProjectDir.toFile())
140+
.withArguments('jacocoAggregateReport', '-x', 'test', '--stacktrace')
141+
.withPluginClasspath()
142+
.build()
143+
144+
then: "the task is skipped because executionData is empty"
145+
result.task(':jacocoAggregateReport').outcome == TaskOutcome.SKIPPED
146+
}
147+
148+
def "each additional subproject with grails-jacoco wires itself into the same aggregate task"() {
149+
given: "two subprojects both applying grails-jacoco"
150+
testProjectDir.resolve('settings.gradle').toFile().text = "include 'module-a', 'module-b'"
151+
testProjectDir.resolve('build.gradle').toFile().text = ''
152+
['module-a', 'module-b'].each { name ->
153+
def dir = testProjectDir.resolve(name)
154+
dir.toFile().mkdirs()
155+
dir.resolve('build.gradle').toFile().text = """
156+
plugins {
157+
id 'groovy'
158+
id 'org.apache.grails.gradle.grails-jacoco'
159+
}
160+
repositories { mavenCentral() }
161+
"""
162+
}
163+
164+
when: "doing a dry run"
165+
def result = GradleRunner.create()
166+
.withProjectDir(testProjectDir.toFile())
167+
.withArguments('jacocoAggregateReport', '--dry-run')
168+
.withPluginClasspath()
169+
.build()
170+
171+
then: "both subproject test tasks appear as dependencies"
172+
result.output.contains(':module-a:test')
173+
result.output.contains(':module-b:test')
174+
175+
and: "only one aggregate task is registered on the root"
176+
result.output.count('jacocoAggregateReport') == 1
177+
}
73178
}

codecov.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one or more
2+
# contributor license agreements. See the NOTICE file distributed with
3+
# this work for additional information regarding copyright ownership.
4+
# The ASF licenses this file to You under the Apache License, Version 2.0
5+
# (the "License"); you may not use this file except in compliance with
6+
# the License. You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
codecov:
17+
require_ci_to_pass: yes
18+
19+
comment:
20+
layout: "reach, diff, flags, files"
21+
behavior: default
22+
require_changes: false
23+
require_base: no
24+
require_head: yes
25+
26+
coverage:
27+
precision: 4
28+
round: nearest
29+
status:
30+
patch:
31+
default:
32+
target: auto
33+
informational: true
34+
project:
35+
default:
36+
target: auto
37+
informational: true

0 commit comments

Comments
 (0)