Why GitHub Spent Months Getting to Ruby 2.7
GitHub has been running on Ruby since its earliest days, but the jump to Ruby 2.7 was never going to be routine. The Ruby Core team used this release to formally deprecate the practice of passing an options hash when a method expects keyword arguments. In future versions, that pattern simply won't be accepted. For GitHub, which is committed to running deprecation-free on both Ruby and Rails, that meant confronting every one of those warnings before the language forces the issue.
That's a bigger lift than it sounds. The GitHub codebase is over 400k lines, and the deprecation warnings numbered more than 11k. To fix them, the team needed a way to divide and track that work across dozens of engineering teams without losing visibility into who owned what.
A Dual-Boot Strategy for Warnings
GitHub's approach borrowed from its previous Rails upgrade: the application was made dual-bootable in Ruby 2.6 and 2.7 using an environment variable. That allowed backwards-compatible changes to be merged straight to the main branch, eliminating the need for a long-running upgrade branch. It also meant other teams could start testing their systems under the new Ruby version without waiting for a coordinated switch-over.
Turning Warnings into Work Items
Warnings in Ruby are just strings in test output, which doesn't scale to thousands of them across a large organization. So GitHub monkey patched the Warning module to capture deprecation warnings as they occurred during tests:
module Warning
def self.warn(warning)
root = ENV["RAILS_ROOT"].to_s + "/"
warning = warning.gsub(root, "")
line = caller_locations.find do |location|
location.path.end_with?("_test.rb")
end
origin = line&.path&.gsub(root, "")
WarningsCollector.instance << [warning.chomp, origin]
STDERR.print(message)
end
end
The patch stores each warning and its associated test path in a WarningCollector object, which writes them to a file for processing:
class WarningsCollector < ParallelCollector
def process
filename = "warnings.txt"
path = File.join(dir, filename)
File.open(path, "a") do |f|
@data.each do |message, origin|
f.puts [message, origin].join("*^.^*") # ascii art so we can split on it later.
end
end
script = File.absolute_path("../../../script/process-ruby-warnings", __FILE__)
system(script, dir)
end
end
From there, the WarningCollector#process method dumps all warnings into a warnings.txt file. GitHub then used CODEOWNERS to parse those warnings and break them into per-team files. Issues were opened for each responsible team with direction on booting the application under Ruby 2.7. Each report contained the file emitting the warning, the warning text, and the test suites that triggered it:
- [x] `app/jobs/delete_job.rb`
- **warnings**
- Line 16: warning: Using the last argument as keyword parameters is deprecated; maybe ** should be added to the call
- **test suites that trigger these warnings**
- test/jobs/delete_job.rb
The CI build tracked warning counts throughout the process, making sure new code didn't introduce fresh deprecations while the existing ones were being resolved. After coordinating with 40 teams and upgrading 30+ gems, the warning count hit zero. Unmaintained gems were swapped out for maintained alternatives. The monkey patch was then altered to turn warnings into errors for Ruby 2.7, enforcing a warning-free baseline for all future code.
Performance Gains After the Upgrade
Measurable performance improvements came with the cleanup. Production application boot time dropped from an average of roughly 90 seconds to about 70 seconds—a 20-second reduction per boot:
Object allocations also fell from ~780k to ~668k. Since allocation count directly impacts memory usage, that reduction matters in a running production service:
The upgrade also served as an opportunity for broader codebase hygiene. GitHub removed unowned, unused code found during the effort and, for maintained gems that emitted warnings, sent fixes back upstream. Patches went to Rails, rails-controller-testing, capybara, factory_bot, view_component, posix-spawn, github-ds, ruby-kafka, and several others.
Rolling Out Slowly
GitHub's deployment process for major language upgrades relies on gradual traffic increases and the ability to roll back quickly. The rollout was staged at 2% of traffic first. That cautious start surfaced a fresh frozen string exception almost immediately, and a fast rollback kept user impact to fewer than ten errors on a single endpoint.
With the fix in place, the rollout restarted at 2%, and after a 15-minute evaluation window, it moved to 30% of Kubernetes partitions. Another 15 minutes later, an additional 30% was added, totaling 60% of Kubernetes partitions. The remaining 30% of non-Kubernetes deployment partitions took longer since those deploys must compile Ruby—a process that alone takes about 15 minutes. The full production deploy followed, and the upgrade branch was merged 30 minutes after that. The entire deployment took roughly two hours with no downtime.
Was the Investment Worth It?
From GitHub's perspective, the answer is unambiguously yes. The performance numbers are concrete, but the broader argument is about not falling behind on language maintenance. Upgrading Ruby catches language and framework bugs, improves application health, and keeps the door open for future upgrades like Ruby 3.0—which the Ruby Core team is working to make 3x faster. For GitHub, staying current on Ruby is part of supporting both the open source community and the stability of its own stack.



