Skip to content

Commit abcdb71

Browse files
committed
Allow declaring override remotes on a RubyGems source
A `source` may declare one or more `overrides:` — secondary RubyGems-compatible repositories that supply alternate builds (for example, prebuilt binaries) of gems that already exist in the primary source. source "https://rubygems.org", overrides: ["https://build-farm.example.com"] Override remotes are consulted only for gems that are also present in the primary source; they cannot introduce new gems. When an override publishes a spec whose name and version match one in the primary source, that spec is preferred for installation if it is compatible with the local platform. If no matching build is available, or fetching the override fails (authentication, SSL, or network errors), Bundler falls back to the primary source. Override remotes are recorded in `Gemfile.lock` under `override:` lines: GEM remote: https://rubygems.org/ override: https://build-farm.example.com/ specs: IRL Testing: This can be exercised against Kou's precompiled-gems build farm hosted on Cloudsmith. The only build target currently published there is Ruby 4.0 on amd64 Ubuntu 24.04 — on any other platform the override is skipped and Bundler falls back to the primary source. source "https://rubygems.org", overrides: ["https://dl.cloudsmith.io/public/rubygems-precompiled-gems/ruby-4-0-amd64-ubuntu-24-04/ruby/"] # Precompiled binaries are available on the override remote: gem "json" gem "openc3" gem "io-event" # Not in the override; fall back to rubygems.org and compile # from source: gem "nokogiri" gem "rails"
1 parent cf21e91 commit abcdb71

7 files changed

Lines changed: 382 additions & 7 deletions

File tree

bundler/lib/bundler/dsl.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ def source(source, *args, &blk)
116116
options = args.last.is_a?(Hash) ? args.pop.dup : {}
117117
options = normalize_hash(options)
118118
source = normalize_source(source)
119+
overrides = options["overrides"]
119120

120121
if options.key?("type")
121122
options["type"] = options["type"].to_s
@@ -130,9 +131,12 @@ def source(source, *args, &blk)
130131
source_opts = options.merge("uri" => source)
131132
with_source(@sources.add_plugin_source(options["type"], source_opts), &blk)
132133
elsif block_given?
133-
with_source(@sources.add_rubygems_source("remotes" => source), &blk)
134+
with_source(@sources.add_rubygems_source(
135+
"remotes" => source,
136+
"overrides" => overrides
137+
), &blk)
134138
else
135-
@sources.add_global_rubygems_remote(source)
139+
@sources.add_global_rubygems_remote(source, overrides: overrides)
136140
end
137141
end
138142

bundler/lib/bundler/man/gemfile.5.ronn

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,29 @@ include the credentials in the Gemfile as part of the source URL.
5555
Credentials in the source URL will take precedence over credentials set using
5656
`config`.
5757

58+
### OVERRIDES
59+
60+
A `source` may declare one or more `overrides`. An override is a secondary
61+
RubyGems-compatible repository that supplies alternate builds (for example,
62+
prebuilt binaries) of gems that already exist in the primary source.
63+
64+
source "https://rubygems.org",
65+
overrides: ["https://build-farm.example.com"]
66+
67+
Overrides are consulted only for gems that are also present in the primary
68+
source — they cannot introduce new gems. When an override publishes a spec
69+
whose name and version match one in the primary source, that spec is preferred
70+
for installation if it is compatible with the local platform. If no matching
71+
build is available, or fetching the override fails (authentication, SSL,
72+
network), Bundler falls back to the primary source.
73+
74+
Overrides are recorded in `Gemfile.lock` under `override:` lines:
75+
76+
GEM
77+
remote: https://rubygems.org/
78+
override: https://build-farm.example.com/
79+
specs:
80+
5881
## RUBY
5982

6083
If your application requires a specific Ruby version or engine, specify your

bundler/lib/bundler/source/rubygems.rb

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ class Rubygems < Source
1212
REQUIRE_MUTEX = Mutex.new
1313

1414
attr_accessor :remotes
15+
attr_reader :override_remotes
1516

1617
def initialize(options = {})
1718
@options = options
1819
@remotes = []
20+
@override_remotes = []
1921
@dependency_names = []
2022
@allow_remote = false
2123
@allow_cached = false
@@ -26,8 +28,12 @@ def initialize(options = {})
2628
@gem_installers_mutex = Mutex.new
2729

2830
Array(options["remotes"]).reverse_each {|r| add_remote(r) }
31+
Array(options["overrides"]).reverse_each {|override| add_override_remote(override) }
2932

30-
@lockfile_remotes = @remotes if options["from_lockfile"]
33+
if options["from_lockfile"]
34+
@lockfile_remotes = @remotes
35+
@lockfile_override_remotes = @override_remotes
36+
end
3137
end
3238

3339
def caches
@@ -73,11 +79,13 @@ def cached!
7379
end
7480

7581
def hash
76-
@remotes.hash
82+
[@remotes, @override_remotes].hash
7783
end
7884

7985
def eql?(other)
80-
other.is_a?(Rubygems) && other.credless_remotes == credless_remotes
86+
other.is_a?(Rubygems) &&
87+
other.credless_remotes == credless_remotes &&
88+
other.override_remotes == override_remotes
8189
end
8290

8391
alias_method :==, :eql?
@@ -105,6 +113,7 @@ def options
105113

106114
def self.from_lock(options)
107115
options["remotes"] = Array(options.delete("remote")).reverse
116+
options["overrides"] = Array(options.delete("override")).reverse
108117
new(options.merge("from_lockfile" => true))
109118
end
110119

@@ -113,6 +122,9 @@ def to_lock
113122
lockfile_remotes.reverse_each do |remote|
114123
out << " remote: #{remote}\n"
115124
end
125+
lockfile_override_remotes.reverse_each do |override|
126+
out << " override: #{override}\n"
127+
end
116128
out << " specs:\n"
117129
end
118130

@@ -248,6 +260,11 @@ def add_remote(source)
248260
@remotes.unshift(uri) unless @remotes.include?(uri)
249261
end
250262

263+
def add_override_remote(source)
264+
uri = normalize_uri(source)
265+
@override_remotes.unshift(uri) unless @override_remotes.include?(uri)
266+
end
267+
251268
def spec_names
252269
if dependency_api_available?
253270
remote_specs.spec_names
@@ -275,6 +292,17 @@ def fetchers
275292
@fetchers ||= remote_fetchers.values.freeze
276293
end
277294

295+
def override_remote_fetchers
296+
@override_remote_fetchers ||= @override_remotes.to_h do |uri|
297+
remote = Source::Rubygems::Remote.new(uri)
298+
[remote, Bundler::Fetcher.new(remote)]
299+
end.freeze
300+
end
301+
302+
def override_fetchers
303+
@override_fetchers ||= override_remote_fetchers.values.freeze
304+
end
305+
278306
def double_check_for(unmet_dependency_names)
279307
return unless dependency_api_available?
280308

@@ -324,6 +352,10 @@ def credless_remotes
324352
remotes.map(&method(:remove_auth))
325353
end
326354

355+
def credless_override_remotes
356+
override_remotes.map(&method(:remove_auth))
357+
end
358+
327359
def cached_gem(spec)
328360
global_cache_path = download_cache_path(spec)
329361
caches << global_cache_path if global_cache_path
@@ -399,6 +431,8 @@ def remote_specs
399431
else
400432
fetch_names(fetchers, nil, idx)
401433
end
434+
435+
fetch_override_specs(idx) if @override_remotes.any? && @allow_remote
402436
end
403437
end
404438

@@ -415,6 +449,46 @@ def fetch_names(fetchers, dependency_names, index)
415449
end
416450
end
417451

452+
# Merge spec from override remotes into +idx+, but only for gems already
453+
# present in the parent source.
454+
def fetch_override_specs(idx)
455+
local_platform = Bundler.local_platform
456+
457+
override_fetchers.each do |fetcher|
458+
filtered_uri = URICredentialsFilter.credential_filtered_uri(fetcher.uri)
459+
begin
460+
Bundler.ui.info "Fetching override gem metadata from #{filtered_uri}", Bundler.ui.debug?
461+
override_index = fetcher.specs_with_retry(dependency_names, self)
462+
Bundler.ui.info "" unless Bundler.ui.debug?
463+
464+
override_index.each do |spec|
465+
unless idx.search([spec.name, spec.version]).any?
466+
Bundler.ui.debug "Skipping #{spec.full_name} from override source #{filtered_uri} (not in parent source)"
467+
next
468+
end
469+
470+
unless spec.installable_on_platform?(local_platform)
471+
Bundler.ui.debug "Skipping #{spec.full_name} from override source #{filtered_uri} (platform #{spec.platform} not compatible with #{local_platform})"
472+
next
473+
end
474+
475+
idx << spec
476+
end
477+
rescue Bundler::Fetcher::AuthenticationRequiredError, Bundler::Fetcher::BadAuthenticationError, Bundler::Fetcher::AuthenticationForbiddenError => e
478+
warn_override_failure(filtered_uri, "requires authentication", e)
479+
rescue Bundler::Fetcher::CertificateFailureError, Bundler::Fetcher::SSLError => e
480+
warn_override_failure(filtered_uri, "has SSL errors", e)
481+
rescue Bundler::Fetcher::FallbackError, Bundler::HTTPError => e
482+
warn_override_failure(filtered_uri, "is unreachable", e)
483+
end
484+
end
485+
end
486+
487+
def warn_override_failure(filtered_uri, reason, error)
488+
Bundler.ui.warn "Override source #{filtered_uri} #{reason}: #{error.message}. Falling back to source compilation."
489+
Bundler.ui.debug "#{error.class}: #{error.message}"
490+
end
491+
418492
def fetch_gem_if_possible(spec, previous_spec = nil)
419493
if spec.remote
420494
fetch_gem(spec, previous_spec)
@@ -460,6 +534,15 @@ def lockfile_remotes
460534
@lockfile_remotes || credless_remotes
461535
end
462536

537+
def lockfile_override_remotes
538+
@lockfile_override_remotes || credless_override_remotes
539+
end
540+
541+
# Combined lookup for download_gem — includes both primary and override fetchers
542+
def all_remote_fetchers
543+
@all_remote_fetchers ||= remote_fetchers.merge(override_remote_fetchers).freeze
544+
end
545+
463546
# Checks if the requested spec exists in the global cache. If it does,
464547
# we copy it to the download path, and if it does not, we download it.
465548
#
@@ -475,7 +558,7 @@ def lockfile_remotes
475558
def download_gem(spec, download_cache_path, previous_spec = nil)
476559
uri = spec.remote.uri
477560
Bundler.ui.confirm("Fetching #{version_message(spec, previous_spec)}")
478-
gem_remote_fetcher = remote_fetchers.fetch(spec.remote).gem_remote_fetcher
561+
gem_remote_fetcher = all_remote_fetchers.fetch(spec.remote).gem_remote_fetcher
479562

480563
Gem.time("Downloaded #{spec.name} in", 0, true) do
481564
Bundler.rubygems.download_gem(spec, uri, download_cache_path, gem_remote_fetcher)

bundler/lib/bundler/source_list.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,11 @@ def add_plugin_source(source, options = {})
5959
add_source_to_list Plugin.source(source).new(options), @plugin_sources
6060
end
6161

62-
def add_global_rubygems_remote(uri)
62+
def add_global_rubygems_remote(uri, overrides: nil)
6363
global_rubygems_source.add_remote(uri)
64+
65+
overrides.each {|override| global_rubygems_source.add_override_remote(override) } if overrides
66+
6467
global_rubygems_source
6568
end
6669

spec/bundler/lockfile_parser_spec.rb

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,69 @@
164164
include_examples "parsing"
165165
end
166166

167+
context "when a GEM source has override remotes" do
168+
let(:lockfile_contents) { <<~L }
169+
GEM
170+
remote: https://rubygems.org/
171+
override: https://build-farm.example.com/
172+
specs:
173+
rake (10.3.2)
174+
175+
PLATFORMS
176+
ruby
177+
178+
DEPENDENCIES
179+
rake
180+
181+
CHECKSUMS
182+
rake (10.3.2) sha256=814828c34f1315d7e7b7e8295184577cc4e969bad6156ac069d02d63f58d82e8
183+
184+
BUNDLED WITH
185+
1.12.0.rc.2
186+
L
187+
188+
before { allow(Bundler::SharedHelpers).to receive(:find_gemfile).and_return(bundled_app("gems.rb")) }
189+
subject { described_class.new(lockfile_contents) }
190+
191+
it "parses the override remote" do
192+
gem_source = subject.sources.find {|source| source.is_a?(Bundler::Source::Rubygems) }
193+
expect(gem_source.override_remotes).to eq [Gem::URI("https://build-farm.example.com/")]
194+
end
195+
196+
it "preserves the primary remote" do
197+
gem_source = subject.sources.find {|source| source.is_a?(Bundler::Source::Rubygems) }
198+
expect(gem_source.remotes.map(&:to_s)).to include("https://rubygems.org/")
199+
end
200+
end
201+
202+
context "when a GEM source has multiple override remotes" do
203+
let(:lockfile_contents) { <<~L }
204+
GEM
205+
remote: https://rubygems.org/
206+
override: https://first.example.com/
207+
override: https://second.example.com/
208+
specs:
209+
rake (10.3.2)
210+
211+
PLATFORMS
212+
ruby
213+
214+
DEPENDENCIES
215+
rake
216+
217+
BUNDLED WITH
218+
1.12.0.rc.2
219+
L
220+
221+
before { allow(Bundler::SharedHelpers).to receive(:find_gemfile).and_return(bundled_app("gems.rb")) }
222+
subject { described_class.new(lockfile_contents) }
223+
224+
it "parses multiple override remotes" do
225+
gem_source = subject.sources.find {|source| source.is_a?(Bundler::Source::Rubygems) }
226+
expect(gem_source.override_remotes.size).to eq 2
227+
end
228+
end
229+
167230
context "when the checksum is urlsafe base64 encoded" do
168231
let(:lockfile_contents) do
169232
super().sub(

0 commit comments

Comments
 (0)