Dual-Boot Ruby

Dual-Boot Ruby

As we mentioned many times, at FastRuby.io we like to use the Dual-Boot technique opens a new window during upgrades to quickly test the same code with the current and the next version of what we are upgrading. We usually talk about dual-booting Rails versions but this can be used to upgrade Ruby itself too. We have to make some changes to adapt the technique, and we’ll explain the basic changes in this article.

Dual-Boot Gems vs Dual-Boot Ruby

When we talk about the Dual-Boot technique, the main concept is to have 2 different Gemfile.lock files, one with the dependencies for the current version of the app, and one with the dependencies for the next version.

We typically include a snippet like this at the top of the Gemfile:

def next?
  File.basename(__FILE__) == "Gemfile.next"
end

And we use the next? helper method to set different versions of gems like this:

if next?
  gem "rails", github: "rails/rails"
else
  gem "rails", "~> 7.1.0"
end

Then, the application can be run with different commands to run one version or the other: rails s will run Rails 7.1, BUNDLE_GEMFILE=Gemfile.next rails s will run Rails’ main branch.

In order to dual-boot Ruby, we can use the same technique but with a few considerations.

Considerations

Versions of the gems are generally only defined in the Gemfile files, but the Ruby version is defined in many places. The most common are:

  • .ruby-version file (used by some version managers)
  • .tool-versions file (used by some version managers)
  • Gemfile (used by bundler, RVM, Heroku, and other tools)
  • Gemfile.lock (the Ruby version in this file is mostly informative)
  • Dockerfile (used by Docker to prepare the container)
  • CI setup steps

And applications can also have the expected Ruby version defined in other not-so-common places.

We have to make sure that a dual-boot setup is compatible with all of them to avoid a negative impact on the developer experience.

It’s important to note that, when dual-booting a gem, we only need to run the same command with different BUNDLE_GEMFILE env variables to quickly test the different version. However, when dual-booting Ruby, we need to first switch the version that is currently active using our Ruby version manager before running the app with the desired version.

Gemfile files

It is really common to specify the required Ruby version in the Gemfile like this:

ruby "3.2.2"

This is used by Bundler to ensure that we are using the expected Ruby version when running the app, but it’s also used by other tools like RVM or Heroku to pick which version of Ruby to set up.

To support the dual-boot, we can change that line to this:

ruby_version = next? ? "3.3.0" : "3.2.2"
ruby ruby_version

With this code, Heroku will install Ruby 3.2.2 when building the application, and at the same time it allows us to run the app with either Ruby 3.3.0 or 3.2.2 without failing.

Note that the version displayed in the Gemfile.lock file is not a problem for us: the Gemfile.lock file will show the current Ruby version, and the Gemfile.next.lock file will show the next. Those files are not meant to be used with the other Ruby.

Version files

Different Ruby version managers handle the *version files in different ways. Managers like asdf or rbenv will update their version files when switching rubies, so there’s nothing to change for these files to make them compatible with a dual-boot setup. It’s important to not commit changes in these files when switching between Ruby versions though. We want these files in the repository to always show the current Ruby version and not the next one.

Docker configuration

If the setup includes using Docker to run the application, we have a complete article on how to dual-boot both Ruby and Rails using docker opens a new window .

CI setup

Finally, we need to update the CI files to run the different jobs with both versions of Ruby. Most CI solutions support a matrix feature to define multiple values of a given tool and they will automatically execute one job for each of those values.

Here’s an example using a matrix to setup multiple Ruby versions in GitHub Actions opens a new window .

This is not enough though, since we also need to specify the BUNDLE_GEMFILE variable.

To do that we have multiple options:

Duplicate jobs

One option is to duplicate the original job and change the values to set up Ruby and the environment variables. You can re-use the original information using YAML anchors, aliases, and overrides opens a new window if the CI service supports them.

Jobs Matrix Another option is to use the matrix feature with extra configuration for each Ruby version if supported.

GitHub Actions, for example, supports matrix configurations using the include property to set other variables for a given matrix value, and the parameters will include the extra keys:

strategy:
  matrix:
    ruby: ["3.2.2", "3.3.0"]
    include:
      - gemfile: "Gemfile.lock"
        ruby: "3.2.2"
      - gemfile: "Gemfile.next.lock"
        ruby: "3.3.0"

Check the documentation on Expanding or adding matrix configurations opens a new window for more information.

CircleCI supports a different pattern by defining all the possible values and excluding the combinations we don’t want to run:

workflows:
  workflow:
    jobs:
      - build:
          matrix:
            parameters:
              ruby: ["3.2.2", "3.3.0"]
              gemfile: ["Gemfile.lock", "Gemfile.next.lock"]
            exclude:
              - ruby: "3.2.2"
                gemfile: "Gemfile.next.lock"
              - ruby: "3.3.0"
                gemfile: "Gemfile.lock"

Check the documentation on Excluding sets of parameters from a matrix opens a new window for more information.

TravisCI offers a different pattern to define the different configurations explicitly instead of using implicit combinations:

jobs:
  include:
    - rvm: 3.2.2
      gemfile: Gemfile
    - rvm: 3.3.0
      gemfile: Gemfile.next

Check the documentation on Listing individual jobs opens a new window for more information.

Conclusion

Dual-booting Ruby is similar to any other dependency, but there are some important differences that we need to address to do it right. There are more ways to do this but we try to minimize the disruption to the normal development workflow as much as possible. With these ideas you can default to use the current Ruby version while also being able to use the next version on demand with a few commands.

It’s important to note that, in some cases, dual-booting Ruby is not needed when the upgrade is simple enough. An initial test using the new Ruby version can be done without adding the complexity of the dual-boot to make an informed decision.

Running behind and need to upgrade to Ruby 3.3.0? Let us help you! opens a new window

Get the book