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