dependabot-bundler 0.138.2 → 0.138.7

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Functions
4
+ class LockfileUpdater
5
+ RETRYABLE_ERRORS = [Bundler::HTTPError].freeze
6
+ GEM_NOT_FOUND_ERROR_REGEX =
7
+ /
8
+ locked\sto\s(?<name>[^\s]+)\s\(|
9
+ not\sfind\s(?<name>[^\s]+)-\d|
10
+ has\s(?<name>[^\s]+)\slocked\sat
11
+ /x.freeze
12
+
13
+ def initialize(gemfile_name:, lockfile_name:, dependencies:)
14
+ @gemfile_name = gemfile_name
15
+ @lockfile_name = lockfile_name
16
+ @dependencies = dependencies
17
+ end
18
+
19
+ def run
20
+ generate_lockfile
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :gemfile_name, :lockfile_name, :dependencies
26
+
27
+ def generate_lockfile
28
+ dependencies_to_unlock = dependencies.map { |d| d.fetch("name") }
29
+
30
+ begin
31
+ definition = build_definition(dependencies_to_unlock)
32
+
33
+ old_reqs = lock_deps_being_updated_to_exact_versions(definition)
34
+
35
+ definition.resolve_remotely!
36
+
37
+ old_reqs.each do |dep_name, old_req|
38
+ d_dep = definition.dependencies.find { |d| d.name == dep_name }
39
+ if old_req == :none then definition.dependencies.delete(d_dep)
40
+ else
41
+ d_dep.instance_variable_set(:@requirement, old_req)
42
+ end
43
+ end
44
+
45
+ cache_vendored_gems(definition) if Bundler.app_cache.exist?
46
+
47
+ definition.to_lock
48
+ rescue Bundler::GemNotFound => e
49
+ unlock_yanked_gem(dependencies_to_unlock, e) && retry
50
+ rescue Bundler::VersionConflict => e
51
+ unlock_blocking_subdeps(dependencies_to_unlock, e) && retry
52
+ rescue *RETRYABLE_ERRORS
53
+ raise if @retrying
54
+
55
+ @retrying = true
56
+ sleep(rand(1.0..5.0))
57
+ retry
58
+ end
59
+ end
60
+
61
+ def cache_vendored_gems(definition)
62
+ # Dependencies that have been unlocked for the update (including
63
+ # sub-dependencies)
64
+ unlocked_gems = definition.instance_variable_get(:@unlock).
65
+ fetch(:gems).reject { |gem| __keep_on_prune?(gem) }
66
+ bundler_opts = {
67
+ cache_all: true,
68
+ cache_all_platforms: true,
69
+ no_prune: true
70
+ }
71
+
72
+ Bundler.settings.temporary(**bundler_opts) do
73
+ # Fetch and cache gems on all platforms without pruning
74
+ Bundler::Runtime.new(nil, definition).cache
75
+
76
+ # Only prune unlocked gems (the original implementation is in
77
+ # Bundler::Runtime)
78
+ cache_path = Bundler.app_cache
79
+ resolve = definition.resolve
80
+ prune_gem_cache(resolve, cache_path, unlocked_gems)
81
+ prune_git_and_path_cache(resolve, cache_path)
82
+ end
83
+ end
84
+
85
+ # This is not officially supported and may be removed without notice.
86
+ def __keep_on_prune?(spec_name)
87
+ unless (specs = Bundler.settings[:persistent_gems_after_clean])
88
+ return false
89
+ end
90
+
91
+ specs.include?(spec_name)
92
+ end
93
+
94
+ # Copied from Bundler::Runtime: Modified to only prune gems that have
95
+ # been unlocked
96
+ def prune_gem_cache(resolve, cache_path, unlocked_gems)
97
+ cached_gems = Dir["#{cache_path}/*.gem"]
98
+
99
+ outdated_gems = cached_gems.reject do |path|
100
+ spec = Bundler.rubygems.spec_from_gem path
101
+
102
+ !unlocked_gems.include?(spec.name) || resolve.any? do |s|
103
+ s.name == spec.name && s.version == spec.version &&
104
+ !s.source.is_a?(Bundler::Source::Git)
105
+ end
106
+ end
107
+
108
+ return unless outdated_gems.any?
109
+
110
+ outdated_gems.each do |path|
111
+ File.delete(path)
112
+ end
113
+ end
114
+
115
+ # Copied from Bundler::Runtime
116
+ def prune_git_and_path_cache(resolve, cache_path)
117
+ cached_git_and_path = Dir["#{cache_path}/*/.bundlecache"]
118
+
119
+ outdated_git_and_path = cached_git_and_path.reject do |path|
120
+ name = File.basename(File.dirname(path))
121
+
122
+ resolve.any? do |s|
123
+ s.source.respond_to?(:app_cache_dirname) &&
124
+ s.source.app_cache_dirname == name
125
+ end
126
+ end
127
+
128
+ return unless outdated_git_and_path.any?
129
+
130
+ outdated_git_and_path.each do |path|
131
+ path = File.dirname(path)
132
+ FileUtils.rm_rf(path)
133
+ end
134
+ end
135
+
136
+ def unlock_yanked_gem(dependencies_to_unlock, error)
137
+ raise unless error.message.match?(GEM_NOT_FOUND_ERROR_REGEX)
138
+
139
+ gem_name = error.message.match(GEM_NOT_FOUND_ERROR_REGEX).
140
+ named_captures["name"]
141
+ raise if dependencies_to_unlock.include?(gem_name)
142
+
143
+ dependencies_to_unlock << gem_name
144
+ end
145
+
146
+ # rubocop:disable Metrics/PerceivedComplexity
147
+ def unlock_blocking_subdeps(dependencies_to_unlock, error)
148
+ all_deps = Bundler::LockfileParser.new(lockfile).
149
+ specs.map(&:name).map(&:to_s)
150
+ top_level = build_definition([]).dependencies.
151
+ map(&:name).map(&:to_s)
152
+ allowed_new_unlocks = all_deps - top_level - dependencies_to_unlock
153
+
154
+ raise if allowed_new_unlocks.none?
155
+
156
+ # Unlock any sub-dependencies that Bundler reports caused the
157
+ # conflict
158
+ potentials_deps =
159
+ error.cause.conflicts.values.
160
+ flat_map(&:requirement_trees).
161
+ map do |tree|
162
+ tree.find { |req| allowed_new_unlocks.include?(req.name) }
163
+ end.compact.map(&:name)
164
+
165
+ # If there are specific dependencies we can unlock, unlock them
166
+ return dependencies_to_unlock.append(*potentials_deps) if potentials_deps.any?
167
+
168
+ # Fall back to unlocking *all* sub-dependencies. This is required
169
+ # because Bundler's VersionConflict objects don't include enough
170
+ # information to chart the full path through all conflicts unwound
171
+ dependencies_to_unlock.append(*allowed_new_unlocks)
172
+ end
173
+ # rubocop:enable Metrics/PerceivedComplexity
174
+
175
+ def build_definition(dependencies_to_unlock)
176
+ defn = Bundler::Definition.build(
177
+ gemfile_name,
178
+ lockfile_name,
179
+ gems: dependencies_to_unlock
180
+ )
181
+
182
+ # Bundler unlocks the sub-dependencies of gems it is passed even
183
+ # if those sub-deps are top-level dependencies. We only want true
184
+ # subdeps unlocked, like they were in the UpdateChecker, so we
185
+ # mutate the unlocked gems array.
186
+ unlocked = defn.instance_variable_get(:@unlock).fetch(:gems)
187
+ must_not_unlock = defn.dependencies.map(&:name).map(&:to_s) -
188
+ dependencies_to_unlock
189
+ unlocked.reject! { |n| must_not_unlock.include?(n) }
190
+
191
+ defn
192
+ end
193
+
194
+ def lock_deps_being_updated_to_exact_versions(definition)
195
+ dependencies.each_with_object({}) do |dep, old_reqs|
196
+ defn_dep = definition.dependencies.find do |d|
197
+ d.name == dep.fetch("name")
198
+ end
199
+
200
+ if defn_dep.nil?
201
+ definition.dependencies <<
202
+ Bundler::Dependency.new(dep.fetch("name"), dep.fetch("version"))
203
+ old_reqs[dep.fetch("name")] = :none
204
+ elsif git_dependency?(dep) &&
205
+ defn_dep.source.is_a?(Bundler::Source::Git)
206
+ defn_dep.source.unlock!
207
+ elsif Gem::Version.correct?(dep.fetch("version"))
208
+ new_req = Gem::Requirement.create("= #{dep.fetch('version')}")
209
+ old_reqs[dep.fetch("name")] = defn_dep.requirement
210
+ defn_dep.instance_variable_set(:@requirement, new_req)
211
+ end
212
+ end
213
+ end
214
+
215
+ def git_dependency?(dep)
216
+ sources = dep.fetch("requirements").map { |r| r.fetch("source") }
217
+ sources.all? { |s| s&.fetch("type", nil) == "git" }
218
+ end
219
+
220
+ def lockfile
221
+ @lockfile ||= File.read(lockfile_name)
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,140 @@
1
+ module Functions
2
+ class VersionResolver
3
+ GEM_NOT_FOUND_ERROR_REGEX = /locked to (?<name>[^\s]+) \(/.freeze
4
+
5
+ attr_reader :dependency_name, :dependency_requirements,
6
+ :gemfile_name, :lockfile_name
7
+
8
+ def initialize(dependency_name:, dependency_requirements:,
9
+ gemfile_name:, lockfile_name:)
10
+ @dependency_name = dependency_name
11
+ @dependency_requirements = dependency_requirements
12
+ @gemfile_name = gemfile_name
13
+ @lockfile_name = lockfile_name
14
+ end
15
+
16
+ def version_details
17
+ dep = dependency_from_definition
18
+
19
+ # If the dependency wasn't found in the definition, but *is*
20
+ # included in a gemspec, it's because the Gemfile didn't import
21
+ # the gemspec. This is unusual, but the correct behaviour if/when
22
+ # it happens is to behave as if the repo was gemspec-only.
23
+ if dep.nil? && dependency_requirements.any?
24
+ return "latest"
25
+ end
26
+
27
+ # Otherwise, if the dependency wasn't found it's because it is a
28
+ # subdependency that was removed when attempting to update it.
29
+ return nil if dep.nil?
30
+
31
+ # If the dependency is Bundler itself then we can't trust the
32
+ # version that has been returned (it's the version Dependabot is
33
+ # running on, rather than the true latest resolvable version).
34
+ return nil if dep.name == "bundler"
35
+
36
+ details = {
37
+ version: dep.version,
38
+ ruby_version: ruby_version,
39
+ fetcher: fetcher_class(dep)
40
+ }
41
+ if dep.source.instance_of?(::Bundler::Source::Git)
42
+ details[:commit_sha] = dep.source.revision
43
+ end
44
+ details
45
+ end
46
+
47
+ private
48
+
49
+ # rubocop:disable Metrics/PerceivedComplexity
50
+ def dependency_from_definition(unlock_subdependencies: true)
51
+ dependencies_to_unlock = [dependency_name]
52
+ dependencies_to_unlock += subdependencies if unlock_subdependencies
53
+ begin
54
+ definition = build_definition(dependencies_to_unlock)
55
+ definition.resolve_remotely!
56
+ rescue ::Bundler::GemNotFound => e
57
+ unlock_yanked_gem(dependencies_to_unlock, e) && retry
58
+ rescue ::Bundler::HTTPError => e
59
+ # Retry network errors
60
+ # Note: in_a_native_bundler_context will also retry `Bundler::HTTPError` errors
61
+ # up to three times meaning we'll end up retrying this error up to six times
62
+ # TODO: Could we get rid of this retry logic and only rely on
63
+ # SharedBundlerHelpers.in_a_native_bundler_context
64
+ attempt ||= 1
65
+ attempt += 1
66
+ raise if attempt > 3 || !e.message.include?("Network error")
67
+
68
+ retry
69
+ end
70
+
71
+ dep = definition.resolve.find { |d| d.name == dependency_name }
72
+ return dep if dep
73
+ return if dependency_requirements.any? || !unlock_subdependencies
74
+
75
+ # If no definition was found and we're updating a sub-dependency,
76
+ # try again but without unlocking any other sub-dependencies
77
+ dependency_from_definition(unlock_subdependencies: false)
78
+ end
79
+ # rubocop:enable Metrics/PerceivedComplexity
80
+
81
+ def subdependencies
82
+ # If there's no lockfile we don't need to worry about
83
+ # subdependencies
84
+ return [] unless lockfile
85
+
86
+ all_deps = ::Bundler::LockfileParser.new(lockfile).
87
+ specs.map(&:name).map(&:to_s).uniq
88
+ top_level = build_definition([]).dependencies.
89
+ map(&:name).map(&:to_s)
90
+
91
+ all_deps - top_level
92
+ end
93
+
94
+ def build_definition(dependencies_to_unlock)
95
+ # Note: we lock shared dependencies to avoid any top-level
96
+ # dependencies getting unlocked (which would happen if they were
97
+ # also subdependencies of the dependency being unlocked)
98
+ ::Bundler::Definition.build(
99
+ gemfile_name,
100
+ lockfile_name,
101
+ gems: dependencies_to_unlock,
102
+ lock_shared_dependencies: true
103
+ )
104
+ end
105
+
106
+ def unlock_yanked_gem(dependencies_to_unlock, error)
107
+ raise unless error.message.match?(GEM_NOT_FOUND_ERROR_REGEX)
108
+
109
+ gem_name = error.message.match(GEM_NOT_FOUND_ERROR_REGEX).
110
+ named_captures["name"]
111
+ raise if dependencies_to_unlock.include?(gem_name)
112
+
113
+ dependencies_to_unlock << gem_name
114
+ end
115
+
116
+ def lockfile
117
+ return @lockfile if defined?(@lockfile)
118
+
119
+ @lockfile =
120
+ begin
121
+ return unless lockfile_name
122
+ return unless File.exist?(lockfile_name)
123
+
124
+ File.read(lockfile_name)
125
+ end
126
+ end
127
+
128
+ def fetcher_class(dep)
129
+ return unless dep.source.is_a?(::Bundler::Source::Rubygems)
130
+
131
+ dep.source.fetchers.first.fetchers.first.class.to_s
132
+ end
133
+
134
+ def ruby_version
135
+ return nil unless gemfile_name
136
+
137
+ @ruby_version ||= build_definition([]).ruby_version&.gem_version
138
+ end
139
+ end
140
+ end
@@ -8,11 +8,11 @@ module BundlerDefinitionRubyVersionPatch
8
8
  if ruby_version
9
9
  requested_version = ruby_version.to_gem_version_with_patchlevel
10
10
  sources.metadata_source.specs <<
11
- Gem::Specification.new("ruby\0", requested_version)
11
+ Gem::Specification.new("Ruby\0", requested_version)
12
12
  end
13
13
 
14
14
  sources.metadata_source.specs <<
15
- Gem::Specification.new("ruby\0", "2.5.3p105")
15
+ Gem::Specification.new("Ruby\0", "2.5.3p105")
16
16
  end
17
17
  end
18
18
  end
data/helpers/v2/run.rb CHANGED
@@ -2,7 +2,7 @@ require "bundler"
2
2
  require "json"
3
3
 
4
4
  $LOAD_PATH.unshift(File.expand_path("./lib", __dir__))
5
- $LOAD_PATH.unshift(File.expand_path("../v1/monkey_patches", __dir__))
5
+ $LOAD_PATH.unshift(File.expand_path("./monkey_patches", __dir__))
6
6
 
7
7
  # Bundler monkey patches
8
8
  require "definition_ruby_version_patch"
@@ -11,11 +11,25 @@ require "git_source_patch"
11
11
 
12
12
  require "functions"
13
13
 
14
+ MIN_BUNDLER_VERSION = "2.0.0"
15
+
16
+ def validate_bundler_version!
17
+ return true if correct_bundler_version?
18
+
19
+ raise StandardError, "Called with Bundler '#{Bundler::VERSION}', expected >= '#{MIN_BUNDLER_VERSION}'"
20
+ end
21
+
22
+ def correct_bundler_version?
23
+ Gem::Version.new(Bundler::VERSION) >= Gem::Version.new(MIN_BUNDLER_VERSION)
24
+ end
25
+
14
26
  def output(obj)
15
27
  print JSON.dump(obj)
16
28
  end
17
29
 
18
30
  begin
31
+ validate_bundler_version!
32
+
19
33
  request = JSON.parse($stdin.read)
20
34
 
21
35
  function = request["function"]
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "native_spec_helper"
4
+ require "shared_contexts"
5
+
6
+ RSpec.describe Functions::ConflictingDependencyResolver do
7
+ include_context "in a temporary bundler directory"
8
+
9
+ let(:conflicting_dependency_resolver) do
10
+ described_class.new(
11
+ dependency_name: dependency_name,
12
+ target_version: target_version,
13
+ lockfile_name: "Gemfile.lock"
14
+ )
15
+ end
16
+
17
+ let(:dependency_name) { "dummy-pkg-a" }
18
+ let(:target_version) { "2.0.0" }
19
+
20
+ let(:project_name) { "blocked_by_subdep" }
21
+
22
+ describe "#conflicting_dependencies" do
23
+ subject(:conflicting_dependencies) do
24
+ in_tmp_folder { conflicting_dependency_resolver.conflicting_dependencies }
25
+ end
26
+
27
+ it "returns a list of dependencies that block the update" do
28
+ expect(conflicting_dependencies).to eq(
29
+ [{
30
+ "explanation" => "dummy-pkg-b (1.0.0) requires dummy-pkg-a (< 2.0.0)",
31
+ "name" => "dummy-pkg-b",
32
+ "version" => "1.0.0",
33
+ "requirement" => "< 2.0.0"
34
+ }]
35
+ )
36
+ end
37
+
38
+ context "for nested transitive dependencies" do
39
+ let(:project_name) { "transitive_blocking" }
40
+ let(:dependency_name) { "activesupport" }
41
+ let(:target_version) { "6.0.0" }
42
+
43
+ it "returns a list of dependencies that block the update" do
44
+ expect(conflicting_dependencies).to match_array(
45
+ [
46
+ {
47
+ "explanation" => "rails (5.2.0) requires activesupport (= 5.2.0)",
48
+ "name" => "rails",
49
+ "requirement" => "= 5.2.0",
50
+ "version" => "5.2.0"
51
+ },
52
+ {
53
+ "explanation" => "rails (5.2.0) requires activesupport (= 5.2.0) via actionpack (5.2.0)",
54
+ "name" => "actionpack",
55
+ "version" => "5.2.0",
56
+ "requirement" => "= 5.2.0"
57
+ },
58
+ {
59
+ "explanation" => "rails (5.2.0) requires activesupport (= 5.2.0) via actionview (5.2.0)",
60
+ "name" => "actionview",
61
+ "version" => "5.2.0",
62
+ "requirement" => "= 5.2.0"
63
+ },
64
+ {
65
+ "explanation" => "rails (5.2.0) requires activesupport (= 5.2.0) via activejob (5.2.0)",
66
+ "name" => "activejob",
67
+ "version" => "5.2.0",
68
+ "requirement" => "= 5.2.0"
69
+ },
70
+ {
71
+ "explanation" => "rails (5.2.0) requires activesupport (= 5.2.0) via activemodel (5.2.0)",
72
+ "name" => "activemodel",
73
+ "version" => "5.2.0",
74
+ "requirement" => "= 5.2.0"
75
+ },
76
+ {
77
+ "explanation" => "rails (5.2.0) requires activesupport (= 5.2.0) via activerecord (5.2.0)",
78
+ "name" => "activerecord",
79
+ "version" => "5.2.0",
80
+ "requirement" => "= 5.2.0"
81
+ },
82
+ {
83
+ "explanation" => "rails (5.2.0) requires activesupport (= 5.2.0) via railties (5.2.0)",
84
+ "name" => "railties",
85
+ "version" => "5.2.0",
86
+ "requirement" => "= 5.2.0"
87
+ }
88
+ ]
89
+ )
90
+ end
91
+ end
92
+
93
+ context "with multiple blocking dependencies" do
94
+ let(:dependency_name) { "activesupport" }
95
+ let(:current_version) { "5.0.0" }
96
+ let(:target_version) { "6.0.0" }
97
+ let(:project_name) { "multiple_blocking" }
98
+
99
+ it "returns all of the blocking dependencies" do
100
+ expect(conflicting_dependencies).to match_array(
101
+ [
102
+ {
103
+ "explanation" => "actionmailer (5.0.0) requires activesupport (= 5.0.0) via actionpack (5.0.0)",
104
+ "name" => "actionpack",
105
+ "version" => "5.0.0",
106
+ "requirement" => "= 5.0.0"
107
+ },
108
+ {
109
+ "explanation" => "actionview (5.0.0) requires activesupport (= 5.0.0)",
110
+ "name" => "actionview",
111
+ "version" => "5.0.0",
112
+ "requirement" => "= 5.0.0"
113
+ },
114
+ {
115
+ "explanation" => "actionmailer (5.0.0) requires activesupport (= 5.0.0) via activejob (5.0.0)",
116
+ "name" => "activejob",
117
+ "version" => "5.0.0",
118
+ "requirement" => "= 5.0.0"
119
+ }
120
+ ]
121
+ )
122
+ end
123
+ end
124
+
125
+ context "without any blocking dependencies" do
126
+ let(:target_version) { "1.0.0" }
127
+
128
+ it "returns an empty list" do
129
+ expect(conflicting_dependencies).to eq([])
130
+ end
131
+ end
132
+ end
133
+ end