Some package managers default to installing packages globally, which can be convenient when starting out or writing quick scripts. In the ruby world the easy approach is to install a gem with gem install, require it in a script, and then give no more thought to it.

A particularly nasty drawback to this is that without locking down the versions of your dependencies to known good versions, you simply can't reproduce the state of your code and make any guarantees that it will work in future.

In a perfect world it wouldn't matter as every new release would be bug-free and fully backwards-compatible, but unfortunately in the real world that's not how it works. By pinning the versions of your dependencies you can guarantee any unexpected side effects from dependency upgrades will not happen outside your control.

In this post you will learn how to create a ruby script with locked-down dependencies that can be run from anywhere on the system (i.e. independently of its working directory).

Gemfile

Pinning versions for your ruby projects starts with the Gemfile, a file that defines the specific versions of gems required. It's actually just a regular ruby script executed in a specific context with a bunch of helper methods made available.

source 'https://rubygems.org'

gem 'colorize',    '~> 0.8.1'
gem 'require_all', '~> 2.0'

~> means "more than or greater than" applied only to the last digit, so colorize is pinned at >=0.8.1 <0.9.0 but require_all is pinned at >=2.0 <3.0. Note that the longhand syntax is valid too.

Once you define your gem versions, the tool to manage them is not gem like you might expect, but actually bundler, a gem itself, and perhaps one of the few gems that is appropriate to have installed globally.

Bundler

From the Bundler website:

Bundler provides a consistent environment for Ruby projects by tracking and installing the exact gems and versions that are needed.

Bundler is an exit from dependency hell, and ensures that the gems you need are present in development, staging, and production. Starting work on a project is as simple as bundle install.

Bundler suffers from non-sane defaults. Unlike other ecosystems where packages are installed and isolated away in some local directory, bundle install will actually by default install all the gems globally. As an unwanted bonus, if any of those gems provide binaries, they will now all be available in your PATH. No one likes having someone else's project's dependencies pointlessly clobbering their PATH. It's not cool.

Anyway, install Bundler globally, if it's not already installed.

gem install bundler

Start a new project

In short, the way to achieve total gem isolation is by telling bundler to store them in vendor/. This is similar to how other ecosystems do it so it may be familiar to you if you've ever seen a node_modules/ directory with npm or vendor/ with composer.

There's no need to write out a Gemfile manually, as bundle will do most of the work.

mkdir ~/greeter && cd ~/greeter && git init
bundle init

Run bundle install with --path now, to ensure that all future installs will automatically use the correct vendor/ directory without manually entering it.

bundle install --path vendor

This is crucial to the whole process. If --path is omitted at this point then the project will be forever doomed to storing its gems globally.

Install some gems.

bundle add colorize
bundle add require_all

Afterwards, you may notice there's a new file called Gemfile.lock.

This is a very important file that specifies the actual installed versions. That is, the version of each dependency that your project is currently locked at. This gives you the guarantee that future bundle installs will install the exact same version of each gem that the project was tested and known to work with, and it's up to the project maintainer to upgrade the gems when appropriate (bundle outdated helps with that).

The files to commit are Gemfile, Gemfile.lock, and .bundle/config. Ensure to ignore the vendor/ directory, as it can be reproduced from the state.

Write some code

#!/usr/bin/env ruby

require 'colorize'

puts 'Hello world'.green

Running that script with ./greet.rb greets us with an error.

$ ./greet.rb
# ...
require: cannot load such file -- colorize (LoadError)
# ...

Now that gems are installed locally, the system ruby has no awareness of them, and can't find colorize. It needs to be run in the context of the gemset defined by the Gemfile, not the system's global gemset.

Enter bundle exec, which runs its parameters in the context of the gemset.

$ bundle exec ./greet.rb
Hello world

It would be nice if there were no need to bear the mental overhead or extra typing from having to prefix everything with bundle exec. In fact it's so common that aliases are littered amongst everyone's dotfiles just to get around the fact that this is such an annoying thing to do.

Instead, require bundler/setup at the top of your script, which will set up the environment according to the gemset, just like bundle exec does.

#!/usr/bin/env ruby

require 'bundler/setup'
require 'colorize'

puts 'Hello world'.green

If it can be assumed that all dependencies specified are needed and can simply be auto-required, then Bundler also provides a helper for this.

#!/usr/bin/env ruby

require 'bundler'
Bundler.require

puts 'Hello world'.green

This way any gem installed will be required automatically.

However, shouldn't each dependency be manually specified in order to make it slightly more difficult to add them, since if anything the barrier should be higher, not lower?

It's each developer's own responsibility to think long and hard before adding any new dependencies, but explicit is generally better.

Increase portability

It's still not possible to call this script from anywhere on the system.

If it were in your PATH, or called absolutely from outside the project directory, you would encounter an error.

$ ./greet.rb
Hello world
$ cd ..
$ ./greeter/greet.rb
# ...
Could not locate Gemfile or .bundle/ directory (Bundler::GemfileNotFound)
# ...

Bundle looks for a Gemfile or .bundle directory relative to the caller, not the script file itself.

Luckily, calling Bundler with the environment variable BUNDLER_GEMFILE set to the path of the Gemfile ensures it starts with the correct environment.

$ BUNDLER_GEMFILE=./greeter/Gemfile bundle exec ./greeter/greet.rb
Hello world

The resulting shell script can be run from anywhere on the system, and it will work.

A downside is that a separate script is needed to call the main ruby project's entrypoint, though. Sometimes you might want to skip the extra level of indirection.

Dir.chdir __dir__ do
    require 'bundler/setup'
    require 'colorize'
end

Temporarily changing the directory to the location of the Gemfile (in this case in the same directory as the script itself) allows Bundler to figure out what it needs to do, before changing it back for the rest of the script execution. Ruby's ability to pass a block to Dir.chdir makes the code surprisingly elegant.

If these requirements are slightly different for your configuration then you can adjust the __dir__ to point to a different Gemfile. Imagine shared dependencies across a suite of scripts.

I am unaware of any caveats to doing this, so please let me know if there are any. It makes one wonder why Bundler can't be a little smarter about how it searches for Gemfiles.

That leaves the following as the final piece.

#!/usr/bin/env ruby

Dir.chdir __dir__ do
    require 'bundler/setup'
    require 'colorize'
end

puts 'Hello world'.green

A script that can be run from anywhere that will never use a dependency outside of the ones described in the Gemfile sitting next to it, with a fully isolated vendor directory, and where every future run of bundle will get an exact reproduction of the dependency state, guaranteed to work. Perfect.