Skip to content

Commit a578e30

Browse files
authored
Install arbitrary bundler versions (heroku#1695)
* Inject bundler version into BundlerWrapper * Install injected Bundler Version Previously the BundlerWrapper reads the Gemfile.lock to extract BUNDLED WITH and then resolves this to a specific **known** good version of bundler. Now, the version is still parsed from the Gemfile.lock, but this is passed in. Instead of resolving to a specific bundler version, we use the version specified in the customer's Gemfile.lock. Keep in mind that the version of bundler used by the platform will use the largest version present on the system, which could also be the default bundler version that ships with Ruby. * Add warning * Remove "escape valve" as this is no longer needed This feature prevented bundler from re-exec-ing with the version of bundler in the `Gemfile.lock`. Since we're now installing an exact match, this is no longer needed. * Remove unused code * Prefer inline private * Remove unused errors These errors are not constructed anymore * Changelog * Improve lockfile parser error Previously when this script failed it was difficult to understand ``` StandardError: Command: 'ruby -e require\ \"json\"' 'require\ \"bundler\"' '' 'path\ \=\ ARGV\[0\]\ or\ raise\ \"First\ argument\ must\ be\ the\ path\ to\ the\ Gemfile.lock\"' 'specs\ \=\ Bundler::LockfileParser.new\(File.read\(path\)\)' '\ \ .specs' '\ \ .each_with_object\(\{\}\)\ \{\|spec,\ hash\|\ hash\[spec.name.to_s\]\ \=\ spec.version.to_s\ \}' 'puts\ specs.to_json' ' ''' failed unexpectedly: # ./lib/language_pack/shell_helpers.rb:80:in `block in run!' ``` This is from the Gemfile.lock file being `nil`. Part of the problem is that the script is shell encoded and present in the output. The other problem is that redirecting stderr to `/dev/null` hides the error message "No such file or directory" from the output. This change pushes the IO failures and input failures outside of the script and uses Open3 and a tempfile to separate out stdout from stderr without losing stderr. * fixup! Inject bundler version into BundlerWrapper
1 parent 0fa1a0f commit a578e30

10 files changed

Lines changed: 147 additions & 234 deletions

File tree

CHANGELOG.md

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

33
## [Unreleased]
44

5+
- Bundler version installed now directly matches the value in `BUNDLED WITH` from the `Gemfile.lock`
6+
Previously, this value was converted to a "known good version." For example:
7+
8+
`BUNDLED WITH` 2.7.x installs `bundler 2.7.2`
9+
10+
Now, the exact version from the `Gemfile.lock` is installed instead. Applications without
11+
a `BUNDLED WITH` value will receive a default bundler version. (https://github.com/heroku/heroku-buildpack-ruby/pull/1695)
512

613
## [v340] - 2026-01-06
714

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
## Ruby applications now receive exact bundler version
2+
3+
The exact bundler version from the `Gemfile.lock` is now installed with no conversion.
4+
5+
If your application fails after this change, you can manually adjust your `BUNDLED WITH` value in the `Gemfile.lock` to match the previously used exact version:
6+
7+
- `BUNDLED WITH 1.x.` installs `bundler 1.17.3`
8+
- `BUNDLED WITH 2.0.x to 2.3.x` installs `bundler 2.3.25`
9+
- `BUNDLED WITH 2.4.x` installs `bundler 2.4.22`
10+
- `BUNDLED WITH 2.5.x` installs `bundler 2.5.23`
11+
- `BUNDLED WITH 2.6.x` installs `bundler 2.6.9`
12+
- `BUNDLED WITH 2.7.x` installs `bundler 2.7.2`
13+
- `BUNDLED WITH 4.0.x` installs `bundler 4.0.0`
14+
15+
For example, if your application started failing, using bundler 2.6.1, you could adjust it to instead use the 2.6.x series above which would be:
16+
17+
```
18+
BUNDLED WITH
19+
2.6.9
20+
```
21+
22+
Applications without a `BUNDLED WITH` value will receive a [default bundler version ](https://devcenter.heroku.com/articles/ruby-support-reference#default-bundler-version).

lib/language_pack.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ def self.call(app_path:, cache_path:, gemfile_lock:, bundle_default_without:, en
2727
warn_io = LanguagePack::ShellHelpers::WarnIO.new
2828
user_env_hash = LanguagePack::ShellHelpers.user_env_hash
2929
bundler_cache = LanguagePack::BundlerCache.new(cache, stack)
30-
bundler_version = LanguagePack::Helpers::BundlerWrapper.detect_bundler_version(contents: gemfile_lock.contents)
30+
bundler_version = LanguagePack::Helpers::BundlerWrapper.resolve_bundler_version(
31+
warn_io: warn_io,
32+
gemfile_lock: gemfile_lock,
33+
)
3134

3235
metadata = LanguagePack::Metadata.new(cache_path: cache_path)
3336
new_app = metadata.empty?
@@ -50,7 +53,7 @@ def self.call(app_path:, cache_path:, gemfile_lock:, bundle_default_without:, en
5053
io: warn_io
5154
)
5255

53-
bundler = Helpers::BundlerWrapper.new(bundler_path: ruby_version.bundler_directory).install
56+
bundler = Helpers::BundlerWrapper.new(bundler_path: ruby_version.bundler_directory, bundler_version: bundler_version).install
5457
default_config_vars = Ruby.default_config_vars(metadata: metadata, ruby_version: ruby_version, bundler: bundler, environment_name: environment_name)
5558
Ruby.setup_language_pack_environment(
5659
app_path: app_path.expand_path,

lib/language_pack/helpers/bundler_wrapper.rb

Lines changed: 40 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
#
1010
# Example:
1111
#
12-
# bundler = LanguagePack::Helpers::BundlerWrapper.new(bundler_path: "vendor/bundle/ruby/3.2.0")
12+
# bundler = LanguagePack::Helpers::BundlerWrapper.new(bundler_path: "vendor/bundle/ruby/3.2.0", bundler_version: "2.5.7")
1313
# bundler.install
14-
# bundler.version => "1.15.2"
15-
# bundler.dir_name => "bundler-1.15.2"
14+
# bundler.version => "2.5.23"
15+
# bundler.dir_name => "bundler-2.5.23"
1616
# bundler.has_gem?("railties") => true
1717
# bundler.gem_version("railties") => "5.2.2"
1818
# bundler.clean
@@ -22,116 +22,33 @@
2222
# of an isolated dyno, you must call `BundlerWrapper#clean`. To reset the environment
2323
# variable:
2424
#
25-
# bundler = LanguagePack::Helpers::BundlerWrapper.new(bundler_path: "vendor/bundle/ruby/3.2.0")
25+
# bundler = LanguagePack::Helpers::BundlerWrapper.new(bundler_path: "vendor/bundle/ruby/3.2.0", bundler_version: "2.5.7")
2626
# bundler.install
2727
# bundler.clean # <========== IMPORTANT =============
2828
#
2929
class LanguagePack::Helpers::BundlerWrapper
3030
include LanguagePack::ShellHelpers
3131

32-
BLESSED_BUNDLER_VERSIONS = {}
3332
# Heroku-22's oldest Ruby version is 3.1
34-
BLESSED_BUNDLER_VERSIONS["2.3"] = "2.3.25"
35-
BLESSED_BUNDLER_VERSIONS["2.4"] = "2.4.22"
36-
BLESSED_BUNDLER_VERSIONS["2.5"] = "2.5.23"
37-
BLESSED_BUNDLER_VERSIONS["2.6"] = "2.6.9"
38-
BLESSED_BUNDLER_VERSIONS["2.7"] = "2.7.2"
39-
BLESSED_BUNDLER_VERSIONS["4.0"] = "4.0.0"
40-
41-
SORTED_KEYS = BLESSED_BUNDLER_VERSIONS.keys.map { |k| Gem::Version.new(k) }.sort
42-
BUNDLER_2_SORTED_KEYS = SORTED_KEYS.select { |k| k.segments.first == 2 }
43-
BUNDLER_2_SMALLEST = BUNDLER_2_SORTED_KEYS.first.to_s
44-
BUNDLER_2_LARGEST = BUNDLER_2_SORTED_KEYS.last.to_s
45-
46-
BUNDLER_4_SORTED_KEYS = SORTED_KEYS.select { |k| k.segments.first == 4 }
47-
BUNDLER_4_SMALLEST = BUNDLER_4_SORTED_KEYS.first.to_s
48-
BUNDLER_4_LARGEST = BUNDLER_4_SORTED_KEYS.last.to_s
49-
50-
DEFAULT_VERSION = BLESSED_BUNDLER_VERSIONS["2.3"]
51-
52-
# Convert arbitrary `<Major>.<Minor>.x` versions
53-
BLESSED_BUNDLER_VERSIONS.default_proc = Proc.new do |hash, key|
54-
case Gem::Version.new(key).segments.first
55-
when 4
56-
hash[BUNDLER_4_LARGEST]
57-
when 2
58-
if Gem::Version.new(key) > Gem::Version.new(BUNDLER_2_LARGEST)
59-
hash[BUNDLER_2_LARGEST]
60-
elsif Gem::Version.new(key) < Gem::Version.new(BUNDLER_2_SMALLEST)
61-
hash[BUNDLER_2_SMALLEST]
62-
else
63-
raise UnsupportedBundlerVersion.new(hash, key)
64-
end
65-
else
66-
raise UnsupportedBundlerVersion.new(hash, key)
67-
end
68-
end
69-
70-
def self.detect_bundler_version(contents: , bundled_with: contents.match(BUNDLED_WITH_REGEX))
71-
if bundled_with
72-
major = bundled_with[:major]
73-
minor = bundled_with[:minor]
74-
version = BLESSED_BUNDLER_VERSIONS["#{major}.#{minor}"]
75-
version
76-
else
77-
DEFAULT_VERSION
78-
end
79-
end
80-
81-
BUNDLED_WITH_REGEX = /^BUNDLED WITH$(\r?\n) {2,3}(?<version>(?<major>\d+)\.(?<minor>\d+)\.\d+)/m
82-
83-
class GemfileParseError < BuildpackError
84-
def initialize(error)
85-
msg = String.new("There was an error parsing your Gemfile, we cannot continue\n")
86-
msg << error
87-
super msg
88-
end
89-
end
90-
91-
class UnsupportedBundlerVersion < BuildpackError
92-
def initialize(version_hash, major_minor)
93-
msg = String.new("Your Gemfile.lock indicates you need bundler `#{major_minor}.x`\n")
94-
msg << "which is not currently supported. You can deploy with bundler version:\n"
95-
version_hash.keys.each do |v|
96-
msg << " - `#{v}.x`\n"
97-
end
98-
msg << "\nTo use another version of bundler, update your `Gemfile.lock` to point\n"
99-
msg << "to a supported version. For example:\n"
100-
msg << "\n"
101-
msg << "```\n"
102-
msg << "BUNDLED WITH\n"
103-
msg << " #{DEFAULT_VERSION}\n"
104-
msg << "```\n"
105-
super msg
106-
end
107-
end
33+
DEFAULT_VERSION = "2.3.25"
10834

10935
attr_reader :bundler_path
11036

11137
def initialize(
11238
bundler_path:,
39+
bundler_version:,
11340
gemfile_path: Pathname.new("./Gemfile"),
11441
report: HerokuBuildReport::GLOBAL
11542
)
11643
@report = report
11744
@gemfile_path = gemfile_path
11845
@gemfile_lock_path = Pathname.new("#{@gemfile_path}.lock")
11946

120-
contents = @gemfile_lock_path.read(mode: "rt")
121-
bundled_with = contents.match(BUNDLED_WITH_REGEX)
12247
dot_ruby_version_file = @gemfile_lock_path.join("..").join(".ruby-version")
12348
@report.capture(
124-
"bundler.bundled_with" => bundled_with&.[]("version") || "empty",
125-
# We use this bundler class to detect the Requested ruby version from the Gemfile.lock
126-
# Rails 8 stopped generating `RUBY VERSION` in the Gemfile.lock and started generating
127-
# a `.ruby-version` file. This will observe the formats to help guide implementation
128-
# decisions
12949
"ruby.dot_ruby_version" => dot_ruby_version_file.exist? ? dot_ruby_version_file.read&.strip : nil
13050
)
131-
@version = self.class.detect_bundler_version(
132-
contents: contents,
133-
bundled_with: bundled_with
134-
)
51+
@version = bundler_version
13552
parts = @version.split(".")
13653
@report.capture(
13754
"bundler.version_installed" => @version,
@@ -175,28 +92,10 @@ def dir_name
17592
@dir_name
17693
end
17794

178-
def self.platform_to_version(bundle_platform_output)
179-
if bundle_platform_output.match(/No ruby version specified/)
180-
""
181-
else
182-
bundle_platform_output.strip.sub('(', '').sub(')', '').sub(/(p-?\d+)/, ' \1').split.join('-')
183-
end
184-
end
185-
186-
def bundler_version_escape_valve!
187-
topic("Removing BUNDLED WITH version in the Gemfile.lock")
188-
contents = File.read(@gemfile_lock_path, mode: "rt")
189-
File.open(@gemfile_lock_path, "w") do |f|
190-
f.write contents.sub(/^BUNDLED WITH$(\r?\n) {2,3}(?<major>\d+)\.\d+\.\d+/m, '')
191-
end
192-
end
193-
194-
private
195-
def fetch_bundler
95+
private def fetch_bundler
19696
return true if Dir.exist?(bundler_path.join("gems", dir_name))
19797

19898
topic("Installing bundler #{@version}")
199-
bundler_version_escape_valve!
20099

201100
# Install directory structure (as of Bundler 2.1.4):
202101
# - cache
@@ -211,7 +110,38 @@ def fetch_bundler
211110
end
212111

213112
# Runs a Ruby subprocess to parse the Gemfile.lock and return specs as a hash.
214-
def specs_from_lockfile
113+
private def specs_from_lockfile
215114
LanguagePack::Helpers::LockfileShellParser.call(lockfile_path: @gemfile_lock_path)
216115
end
116+
117+
def self.resolve_bundler_version(gemfile_lock:, warn_io: )
118+
version = gemfile_lock.bundler.version
119+
if version
120+
version
121+
else
122+
warn_io.warn(<<~WARNING)
123+
Using default bundler version `#{DEFAULT_VERSION}`
124+
125+
The Ruby buildpack uses the `BUNDLED WITH` value in your `Gemfile.lock` to determine the version
126+
of bundler to install. Your `Gemfile.lock` does not contain this section, so a default version
127+
of bundler will be installed instead.
128+
129+
Heroku recommends that you have both a `RUBY VERSION` and `BUNDLED WITH` version listed in your `Gemfile.lock`.
130+
You can add it to your project by running:
131+
132+
```
133+
$ bundle update --bundler
134+
```
135+
136+
Commit the results to git before redeploying:
137+
138+
```
139+
$ git add Gemfile.lock
140+
$ git commit -m "Add BUNDLED WITH version"
141+
```
142+
WARNING
143+
144+
DEFAULT_VERSION
145+
end
146+
end
217147
end

lib/language_pack/helpers/lockfile_shell_parser.rb

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# frozen_string_literal: true
22

33
require "json"
4+
require "tempfile"
5+
require "open3"
46

57
module LanguagePack
68
module Helpers
@@ -22,14 +24,11 @@ module Helpers
2224
# specs["rake"] # => #<Gem::Version "13.2.1">
2325
#
2426
module LockfileShellParser
25-
extend LanguagePack::ShellHelpers
26-
2727
RUBY_PARSER_CODE = <<~RUBY
2828
require "json"
2929
require "bundler"
3030
31-
path = ARGV[0] or raise "First argument must be the path to the Gemfile.lock"
32-
specs = Bundler::LockfileParser.new(File.read(path))
31+
specs = Bundler::LockfileParser.new(STDIN.read)
3332
.specs
3433
.each_with_object({}) {|spec, hash| hash[spec.name.to_s] = spec.version.to_s }
3534
puts specs.to_json
@@ -47,8 +46,32 @@ module LockfileShellParser
4746
# specs["nokogiri"] # => #<Gem::Version "1.15.0">
4847
#
4948
def self.call(lockfile_path:)
50-
output = run!("ruby -e #{RUBY_PARSER_CODE.shellescape} #{lockfile_path.to_s.shellescape}", out: "2>/dev/null")
51-
JSON.parse(output).transform_values { |version| Gem::Version.new(version) }
49+
lockfile_path = Pathname(lockfile_path)
50+
Tempfile.create(['lockfile_parser', '.rb']) do |tempfile|
51+
tempfile.write(RUBY_PARSER_CODE)
52+
tempfile.flush
53+
54+
stdout, stderr, status = Open3.capture3("ruby", tempfile.path, stdin_data: lockfile_path.read(mode: "rt"))
55+
if status.success?
56+
JSON.parse(stdout).transform_values { |version| Gem::Version.new(version) }
57+
else
58+
raise <<~ERROR
59+
Cannot parse `Gemfile.lock` file at path `#{lockfile_path}`
60+
61+
The Ruby buildpack runs a Ruby script that uses Bundler::LockfileParser to parse the lockfile of your application.
62+
This information is needed to set environment variables based on requested gems such as `RAILS_ENV`
63+
before gems are installed via `bundle install`.
64+
65+
This script failed to parse `#{lockfile_path}` and the buildpack cannot continue.
66+
67+
Debugging information:
68+
69+
status: #{status}
70+
stdout: #{stdout}
71+
stderr: #{stderr}
72+
ERROR
73+
end
74+
end
5275
end
5376
end
5477
end

spec/hatchet/rails6_spec.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
describe "Rails 6" do
44
it "should detect successfully" do
55
Hatchet::App.new('rails61').in_directory_fork do
6-
bundler = LanguagePack::Helpers::BundlerWrapper.new(bundler_path: Dir.mktmpdir)
6+
bundler = LanguagePack::Helpers::BundlerWrapper.new(
7+
bundler_path: Dir.mktmpdir,
8+
bundler_version: LanguagePack::Helpers::BundlerWrapper::DEFAULT_VERSION
9+
)
710
expect(LanguagePack::Rails5.use?(bundler: bundler)).to eq(false)
811
expect(LanguagePack::Rails6.use?(bundler: bundler)).to eq(true)
912
end

spec/hatchet/rails7_spec.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
describe "Rails 7" do
44
it "should detect successfully" do
55
Hatchet::App.new('rails-jsbundling').in_directory_fork do
6-
bundler = LanguagePack::Helpers::BundlerWrapper.new(bundler_path: Dir.mktmpdir)
6+
bundler = LanguagePack::Helpers::BundlerWrapper.new(
7+
bundler_path: Dir.mktmpdir,
8+
bundler_version: LanguagePack::Helpers::BundlerWrapper::DEFAULT_VERSION
9+
)
710
expect(LanguagePack::Rails6.use?(bundler: bundler)).to eq(false)
811
expect(LanguagePack::Rails7.use?(bundler: bundler)).to eq(true)
912
end

0 commit comments

Comments
 (0)