Fullscript Logo
Backend Development

Upgrading Ruby and Rails at Fullscript

Author

Joshua Scharf - Profile Picture
Joshua Scharf

Date Published

Share this post

With Rails 7 being released last year, I want to talk about our process for upgrading to Ruby 2.7 and Rails 6.1, and how that prepared us for our Rails 7 upgrade. At Fullscript, we have an application infrastructure team that’s dedicated to upgrades like this. It was born from the need to keep our tools up to date, our infrastructure up to date, and to save the company from the trouble that comes when core pieces of the platform get outdated: unpatched security vulnerabilities.

Before we started this effort, and before this dedicated team was created, we were on Ruby 2.6.5 and Rails 5.2. Ruby 2.6.5 was released on October 1st, 2019 and Rails 5.2 was released on April 4th, 2018. This was a decent position, because at the time we weren’t too far behind, so we weren’t concerned about support going end-of-life, which could eventually lead to security vulnerabilities not getting patched.

However, we still wanted to leap forward to the newest version of Rails to take advantage of all of the latest features in the framework. We also really liked the idea of investing in infrastructure and tools now, so that any future upgrades could be done easier, faster, and with a higher degree of confidence than before.

It started with a Gemfile audit, and evolved from there.

Gemfile audit

We started with over 160 gems in our Gemfile, including our development and test groups. As part of this upgrade, we wanted to take the time to evaluate every gem in our Gemfile for three reasons:

  1. Confirm if the gem was still being used
  2. If it was, update it to the latest version
  3. If it wasn’t, flag it for removal

Then for any gems that were flagged for removal, we asked any relevant teams with domain experience in that area to review it and confirm. With this process in place, we communicated clearly whenever a gem was a candidate for removal, to ensure that nobody found out after the fact that a gem had been removed without their knowledge.

We used the following convention for keeping track of the status of each gem in the audit:

⚠️ Pending review (do not update yet)

🔵 Ready to update

❌ Do not update

✅ Done updating

And a simple table with one row per gem:

Rather than create a separate issue for every gem, we found it easier to track it this way. With a single issue for each gem update, that would be over 100 different issues to manage. Centralizing them all in one place meant less administrative overhead. Having multiple people working on this project, we found that a single document that tracks all gem updates in real time to be a more effective collaboration tool than a network of individual issues.

Adding capabilities to our CI pipeline

To support these upgrades, we knew we wanted to improve our Continuous Integration (CI) pipelines, to give us the ability to run them against two different versions of Ruby or two different versions of Rails.

The important goal for us was to be able to run the entire test suite in our application through our usual CI workflow, but with different versions and Ruby and/or Rails. This would give us a higher degree of confidence that any new changes coming in were compatible with the new versions, and allow us to quickly catch any changes that were not. It was also important for this to be optional, as we wanted to minimize disruption to existing workflows.

We use Gitlab for our development pipelines, so we leveraged a feature of theirs named Parent-child Pipelines. With this feature, we added another set of jobs to our CI pipelines that could be run on every branch, but out of the way of the jobs that run automatically. This was a great solution for us, as it allowed us to push our build infrastructure forward without changing existing workflows.

When we got closer to the deployment date of the upgrade, we made these additional pipeline jobs mandatory to ensure that every change was compatible with the upcoming version.

Upgrading to Ruby 2.7

The first child pipeline we added was for booting our application using a different version of Ruby. We called it ruby-next. This involved updating some other repositories that held our CI image code so that we could configure the new jobs to use the image with the updated Ruby version. In our case this was Ruby 2.7.3, as we wanted to go to the latest minor and patch version of the next major version after Ruby 2.6.x. This meant that we were using two CI images: one for the current version of Ruby, and another for the “next” version. In our gitlab-ci.yml file, that meant adding a suffix to certain places to differentiate between the current and next pipelines.

1ruby-and-node:current:
2 <<: *build_keys
3 script:
4 — docker build -t <image_url_current> -f ruby/2.6.5/Dockerfile
5 — docker push <image_url_current>
1ruby-and-node:next:
2 <<: *build_keys
3 script:
4 — docker build -t <image_url_next> -f ruby/2.7.3/Dockerfile
5 — docker push <image_url_next>

Note that in the code block above, <image_url_current> and <image_url_next> are placeholder values.<image_url_current><image_url_next>

While this work was being done, we were also systematically addressing deprecations and syntax changes that we knew were coming in Ruby 2.7.x. One such change was the “Separation of positional and keyword arguments” which you can read more about in the Ruby 2.7.0 release notes. The automatic conversion of positional and keyword arguments was deprecated, and was planned for removal in Ruby 3.0. We decided to address that during this upgrade instead of waiting for the Ruby 3.0 upgrade in the future. Because these specific changes were compatible with both Ruby 2.6 and 2.7, we could make all of the changes incrementally without breaking any existing code.

The order of operations looked like this:

  1. Start making code changes that are compatible with both Ruby 2.6 and Ruby 2.7
  2. Add a new image for Ruby 2.7 with a Dockerfile for “ruby next”
  3. Amend the deployment jobs to select the “ruby next” image
  4. Modify all the CI jobs to run on the 2.7.x image
  5. Open a merge request for changes that are only compatible with Ruby 2.7+
  6. Choose a deployment time far outside of peak traffic hours
  7. Coordinate with members of the DevOps team to ensure smooth deployment

Upgrading to Rails 6.1

The second child pipeline that we added was to boot our application into Rails 6. We called it rails-next. To do this, we needed a mechanism to switch between our current dependencies and our future dependencies. Inspired by a RailsConf talk by Rafael França, we built out a split-gemfile strategy using the Bundler environment variable BUNDLE_GEMFILE. This environment variable allows you to specify a different Gemfile before bundling, so you can use a different set of dependencies, like gem 'rails', '6.1.0' for example.

We created two new Gemfiles for this strategy: Gemfile.shared, and Gemfile.next. Gemfile.shared held all of the gems that were common between our current and future dependencies, and Gemfile.next held only the dependencies that we needed for Rails 6 but were incompatible with Rails 5.

Then, when we want to bundle our dependencies for Rails 6, we just need to run BUNDLE_GEMFILE=Gemfile.next bundle install. If we needed to bundle for our usual Rails 5 workflow, we would run bundle install as we normally would.

The environment variable is mentioned in the official Bundler documentation if you would like to read more about it there.

Continued evolution

As time went on, our engineers grew a bit weary of having to manually keep our two sets of Gemfiles in sync (the default Gemfile and Gemfile.lock, plus the newer Gemfile.next and Gemfile.next.lock). We even created our own automation in our deployment pipelines to check if the files were out of sync, but we found this added to the overhead of the engineers developing product features.

This led to us choosing to adopt the Bundler plugin bootboot, which lets you use a single Gemfile, but have a separate lock file for the “next” versions of your dependencies. It also keeps these two lock files in sync so that you don’t have to ask engineers to do it manually.

We left in our existing automation to check the lock files, but now the check rarely ever fails and it serves as a safety mechanism in case something goes wrong with the Bundler plugin.

The importance of upgrading

The cost of upgrading can feel gargantuan compared to the allure of increased revenue from new features. It’s a tale as old as time. So why was this so important to us? We knew that as more time goes by, these upgrades would get harder to do, as the codebase gets further and further out of date.

By investing in these upgrades now, we’re saving ourselves from a more difficult upgrade process in the future. Then there is the opportunity cost to consider. If we didn’t upgrade to Rails 6, we wouldn’t be able to take advantage of the great new multi-database support that was added. Access to this new feature allows us to scale our application in new ways to help power the next phase of the company’s growth.

One Rails 6 feature that we took advantage of right away was splitting up our routes file. In the Rails 6 routing guide, there is a section named Breaking up very large route file into multiple small ones. We found this extremely useful as we have a large application with many routes, and our routes file was getting way too long. This feature allowed us to easily split our single routes file into multiple domain-specific files that made sense to be logically grouped together.

Lastly, another very compelling reason to upgrade is security. As a company in the healthcare space, we need to be very diligent about staying up to date with the latest security patches. Any vulnerability in our system needs to be taken seriously. Doing these upgrades helps us ensure our customers that we’re confident about keeping their information safe. If a new vulnerability is announced in a dependency that we rely on, we can quickly upgrade to the latest version of it, without having to worry that it will take us days or weeks to patch because of required code changes from many versions past.

Conclusion

As I mentioned at the beginning of this post, Rails 7 has been out since the end of last year. With the work that we put into these projects, we had tools at our disposal that made future upgrades easier and more reliable. As of this writing, I’m thrilled to be able to say that we are running Rails 7 in production at Fullscript!

We are confident that the things we learned during this investment in tooling and infrastructure will continue to pay off for years to come. The tooling that we added through the journey of upgrading to Ruby 2.7, and then to Rails 6, made our Rails 7 upgrade process much faster.

A goal we have had since the beginning of this effort was to be able to run our CI pipeline against the main branch of Rails to be on the cutting edge of any changes that occur, and after all of the progress we’ve made, we now have the ability to do that. Thanks to this tooling, we only need to list rails as a dependency in our Gemfile alongside the bootboot dual booting code, like this:

1if ENV['DEPENDENCIES_NEXT'] == "1"
2 enable_dual_booting if Plugin.installed?('bootboot')
3 gem 'rails', git: 'https://github.com/rails/rails.git', branch: 'main'
4else
5 gem 'rails', '~> 7.0.3'
6end

Our next major use of this investment is right around the corner: upgrading to Ruby 3.

Onwards and upwards!

Share this post