/ ruby

How to use RVM with Jenkins Pipeline?

RVM allows to manage multiple Ruby versions and isolated gem environments within each version.
Sounds like a perfect tool for CI server, right?

Even though RVM documentation discusses CI integration I couldn't make it work with Jenkins.
RVM requires a single sourcing command in shell profile file.
It seems Jenkins uses non-interactive remote execution with ssh and does not respect neither profile nor login shell config files.
As a result I didn't find a way to inject RVM to Jenkins Pipeline sh command.
Even if it could have been done, sourcing allows to invoke rvm command but appropriate Ruby version still needs to be set with rvm use.
Let's assume rvm command is in place.

node {
    sh 'rvm use --install --create 2.3.1'
    sh 'ruby --version' // Env vars from previous command are forgotten
}

It does not work because sh step isolates the environment.

Simple sh step wrapper

We managed to figure out that sh 'source ~/.rvm/scripts/rvm' works but only within the scope of single sh DSL command.
So the simplest solution might be to create sh wrapper function which sources RVM script:

def rvmSh(String rubyVersion, String cmd) {
    def sourceRvm = 'source ~/.rvm/scripts/rvm'
    def useRuby = "rvm use --install $rubyVersion"
    sh "${sourceRvm}; ${useRuby}; $cmd"
}

node {
    rvmSh 'ruby --version'
}

It certainly does the job but has a couple of drawbacks.
First, we have to remember to use the wrapper instead of native DSL function (sh).
Second, other Jenkins Pipeline steps are not aware of our wrapper and processes invoked by them couldn't take benefit of RVM at all.
Last but on least, sh command operate in bash debug mode so the code above prints dozens lines of logs.

Step closure

Another possible solution would be to wrap sh in closure so the DSL context is aware that sh should be preceded with RVM sourcing.
An example of such implementation can be seen in Docker Pipeline inside step.
The solution would be much more convenient but also requires some serious programming work.

Simple Closure

An alternative solutions would be to figure out what rvm use does under the hood and try to replicate it using Jenkins Pipeline environment mechanism and take advantage of DSL closures.

RVM sets appropriate environment variables; by changing PATH and Ruby specific env vars it dynamically changes Ruby installation directories.
To figure out which env vars are required let's see the output of rvm info:

  environment:
    PATH:         "/Users/mk/.rvm/gems/ruby-2.3.1/bin:/Users/mk/.rvm/rubies/ruby-2.3.1/bin:/Users/mk/.rvm/bin:/Users/mk/.pyenv/shims:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/X11/bin:/usr/local/go/bin:/Users/mk/bin:/Users/mk/.gopath/bin"
    GEM_HOME:     "/Users/mk/.rvm/gems/ruby-2.3.1"
    GEM_PATH:     "/Users/mk/.rvm/gems/ruby-2.3.1"
    MY_RUBY_HOME: "/Users/mk/.rvm/rubies/ruby-2.3.1"
    IRBRC:        "/Users/mk/.rvm/rubies/ruby-2.3.1/.irbrc"
    RUBYOPT:      ""
    gemset:       ""

As we can see RVM manipulates on a couple of env vars including system PATH.
Jenkins Pipeline withEnv allows to change environmental variables in a safely manner. Let's write a groovy closure which utilise withEnv.

def withRvm(String version, String gemset, Closure cl) {
    // First we have to amend the `PATH`.
    final RVM_HOME = '$HOME/.rvm'
    paths = [
        "$RVM_HOME/gems/$version@$gemset/bin",
        "$RVM_HOME/gems/$version@global/bin",
        "$RVM_HOME/rubies/$version/bin",
        "$RVM_HOME/bin",
        "${env.PATH}"
    ]
    def path = paths.join(':')
    // First, let's make sure Ruby version is present.
    withEnv(["PATH=${env.PATH}:$RVM_HOME", "RVM_HOME=$RVM_HOME"]) {
        // Having `rvm` command available, `rvm use` can be used directly:
        sh "set +x; source $RVM_HOME/scripts/rvm; rvm use --create --install --binary $version@$gemset"
    }
    // Because we've just made sure Ruby is installed and Gemset is present, Ruby env vars can be exported just as `rvm use` would set them.
    withEnv([
        "PATH=$path",
        "GEM_HOME=$RVM_HOME/gems/$version@$gemset",
        "GEM_PATH=$RVM_HOME/gems/$version@$gemset:$RVM_HOME/gems/$version@global",
        "MY_RUBY_HOME=$RVM_HOME/rubies/$version",
        "IRBRC=$RVM_HOME/rubies/$version/.irbrc",
        "RUBY_VERSION=$version"
    ]) {
        // `rvm` can't tell if `rvm use` was run or the env vars were set manually.
        sh 'rvm info'
        sh 'ruby --version'
        cl()
    }
}

The first withEnv block does not set Ruby/Gem paths because required Ruby version might not exist yet.
The simples solution is to invoke rvm use --create --install which installs Ruby and creates gemset if required.

By setting correct PATH and gem variables every command run withing the closure uses desired Ruby version.

We want to avoid sharing gem directories during build execution and race conditions during resolving dependency
so we can easily address that by using one gemset per executor:

def withRvm(String version, Closure cl) {
    withRvm(version, "executor-${env.EXECUTOR_NUMBER}") {
        cl()
    }
}

At first glance the code might look complicated but by adding it to Jenkins Global Shared Library the build definition is as simple as:

node {
    withRvm('ruby-2.3.1') {
        sh 'ruby --version'
        sh 'gem install rake'
    }
}

Summary

We managed to come up with a working solution for utilising RVM using Jenkins Pipeline. Let's sum up the advantages and disadvantages.

Pros:

  • Affects all processes invoked by any Jenkins Pipeline step.
  • Isolated gem environments.
  • Clean and easy to use Jenkins Pipeline step.

Cons:

  • withRvm is less elastic than rvm use in terms of allowed Ruby version names.
    Only fully quantified names are accepted, e.g. ruby-2.3.1, ruby-1.9.3-p551 and jruby-9.0.5.0 works but 2.2, jruby doesn't. Probably, this can be fixed with some more coding. A possible solution might be executing rvm info, capturing std output and applying env vars accordingly.
  • Takes a lot of space because every executor gemset ends up with a copy of the same set of gems.
  • Depends highly on the implementation of rvm. If something changes it may stop working. If that becomes a problem, a solution might be to utilise the output of rvm info environment.