A Custom Outdated Gems Script to Speed Up Rails Dependency Upgrades [+ Video]
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.

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 topipe|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:
- Run the script:
ruby bin/lib/outdated_gems_audit.rb
- Paste into Google Sheets and set up pivot table
- Filter to only explicitly required gems
- Sort by release date to find my oldest dependencies
- Color code based on priority:
The actual pivot table ends up looking like this:

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:

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)

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