Managing Rails Versions with Capistrano

(Thu Jan 18, 2007) [/Rails#

Rails 1.2 is out! I can hear the bits screaming as they race down the wires. Time to get those apps upgraded! But first... You're gonna freeze your Rails apps to a specific Rails version, aren't you? Yes, of course I am, but how?

These are the questions I asked myself today as I prepared for the new release. I have newly-minted apps that I'd like to lock onto Rails 1.2, and I have "legacy" apps that will take some time to test and upgrade. I want to make doubly-sure my apps aren't at the mercy of system-wide Rails installs. Yes, we must be selfish when it comes to our Rails versions.

Having checked that off the to-do list, I then started wondering if I've been managing Rails versions correctly all along. Something didn't feel right, so I began asking around to see what other folks I respect are doing. Here's what I found...

Freezing

Any of the following commands will copy a version of Rails into your application's vendor/rails directory:

rake rails:freeze:edge
rake rails:freeze:edge REVISION=xyz
rake rails:freeze:edge TAG=xyz
rake rails:freeze:gems

The exact version comes from the Rails Subversion repository or, in the case of the last command, the version of Rails currently installed as a gem on your machine.

Then, when you fire up your application, it will use the version of Rails in the vendor/rails directory. In other words, your application is no longer subject to the ebb and flow of the system-wide installation of Rails. Instead, your app is locked to a specific version of Rails.

This approach is quite handy, but it's not the end of the story. Freezing Rails in this way doesn't affect your local version control repository. You simply have a copy of all the Rails bits on your local machine. What version of Rails will be used when you deploy your application to another machine? Hrm.

Linking

The next step then is to make sure that a specific Rails version will travel along when you deploy your application onto a production machine.

One way to do that would be to freeze and then check all the files under the vendor/rails directory into your version control repository. This would work, but it makes upgrading (and downgrading) Rails versions a hassle. Your repository has one copy of the Rails version and the official Rails repository has another. It would be more convenient to simply create a link to the official Rails repository.

Linking up to the Rails mother ship turns out to be quite easy to do using Subversion externals. For example, to link to Edge Rails, you'd run the command

svn propset svn:externals \
            "rails http://dev.rubyonrails.org/svn/rails/trunk" \
            vendor

Now every time you run svn update you'll get the latest version of your application code and any changes made to the version of Rails it's linked to. It's just like having the Rails core members on your project!

Living on the edge can be a thrill while you're developing the app, but it's wise to bind your application to a version of Rails that's a bit less volatile before going into production. To do that, you'd just update the external link to a stable version:

svn propset svn:externals \
            "rails http://dev.rubyonrails.org/svn/rails/tags/rel_1-2-1" \
            vendor
svn update

Then of course you'd run your tests locally and all that good stuff before actually deploying it. With the link in place, you don't have to do anything special after deploying the app. You run cap deploy and it makes sure to check out your code onto the remote machine(s). And because vendor/rails has an external link to a Rails version, that version will get checked out as part of the automated process. So far so good!

Gratuitous Reflection Moment

This is the way I started doing deployments, but recently I made a switch to make things a bit more efficient. See, we run cap deploy a lot. Sometimes it's to push new official features to a production server, but more frequently it's to push small changes during development to our private look-at-me server. It's the server that hosts our Edge application for everyone on the team to poke and prod.

On a good development day I might cap deploy to the private server a dozen or more times, while on a bad development day I might cap rollback it just as many times. And every time anyone deploys, the external link triggers a trip out to the Rails Subversion repository to suck down all those bits. That doesn't seem very responsible.

A More Efficient Approach

After asking around to see what other folks are doing, my eyes were opened to a few alternatives which I'll try to distill here.

First, as effortless as it is to have an external link to Rails and automatically get updates when I run svn update on my app, I've never really felt that comfortable with it. The majority of the time I run that command to update my code, and getting Rails updates is usually an unexpected side effect. Perhaps I just don't belong on Edge Rails, but I'd prefer to have a two-step process: Update my code, and then if everything shakes out go ahead and update Rails. That way I'm not potentially fighting compounded problems.

Second, I have several apps all running the same Rails version, and I'd like to share that version. Yeah, disk space is cheap, but it seems silly to constantly be fetching stuff over the external link.

So the first change I've made is to stop using an external link for Rails. Instead, I check out a version of Rails to a common directory and symlink it to vendor/rails in each of my Rails applications. Here's how I do that, using Rails 1.2 as an example:

svn co http://dev.rubyonrails.org/svn/rails/tags/rel_1-2-1 \
       ~/work/rails/rel_1-2-1
cd my_rails_app
ln -s ~/work/rails/rel_1-2-1 vendor/rails

That covers managing Rails versions on my local machine. When it comes to deployment, I do something similar: Check out a version of Rails to a common directory on the deployment server and symlink it to vendor/rails of the deployed application.

Here's the simplest Capistrano task I could think of for doing that, which defaults to using Rails version 1.2:

desc "Deploy a shared Rails version"
task :deploy_rails do
  ENV['RAILS_TAG'] ||= 'rel_1-2-1'
  checkout_path = "#{shared_path}/rails"
  symlink_path  = "#{release_path}/vendor/rails"

  run <<-CMD
    if [ ! -d #{checkout_path} ];
    then
     echo "Checking out Rails #{ENV['RAILS_TAG']}...";
     svn checkout --quiet http://dev.rubyonrails.org/svn/rails/tags/#{ENV['RAILS_TAG']} \     
                          #{checkout_path};
    fi
  CMD
  
  puts 'Linking Rails...'
  run "rm -rf #{symlink_path}"
  run "ln -nfs #{checkout_path} #{symlink_path}"
end

If the shared/rails directory doesn't exist, then the specified version of Rails is checked out to that directory. Then a symlink is made between shared/rails and the vendor/rails directory of the deployed application. The upshot is the Rails Subversion repository is only accessed once to create a shared directory full of Rails bits.

The deploy_rails task then needs to be added to an after_update_code task in the same Capistrano recipe file:

task :after_update_code, :roles => :app do
  deploy_rails
end

Running cap deploy now will make sure the symlink is formed between each deployed application release and the shared Rails version.

This approach gets the job done without fuss, and I like to start with simple stuff like this to get something working fast and learn from it. But it's a bit messy, specifically because it needs to check for the existence of the directory on the remote machine. To do that I had to resort to some old-school shell syntax inside of the run section, which runs those commands on the remote machine. I'm already missing Ruby syntax.

As well, it would be really nice if I could easily upgrade and downgrade the version of Rails used by the production app. Thankfully, Rick Olson has already cracked this nut.

The Cadillac Approach

After whipping up that Capistrano task, I appreciated Rick's solution all the more. It has the same basic underpinnings, but with a lot more cushion. Sit back and enjoy the smooth ride.

1. Snag This Code

Add this code (a Rake task and a helper method) to your lib/tasks/common.rake file, for example. Then make sure to check that file in to your SVN repository.

The Rake task uses a common Rails shared path to hold Rails revisions. Here's what the Rails shared path directory structure looks like:

shared/rails
shared/rails/trunk
shared/rails/rev_5989
shared/rails/rev_5990

It simply runs svn update on the shared/trunk directory to update it to a given Rails revision, then exports it to a separate revision directory. It then symlinks this exported Rails revision directory to the vendor/rails directory of the current release of your application.

The next time the app is deployed using that Rails revision, it only needs to symlink the revision (no SVN access). Each release is bound to its own version of Rails, and you can roll back to older version of Rails if need be.

2. Hook It

Add the following after_update_code Capistrano task to your config/deploy.rb file:

set :rails_version, 5990 unless variables[:rails_version]

task :after_update_code, :roles => :app do
  run <<-CMD
    cd #{release_path} && 
    rake deploy_edge REVISION=#{rails_version} 
  CMD
end

This task simply runs the deploy_edge Rake task that you added to your lib/tasks/common.rake file after Capistrano has checked out your application code. Remember that all this happens on the remote machine!

The rails_version variable is not a standard Capistrano variable, so make sure to set it to the desired Rails revision number. In this example I'm targetting Rails 1.2.1 which has a revision number of 5990.

By default, the shared Rails version will end up in the shared directory that Capistrano creates on the remote machine. But you can override this to put Rails anywhere on the remote machine, which means you can share it across apps. To do that, change the call to the Rake task to include setting the RAILS_PATH environment variable:

rake deploy_edge REVISION=#{rails_version} \
                 RAILS_PATH=/path/to/shared/rails

3. Deploy At Will

Now we're back to that single-command deployment step that we've all grown to love.

cap deploy

Except this time there's a bit more oomph! as it also sorts out the Rails version on the remote machine.

To upgrade or downgrade Rails versions, override the default value of the rails_version variable on the command line:

cap -S rails_version=5991 deploy

Ah... now that feels a lot better!

The End

I haven't done anything new here. I didn't even plan (or have the time) to write this. I just sorta felt some pinching every time I deployed an app, and figured I'd listen to it and try to learn something new. What I found through Rick's help was something I felt deserved to bask in a bit more glow. It's certainly working really well for me so far. If you have even better mojo, please blog it.

Thanks to Rick Olson and Jamis Buck for the help and ideas!