dependabot-github_actions 0.393.0 → 0.394.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,96 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require "dependabot/github_actions/file_updater/workflow_updater"
5
+
6
+ module Dependabot
7
+ module GithubActions
8
+ class FileUpdater < Dependabot::FileUpdaters::Base
9
+ class WorkflowUpdater
10
+ class YamlCommentFinder
11
+ extend T::Sig
12
+
13
+ sig { params(suffix: String).returns(T.nilable(String)) }
14
+ def find(suffix)
15
+ previous_non_whitespace = T.let(nil, T.nilable(String))
16
+ index = 0
17
+
18
+ while index < suffix.length
19
+ character = T.must(suffix[index])
20
+ if quoted_scalar_start?(character, previous_non_whitespace)
21
+ previous_non_whitespace = character
22
+ index = quoted_scalar_end(suffix, index)
23
+ next
24
+ end
25
+
26
+ return comment_with_leading_whitespace(suffix, index) if comment_start?(suffix, index)
27
+
28
+ previous_non_whitespace = character unless character.match?(/\s/)
29
+ index += 1
30
+ end
31
+ nil
32
+ end
33
+
34
+ sig { params(suffix: String).returns(T.nilable(String)) }
35
+ def find_immediate(suffix)
36
+ comment = find(suffix)
37
+ return unless comment
38
+
39
+ comment_start = T.must(suffix.b.index(comment.b))
40
+ prefix = suffix.byteslice(0...comment_start) || ""
41
+ comment if prefix.match?(/\A[ \t]*,?[ \t]*\z/)
42
+ end
43
+
44
+ sig { params(line: String).returns(T.nilable(String)) }
45
+ def find_line(line)
46
+ stripped = line.lstrip
47
+ return stripped if stripped.start_with?("#")
48
+
49
+ find(line)
50
+ end
51
+
52
+ private
53
+
54
+ sig { params(character: String, previous_non_whitespace: T.nilable(String)).returns(T::Boolean) }
55
+ def quoted_scalar_start?(character, previous_non_whitespace)
56
+ return false unless character == "'" || character == '"'
57
+
58
+ previous_non_whitespace.nil? || ":,[{?-".include?(previous_non_whitespace)
59
+ end
60
+
61
+ sig { params(suffix: String, quote_index: Integer).returns(Integer) }
62
+ def quoted_scalar_end(suffix, quote_index)
63
+ quote = T.must(suffix[quote_index])
64
+ index = quote_index + 1
65
+
66
+ while index < suffix.length
67
+ character = T.must(suffix[index])
68
+ if quote == "'" && character == "'" && suffix[index + 1] == "'"
69
+ index += 2
70
+ elsif quote == '"' && character == "\\"
71
+ index += 2
72
+ elsif character == quote
73
+ return index + 1
74
+ else
75
+ index += 1
76
+ end
77
+ end
78
+ suffix.length
79
+ end
80
+
81
+ sig { params(suffix: String, index: Integer).returns(T::Boolean) }
82
+ def comment_start?(suffix, index)
83
+ suffix[index] == "#" && index.positive? && T.must(suffix[index - 1]).match?(/\s/)
84
+ end
85
+
86
+ sig { params(suffix: String, hash_index: Integer).returns(String) }
87
+ def comment_with_leading_whitespace(suffix, hash_index)
88
+ comment_start = hash_index
89
+ comment_start -= 1 while comment_start.positive? && T.must(suffix[comment_start - 1]).match?(/\s/)
90
+ T.must(suffix[comment_start..])
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,222 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "dependabot/github_actions/file_updater"
5
+
6
+ module Dependabot
7
+ module GithubActions
8
+ class FileUpdater < Dependabot::FileUpdaters::Base
9
+ class WorkflowUpdater
10
+ extend T::Sig
11
+
12
+ class SourceUpdate < T::Struct
13
+ const :declaration, WorkflowFile::UsesDeclaration
14
+ const :old_declaration, String
15
+ const :new_declaration, String
16
+ const :old_ref, String
17
+ const :new_ref, String
18
+ end
19
+
20
+ class ContentEdit < T::Struct
21
+ const :start_offset, Integer
22
+ const :end_offset, Integer
23
+ const :replacement, String
24
+ end
25
+
26
+ require_relative "workflow_updater/version_commenter"
27
+ require_relative "workflow_updater/yaml_comment_finder"
28
+ require_relative "workflow_updater/source_comment_locator"
29
+ require_relative "workflow_updater/source_comment_updater"
30
+ require_relative "workflow_updater/flow_sequence_comments"
31
+ require_relative "workflow_updater/source_updater"
32
+
33
+ sig do
34
+ params(
35
+ file: Dependabot::DependencyFile,
36
+ dependency: Dependabot::Dependency,
37
+ commenter: VersionCommenter
38
+ ).void
39
+ end
40
+ def initialize(file:, dependency:, commenter:)
41
+ @file = file
42
+ @dependency = dependency
43
+ @commenter = commenter
44
+ @comment_finder = T.let(YamlCommentFinder.new, YamlCommentFinder)
45
+ end
46
+
47
+ sig { returns(String) }
48
+ def updated_content
49
+ workflow_file = WorkflowFile.new(T.must(file.content))
50
+ source_updates, fallback_pairs = requirement_updates(workflow_file)
51
+
52
+ content = SourceUpdater.new(
53
+ workflow_file: workflow_file,
54
+ updates: source_updates,
55
+ commenter: commenter,
56
+ comment_finder: comment_finder
57
+ ).updated_content
58
+ fallback_pairs.each do |new_req, old_req|
59
+ content = apply_text_update(content, new_req, old_req)
60
+ end
61
+ content
62
+ end
63
+
64
+ private
65
+
66
+ sig { returns(Dependabot::DependencyFile) }
67
+ attr_reader :file
68
+
69
+ sig { returns(Dependabot::Dependency) }
70
+ attr_reader :dependency
71
+
72
+ sig { returns(VersionCommenter) }
73
+ attr_reader :commenter
74
+
75
+ sig { returns(YamlCommentFinder) }
76
+ attr_reader :comment_finder
77
+
78
+ sig do
79
+ params(workflow_file: WorkflowFile).returns(
80
+ [
81
+ T::Array[SourceUpdate],
82
+ T::Array[[Dependabot::DependencyRequirement, Dependabot::DependencyRequirement]]
83
+ ]
84
+ )
85
+ end
86
+ def requirement_updates(workflow_file)
87
+ source_updates = T.let([], T::Array[SourceUpdate])
88
+ fallback_pairs = T.let(
89
+ [],
90
+ T::Array[[Dependabot::DependencyRequirement, Dependabot::DependencyRequirement]]
91
+ )
92
+
93
+ dependency.requirements.zip(T.must(dependency.previous_requirements)).each do |new_req, old_req|
94
+ previous_requirement = T.must(old_req)
95
+ next if new_req.file != file.name || new_req.source == previous_requirement.source
96
+ next unless new_req.source_string("type") == "git"
97
+
98
+ source_update = source_update_for(workflow_file, new_req, previous_requirement)
99
+ source_update ? source_updates << source_update : fallback_pairs << [new_req, previous_requirement]
100
+ end
101
+
102
+ [source_updates, fallback_pairs]
103
+ end
104
+
105
+ sig do
106
+ params(
107
+ workflow_file: WorkflowFile,
108
+ new_req: Dependabot::DependencyRequirement,
109
+ old_req: Dependabot::DependencyRequirement
110
+ ).returns(T.nilable(SourceUpdate))
111
+ end
112
+ def source_update_for(workflow_file, new_req, old_req)
113
+ path = yaml_source_path(old_req)
114
+ return unless path
115
+
116
+ declaration = workflow_file.declaration_at(path)
117
+ return unless declaration
118
+ return unless declaration.value_node && declaration.source_node && declaration.mapping_node
119
+
120
+ old_declaration = T.must(old_req.metadata_string("declaration_string"))
121
+ new_ref = T.must(new_req.source_string("ref"))
122
+ SourceUpdate.new(
123
+ declaration: declaration,
124
+ old_declaration: old_declaration,
125
+ new_declaration: old_declaration.gsub(/@.*+/, "@#{new_ref}"),
126
+ old_ref: T.must(old_req.source_string("ref")),
127
+ new_ref: new_ref
128
+ )
129
+ end
130
+
131
+ sig { params(requirement: Dependabot::DependencyRequirement).returns(T.nilable(WorkflowFile::Path)) }
132
+ def yaml_source_path(requirement)
133
+ metadata = requirement.metadata
134
+ return unless metadata
135
+
136
+ raw_source = metadata[:yaml_source] || metadata["yaml_source"]
137
+ return unless raw_source.is_a?(Hash)
138
+
139
+ raw_path = raw_source[:path] || raw_source["path"]
140
+ return unless raw_path.is_a?(Array)
141
+ return unless raw_path.all? { |part| part.is_a?(String) || part.is_a?(Integer) }
142
+
143
+ path = T.let([], WorkflowFile::Path)
144
+ raw_path.each { |part| path << part }
145
+ path
146
+ end
147
+
148
+ sig do
149
+ params(
150
+ content: String,
151
+ new_req: Dependabot::DependencyRequirement,
152
+ old_req: Dependabot::DependencyRequirement
153
+ ).returns(String)
154
+ end
155
+ def apply_text_update(content, new_req, old_req)
156
+ old_ref = T.must(old_req.source_string("ref"))
157
+ new_ref = T.must(new_req.source_string("ref"))
158
+ old_declaration = T.must(old_req.metadata_string("declaration_string"))
159
+ new_declaration = old_declaration.gsub(/@.*+/, "@#{new_ref}")
160
+ declaration_pattern = uses_declaration_pattern(old_declaration)
161
+
162
+ content.gsub(/#{declaration_pattern}(?<suffix>[^\r\n]*)/) do |match|
163
+ updated_workflow_line(
164
+ match: match,
165
+ comment: comment_finder.find(T.must(Regexp.last_match(:suffix))),
166
+ declaration_pattern: declaration_pattern,
167
+ old_declaration: old_declaration,
168
+ new_declaration: new_declaration,
169
+ old_ref: old_ref,
170
+ new_ref: new_ref
171
+ )
172
+ end
173
+ end
174
+
175
+ sig { params(declaration: String).returns(Regexp) }
176
+ def uses_declaration_pattern(declaration)
177
+ escaped_declaration = Regexp.escape(declaration)
178
+
179
+ /
180
+ (?:^|[ \t{,])
181
+ (?:"uses"|'uses'|uses)[ \t]*:[ \t]*
182
+ (?:"#{escaped_declaration}"|'#{escaped_declaration}'|#{escaped_declaration})
183
+ (?=[ \t]|$|[,}\]])
184
+ /x
185
+ end
186
+
187
+ sig do
188
+ params(
189
+ match: String,
190
+ comment: T.nilable(String),
191
+ declaration_pattern: Regexp,
192
+ old_declaration: String,
193
+ new_declaration: String,
194
+ old_ref: String,
195
+ new_ref: String
196
+ ).returns(String)
197
+ end
198
+ def updated_workflow_line(
199
+ match:,
200
+ comment:,
201
+ declaration_pattern:,
202
+ old_declaration:,
203
+ new_declaration:,
204
+ old_ref:,
205
+ new_ref:
206
+ )
207
+ line_without_comment = comment ? match.delete_suffix(comment) : match
208
+ updated_match = line_without_comment.gsub(declaration_pattern) do |declaration|
209
+ declaration.sub(old_declaration, new_declaration)
210
+ end
211
+
212
+ if comment
213
+ updated_match << (commenter.updated_comment(comment, old_ref, new_ref) || comment)
214
+ elsif (new_comment = commenter.new_comment(old_ref, new_ref))
215
+ updated_match << new_comment
216
+ end
217
+ updated_match
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end
@@ -8,12 +8,15 @@ require "dependabot/file_updaters"
8
8
  require "dependabot/file_updaters/base"
9
9
  require "dependabot/github_actions/constants"
10
10
  require "dependabot/github_actions/lockfile"
11
+ require "dependabot/github_actions/workflow_file"
11
12
 
12
13
  module Dependabot
13
14
  module GithubActions
14
15
  class FileUpdater < Dependabot::FileUpdaters::Base
15
16
  extend T::Sig
16
17
 
18
+ require_relative "file_updater/workflow_updater"
19
+
17
20
  sig { override.returns(T::Array[Dependabot::DependencyFile]) }
18
21
  def updated_dependency_files
19
22
  updated_files = changed_workflow_files.map do |file|
@@ -138,85 +141,24 @@ module Dependabot
138
141
  file.path.delete_prefix("/")
139
142
  end
140
143
 
141
- # rubocop:disable Metrics/AbcSize
142
144
  sig { params(file: Dependabot::DependencyFile).returns(String) }
143
145
  def updated_workflow_file_content(file)
144
- updated_requirement_pairs =
145
- dependency.requirements.zip(T.must(dependency.previous_requirements))
146
- .reject do |new_req, old_req|
147
- next true if new_req.file != file.name
148
-
149
- new_req.source == T.must(old_req).source
150
- end
151
-
152
- updated_content = T.must(file.content)
153
-
154
- updated_requirement_pairs.each do |new_req, old_req|
155
- # TODO: Support updating Docker sources
156
- next unless new_req.source_string("type") == "git"
157
-
158
- old_ref = T.must(T.must(old_req).source_string("ref"))
159
- new_ref = T.must(new_req.source_string("ref"))
160
-
161
- old_declaration = T.must(T.must(old_req).metadata_string("declaration_string"))
162
- new_declaration =
163
- old_declaration
164
- .gsub(/@.*+/, "@#{new_ref}")
165
-
166
- # Replace the old declaration that's preceded by a non-word character (unless it's a hyphen)
167
- # and followed by a whitespace character (comments) or EOL.
168
- # If the declaration is followed by a comment that lists the version associated
169
- # with the SHA source ref, then update the comment to the human-readable new version.
170
- # However, if the comment includes additional text beyond the version, for safety
171
- # we skip updating the comment in case it's a custom note, todo, warning etc of some kind.
172
- # See the related unit tests for examples.
173
- updated_content =
174
- updated_content
175
- .gsub(
176
- /(?<=[^a-zA-Z_-]|"|')#{Regexp.escape(old_declaration)}["']?(?<comment>\s+#.*)?(?=\s|$)/
177
- ) do |match|
178
- comment = Regexp.last_match(:comment)
179
- match.gsub!(old_declaration, new_declaration)
180
- if comment && (updated_comment = updated_version_comment(comment, old_ref, new_ref))
181
- match.gsub!(comment, updated_comment)
182
- end
183
- match
184
- end
185
- end
186
-
187
- updated_content
188
- end
189
- # rubocop:enable Metrics/AbcSize
190
-
191
- sig { params(comment: T.nilable(String), old_ref: String, new_ref: String).returns(T.nilable(String)) }
192
- def updated_version_comment(comment, old_ref, new_ref)
193
- raise "No comment!" unless comment
194
-
195
- comment = comment.rstrip
196
- git_checker = Dependabot::GitCommitChecker.new(dependency: dependency, credentials: credentials)
197
- return unless git_checker.ref_looks_like_commit_sha?(old_ref)
198
-
199
- previous_version_tags = git_checker.most_specific_version_tags_for_sha(old_ref)
200
- return unless previous_version_tags.any? # There's no tag for this commit
201
-
202
- # Use the most specific (longest) matching version to avoid partial replacements.
203
- # Tags are sorted ascending, so ["v1", "v1.0", "v1.0.1"] maps to ["1", "1.0", "1.0.1"].
204
- # Without this, "1" could match the end of "v1.0.1", causing gsub("1", "1.1") => "v1.1.0.1.1".
205
- previous_version = previous_version_tags.map { |tag| version_class.new(tag).to_s }
206
- .select { |version| comment.end_with?(version) }
207
- .max_by(&:length)
208
- return unless previous_version
209
-
210
- new_version_tag = git_checker.most_specific_version_tag_for_sha(new_ref)
211
- return unless new_version_tag
212
-
213
- new_version = version_class.new(new_version_tag).to_s
214
- comment.gsub(previous_version, new_version)
215
- end
216
-
217
- sig { returns(T.class_of(Dependabot::GithubActions::Version)) }
218
- def version_class
219
- GithubActions::Version
146
+ WorkflowUpdater.new(
147
+ file: file,
148
+ dependency: dependency,
149
+ commenter: version_commenter
150
+ ).updated_content
151
+ end
152
+
153
+ sig { returns(WorkflowUpdater::VersionCommenter) }
154
+ def version_commenter
155
+ @version_commenter ||= T.let(
156
+ WorkflowUpdater::VersionCommenter.new(
157
+ dependency: dependency,
158
+ credentials: credentials
159
+ ),
160
+ T.nilable(WorkflowUpdater::VersionCommenter)
161
+ )
220
162
  end
221
163
  end
222
164
  end
@@ -59,7 +59,7 @@ module Dependabot
59
59
 
60
60
  sig { override.returns(T::Boolean) }
61
61
  def up_to_date?
62
- super && !onboarded_requirements_changed?
62
+ super && !relevant_requirements_changed?
63
63
  end
64
64
 
65
65
  sig { override.returns(T::Array[Dependabot::DependencyRequirement]) }
@@ -82,16 +82,36 @@ module Dependabot
82
82
  return true if super
83
83
  return false unless requirements_to_unlock == :own
84
84
 
85
- onboarded_requirements_changed?
85
+ relevant_requirements_changed?
86
86
  end
87
87
 
88
88
  sig { returns(T::Boolean) }
89
- def onboarded_requirements_changed?
89
+ def relevant_requirements_changed?
90
90
  dependency.requirements.zip(updated_requirements).any? do |current, updated|
91
- onboarded_requirement?(current) && current != updated
91
+ next false unless updated
92
+
93
+ current != updated && (onboarded_requirement?(current) || tag_to_sha_requirement?(current, updated))
92
94
  end
93
95
  end
94
96
 
97
+ sig do
98
+ params(
99
+ current: Dependabot::DependencyRequirement,
100
+ updated: Dependabot::DependencyRequirement
101
+ ).returns(T::Boolean)
102
+ end
103
+ def tag_to_sha_requirement?(current, updated)
104
+ return false unless pin_to_sha?
105
+
106
+ current_ref = current.source_string("ref")
107
+ updated_ref = updated.source_string("ref")
108
+ return false unless current_ref && updated_ref
109
+ return false unless version_class.correct?(current_ref)
110
+ return false if Dependabot::GithubActions::Version.path_based?(current_ref)
111
+
112
+ git_commit_checker.ref_looks_like_commit_sha?(updated_ref)
113
+ end
114
+
95
115
  # A requirement is "onboarded" when the repo carries an `actions.lock` that is
96
116
  # authoritative for the requirement's workflow. Only onboarded requirements get
97
117
  # per-source precision selection; everything else flows through the combined
@@ -246,11 +266,12 @@ module Dependabot
246
266
  # TODO: Support Docker sources
247
267
  return unless git_commit_checker.git_dependency?
248
268
 
249
- git_source = source && symbolize_source(source)
269
+ git_source = git_source_for(source)
270
+ current_ref = git_source&.fetch(:ref, nil)
250
271
  finder = onboarded ? latest_version_finder_for(git_source) : T.must(latest_version_finder)
251
272
 
252
273
  if vulnerable? && (new_tag = finder.lowest_security_fix_release)
253
- return new_tag.fetch(:tag)
274
+ return ref_for_release(new_tag, current_ref: current_ref)
254
275
  end
255
276
 
256
277
  source_git_commit_checker = git_helper.git_commit_checker_for(git_source)
@@ -258,7 +279,7 @@ module Dependabot
258
279
  # Return the git tag if updating a pinned version
259
280
  if source_git_commit_checker.pinned_ref_looks_like_version? &&
260
281
  (new_tag = finder.latest_version_tag_respecting_cooldown)
261
- return new_tag.fetch(:tag)
282
+ return ref_for_release(new_tag, current_ref: current_ref)
262
283
  end
263
284
 
264
285
  # Return the pinned git commit if one is available
@@ -271,6 +292,34 @@ module Dependabot
271
292
  nil
272
293
  end
273
294
 
295
+ sig { returns(T::Boolean) }
296
+ def pin_to_sha?
297
+ T.cast(options.fetch(:github_actions_pin_to_sha, false), T::Boolean)
298
+ end
299
+
300
+ sig do
301
+ params(
302
+ release: T::Hash[Symbol, Object],
303
+ current_ref: T.nilable(String)
304
+ ).returns(String)
305
+ end
306
+ def ref_for_release(release, current_ref:)
307
+ return T.cast(release.fetch(:tag), String) unless pin_to_sha?
308
+ if current_ref && Dependabot::GithubActions::Version.path_based?(current_ref)
309
+ return T.cast(release.fetch(:tag), String)
310
+ end
311
+
312
+ T.cast(release.fetch(:commit_sha), String)
313
+ end
314
+
315
+ sig do
316
+ params(source: T.nilable(Dependabot::DependencyRequirement::ObjectHash))
317
+ .returns(T.nilable(GitSource))
318
+ end
319
+ def git_source_for(source)
320
+ source && symbolize_source(source)
321
+ end
322
+
274
323
  sig do
275
324
  params(source: Dependabot::DependencyRequirement::ObjectHash)
276
325
  .returns(GitSource)