Upgrading Rails: The Dual-Boot Way at RailsConf 2022

This is a companion page to the Rails upgrade workshop at RailsConf 2022.
Here you will find a series of hands-on exercises to get started.

The main goal for this workshop is to learn a proven technique for upgrading Rails applications. For all exercises you will use our sample application.

1. Pre-Requisites

Next Step

These are the requirements for our sample app:

To make sure `git` is installed, please run this in your terminal:


git --version
git version 2.30.1 (Apple Git-130)
            

To make sure `docker` is installed, please run this in your terminal:


docker --version
Docker version 20.10.8, build 3967b7d
            

To make sure `docker-compose` is installed, please run this in your terminal:


docker-compose --version
docker-compose version 1.29.2, build 5becea4c
        

If you don't have Docker, docker-compose, or git installed, please take a moment to install them.

2. Set up Sample Outdated Rails App

Previous Step Next Step Back to Top

We will work with this sample application: Refuge Restrooms -- an open source application that indexes and maps safe restroom locations for trans, intersex, and gender nonconforming individuals.

You can clone the repository with git:


git clone git@github.com:fastruby/refugerestrooms.git
cd refugerestrooms
git checkout start-exercise-1
          

Next we'll set up the application in your local environment and start a bash session. However, what you need to do depends on the type of computer you have.

If you have a Mac M1 laptop, do the following - note this is only for Mac M1's. If you're not sure if your Mac is an M1, in your Apple menu, open "About This Mac", and check the "Chip" value.

# For Mac M1 only!
docker-compose -f docker-compose.yml -f docker-compose.mac-m1.yml up --no-build
# Then Ctrl-C when it's done and do:
docker-compose -f docker-compose.yml -f docker-compose.mac-m1.yml run web /bin/bash

If you do not have a Mac M1 laptop, do the following:

docker-compose run web /bin/bash

For the rest of the workshop, make sure you are always running the steps inside the docker container's bash.

3. How outdated is our application?

Previous Step Next Step Back to Top

To get an idea of how outdated our application really is we can use the next_rails gem.


group :development, :test do
  gem 'next_rails'
  # ...
end
            

Then we will need to install it:

$ bundle install
        

Let's check it out! It should give us a good idea about the shape of our dependencies:


$ bundle_report outdated

puma 5.6.2: released Jan  1, 1980 (latest version, 5.6.4, released Jan  1, 1980)
formtastic_i18n 0.6.0: released Mar 10, 2016 (latest version, 0.7.0, released May 23, 2021)
…
rails 5.2.6.3: released Mar  8, 2022 (latest version, 7.0.3, released May  9, 2022)
bundler 2.1.4: released Mar 29, 2022 (latest version, 2.3.13, released May  4, 2022)

0 gems are sourced from git
105 of the 163 gems are out-of-date (64%)
            

We now know that the dependencies are 64% out of date.

`next_rails` also provides an interesting command that we could run to find known compatibility issues:


$ bundle_report compatibility --rails-version=6.0.5

=> Incompatible with Rails 6.0.5 (with new versions that are compatible):
These gems will need to be upgraded before upgrading to Rails 6.0.5.

dotenv-rails 2.2.2 - upgrade to 2.7.6

1 gems incompatible with Rails 6.0.5
            

Now we know there will be two gems that will cause problems. We can add these gems to our TODO list.

4. Dual Boot: Setup

Previous Step Next Step Back to Top

We are going to use a helper tool for dual booting: `next_rails`.

$ next --init

This command created a symlink called `Gemfile.next` and added a helper method to the top of the `Gemfile`.

Now we can open our `Gemfile` and verify that we have a `next?` method defined in it. In the odd chance it didn't work, we can try doing it manually like this:

$ ln -s Gemfile Gemfile.next
            

Then we can add this method to the top of your `Gemfile`:


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

5. Dual Boot: Usage

Previous Step Next Step Back to Top

Now we have all we need to start tweaking our `Gemfile` for the next version of Rails:


if next?
  gem 'rails', '~> 6.0'
else
  gem 'rails', '5.2.6.3'
end
            

It's time to `bundle install` using the next version. We can use the `next` command like this:


$ next bundle update rails

...
Bundler could not find compatible versions for gem "railties":
  In Gemfile.next:
    dotenv-rails (~> 2.2.1) was resolved to 2.2.2, which depends on
      railties (>= 3.2, < 6.0)

    rails (~> 6.0.5) was resolved to 6.0.5, which depends on
      railties (= 6.0.5)
            

If that doesn't work, you can try with:

$ BUNDLE_GEMFILE=Gemfile.next bundle update rails

As you can see from the output, looks like there's an issue with the dotenv-rails when trying to update rails. To fix that we can use our new `next?` method:


# Gemfile

if next?
  gem ‘dotenv-rails‘
else
  gem ‘dotenv-rails', ‘~> 2.2.1’
end
            

And then run:

$ next bundle update rails dotenv-rails

6. Dual Boot: rails console and tests

Previous Step Next Step Back to Top

Now that we have bundled our project with both versions of Rails, we can start the rails console to check if we have any errors.


$ bundle exec rails console
Loading development environment (Rails 5.2.6.3)
irb(main):001:0>
            

$ next bundle exec rails console
Loading development environment (Rails 6.0.5)
irb(main):001:0>
            

In the same way we can start running the test suite to identify possible issues.


$ bundle exec rspec

.........................................................

Finished in 58.22 seconds (files took 2.49 seconds to load)
64 examples, 0 failures
            

$ next bundle exec rspec

DEPRECATION WARNING: Class level methods will no longer inherit scoping from `current` in Rails 6.1. To continue using the scoped relation, pass it into the block directly. To instead access the full set of models, as Rails 6.1 will, use `Restroom.default_scoped`. (called from block in  at /refugerestrooms/app/models/restroom.rb:47)
.........................................................

Finished in 35.61 seconds (files took 3.04 seconds to load)
64 examples, 0 failures

            

Note that the deprecation warning that we see doesn't need to be addressed now, but on the next version jump (Rails 6.0 to 6.1).

Also, this application doesn't have any test failure, but it's worth mentioning that every failure we find should become a new story in our roadmap.

A good way to find the root cause of a test failure would be to check the changes between Rails versions. You can do that in the official guides or our guides.

Another useful resource is RailsDiff, where we can see what changed between Rails 5.2 and Rails 6.0, for example.

See the Bonus section below for more about deprecation warnings.

7. Class Autoloading & Dual Booting

Previous Step Next Step Back to Top

Please see the Classic to Zeitwerk HOWTO Rails Guide for an overview of Rails' class autoloading and the changes introduced in Rails 6.

Let's update the application to use Zeitwerk with Rails 6, while still using the Classic autoloader for Rails 5.2. We'll start with some experimenting.

# In config/application.rb change the config defaults to Rails 6.0:
config.load_defaults 6.0

# Now run the tests for Rails 6
# You should see an error, after the deprecation warning:
$ next bundle exec rspec spec/models/

# Add this to config/application.rb and run the tests again:
config.autoloader = :classic

# The tests should pass again now:
$ next bundle exec rspec spec/models/

# Change the autoloader to Zeitwerk
config.autoloader = :zeitwerk

# Run the task to check Zeitwerk compatibility,
# which will return the same error we saw in the tests:
$ next bin/rails zeitwerk:check

# Let's fix it! Add this to config/initializers/inflections.rb
# For the explanation see:
# https://guides.rubyonrails.org/classic_to_zeitwerk_howto.html#acronyms
ActiveSupport::Inflector.inflections(:en) do |inflect|
  inflect.acronym "API"
end

# The Zeitwerk check should pass now:
$ next bin/rails zeitwerk:check

# Show that the tests pass again for Rails 6:
$ next bundle exec rspec spec/models/

Now we're ready to take what we've learned to set up dual booting for Rails 5.2 and Rails 6.0.

# Let’s define a $next_rails global var in the Gemfile:

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

# Update config/application.rb
if $next_rails
  config.load_defaults 6.0
  config.autoloader = :zeitwerk
else
  config.load_defaults 5.2
  config.autoloader = :classic # optional line
end

# Show that the tests pass for Rails 5.2
$ bundle exec rspec spec

# Show that the tests pass for Rails 6.0
$ next bundle exec rspec spec

8. Stay Current

Previous Step Next Step Back to Top

Deprecation warnings in the latest versions of Rails have been quite helpful in guiding us to the next version of Rails. We should treat all new deprecation warnings as exceptions:

# config/environments/test.rb

Rails.application.configure do
  # Raise on deprecation notices
  config.active_support.deprecation = :raise
end
            

That way we can turn _noise_ into many _signals_. Deprecation warnings can easily be ignored when they're just a message in `test.log`. They can't be easily ignored if they break your test suite.

Another great resource is to add bundler-audit gem as a dependency and make sure to run a check every time you run your test suite. We can do this by tweaking your `Rakefile`:


# Rakefile

require File.expand_path('../config/application', __FILE__)
require 'rake'

Rails.application.load_tasks

task default: %i[
  bundle:audit brakeman:check
  spec spec:javascripts cucumber
]
            

Now everytime you run `bundle exec rake`, it will not only run your test suite but also run `bundle:audit` which does this:


namespace :bundle do
  desc "Audit bundle for any known vulnerabilities"
    task :audit do
      unless system "bundle-audit check --update"
        exit 1
      end
    end
  end
end
            

Finally you can keep up with the latest version by using the Rails `main` branch:


# Gemfile

source 'https://rubygems.org'

git_source(:github) do |repo_name|
  repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/")
  "https://github.com/#{repo_name}.git"
end

if next?
  gem 'rails', github: 'rails/rails'
else
  gem 'rails', '~> 7.0.3'
end
            

You can find instructions on how to set this up in your CI server over here: https://fastruby.io/blog/upgrade-rails/dual-boot/dual-boot-with-rails-6-0-beta.html

At this point you should have the sample app running on Rails 6.0 (or even Rails 6.1!).

9. Questions?

Previous Step Next Step Back to Top

If you have any questions, you are welcome to reach out to us in Slack or sending us a message at FastRuby.io.
You can also find us on Twitter at: @etagwerker, @mtoppa and @lubc32

10. Thank you!

Previous Step Next Step Back to Top

You are wonderful! Thanks for participating in our workshop. We hope you can apply all of this in your next Rails upgrade project! :)

11. Bonus: Deprecation Warnings

Previous Step Back to Top

Tracking deprecation warnings

next_rails has a handy feature to track deprecation warnings when running tests. You can configure it like this:

# rails_helper.rb / spec_helper.rb

RSpec.configure do |config|
  # Tracker deprecation messages in each file
  if ENV["DEPRECATION_TRACKER"]
    DeprecationTracker.track_rspec(
      config,
      shitlist_path: "spec/support/deprecation_warning.shitlist.json",
      mode: ENV["DEPRECATION_TRACKER"],
      transform_message: -> (message) { message.gsub("#{Rails.root}/", "") }
    )
  end
end

And run it like this:

$ DEPRECATION_TRACKER=save next rspec

This will generate a file where you can better see all your deprecation warnings.

Fixing the deprecation warnings

The first deprecation warning you'll see is this one:

DEPRECATION WARNING: Single arity template handlers are deprecated. Template handlers must
now accept two parameters, the view object and the source for the view object.
Change:
>> Coffee::Rails::TemplateHandler.call(template)
To:
>> Coffee::Rails::TemplateHandler.call(template, source)
              (called from <top (required)&rt; at /refugerestrooms/config/environment.rb:5)

This can be addressed by updating the coffee-rails gem (the mention of Coffee::Rails in the error is a hint to try updating the gem):

if next?
  gem 'coffee-rails', '~> 5.0'
else
  gem 'coffee-rails', '~> 4.2'
end

And then run next bundle update coffee-rails

The second deprecation warning you'll see is this one:

DEPRECATION WARNING: Class level methods will no longer inherit scoping from `current` in Rails 6.1. To continue using the scoped relation, pass it into the block directly. To instead access the full set of models, as Rails 6.1 will, use `Restroom.default_scoped`. (called from block in <class:Restroom> at /refugerestrooms/app/models/restroom.rb:47)

It tells us the line of code to look at, which take us to:

scope :current, lambda {
  Restroom.where('id IN (SELECT MAX(id) FROM restrooms WHERE approved GROUP BY edit_id)')
}

Since this code is in the Restroom model, there is no need to specify the name of the model here. So the following change will address the warning:

scope :current, lambda {
  self.where('id IN (SELECT MAX(id) FROM restrooms WHERE approved GROUP BY edit_id)')
}