From c9c9adfeba64c9b26dd2d22bf2925771d5a0302c Mon Sep 17 00:00:00 2001 From: wt-l00 Date: Fri, 3 Apr 2026 16:52:16 +0900 Subject: [PATCH] Fetch custom Ruby version file specified by `ruby file:` option in Gemfile Add support for Bundler's `ruby file: "filename"` syntax, which allows managing the Ruby version via an external file. The FileFetcher now fetches the referenced file as a support file. - Add `ruby_file_version_filename` to extract the filename from Gemfile content using a regex - Add `ruby_file_version_file` to fetch the file via `fetch_support_file` - Wire it into `fetch_files` following the same pattern as `.ruby-version` and `.tool-versions` ref: https://github.com/ruby/rubygems/commit/fb9354b7bf998ba033144df215159cc5367749bf --- .../lib/dependabot/bundler/file_fetcher.rb | 30 +++++++++++ .../bundler/file_parser/file_preparer.rb | 26 ++++++++++ .../bundler/file_updater/lockfile_updater.rb | 36 +++++++++++++ .../bundler/update_checker/file_preparer.rb | 26 ++++++++++ .../dependabot/bundler/file_fetcher_spec.rb | 42 ++++++++++++++++ .../bundler/file_parser/file_preparer_spec.rb | 10 ++++ .../dependabot/bundler/file_updater_spec.rb | 18 +++++++ .../latest_version_finder_spec.rb | 6 +++ ...ontents_ruby_with_custom_version_file.json | 50 +++++++++++++++++++ .../github/custom_ruby_version_content.json | 18 +++++++ ...gemfile_with_ruby_file_option_content.json | 18 +++++++ .../bundler2/ruby_file_option/Gemfile | 6 +++ .../bundler2/ruby_file_option/Gemfile.lock | 15 ++++++ .../ruby_file_option/custom-ruby-version | 1 + 14 files changed, 302 insertions(+) create mode 100644 bundler/spec/fixtures/github/contents_ruby_with_custom_version_file.json create mode 100644 bundler/spec/fixtures/github/custom_ruby_version_content.json create mode 100644 bundler/spec/fixtures/github/gemfile_with_ruby_file_option_content.json create mode 100644 bundler/spec/fixtures/projects/bundler2/ruby_file_option/Gemfile create mode 100644 bundler/spec/fixtures/projects/bundler2/ruby_file_option/Gemfile.lock create mode 100644 bundler/spec/fixtures/projects/bundler2/ruby_file_option/custom-ruby-version diff --git a/bundler/lib/dependabot/bundler/file_fetcher.rb b/bundler/lib/dependabot/bundler/file_fetcher.rb index dca122ca42e..a10f4794618 100644 --- a/bundler/lib/dependabot/bundler/file_fetcher.rb +++ b/bundler/lib/dependabot/bundler/file_fetcher.rb @@ -50,6 +50,7 @@ def fetch_files fetched_files += gemspecs fetched_files << T.must(ruby_version_file) if ruby_version_file fetched_files << T.must(tool_versions_file) if tool_versions_file + fetched_files << T.must(ruby_file_version_file) if ruby_file_version_file fetched_files += path_gemspecs fetched_files += find_included_files(fetched_files) @@ -138,6 +139,35 @@ def tool_versions_file @tool_versions_file ||= T.let(fetch_support_file(".tool-versions"), T.nilable(Dependabot::DependencyFile)) end + sig { returns(T.nilable(DependencyFile)) } + def ruby_file_version_file + return unless gemfile + + @ruby_file_version_file ||= T.let( + begin + filename = ruby_file_version_filename + fetch_support_file(filename) if filename + end, + T.nilable(Dependabot::DependencyFile) + ) + end + + sig { returns(T.nilable(String)) } + def ruby_file_version_filename + content = gemfile&.content + return unless content + + match = content.match(/^\s*ruby\s+file:\s*['"]([^'"]+)['"]/) + filename = match&.captures&.first + return unless filename + + pathname = Pathname.new(filename).cleanpath + return unless pathname.relative? + return if pathname.to_s.start_with?("..") + + pathname.to_s + end + sig { returns(T::Array[DependencyFile]) } def path_gemspecs gemspec_files = T.let([], T::Array[Dependabot::DependencyFile]) diff --git a/bundler/lib/dependabot/bundler/file_parser/file_preparer.rb b/bundler/lib/dependabot/bundler/file_parser/file_preparer.rb index 7bca34d8383..65767b13b2e 100644 --- a/bundler/lib/dependabot/bundler/file_parser/file_preparer.rb +++ b/bundler/lib/dependabot/bundler/file_parser/file_preparer.rb @@ -35,6 +35,7 @@ def prepared_dependency_files lockfile, ruby_version_file, tool_versions_file, + ruby_file_version_file, *imported_ruby_files, *specification_files ].compact @@ -89,6 +90,31 @@ def tool_versions_file dependency_files.find { |f| f.name == ".tool-versions" } end + # Returns the custom Ruby version file specified by `ruby file:` in the Gemfile + sig { returns(T.nilable(Dependabot::DependencyFile)) } + def ruby_file_version_file + filename = ruby_file_version_filename + return unless filename + + dependency_files.find { |f| f.name == filename } + end + + sig { returns(T.nilable(String)) } + def ruby_file_version_filename + content = gemfile&.content + return unless content + + match = content.match(/^\s*ruby\s+file:\s*['"]([^'"]+)['"]/) + filename = match&.captures&.first + return unless filename + + pathname = Pathname.new(filename).cleanpath + return unless pathname.relative? + return if pathname.to_s.start_with?("..") + + pathname.to_s + end + sig { returns(T::Array[Dependabot::DependencyFile]) } def imported_ruby_files dependency_files diff --git a/bundler/lib/dependabot/bundler/file_updater/lockfile_updater.rb b/bundler/lib/dependabot/bundler/file_updater/lockfile_updater.rb index fb7b912d104..e80472dc93b 100644 --- a/bundler/lib/dependabot/bundler/file_updater/lockfile_updater.rb +++ b/bundler/lib/dependabot/bundler/file_updater/lockfile_updater.rb @@ -132,6 +132,7 @@ def write_temporary_dependency_files write_gemspecs(top_level_gemspecs) write_ruby_version_file write_tool_versions_file + write_ruby_file_version_file write_gemspecs(path_gemspecs) write_specification_files write_imported_ruby_files @@ -161,6 +162,16 @@ def write_tool_versions_file File.write(path, T.must(tool_versions_file).content) end + # Write the custom Ruby version file specified by `ruby file:` to the temporary directory + sig { void } + def write_ruby_file_version_file + return unless ruby_file_version_file + + path = T.must(ruby_file_version_file).name + FileUtils.mkdir_p(Pathname.new(path).dirname) + File.write(path, T.must(ruby_file_version_file).content) + end + sig { params(files: T::Array[Dependabot::DependencyFile]).void } def write_gemspecs(files) files.each do |file| @@ -218,6 +229,31 @@ def tool_versions_file dependency_files.find { |f| f.name == ".tool-versions" } end + # Returns the custom Ruby version file specified by `ruby file:` in the Gemfile + sig { returns(T.nilable(Dependabot::DependencyFile)) } + def ruby_file_version_file + filename = ruby_file_version_filename + return unless filename + + dependency_files.find { |f| f.name == filename } + end + + sig { returns(T.nilable(String)) } + def ruby_file_version_filename + content = gemfile&.content + return unless content + + match = content.match(/^\s*ruby\s+file:\s*['"]([^'"]+)['"]/) + filename = match&.captures&.first + return unless filename + + pathname = Pathname.new(filename).cleanpath + return unless pathname.relative? + return if pathname.to_s.start_with?("..") + + pathname.to_s + end + sig { params(lockfile_body: String).returns(String) } def post_process_lockfile(lockfile_body) lockfile_body = reorder_git_dependencies(lockfile_body) diff --git a/bundler/lib/dependabot/bundler/update_checker/file_preparer.rb b/bundler/lib/dependabot/bundler/update_checker/file_preparer.rb index 1d59a63fa83..83c1fea178f 100644 --- a/bundler/lib/dependabot/bundler/update_checker/file_preparer.rb +++ b/bundler/lib/dependabot/bundler/update_checker/file_preparer.rb @@ -119,6 +119,7 @@ def prepared_dependency_files lockfile, ruby_version_file, tool_versions_file, + ruby_file_version_file, *imported_ruby_files, *specification_files ].compact @@ -200,6 +201,31 @@ def tool_versions_file dependency_files.find { |f| f.name == ".tool-versions" } end + # Returns the custom Ruby version file specified by `ruby file:` in the Gemfile + sig { returns(T.nilable(Dependabot::DependencyFile)) } + def ruby_file_version_file + filename = ruby_file_version_filename + return unless filename + + dependency_files.find { |f| f.name == filename } + end + + sig { returns(T.nilable(String)) } + def ruby_file_version_filename + content = gemfile&.content + return unless content + + match = content.match(/^\s*ruby\s+file:\s*['"]([^'"]+)['"]/) + filename = match&.captures&.first + return unless filename + + pathname = Pathname.new(filename).cleanpath + return unless pathname.relative? + return if pathname.to_s.start_with?("..") + + pathname.to_s + end + sig { returns(T::Array[Dependabot::DependencyFile]) } def path_gemspecs all = dependency_files.select { |f| f.name.end_with?(".gemspec") } diff --git a/bundler/spec/dependabot/bundler/file_fetcher_spec.rb b/bundler/spec/dependabot/bundler/file_fetcher_spec.rb index 2f902545f1c..31f3d324970 100644 --- a/bundler/spec/dependabot/bundler/file_fetcher_spec.rb +++ b/bundler/spec/dependabot/bundler/file_fetcher_spec.rb @@ -205,6 +205,48 @@ end end + context "with a custom version file referenced via `ruby file:` option" do + before do + stub_request(:get, url + "?ref=sha") + .with(headers: { "Authorization" => "token token" }) + .to_return( + status: 200, + body: fixture("github", "contents_ruby_with_custom_version_file.json"), + headers: { "content-type" => "application/json" } + ) + + stub_request(:get, url + "Gemfile?ref=sha") + .with(headers: { "Authorization" => "token token" }) + .to_return( + status: 200, + body: fixture("github", "gemfile_with_ruby_file_option_content.json"), + headers: { "content-type" => "application/json" } + ) + + stub_request(:get, url + "Gemfile.lock?ref=sha") + .with(headers: { "Authorization" => "token token" }) + .to_return( + status: 200, + body: fixture("github", "gemfile_lock_content.json"), + headers: { "content-type" => "application/json" } + ) + + stub_request(:get, url + "custom-ruby-version?ref=sha") + .with(headers: { "Authorization" => "token token" }) + .to_return( + status: 200, + body: fixture("github", "custom_ruby_version_content.json"), + headers: { "content-type" => "application/json" } + ) + end + + it "fetches the custom version file specified by `ruby file:` option" do + expect(file_fetcher_instance.files.count).to eq(3) + expect(file_fetcher_instance.files.map(&:name)) + .to include("custom-ruby-version") + end + end + context "with a gems.rb rather than a Gemfile" do before do stub_request(:get, url + "?ref=sha") diff --git a/bundler/spec/dependabot/bundler/file_parser/file_preparer_spec.rb b/bundler/spec/dependabot/bundler/file_parser/file_preparer_spec.rb index aa2317e6e48..1fb09471260 100644 --- a/bundler/spec/dependabot/bundler/file_parser/file_preparer_spec.rb +++ b/bundler/spec/dependabot/bundler/file_parser/file_preparer_spec.rb @@ -58,6 +58,16 @@ its(:content) { is_expected.to eq("ruby 2.2.0\n") } end + describe "the updated custom ruby version file specified by `ruby file:` option" do + subject do + prepared_dependency_files.find { |f| f.name == "custom-ruby-version" } + end + + let(:dependency_files) { bundler_project_dependency_files("ruby_file_option") } + + its(:content) { is_expected.to eq("2.2.0\n") } + end + describe "the updated .specification file" do subject do prepared_dependency_files.find { |f| f.name == "plugins/example/.specification" } diff --git a/bundler/spec/dependabot/bundler/file_updater_spec.rb b/bundler/spec/dependabot/bundler/file_updater_spec.rb index bf98bf02191..f613058d164 100644 --- a/bundler/spec/dependabot/bundler/file_updater_spec.rb +++ b/bundler/spec/dependabot/bundler/file_updater_spec.rb @@ -547,6 +547,24 @@ end end + context "when the Gemfile loads a custom ruby version file via `ruby file:` option" do + let(:project_name) { "ruby_file_option" } + let(:updater) do + described_class.new( + dependency_files: dependency_files, + dependencies: [dependency], + credentials: [{ + "type" => "git_source", + "host" => "github.com" + }] + ) + end + + it "locks the updated gem to the latest version" do + expect(file.content).to include "business (1.5.0)" + end + end + context "when the Gemfile.lock didn't have a BUNDLED WITH line" do let(:project_name) { "no_bundled_with" } diff --git a/bundler/spec/dependabot/bundler/update_checker/latest_version_finder_spec.rb b/bundler/spec/dependabot/bundler/update_checker/latest_version_finder_spec.rb index 22715a40077..d3ad93862b0 100644 --- a/bundler/spec/dependabot/bundler/update_checker/latest_version_finder_spec.rb +++ b/bundler/spec/dependabot/bundler/update_checker/latest_version_finder_spec.rb @@ -227,6 +227,12 @@ its([:version]) { is_expected.to eq(Dependabot::Bundler::Version.new("1.5.0")) } end + context "when the Gemfile loads a custom ruby version file via `ruby file:` option" do + let(:dependency_files) { bundler_project_dependency_files("ruby_file_option") } + + its([:version]) { is_expected.to eq(Dependabot::Bundler::Version.new("1.5.0")) } + end + context "with a gemspec and a Gemfile" do let(:dependency_files) { bundler_project_dependency_files("gemfile_small_example") } diff --git a/bundler/spec/fixtures/github/contents_ruby_with_custom_version_file.json b/bundler/spec/fixtures/github/contents_ruby_with_custom_version_file.json new file mode 100644 index 00000000000..396c477589c --- /dev/null +++ b/bundler/spec/fixtures/github/contents_ruby_with_custom_version_file.json @@ -0,0 +1,50 @@ +[ + { + "name": "Gemfile", + "path": "Gemfile", + "sha": "88b4e0a1c8093fae2b4fa52534035f9f85ed0956", + "size": 100, + "url": "https://api.github.com/repos/gocardless/bump/contents/Gemfile?ref=master", + "html_url": "https://github.com/gocardless/bump/blob/master/Gemfile", + "git_url": "https://api.github.com/repos/gocardless/bump/git/blobs/88b4e0a1c8093fae2b4fa52534035f9f85ed0956", + "download_url": "https://raw.githubusercontent.com/gocardless/bump/master/Gemfile", + "type": "file", + "_links": { + "self": "https://api.github.com/repos/gocardless/bump/contents/Gemfile?ref=master", + "git": "https://api.github.com/repos/gocardless/bump/git/blobs/88b4e0a1c8093fae2b4fa52534035f9f85ed0956", + "html": "https://github.com/gocardless/bump/blob/master/Gemfile" + } + }, + { + "name": "Gemfile.lock", + "path": "Gemfile.lock", + "sha": "d429264c8c2f0f306a422900c2f41123e07c31b4", + "size": 100, + "url": "https://api.github.com/repos/gocardless/bump/contents/Gemfile.lock?ref=master", + "html_url": "https://github.com/gocardless/bump/blob/master/Gemfile.lock", + "git_url": "https://api.github.com/repos/gocardless/bump/git/blobs/d429264c8c2f0f306a422900c2f41123e07c31b4", + "download_url": "https://raw.githubusercontent.com/gocardless/bump/master/Gemfile.lock", + "type": "file", + "_links": { + "self": "https://api.github.com/repos/gocardless/bump/contents/Gemfile.lock?ref=master", + "git": "https://api.github.com/repos/gocardless/bump/git/blobs/d429264c8c2f0f306a422900c2f41123e07c31b4", + "html": "https://github.com/gocardless/bump/blob/master/Gemfile.lock" + } + }, + { + "name": "custom-ruby-version", + "path": "custom-ruby-version", + "sha": "005119baaa0653ca59d923010341d8341daa8c43", + "size": 6, + "url": "https://api.github.com/repos/gocardless/bump/contents/custom-ruby-version?ref=master", + "html_url": "https://github.com/gocardless/bump/blob/master/custom-ruby-version", + "git_url": "https://api.github.com/repos/gocardless/bump/git/blobs/005119baaa0653ca59d923010341d8341daa8c43", + "download_url": "https://raw.githubusercontent.com/gocardless/bump/master/custom-ruby-version", + "type": "file", + "_links": { + "self": "https://api.github.com/repos/gocardless/bump/contents/custom-ruby-version?ref=master", + "git": "https://api.github.com/repos/gocardless/bump/git/blobs/005119baaa0653ca59d923010341d8341daa8c43", + "html": "https://github.com/gocardless/bump/blob/master/custom-ruby-version" + } + } +] diff --git a/bundler/spec/fixtures/github/custom_ruby_version_content.json b/bundler/spec/fixtures/github/custom_ruby_version_content.json new file mode 100644 index 00000000000..ac4f542e8a4 --- /dev/null +++ b/bundler/spec/fixtures/github/custom_ruby_version_content.json @@ -0,0 +1,18 @@ +{ + "name": "custom-ruby-version", + "path": "custom-ruby-version", + "sha": "005119baaa0653ca59d923010341d8341daa8c43", + "size": 6, + "url": "https://api.github.com/repos/gocardless/bump/contents/custom-ruby-version?ref=master", + "html_url": "https://github.com/gocardless/bump/blob/master/custom-ruby-version", + "git_url": "https://api.github.com/repos/gocardless/bump/git/blobs/005119baaa0653ca59d923010341d8341daa8c43", + "download_url": "https://raw.githubusercontent.com/gocardless/bump/master/custom-ruby-version", + "type": "file", + "content": "My4yLjAK\n", + "encoding": "base64", + "_links": { + "self": "https://api.github.com/repos/gocardless/bump/contents/custom-ruby-version?ref=master", + "git": "https://api.github.com/repos/gocardless/bump/git/blobs/005119baaa0653ca59d923010341d8341daa8c43", + "html": "https://github.com/gocardless/bump/blob/master/custom-ruby-version" + } +} diff --git a/bundler/spec/fixtures/github/gemfile_with_ruby_file_option_content.json b/bundler/spec/fixtures/github/gemfile_with_ruby_file_option_content.json new file mode 100644 index 00000000000..105dc9646ab --- /dev/null +++ b/bundler/spec/fixtures/github/gemfile_with_ruby_file_option_content.json @@ -0,0 +1,18 @@ +{ + "name": "Gemfile", + "path": "Gemfile", + "sha": "aabbcc1122334455667788990011223344556677", + "size": 80, + "url": "https://api.github.com/repos/gocardless/bump/contents/Gemfile?ref=master", + "html_url": "https://github.com/gocardless/bump/blob/master/Gemfile", + "git_url": "https://api.github.com/repos/gocardless/bump/git/blobs/aabbcc1122334455667788990011223344556677", + "download_url": "https://raw.githubusercontent.com/gocardless/bump/master/Gemfile", + "type": "file", + "content": "c291cmNlICJodHRwczovL3J1YnlnZW1zLm9yZyIKCnJ1YnkgZmlsZTogImN1\nc3RvbS1ydWJ5LXZlcnNpb24iCgpnZW0gImJ1c2luZXNzIiwgIn4+IDEuMCIK\n", + "encoding": "base64", + "_links": { + "self": "https://api.github.com/repos/gocardless/bump/contents/Gemfile?ref=master", + "git": "https://api.github.com/repos/gocardless/bump/git/blobs/aabbcc1122334455667788990011223344556677", + "html": "https://github.com/gocardless/bump/blob/master/Gemfile" + } +} diff --git a/bundler/spec/fixtures/projects/bundler2/ruby_file_option/Gemfile b/bundler/spec/fixtures/projects/bundler2/ruby_file_option/Gemfile new file mode 100644 index 00000000000..0df99a80a13 --- /dev/null +++ b/bundler/spec/fixtures/projects/bundler2/ruby_file_option/Gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +ruby file: "custom-ruby-version" + +gem "business", "~> 1.4.0" +gem "statesman", "~> 1.2.0" diff --git a/bundler/spec/fixtures/projects/bundler2/ruby_file_option/Gemfile.lock b/bundler/spec/fixtures/projects/bundler2/ruby_file_option/Gemfile.lock new file mode 100644 index 00000000000..3fa0fb21893 --- /dev/null +++ b/bundler/spec/fixtures/projects/bundler2/ruby_file_option/Gemfile.lock @@ -0,0 +1,15 @@ +GEM + remote: https://rubygems.org/ + specs: + business (1.4.0) + statesman (1.2.1) + +PLATFORMS + ruby + +DEPENDENCIES + business (~> 1.4.0) + statesman (~> 1.2.0) + +BUNDLED WITH + 2.2.0 diff --git a/bundler/spec/fixtures/projects/bundler2/ruby_file_option/custom-ruby-version b/bundler/spec/fixtures/projects/bundler2/ruby_file_option/custom-ruby-version new file mode 100644 index 00000000000..ccbccc3dc62 --- /dev/null +++ b/bundler/spec/fixtures/projects/bundler2/ruby_file_option/custom-ruby-version @@ -0,0 +1 @@ +2.2.0