diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 00000000000..d3aea1995f3 --- /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/upload-artifact@v7.0.1 + 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/upload-artifact@v7.0.1 + 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/download-artifact@v7.0.0 + 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 c716ea57e84..0071fa61e9f 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 { + static final String AGGREGATE_TASK_NAME = 'jacocoAggregateReport' + @Override void apply(Project project) { project.logger.info("Configuring JaCoCo for project: ${project.name}") @@ -54,5 +62,57 @@ class GrailsJacocoPlugin implements Plugin { 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. + // Hibernate 7 variant subprojects compile identical class names to their Hibernate 5 + // counterparts; including both causes JaCoCo to throw "Can't add different class with + // same name". Exec data from H7 test runs is still included above so their coverage + // is attributed to the H5 class definitions. + if (!project.path.contains('hibernate7')) { + 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 d32427e5d83..5ee04174959 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 00000000000..6c9d67761c9 --- /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