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
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).
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.
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
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
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
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
.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.
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.
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.
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
.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.