DEV Community

Cover image for Automate Your Java Upgrades: A Practical Case Study with OpenRewrite and GitHub Actions
Daniil Roman for Berlin Tech Blog

Posted on • Originally published at Medium

Automate Your Java Upgrades: A Practical Case Study with OpenRewrite and GitHub Actions

Tech debt grows relentlessly. Even on services you don't touch, dependencies become outdated, creating a constant maintenance burden. Manually upgrading dozens of services is slow and error-prone. What if you could automate a significant part of that process?

Remember your last big Spring Boot or Java version upgrade? How did it go? Did you spend hours renaming javax to jakarta packages across 30 or maybe 50 different services? Or perhaps you lost a whole day figuring out why a simple dependency bump broke the build? If any of this sounds familiar, you're in the right place.

What is OpenRewrite?

OpenRewrite is an open-source refactoring engine that automates code modifications at scale, enabling consistent and reliable refactoring across large codebases and significantly reducing manual effort.

OpenRewrite works by making changes to Lossless Semantic Trees (LSTs) that represent your source code and printing the modified trees back into source code. You can then review the changes in your code and commit the results. Modifications to the LST are performed in Visitors and visitors are aggregated into Recipes. OpenRewrite recipes make minimally invasive changes to your source code that honor the original formatting.

You can check this project out on GitHub

OpenRewrite statistics on GitHub

When would you use OpenRewrite?

You might be thinking, "This sounds great in theory, but what are the real-world use cases?" Let's recall some notable migrations many of us have faced recently:

Spring Boot 2.x to 3.x migration
This migration was especially remarkably by the seemingly simple task of renaming javax.* to jakarta.* namespaces. However, this change had to be applied to every single service using Spring Boot. In a typical Java and Spring ecosystem, that means changing it everywhere.

OpenRewrite offers a recipe that automates this entire process.
https://docs.openrewrite.org/recipes/java/spring/boot3/upgradespringboot_3_0

JUnit 4 to JUnit 5 Migration
Imagine you need to migrate from JUnit 4 to JUnit 5, but your codebase still has a few outdated annotations scattered around. As a part of this migration you'd need to rename @BeforeClass to @BeforeAll or @AfterClass to @AfterAll.
It doesn't sound too complicated, but it's tedious work that can be fully automated with an OpenRewrite recipe.
https://docs.openrewrite.org/running-recipes/popular-recipe-guides/migrate-from-junit-4-to-junit-5

Java Version Upgrades:
Or maybe you're upgrading to Java 21 and want to replace the deprecated new URL(String) constructor with the URI.create(String).toURL() across your entire codebase.
There's a recipe for that too: https://docs.openrewrite.org/recipes/java/migrate/upgradetojava21

In other words, we developers are constantly challenged with keeping our dependencies up to date. OpenRewrite is here to rescue us—or at least, to significantly reduce the pain.

Our experience of using OpenRewrite

What worked for us

Let's start with what went well, as we found the tool both useful and promising.

Keeping pom.xml in shape

In the OpenRewrite ecosystem, the magic comes from its recipes. For us, the first no-brainer was the Apache Maven best practices recipe.
It was immediately clear that we had no other tool in our stack that could consistently keep our pom.xml files in good shape.

As a simple but welcome feature, this recipe reorders the sections of a pom.xml to follow a standard pattern. This helps with readability, especially in large files.

pom.xml reordering result

But its real power lies elsewhere: the recipe can find and remove duplicate or unused dependencies, improving the long-term stability of a service.

Unused or duplicated dependencies were removed

As a note of caution, I admit it can be scary at first to accept an automated PR that removes dependencies. Make sure you have good test coverage before trusting any automated tool to this extent.

Refactoring test libraries

Of course, we wanted to see it work on actual Java code. As always, the safest place to try a new tool is on your tests, so that's exactly what we did. We were already in the process of standardizing on AssertJ, so we introduced three relevant recipes:

  • org.openrewrite.java.testing.assertj.Assertj
  • org.openrewrite.java.testing.mockito.MockitoBestPractices
  • org.openrewrite.java.testing.testcontainers.TestContainersBestPractices We ran these without specific expectations and were pleasantly surprised when they spotted and fixed several sore spots in our test code.

Static import was fixed

The result of a recipe for testcontainers to use a specific Docker image

What didn't work

But of course, the juicy part is always where things go wrong.
In the following paragraphs, we will look at a few examples that we were not entirely satisfied with.

Complex, custom refactoring

We tried to use a recipe to fully migrate our tests from Hamcrest to AssertJ, but it simply ignored our custom matchers.

Complex Hamcrest matcher that OpenRewrite recipe wasn't able to migrate to AssertJ

While some recipes are more powerful than others, our general feeling is that OpenRewrite struggles with highly complex or bespoke refactorings on its own.

Running Java recipes on Kotlin projects

It may seem obvious that Java recipes should only be run on Java projects. However, like many companies, we have a mix of Java and Kotlin projects, so we simply ran the recipes against all of our team's services to see what would happen. It turns out that it partially works, but it fails in enough cases to produce strange changes and broken PRs.

A result of running a Java recipe against Kotlin project

This makes things tricky if you want to run a uniform set of recipes across all repositories using a tool like GitHub Actions, which we'll cover next.

Newest recipes are often commercial

This might be obvious if you're already familiar with OpenRewrite, but it's worth mentioning. If you want to use OpenRewrite for Spring Boot upgrades, you'll find that the recipe for the latest version might be under a commercial license. For example, if Spring Boot 3.5 is the latest release, the open-source recipe might only support up to version 3.4. This makes perfect sense from a business perspective, but it's something to keep in mind. In short: the OpenRewrite engine is open-source, but the most cutting-edge recipes are often licensed separately.

Our automation setup with GitHub Actions

There are a few ways to run OpenRewrite recipes. If you're using Maven, you can add the rewrite-maven-plugin directly to your pom.xml. This can be configured to run during your local build or only on CI.

Example:

<project>
  <build>
    <plugins>
      <plugin>
        <groupId>org.openrewrite.maven</groupId>
        <artifactId>rewrite-maven-plugin</artifactId>
        <version>6.18.0</version>
        <configuration>
          <exportDatatables>true</exportDatatables>
          <activeRecipes>
            <recipe>org.openrewrite.staticanalysis.JavaApiBestPractices</recipe>
          </activeRecipes>
        </configuration>
        <dependencies>
          <dependency>
            <groupId>org.openrewrite.recipe</groupId>
            <artifactId>rewrite-static-analysis</artifactId>
            <version>2.17.0</version>
          </dependency>
        </dependencies>
      </plugin>
    </plugins>
  </build>
</project>
Enter fullscreen mode Exit fullscreen mode

Alternatively, you can run the plugin directly from the command line:

mvn -U org.openrewrite.maven:rewrite-maven-plugin:run -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-static-analysis:RELEASE -Drewrite.activeRecipes=org.openrewrite.staticanalysis.JavaApiBestPractices -Drewrite.exportDatatables=true
Enter fullscreen mode Exit fullscreen mode

This gives you the flexibility to run it manually, as part of a CI pipeline, or on a nightly schedule.
We chose the third option: run it via a scheduled GitHub Action on a daily basis and automatically create a PR if any changes are detected.

Why not just use the Maven plugin?

The main drawback of adding the plugin to your pom.xml is that it significantly slows down every build, even when there are no changes to be made. You could run it as a CI-only check, but that creates a frustrating workflow: the CI build would fail, and a developer would have to run the command locally to generate the changes and push another commit. This kind of friction hurts tool adoption.

Running OpenRewrite on a schedule mimics the behavior of Dependabot or Renovate. Developers don't have to actively run anything; they simply review and merge the auto-generated PRs. Ideally, only a small portion of these PRs will have failures.

Another benefit of the GitHub Action approach is centralization. We can update the recipes in a single, shared workflow file and have that change apply to all our repositories without touching a single pom.xml. And since the result is always a PR and not a direct commit, it's a completely safe operation.

The GitHub workflow in detail

Imagine you have 20 repositories. Modifying the pom.xml in every one of them just to add or change a recipe would be painful and would quickly lead to abandoning the tool. With a centralized GitHub Action, however, each repository only needs a small trigger file:

name: OpenRewrite Scheduled PR

on:
  schedule:
    - cron: '0 7 * * MON-FRI'
  workflow_dispatch: # Allows manual triggering

jobs:
  call-openrewrite-workflow:
    uses: your-organisation/your-repository/.github/workflows/reusable-openrewrite-auto-pr.yml@main
    secrets: inherit
Enter fullscreen mode Exit fullscreen mode

This file references a reusable workflow, which contains the actual logic and OpenRewrite configuration. Now, whenever we add or remove a recipe, we only modify the central workflow, and all repositories pick up the change on their next scheduled run.

name: Reusable OpenRewrite Auto PR Workflow

on:
  workflow_call:
    inputs:
      ADDITIONAL_MVN_COMMAND_TO_APPLY:
        description: 'Additional OpenRewrite command to apply'
        required: false
        type: string
        default: ''

env:
  PR_BRANCH_NAME: openrewrite/auto-improvements

jobs:
  check-branch:
    name: Check if branch exists
    runs-on: ...
    outputs:
      should_run: ${{ steps.check_branch.outputs.should_run }}

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Check if branch exists
        id: check_branch
        run: |
          if git ls-remote --heads origin ${{ env.PR_BRANCH_NAME }} | grep -q ${{ env.PR_BRANCH_NAME }}; then
            echo "Branch ${{ env.PR_BRANCH_NAME }} already exists. Skipping workflow."
            echo "should_run=false" >> $GITHUB_OUTPUT
          else
            echo "Branch does not exist. Proceeding with workflow."
            echo "should_run=true" >> $GITHUB_OUTPUT
          fi

  openrewrite:
    name: Apply OpenRewrite recommendations

    needs: check-branch
    if: needs.check-branch.outputs.should_run == 'true'
    runs-on: ...

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Set up Java
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '21'
          cache: 'maven'
          settings-path: ${{ github.workspace }}

      - name: Run OpenRewrite via Maven
        run: |
          ./mvnw --batch-mode -U org.openrewrite.maven:rewrite-maven-plugin:run \
            -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-testing-frameworks:RELEASE \
            -Drewrite.activeRecipes=org.openrewrite.staticanalysis.JavaApiBestPractices,org.openrewrite.maven.BestPractices

          ${{ inputs.ADDITIONAL_MVN_COMMAND_TO_APPLY }}

      - name: Create Pull Request
        id: create_pr
        uses: peter-evans/create-pull-request@v7
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          commit-message: "refactor: apply OpenRewrite recommendations"
          title: "refactor: apply OpenRewrite recommendations"
          add-paths: |
            .
            :!settings.xml
            :!toolchains.xml
          body: |
            This Pull Request was automatically generated by OpenRewrite.

            ## Changes Applied
            The following recipes were applied:
            - `org.openrewrite.maven.BestPractices` (Maven best practices)
            - `org.openrewrite.staticanalysis.JavaApiBestPractices` (Java API best practices)
            ${{ inputs.OPENREWRITE_RECIPES_TO_APPLY }}

            Please review the changes and merge if acceptable.
          branch: ${{ env.PR_BRANCH_NAME }}
          delete-branch: true
          labels: |
            openrewrite
            automated-pr
          draft: false

      - name: PR Status
        run: |
          echo "Pull request creation status: ${{ steps.create_pr.outputs.pull-request-operation }}"
          echo "Pull request number: ${{ steps.create_pr.outputs.pull-request-number }}"
Enter fullscreen mode Exit fullscreen mode

Note:
Several unrelated and infrastructure-specific steps have been removed from the GitHub workflow described above.

Below you can see the steps of the GitHub workflow:

OpenRewrite GitHub workflow steps

The main steps are:

  1. Run OpenRewrite via Maven
  2. Create Pull Request

This approach has proven to be robust; we've already added new recipes and removed old ones, confirming it works well in different scenarios. You can scope a shared GitHub Action to a team or an entire organization and still provide repository-specific overrides using environment variables. This allows the shared workflow to be as complex as necessary, as long as it remains maintainable.

Our results

  • We ran 5 distinct recipes on a schedule across our team's repositories.
  • The workflow was rolled out to 14 services.
  • In a short time, we have already merged over 40 automated PRs generated by this system.

PR description of a successful run of an OpenRewrite GitHub workflow

Final thoughts

In this article, we covered the pain points OpenRewrite solves, what worked (and didn't work) for us, and how we use the open-source version with GitHub Actions to run recipes automatically across all our repositories.

So far, our experience with OpenRewrite has been very positive. It has filled a crucial gap in our toolkit, especially for keeping our Maven pom.xml files clean and consistent.

Check out the OpenRewrite documentation to find a recipe that fits your needs, and feel free to use our GitHub workflow as inspiration for your own automation.

Top comments (0)