Skip to content

Commit d9dac8e

Browse files
runningcodeclaude
andauthored
test(spring): [Sample Tests 42] Run each Spring Boot matrix version as its own Gradle task (#6382)
* test(spring): Run each Spring Boot matrix version as its own Gradle task The Spring Boot matrix workflows tested each version by rewriting the catalog with perl, so a version could only be tested locally by editing the catalog, and every job recompiled the SDK and the suites. The new io.sentry.spring-boot-matrix plugin registers a test task per suite and version, e.g. integrationTestWebfluxBoot3_2_12. It reuses the suite's compiled classes on a runtime classpath resolved for that version, which swaps every org.springframework.boot artifact, the BOM included, as the catalog edit did. The resolved classpaths match the catalog edit's exactly. So the SDK and the suites now compile once, against the catalog's version, and only the runtime varies, which is how users run our jars. Suite code may only use APIs of the oldest version of its line. allIntegrationTestsBoot<version> runs one version's suites in a module, and allIntegrationTestsBoot<major> every version's. Each workflow runs the latter from the root in one job per Boot line, with --continue so a failing version doesn't hide the others. The versions live only in SpringBootVersions in build-logic. Resolves JAVA-802 Co-Authored-By: Claude <noreply@anthropic.com> * test(spring): Run each Spring Boot version in its own CI job One job per Boot line doubled the matrix wall time, e.g. 12.5 minutes instead of about 6 for Boot 3. A single runner ran every version's suites, and the bare task name made Gradle configure every project to find it. The versions move to gradle/spring-boot-versions.json. build-logic reads it, and each workflow turns its line into one job per version, which runs that version's aggregate in both modules by project path. The version list stays in one place, and the check names match main's again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(spring): Create the Spring Boot matrix classpaths as resolvable configurations They're only ever resolved, so configurations.resolvable() fits them better than create() with isCanBeConsumed = false: nothing can consume them or declare dependencies on them. Every matrix classpath resolves the same as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1b46292 commit d9dac8e

13 files changed

Lines changed: 289 additions & 57 deletions

File tree

‎.cursor/rules/e2e_tests.mdc‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,18 @@ it. Gradle runs the two in parallel.
6464

6565
## Running them
6666

67-
- The `spring-boot-{2,3,4}-matrix.yml` workflows run the suites against each supported Spring Boot
68-
version, by setting the version in `gradle/libs.versions.toml`. The suites aren't part of
69-
`check`, so a new suite only runs on CI once it's added to its matrix.
67+
- The `springBootMatrix` block in each Spring module's build file runs its suites on every version
68+
of its Spring Boot line in `gradle/spring-boot-versions.json`, with a task per suite and version,
69+
e.g. `integrationTestWebfluxBoot3_2_12`. The suites and the SDK compile once, against the
70+
catalog's version; only the runtime classpath changes. `allIntegrationTestsBoot3_2_12` runs one
71+
version's suites, `allIntegrationTestsBoot3` every version's.
72+
- The `spring-boot-{2,3,4}-matrix.yml` workflows read the same file and run each version in its own
73+
job, e.g. `./gradlew :sentry-spring-jakarta:allIntegrationTestsBoot3_2_12
74+
:sentry-spring-boot-jakarta:allIntegrationTestsBoot3_2_12`. The suites aren't part of `check`, so
75+
a new suite only runs on CI once it's added to its module's `springBootMatrix` block, and a new
76+
module once it's added to its workflow.
77+
- Suite code may only use APIs that exist in the oldest version of its line. Compiled against a
78+
newer one, a missing API fails at runtime with `NoSuchMethodError` or `NoClassDefFoundError`.
7079

7180
## Gotchas
7281

‎.github/workflows/spring-boot-2-matrix.yml‎

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,30 @@ concurrency:
1515
cancel-in-progress: true
1616

1717
jobs:
18+
versions:
19+
runs-on: ubuntu-latest
20+
name: Spring Boot 2.x versions
21+
outputs:
22+
versions: ${{ steps.versions.outputs.versions }}
23+
24+
steps:
25+
- name: Checkout Repo
26+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
27+
with:
28+
sparse-checkout: gradle
29+
30+
- name: Read Spring Boot 2.x versions
31+
id: versions
32+
run: echo "versions=$(jq -c .boot2 gradle/spring-boot-versions.json)" >> "$GITHUB_OUTPUT"
33+
1834
spring-boot-2-matrix:
35+
needs: versions
1936
timeout-minutes: 45
2037
runs-on: ubuntu-latest
2138
strategy:
2239
fail-fast: false
2340
matrix:
24-
springboot-version: [ '2.7.0', '2.7.18' ]
41+
springboot-version: ${{ fromJSON(needs.versions.outputs.versions) }}
2542

2643
name: Spring Boot ${{ matrix.springboot-version }}
2744
env:
@@ -51,21 +68,13 @@ jobs:
5168
with:
5269
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
5370

54-
- name: Update Spring Boot 2.x version
55-
run: |
56-
springboot_version="${{ matrix.springboot-version }}"
57-
perl -0pi -e 'BEGIN { $v = shift } s/^springboot2[[:space:]]*=[[:space:]]*"\K[^"]*/$v/m or die "::error::springboot2 version entry not found in gradle/libs.versions.toml\n"' "$springboot_version" gradle/libs.versions.toml
58-
echo "Updated Spring Boot 2.x version to $springboot_version"
59-
6071
- name: Test integration suites
72+
# Project paths, unlike a bare task name, keep Gradle from configuring every project.
6173
run: |
62-
./gradlew \
63-
:sentry-spring:integrationTest \
64-
:sentry-spring-boot:integrationTest \
65-
:sentry-spring-boot:integrationTestWebflux \
66-
:sentry-spring-boot:integrationTestOtelNoAgent \
67-
:sentry-spring-boot:integrationTestOtelAgent \
68-
:sentry-spring-boot:integrationTestOtelAgentWithoutAutoInit
74+
task="allIntegrationTestsBoot${SPRINGBOOT_VERSION//./_}"
75+
./gradlew ":sentry-spring:$task" ":sentry-spring-boot:$task"
76+
env:
77+
SPRINGBOOT_VERSION: ${{ matrix.springboot-version }}
6978

7079
- name: Upload test results
7180
if: always()

‎.github/workflows/spring-boot-3-matrix.yml‎

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,30 @@ concurrency:
1515
cancel-in-progress: true
1616

1717
jobs:
18+
versions:
19+
runs-on: ubuntu-latest
20+
name: Spring Boot 3.x versions
21+
outputs:
22+
versions: ${{ steps.versions.outputs.versions }}
23+
24+
steps:
25+
- name: Checkout Repo
26+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
27+
with:
28+
sparse-checkout: gradle
29+
30+
- name: Read Spring Boot 3.x versions
31+
id: versions
32+
run: echo "versions=$(jq -c .boot3 gradle/spring-boot-versions.json)" >> "$GITHUB_OUTPUT"
33+
1834
spring-boot-3-matrix:
35+
needs: versions
1936
timeout-minutes: 45
2037
runs-on: ubuntu-latest
2138
strategy:
2239
fail-fast: false
2340
matrix:
24-
springboot-version: [ '3.2.12', '3.3.13', '3.4.13', '3.5.13' ]
41+
springboot-version: ${{ fromJSON(needs.versions.outputs.versions) }}
2542

2643
name: Spring Boot ${{ matrix.springboot-version }}
2744
env:
@@ -51,22 +68,13 @@ jobs:
5168
with:
5269
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
5370

54-
- name: Update Spring Boot 3.x version
55-
run: |
56-
springboot_version="${{ matrix.springboot-version }}"
57-
perl -0pi -e 'BEGIN { $v = shift } s/^springboot3[[:space:]]*=[[:space:]]*"\K[^"]*/$v/m or die "::error::springboot3 version entry not found in gradle/libs.versions.toml\n"' "$springboot_version" gradle/libs.versions.toml
58-
echo "Updated Spring Boot 3.x version to $springboot_version"
59-
6071
- name: Test integration suites
72+
# Project paths, unlike a bare task name, keep Gradle from configuring every project.
6173
run: |
62-
./gradlew \
63-
:sentry-spring-jakarta:integrationTest \
64-
:sentry-spring-boot-jakarta:integrationTest \
65-
:sentry-spring-boot-jakarta:integrationTestWebflux \
66-
:sentry-spring-boot-jakarta:integrationTestOtelNoAgent \
67-
:sentry-spring-boot-jakarta:integrationTestLog4j2 \
68-
:sentry-spring-boot-jakarta:integrationTestOtelAgent \
69-
:sentry-spring-boot-jakarta:integrationTestOtelAgentWithoutAutoInit
74+
task="allIntegrationTestsBoot${SPRINGBOOT_VERSION//./_}"
75+
./gradlew ":sentry-spring-jakarta:$task" ":sentry-spring-boot-jakarta:$task"
76+
env:
77+
SPRINGBOOT_VERSION: ${{ matrix.springboot-version }}
7078

7179
- name: Upload test results
7280
if: always()

‎.github/workflows/spring-boot-4-matrix.yml‎

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,30 @@ concurrency:
1515
cancel-in-progress: true
1616

1717
jobs:
18+
versions:
19+
runs-on: ubuntu-latest
20+
name: Spring Boot 4.x versions
21+
outputs:
22+
versions: ${{ steps.versions.outputs.versions }}
23+
24+
steps:
25+
- name: Checkout Repo
26+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
27+
with:
28+
sparse-checkout: gradle
29+
30+
- name: Read Spring Boot 4.x versions
31+
id: versions
32+
run: echo "versions=$(jq -c .boot4 gradle/spring-boot-versions.json)" >> "$GITHUB_OUTPUT"
33+
1834
spring-boot-4-matrix:
35+
needs: versions
1936
timeout-minutes: 45
2037
runs-on: ubuntu-latest
2138
strategy:
2239
fail-fast: false
2340
matrix:
24-
springboot-version: [ '4.0.0', '4.0.5', '4.1.0' ]
41+
springboot-version: ${{ fromJSON(needs.versions.outputs.versions) }}
2542

2643
name: Spring Boot ${{ matrix.springboot-version }}
2744
env:
@@ -51,23 +68,13 @@ jobs:
5168
with:
5269
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
5370

54-
- name: Update Spring Boot 4.x version
55-
run: |
56-
springboot_version="${{ matrix.springboot-version }}"
57-
perl -0pi -e 'BEGIN { $v = shift } s/^springboot4[[:space:]]*=[[:space:]]*"\K[^"]*/$v/m or die "::error::springboot4 version entry not found in gradle/libs.versions.toml\n"' "$springboot_version" gradle/libs.versions.toml
58-
echo "Updated Spring Boot 4.x version to $springboot_version"
59-
6071
- name: Test integration suites
72+
# Project paths, unlike a bare task name, keep Gradle from configuring every project.
6173
run: |
62-
./gradlew \
63-
:sentry-spring-7:integrationTest \
64-
:sentry-spring-boot-4:integrationTest \
65-
:sentry-spring-boot-4:integrationTestWebflux \
66-
:sentry-spring-boot-4:integrationTestOtelNoAgent \
67-
:sentry-spring-boot-4:integrationTestLog4j2 \
68-
:sentry-spring-boot-4:integrationTestOtelAgent \
69-
:sentry-spring-boot-4:integrationTestOtelAgentWithoutAutoInit \
70-
:sentry-spring-boot-4:integrationTestOtlp
74+
task="allIntegrationTestsBoot${SPRINGBOOT_VERSION//./_}"
75+
./gradlew ":sentry-spring-7:$task" ":sentry-spring-boot-4:$task"
76+
env:
77+
SPRINGBOOT_VERSION: ${{ matrix.springboot-version }}
7178

7279
- name: Upload test results
7380
if: always()

‎build-logic/build.gradle.kts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,9 @@ gradlePlugin {
2424
id = "io.sentry.animalsniffer.android"
2525
implementationClass = "io.sentry.gradle.SentryAnimalSnifferAndroidPlugin"
2626
}
27+
register("sentrySpringBootMatrix") {
28+
id = "io.sentry.spring-boot-matrix"
29+
implementationClass = "io.sentry.gradle.SpringBootMatrixPlugin"
30+
}
2731
}
2832
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package io.sentry.gradle
2+
3+
import groovy.json.JsonSlurper
4+
import javax.inject.Inject
5+
import org.gradle.api.Plugin
6+
import org.gradle.api.Project
7+
import org.gradle.api.file.FileCollection
8+
import org.gradle.api.tasks.SourceSetContainer
9+
import org.gradle.api.tasks.testing.Test
10+
import org.gradle.language.base.plugins.LifecycleBasePlugin
11+
12+
/**
13+
* Runs integration suites on each Spring Boot version of a line, without recompiling anything.
14+
*
15+
* The suites and the SDK compile once, against the version catalog's Spring Boot version. For each
16+
* version, a suite's test task reuses its compiled classes on a runtime classpath resolved for that
17+
* version, which is how users run our published jars on their Spring Boot version.
18+
*/
19+
class SpringBootMatrixPlugin : Plugin<Project> {
20+
override fun apply(project: Project) {
21+
project.extensions.create("springBootMatrix", SpringBootMatrixExtension::class.java)
22+
}
23+
}
24+
25+
abstract class SpringBootMatrixExtension @Inject constructor(private val project: Project) {
26+
private var versions = emptyList<String>()
27+
28+
/**
29+
* Registers `allIntegrationTestsBoot<version>` for each version of Spring Boot [line] in
30+
* `gradle/spring-boot-versions.json`, and `allIntegrationTestsBoot<line>` for all of them. The CI
31+
* workflows read the same file and run each version in its own job. A WAR module uses its Boot
32+
* module's line, because the Boot BOM decides its Spring version.
33+
*/
34+
fun versions(line: Int) {
35+
val file = project.layout.settingsDirectory.file("gradle/spring-boot-versions.json")
36+
val lines =
37+
JsonSlurper().parseText(project.providers.fileContents(file).asText.get()) as Map<*, *>
38+
val versions = (lines["boot$line"] as List<*>).map { it as String }
39+
this.versions = versions
40+
project.tasks.register("allIntegrationTestsBoot$line") {
41+
description = "Runs the integration suites on Spring Boot ${versions.joinToString()}."
42+
group = LifecycleBasePlugin.VERIFICATION_GROUP
43+
dependsOn(versions.map { aggregateName(it) })
44+
}
45+
versions.forEach { version ->
46+
project.tasks.register(aggregateName(version)) {
47+
description = "Runs the integration suites on Spring Boot $version."
48+
group = LifecycleBasePlugin.VERIFICATION_GROUP
49+
}
50+
}
51+
}
52+
53+
/**
54+
* Registers `<name>Boot<version>` for each version, running [suite]'s tests. [name] tells apart
55+
* several tasks of one suite, and [configure] adds what the suite's own task has beyond its
56+
* classes and classpath.
57+
*/
58+
fun test(suite: String, name: String = suite, configure: Test.(version: String) -> Unit = {}) {
59+
check(versions.isNotEmpty()) { "Call versions() before test()" }
60+
versions.forEach { version ->
61+
// Created now rather than with the task, so `dependencies --configuration` finds it.
62+
val runtimeClasspath = runtimeClasspath(suite, version)
63+
val task =
64+
project.tasks.register("$name${suffix(version)}", Test::class.java) {
65+
description = "Runs $name on Spring Boot $version."
66+
group = LifecycleBasePlugin.VERIFICATION_GROUP
67+
testClassesDirs = sourceSet(suite).output.classesDirs
68+
classpath = runtimeClasspath
69+
useJUnit()
70+
configure(version)
71+
}
72+
project.tasks.named(aggregateName(version)) { dependsOn(task) }
73+
}
74+
}
75+
76+
/** [sourceSet]'s output and runtime dependencies, with Spring Boot at [version]. */
77+
fun runtimeClasspath(sourceSet: String, version: String): FileCollection {
78+
val sources = sourceSet(sourceSet)
79+
val name = "$sourceSet${suffix(version)}RuntimeClasspath"
80+
val configurations = project.configurations
81+
val dependencies =
82+
configurations.findByName(name)
83+
?: configurations
84+
.resolvable(name) {
85+
extendsFrom(
86+
configurations.getByName(sources.implementationConfigurationName),
87+
configurations.getByName(sources.runtimeOnlyConfigurationName),
88+
)
89+
attributes.addAllLater(
90+
configurations.getByName(sources.runtimeClasspathConfigurationName).attributes
91+
)
92+
// What setting the version in the catalog does: every Spring Boot artifact, the BOM
93+
// included, is in this group. Libraries pinned to their own version still win
94+
// conflicts.
95+
resolutionStrategy.eachDependency {
96+
if (requested.group == "org.springframework.boot") useVersion(version)
97+
}
98+
}
99+
.get()
100+
return sources.output + dependencies
101+
}
102+
103+
private fun sourceSet(name: String) =
104+
project.extensions.getByType(SourceSetContainer::class.java).getByName(name)
105+
106+
private fun aggregateName(version: String) = "allIntegrationTests${suffix(version)}"
107+
108+
private fun suffix(version: String) = "Boot" + version.replace('.', '_')
109+
}

‎gradle/spring-boot-versions.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"boot2": ["2.7.0", "2.7.18"],
3+
"boot3": ["3.2.12", "3.3.13", "3.4.13", "3.5.13"],
4+
"boot4": ["4.0.0", "4.0.5", "4.1.0"]
5+
}

‎sentry-spring-7/build.gradle.kts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin
55
plugins {
66
`java-library`
77
id("io.sentry.javadoc")
8+
id("io.sentry.spring-boot-matrix")
89
alias(libs.plugins.kotlin.jvm)
910
alias(libs.plugins.errorprone)
1011
alias(libs.plugins.gradle.versions)
@@ -142,3 +143,9 @@ testing {
142143
}
143144
}
144145
}
146+
147+
// The suites on each Spring Boot 4 version the CI matrix tests.
148+
springBootMatrix {
149+
versions(line = 4)
150+
test("integrationTest")
151+
}

‎sentry-spring-boot-4/build.gradle.kts‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin
66
plugins {
77
`java-library`
88
id("io.sentry.javadoc")
9+
id("io.sentry.spring-boot-matrix")
910
alias(libs.plugins.kotlin.jvm)
1011
alias(libs.plugins.kotlin.spring)
1112
alias(libs.plugins.errorprone)
@@ -387,13 +388,17 @@ testing {
387388
/**
388389
* Has the OpenTelemetry agent tests start agentFixture's app in a child JVM, with the built agent
389390
* attached. [autoInit] decides whether the agent initializes Sentry or, as with
390-
* `SENTRY_AUTO_INIT=false`, the Spring Boot starter.
391+
* `SENTRY_AUTO_INIT=false`, the Spring Boot starter. [appClasspath] is the app's classpath for
392+
* another Spring Boot version.
391393
*/
392-
fun Test.runAgentFixture(autoInit: Boolean) {
394+
fun Test.runAgentFixture(
395+
autoInit: Boolean,
396+
appClasspath: FileCollection = agentFixture.runtimeClasspath,
397+
) {
393398
jvmArgumentProviders.add(
394399
objects.newInstance<ChildJvmTestArguments>().apply {
395400
agentJar.from(otelAgentJar)
396-
appClasspath.from(agentFixture.runtimeClasspath)
401+
this.appClasspath.from(appClasspath)
397402
}
398403
)
399404
systemProperty("sentry.test.agentAutoInit", autoInit)
@@ -423,3 +428,19 @@ listOf(
423428
.forEach {
424429
tasks.named<KotlinCompile>(it) { compilerOptions.javaParameters = true }
425430
}
431+
432+
// The suites on each Spring Boot 4 version the CI matrix tests.
433+
springBootMatrix {
434+
versions(line = 4)
435+
test("integrationTest")
436+
test("integrationTestLog4j2")
437+
test("integrationTestWebflux")
438+
test("integrationTestOtelNoAgent")
439+
test("integrationTestOtlp")
440+
test("integrationTestOtelAgent") { version ->
441+
runAgentFixture(autoInit = true, appClasspath = runtimeClasspath("agentFixture", version))
442+
}
443+
test("integrationTestOtelAgent", name = "integrationTestOtelAgentWithoutAutoInit") { version ->
444+
runAgentFixture(autoInit = false, appClasspath = runtimeClasspath("agentFixture", version))
445+
}
446+
}

0 commit comments

Comments
 (0)