Skip to content

Commit 033bff7

Browse files
runningcodeclaude
andcommitted
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>
1 parent bed55a4 commit 033bff7

13 files changed

Lines changed: 238 additions & 81 deletions

File tree

‎.cursor/rules/e2e_tests.mdc‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,16 @@ 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 Spring
68+
Boot version in `SpringBootVersions` (build-logic), with a task per suite and version, e.g.
69+
`integrationTestWebfluxBoot3_2_12`. The suites and the SDK compile once, against the catalog's
70+
version; only the runtime classpath changes. `allIntegrationTestsBoot3_2_12` runs one version's
71+
suites, `allIntegrationTestsBoot3` every version's.
72+
- The `spring-boot-{2,3,4}-matrix.yml` workflows run `./gradlew allIntegrationTestsBoot<major>`,
73+
which picks up every module of the line. The suites aren't part of `check`, so a new suite only
74+
runs on CI once it's added to its module's `springBootMatrix` block.
75+
- Suite code may only use APIs that exist in the oldest version of its line. Compiled against a
76+
newer one, a missing API fails at runtime with `NoSuchMethodError` or `NoClassDefFoundError`.
7077

7178
## Gotchas
7279

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

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,7 @@ jobs:
1818
spring-boot-2-matrix:
1919
timeout-minutes: 45
2020
runs-on: ubuntu-latest
21-
strategy:
22-
fail-fast: false
23-
matrix:
24-
springboot-version: [ '2.7.0', '2.7.18' ]
25-
26-
name: Spring Boot ${{ matrix.springboot-version }}
21+
name: Spring Boot 2.x
2722
env:
2823
GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
2924

@@ -51,27 +46,15 @@ jobs:
5146
with:
5247
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
5348

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-
6049
- name: Test integration suites
61-
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
50+
# Runs every version even after one fails, as separate jobs per version would.
51+
run: ./gradlew allIntegrationTestsBoot2 --continue
6952

7053
- name: Upload test results
7154
if: always()
7255
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
7356
with:
74-
name: test-results-springboot-2-${{ matrix.springboot-version }}
57+
name: test-results-springboot-2
7558
path: |
7659
**/build/reports/*
7760
**/build/test-results/**/*.xml
@@ -80,7 +63,7 @@ jobs:
8063
uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16
8164
if: always()
8265
with:
83-
name: JUnit Spring Boot 2.x ${{ matrix.springboot-version }}
66+
name: JUnit Spring Boot 2.x
8467
path: |
8568
**/build/test-results/**/*.xml
8669
reporter: java-junit

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

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,7 @@ jobs:
1818
spring-boot-3-matrix:
1919
timeout-minutes: 45
2020
runs-on: ubuntu-latest
21-
strategy:
22-
fail-fast: false
23-
matrix:
24-
springboot-version: [ '3.2.12', '3.3.13', '3.4.13', '3.5.13' ]
25-
26-
name: Spring Boot ${{ matrix.springboot-version }}
21+
name: Spring Boot 3.x
2722
env:
2823
GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
2924

@@ -51,28 +46,15 @@ jobs:
5146
with:
5247
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
5348

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-
6049
- name: Test integration suites
61-
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
50+
# Runs every version even after one fails, as separate jobs per version would.
51+
run: ./gradlew allIntegrationTestsBoot3 --continue
7052

7153
- name: Upload test results
7254
if: always()
7355
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
7456
with:
75-
name: test-results-springboot-3-${{ matrix.springboot-version }}
57+
name: test-results-springboot-3
7658
path: |
7759
**/build/reports/*
7860
**/build/test-results/**/*.xml
@@ -81,7 +63,7 @@ jobs:
8163
uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16
8264
if: always()
8365
with:
84-
name: JUnit Spring Boot 3.x ${{ matrix.springboot-version }}
66+
name: JUnit Spring Boot 3.x
8567
path: |
8668
**/build/test-results/**/*.xml
8769
reporter: java-junit

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

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,7 @@ jobs:
1818
spring-boot-4-matrix:
1919
timeout-minutes: 45
2020
runs-on: ubuntu-latest
21-
strategy:
22-
fail-fast: false
23-
matrix:
24-
springboot-version: [ '4.0.0', '4.0.5', '4.1.0' ]
25-
26-
name: Spring Boot ${{ matrix.springboot-version }}
21+
name: Spring Boot 4.x
2722
env:
2823
GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
2924

@@ -51,29 +46,15 @@ jobs:
5146
with:
5247
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
5348

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-
6049
- name: Test integration suites
61-
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
50+
# Runs every version even after one fails, as separate jobs per version would.
51+
run: ./gradlew allIntegrationTestsBoot4 --continue
7152

7253
- name: Upload test results
7354
if: always()
7455
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
7556
with:
76-
name: test-results-springboot-4-${{ matrix.springboot-version }}
57+
name: test-results-springboot-4
7758
path: |
7859
**/build/reports/*
7960
**/build/test-results/**/*.xml
@@ -82,7 +63,7 @@ jobs:
8263
uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16
8364
if: always()
8465
with:
85-
name: JUnit Spring Boot 4.x ${{ matrix.springboot-version }}
66+
name: JUnit Spring Boot 4.x
8667
path: |
8768
**/build/test-results/**/*.xml
8869
reporter: java-junit

‎build-logic/build.gradle.kts‎

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

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import io.sentry.gradle.SpringBootVersions
12
import net.ltgt.gradle.errorprone.errorprone
23
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
34
import org.springframework.boot.gradle.plugin.SpringBootPlugin
45

56
plugins {
67
`java-library`
78
id("io.sentry.javadoc")
9+
id("io.sentry.spring-boot-matrix")
810
alias(libs.plugins.kotlin.jvm)
911
alias(libs.plugins.errorprone)
1012
alias(libs.plugins.gradle.versions)
@@ -142,3 +144,9 @@ testing {
142144
}
143145
}
144146
}
147+
148+
// The suites on each Spring Boot 4 version the CI matrix tests.
149+
springBootMatrix {
150+
versions(SpringBootVersions.BOOT_4)
151+
test("integrationTest")
152+
}

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

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import io.sentry.gradle.ChildJvmTestArguments
2+
import io.sentry.gradle.SpringBootVersions
23
import net.ltgt.gradle.errorprone.errorprone
34
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
45
import org.springframework.boot.gradle.plugin.SpringBootPlugin
56

67
plugins {
78
`java-library`
89
id("io.sentry.javadoc")
10+
id("io.sentry.spring-boot-matrix")
911
alias(libs.plugins.kotlin.jvm)
1012
alias(libs.plugins.kotlin.spring)
1113
alias(libs.plugins.errorprone)
@@ -387,13 +389,17 @@ testing {
387389
/**
388390
* Has the OpenTelemetry agent tests start agentFixture's app in a child JVM, with the built agent
389391
* attached. [autoInit] decides whether the agent initializes Sentry or, as with
390-
* `SENTRY_AUTO_INIT=false`, the Spring Boot starter.
392+
* `SENTRY_AUTO_INIT=false`, the Spring Boot starter. [appClasspath] is the app's classpath for
393+
* another Spring Boot version.
391394
*/
392-
fun Test.runAgentFixture(autoInit: Boolean) {
395+
fun Test.runAgentFixture(
396+
autoInit: Boolean,
397+
appClasspath: FileCollection = agentFixture.runtimeClasspath,
398+
) {
393399
jvmArgumentProviders.add(
394400
objects.newInstance<ChildJvmTestArguments>().apply {
395401
agentJar.from(otelAgentJar)
396-
appClasspath.from(agentFixture.runtimeClasspath)
402+
this.appClasspath.from(appClasspath)
397403
}
398404
)
399405
systemProperty("sentry.test.agentAutoInit", autoInit)
@@ -423,3 +429,19 @@ listOf(
423429
.forEach {
424430
tasks.named<KotlinCompile>(it) { compilerOptions.javaParameters = true }
425431
}
432+
433+
// The suites on each Spring Boot 4 version the CI matrix tests.
434+
springBootMatrix {
435+
versions(SpringBootVersions.BOOT_4)
436+
test("integrationTest")
437+
test("integrationTestLog4j2")
438+
test("integrationTestWebflux")
439+
test("integrationTestOtelNoAgent")
440+
test("integrationTestOtlp")
441+
test("integrationTestOtelAgent") { version ->
442+
runAgentFixture(autoInit = true, appClasspath = runtimeClasspath("agentFixture", version))
443+
}
444+
test("integrationTestOtelAgent", name = "integrationTestOtelAgentWithoutAutoInit") { version ->
445+
runAgentFixture(autoInit = false, appClasspath = runtimeClasspath("agentFixture", version))
446+
}
447+
}

0 commit comments

Comments
 (0)