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") +}