Skip to content

Commit 9ea7b98

Browse files
authored
Install bundler via gem install (heroku#1680)
* Install bundler via `gem install` Previously we downloaded a version from S3. This moves the classic buildpack to be more like the CNB https://github.com/heroku/buildpacks-ruby/blob/8729d93fbef99696ec1f039a4accc52a2f98f3bc/buildpacks/ruby/src/layers/bundle_download_layer.rs. * Shell out to parse Gemfile.lock This strategy can also be used in the Ruby CNB. This is needed to make this a refactor and preserve the ability to set default_config_vars before calling `bundle install` based on gems (versus after calling `bundle install` we can use `bundle list` output/parsing). * Install bundler where we want it from the beginning We know where bundler will live, we can put it in the right place from the start. * Fix cache logic The prior directory check only looked at `vendor/bundle/#{engine}/#{major}.#{minor}.0` (which does not include the bundler version). This prevented installing/updating bundler versions. The logic now checks the specific bundler version before skipping installation. * Changelog * Unit test new lockfile parsing functionality
1 parent f44e292 commit 9ea7b98

12 files changed

Lines changed: 180 additions & 47 deletions

CHANGELOG.md

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

33
## [Unreleased]
44

5+
- Bundler version is now installed via running `gem install bundler` previously it was pre-built
6+
and downloaded directly from S3.
7+
8+
This change should be a refactor (no observed change in build behavior). If your app can build with `https://github.com/heroku/heroku-buildpack-ruby#v334` but not with this version, please open a
9+
support ticket https://help.heroku.com/. (https://github.com/heroku/heroku-buildpack-ruby/pull/1680)
510

611
## [v339] - 2026-01-05
712

lib/language_pack.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def self.call(app_path:, cache_path:, gemfile_lock:, bundle_default_without:, en
5050
io: warn_io
5151
)
5252

53-
bundler = Helpers::BundlerWrapper.new.install
53+
bundler = Helpers::BundlerWrapper.new(bundler_path: ruby_version.bundler_directory).install
5454
default_config_vars = Ruby.default_config_vars(metadata: metadata, ruby_version: ruby_version, bundler: bundler, environment_name: environment_name)
5555
Ruby.setup_language_pack_environment(
5656
app_path: app_path.expand_path,
@@ -59,7 +59,6 @@ def self.call(app_path:, cache_path:, gemfile_lock:, bundle_default_without:, en
5959
bundle_default_without: bundle_default_without,
6060
default_config_vars: default_config_vars
6161
)
62-
Ruby.install_bundler_in_app(bundler_src_dir: bundler.bundler_path, app_bundler_dir: ruby_version.bundler_directory)
6362
Ruby.load_bundler_cache(
6463
ruby_version: ruby_version,
6564
new_app: new_app,
@@ -139,6 +138,7 @@ def self.detect(arch:, app_path:, cache_path:, environment_name:, gemfile_lock:,
139138
require "language_pack/helpers/rails_runner"
140139
require "language_pack/helpers/puma_warn_error"
141140
require "language_pack/helpers/bundler_wrapper"
141+
require "language_pack/helpers/lockfile_shell_parser"
142142
require "language_pack/helpers/default_env_vars"
143143
require "language_pack/helpers/outdated_ruby_version"
144144
require "language_pack/helpers/download_presence"

lib/language_pack/helpers/bundler_wrapper.rb

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

3-
require 'language_pack/fetcher'
3+
require "json"
44

55
# This class is responsible for installing and maintaining a
66
# reference to bundler. It contains access to bundler internals
@@ -9,7 +9,7 @@
99
#
1010
# Example:
1111
#
12-
# bundler = LanguagePack::Helpers::BundlerWrapper.new
12+
# bundler = LanguagePack::Helpers::BundlerWrapper.new(bundler_path: "vendor/bundle/ruby/3.2.0")
1313
# bundler.install
1414
# bundler.version => "1.15.2"
1515
# bundler.dir_name => "bundler-1.15.2"
@@ -22,7 +22,7 @@
2222
# of an isolated dyno, you must call `BundlerWrapper#clean`. To reset the environment
2323
# variable:
2424
#
25-
# bundler = LanguagePack::Helpers::BundlerWrapper.new
25+
# bundler = LanguagePack::Helpers::BundlerWrapper.new(bundler_path: "vendor/bundle/ruby/3.2.0")
2626
# bundler.install
2727
# bundler.clean # <========== IMPORTANT =============
2828
#
@@ -109,13 +109,11 @@ def initialize(version_hash, major_minor)
109109
attr_reader :bundler_path
110110

111111
def initialize(
112-
bundler_path: nil,
112+
bundler_path:,
113113
gemfile_path: Pathname.new("./Gemfile"),
114114
report: HerokuBuildReport::GLOBAL
115115
)
116116
@report = report
117-
@bundler_tmp = Pathname.new(Dir.mktmpdir)
118-
@fetcher = LanguagePack::Fetcher.new(LanguagePack::Base::VENDOR_URL) # coupling
119117
@gemfile_path = gemfile_path
120118
@gemfile_lock_path = Pathname.new("#{@gemfile_path}.lock")
121119

@@ -143,38 +141,30 @@ def initialize(
143141
)
144142
@dir_name = "bundler-#{@version}"
145143

146-
@bundler_path = bundler_path || @bundler_tmp.join(@dir_name)
147-
@bundler_tar = "bundler/#{@dir_name}.tgz"
144+
@bundler_path = Pathname(bundler_path)
148145
@orig_bundle_gemfile = ENV['BUNDLE_GEMFILE']
149-
@path = Pathname.new("#{@bundler_path}/gems/#{@dir_name}/lib")
150146
end
151147

152148
def install
153149
ENV['BUNDLE_GEMFILE'] = @gemfile_path.to_s
154-
155150
fetch_bundler
156-
$LOAD_PATH << @path
157-
require "bundler"
158151
self
159152
end
160153

161154
def clean
162155
ENV['BUNDLE_GEMFILE'] = @orig_bundle_gemfile
163-
@bundler_tmp.rmtree if @bundler_tmp.directory?
164156
end
165157

166158
def has_gem?(name)
167159
specs.key?(name)
168160
end
169161

170162
def gem_version(name)
171-
if spec = specs[name]
172-
spec.version
173-
end
163+
specs[name]
174164
end
175165

176166
def specs
177-
@specs ||= lockfile_parser.specs.each_with_object({}) {|spec, hash| hash[spec.name] = spec }
167+
@specs ||= specs_from_lockfile
178168
end
179169

180170
def version
@@ -193,10 +183,6 @@ def self.platform_to_version(bundle_platform_output)
193183
end
194184
end
195185

196-
def lockfile_parser
197-
@lockfile_parser ||= parse_gemfile_lock
198-
end
199-
200186
def bundler_version_escape_valve!
201187
topic("Removing BUNDLED WITH version in the Gemfile.lock")
202188
contents = File.read(@gemfile_lock_path, mode: "rt")
@@ -207,7 +193,7 @@ def bundler_version_escape_valve!
207193

208194
private
209195
def fetch_bundler
210-
return true if Dir.exist?(bundler_path)
196+
return true if Dir.exist?(bundler_path.join("gems", dir_name))
211197

212198
topic("Installing bundler #{@version}")
213199
bundler_version_escape_valve!
@@ -221,14 +207,11 @@ def fetch_bundler
221207
# - extensions
222208
# - doc
223209
FileUtils.mkdir_p(bundler_path)
224-
Dir.chdir(bundler_path) do
225-
@fetcher.fetch_untar(@bundler_tar)
226-
end
227-
Dir["bin/*"].each {|path| `chmod 755 #{path}` }
210+
run!("GEM_HOME=#{bundler_path} gem install bundler --version #{@version} --no-document --env-shebang")
228211
end
229212

230-
def parse_gemfile_lock
231-
gemfile_contents = File.read(@gemfile_lock_path)
232-
Bundler::LockfileParser.new(gemfile_contents)
213+
# Runs a Ruby subprocess to parse the Gemfile.lock and return specs as a hash.
214+
def specs_from_lockfile
215+
LanguagePack::Helpers::LockfileShellParser.call(lockfile_path: @gemfile_lock_path)
233216
end
234217
end
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# frozen_string_literal: true
2+
3+
require "json"
4+
5+
module LanguagePack
6+
module Helpers
7+
# Parses gem specs from a Gemfile.lock file using Bundler::LockfileParser.
8+
#
9+
# This module encapsulates the logic for extracting gem names and versions
10+
# from a lockfile, running the parsing in a subprocess to avoid polluting
11+
# the current process's Bundler state.
12+
#
13+
# This can be called before gems are installed (but after Ruby and Bundler are installed).
14+
# The output of this and `bundle list` (which can only be invoked after gems are installed)
15+
# will disagree based on env vars such as `BUNDLE_WITHOUT`.
16+
#
17+
# This output will usually be a superset of `bundle list` output.
18+
#
19+
# Example:
20+
#
21+
# specs = LockfileShellParser.call(lockfile_path: "/path/to/Gemfile.lock")
22+
# specs["rake"] # => #<Gem::Version "13.2.1">
23+
#
24+
module LockfileShellParser
25+
extend LanguagePack::ShellHelpers
26+
27+
RUBY_PARSER_CODE = <<~RUBY
28+
require "json"
29+
require "bundler"
30+
31+
path = ARGV[0] or raise "First argument must be the path to the Gemfile.lock"
32+
specs = Bundler::LockfileParser.new(File.read(path))
33+
.specs
34+
.each_with_object({}) {|spec, hash| hash[spec.name.to_s] = spec.version.to_s }
35+
puts specs.to_json
36+
RUBY
37+
38+
# Parses gem specs from a Gemfile.lock file path.
39+
#
40+
# @param lockfile_path [String, Pathname] Path to the Gemfile.lock file
41+
# @return [Hash{String => Gem::Version}] Hash of gem names to their versions
42+
#
43+
# Example:
44+
#
45+
# specs = LockfileShellParser.call(lockfile_path: "Gemfile.lock")
46+
# specs["rails"] # => #<Gem::Version "7.0.4">
47+
# specs["nokogiri"] # => #<Gem::Version "1.15.0">
48+
#
49+
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) }
52+
end
53+
end
54+
end
55+
end
56+

lib/language_pack/ruby.rb

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -539,13 +539,6 @@ def self.install_ruby(app_path: , ruby_version: , stack:, arch: , metadata:, io:
539539
io.error message
540540
end
541541

542-
# installs vendored gems into the slug
543-
def self.install_bundler_in_app(bundler_src_dir:, app_bundler_dir:)
544-
FileUtils.mkdir_p(app_bundler_dir)
545-
Dir.chdir(app_bundler_dir) do |dir|
546-
`cp -R #{bundler_src_dir}/. .`
547-
end
548-
end
549542

550543
# default set of binaries to install
551544
# @return [Array] resulting list
@@ -933,8 +926,13 @@ def self.load_bundler_cache(cache: , metadata: , stack:, bundler_cache: , bundle
933926

934927
def self.purge_bundler_cache(bundler_cache: , stack: nil, ruby_version: , bundler:)
935928
bundler_cache.clear(stack)
936-
# need to reinstall language pack gems
937-
install_bundler_in_app(bundler_src_dir: bundler.bundler_path, app_bundler_dir: ruby_version.bundler_directory)
929+
# need to reinstall bundler
930+
bundler.install
931+
end
932+
933+
def purge_bundler_cache(stack = nil)
934+
@bundler_cache.clear(stack)
935+
bundler.install
938936
end
939937

940938
# writes ERB based database.yml for Rails. The database.yml uses the DATABASE_URL from the environment during runtime.

spec/hatchet/getting_started_spec.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88

99
secret_key_base = app.run("echo $SECRET_KEY_BASE")
1010

11+
set_bundler_version(version: "2.6.9")
12+
1113
# Re-deploy with cache
12-
run!("git commit --allow-empty -m empty")
14+
run!("git add .; git commit -m 'Change bundler version'")
1315
app.push!
1416

1517
# Assert used cached gems

spec/hatchet/rails6_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
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.install
6+
bundler = LanguagePack::Helpers::BundlerWrapper.new(bundler_path: Dir.mktmpdir)
77
expect(LanguagePack::Rails5.use?(bundler: bundler)).to eq(false)
88
expect(LanguagePack::Rails6.use?(bundler: bundler)).to eq(true)
99
end

spec/hatchet/rails7_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
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.install
6+
bundler = LanguagePack::Helpers::BundlerWrapper.new(bundler_path: Dir.mktmpdir)
77
expect(LanguagePack::Rails6.use?(bundler: bundler)).to eq(false)
88
expect(LanguagePack::Rails7.use?(bundler: bundler)).to eq(true)
99
end

spec/helpers/bundler_wrapper_spec.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
report = HerokuBuildReport.dev_null
7373

7474
bundler = LanguagePack::Helpers::BundlerWrapper.new(
75+
bundler_path: Dir.mktmpdir,
7576
gemfile_path: gemfile,
7677
report: report
7778
)
@@ -96,7 +97,7 @@
9697
ENV['RUBYOPT'] = ENV['RUBYOPT'].sub('-rbundler/setup', '')
9798
end
9899

99-
@bundler = LanguagePack::Helpers::BundlerWrapper.new
100+
@bundler = LanguagePack::Helpers::BundlerWrapper.new(bundler_path: Dir.mktmpdir)
100101
end
101102

102103
after(:each) do
@@ -117,7 +118,7 @@
117118

118119
expect(tmp_gemfile_lock_path.read).to match("BUNDLED")
119120

120-
wrapper = LanguagePack::Helpers::BundlerWrapper.new(gemfile_path: tmp_gemfile_path )
121+
wrapper = LanguagePack::Helpers::BundlerWrapper.new(bundler_path: Dir.mktmpdir, gemfile_path: tmp_gemfile_path)
121122

122123
expect(wrapper.version).to eq(LanguagePack::Helpers::BundlerWrapper::BLESSED_BUNDLER_VERSIONS["2"])
123124

spec/helpers/fetcher_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
lockfile.write("BUNDLED WITH\n #{version}")
1111

1212
fetcher = LanguagePack::Fetcher.new(LanguagePack::Base::VENDOR_URL)
13-
fetcher.fetch_untar("bundler/#{LanguagePack::Helpers::BundlerWrapper.new.dir_name}.tgz")
13+
fetcher.fetch_untar("bundler/#{LanguagePack::Helpers::BundlerWrapper.new(bundler_path: Dir.mktmpdir).dir_name}.tgz")
1414

1515
expect(run!("ls bin")).to match("bundle")
1616
end

0 commit comments

Comments
 (0)