Skip to content

chore(build): Compile with a JDK 21 toolchain and upgrade Mockito and Error Prone - #6242

Open
markushi wants to merge 25 commits into
mainfrom
build/jdk-21
Open

markushi wants to merge 25 commits into
mainfrom
build/jdk-21

Conversation

@markushi

@markushi markushi commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

📜 Description

Switches the build and CI from JDK 17 to JDK 21, and upgrades the tools that the new JDK forces us to move: Mockito, Error Prone and NullAway. Compilation and tests run on a JDK 21 toolchain. The bytecode we publish still targets Java 8 (Spring Boot 4 / Spring 7 modules target 17).

Just changing to 21 has a few ripple effects, so there's quite a broad set of changes involved across the codebase.

JDK 21

  • All CI workflows now use JDK 21. CONTRIBUTING.md is updated.
  • The build fails fast with a clear message when Gradle requires a JDK older than 21.
  • A JDK 21 toolchain in the new io.sentry.jvm-targets convention plugin (build-logic) compiles the code and runs the tests, so a contributor on a newer JDK gets the same output as CI. The foojay resolver fetches a JDK 21 when no local one matches.
  • JVM library modules now compile with --release <target>. A newer JDK could otherwise let APIs such as List.getFirst() get into Java 8 bytecode without a compile error, reducing the risk of introducing breaking changes for Android.
  • The Java 8 and Java 17 pins apply to main compilation only, so test code - which is never published - simply follows the toolchain instead of being patched back up per task.

Mockito 4.8 → 5.24, mockito-kotlin 4.1 → 6.4

  • Mockito 4.8 cannot mock on JDK 21 unless we set net.bytebuddy.experimental.
  • mockito-inline is replaced with mockito-core. Mockito 5 uses the inline mock maker by default.
  • Mockito 5 and mockito-kotlin 5+ are built for Java 11. Thus test code (never published) now compiles for the toolchain's Java 21 rather than main's Java 8.
  • 6 tests use anyVararg(), because in Mockito 5 any() on a varargs parameter matches exactly one argument.

Error Prone 2.11 → 2.50, NullAway 0.9.5 → 0.14.2, Gradle plugin 3.0.1 → 5.1.1

  • The old versions crash on JDK 21: Error Prone 2.11 fails with NoSuchMethodError: TreeMaker.Select, and NullAway 0.9.5 cannot load against a newer Error Prone. Neither bump can be split out of this PR.
  • The new NullAway found 15 errors, fixed here:
    • Scope.executeBeforeBreadcrumb could throw an NPE. This happens when the callback returns null and the close() of the reentrancy guard then throws.
    • Spring 7 is annotated with JSpecify. Our Spring 7 advices dereferenced MethodInvocation.getThis(), which is null for static methods. They now pass a null target class to AopUtils.getMostSpecificMethod. invoke() is now @Nullable, the same as the Spring 7 contract.
    • SentryAppender (logback) passes an empty array instead of null varargs. The behavior is the same.
  • The new Error Prone version and the JDK 21 javac lints gave 114 new warnings:
    • Simple ones are fixed: pattern-matching instanceof (Java 17 modules only), Locale.ROOT for toUpperCase, explicit long → double casts, /** on non-Javadoc comments, and modifiers on effectively-private members.
    • The others are suppressed in the code, each with a one-line reason: identity comparisons with sentinels, rrweb enum ordinals, JdkObsolete replacements that need Java 10+, this-escape in public non-final classes, serial, and the vendored Gson.

Samples

  • The samples follow the toolchain too, with one exception: the five modules the Spring Boot 2.x matrix builds keep their published Java level (11, and 8 for sentry-samples-spring), as otherwise those would stop working because of Unsupported class file major version 65 errors.

Other

  • SentryEnvelopeItemTest."fromAttachment with file SecurityManager denies read access" installs a SecurityManager. Since JDK 18 this needs -Djava.security.manager=allow, which the sentry tests now set.
  • The Build job name no longer carries a Java version that goes stale at the next bump.

💡 Motivation and Context

Preparation for SDK 37 support (for example ActivityManager.registerAnrWarningListener). Robolectric 4.17 is the first version that supports SDK 37, and SDK 37 needs Java 21, so JDK 21 has to land first.

💚 How did you test it?

Tests

📝 Checklist

  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.

🔮 Next steps

  • Robolectric 4.15 → 4.17 with the SDK 37 sandbox, including the 13 re-rendered snapshots
  • Scope the Java pins of the five samples outside the system tests (sentry-samples-android, -ktor-client, -openfeign, -servlet, -netflix-dgs) to main compilation, so every sample treats its test code the same way
  • Fix the flaky InternalSentrySdkTest, which reads session.json right after an asynchronous write

#skip-changelog

@sentry

sentry Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
SDK Size io.sentry.tests.size 8.60.0 (1) release

⚙️ sentry-android Build Distribution Settings

@markushi markushi changed the title build: Switch to JDK 21 and upgrade Mockito, Robolectric and Error Prone chore(build): Switch to JDK 21 and upgrade Mockito, Robolectric and Error Prone Oct 9, 2026

@runningcode runningcode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm all for this! i gave it a first round of reviews, ping me for another round!

Comment thread buildSrc/build.gradle.kts
Comment thread gradle/libs.versions.toml
Comment thread gradle/libs.versions.toml
try {
final double enqueuedTimeSeconds = Double.parseDouble(enqueuedTimeStr);
final double nowSeconds = DateUtils.millisToSeconds(System.currentTimeMillis());
final double nowSeconds = DateUtils.millisToSeconds((double) System.currentTimeMillis());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unrelated to the PR what's going on here? why are we using a double? i don't see a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah it's super odd. I assume it's because the incoming enqueuedTimeStr is already in seconds, so we go along with this format here.
Many timestamps within the sentry universe are transmitted as floating point numbers: seconds since unix time (e.g. event.timestamp). I guess this is due to the python's time.time() giving you exactly that.

final @NotNull Object bean, @NotNull final String beanName) throws BeansException {
if (bean instanceof SentryOptions) {
final SentryOptions options = (SentryOptions) bean;
if (bean instanceof SentryOptions options) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool java syntax! does this at all affect the minimum java for this code? what even is the minimum java here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it does. But as this module is already pinned to Java 17 features anyway, it will fine here.
It all comes down to us specifying -Werror. Here's the full log:

SentryInitBeanPostProcessor.java:45: warning: [PatternMatchingInstanceof] This code can be simplified to use a pattern-matching instanceof.
error: warnings found and -Werror specified

id("com.apollographql.apollo") version "4.1.1"
}

// Not published. Mockito 5 and mockito-kotlin 5+ are built for Java 11.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if it isn't published why not bump to 21?

Comment thread build.gradle.kts Outdated
// share the Java 8 target of the published code; it compiles for the build JDK instead.
// Registered once each plugin is applied, so this afterEvaluate runs after the plugins and the
// module have pinned the JVM target of all compile tasks.
listOf("java", "com.android.library", "com.android.application").forEach { pluginId ->

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we shouldn't be adding any logic to the build.gradle.kts file. this should go in a convention plugin like we already have in build-logic

also don't we already have some code that sets this same type of logic to jdk 11 ? why do we set it to 21 here?

also: this seems like we should be using gradle's toolchains support here. https://docs.gradle.org/current/userguide/toolchains.html

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added toolchain support, but it hasn't as many benefits as I was hoping for. The Gradle docs mention that the --release flag should be used in favor of source/target compat, but that isn't supported by AGP as --release seems to nuke the bootclasspath and removes android.jar provided by AGP for compilation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah you're right.

Comment thread build.gradle.kts Outdated
targetCompatibility = JavaVersion.VERSION_21.toString()
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile>().matching(isTestTask).configureEach {
compilerOptions.jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same, we should probably be using toolchains here: https://docs.gradle.org/current/userguide/toolchains.html

Comment thread build.gradle.kts Outdated
// JDK's, so newer JDK APIs (e.g. List.getFirst()) can't leak into published bytecode.
val javaExtension = the<JavaPluginExtension>()
tasks.named<JavaCompile>("compileJava") {
options.release.set(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread build.gradle.kts
Comment thread sentry/src/main/java/io/sentry/SynchronizedCollection.java
@markushi markushi changed the title chore(build): Switch to JDK 21 and upgrade Mockito, Robolectric and Error Prone chore(build): Compile with a JDK 21 toolchain and upgrade Mockito and Error Prone Oct 13, 2026
@markushi
markushi marked this pull request as ready for review October 14, 2026 07:40
*/
fun Project.applyToolchainToCompileTasks(taskFilter: (Task) -> Boolean) {
// AGP assigns these task properties while it creates the variants, so this has to run later.
afterEvaluate {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we remove the afterEvaluate here? afterEvaluate is almost always a bad practice. here's a blog post: https://www.liutikas.net/2022/12/09/Evaluate-Your-AfterEvaluate.html

@markushi markushi Oct 16, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately we can't for the Java compile tasks. Since the AGP plugin would overwrite the setting within their own .configure block. I've tried a few more options (e.g. onVariants, etc..) but none of them worked.

@runningcode runningcode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gave it another round of review! it looks much better!

// Modules that publish a higher level override this; test code and samples are never published and
// follow the toolchain.
// Keep this in sync with the publication filter in the root build script.
val isPublished =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this works right now but it relies on us always following this naming convention and silently breaking if we forget to follow it.

a better way would be to move this logic to a separate plugin and then only apply the plugin to those builds.
otherwise another way would be to set a gradle property in those builds and then this plugin detects that property.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed this in favor of checking if the maven publishing plugin is applied or not.

Comment thread build-logic/build.gradle.kts Outdated
dependencies {
implementation(libs.animalsniffer.gradle.plugin)
implementation(libs.spotlessLib)
// Only for the Kotlin task types the jvm-targets plugin configures.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't think we need this comment.

Separately, do you know why it is compileOnly here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cleaned it up, compileOnly as we just need it in order to access classes like KotlinJvmCompile within our plugin.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes but it would fail without having that code there? we're relying on the fact that someone else bundles it without handling its absence.

Comment thread gradle/libs.versions.toml
Comment thread settings.gradle.kts Outdated
Comment thread build.gradle.kts Outdated
Comment thread sentry-test-support/build.gradle.kts Outdated
Comment thread sentry-system-test-support/build.gradle.kts Outdated
}

tasks.withType<KotlinCompile>().configureEach {
tasks.named<KotlinCompile>("compileKotlin") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you know the reason this was changed to be named? is it so that we don't affect the test tasks?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, exactly!

Comment thread sentry-spring-jakarta/build.gradle.kts
}

tasks.withType<KotlinCompile>().configureEach {
tasks.named<KotlinCompile>("compileKotlin") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you know why all these were changed from withType to named?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah it's to define different targets for unit tests (targeting 21) and main compilation (targeting 1.8), because otherwise Mockito 5 (which requires Java 11) would fail.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e387384. Configure here.

options.release.set(PUBLISHED_JAVA_VERSION.majorVersion.toInt())
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kotlin lacks JDK API release pin

Medium Severity

The new JDK 21 toolchain pins published Java with --release, but Kotlin main compilation only sets jvmTarget. Compiling Kotlin against JDK 21 can resolve APIs such as List.getFirst() and emit them into Java 8 or 17 bytecode with no compile error, which then fails at runtime on Java 8 and older Android.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bugbot

Reviewed by Cursor Bugbot for commit e387384. Configure here.

@runningcode runningcode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're right toolchains don't solve this problem at all. i feel adding it only makes this more complicated.


// Only works for non-android modules
// See https://issuetracker.google.com/issues/278800528
project.plugins.withId("com.vanniktech.maven.publish") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i was going to ask why this line exists but then i saw your other comment.

i think this is fragile since it relies on precise task namings. i don't think this would match compileReleaseJava for example meaning that these tasks are not set properly: https://scans.gradle.com/s/mom72s36qrefi/timeline?name=compileReleaseJava

}
}

private fun Project.minSdk(): Int =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh! fancy!

// source/targetCompatibility are plain fields, so the last write wins, and AGP writes its
// compileOptions while it creates the compile tasks. Registering earlier - at apply time or
// in onVariants - is silently overwritten, so right now there's no way around afterEvaluate.
afterEvaluate {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here, we don't have any strict requirements on sourcecompatibilty or targetcompatibility since this is test code. i think we should just set the toolchain here (and hopefully also remove the afterEvaluate in the process since I'm pretty sure AGP doesn't set the toolchain)

* - published Android modules main code targets Java 1.8
* - minSdk is set for Android libraries
*/
class SentryCompilationPlugin : Plugin<Project> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this is about "java compatibility" so i think a name like JdkTargetConvention or something like that would make more sense.

}

project.plugins.withId("com.android.library") {
val android = project.extensions.getByType(BaseExtension::class.java)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have some production kotlin code. do you know if this sets the JDK compatibility on that?

I looked at how ANdroidx does this might be worth copying. They notably don't set the toolchain anywhere in code. https://github.com/androidx/androidx/blob/95347b245f1527ec6856a3daa580a1c0abbddc11/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt#L492

Comment thread build-logic/build.gradle.kts Outdated
dependencies {
implementation(libs.animalsniffer.gradle.plugin)
implementation(libs.spotlessLib)
// Only for the Kotlin task types the jvm-targets plugin configures.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes but it would fail without having that code there? we're relying on the fact that someone else bundles it without handling its absence.

Comment thread build.gradle.kts Outdated
// share the Java 8 target of the published code; it compiles for the build JDK instead.
// Registered once each plugin is applied, so this afterEvaluate runs after the plugins and the
// module have pinned the JVM target of all compile tasks.
listOf("java", "com.android.library", "com.android.application").forEach { pluginId ->

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah you're right.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants