The recent release of Rails 6 means Rails 4.2 will no longer receive any further updates—including critical security patches. It also means that a lot of teams are suddenly quite motivated to figure out how to pull off a major upgrade (or two; or three) with minimal risk of failure or disruption to feature development.
First, the bad news: unless your app is home to the most straightforward, conventions-adherent codebase on the planet, major Rails upgrades will be a time and labor intensive effort. But there’s good news, too: by applying just the right amount of process and tooling, Rails upgrades can be carried out in tandem with ongoing feature development, incrementally and predictably.
Why are major Rails upgrades so error prone that teams regularly find themselves holding onto an old release, sometimes years after it reaches end-of-life support? It might have something to do with the same “magic” that makes Rails such a productive environment to work in. In Rails, most application behavior is invisibly inherited from Rails’ defaults, as opposed to from invoking explicit APIs with clear contracts. That means when Rails changes in a significant way, how those changes will impact our app is similarly invisible.
The only surefire way to know whether your app will work on a new version is to boot it up and find out: if you’re lucky, you’ll get a test failure or a deprecation warning; if you’re not, you’ll watch hours pass as you bounce between a 30-frame deep backtrace and contemporaneous pull requests, struggling to divine how a seemingly-unrelated change to Rails broke your app.
We’ve seen this story repeat often enough—across multiple Rails versions and on numerous teams—to observe a few common frustrations with major Rails upgrades:
It’s hard to know in advance what will break between your current version of Rails and the version you’re targeting
The changes demanded by an upgrade tend to be so broad-based and sweeping that it can feel necessary to halt all feature development until the upgrade effort is complete. Alternatively, an upgrade that’s sequestered to a long-lived branch will inevitably devolve into a pressure cooker as patience wears thin and as merge conflicts become increasingly severe
When making incremental progress migrating your code to a new API, it’s easy for that progress to be unmade by other team members who might not yet be privy to each new way of doing things that’s been necessitated by a later Rails version
This post will share strategies to mitigate all three of these pain points. Like most prescriptions for soothing a significant software ailment, however, none of these approaches are a silver bullet. Hard things are still hard. We’ve just uncovered a few practices along the way that make forward progress more attainable when upgrading Rails.
Problem: It’s hard to know what changes to subsequent versions of Rails will necessitate updates to your application code.
Solution: Don’t jump straight to your final destination; instead, upgrade just one point release of Rails at a time.
No matter what version you’re migrating from, it’s important to make incremental upgrades to take advantage of the deprecation warnings of each subsequent version (as opposed to trying to rip off the band-aid and painstakingly unbreak a thousand things). That means that if you’re on Rails 4.2, upgrade to Rails 5.0 before jumping further ahead. And if you’re on 5.0, upgrade to 5.1, then 5.2, and so on. The Rails Core team publishes guides on what changes to expect between each release, but they’ll only be much help if you migrate your codebase one version at a time. Additionally, estimating the effort of incremental upgrades is made easier by the fact that deprecation warnings can be counted and categorized like items on a backlog.
This may sound daunting at first. You may be thinking, we’ve failed to pull off a single upgrade from 4.1 to 5.2 and now you’re telling us to upgrade the app four times to reach the same destination? In practice, however, you’ll find that each point release upgrade is so much easier that four or five tiny upgrade cycles are cumulatively less work than attempting one massive upgrade.
Best of all, if for whatever reason your efforts are interrupted by other priorities, you get to keep your progress! If your upgrade marathon from 4.0 to 6.0 gets paused at 5.1, you’ll be able to pick up where you left off later. Compare with a multi-version upgrade attempted in a single leapfrog effort: the pressure to deliver will be much higher, because success becomes an all-or-nothing proposition.
Problem: It will take time for the app to run under the next version of Rails, but neither long-lived branches nor feature development freezes are workable approaches.
Solution: Get the app and its test suite bootable under either version of Rails, allowing progress to be merged in frequently and uneventfully.
Unless you want to set up permanent residence in a
rails-upgrade branch or
plan on stiff-arming your colleagues from making any changes to the application
while the upgrade effort is underway, it is essential to first set up a
dual boot environment for your application: one means of running the application
under the current version of Rails, and one way to run it under the next version
you’re targeting. This way, you’ll be able to establish a workflow that
integrates your changes much more frequently than if only one version of Rails
was able to be loaded by your server, rake tasks, console, or tests.
So, how do you accomplish a dual boot setup? One way to do this is by
establishing an environment variable like
RAILS_NEXT that indicates the app
should load the later version of Rails you’re targeting. With a single
Gemfile, you can generate two
Gemfile.lock files that branch on this
environment variable, and then run the application using the appropriate
lockfile to activate the desired version of Rails and other gems:
gem "rails", "~> 5.0.7"
gem "rack", "~> 1.5"
gem "rails", "~> 4.2.11"
gem "rack", "~> 1.4.7"
This technique has been documented by both
in their quests to update from legacy Rails versions. Shopify also maintains a
bootboot to help you
dual-boot your app. Because it can get a little bit confusing, we’ve put
together a minimal sample repository for
you with bootboot already set
up so you can try it out.
Once you’re able to dual boot, you’re all set to start implementing the changes demanded by the next Rails version. We found ourselves making helper methods that use the same environment variable to branch behavior based on which version of Rails the application was running:
You can then use these methods to branch wherever you find an error or deprecation warning:
# old, deprecated, existing behavior
# new, happy, upgrade path
Once the current incremental upgrade is complete you can remove the
conditionals, preserving only the code in the
else branch, before starting the
process over again for the next incremental Rails upgrade.
So, what does dual booting buy you? It means that whoever is working on a Rails upgrade can merge in their work as frequently as they like! Because the server, tests, and CI will only run the next version of Rails in the presence of an environment variable, any changes made for the sake of the upgrade won’t be executed by the application until you’re ready to cut things over.
This way of working can be hugely liberating. Not only does it reduce the frequency and severity of resolving merge conflicts, it gives the people working on an upgrade the ability to establish their own cadence for their work. Instead of feeling like they’re marooned on a months-long slog until the upgrade reaches production, dual booting can enable the team to integrate their upgrade-related changes on a consistent schedule each week. Empowering a team to design an iteration rhythm that’s divorced from extrinsic factors (like the scope of a major upgrade or the timing of its deployment to production) is an important ingredient in encouraging productivity and morale.
Problem: Even when merging upgrade-related changes in frequently, it’s possible that new code will continue to be written by others on the team that targets an older version of Rails, pushing the codebase two-steps-forward and one-step-back.
Solution: As soon as possible, require that CI pass under the new Rails version for areas that have already been updated for the upgrade.
If dual booting the app is the solution to version control for a long-running upgrade, then requiring CI to pass under the next Rails version for everyone’s builds is the solution to avoiding upgrade-related regressions.
For example, say you’re upgrading from Rails 4.1 to 4.2 and you’ve tucked each
respond_with behind a conditional such that your controllers’ tests pass under
the new version. Another developer on your team might—unaware of this change—add
a new controller action that invokes
respond_with without any such
rails_4_2? conditional and—because everything will work for them locally, on
CI, and in production—break the app again for the newer version of Rails,
resulting in rework for whoever’s focused on the upgrade.
To avoid this sort of regression, consider breaking up your continuous integration build into multiple jobs:
- A main build that runs all the tests on the current version of Rails
- A Rails upgrade build (i.e. with
RAILS_NEXTset) that is expected to fail, and whose test failure count represents a sort of backlog of work for the developers tasked with the upgrade
- A second Rails upgrade build, but this one being required to pass for all developers and on all branches. As parts of the system are fixed for an upgrade, their corresponding passing tests are marked for inclusion in this build. This will ensure early detection if anyone else’s work inadvertently re-couples it to the older version of Rails
Just as dual booting the app can facilitate frequent merges of upgraded code and subsequently raise awareness about upcoming changes, requiring that team members not break functionality that’s been made to pass under the next version of Rails can gradually spread accountability for the upgraded codebase—as opposed to dumping every single Rails API change on the rest of the team’s lap all-at-once when production is cut over and the old version is removed.
It’s easy for a talking head in a blog post to declare that upgrading from an end-of-life version of Rails to a supported release is an obvious decision. But as self-evident as it might seem, in reality many teams full of smart people struggle to plan and implement major Rails upgrades, so it’s worth approaching each upgrade thoughtfully and deliberately so as to avoid common pitfalls.
The number one reason we’ve seen Rails upgrade projects fail is because buy-in from management is a finite, constantly depleting resource when every day spent on an upgrade represents a day that no progress can be made on whatever the system is actually supposed to do. If the only perceived benefit to upgrading Rails is “security patches”, but the anticipated costs are portrayed as an interminable team-wide effort that will halt all feature development, it would be hard to blame management for feeling hesitant about throwing their full-throated support behind a major Rails upgrade initiative.
That’s why it’s so important to establish a workflow like the one described in this article, wherein the team can continue to make forward progress on an application’s features and functionality even while an upgrade is underway. It may take a bit more planning to pull off, but by finding a way to avoid disrupting the rest of the business, housekeeping activities like keeping your dependencies up-to-date can be done over the course of everyday development—hopefully helping the team avoid ever again falling several years behind on a major dependency like Rails.
Over the last several years, Test Double has led Rails upgrades for numerous clients, large and small. We love sharing the ways we’ve found to speed upgrades along while working to mitigate their associated risks. Whether your app is on an ancient fork of Rails 2 or is simply transitioning from Rails 5 to 6, we’d love to talk with you and show you how we can help modernize your Rails codebase without missing a beat on feature delivery.