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 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 0e4470a9e..59d9e84a2 100644 --- a/lib/language_pack.rb +++ b/lib/language_pack.rb @@ -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 diff --git a/lib/language_pack/helpers/bundler_wrapper.rb b/lib/language_pack/helpers/bundler_wrapper.rb index 386118518..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"), + 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 ab5452133..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") + File.exist?(LanguagePack.gemfile_name) 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(LanguagePack.gemfile_name).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", LanguagePack.gemfile_name end def warn_outdated_ruby @@ -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 @@ -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" @@ -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