From 1b46292f36d865115bb1a17ea524f10f6ac34689 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 26 Oct 2026 16:32:43 +0100 Subject: [PATCH 1/2] test(spring-boot): Drop Spring Boot 2.4 to 2.6 from the test matrix Spring Boot 2.4 to 2.6 are long past end of life. They were the only reason the Boot 2 suites left GraphQL and Kafka out of their compilation below 2.7, which stands in the way of compiling the suites once and running them on each Boot version's classpath (JAVA-802). The Kafka beans stay in a profile, so only the Kafka tests need a broker. Co-Authored-By: Claude --- .github/workflows/spring-boot-2-matrix.yml | 2 +- sentry-spring-boot/build.gradle.kts | 63 ++++++------------- .../boot/agentfixture/KafkaController.kt | 4 +- .../boot/integrationtest/KafkaController.kt | 3 +- .../integrationtest/KafkaIntegrationTest.kt | 4 +- .../KafkaOtelCoexistenceIntegrationTest.kt | 4 +- .../KafkaController.kt | 4 +- .../KafkaOtelCoexistenceIntegrationTest.kt | 4 +- 8 files changed, 32 insertions(+), 56 deletions(-) diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index edfe9e74fc..3ee4553cf2 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -21,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - springboot-version: [ '2.4.13', '2.5.15', '2.6.15', '2.7.0', '2.7.18' ] + springboot-version: [ '2.7.0', '2.7.18' ] name: Spring Boot ${{ matrix.springboot-version }} env: diff --git a/sentry-spring-boot/build.gradle.kts b/sentry-spring-boot/build.gradle.kts index 151d5a829d..3f24e73dc9 100644 --- a/sentry-spring-boot/build.gradle.kts +++ b/sentry-spring-boot/build.gradle.kts @@ -21,9 +21,8 @@ tasks.named("compileKotlin") { // Spring scans the integration test apps' own bytecode with its repackaged ASM at startup, and // the ASM in Spring before 5.3.26 fails on class files as new as the toolchain's: "Unsupported -// class file major version 65". In the Spring Boot 2.x matrix, 2.4.13 (Spring 5.3.13) reads up -// to Java 18 and 2.7.0 (Spring 5.3.20) up to Java 19, so the suites target 17. Remove this once -// the matrix drops 2.4.13 and moves 2.7.0 to 2.7.10 or later. +// class file major version 65". The Spring Boot 2.x matrix's 2.7.0 (Spring 5.3.20) reads up to +// Java 19, so the suites target 17. Remove this once the matrix moves 2.7.0 to 2.7.10 or later. // The suites are Kotlin only. Their Java tasks keep the toolchain's level, because it decides // which Java version their classpaths ask for, and sentry-test-support needs the toolchain's. tasks @@ -137,13 +136,6 @@ tasks.jar { } } -// Spring Boot supports GraphQL only from 2.7, and its Kafka auto-configuration needs 2.7 for the -// spring-kafka version Sentry integrates with. The Spring Boot 2 matrix also covers older versions. -val bootSupportsGraphqlAndKafka = - libs.versions.springboot2.get().split(".").let { (major, minor) -> - major.toInt() > 2 || minor.toInt() >= 7 - } - // The OpenTelemetry agent tests run this app in a child JVM, on its own classpath like a real app // with the agent attached, rather than on the test's classpath. val agentFixture = sourceSets.create("agentFixture") @@ -164,20 +156,18 @@ dependencies { "agentFixtureImplementation"(platform(libs.springboot2.bom)) "agentFixtureImplementation"(projects.sentrySpringBootStarter) "agentFixtureImplementation"(projects.sentryAsyncProfiler) + "agentFixtureImplementation"(projects.sentryGraphql) "agentFixtureImplementation"(projects.sentryJdbc) + "agentFixtureImplementation"(projects.sentryKafka) "agentFixtureImplementation"(projects.sentryLogback) "agentFixtureImplementation"(kotlin(Config.kotlinStdLib)) "agentFixtureImplementation"(libs.otel) + "agentFixtureImplementation"(libs.springboot.starter.graphql) "agentFixtureImplementation"(libs.springboot.starter.jdbc) "agentFixtureImplementation"(libs.springboot.starter.web) "agentFixtureImplementation"(libs.springboot.starter.webflux) + "agentFixtureImplementation"(libs.spring.kafka2) "agentFixtureRuntimeOnly"(libs.hsqldb) - if (bootSupportsGraphqlAndKafka) { - "agentFixtureImplementation"(projects.sentryGraphql) - "agentFixtureImplementation"(projects.sentryKafka) - "agentFixtureImplementation"(libs.springboot.starter.graphql) - "agentFixtureImplementation"(libs.spring.kafka2) - } otelAgentJar(projects.sentryOpentelemetry.sentryOpentelemetryAgent) } @@ -192,7 +182,9 @@ testing { implementation(platform(libs.springboot2.bom)) implementation(projects.sentrySpringBootStarter) implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryGraphql) implementation(projects.sentryJdbc) + implementation(projects.sentryKafka) implementation(projects.sentryLogback) implementation(projects.sentryOkhttp) implementation(projects.sentryTestSupport) @@ -202,18 +194,14 @@ testing { implementation(libs.okhttp) implementation(libs.springboot.starter.aop) implementation(libs.springboot.starter.cache) + implementation(libs.springboot.starter.graphql) implementation(libs.springboot.starter.jdbc) implementation(libs.springboot.starter.test) implementation(libs.springboot.starter.web) implementation(libs.springboot.starter.webflux) + implementation(libs.spring.kafka2) + implementation(libs.spring.kafka2.test) runtimeOnly(libs.hsqldb) - if (bootSupportsGraphqlAndKafka) { - implementation(projects.sentryGraphql) - implementation(projects.sentryKafka) - implementation(libs.springboot.starter.graphql) - implementation(libs.spring.kafka2) - implementation(libs.spring.kafka2.test) - } } } @@ -224,16 +212,14 @@ testing { implementation(platform(libs.springboot2.bom)) implementation(projects.sentrySpringBootStarter) implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryGraphql) implementation(projects.sentryLogback) implementation(projects.sentryTestSupport) implementation(libs.kotlin.test.junit) implementation(libs.google.truth) + implementation(libs.springboot.starter.graphql) implementation(libs.springboot.starter.test) implementation(libs.springboot.starter.webflux) - if (bootSupportsGraphqlAndKafka) { - implementation(projects.sentryGraphql) - implementation(libs.springboot.starter.graphql) - } } } @@ -246,24 +232,22 @@ testing { implementation(projects.sentrySpringBootStarter) implementation(projects.sentryOpentelemetry.sentryOpentelemetryAgentlessSpring) implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryGraphql) implementation(projects.sentryJdbc) + implementation(projects.sentryKafka) implementation(projects.sentryLogback) implementation(projects.sentryTestSupport) implementation(libs.kotlin.test.junit) implementation(libs.google.truth) implementation(libs.springboot.starter.aop) + implementation(libs.springboot.starter.graphql) implementation(libs.springboot.starter.jdbc) implementation(libs.springboot.starter.test) implementation(libs.springboot.starter.web) implementation(libs.springboot.starter.webflux) + implementation(libs.spring.kafka2) + implementation(libs.spring.kafka2.test) runtimeOnly(libs.hsqldb) - if (bootSupportsGraphqlAndKafka) { - implementation(projects.sentryGraphql) - implementation(projects.sentryKafka) - implementation(libs.springboot.starter.graphql) - implementation(libs.spring.kafka2) - implementation(libs.spring.kafka2.test) - } } } @@ -278,9 +262,7 @@ testing { // For TestRestTemplate. Only the app's JVM runs the agent, so it doesn't trace the tests. implementation(libs.springboot.starter.test) implementation(libs.springboot.starter.web) - if (bootSupportsGraphqlAndKafka) { - implementation(libs.spring.kafka2.test) - } + implementation(libs.spring.kafka2.test) } targets.all { testTask.configure { runAgentFixture(autoInit = true) } } } @@ -324,10 +306,5 @@ listOf( "compileIntegrationTestOtelAgentKotlin", ) .forEach { - tasks.named(it) { - compilerOptions.javaParameters = true - if (!bootSupportsGraphqlAndKafka) { - exclude("**/Graphql*", "**/Greeting*", "**/Kafka*") - } - } + tasks.named(it) { compilerOptions.javaParameters = true } } diff --git a/sentry-spring-boot/src/agentFixture/kotlin/io/sentry/spring/boot/agentfixture/KafkaController.kt b/sentry-spring-boot/src/agentFixture/kotlin/io/sentry/spring/boot/agentfixture/KafkaController.kt index 7566632434..28cf59e737 100644 --- a/sentry-spring-boot/src/agentFixture/kotlin/io/sentry/spring/boot/agentfixture/KafkaController.kt +++ b/sentry-spring-boot/src/agentFixture/kotlin/io/sentry/spring/boot/agentfixture/KafkaController.kt @@ -11,8 +11,8 @@ import org.springframework.web.bind.annotation.RestController const val KAFKA_TOPIC = "sentry-topic" -// Kafka only works with Spring Boot 2.7 and later, so its beans belong to a profile rather than to -// the app most tests share. See KafkaOtelCoexistenceIntegrationTest. +// The beans belong to a profile, so only the app KafkaOtelCoexistenceIntegrationTest starts needs a +// broker. @RestController @Profile("kafka") diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaController.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaController.kt index 04922bdae0..1613809885 100644 --- a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaController.kt +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaController.kt @@ -11,8 +11,7 @@ import org.springframework.web.bind.annotation.RestController const val KAFKA_TOPIC = "sentry-topic" -// Kafka only works with Spring Boot 2.7 and later, so its beans and broker belong to a profile -// rather than to every test's context. See KafkaIntegrationTest. +// The beans belong to a profile, so only KafkaIntegrationTest's context needs a broker. @RestController @Profile("kafka") diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaIntegrationTest.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaIntegrationTest.kt index 6bd290baf7..e6e8241268 100644 --- a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaIntegrationTest.kt +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaIntegrationTest.kt @@ -11,8 +11,8 @@ import org.springframework.test.context.TestPropertySource /** * A message sent through Kafka is traced on both sides: where it's published and processed. * - * Unlike the other tests, this one runs its own application context with an in-JVM broker, because - * the shared context has to work on Spring Boot versions without Kafka support. + * Unlike the other tests, this one runs its own application context with an in-JVM broker, so the + * shared context doesn't need one. */ @ActiveProfiles("kafka") @EmbeddedKafka(topics = [KAFKA_TOPIC], bootstrapServersProperty = "spring.kafka.bootstrap-servers") diff --git a/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt b/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt index c786274b9e..6913a908fa 100644 --- a/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt +++ b/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt @@ -12,8 +12,8 @@ import org.springframework.kafka.test.EmbeddedKafkaBroker * With the agent active, OpenTelemetry traces Kafka instead of Sentry's own Spring Kafka * integration. * - * Unlike the other tests, this one starts an app of its own, with Kafka and an in-JVM broker. The - * shared app leaves them out, because it has to work on Spring Boot versions without Kafka support. + * Unlike the other tests, this one starts an app of its own, with Kafka and an in-JVM broker, so + * the shared app doesn't need a broker. */ class KafkaOtelCoexistenceIntegrationTest : IntegrationTest() { override val app: AgentApp diff --git a/sentry-spring-boot/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/otelnoagentintegrationtest/KafkaController.kt b/sentry-spring-boot/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/otelnoagentintegrationtest/KafkaController.kt index e564146043..24f6ed7b6b 100644 --- a/sentry-spring-boot/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/otelnoagentintegrationtest/KafkaController.kt +++ b/sentry-spring-boot/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/otelnoagentintegrationtest/KafkaController.kt @@ -11,8 +11,8 @@ import org.springframework.web.bind.annotation.RestController const val KAFKA_TOPIC = "sentry-topic" -// Kafka only works with Spring Boot 2.7 and later, so its beans and broker belong to a profile -// rather than to every test's context. See KafkaOtelCoexistenceIntegrationTest. +// The beans belong to a profile, so only KafkaOtelCoexistenceIntegrationTest's context needs a +// broker. @RestController @Profile("kafka") diff --git a/sentry-spring-boot/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/otelnoagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt b/sentry-spring-boot/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/otelnoagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt index a2a01ce0c4..5efef8875b 100644 --- a/sentry-spring-boot/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/otelnoagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt +++ b/sentry-spring-boot/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/otelnoagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt @@ -11,8 +11,8 @@ import org.springframework.test.context.TestPropertySource /** * With OpenTelemetry active, it traces Kafka instead of Sentry's own Spring Kafka integration. * - * Unlike the other tests, this one adds Kafka and an in-JVM broker to its application context. The - * others leave them out, because they have to work on Spring Boot versions without Kafka support. + * Unlike the other tests, this one adds Kafka and an in-JVM broker to its application context, so + * the others don't need a broker. */ @ActiveProfiles("kafka") @EmbeddedKafka(topics = [KAFKA_TOPIC], bootstrapServersProperty = "spring.kafka.bootstrap-servers") From d9dac8e47ba644773b6941d202d3fe6a588a8360 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 28 Oct 2026 15:50:19 +0100 Subject: [PATCH 2/2] 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 runs one version's suites in a module, and allIntegrationTestsBoot 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 * 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 * 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 --------- Co-authored-by: Claude --- .cursor/rules/e2e_tests.mdc | 15 ++- .github/workflows/spring-boot-2-matrix.yml | 37 +++--- .github/workflows/spring-boot-3-matrix.yml | 38 +++--- .github/workflows/spring-boot-4-matrix.yml | 39 ++++--- build-logic/build.gradle.kts | 4 + .../sentry/gradle/SpringBootMatrixPlugin.kt | 109 ++++++++++++++++++ gradle/spring-boot-versions.json | 5 + sentry-spring-7/build.gradle.kts | 7 ++ sentry-spring-boot-4/build.gradle.kts | 27 ++++- sentry-spring-boot-jakarta/build.gradle.kts | 26 ++++- sentry-spring-boot/build.gradle.kts | 25 +++- sentry-spring-jakarta/build.gradle.kts | 7 ++ sentry-spring/build.gradle.kts | 7 ++ 13 files changed, 289 insertions(+), 57 deletions(-) create mode 100644 build-logic/src/main/kotlin/io/sentry/gradle/SpringBootMatrixPlugin.kt create mode 100644 gradle/spring-boot-versions.json diff --git a/.cursor/rules/e2e_tests.mdc b/.cursor/rules/e2e_tests.mdc index 1785e61b5c..3603794cd5 100644 --- a/.cursor/rules/e2e_tests.mdc +++ b/.cursor/rules/e2e_tests.mdc @@ -64,9 +64,18 @@ it. Gradle runs the two in parallel. ## Running them -- The `spring-boot-{2,3,4}-matrix.yml` workflows run the suites against each supported Spring Boot - version, by setting the version in `gradle/libs.versions.toml`. The suites aren't part of - `check`, so a new suite only runs on CI once it's added to its matrix. +- The `springBootMatrix` block in each Spring module's build file runs its suites on every version + of its Spring Boot line in `gradle/spring-boot-versions.json`, with a task per suite and version, + e.g. `integrationTestWebfluxBoot3_2_12`. The suites and the SDK compile once, against the + catalog's version; only the runtime classpath changes. `allIntegrationTestsBoot3_2_12` runs one + version's suites, `allIntegrationTestsBoot3` every version's. +- The `spring-boot-{2,3,4}-matrix.yml` workflows read the same file and run each version in its own + job, e.g. `./gradlew :sentry-spring-jakarta:allIntegrationTestsBoot3_2_12 + :sentry-spring-boot-jakarta:allIntegrationTestsBoot3_2_12`. The suites aren't part of `check`, so + a new suite only runs on CI once it's added to its module's `springBootMatrix` block, and a new + module once it's added to its workflow. +- Suite code may only use APIs that exist in the oldest version of its line. Compiled against a + newer one, a missing API fails at runtime with `NoSuchMethodError` or `NoClassDefFoundError`. ## Gotchas diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 3ee4553cf2..02b166fca4 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -15,13 +15,30 @@ concurrency: cancel-in-progress: true jobs: + versions: + runs-on: ubuntu-latest + name: Spring Boot 2.x versions + outputs: + versions: ${{ steps.versions.outputs.versions }} + + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: gradle + + - name: Read Spring Boot 2.x versions + id: versions + run: echo "versions=$(jq -c .boot2 gradle/spring-boot-versions.json)" >> "$GITHUB_OUTPUT" + spring-boot-2-matrix: + needs: versions timeout-minutes: 45 runs-on: ubuntu-latest strategy: fail-fast: false matrix: - springboot-version: [ '2.7.0', '2.7.18' ] + springboot-version: ${{ fromJSON(needs.versions.outputs.versions) }} name: Spring Boot ${{ matrix.springboot-version }} env: @@ -51,21 +68,13 @@ jobs: with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - name: Update Spring Boot 2.x version - run: | - springboot_version="${{ matrix.springboot-version }}" - 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 - echo "Updated Spring Boot 2.x version to $springboot_version" - - name: Test integration suites + # Project paths, unlike a bare task name, keep Gradle from configuring every project. run: | - ./gradlew \ - :sentry-spring:integrationTest \ - :sentry-spring-boot:integrationTest \ - :sentry-spring-boot:integrationTestWebflux \ - :sentry-spring-boot:integrationTestOtelNoAgent \ - :sentry-spring-boot:integrationTestOtelAgent \ - :sentry-spring-boot:integrationTestOtelAgentWithoutAutoInit + task="allIntegrationTestsBoot${SPRINGBOOT_VERSION//./_}" + ./gradlew ":sentry-spring:$task" ":sentry-spring-boot:$task" + env: + SPRINGBOOT_VERSION: ${{ matrix.springboot-version }} - name: Upload test results if: always() diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index f698683cdc..0cbe496cda 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -15,13 +15,30 @@ concurrency: cancel-in-progress: true jobs: + versions: + runs-on: ubuntu-latest + name: Spring Boot 3.x versions + outputs: + versions: ${{ steps.versions.outputs.versions }} + + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: gradle + + - name: Read Spring Boot 3.x versions + id: versions + run: echo "versions=$(jq -c .boot3 gradle/spring-boot-versions.json)" >> "$GITHUB_OUTPUT" + spring-boot-3-matrix: + needs: versions timeout-minutes: 45 runs-on: ubuntu-latest strategy: fail-fast: false matrix: - springboot-version: [ '3.2.12', '3.3.13', '3.4.13', '3.5.13' ] + springboot-version: ${{ fromJSON(needs.versions.outputs.versions) }} name: Spring Boot ${{ matrix.springboot-version }} env: @@ -51,22 +68,13 @@ jobs: with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - name: Update Spring Boot 3.x version - run: | - springboot_version="${{ matrix.springboot-version }}" - 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 - echo "Updated Spring Boot 3.x version to $springboot_version" - - name: Test integration suites + # Project paths, unlike a bare task name, keep Gradle from configuring every project. run: | - ./gradlew \ - :sentry-spring-jakarta:integrationTest \ - :sentry-spring-boot-jakarta:integrationTest \ - :sentry-spring-boot-jakarta:integrationTestWebflux \ - :sentry-spring-boot-jakarta:integrationTestOtelNoAgent \ - :sentry-spring-boot-jakarta:integrationTestLog4j2 \ - :sentry-spring-boot-jakarta:integrationTestOtelAgent \ - :sentry-spring-boot-jakarta:integrationTestOtelAgentWithoutAutoInit + task="allIntegrationTestsBoot${SPRINGBOOT_VERSION//./_}" + ./gradlew ":sentry-spring-jakarta:$task" ":sentry-spring-boot-jakarta:$task" + env: + SPRINGBOOT_VERSION: ${{ matrix.springboot-version }} - name: Upload test results if: always() diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 8f91289db9..75070612eb 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -15,13 +15,30 @@ concurrency: cancel-in-progress: true jobs: + versions: + runs-on: ubuntu-latest + name: Spring Boot 4.x versions + outputs: + versions: ${{ steps.versions.outputs.versions }} + + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: gradle + + - name: Read Spring Boot 4.x versions + id: versions + run: echo "versions=$(jq -c .boot4 gradle/spring-boot-versions.json)" >> "$GITHUB_OUTPUT" + spring-boot-4-matrix: + needs: versions timeout-minutes: 45 runs-on: ubuntu-latest strategy: fail-fast: false matrix: - springboot-version: [ '4.0.0', '4.0.5', '4.1.0' ] + springboot-version: ${{ fromJSON(needs.versions.outputs.versions) }} name: Spring Boot ${{ matrix.springboot-version }} env: @@ -51,23 +68,13 @@ jobs: with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - name: Update Spring Boot 4.x version - run: | - springboot_version="${{ matrix.springboot-version }}" - 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 - echo "Updated Spring Boot 4.x version to $springboot_version" - - name: Test integration suites + # Project paths, unlike a bare task name, keep Gradle from configuring every project. run: | - ./gradlew \ - :sentry-spring-7:integrationTest \ - :sentry-spring-boot-4:integrationTest \ - :sentry-spring-boot-4:integrationTestWebflux \ - :sentry-spring-boot-4:integrationTestOtelNoAgent \ - :sentry-spring-boot-4:integrationTestLog4j2 \ - :sentry-spring-boot-4:integrationTestOtelAgent \ - :sentry-spring-boot-4:integrationTestOtelAgentWithoutAutoInit \ - :sentry-spring-boot-4:integrationTestOtlp + task="allIntegrationTestsBoot${SPRINGBOOT_VERSION//./_}" + ./gradlew ":sentry-spring-7:$task" ":sentry-spring-boot-4:$task" + env: + SPRINGBOOT_VERSION: ${{ matrix.springboot-version }} - name: Upload test results if: always() diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 60a340c9b8..29760f38a9 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -24,5 +24,9 @@ gradlePlugin { id = "io.sentry.animalsniffer.android" implementationClass = "io.sentry.gradle.SentryAnimalSnifferAndroidPlugin" } + register("sentrySpringBootMatrix") { + id = "io.sentry.spring-boot-matrix" + implementationClass = "io.sentry.gradle.SpringBootMatrixPlugin" + } } } diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/SpringBootMatrixPlugin.kt b/build-logic/src/main/kotlin/io/sentry/gradle/SpringBootMatrixPlugin.kt new file mode 100644 index 0000000000..c75a316651 --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/SpringBootMatrixPlugin.kt @@ -0,0 +1,109 @@ +package io.sentry.gradle + +import groovy.json.JsonSlurper +import javax.inject.Inject +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.file.FileCollection +import org.gradle.api.tasks.SourceSetContainer +import org.gradle.api.tasks.testing.Test +import org.gradle.language.base.plugins.LifecycleBasePlugin + +/** + * Runs integration suites on each Spring Boot version of a line, without recompiling anything. + * + * The suites and the SDK compile once, against the version catalog's Spring Boot version. For each + * version, a suite's test task reuses its compiled classes on a runtime classpath resolved for that + * version, which is how users run our published jars on their Spring Boot version. + */ +class SpringBootMatrixPlugin : Plugin { + override fun apply(project: Project) { + project.extensions.create("springBootMatrix", SpringBootMatrixExtension::class.java) + } +} + +abstract class SpringBootMatrixExtension @Inject constructor(private val project: Project) { + private var versions = emptyList() + + /** + * Registers `allIntegrationTestsBoot` for each version of Spring Boot [line] in + * `gradle/spring-boot-versions.json`, and `allIntegrationTestsBoot` for all of them. The CI + * workflows read the same file and run each version in its own job. A WAR module uses its Boot + * module's line, because the Boot BOM decides its Spring version. + */ + fun versions(line: Int) { + val file = project.layout.settingsDirectory.file("gradle/spring-boot-versions.json") + val lines = + JsonSlurper().parseText(project.providers.fileContents(file).asText.get()) as Map<*, *> + val versions = (lines["boot$line"] as List<*>).map { it as String } + this.versions = versions + project.tasks.register("allIntegrationTestsBoot$line") { + description = "Runs the integration suites on Spring Boot ${versions.joinToString()}." + group = LifecycleBasePlugin.VERIFICATION_GROUP + dependsOn(versions.map { aggregateName(it) }) + } + versions.forEach { version -> + project.tasks.register(aggregateName(version)) { + description = "Runs the integration suites on Spring Boot $version." + group = LifecycleBasePlugin.VERIFICATION_GROUP + } + } + } + + /** + * Registers `Boot` for each version, running [suite]'s tests. [name] tells apart + * several tasks of one suite, and [configure] adds what the suite's own task has beyond its + * classes and classpath. + */ + fun test(suite: String, name: String = suite, configure: Test.(version: String) -> Unit = {}) { + check(versions.isNotEmpty()) { "Call versions() before test()" } + versions.forEach { version -> + // Created now rather than with the task, so `dependencies --configuration` finds it. + val runtimeClasspath = runtimeClasspath(suite, version) + val task = + project.tasks.register("$name${suffix(version)}", Test::class.java) { + description = "Runs $name on Spring Boot $version." + group = LifecycleBasePlugin.VERIFICATION_GROUP + testClassesDirs = sourceSet(suite).output.classesDirs + classpath = runtimeClasspath + useJUnit() + configure(version) + } + project.tasks.named(aggregateName(version)) { dependsOn(task) } + } + } + + /** [sourceSet]'s output and runtime dependencies, with Spring Boot at [version]. */ + fun runtimeClasspath(sourceSet: String, version: String): FileCollection { + val sources = sourceSet(sourceSet) + val name = "$sourceSet${suffix(version)}RuntimeClasspath" + val configurations = project.configurations + val dependencies = + configurations.findByName(name) + ?: configurations + .resolvable(name) { + extendsFrom( + configurations.getByName(sources.implementationConfigurationName), + configurations.getByName(sources.runtimeOnlyConfigurationName), + ) + attributes.addAllLater( + configurations.getByName(sources.runtimeClasspathConfigurationName).attributes + ) + // What setting the version in the catalog does: every Spring Boot artifact, the BOM + // included, is in this group. Libraries pinned to their own version still win + // conflicts. + resolutionStrategy.eachDependency { + if (requested.group == "org.springframework.boot") useVersion(version) + } + } + .get() + return sources.output + dependencies + } + + private fun sourceSet(name: String) = + project.extensions.getByType(SourceSetContainer::class.java).getByName(name) + + private fun aggregateName(version: String) = "allIntegrationTests${suffix(version)}" + + private fun suffix(version: String) = "Boot" + version.replace('.', '_') +} diff --git a/gradle/spring-boot-versions.json b/gradle/spring-boot-versions.json new file mode 100644 index 0000000000..efe0fdd062 --- /dev/null +++ b/gradle/spring-boot-versions.json @@ -0,0 +1,5 @@ +{ + "boot2": ["2.7.0", "2.7.18"], + "boot3": ["3.2.12", "3.3.13", "3.4.13", "3.5.13"], + "boot4": ["4.0.0", "4.0.5", "4.1.0"] +} diff --git a/sentry-spring-7/build.gradle.kts b/sentry-spring-7/build.gradle.kts index b9d418abe4..5b31c03162 100644 --- a/sentry-spring-7/build.gradle.kts +++ b/sentry-spring-7/build.gradle.kts @@ -5,6 +5,7 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { `java-library` id("io.sentry.javadoc") + id("io.sentry.spring-boot-matrix") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -142,3 +143,9 @@ testing { } } } + +// The suites on each Spring Boot 4 version the CI matrix tests. +springBootMatrix { + versions(line = 4) + test("integrationTest") +} diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts index 7f4428551b..947657f62c 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -6,6 +6,7 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { `java-library` id("io.sentry.javadoc") + id("io.sentry.spring-boot-matrix") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) alias(libs.plugins.errorprone) @@ -387,13 +388,17 @@ testing { /** * Has the OpenTelemetry agent tests start agentFixture's app in a child JVM, with the built agent * attached. [autoInit] decides whether the agent initializes Sentry or, as with - * `SENTRY_AUTO_INIT=false`, the Spring Boot starter. + * `SENTRY_AUTO_INIT=false`, the Spring Boot starter. [appClasspath] is the app's classpath for + * another Spring Boot version. */ -fun Test.runAgentFixture(autoInit: Boolean) { +fun Test.runAgentFixture( + autoInit: Boolean, + appClasspath: FileCollection = agentFixture.runtimeClasspath, +) { jvmArgumentProviders.add( objects.newInstance().apply { agentJar.from(otelAgentJar) - appClasspath.from(agentFixture.runtimeClasspath) + this.appClasspath.from(appClasspath) } ) systemProperty("sentry.test.agentAutoInit", autoInit) @@ -423,3 +428,19 @@ listOf( .forEach { tasks.named(it) { compilerOptions.javaParameters = true } } + +// The suites on each Spring Boot 4 version the CI matrix tests. +springBootMatrix { + versions(line = 4) + test("integrationTest") + test("integrationTestLog4j2") + test("integrationTestWebflux") + test("integrationTestOtelNoAgent") + test("integrationTestOtlp") + test("integrationTestOtelAgent") { version -> + runAgentFixture(autoInit = true, appClasspath = runtimeClasspath("agentFixture", version)) + } + test("integrationTestOtelAgent", name = "integrationTestOtelAgentWithoutAutoInit") { version -> + runAgentFixture(autoInit = false, appClasspath = runtimeClasspath("agentFixture", version)) + } +} diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 9451cf9511..8587e9e2a6 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -6,6 +6,7 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { `java-library` id("io.sentry.javadoc") + id("io.sentry.spring-boot-matrix") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -327,13 +328,17 @@ testing { /** * Has the OpenTelemetry agent tests start agentFixture's app in a child JVM, with the built agent * attached. [autoInit] decides whether the agent initializes Sentry or, as with - * `SENTRY_AUTO_INIT=false`, the Spring Boot starter. + * `SENTRY_AUTO_INIT=false`, the Spring Boot starter. [appClasspath] is the app's classpath for + * another Spring Boot version. */ -fun Test.runAgentFixture(autoInit: Boolean) { +fun Test.runAgentFixture( + autoInit: Boolean, + appClasspath: FileCollection = agentFixture.runtimeClasspath, +) { jvmArgumentProviders.add( objects.newInstance().apply { agentJar.from(otelAgentJar) - appClasspath.from(agentFixture.runtimeClasspath) + this.appClasspath.from(appClasspath) } ) systemProperty("sentry.test.agentAutoInit", autoInit) @@ -362,3 +367,18 @@ listOf( .forEach { tasks.named(it) { compilerOptions.javaParameters = true } } + +// The suites on each Spring Boot 3 version the CI matrix tests. +springBootMatrix { + versions(line = 3) + test("integrationTest") + test("integrationTestLog4j2") + test("integrationTestWebflux") + test("integrationTestOtelNoAgent") + test("integrationTestOtelAgent") { version -> + runAgentFixture(autoInit = true, appClasspath = runtimeClasspath("agentFixture", version)) + } + test("integrationTestOtelAgent", name = "integrationTestOtelAgentWithoutAutoInit") { version -> + runAgentFixture(autoInit = false, appClasspath = runtimeClasspath("agentFixture", version)) + } +} diff --git a/sentry-spring-boot/build.gradle.kts b/sentry-spring-boot/build.gradle.kts index 3f24e73dc9..266142dd2b 100644 --- a/sentry-spring-boot/build.gradle.kts +++ b/sentry-spring-boot/build.gradle.kts @@ -6,6 +6,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") + id("io.sentry.spring-boot-matrix") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -272,13 +273,17 @@ testing { /** * Has the OpenTelemetry agent tests start agentFixture's app in a child JVM, with the built agent * attached. [autoInit] decides whether the agent initializes Sentry or, as with - * `SENTRY_AUTO_INIT=false`, the Spring Boot starter. + * `SENTRY_AUTO_INIT=false`, the Spring Boot starter. [appClasspath] is the app's classpath for + * another Spring Boot version. */ -fun Test.runAgentFixture(autoInit: Boolean) { +fun Test.runAgentFixture( + autoInit: Boolean, + appClasspath: FileCollection = agentFixture.runtimeClasspath, +) { jvmArgumentProviders.add( objects.newInstance().apply { agentJar.from(otelAgentJar) - appClasspath.from(agentFixture.runtimeClasspath) + this.appClasspath.from(appClasspath) } ) systemProperty("sentry.test.agentAutoInit", autoInit) @@ -308,3 +313,17 @@ listOf( .forEach { tasks.named(it) { compilerOptions.javaParameters = true } } + +// The suites on each Spring Boot 2 version the CI matrix tests. +springBootMatrix { + versions(line = 2) + test("integrationTest") + test("integrationTestWebflux") + test("integrationTestOtelNoAgent") + test("integrationTestOtelAgent") { version -> + runAgentFixture(autoInit = true, appClasspath = runtimeClasspath("agentFixture", version)) + } + test("integrationTestOtelAgent", name = "integrationTestOtelAgentWithoutAutoInit") { version -> + runAgentFixture(autoInit = false, appClasspath = runtimeClasspath("agentFixture", version)) + } +} diff --git a/sentry-spring-jakarta/build.gradle.kts b/sentry-spring-jakarta/build.gradle.kts index 701d5a0420..34e9b80bf6 100644 --- a/sentry-spring-jakarta/build.gradle.kts +++ b/sentry-spring-jakarta/build.gradle.kts @@ -5,6 +5,7 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { `java-library` id("io.sentry.javadoc") + id("io.sentry.spring-boot-matrix") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -129,3 +130,9 @@ testing { } } } + +// The suites on each Spring Boot 3 version the CI matrix tests. +springBootMatrix { + versions(line = 3) + test("integrationTest") +} diff --git a/sentry-spring/build.gradle.kts b/sentry-spring/build.gradle.kts index f11087c1b0..b5a8440e2b 100644 --- a/sentry-spring/build.gradle.kts +++ b/sentry-spring/build.gradle.kts @@ -5,6 +5,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") + id("io.sentry.spring-boot-matrix") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -117,3 +118,9 @@ testing { } } } + +// The suites on each Spring Boot 2 version the CI matrix tests. +springBootMatrix { + versions(line = 2) + test("integrationTest") +}