name: bump-java-17-to-21
description: Migrate a Maven or Gradle project one Java LTS step from Java 17 to Java 21: it must compile under JDK 21, conserve every previously-passing test, and raise the effective compiler target to 21, using only standard tools (JDKs, Maven or Gradle, OpenRewrite recipes from Maven Central; no project-specific scripts). Use for a 17→21 Java LTS bump.
Bump Java 17 → 21
Migrate the project in your working directory from Java 17 to Java 21. GOAL: it COMPILES and passes its TESTS under JDK 21, conserving every test that passed under JDK 17, with the effective compiler target raised to 21 (a project that merely compiles under 21 but still targets 17 is NOT a bump). Work autonomously until done.
Tools: standard only (JDKs 17 and 21, Maven or Gradle, OpenRewrite from Maven Central)
Three operations recur below; run each with the two JDKs, no project-specific scripts:
FIRST detect the build tool and use ONLY it for every operation: pom.xml present → Maven; otherwise (build.gradle/build.gradle.kts) → Gradle. NEVER introduce the other build system: do NOT create a pom.xml in a Gradle project (or a build.gradle in a Maven one). The build/test gate compiles whatever build file is present, so adding the wrong one silently breaks dependency resolution (deps declared in the project's real build tool show up as package … does not exist).
- compile under JDK N: Maven:
JAVA_HOME=<jdkN> mvn -B -ntp -DskipTests compile; Gradle: ./gradlew -Dorg.gradle.java.home=<jdkN> compileJava (also compileKotlin/compileTestJava).
- test under JDK N: Maven:
JAVA_HOME=<jdkN> mvn -B -ntp test; Gradle: ./gradlew -Dorg.gradle.java.home=<jdkN> test.
- apply the OpenRewrite program: write
rewrite.yml (below), then run it under JDK 17 with the Java-21 recipe artifacts:
- Maven:
JAVA_HOME=<jdk17> mvn -B -ntp -U -Denforcer.skip=true org.openrewrite.maven:rewrite-maven-plugin:6.40.0:run -Drewrite.configLocation=$(pwd)/rewrite.yml -Drewrite.activeRecipes=com.bjv.Bump -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-migrate-java:3.35.0,org.openrewrite.recipe:rewrite-spring:6.31.0,org.openrewrite.recipe:rewrite-java-dependencies:1.55.0, the absolute configLocation ($(pwd)/rewrite.yml) is required, else multi-module submodules report Recipe(s) not found.
- Gradle: apply the OpenRewrite plugin through an init-script with
rewrite-migrate-java:3.35.0 / rewrite-spring:6.31.0 / rewrite-java-dependencies:1.55.0 on the rewrite configuration, then run rewriteRun.
- NEVER add the OpenRewrite plugin to a build file (
id('org.openrewrite') / apply plugin: 'org.openrewrite.rewrite' / a rewrite { } block / rewrite(...) deps): it PERSISTS into the gate build, which resolves plugins from the OFFLINE mirror and dies with Could not find org.openrewrite:rewrite-gradle-plugin:N (or could not resolve plugin artifact ... org.openrewrite.gradle.plugin) -> FAIL_build_post. Apply the recipe ONLY via the transient init-script above; if you already added the plugin, REMOVE it plus the rewrite{} block and rewrite(...) deps before the gate. (Proven: rr_11_48 kennedykori/jutils left rewrite-gradle-plugin:6.40.0 -> gate post 0; the clean bump, toolchain of(11)->of(17), is PASS 41/41.)
CRITICAL. NEVER time-box these builds. Cold Gradle/Maven runs download distributions + the OpenRewrite jars and can take several MINUTES; let them finish. An apply that was cut off means the recipe was NOT applied. After applying, confirm BUILD SUCCESS and that the build files actually changed.
How to work (graded on a CORRECT migration that conserves tests: nothing else counts if a test is lost or the bytecode isn't really at 21)
- Prefer the off-the-shelf transforms: the unparametrized meta-recipes + setting the Java version to 21 + (only if needed) the pinned Gradle wrapper are the clean path. Use them first.
- On Gradle, land the target with a direct build-file edit; the reward follows the target, not the tool. Setting the target to 21 is a free hop-fixed intent (no penalty), and the combined gate credits the bump only once the effective bytecode target actually reaches 21.
UpgradeJavaVersion often leaves a Gradle toolchain (JavaLanguageVersion.of(N)) or sourceCompatibility/targetCompatibility untouched, and the rewrite-gradle-plugin init-script can fail to resolve offline or clash with the repo's Gradle version, so a run that loops on it while the target never lands earns nothing (it scores FAIL_target_not_bumped with edits 0). The dependable path to the reward is to set the target yourself: JavaLanguageVersion.of(<21) to of(21), plus any sourceCompatibility/targetCompatibility/options.release/JavaVersion.VERSION_* below 21, across the root and every module (allprojects/subprojects). Keep OpenRewrite for the heavier transforms (Spring, javax->jakarta, dependency floors); if rewriteRun will not resolve, the direct edit has already secured the target. (On Maven the rewrite-maven-plugin route is reliable; the direct edit is <release>21</release> or maven.compiler.release.)
- Treat every manual edit and every project-specific dependency/plugin change as a LIABILITY. Make the FEWEST changes that genuinely work.
- Never touch or weaken test code (see FORBIDDEN).
Proactive step: Gradle Kotlin/Java toolchain target (verify the bump actually landed; gated on a structural signal, not an error)
On Gradle projects the UpgradeJavaVersion recipe frequently does NOT touch a Kotlin-DSL toolchain block, java { toolchain { languageVersion.set(JavaLanguageVersion.of(N)) } } (or the Groovy JavaLanguageVersion.of(N)). The build then compiles cleanly but to the OLD bytecode (effective target stays 17), so there is NO error to react to. It silently scores FAIL_target_not_bumped. So after applying the recipe, grep the build files for JavaLanguageVersion.of(: if any still names a version < 21, hand-edit it to JavaLanguageVersion.of(21) before the JDK-21 build. This is proactive precisely because the failure is silent, the structural trigger (a JavaLanguageVersion.of(<21) left in a build file) is unambiguous. For a Kotlin project this Java toolchain is enough: the Kotlin plugin derives jvmTarget from it, so bytecode goes to 21 with no separate kotlinOptions.jvmTarget/jvmToolchain change (add those only if you later hit the Inconsistent JVM-target compatibility wall below). Note the Gradle wrapper version is a separate axis. But you also need to bump it: the gate runs Gradle itself under JDK 21, so a wrapper below the 8.5 floor (e.g. 7.6, 8.1.1) cannot run at all and dies parsing its own _BuildScript_ with Unsupported class file major version 65 before any compile, collapsing the whole build (proven: rr_17_29 left 8.1.1, rr_17_129 left 7.6.1). Set distributionUrl to gradle-8.10.2-bin.zip (via org.openrewrite.gradle.UpdateGradleWrapper {version: "8.10.2"} or by hand) and keep gradlew executable; like the target, the pinned wrapper is a free hop-fixed intent. Counts as a free hop-fixed intent, like setting the target.
START HERE: write rewrite.yml, then apply it
type: specs.openrewrite.org/v1beta/recipe
name: com.bjv.Bump
recipeList:
- org.openrewrite.java.migrate.UpgradePluginsForJava21
- org.openrewrite.java.migrate.UpgradeBuildToJava21
- org.openrewrite.java.migrate.UpgradeJavaVersion:
version: 21
Then compile under JDK 21. If it compiles, test under JDK 21. If tests pass and none are lost, you are done.
Reflect loop: if compile or test under 21 fails, read the error, fix the FIRST wall, re-run (no iteration limit)
JDK-21 class-file version is 65: a tool that reads bytecode via ASM must be new enough for v65.
- Test fork strong-encapsulation (
InaccessibleObjectException / module {A} does not "opens {pkg}"): hand-edit the test fork args (Maven surefire <argLine>, Gradle tasks.test { jvmArgs(...) }) adding --add-opens=<module>/<pkg>=ALL-UNNAMED per package the error names (one token each, joined with =; preserve existing argLine like JaCoCo's @{argLine}). Whole block = ONE edit.
- ByteBuddy/Mockito on v65:
Cannot define class using reflection / Mockito cannot mock this class / Unsupported class file major version 65. Mockito needs 5.18.0 for JDK 21; and force net.bytebuddy:byte-buddy(:agent) to 1.14.12 (a plain bump is overridden by the Spring BOM ~1.14. Force it: Maven <byte-buddy.version> property, Gradle configurations.all { resolutionStrategy.eachDependency { if (requested.group=="net.bytebuddy") useVersion("1.14.12") } }). UpgradeDependencyVersion.
- JaCoCo on v65:
Unsupported class file major version 65 → floor jacoco to 0.8.12.
- Lombok breaks javac on JDK 21:
NoSuchFieldError: Class com.sun.tools.javac.tree.JCTree$JCImport does not have member field 'com.sun.tools.javac.tree.JCTree qualid' (javac internals changed in 21; same root family as the 11->17 Lombok row). Floor Lombok to 1.18.30 (the first JDK-21-capable release). For Spring Boot the version is BOM-managed, so a bare dependency bump won't beat the parent BOM: override the lombok.version property in EVERY module that declares Lombok. Proven on AndresPin0/IngSoftV 17->21: the -tests module's testCompile died on JCImport.qualid; lombok->1.18.34 conserved all 190 tests.
- A build plugin doesn't know the new language level:
No enum constant com.github.javaparser.ParserConfiguration.LanguageLevel.JAVA_21 (or JAVA_25 on 21->25) -- an import/format/analysis plugin (impsort-maven-plugin, fmt-maven-plugin, spotless) bundles a javaparser too old to parse the bumped <source>/<release>. It runs in an EARLY phase, so it fails the build BEFORE compile (looks like a plugin crash, not a Java error). Fix = bump the plugin to a javaparser-aware version (impsort-maven-plugin 1.9.0 -> 1.12.0 for JAVA_21) via its version property -- -Dimpsort.skip/a <impsort.skip> property does NOT help because the pom's plugin <configuration> overrides it. Proven on jakartaee/cdi 17->21: impsort 1.9.0 -> JAVA_21 enum error; 1.12.0 -> VERDICT PASS target 21.
- Spring component-scan
Unsupported class file major version 65 / SimpleMetadataReader BeanDefinitionStoreException: the SB2 BOM is too old for v65. Prefer org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_3 (also does javax→jakarta + Spring Security 6). If you must stay on the 2.7 line, the floor is SB 2.7.17+ (earlier 2.7.x ships an ASM that can't read v65), but 2.7 is EOL, prefer 3.x. For Gradle, even a patch bump can fix it (SB 3.0.0's ASM 9.4 can't parse v65; 3.0.7+ ships ASM 9.5; 3.2.x fully supports 21). If already on SB ≥ 3.2 and STILL hitting v65 ASM, it's a transitive old shaded ASM, find and force that dep, don't keep bumping Spring Boot.
cannot find symbol: WebSecurityConfigurerAdapter (removed in Spring Security 6): do the SB 2→3 upgrade (UpgradeSpringBoot_3_3), which migrates it.
cannot find symbol for the project's OWN Kotlin classes in mixed Kotlin+Java Maven modules (kotlin-maven-plugin compile then maven-compiler-plugin java-compile): the Java sources reference same-package Kotlin types (interfaces/classes generated by the Kotlin step) and javac can't see them. Root cause is stale Kotlin incremental cache: the kotlin-maven-plugin runs incremental ([WARNING] Using experimental Kotlin incremental compilation) and, when the gate wipes target/classes but leaves target/kotlin-ic, the daemon sees “no source changes” and emits ZERO classes (no Compiling N Kotlin source files line), so the following javac fails. Symptom: Kotlin compile goal logs the daemon launch then jumps straight to java-compile with no class output. FIX (one edit): disable incremental in the kotlin-maven-plugin <configuration> with <myIncremental>false</myIncremental> (the mojo param behind ${kotlin.compiler.incremental}; a bare <kotlin.compiler.incremental>false</kotlin.compiler.incremental> property does NOT take effect, set the config field). Then the Kotlin step always re-emits and javac finds the types. Validated on kpavlov/ksp-maven-plugin 17→21: FAIL_build_post target -1 → VERDICT PASS target 21 82/82 tests.
- Gradle wrapper below the JDK-21 floor (8.5): bump via
org.openrewrite.gradle.UpdateGradleWrapper {version: "8.10.2"} (the pinned value), or by hand. Signatures: Unsupported class file major version N while Gradle CONFIGURES, or Could not determine java version from '21.0.x'. NEVER point distributionUrl at a file:// path.
- After a wrapper bump to 9.x:
Failed to apply plugin naming a Gradle-internal type (PatternSets$PatternSetFactory, No such property: internal for BuildParams) → bump the failing plugin to its Gradle-9 line, not Gradle.
- Gradle + Kotlin:
Inconsistent JVM-target compatibility → kotlin { jvmToolchain(21) }.
sun.misc.Unsafe … terminally deprecated WARNING from a dep (jctools/Netty): while only a warning and tests pass, it's cosmetic, conserve.
- Multi-module: a JDK bump is per-BUILD, not per-module.
Dependency resolution is looking for a library compatible with JVM runtime version N, but 'project :X' is only compatible with M = some modules' target wasn't set. Set the SAME target in every module (root allprojects/subprojects).
Cannot find a Java installation … matching {languageVersion=N} (foojay resolver timeout, no network): point Gradle at the installed JDKs -Porg.gradle.java.installations.paths=<jdk17>,<jdk21> and DROP vendor/implementation pins from toolchain{}, languageVersion alone is enough.
Entry <path> is a duplicate but no duplicate handling strategy has been set (Gradle 7+ hard error): tasks.withType(Copy).configureEach { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }.
- Maven
FAIL_target_not_bumped with <release> set but the effective target still low: a competing <source>/<target> or maven.compiler.source/maven.compiler.target (often from a parent POM or a duplicate compiler config) overrides it, <release> and <source>/<target> are mutually exclusive, so remove the lower one and keep a single <release>21. Grep every pom.xml for maven.compiler / <source> / <target> / <release>, not just the module the recipe edited. Also watch for a plugin that unzips prebuilt vendor JARs into target/classes (e.g. maven-antrun-plugin extract-deps bound to process-classes): those classes carry their own (often Java-6, major 50) bytecode and drag the measured min major version down regardless of your compiler level, move that extraction to a LATER phase (prepare-package, still before shade/package) so it no longer pollutes target/classes during the test-compile/test measurement window.
- Maven total wipeout (
post 0, all conserved tests lost) with [INFO] No tests to run. / No sources to compile: the run reached only the root pom.xml, which is a standalone (non-aggregator) pom in a multi-PROJECT repo. It has no src/ and no <modules> listing the real sub-projects (they sit in subdirs with their own poms, often sharing the root's groupId/artifactId). mvn test at the root builds nothing, so 0 tests run and every baseline test counts as lost (the baseline's pre-count came from committed target/surefire-reports/TEST-*.xml, not a real run). FIX: turn the root pom into an aggregator. Set <packaging>pom</packaging> and add a <modules> block listing every subdir whose pom carries conserved tests; rename the root <artifactId> if it duplicates a module's coordinates (a reactor cannot hold two artifacts with the same GAV). Bump the target (source/target/release) in each listed module, not just the root, effective-target is the global MIN across all built target/classes. Whole aggregator block = ONE edit.
- Surefire silently runs 0 tests from a module (
Tests run line absent, BUILD SUCCESS): the test classes don't match surefire's default include pattern (*Test/Test*/*Tests/*TestCase), e.g. lowercase testesAluno, baseTestes. The conserved tests then vanish post-bump with no error. FIX: add a maven-surefire-plugin <configuration><includes> covering the actual class-name pattern (e.g. **/testes*.java) plus **/*Test.java so you don't drop the conventionally-named ones. This is a build-config fix (NOT touching test code), so it's allowed.
- A removed/changed JDK API in the project's OWN source: hand-edit minimally.
- Runtime Groovy script compilation
BUG! exception in phase 'semantic analysis' in source unit 'Script1.groovy' Unsupported class file major version 65 (NOT the Spring ASM component-scan row above. This is the embedded Groovy compiler invoked at test runtime to compile .groovy scripts, e.g. JMeter ScriptSampler / mark59 ViaExcel / Gatling-driver tests): Groovy < 3.0.20 can't read JDK-21 (major 65) bytecode. Floor groovy to 3.0.20+ (3.0.24 validated; stay on the 3.0.x line, 4.x moves the coordinate to org.apache.groovy). The trap: the stale groovy often arrives transitively under <scope>provided</scope> (here via ApacheJMeter_java:5.5, alongside a groovy-all pom), and provided-scope deps ARE on the test classpath, a plain compile-scope bump leaves the 3.0.11 groovy core jar (which hosts the compiler) on the classpath. FIX (one edit per affected module): add a <dependencyManagement> block pinning org.codehaus.groovy:groovy (the core jar) + co-resolved modules (groovy-json/-sql/-templates/-xml/-jsr223/etc.) to 3.0.24. That overrides every path regardless of scope. Confirm with dependency:build-classpath -DincludeScope=test that only 3.0.24 jars remain. Validated on mark-5-9/mark59 17→21: the *ViaExcel/MetricsUtils Groovy tests went green, restoring lost 0 (combine with the SB 3.0.2→3.0.7 ASM fix for the Spring/Gatling modules in the same reactor).
General discipline (these stop you chasing non-problems)
- VERIFY THE TARGET LANDED: a clean JDK-21 build is NOT proof of a real bump. Gradle Kotlin-DSL
JavaLanguageVersion.of(N) toolchains (and soft-pinned Maven source/target/release) can leave bytecode at 17 with zero errors. After the build, confirm no build file still has JavaLanguageVersion.of(<21) (see the proactive step). If nothing forced an edit, the recipe under-applied. Fix the version declaration by hand.
- EDIT HYGIENE: after EVERY build-file edit, BEFORE rebuilding, validate it,
./gradlew help -q (Maven mvn -q validate). If that fails naming the file you just edited, YOUR edit broke the script: fix/revert it, do not chase it as a migration error. Make minimal, validated edits.
- NOT YOUR TESTS: tests needing Docker/Testcontainers/a database/Selenium/the network were already failing in the baseline, NOT in your conserve-set. Only tests that PASSED under 17 must still pass.
- OOM during tests is usually DOWNSTREAM of an earlier real error. Fix the FIRST error; only raise
-Xmx if it's genuinely heap.
- Stale generated classes (
ClassAlreadyExistsException): ./gradlew clean / rm -rf target, re-run.
--add-opens for deep reflection, --add-exports only for compile/link to a non-exported API; do NOT use --illegal-access=permit (removed in JDK 17). Canonical opens set: --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.desktop/java.awt.font=ALL-UNNAMED --add-opens=java.management/java.lang.management=ALL-UNNAMED.
When to bail (report honestly)
- EasyMock has no clean JDK-21+ mocking path (
ClassProxyFactory define fails). Bail.
- A semantic JDK change in the project's own code (confirm with a pristine-checkout probe).
- An abandoned dependency with no JDK-21 release.
- Baseline poisoned by a STALE committed surefire report (
lost stuck at ≥1 even though the module builds and all its tests pass green under 21): the repo committed a target/surefire-reports/TEST-*.xml that is out of sync with the source at the same SHA, e.g. the baseline set names disciplinasDoProfessorTest but the source method was renamed to getDisciplinasDoProfessorTest after that report was committed. The harness locks pre_set from those committed XMLs at baseline time (before any fix), so a test name that the real source can never emit is permanently in the conserve-set; no JDK bump can produce it and the lost() rename-normalizer (strips digits/UUIDs, not a get-prefix) won't bridge it. This is a corrupt upstream fixture, NOT a migration wall. Do NOT doctor source/baseline to force a pass (that's cheating). Report it honestly: bump succeeded (target 21, full reactor green, the test runs and passes under its current name), only the stale label blocks lost 0.
- Native (CMake/JNI) modules.
Absolutely forbidden (cheating: fails the run)
Do NOT delete/skip/disable/weaken any test or assertion; no -DskipTests/<skip>true; do NOT delete test files; do NOT touch test code to make it pass. Conservation is checked independently.
When the project passes its tests under JDK 21 with no tests lost and the effective target is 21, say you are done and summarize what you changed.