A Custom Outdated Gems Script to Speed Up Rails Dependency Upgrades [+ Video]

7 min read Kane Jamison Kane Jamison

I spent much of 2024 working on modernizing Content Harmony's Rails stack. During that time, one of the bigger backlogs that had accumulated was out-of-date gem dependencies.

We were on Rails 6.0.0_rc1 while Rails was working on development for Rails 8.

We were on Ruby 2.5 shortly after 3.3 had been released.

And to top it off, we had over 90 gems in our Gemfile that were pinned haphazardly without clear logic or notes.

Getting everything up to date was a team effort, but one of the problems that needed to be solved was that we needed to do incremental updates on nearly every single dependency, upgrading them one by one and dealing with breaking changes and lack of test coverage on most of them.

When you're at the starting line and have to handle dozens or hundreds of dependency upgrades, out-of-the-box tools like bundle outdated aren't always sufficient for understanding the current state of your gemfile.

At a glance, I can't tell which gems are critical vs dev/test only, how out of date each gem is, what my oldest gems are, etc.

So, I began the process of writing a custom script that I could run to gather more data via the Ruby Gems API.

What My Script Does Differently

Instead of just showing me what's outdated, my script pulls together the context that actually matters:

  • Release dates - I can see I'm running a gem from May 2020 versus March 2025, which tells me way more than just version numbers
  • Number of versions available - 55 available versions might sound scary, but if AWS pumps out updates weekly, that's different than a gem that hasn't been touched in years
  • Explicit vs implicit requirements - I only care about gems I explicitly added to my Gemfile, not the dozens of downstream dependencies that will update automatically
  • Metadata - Direct links to changelogs and homepages so I can quickly assess what's changed
  • Formatting - One other aspect of bundle outdated - when you copy/paste it out of the terminal it doesn't parse well into a spreadsheet. So instead of outputting a fancy format version, we just output everything to pipe|separated|data.
  • Move Terminal Output to Google Sheets - Copies everything to user clipboard then opens up a new Google Sheet to set up a pivot table for analysis.

The script outputs everything, copies it to my clipboard, and automatically opens a new Google Sheet.

From there, I split the data into columns and build a pivot table that lets me filter and sort however I need.

My Outdated Gems Script

Here is the latest version which lives at bin/lib/outdated_gems_audit.rb in my app:

# From the app root folder, run the script with this command:
# >    ruby bin/lib/outdated_gems_audit.rb
# Then the script will copy the output to your clipboard and open a new spreadsheet.
# Paste into the spreadsheet and split Text To Columns by | to analyze the results.
# Add alias outdatedgems="ruby lib/scripts/outdated_gems_audit.rb" to ~/.bashrc or ~/.zshrc

require "bundler"
require "net/http"
require "json"
require "uri"
require "launchy"
require "date"

# Determine the OS to allow copy to clipboard to work
def os
  @os ||= (
    host_os = RUBY_PLATFORM.downcase
    case host_os
    when /linux/
      "linux"
    when /darwin/
      "mac"
    when /mswin|mingw|cygwin/
      "windows"
    else
      raise "unknown os: #{host_os.inspect}"
    end
  )
end

# Copy the output to the clipboard
def copy_to_clipboard(text)
  case os
  when "mac"
    IO.popen('pbcopy', 'w') { |f| f << text }
    puts "Output copied to clipboard!"
  when "linux"
    # Attempt to use xclip, but fall back to xsel if xclip is not available
    if system("command -v xclip > /dev/null")
      IO.popen('xclip -selection clipboard', 'w') { |f| f << text }
    elsif system("command -v xsel > /dev/null")
      IO.popen('xsel --clipboard --input', 'w') { |f| f << text }
    else
      raise "Clipboard utilities xclip or xsel are not installed."
    end
    puts "Output copied to clipboard!"
  when "windows"
    IO.popen('clip', 'w') { |f| f << text }
    puts "Output copied to clipboard!"
  else
    raise "Clipboard not supported on this OS"
  end
end


# Parse the Gemfile and Gemfile.lock
definition = Bundler.definition
lockfile = Bundler::LockfileParser.new(Bundler.read_file(Bundler.default_lockfile))

def fetch_url_content(url)
  uri = URI.parse(url)
  response = Net::HTTP.get_response(uri)

  case response
  when Net::HTTPSuccess then
    JSON.parse(response.body)
  else
    raise "Could not fetch URL: #{url} - Error: #{response.message}"
  end
rescue URI::InvalidURIError
  raise "Invalid URL: #{url}"
end

def fetch_gem_release_date_and_versions(gem_name, current_version)
  versions_url = "https://rubygems.org/api/v1/versions/#{gem_name}.json"
  versions_data = fetch_url_content(versions_url)

  # Extract all version numbers from the fetched data
  all_versions = versions_data.map { |v| v["number"] }
  
  # Check if a version is a prerelease (contains alpha, beta, rc, pre patterns)
  def prerelease_version?(version)
    version.match?(/\.(alpha|beta|rc|pre|dev)/i) || version.match?(/-(alpha|beta|rc|pre|dev)/i)
  end
  
  # Separate stable and prerelease versions
  versions = all_versions.reject { |v| prerelease_version?(v) }
  prerelease_versions = all_versions.select { |v| prerelease_version?(v) }

  # Calculate the number of versions newer than the current version
  current_version_index = versions.find_index(current_version)

  # Find the release date of the current version, if it exists
  current_version_data = versions_data.find { |data| data["number"] == current_version }

  # Initialize defaults
  release_date = "Not Found"
  changelog_url = ""
  homepage_url = ""

  if current_version_data
      if current_version_data["built_at"]
        # Parse the date and format it as YYYY-MM-DD
        release_date = Date.parse(current_version_data["built_at"]).strftime("%Y-%m-%d")
      end

      # Extract metadata for changelog and homepage URLs if available
      if current_version_data["metadata"]
        changelog_url = current_version_data["metadata"]["changelog_uri"] || ""
        homepage_url = current_version_data["metadata"]["homepage_uri"] || ""
      end
    end

    # Generate versions string excluding the current version (only newer versions)
    versions_available_array = current_version_index.nil? ? [] : versions[0...current_version_index]
    versions_string = versions_available_array.join(",")
    
    # Generate prerelease versions string (only newer prerelease versions)
    current_prerelease_index = prerelease_versions.find_index(current_version)
    if current_prerelease_index.nil?
      # Current version is not a prerelease, so include all newer prerelease versions
      prerelease_available_array = prerelease_versions
    else
      # Current version is a prerelease, exclude it from the list
      prerelease_available_array = prerelease_versions[0...current_prerelease_index]
    end
    prerelease_string = prerelease_available_array.join(",")

    # If current_version_index is nil, it means the current version was not found in the list
    # which should ideally not happen but could indicate an issue with version naming or data retrieval
    if current_version_index.nil?
      versions_available = "Nil"
    else
      # Versions newer than the current version
      versions_available = versions_available_array.length
    end

    [release_date, versions_available, changelog_url, homepage_url, versions_string, prerelease_string]
rescue => e
    puts "Error fetching data for #{gem_name} #{current_version}: #{e}"
    ["Release date not found", 0, "", "", "", ""]
end

def open_new_google_sheet
  Launchy.open("http://sheets.new")
rescue => e
  puts "Failed to open browser: #{e}"
end

# Store all output so we can copy it to the clipboard
headers = "Gem|Explicitly Required|Pinned At|Groups|Current|Release Date|Versions Available|Changelog URL|Homepage|Versions|Prerelease Versions"
output = "#{headers}\n"
puts headers

lockfile.specs.each do |spec|
  # Determine if the gem is explicitly required in the Gemfile
  explicitly_required = definition.dependencies.any? { |dep| dep.name == spec.name }

  # Find the version requirement and groups for the gem from the Gemfile
  dep = definition.dependencies.find { |d| d.name == spec.name }
  pinned_version = dep && dep.requirement.to_s
  groups = dep ? dep.groups.join(", ") : "default"

  # Fetch additional gem version information
  release_date, versions_available, changelog_url, homepage_url, versions_string, prerelease_string = fetch_gem_release_date_and_versions(spec.name, spec.version.to_s)

  line = "#{spec.name}|#{explicitly_required}|#{pinned_version}|#{groups}|#{spec.version}|#{release_date}|#{versions_available}|#{changelog_url}|#{homepage_url}|#{versions_string}|#{prerelease_string}"

  output += "#{line}\n"
  puts line
end

# Copy the generated output to the clipboard
copy_to_clipboard(output)

# Open a new Google Sheet for pasting the data
open_new_google_sheet

Walkthrough Video

Here's a walkthrough video on how I use the script to quickly triage all of the outdated gem dependencies in a Rails app, and how I handle ongoing maintenance updates every month or every few months. (Note: I ran this with an old branch to have more sample data, so this is not an accurate reflection of the gems currently used on that project 😄)

My Actual Gem Update Workflow

Here's how I use this in practice:

  1. Run the script: ruby bin/lib/outdated_gems_audit.rb
  2. Paste into Google Sheets and set up pivot table
  3. Filter to only explicitly required gems
  4. Sort by release date to find my oldest dependencies
  5. Color code based on priority:
Green: completed updates
🛑
Red: known blockers that need separate attention
⚠️
Yellow: major releases that need their own PR
ℹ️
Other labels ad hoc when I need to tag them as something else.

The actual pivot table ends up looking like this:

Sample outdatedgems audit on a Rails 7.1 app example.

I typically tackle dev/test gems first since they're lower risk, then work through production dependencies.

Each gem gets its own commit in a single "gem updates" PR so I have clean CI logs if something breaks.

On larger updates that require code changes I will break off a standalone PR.

I prefix my Dependency PRs with the ⬆️ up arrow emoji to make them easier to glance through in Github history:

Some recent dependency update and upgrade PRs in a Rails app.

The biggest win is being able to prioritize more quickly and see which gems might require more review work. That context makes all the difference when you're trying to methodically work through a dependency backlog without breaking your app (or an app that's new to you).

Another perk is accumulating some historical artifacts after you complete this process a number of times. I can reference back on update history here (though it's stored in GitHub history as well)

My numerous outdated gem audit pivot tables that have accumulated since I started using this process.

If you're dealing with a large list of outdated dependencies, you might want to build something similar. The script is straightforward - it's just hitting the RubyGems API and formatting the response in a way that's actually useful for decision-making.


Acknowledgements: Many thanks to Jeremy Smith for pushing me to improve on my original quickly scripted version that just scraped the Ruby Gems website and instead dive into the Ruby Gems API and build a proper integration.