From ba77d4a0e7a1d190d437fda5e353143957d8a3a6 Mon Sep 17 00:00:00 2001
From: Rhys Arkins <rhys@arkins.net>
Date: Mon, 14 Jan 2019 06:52:13 +0100
Subject: [PATCH] feat(bundler): extract, update, artifacts (#3058)

This completes the work of adding basic Ruby/Bundler support to Renovate. It will now find all Gemfiles in a repository, extract dependencies from them, look up results on Rubygems, and raise PRs if updates are found.

Closes #932
---
 lib/config/definitions.js                     |   4 +-
 lib/manager/bundler/artifacts.js              | 107 +++-
 lib/manager/bundler/extract.js                | 151 ++++-
 lib/manager/bundler/update.js                 |  28 +-
 lib/workers/branch/index.js                   |   6 +
 test/_fixtures/bundler/Gemfile.rails          | 161 +++++
 test/_fixtures/bundler/Gemfile.sourceGroup    |  16 +
 .../__snapshots__/extract.spec.js.snap        | 553 ++++++++++++++++++
 test/manager/bundler/extract.spec.js          |  27 +
 test/manager/bundler/update.spec.js           |  46 +-
 website/docs/ruby.md                          |  33 ++
 11 files changed, 1101 insertions(+), 31 deletions(-)
 create mode 100644 test/_fixtures/bundler/Gemfile.rails
 create mode 100644 test/_fixtures/bundler/Gemfile.sourceGroup
 create mode 100644 test/manager/bundler/__snapshots__/extract.spec.js.snap
 create mode 100644 website/docs/ruby.md

diff --git a/lib/config/definitions.js b/lib/config/definitions.js
index a4514fa913..1f18402581 100644
--- a/lib/config/definitions.js
+++ b/lib/config/definitions.js
@@ -1085,7 +1085,9 @@ const options = [
     stage: 'package',
     type: 'json',
     default: {
-      fileMatch: [],
+      enabled: false,
+      fileMatch: ['(^|/)Gemfile$'],
+      versionScheme: 'ruby',
     },
     mergeable: true,
   },
diff --git a/lib/manager/bundler/artifacts.js b/lib/manager/bundler/artifacts.js
index 5582aee4e8..2c7fc31286 100644
--- a/lib/manager/bundler/artifacts.js
+++ b/lib/manager/bundler/artifacts.js
@@ -1,19 +1,110 @@
+/* istanbul ignore file */
+
+const { exec } = require('child-process-promise');
+const fs = require('fs-extra');
+const upath = require('upath');
+
 module.exports = {
   getArtifacts,
 };
 
-/*
- *  The getArtifacts() function is optional and necessary only if it is necessary to update "artifacts"
- *  after updating package files. Artifacts are files such as lock files or checksum files.
- *  Usually this will require running a child process command to produce an update.
- */
-
 async function getArtifacts(
   packageFileName,
   updatedDeps,
   newPackageFileContent,
   config
 ) {
-  await logger.debug({ config }, `composer.getArtifacts(${packageFileName})`);
-  return null;
+  logger.debug(`bundler.getArtifacts(${packageFileName})`);
+  const lockFileName = packageFileName + '.lock';
+  const existingLockFileContent = await platform.getFile(lockFileName);
+  if (!existingLockFileContent) {
+    logger.debug('No Gemfile.lock found');
+    return null;
+  }
+  const cwd = upath.join(config.localDir, upath.dirname(packageFileName));
+  let stdout;
+  let stderr;
+  try {
+    const localPackageFileName = upath.join(config.localDir, packageFileName);
+    await fs.outputFile(localPackageFileName, newPackageFileContent);
+    const localLockFileName = upath.join(config.localDir, lockFileName);
+    if (!config.gitFs) {
+      await fs.outputFile(localLockFileName, existingLockFileContent);
+      const fileList = await platform.getFileList();
+      const gemspecs = fileList.filter(file => file.endsWith('.gemspec'));
+      for (const gemspec of gemspecs) {
+        const content = await platform.getFile(gemspec);
+        await fs.outputFile(upath.join(config.localDir, gemspec), content);
+      }
+    }
+    const env =
+      global.trustLevel === 'high'
+        ? process.env
+        : {
+            HOME: process.env.HOME,
+            PATH: process.env.PATH,
+          };
+    const startTime = process.hrtime();
+    let cmd;
+    if (config.binarySource === 'docker') {
+      logger.info('Running bundler via docker');
+      cmd = `docker run --rm `;
+      const volumes = [config.localDir];
+      cmd += volumes.map(v => `-v ${v}:${v} `).join('');
+      const envVars = [];
+      cmd += envVars.map(e => `-e ${e} `);
+      cmd += `-w ${cwd} `;
+      cmd += `renovate/bundler bundler`;
+    } else {
+      logger.info('Running bundler via global bundler');
+      cmd = 'bundler';
+    }
+    const args = 'lock';
+    logger.debug({ cmd, args }, 'bundler command');
+    ({ stdout, stderr } = await exec(`${cmd} ${args}`, {
+      cwd,
+      shell: true,
+      env,
+    }));
+    const duration = process.hrtime(startTime);
+    const seconds = Math.round(duration[0] + duration[1] / 1e9);
+    logger.info(
+      { seconds, type: 'Gemfile.lock', stdout, stderr },
+      'Generated lockfile'
+    );
+    // istanbul ignore if
+    if (config.gitFs) {
+      const status = await platform.getRepoStatus();
+      if (!status.modified.includes(lockFileName)) {
+        return null;
+      }
+    } else {
+      const newLockFileContent = await fs.readFile(localLockFileName, 'utf8');
+
+      if (newLockFileContent === existingLockFileContent) {
+        logger.debug('Gemfile.lock is unchanged');
+        return null;
+      }
+    }
+    logger.debug('Returning updated Gemfile.lock');
+    return {
+      file: {
+        name: lockFileName,
+        contents: await fs.readFile(localLockFileName, 'utf8'),
+      },
+    };
+  } catch (err) {
+    if (
+      err.stdout &&
+      err.stdout.includes('No such file or directory') &&
+      !config.gitFs
+    ) {
+      throw new Error('bundler-fs');
+    }
+    logger.info(
+      { err, message: err.message },
+      'Failed to generate bundler.lock (unknown error)'
+    );
+    throw new Error('bundler-unknown');
+  }
 }
diff --git a/lib/manager/bundler/extract.js b/lib/manager/bundler/extract.js
index 33a98bcaff..0e0ec50b27 100644
--- a/lib/manager/bundler/extract.js
+++ b/lib/manager/bundler/extract.js
@@ -1,18 +1,143 @@
+const { isValid } = require('../../versioning/ruby');
+
 module.exports = {
   extractPackageFile,
 };
 
-/*
- * The extractPackageFile() function is mandatory unless extractAllPackageFiles() is used instead.
- *
- * Use extractPackageFile() if it is OK to parse/extract package files in parallel independently.
- *
- * Here are examples of when extractAllPackageFiles has been necessary to be used instead:
- *  - for npm/yarn/lerna, "monorepos" can have links between package files and logic requiring us to selectively ignore "internal" dependencies within the same repository
- *  - for gradle, we use a third party CLI tool to extract all dependencies at once and so it should not be called independently on each package file separately
- */
-
-function extractPackageFile(content, fileName) {
-  logger.trace(`bundler.extractPackageFile(${fileName})`);
-  return null;
+function extractPackageFile(content) {
+  const res = {
+    registryUrls: [],
+    deps: [],
+  };
+  const lines = content.split('\n');
+  for (let lineNumber = 0; lineNumber < lines.length; lineNumber += 1) {
+    const line = lines[lineNumber];
+    const sourceMatch = line.match(/^source "([^"]+)"\s*$/);
+    if (sourceMatch) {
+      res.registryUrls.push(sourceMatch[1]);
+    }
+    const gemMatch = line.match(/^gem "([^"]+)"(,\s+"([^"]+)"){0,2}/);
+    if (gemMatch) {
+      const dep = {
+        depName: gemMatch[1],
+        lineNumber,
+      };
+      if (gemMatch[3]) {
+        dep.currentValue = gemMatch[0]
+          .substring(`gem "${dep.depName}",`.length)
+          .replace(/"/g, '')
+          .trim();
+        if (!isValid(dep.currentValue)) {
+          dep.skipReason = 'invalid-value';
+        }
+      } else {
+        dep.skipReason = 'no-version';
+      }
+      if (!dep.skipReason) {
+        dep.purl = 'pkg:rubygems/' + dep.depName;
+      }
+      res.deps.push(dep);
+    }
+    const groupMatch = line.match(/^group\s+(.*?)\s+do/);
+    if (groupMatch) {
+      const depTypes = groupMatch[1]
+        .split(',')
+        .map(group => group.trim())
+        .map(group => group.replace(/^:/, ''));
+      const groupLineNumber = lineNumber;
+      let groupContent = '';
+      let groupLine = '';
+      while (lineNumber < lines.length && groupLine !== 'end') {
+        lineNumber += 1;
+        groupLine = lines[lineNumber];
+        if (groupLine !== 'end') {
+          groupContent += groupLine.replace(/^ {2}/, '') + '\n';
+        }
+      }
+      const groupRes = extractPackageFile(groupContent);
+      if (groupRes) {
+        res.deps = res.deps.concat(
+          groupRes.deps.map(dep => ({
+            ...dep,
+            depTypes,
+            lineNumber: dep.lineNumber + groupLineNumber + 1,
+          }))
+        );
+      }
+    }
+    const sourceBlockMatch = line.match(/^source\s+"(.*?)"\s+do/);
+    if (sourceBlockMatch) {
+      const repositoryUrl = sourceBlockMatch[1];
+      const sourceLineNumber = lineNumber;
+      let sourceContent = '';
+      let sourceLine = '';
+      while (lineNumber < lines.length && sourceLine !== 'end') {
+        lineNumber += 1;
+        sourceLine = lines[lineNumber];
+        if (sourceLine !== 'end') {
+          sourceContent += sourceLine.replace(/^ {2}/, '') + '\n';
+        }
+      }
+      const sourceRes = extractPackageFile(sourceContent);
+      if (sourceRes) {
+        res.deps = res.deps.concat(
+          sourceRes.deps.map(dep => ({
+            ...dep,
+            registryUrls: [repositoryUrl],
+            lineNumber: dep.lineNumber + sourceLineNumber + 1,
+          }))
+        );
+      }
+    }
+    const platformsMatch = line.match(/^platforms\s+(.*?)\s+do/);
+    if (platformsMatch) {
+      const platformsLineNumber = lineNumber;
+      let platformsContent = '';
+      let platformsLine = '';
+      while (lineNumber < lines.length && platformsLine !== 'end') {
+        lineNumber += 1;
+        platformsLine = lines[lineNumber];
+        if (platformsLine !== 'end') {
+          platformsContent += platformsLine.replace(/^ {2}/, '') + '\n';
+        }
+      }
+      const platformsRes = extractPackageFile(platformsContent);
+      if (platformsRes) {
+        res.deps = res.deps.concat(
+          // eslint-disable-next-line no-loop-func
+          platformsRes.deps.map(dep => ({
+            ...dep,
+            lineNumber: dep.lineNumber + platformsLineNumber + 1,
+          }))
+        );
+      }
+    }
+    const ifMatch = line.match(/^if\s+(.*?)/);
+    if (ifMatch) {
+      const ifLineNumber = lineNumber;
+      let ifContent = '';
+      let ifLine = '';
+      while (lineNumber < lines.length && ifLine !== 'end') {
+        lineNumber += 1;
+        ifLine = lines[lineNumber];
+        if (ifLine !== 'end') {
+          ifContent += ifLine.replace(/^ {2}/, '') + '\n';
+        }
+      }
+      const ifRes = extractPackageFile(ifContent);
+      if (ifRes) {
+        res.deps = res.deps.concat(
+          // eslint-disable-next-line no-loop-func
+          ifRes.deps.map(dep => ({
+            ...dep,
+            lineNumber: dep.lineNumber + ifLineNumber + 1,
+          }))
+        );
+      }
+    }
+  }
+  if (!res.deps.length && !res.registryUrls.length) {
+    return null;
+  }
+  return res;
 }
diff --git a/lib/manager/bundler/update.js b/lib/manager/bundler/update.js
index 89178929fb..0816107a4c 100644
--- a/lib/manager/bundler/update.js
+++ b/lib/manager/bundler/update.js
@@ -9,7 +9,29 @@ module.exports = {
  */
 
 function updateDependency(currentFileContent, upgrade) {
-  logger.debug({ config: upgrade }, 'bundler.updateDependency()');
-  // TODO
-  return currentFileContent;
+  try {
+    const lines = currentFileContent.split('\n');
+    const lineToChange = lines[upgrade.lineNumber];
+    if (!lineToChange.includes(upgrade.depName)) {
+      logger.debug('No gem match on line');
+      return null;
+    }
+    const newValue = upgrade.newValue
+      .split(',')
+      .map(part => `, "${part.trim()}"`)
+      .join('');
+    const newLine = lineToChange.replace(
+      /(gem "[^"]+")(,\s+"[^"]+"){0,2}/,
+      `$1${newValue}`
+    );
+    if (newLine === lineToChange) {
+      logger.debug('No changes necessary');
+      return currentFileContent;
+    }
+    lines[upgrade.lineNumber] = newLine;
+    return lines.join('\n');
+  } catch (err) {
+    logger.info({ err }, 'Error setting new Gemfile value');
+    return null;
+  }
 }
diff --git a/lib/workers/branch/index.js b/lib/workers/branch/index.js
index 7495b8f538..747edf5fdd 100644
--- a/lib/workers/branch/index.js
+++ b/lib/workers/branch/index.js
@@ -323,6 +323,12 @@ async function processBranch(branchConfig, prHourlyLimitReached, packageFiles) {
     }
     if (err.message === 'update-failure') {
       logger.warn('Error updating branch: update failure');
+    } else if (err.message === 'bundler-fs') {
+      logger.warn(
+        'It is necessary to run Renovate in gitFs mode - contact your bot administrator'
+      );
+    } else if (err.message === 'bundler-unknown') {
+      logger.warn('Unknown bundler error');
     } else if (
       err.message !== 'registry-failure' &&
       err.message !== 'platform-failure'
diff --git a/test/_fixtures/bundler/Gemfile.rails b/test/_fixtures/bundler/Gemfile.rails
new file mode 100644
index 0000000000..eced62380f
--- /dev/null
+++ b/test/_fixtures/bundler/Gemfile.rails
@@ -0,0 +1,161 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+git_source(:github) { |repo| "https://github.com/#{repo}.git" }
+
+gemspec
+
+# We need a newish Rake since Active Job sets its test tasks' descriptions.
+gem "rake", ">= 11.1"
+
+gem "capybara", ">= 2.15"
+
+gem "rack-cache", "~> 1.2"
+gem "sass-rails"
+gem "turbolinks", "~> 5"
+gem "webpacker", github: "rails/webpacker", require: ENV["SKIP_REQUIRE_WEBPACKER"] != "true"
+# require: false so bcrypt is loaded only when has_secure_password is used.
+# This is to avoid Active Model (and by extension the entire framework)
+# being dependent on a binary library.
+gem "bcrypt", "~> 3.1.11", require: false
+
+# This needs to be with require false to avoid it being automatically loaded by
+# sprockets.
+gem "uglifier", ">= 1.3.0", require: false
+
+# Explicitly avoid 1.x that doesn't support Ruby 2.4+
+gem "json", ">= 2.0.0"
+
+gem "rubocop", ">= 0.47", require: false
+
+group :doc do
+  gem "sdoc", "~> 1.0"
+  gem "redcarpet", "~> 3.2.3", platforms: :ruby
+  gem "w3c_validators"
+  gem "kindlerb", "~> 1.2.0"
+end
+
+# Active Support
+gem "dalli"
+gem "listen", ">= 3.0.5", "< 3.2", require: false
+gem "libxml-ruby", platforms: :ruby
+gem "connection_pool", require: false
+
+# for railties app_generator_test
+gem "bootsnap", ">= 1.1.0", require: false
+
+# Active Job
+group :job do
+  gem "resque", require: false
+  gem "resque-scheduler", require: false
+  gem "sidekiq", require: false
+  gem "sucker_punch", require: false
+  gem "delayed_job", require: false
+  gem "queue_classic", github: "rafaelfranca/queue_classic", branch: "update-pg", require: false, platforms: :ruby
+  gem "sneakers", require: false
+  gem "que", require: false
+  gem "backburner", require: false
+  gem "delayed_job_active_record", require: false
+  gem "sequel", require: false
+end
+
+# Action Cable
+group :cable do
+  gem "puma", require: false
+
+  gem "hiredis", require: false
+  gem "redis", "~> 4.0", require: false
+
+  gem "redis-namespace"
+
+  gem "websocket-client-simple", github: "matthewd/websocket-client-simple", branch: "close-race", require: false
+
+  gem "blade", require: false, platforms: [:ruby]
+  gem "blade-sauce_labs_plugin", require: false, platforms: [:ruby]
+  gem "sprockets-export", require: false
+end
+
+# Active Storage
+group :storage do
+  gem "aws-sdk-s3", require: false
+  gem "google-cloud-storage", "~> 1.11", require: false
+  gem "azure-storage", require: false
+
+  gem "image_processing", "~> 1.2"
+end
+
+# Action Mailbox
+gem "aws-sdk-sns", require: false
+gem "webmock"
+
+group :ujs do
+  gem "qunit-selenium"
+  gem "chromedriver-helper"
+end
+
+# Add your own local bundler stuff.
+local_gemfile = File.expand_path(".Gemfile", __dir__)
+instance_eval File.read local_gemfile if File.exist? local_gemfile
+
+group :test do
+  gem "minitest-bisect"
+  gem "minitest-retry"
+
+  platforms :mri do
+    gem "stackprof"
+    gem "byebug"
+  end
+
+  gem "benchmark-ips"
+end
+
+platforms :ruby, :mswin, :mswin64, :mingw, :x64_mingw do
+  gem "nokogiri", ">= 1.8.1"
+
+  # Needed for compiling the ActionDispatch::Journey parser.
+  gem "racc", ">=1.4.6", require: false
+
+  # Active Record.
+  gem "sqlite3", "~> 1.3.6"
+
+  group :db do
+    gem "pg", ">= 0.18.0"
+    gem "mysql2", ">= 0.4.10"
+  end
+end
+
+platforms :jruby do
+  if ENV["AR_JDBC"]
+    gem "activerecord-jdbcsqlite3-adapter", github: "jruby/activerecord-jdbc-adapter", branch: "master"
+    group :db do
+      gem "activerecord-jdbcmysql-adapter", github: "jruby/activerecord-jdbc-adapter", branch: "master"
+      gem "activerecord-jdbcpostgresql-adapter", github: "jruby/activerecord-jdbc-adapter", branch: "master"
+    end
+  else
+    gem "activerecord-jdbcsqlite3-adapter", ">= 1.3.0"
+    group :db do
+      gem "activerecord-jdbcmysql-adapter", ">= 1.3.0"
+      gem "activerecord-jdbcpostgresql-adapter", ">= 1.3.0"
+    end
+  end
+end
+
+platforms :rbx do
+  # The rubysl-yaml gem doesn't ship with Psych by default as it needs
+  # libyaml that isn't always available.
+  gem "psych", "~> 3.0"
+end
+
+# Gems that are necessary for Active Record tests with Oracle.
+if ENV["ORACLE_ENHANCED"]
+  platforms :ruby do
+    gem "ruby-oci8", "~> 2.2"
+  end
+  gem "activerecord-oracle_enhanced-adapter", github: "rsim/oracle-enhanced", branch: "master"
+end
+
+# A gem necessary for Active Record tests with IBM DB.
+gem "ibm_db" if ENV["IBM_DB"]
+gem "tzinfo-data", platforms: [:mingw, :mswin, :x64_mingw, :jruby]
+gem "wdm", ">= 0.1.0", platforms: [:mingw, :mswin, :x64_mingw, :mswin64]
diff --git a/test/_fixtures/bundler/Gemfile.sourceGroup b/test/_fixtures/bundler/Gemfile.sourceGroup
new file mode 100644
index 0000000000..47be95ab28
--- /dev/null
+++ b/test/_fixtures/bundler/Gemfile.sourceGroup
@@ -0,0 +1,16 @@
+source "https://rubygems.org"
+
+source "https://gems.example.com" do
+  gem "some_internal_gem"
+  gem "another_internal_gem"
+end
+
+platforms :ruby do
+  gem "ruby-debug", "latest"
+  gem "sqlite3"
+end
+
+group :development, :optional => true do
+  gem "wirble"
+  gem "faker"
+end
diff --git a/test/manager/bundler/__snapshots__/extract.spec.js.snap b/test/manager/bundler/__snapshots__/extract.spec.js.snap
new file mode 100644
index 0000000000..640506e22e
--- /dev/null
+++ b/test/manager/bundler/__snapshots__/extract.spec.js.snap
@@ -0,0 +1,553 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`lib/manager/bundler/extract extractPackageFile() parses rails Gemfile 1`] = `
+Object {
+  "deps": Array [
+    Object {
+      "currentValue": ">= 11.1",
+      "depName": "rake",
+      "lineNumber": 9,
+      "purl": "pkg:rubygems/rake",
+    },
+    Object {
+      "currentValue": ">= 2.15",
+      "depName": "capybara",
+      "lineNumber": 11,
+      "purl": "pkg:rubygems/capybara",
+    },
+    Object {
+      "currentValue": "~> 1.2",
+      "depName": "rack-cache",
+      "lineNumber": 13,
+      "purl": "pkg:rubygems/rack-cache",
+    },
+    Object {
+      "depName": "sass-rails",
+      "lineNumber": 14,
+      "skipReason": "no-version",
+    },
+    Object {
+      "currentValue": "~> 5",
+      "depName": "turbolinks",
+      "lineNumber": 15,
+      "purl": "pkg:rubygems/turbolinks",
+    },
+    Object {
+      "depName": "webpacker",
+      "lineNumber": 16,
+      "skipReason": "no-version",
+    },
+    Object {
+      "currentValue": "~> 3.1.11",
+      "depName": "bcrypt",
+      "lineNumber": 20,
+      "purl": "pkg:rubygems/bcrypt",
+    },
+    Object {
+      "currentValue": ">= 1.3.0",
+      "depName": "uglifier",
+      "lineNumber": 24,
+      "purl": "pkg:rubygems/uglifier",
+    },
+    Object {
+      "currentValue": ">= 2.0.0",
+      "depName": "json",
+      "lineNumber": 27,
+      "purl": "pkg:rubygems/json",
+    },
+    Object {
+      "currentValue": ">= 0.47",
+      "depName": "rubocop",
+      "lineNumber": 29,
+      "purl": "pkg:rubygems/rubocop",
+    },
+    Object {
+      "currentValue": "~> 1.0",
+      "depName": "sdoc",
+      "depTypes": Array [
+        "doc",
+      ],
+      "lineNumber": 32,
+      "purl": "pkg:rubygems/sdoc",
+    },
+    Object {
+      "currentValue": "~> 3.2.3",
+      "depName": "redcarpet",
+      "depTypes": Array [
+        "doc",
+      ],
+      "lineNumber": 33,
+      "purl": "pkg:rubygems/redcarpet",
+    },
+    Object {
+      "depName": "w3c_validators",
+      "depTypes": Array [
+        "doc",
+      ],
+      "lineNumber": 34,
+      "skipReason": "no-version",
+    },
+    Object {
+      "currentValue": "~> 1.2.0",
+      "depName": "kindlerb",
+      "depTypes": Array [
+        "doc",
+      ],
+      "lineNumber": 35,
+      "purl": "pkg:rubygems/kindlerb",
+    },
+    Object {
+      "depName": "dalli",
+      "lineNumber": 39,
+      "skipReason": "no-version",
+    },
+    Object {
+      "currentValue": ">= 3.0.5, < 3.2",
+      "depName": "listen",
+      "lineNumber": 40,
+      "purl": "pkg:rubygems/listen",
+    },
+    Object {
+      "depName": "libxml-ruby",
+      "lineNumber": 41,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "connection_pool",
+      "lineNumber": 42,
+      "skipReason": "no-version",
+    },
+    Object {
+      "currentValue": ">= 1.1.0",
+      "depName": "bootsnap",
+      "lineNumber": 45,
+      "purl": "pkg:rubygems/bootsnap",
+    },
+    Object {
+      "depName": "resque",
+      "depTypes": Array [
+        "job",
+      ],
+      "lineNumber": 49,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "resque-scheduler",
+      "depTypes": Array [
+        "job",
+      ],
+      "lineNumber": 50,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "sidekiq",
+      "depTypes": Array [
+        "job",
+      ],
+      "lineNumber": 51,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "sucker_punch",
+      "depTypes": Array [
+        "job",
+      ],
+      "lineNumber": 52,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "delayed_job",
+      "depTypes": Array [
+        "job",
+      ],
+      "lineNumber": 53,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "queue_classic",
+      "depTypes": Array [
+        "job",
+      ],
+      "lineNumber": 54,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "sneakers",
+      "depTypes": Array [
+        "job",
+      ],
+      "lineNumber": 55,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "que",
+      "depTypes": Array [
+        "job",
+      ],
+      "lineNumber": 56,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "backburner",
+      "depTypes": Array [
+        "job",
+      ],
+      "lineNumber": 57,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "delayed_job_active_record",
+      "depTypes": Array [
+        "job",
+      ],
+      "lineNumber": 58,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "sequel",
+      "depTypes": Array [
+        "job",
+      ],
+      "lineNumber": 59,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "puma",
+      "depTypes": Array [
+        "cable",
+      ],
+      "lineNumber": 64,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "hiredis",
+      "depTypes": Array [
+        "cable",
+      ],
+      "lineNumber": 66,
+      "skipReason": "no-version",
+    },
+    Object {
+      "currentValue": "~> 4.0",
+      "depName": "redis",
+      "depTypes": Array [
+        "cable",
+      ],
+      "lineNumber": 67,
+      "purl": "pkg:rubygems/redis",
+    },
+    Object {
+      "depName": "redis-namespace",
+      "depTypes": Array [
+        "cable",
+      ],
+      "lineNumber": 69,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "websocket-client-simple",
+      "depTypes": Array [
+        "cable",
+      ],
+      "lineNumber": 71,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "blade",
+      "depTypes": Array [
+        "cable",
+      ],
+      "lineNumber": 73,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "blade-sauce_labs_plugin",
+      "depTypes": Array [
+        "cable",
+      ],
+      "lineNumber": 74,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "sprockets-export",
+      "depTypes": Array [
+        "cable",
+      ],
+      "lineNumber": 75,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "aws-sdk-s3",
+      "depTypes": Array [
+        "storage",
+      ],
+      "lineNumber": 80,
+      "skipReason": "no-version",
+    },
+    Object {
+      "currentValue": "~> 1.11",
+      "depName": "google-cloud-storage",
+      "depTypes": Array [
+        "storage",
+      ],
+      "lineNumber": 81,
+      "purl": "pkg:rubygems/google-cloud-storage",
+    },
+    Object {
+      "depName": "azure-storage",
+      "depTypes": Array [
+        "storage",
+      ],
+      "lineNumber": 82,
+      "skipReason": "no-version",
+    },
+    Object {
+      "currentValue": "~> 1.2",
+      "depName": "image_processing",
+      "depTypes": Array [
+        "storage",
+      ],
+      "lineNumber": 84,
+      "purl": "pkg:rubygems/image_processing",
+    },
+    Object {
+      "depName": "aws-sdk-sns",
+      "lineNumber": 88,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "webmock",
+      "lineNumber": 89,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "qunit-selenium",
+      "depTypes": Array [
+        "ujs",
+      ],
+      "lineNumber": 92,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "chromedriver-helper",
+      "depTypes": Array [
+        "ujs",
+      ],
+      "lineNumber": 93,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "minitest-bisect",
+      "depTypes": Array [
+        "test",
+      ],
+      "lineNumber": 101,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "minitest-retry",
+      "depTypes": Array [
+        "test",
+      ],
+      "lineNumber": 102,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "stackprof",
+      "depTypes": Array [
+        "test",
+      ],
+      "lineNumber": 105,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "byebug",
+      "depTypes": Array [
+        "test",
+      ],
+      "lineNumber": 106,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "benchmark-ips",
+      "depTypes": Array [
+        "test",
+      ],
+      "lineNumber": 109,
+      "skipReason": "no-version",
+    },
+    Object {
+      "currentValue": ">= 1.8.1",
+      "depName": "nokogiri",
+      "lineNumber": 113,
+      "purl": "pkg:rubygems/nokogiri",
+    },
+    Object {
+      "currentValue": ">=1.4.6",
+      "depName": "racc",
+      "lineNumber": 116,
+      "purl": "pkg:rubygems/racc",
+    },
+    Object {
+      "currentValue": "~> 1.3.6",
+      "depName": "sqlite3",
+      "lineNumber": 119,
+      "purl": "pkg:rubygems/sqlite3",
+    },
+    Object {
+      "currentValue": ">= 0.18.0",
+      "depName": "pg",
+      "depTypes": Array [
+        "db",
+      ],
+      "lineNumber": 122,
+      "purl": "pkg:rubygems/pg",
+    },
+    Object {
+      "currentValue": ">= 0.4.10",
+      "depName": "mysql2",
+      "depTypes": Array [
+        "db",
+      ],
+      "lineNumber": 123,
+      "purl": "pkg:rubygems/mysql2",
+    },
+    Object {
+      "depName": "activerecord-jdbcsqlite3-adapter",
+      "lineNumber": 129,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "activerecord-jdbcmysql-adapter",
+      "depTypes": Array [
+        "db",
+      ],
+      "lineNumber": 131,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "activerecord-jdbcpostgresql-adapter",
+      "depTypes": Array [
+        "db",
+      ],
+      "lineNumber": 132,
+      "skipReason": "no-version",
+    },
+    Object {
+      "currentValue": ">= 1.3.0",
+      "depName": "activerecord-jdbcsqlite3-adapter",
+      "lineNumber": 135,
+      "purl": "pkg:rubygems/activerecord-jdbcsqlite3-adapter",
+    },
+    Object {
+      "currentValue": ">= 1.3.0",
+      "depName": "activerecord-jdbcmysql-adapter",
+      "depTypes": Array [
+        "db",
+      ],
+      "lineNumber": 137,
+      "purl": "pkg:rubygems/activerecord-jdbcmysql-adapter",
+    },
+    Object {
+      "currentValue": ">= 1.3.0",
+      "depName": "activerecord-jdbcpostgresql-adapter",
+      "depTypes": Array [
+        "db",
+      ],
+      "lineNumber": 138,
+      "purl": "pkg:rubygems/activerecord-jdbcpostgresql-adapter",
+    },
+    Object {
+      "currentValue": "~> 3.0",
+      "depName": "psych",
+      "lineNumber": 146,
+      "purl": "pkg:rubygems/psych",
+    },
+    Object {
+      "currentValue": "~> 2.2",
+      "depName": "ruby-oci8",
+      "lineNumber": 152,
+      "purl": "pkg:rubygems/ruby-oci8",
+    },
+    Object {
+      "depName": "activerecord-oracle_enhanced-adapter",
+      "lineNumber": 154,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "ibm_db",
+      "lineNumber": 158,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "tzinfo-data",
+      "lineNumber": 159,
+      "skipReason": "no-version",
+    },
+    Object {
+      "currentValue": ">= 0.1.0",
+      "depName": "wdm",
+      "lineNumber": 160,
+      "purl": "pkg:rubygems/wdm",
+    },
+  ],
+  "registryUrls": Array [
+    "https://rubygems.org",
+  ],
+}
+`;
+
+exports[`lib/manager/bundler/extract extractPackageFile() parses sourceGroups 1`] = `
+Object {
+  "deps": Array [
+    Object {
+      "depName": "some_internal_gem",
+      "lineNumber": 3,
+      "registryUrls": Array [
+        "https://gems.example.com",
+      ],
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "another_internal_gem",
+      "lineNumber": 4,
+      "registryUrls": Array [
+        "https://gems.example.com",
+      ],
+      "skipReason": "no-version",
+    },
+    Object {
+      "currentValue": "latest",
+      "depName": "ruby-debug",
+      "lineNumber": 8,
+      "skipReason": "invalid-value",
+    },
+    Object {
+      "depName": "sqlite3",
+      "lineNumber": 9,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "wirble",
+      "depTypes": Array [
+        "development",
+        "optional => true",
+      ],
+      "lineNumber": 13,
+      "skipReason": "no-version",
+    },
+    Object {
+      "depName": "faker",
+      "depTypes": Array [
+        "development",
+        "optional => true",
+      ],
+      "lineNumber": 14,
+      "skipReason": "no-version",
+    },
+  ],
+  "registryUrls": Array [
+    "https://rubygems.org",
+  ],
+}
+`;
diff --git a/test/manager/bundler/extract.spec.js b/test/manager/bundler/extract.spec.js
index d987c82c38..05fafb7007 100644
--- a/test/manager/bundler/extract.spec.js
+++ b/test/manager/bundler/extract.spec.js
@@ -1,5 +1,22 @@
+const fs = require('fs');
 const { extractPackageFile } = require('../../../lib/manager/bundler/extract');
 
+const railsGemfile = fs.readFileSync(
+  'test/_fixtures/bundler/Gemfile.rails',
+  'utf8'
+);
+
+const sourceGroupGemfile = fs.readFileSync(
+  'test/_fixtures/bundler/Gemfile.sourceGroup',
+  'utf8'
+);
+
+function validateGems(raw, parsed) {
+  const gemfileGemCount = raw.match(/\n\s*gem\s+/g).length;
+  const parsedGemCount = parsed.deps.length;
+  expect(gemfileGemCount).toEqual(parsedGemCount);
+}
+
 describe('lib/manager/bundler/extract', () => {
   describe('extractPackageFile()', () => {
     let config;
@@ -9,5 +26,15 @@ describe('lib/manager/bundler/extract', () => {
     it('returns null for empty', () => {
       expect(extractPackageFile('nothing here', config)).toBeNull();
     });
+    it('parses rails Gemfile', () => {
+      const res = extractPackageFile(railsGemfile);
+      expect(res).toMatchSnapshot();
+      validateGems(railsGemfile, res);
+    });
+    it('parses sourceGroups', () => {
+      const res = extractPackageFile(sourceGroupGemfile);
+      expect(res).toMatchSnapshot();
+      validateGems(sourceGroupGemfile, res);
+    });
   });
 });
diff --git a/test/manager/bundler/update.spec.js b/test/manager/bundler/update.spec.js
index 9ab00c8d7b..fab1a59d57 100644
--- a/test/manager/bundler/update.spec.js
+++ b/test/manager/bundler/update.spec.js
@@ -1,13 +1,47 @@
+const fs = require('fs');
 const { updateDependency } = require('../../../lib/manager/bundler/update');
 
-describe('lib/manager/bundler/update', () => {
-  describe('updateDependency()', () => {
-    let config;
-    beforeEach(() => {
-      config = {};
+const railsGemfile = fs.readFileSync(
+  'test/_fixtures/bundler/Gemfile.rails',
+  'utf8'
+);
+
+describe('manager/docker-compose/update', () => {
+  describe('updateDependency', () => {
+    it('replaces existing value', () => {
+      // gem "rack-cache", "~> 1.2"
+      const upgrade = {
+        lineNumber: 13,
+        depName: 'rack-cache',
+        newValue: '~> 1.3',
+      };
+      const res = updateDependency(railsGemfile, upgrade);
+      expect(res).not.toEqual(railsGemfile);
+      expect(res.includes(upgrade.newValue)).toBe(true);
     });
     it('returns same', () => {
-      expect(updateDependency('abc', config)).toEqual('abc');
+      // gem "rack-cache", "~> 1.2"
+      const upgrade = {
+        lineNumber: 13,
+        depName: 'rack-cache',
+        newValue: '~> 1.2',
+      };
+      const res = updateDependency(railsGemfile, upgrade);
+      expect(res).toEqual(railsGemfile);
+    });
+    it('returns null if mismatch', () => {
+      // gem "rack-cache", "~> 1.2"
+      const upgrade = {
+        lineNumber: 13,
+        depName: 'wrong',
+        newValue: '~> 1.3',
+      };
+      const res = updateDependency(railsGemfile, upgrade);
+      expect(res).toBe(null);
+    });
+    it('returns null if error', () => {
+      const res = updateDependency(null, null);
+      expect(res).toBe(null);
     });
   });
 });
diff --git a/website/docs/ruby.md b/website/docs/ruby.md
new file mode 100644
index 0000000000..96dadfa2b3
--- /dev/null
+++ b/website/docs/ruby.md
@@ -0,0 +1,33 @@
+---
+title: Ruby Bundler Support
+description: Ruby Bundler support in Renovate
+---
+
+# Automated Dependency Updates for Ruby Bundler Dependencies
+
+Renovate supports upgrading dependencies in Bundler's `Gemfile`s and their accompanying `Gemfile.lock` files. Support is considered "alpha" stage until there have been some more real-world tests.
+
+## How It Works
+
+1.  Renovate will search each repository for any `Gemfile` files.
+2.  Existing dependencies will be extracted from the files
+3.  Renovate will resolve the dependency on Rubygems or elsewhere if configured, and look for any newer versions
+4.  A PR will be created with `Gemfile` and `Gemfile.lock` updated in the same commit
+5.  If the source repository has either a "changelog" file or uses GitHub releases, then Release Notes for each version will be embedded in the generated PR.
+
+## Enabling
+
+Either install the [Renovate App](https://github.com/apps/renovate) on GitHub, or check out [Renovate OSS](https://github.com/renovatebot/renovate) or [Renovate Pro](https://renovatebot.com/pro) for self-hosted options.
+
+Because Bundler is considered to be in alpha stage, it is not enabled by default. To opt-in to using it, `bundler.enabled` must be set to `true` in your config. If you are using the hosted Renovate App, then either:
+
+(a) if Renovate has already detected other languages in the same repo, the add `"bundler": { "enabled": true }` to the config, or
+(b) if Renovate doesn't onboard the repo because it doesn't find package files, add your `renovate.json` to `master` branch manually, or
+(c) Contact support@renovatebot.com to ask that Bundler support be added on the server side so that you can receive an onboarding PR
+
+## Future work
+
+- Updating `.gemspec` files
+- Pinning dependencies to the version found in `Gemfile.lock` rather than the latest matching version
+- Lock file maintenance
+- Selective lock file updating (if ranges are in use in the `Gemfile`)
-- 
GitLab