Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stop it failing in our fork.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion bin/support/ruby_compile.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heroku passes user config vars to buildpacks via a directory of files (one file per var), not as actual ENV entries. The path to that dir is ARGV[2] in ruby_compile.rb.
Until LanguagePack::ShellHelpers.initialize_env(ARGV[2]) runs, that data isn't loaded anywhere.

Sequence before our fix:

gemfile_lock = LanguagePack.gemfile_lock(...)   # reads ENV["BUNDLE_GEMFILE"] → ""
Dir.chdir(app_path)
LanguagePack::ShellHelpers.initialize_env(...)  # now user_env_hash["BUNDLE_GEMFILE"] = "Gemfile.next"

LanguagePack.gemfile_name (which gemfile_lock calls via lockfile_name) reads:

  ENV["BUNDLE_GEMFILE"]                            # empty, Heroku doesn't set it in ENV
  || LanguagePack::ShellHelpers.user_env_hash[..]  # empty too, initialize_env hasn't run yet
  || "Gemfile"                                     # falls through to default

So it picked Gemfile.lock. The bundler/Ruby version detection then used the wrong lockfile, and build_bundler later built against Gemfile. Meanwhile bundle list (called with user_env: true) read user_env_hash["BUNDLE_GEMFILE"] = Gemfile.next and tried to verify gems from Gemfile.next.lock which had never been installed. That's the exact mismatch the failed build exposed.

After moving the line below initialize_env, user_env_hash is populated first, gemfile_name resolves to Gemfile.next, and every later phase agrees on the same lockfile.

LanguagePack.call(
app_path: app_path,
cache_path: cache_path,
Expand Down
21 changes: 19 additions & 2 deletions lib/language_pack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,31 @@ 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
if raw.empty? && defined?(LanguagePack::ShellHelpers)
raw = LanguagePack::ShellHelpers.user_env_hash["BUNDLE_GEMFILE"].to_s
end
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.lock")
path = app_path.join(lockfile_name)
if path.exist?
LanguagePack::Helpers::GemfileLock.new(
contents: path.read
)
else
raise BuildpackError.new("Gemfile.lock required. Please check it in.")
raise BuildpackError.new("#{lockfile_name} required. Please check it in.")
end
end

Expand Down
2 changes: 1 addition & 1 deletion lib/language_pack/helpers/bundler_wrapper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class LanguagePack::Helpers::BundlerWrapper
def initialize(
bundler_path:,
bundler_version:,
gemfile_path: Pathname.new("./Gemfile"),
gemfile_path: Pathname.new("./#{LanguagePack.gemfile_name}"),
report: HerokuBuildReport::GLOBAL
)
@report = report
Expand Down
35 changes: 31 additions & 4 deletions lib/language_pack/ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?(LanguagePack.gemfile_name)
end

def initialize(...)
Expand Down Expand Up @@ -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(LanguagePack.gemfile_name).to_s
end

# Sets up the environment variables for subsequent processes run by
Expand All @@ -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
Expand Down Expand Up @@ -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", LanguagePack.gemfile_name
end

def warn_outdated_ruby
Expand Down Expand Up @@ -617,7 +620,29 @@ def self.remove_vendor_bundle(app_path:)
end

# runs bundler to install the dependencies
# 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 #{LanguagePack.gemfile_name} symlink -> #{target}")
contents = target_path.read
gemfile.delete
gemfile.write(contents)
end

def self.build_bundler(app_path:, io:, bundler_cache:, bundler_version:, bundler_output:, ruby_version:)
materialize_gemfile_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
Expand All @@ -640,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").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"
Expand Down Expand Up @@ -709,11 +734,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(LanguagePack.gemfile_name).to_s
base.merge(user_env_hash)
end

def database_url
Expand Down