Skip to content

Commit 7e58e63

Browse files
authored
Capture Ruby Version information directly from the Gemfile.lock (heroku#1603)
* Introduce GemfileLock class This class parses a Gemfile.lock for the purposes of extracting information about the Ruby version and Bundler version. Tests cases are pulled from `ruby_version_spec.rb` and from the Ruby CNB https://github.com/heroku/buildpacks-ruby/blob/e249a8bcbc75f7e5322f02bb4e32aaed50724355/commons/src/gemfile_lock.rs. * Fix spec indentation I didn't notice before, but vscode silently removed the extra space when I indented this code. There should be three spaces before the version i.e. ` 2.3.25`. Apparently `bundle platform --ruby` is not as strict and will accept two spaces. I'm unsure if there are customers relying on this behavior, running both approaches and recording metrics will answer that question. * Introduce GemfileLock entry point for RubyVersion The current code uses the output of `bundle platform --ruby` to produce a RubyVersion via `RubyVersion.bundle_platform_ruby` this adds `RubyVersion.from_gemfile_lock` that allows us to build the same value class from the raw `Gemfile.lock` file on disk (don't have to execute any Ruby code). This behavior is validated by running both modes in the `ruby_version_spec.rb` tests. * Move Gemfile.lock presence check and return class This move allows us to inject the information into language packs (not yet wired up). * Inject GemfileLock into language packs * Rename variable Manipulating classes as variables is common, but they're usually annotated to indicate they're a class. * Build RubyVersion from GemfileLock information * Capture RubyVersion differences * Changelog * Remove dead-end update ci-queue The dead_end gem is renamed into syntax_suggest and now comes with Ruby 3.2 (I maintain it!).
1 parent dc15301 commit 7e58e63

14 files changed

Lines changed: 421 additions & 25 deletions

File tree

.rspec

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +0,0 @@
1-
--require dead_end

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## [Unreleased]
44

5+
- Introduce internal metrics for deriving Ruby version directly from the `Gemfile.lock` to avoid needing to call `bundle platform --ruby` in the future. No change in behavior is expected. (https://github.com/heroku/heroku-buildpack-ruby/pull/1603)
56

67
## [v309] - 2025-05-19
78

Gemfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,4 @@ group :development, :test do
1515
gem 'json'
1616
gem 'ci-queue'
1717
gem 'redis'
18-
gem 'dead_end'
1918
end

Gemfile.lock

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@ GEM
22
remote: https://rubygems.org/
33
specs:
44
base64 (0.2.0)
5-
ci-queue (0.65.0)
5+
ci-queue (0.67.0)
66
logger
77
citrus (3.0.2)
88
connection_pool (2.5.3)
9-
dead_end (4.0.0)
109
diff-lcs (1.6.1)
1110
erubis (2.7.0)
1211
excon (0.110.0)
@@ -63,7 +62,6 @@ PLATFORMS
6362

6463
DEPENDENCIES
6564
ci-queue
66-
dead_end
6765
excon
6866
heroku_hatchet
6967
json

bin/support/ruby_compile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,15 @@ HerokuBuildReport.set_global(
2121
begin
2222
app_path = Pathname(ARGV[0])
2323
cache_path = Pathname(ARGV[1])
24+
gemfile_lock = LanguagePack.gemfile_lock(app_path: app_path)
2425
Dir.chdir(app_path)
2526

2627
LanguagePack::ShellHelpers.initialize_env(ARGV[2])
27-
if pack = LanguagePack.detect(app_path: app_path, cache_path: cache_path)
28+
if pack = LanguagePack.detect(
29+
app_path: app_path,
30+
cache_path: cache_path,
31+
gemfile_lock: gemfile_lock
32+
)
2833
pack.topic("Compiling #{pack.name}")
2934
pack.compile
3035
end

bin/support/ruby_test-compile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,14 @@ include LanguagePack::ShellHelpers
2121
begin
2222
app_path = Pathname(ARGV[0])
2323
cache_path = Pathname(ARGV[1])
24+
gemfile_lock = LanguagePack.gemfile_lock(app_path: app_path)
2425
Dir.chdir(app_path)
2526

26-
if pack = LanguagePack.detect(app_path: app_path, cache_path: cache_path)
27+
if pack = LanguagePack.detect(
28+
app_path: app_path,
29+
cache_path: cache_path,
30+
gemfile_lock: gemfile_lock
31+
)
2732
LanguagePack::ShellHelpers.initialize_env(ARGV[2])
2833
pack.topic("Setting up Test for #{pack.name}")
2934
pack.compile

lib/language_pack.rb

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,39 @@
22
require 'benchmark'
33

44
require 'language_pack/shell_helpers'
5+
require "language_pack/helpers/gemfile_lock"
56

67
# General Language Pack module
78
module LanguagePack
89
module Helpers
910
end
1011

11-
# detects which language pack to use
12-
def self.detect(app_path:, cache_path:)
13-
if !File.exist?("Gemfile.lock")
12+
def self.gemfile_lock(app_path: )
13+
path = app_path.join("Gemfile.lock")
14+
if path.exist?
15+
LanguagePack::Helpers::GemfileLock.new(
16+
contents: path.read
17+
)
18+
else
1419
raise BuildpackError.new("Gemfile.lock required. Please check it in.")
1520
end
21+
end
1622

17-
pack = [ Rails8, Rails7, Rails6, Rails5, Rails42, Rails41, Rails4, Rails3, Rails2, Rack, Ruby ].detect do |klass|
23+
# detects which language pack to use
24+
def self.detect(app_path:, cache_path:, gemfile_lock: )
25+
pack_klass = [ Rails8, Rails7, Rails6, Rails5, Rails42, Rails41, Rails4, Rails3, Rails2, Rack, Ruby ].detect do |klass|
1826
klass.use?
1927
end
2028

21-
return pack ? pack.new(app_path: app_path, cache_path: cache_path) : nil
29+
if pack_klass
30+
pack_klass.new(
31+
app_path: app_path,
32+
cache_path: cache_path,
33+
gemfile_lock: gemfile_lock
34+
)
35+
else
36+
nil
37+
end
2238
end
2339
end
2440

lib/language_pack/base.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class LanguagePack::Base
2222

2323
attr_reader :app_path, :cache, :stack
2424

25-
def initialize(app_path: , cache_path: )
25+
def initialize(app_path: , cache_path: , gemfile_lock: )
2626
@app_path = app_path
2727
@stack = ENV.fetch("STACK")
2828
@cache = LanguagePack::Cache.new(cache_path)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
module LanguagePack
2+
module Helpers
3+
# Centralize logic for extracting information from the `Gemfile.lock` format
4+
#
5+
# - Extracts Ruby version from `RUBY VERSION`
6+
# - Extracts Bundler version from `BUNDLED WITH`
7+
#
8+
# Example:
9+
#
10+
# gemfile_lock = GemfileLock.new(contents: <<~EOF)
11+
# RUBY VERSION
12+
# ruby 3.3.5p100
13+
# BUNDLED WITH
14+
# 2.3.4
15+
# EOF
16+
#
17+
# expect(gemfile_lock.bundler.version).to eq("2.3.4")
18+
# expect(gemfile_lock.ruby.ruby_version).to eq("3.3.5")
19+
class GemfileLock
20+
attr_reader :ruby, :bundler
21+
22+
def initialize(contents: , report: HerokuBuildReport::GLOBAL)
23+
@ruby = RubyVersionParse.new(contents: contents, report: report)
24+
@bundler = BundlerVersionParse.new(contents: contents, report: report)
25+
end
26+
27+
# Holds information about the RUBY VERSION of the parsed Gemfile.lock
28+
class RubyVersionParse
29+
# Ruby version from Gemfile.lock i.e. `3.3.8`
30+
# Either 3 numbers or nil
31+
attr_reader :ruby_version,
32+
# Contains pre-release info
33+
# - String: i.e. "rc2" is a prerelease
34+
# - nil: No pre-release (or no version at all)
35+
:pre,
36+
# Either :ruby or :jruby
37+
:engine,
38+
# `engine_version` is the JRuby version or for Ruby, it is the same as `ruby_version`
39+
# i.e. `<major>.<minor>.<patch>`
40+
:engine_version
41+
42+
def initialize(contents: , report: HerokuBuildReport::GLOBAL)
43+
if match = contents.match(/^RUBY VERSION(\r?\n) ruby (?<version>\d+\.\d+\.\d+)((\-|\.)(?<pre>\S*))?/m)
44+
@pre = match[:pre]
45+
@empty = false
46+
@ruby_version = match[:version]
47+
else
48+
if contents.match?(/RUBY VERSION/)
49+
report.capture("gemfile_lock.ruby_version.failed_parse" => true)
50+
if match = contents.match(/(?<contents>RUBY VERSION(\r?\n).*)$/)
51+
report.capture("gemfile_lock.ruby_version.failed_contents" => match[:contents])
52+
end
53+
end
54+
@pre = nil
55+
@empty = true
56+
@ruby_version = nil
57+
end
58+
59+
if jruby = contents.to_s.match(/^RUBY VERSION(\r?\n) ruby [^\(]*\(jruby (?<version>(\d+|\.)+)\)/m)
60+
@engine = :jruby
61+
@engine_version = jruby[:version]
62+
else
63+
@engine = :ruby
64+
@engine_version = ruby_version
65+
end
66+
end
67+
68+
def empty?
69+
@empty
70+
end
71+
end
72+
73+
class BundlerVersionParse
74+
# Bundler value from `Gemfile.lock` (String or nil) i.e. `2.5.23`
75+
attr_reader :version
76+
77+
def initialize(contents: , report: HerokuBuildReport::GLOBAL)
78+
if match = contents.match(/^BUNDLED WITH(\r?\n) (?<version>(?<major>\d+)\.(?<minor>\d+)\.\d+)/m)
79+
@empty = false
80+
@version = match[:version]
81+
else
82+
if contents.match?(/BUNDLED WITH/)
83+
report.capture("gemfile_lock.bundler_version.failed_parse" => true)
84+
if match = contents.match(/(?<contents>BUNDLED WITH(\r?\n).*)$/)
85+
report.capture("gemfile_lock.bundler_version.failed_contents" => match[:contents])
86+
end
87+
end
88+
@empty = true
89+
@version = nil
90+
end
91+
end
92+
93+
def empty?
94+
@empty
95+
end
96+
end
97+
end
98+
end
99+
end

lib/language_pack/rails2.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ def self.use?
1414
return is_rails2
1515
end
1616

17-
def initialize(app_path: , cache_path: )
18-
super(app_path: app_path, cache_path: cache_path)
17+
def initialize(app_path: , cache_path: , gemfile_lock:)
18+
super(app_path: app_path, cache_path: cache_path, gemfile_lock: gemfile_lock)
1919
@rails_runner = LanguagePack::Helpers::RailsRunner.new
2020
end
2121

0 commit comments

Comments
 (0)