From a034ccbcac75fc4821c36fce68679c1c80601411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Mon, 25 May 2026 14:24:53 -0600 Subject: [PATCH 1/5] Hardcode Gemfile.next as the gemfile for dual-boot deploys Rebases the spirit of PR #3 (use_gemfile_next) onto upstream v359. All buildpack phases now operate on Gemfile.next / Gemfile.next.lock instead of the default Gemfile / Gemfile.lock. Spots patched: - lib/language_pack.rb: top-level lockfile detection reads Gemfile.next.lock (drives bundler version and ruby version resolution). - lib/language_pack/helpers/bundler_wrapper.rb: default gemfile_path is ./Gemfile.next. - lib/language_pack/ruby.rb: * self.use? detects by Gemfile.next presence. * setup_language_pack_environment sets ENV['BUNDLE_GEMFILE'] for in-process build steps. * setup_export exports BUNDLE_GEMFILE for subsequent buildpacks. * setup_profiled sets the runtime override so dynos boot Gemfile.next. * build_bundler installs gems for Gemfile.next. * rake_env injects BUNDLE_GEMFILE into the rake subprocess (this is the spot the original PR #3 missed, which caused the rake-task detection step to fall back to Gemfile.lock). --- lib/language_pack.rb | 4 ++-- lib/language_pack/helpers/bundler_wrapper.rb | 2 +- lib/language_pack/ruby.rb | 13 +++++++++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/language_pack.rb b/lib/language_pack.rb index 0e4470a9e..1dad7725f 100644 --- a/lib/language_pack.rb +++ b/lib/language_pack.rb @@ -10,13 +10,13 @@ module Helpers end def self.gemfile_lock(app_path:) - path = app_path.join("Gemfile.lock") + path = app_path.join("Gemfile.next.lock") if path.exist? LanguagePack::Helpers::GemfileLock.new( contents: path.read ) else - raise BuildpackError.new("Gemfile.lock required. Please check it in.") + raise BuildpackError.new("Gemfile.next.lock required. Please check it in.") end end diff --git a/lib/language_pack/helpers/bundler_wrapper.rb b/lib/language_pack/helpers/bundler_wrapper.rb index 386118518..ae75ddbfd 100644 --- a/lib/language_pack/helpers/bundler_wrapper.rb +++ b/lib/language_pack/helpers/bundler_wrapper.rb @@ -36,7 +36,7 @@ class LanguagePack::Helpers::BundlerWrapper def initialize( bundler_path:, bundler_version:, - gemfile_path: Pathname.new("./Gemfile"), + gemfile_path: Pathname.new("./Gemfile.next"), report: HerokuBuildReport::GLOBAL ) @report = report diff --git a/lib/language_pack/ruby.rb b/lib/language_pack/ruby.rb index ab5452133..54623671d 100644 --- a/lib/language_pack/ruby.rb +++ b/lib/language_pack/ruby.rb @@ -19,7 +19,7 @@ class LanguagePack::Ruby < LanguagePack::Base # detects if this is a valid Ruby app # @return [Boolean] true if it's a Ruby app def self.use?(bundler: nil) - File.exist?("Gemfile") + File.exist?("Gemfile.next") end def initialize(...) @@ -306,6 +306,7 @@ def self.setup_language_pack_environment(app_path:, ruby_version:, bundle_defaul ENV["BUNDLE_PATH"] = "vendor/bundle" ENV["BUNDLE_BIN"] = "vendor/bundle/bin" ENV["BUNDLE_DEPLOYMENT"] = "1" + ENV["BUNDLE_GEMFILE"] = app_path.join("Gemfile.next").to_s end # Sets up the environment variables for subsequent processes run by @@ -331,6 +332,7 @@ def setup_export(app_path:, ruby_version:, default_config_vars:) set_export_default "BUNDLE_WITHOUT", ENV["BUNDLE_WITHOUT"] set_export_default "BUNDLE_BIN", ENV["BUNDLE_BIN"] set_export_default "BUNDLE_DEPLOYMENT", ENV["BUNDLE_DEPLOYMENT"] # Unset on windows since we delete the Gemfile.lock + set_export_default "BUNDLE_GEMFILE", ENV["BUNDLE_GEMFILE"] default_config_vars.each do |key, value| set_export_default key, value end @@ -375,6 +377,7 @@ def setup_profiled(ruby_layer_path:, gem_layer_path:, ruby_version:, default_con set_env_default "BUNDLE_WITHOUT", ENV["BUNDLE_WITHOUT"] set_env_default "BUNDLE_BIN", ENV["BUNDLE_BIN"] set_env_default "BUNDLE_DEPLOYMENT", ENV["BUNDLE_DEPLOYMENT"] if ENV["BUNDLE_DEPLOYMENT"] # Unset on windows since we delete the Gemfile.lock + set_env_override "BUNDLE_GEMFILE", "Gemfile.next" end def warn_outdated_ruby @@ -640,7 +643,7 @@ def self.build_bundler(app_path:, io:, bundler_cache:, bundler_version:, bundler io.topic("Installing dependencies using bundler #{bundler_version}") env_vars = {} - env_vars["BUNDLE_GEMFILE"] = app_path.join("Gemfile").to_s + env_vars["BUNDLE_GEMFILE"] = app_path.join("Gemfile.next").to_s env_vars["BUNDLE_CONFIG"] = app_path.join(".bundle/config").to_s env_vars["NOKOGIRI_USE_SYSTEM_LIBRARIES"] = "true" env_vars["BUNDLE_DISABLE_VERSION_CHECK"] = "true" @@ -709,11 +712,13 @@ def rake end def rake_env - if database_url + base = if database_url {"DATABASE_URL" => database_url} else {} - end.merge(user_env_hash) + end + base["BUNDLE_GEMFILE"] = app_path.join("Gemfile.next").to_s + base.merge(user_env_hash) end def database_url From 48f102024ea211afa41de942d4fb3570955b5cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Mon, 25 May 2026 14:30:05 -0600 Subject: [PATCH 2/5] Materialize Gemfile.next symlink into a real file at build time When an app uses the common next_rails dual-boot setup with Gemfile.next as a symlink to Gemfile, the __FILE__-based 'next?' check can resolve inconsistently across Bundler versions: older Bundler keeps __FILE__ as the symlink path ('Gemfile.next'), newer Bundler may dereference it to 'Gemfile', silently flipping the dual-boot conditional and installing the wrong Rails version. Rewrite the symlink to a real file containing the same contents at the start of build_bundler. The repo keeps the symlink for the developer workflow; only the buildpack's working copy is changed. --- lib/language_pack/ruby.rb | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/lib/language_pack/ruby.rb b/lib/language_pack/ruby.rb index 54623671d..413b3d232 100644 --- a/lib/language_pack/ruby.rb +++ b/lib/language_pack/ruby.rb @@ -620,7 +620,29 @@ def self.remove_vendor_bundle(app_path:) end # runs bundler to install the dependencies + # If Gemfile.next is a symlink (a common next_rails dual-boot setup where + # Gemfile.next -> Gemfile), materialize it as a real file so that + # `File.basename(__FILE__)` inside the Gemfile resolves to "Gemfile.next" + # regardless of how the active Bundler version handles symlinks. The repo + # keeps the symlink for the developer workflow; the buildpack only rewrites + # it for the duration of this build. + def self.materialize_gemfile_next_symlink(app_path:, io:) + gemfile_next = app_path.join("Gemfile.next") + return unless gemfile_next.symlink? + + target = File.readlink(gemfile_next.to_s) + target_path = gemfile_next.dirname.join(target) + return unless target_path.exist? + + io.topic("Materializing Gemfile.next symlink -> #{target}") + contents = target_path.read + gemfile_next.delete + gemfile_next.write(contents) + end + def self.build_bundler(app_path:, io:, bundler_cache:, bundler_version:, bundler_output:, ruby_version:) + materialize_gemfile_next_symlink(app_path: app_path, io: io) + if app_path.join(".bundle/config").exist? warn(<<~WARNING, inline: true) You have the `.bundle/config` file checked into your repository From d18d9498e6ba556f9f4d54f47b7cf4b8a4a42f9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Mon, 25 May 2026 14:56:50 -0600 Subject: [PATCH 3/5] Honor BUNDLE_GEMFILE env var so the buildpack works for current and next Replace the hardcoded 'Gemfile.next' references with a single source of truth: LanguagePack.gemfile_name, which reads ENV['BUNDLE_GEMFILE'] and returns its basename, defaulting to 'Gemfile' when unset. Lockfile name is derived as '.lock'. With this change, the fork acts as a drop-in replacement for the stock heroku/ruby buildpack when BUNDLE_GEMFILE is not set, and switches to a next_rails-style alternative (e.g. Gemfile.next) when the env var is set via a Heroku config var. The symlink materializer also operates on the chosen Gemfile generically. --- lib/language_pack.rb | 18 ++++++++- lib/language_pack/helpers/bundler_wrapper.rb | 2 +- lib/language_pack/ruby.rb | 42 ++++++++++---------- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/lib/language_pack.rb b/lib/language_pack.rb index 1dad7725f..3e43a6965 100644 --- a/lib/language_pack.rb +++ b/lib/language_pack.rb @@ -9,14 +9,28 @@ module LanguagePack module Helpers end + # Name of the Gemfile this buildpack should use. Honors the BUNDLE_GEMFILE + # env var (typically set via a Heroku config var) so a user can flip between + # the current and next Gemfile without changing buildpack URLs. Falls back + # to "Gemfile", the same default as the stock heroku/ruby buildpack, making + # this a drop-in replacement when BUNDLE_GEMFILE is not set. + def self.gemfile_name + raw = ENV["BUNDLE_GEMFILE"].to_s + raw.empty? ? "Gemfile" : File.basename(raw) + end + + def self.lockfile_name + "#{gemfile_name}.lock" + end + def self.gemfile_lock(app_path:) - path = app_path.join("Gemfile.next.lock") + path = app_path.join(lockfile_name) if path.exist? LanguagePack::Helpers::GemfileLock.new( contents: path.read ) else - raise BuildpackError.new("Gemfile.next.lock required. Please check it in.") + raise BuildpackError.new("#{lockfile_name} required. Please check it in.") end end diff --git a/lib/language_pack/helpers/bundler_wrapper.rb b/lib/language_pack/helpers/bundler_wrapper.rb index ae75ddbfd..616b4b230 100644 --- a/lib/language_pack/helpers/bundler_wrapper.rb +++ b/lib/language_pack/helpers/bundler_wrapper.rb @@ -36,7 +36,7 @@ class LanguagePack::Helpers::BundlerWrapper def initialize( bundler_path:, bundler_version:, - gemfile_path: Pathname.new("./Gemfile.next"), + gemfile_path: Pathname.new("./#{LanguagePack.gemfile_name}"), report: HerokuBuildReport::GLOBAL ) @report = report diff --git a/lib/language_pack/ruby.rb b/lib/language_pack/ruby.rb index 413b3d232..161557e11 100644 --- a/lib/language_pack/ruby.rb +++ b/lib/language_pack/ruby.rb @@ -19,7 +19,7 @@ class LanguagePack::Ruby < LanguagePack::Base # detects if this is a valid Ruby app # @return [Boolean] true if it's a Ruby app def self.use?(bundler: nil) - File.exist?("Gemfile.next") + File.exist?(LanguagePack.gemfile_name) end def initialize(...) @@ -306,7 +306,7 @@ def self.setup_language_pack_environment(app_path:, ruby_version:, bundle_defaul ENV["BUNDLE_PATH"] = "vendor/bundle" ENV["BUNDLE_BIN"] = "vendor/bundle/bin" ENV["BUNDLE_DEPLOYMENT"] = "1" - ENV["BUNDLE_GEMFILE"] = app_path.join("Gemfile.next").to_s + ENV["BUNDLE_GEMFILE"] = app_path.join(LanguagePack.gemfile_name).to_s end # Sets up the environment variables for subsequent processes run by @@ -377,7 +377,7 @@ def setup_profiled(ruby_layer_path:, gem_layer_path:, ruby_version:, default_con set_env_default "BUNDLE_WITHOUT", ENV["BUNDLE_WITHOUT"] set_env_default "BUNDLE_BIN", ENV["BUNDLE_BIN"] set_env_default "BUNDLE_DEPLOYMENT", ENV["BUNDLE_DEPLOYMENT"] if ENV["BUNDLE_DEPLOYMENT"] # Unset on windows since we delete the Gemfile.lock - set_env_override "BUNDLE_GEMFILE", "Gemfile.next" + set_env_override "BUNDLE_GEMFILE", LanguagePack.gemfile_name end def warn_outdated_ruby @@ -620,28 +620,28 @@ def self.remove_vendor_bundle(app_path:) end # runs bundler to install the dependencies - # If Gemfile.next is a symlink (a common next_rails dual-boot setup where - # Gemfile.next -> Gemfile), materialize it as a real file so that - # `File.basename(__FILE__)` inside the Gemfile resolves to "Gemfile.next" - # regardless of how the active Bundler version handles symlinks. The repo - # keeps the symlink for the developer workflow; the buildpack only rewrites - # it for the duration of this build. - def self.materialize_gemfile_next_symlink(app_path:, io:) - gemfile_next = app_path.join("Gemfile.next") - return unless gemfile_next.symlink? - - target = File.readlink(gemfile_next.to_s) - target_path = gemfile_next.dirname.join(target) + # If the active Gemfile (typically Gemfile.next in dual-boot setups) is a + # symlink, materialize it as a real file so that `File.basename(__FILE__)` + # inside the Gemfile resolves to the symlink name regardless of how the + # active Bundler version handles symlinks. The repo keeps the symlink for + # the developer workflow; the buildpack only rewrites the working copy for + # the duration of this build. + def self.materialize_gemfile_symlink(app_path:, io:) + gemfile = app_path.join(LanguagePack.gemfile_name) + return unless gemfile.symlink? + + target = File.readlink(gemfile.to_s) + target_path = gemfile.dirname.join(target) return unless target_path.exist? - io.topic("Materializing Gemfile.next symlink -> #{target}") + io.topic("Materializing #{LanguagePack.gemfile_name} symlink -> #{target}") contents = target_path.read - gemfile_next.delete - gemfile_next.write(contents) + gemfile.delete + gemfile.write(contents) end def self.build_bundler(app_path:, io:, bundler_cache:, bundler_version:, bundler_output:, ruby_version:) - materialize_gemfile_next_symlink(app_path: app_path, io: io) + materialize_gemfile_symlink(app_path: app_path, io: io) if app_path.join(".bundle/config").exist? warn(<<~WARNING, inline: true) @@ -665,7 +665,7 @@ def self.build_bundler(app_path:, io:, bundler_cache:, bundler_version:, bundler io.topic("Installing dependencies using bundler #{bundler_version}") env_vars = {} - env_vars["BUNDLE_GEMFILE"] = app_path.join("Gemfile.next").to_s + env_vars["BUNDLE_GEMFILE"] = app_path.join(LanguagePack.gemfile_name).to_s env_vars["BUNDLE_CONFIG"] = app_path.join(".bundle/config").to_s env_vars["NOKOGIRI_USE_SYSTEM_LIBRARIES"] = "true" env_vars["BUNDLE_DISABLE_VERSION_CHECK"] = "true" @@ -739,7 +739,7 @@ def rake_env else {} end - base["BUNDLE_GEMFILE"] = app_path.join("Gemfile.next").to_s + base["BUNDLE_GEMFILE"] = app_path.join(LanguagePack.gemfile_name).to_s base.merge(user_env_hash) end From 535bb1fe421e809e67eca790d064a52184058ed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Mon, 25 May 2026 15:09:22 -0600 Subject: [PATCH 4/5] Read BUNDLE_GEMFILE from user config vars (env dir), not just ENV Heroku passes user-set config vars to buildpacks via the env dir (ARGV[2] of ruby_compile), not by setting ENV directly. The previous implementation read ENV['BUNDLE_GEMFILE'] before calling initialize_env, so the value was always empty and gemfile_name defaulted to 'Gemfile' even when the user had set BUNDLE_GEMFILE=Gemfile.next. Two fixes: - ruby_compile.rb: call initialize_env before LanguagePack.gemfile_lock so the env dir is loaded before we touch any lockfile. - LanguagePack.gemfile_name: fall back to user_env_hash when ENV is empty so the value is visible to all phases of the build, not just those that pipe with user_env: true. --- bin/support/ruby_compile.rb | 6 +++++- lib/language_pack.rb | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/bin/support/ruby_compile.rb b/bin/support/ruby_compile.rb index 036c67dad..e126728f1 100755 --- a/bin/support/ruby_compile.rb +++ b/bin/support/ruby_compile.rb @@ -17,10 +17,14 @@ begin app_path = Pathname(ARGV[0]) cache_path = Pathname(ARGV[1]) - gemfile_lock = LanguagePack.gemfile_lock(app_path: app_path) Dir.chdir(app_path) + # Load user config vars from the env dir before we touch the Gemfile so that + # BUNDLE_GEMFILE (set as a Heroku config var) can steer which lockfile we + # read. Without this, gemfile_lock would always read Gemfile.lock regardless + # of the user's BUNDLE_GEMFILE setting. LanguagePack::ShellHelpers.initialize_env(ARGV[2]) + gemfile_lock = LanguagePack.gemfile_lock(app_path: app_path) LanguagePack.call( app_path: app_path, cache_path: cache_path, diff --git a/lib/language_pack.rb b/lib/language_pack.rb index 3e43a6965..59d9e84a2 100644 --- a/lib/language_pack.rb +++ b/lib/language_pack.rb @@ -16,6 +16,9 @@ module Helpers # this a drop-in replacement when BUNDLE_GEMFILE is not set. def self.gemfile_name raw = ENV["BUNDLE_GEMFILE"].to_s + if raw.empty? && defined?(LanguagePack::ShellHelpers) + raw = LanguagePack::ShellHelpers.user_env_hash["BUNDLE_GEMFILE"].to_s + end raw.empty? ? "Gemfile" : File.basename(raw) end From f8b404fbc46fa1be18fc05c611031641e5ef945c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Mon, 25 May 2026 15:37:23 -0600 Subject: [PATCH 5/5] CI: skip integration-test on forks, document changes in CHANGELOG - Gate the integration-test job on github.repository == upstream so forks do not fail on a job that requires Heroku API secrets they cannot have. - Add CHANGELOG entries describing the BUNDLE_GEMFILE wiring and the CI fork-gate. Satisfies the check-changelog CI job. --- .github/workflows/ci.yml | 4 ++++ CHANGELOG.md | 3 +++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4351ae368..4b76ea82a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,10 @@ jobs: xargs -0 shellcheck --check-sourced --color=always integration-test: + # Only runs on the upstream repo, which holds the required secrets + # (HEROKU_API_KEY, etc.). Forks don't have access, so the job would + # always fail at the Hatchet setup step. + if: github.repository == 'heroku/heroku-buildpack-ruby' runs-on: ubuntu-24.04 env: HATCHET_APP_LIMIT: 300 diff --git a/CHANGELOG.md b/CHANGELOG.md index 22524b30e..c9dbd9ecd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +- Fork-only: honor `BUNDLE_GEMFILE` (typically a Heroku config var) so the buildpack can deploy either the current Gemfile or a next_rails-style alternative (e.g. `Gemfile.next`). Defaults to `Gemfile`, so behavior is unchanged when `BUNDLE_GEMFILE` is not set. Includes a build-time materializer that rewrites a `Gemfile.next` symlink as a real file so the `File.basename(__FILE__)` dual-boot trick works deterministically across Bundler versions. +- Fork-only: skip the `integration-test` workflow on forks (the job requires Heroku API secrets only available on the upstream repo). + ## [v359] - 2026-05-20