dependabot-github_actions 0.392.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
@@ -1,4 +1,4 @@
1
- # typed: strict
1
+ # typed: strong
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "json"
@@ -78,14 +78,16 @@ module Dependabot
78
78
  # If the dependency is pinned to a tag that looks like a version then
79
79
  # we want to update that tag.
80
80
  if git_commit_checker.pinned_ref_looks_like_version? && latest_version_tag
81
- latest_version = latest_version_tag&.fetch(:version)
81
+ latest_version = tag_version(T.must(latest_version_tag))
82
+ return unless latest_version.is_a?(Dependabot::Version)
82
83
  return current_version if shortened_semver_eq?(dependency.version, latest_version.to_s)
83
84
 
84
85
  return latest_version
85
86
  end
86
87
 
87
88
  if git_commit_checker.pinned_ref_looks_like_commit_sha? && latest_version_tag
88
- latest_version = latest_version_tag&.fetch(:version)
89
+ latest_version = tag_version(T.must(latest_version_tag))
90
+ return unless latest_version.is_a?(Dependabot::Version)
89
91
  return latest_commit_for_pinned_ref unless git_commit_checker.local_tag_for_pinned_sha
90
92
 
91
93
  return latest_version
@@ -97,7 +99,7 @@ module Dependabot
97
99
  end
98
100
  # rubocop:enable Metrics/PerceivedComplexity
99
101
 
100
- sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) }
102
+ sig { returns(T.nilable(T::Hash[Symbol, Object])) }
101
103
  def lowest_security_fix_version_tag
102
104
  # TODO: Support Docker sources
103
105
  return unless git_dependency?
@@ -113,25 +115,27 @@ module Dependabot
113
115
  find_lowest_secure_version(tags)
114
116
  end
115
117
  end,
116
- T.nilable(T::Hash[Symbol, String])
118
+ T.nilable(T::Hash[Symbol, Object])
117
119
  )
118
120
  end
119
121
 
120
- sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) }
122
+ sig { returns(T.nilable(T::Hash[Symbol, Object])) }
121
123
  def latest_version_tag
122
124
  @latest_version_tag ||= T.let(
123
125
  begin
124
126
  return git_commit_checker.local_tag_for_latest_version if dependency.version.nil?
125
127
 
126
128
  ref = git_commit_checker.local_ref_for_latest_version_matching_existing_precision
127
- return ref if ref && ref.fetch(:version) > current_version
129
+ return ref if ref&.version && T.must(ref.version) > current_version
128
130
 
129
131
  lower_precision_ref = git_commit_checker.local_ref_for_latest_version_lower_precision
130
- return ref if ref&.fetch(:version) == current_version
132
+ # Keep the matching-precision ref when no newer lower-precision tag exists
133
+ return ref if ref&.version == current_version &&
134
+ (lower_precision_ref.nil? || T.must(lower_precision_ref.version) <= current_version)
131
135
 
132
136
  lower_precision_ref
133
137
  end,
134
- T.nilable(T::Hash[Symbol, T.untyped])
138
+ T.nilable(T::Hash[Symbol, Object])
135
139
  )
136
140
  end
137
141
 
@@ -159,9 +163,7 @@ module Dependabot
159
163
  []
160
164
  end
161
165
 
162
- sig do
163
- returns(T::Array[T::Hash[Symbol, T.untyped]])
164
- end
166
+ sig { returns(T::Array[T::Hash[Symbol, Object]]) }
165
167
  def allowed_version_tags_with_release_dates
166
168
  allowed_version_tags_hashes = git_commit_checker.local_tags_for_allowed_versions
167
169
  tag_to_release_date = T.let({}, T::Hash[String, T.nilable(String)])
@@ -173,14 +175,14 @@ module Dependabot
173
175
 
174
176
  # Combine version info with release dates and sort by version descending
175
177
  result = allowed_version_tags_hashes.map do |tag_hash|
176
- tag_name = tag_hash.fetch(:tag)
178
+ tag_name = tag_name(tag_hash)
177
179
  tag_hash.merge(
178
180
  release_date: tag_to_release_date[tag_name]
179
181
  )
180
182
  end
181
183
 
182
184
  # Sort by version descending (newest first)
183
- result.sort_by { |tag_hash| tag_hash.fetch(:version) }.reverse
185
+ result.sort_by { |tag_hash| T.cast(tag_hash.fetch(:version), Gem::Version) }.reverse
184
186
  end
185
187
 
186
188
  private
@@ -244,23 +246,39 @@ module Dependabot
244
246
  end
245
247
 
246
248
  sig do
247
- params(tags: T::Array[T::Hash[Symbol, T.untyped]]).returns(T.nilable(T::Hash[Symbol, T.untyped]))
249
+ params(tags: T::Array[T::Hash[Symbol, Object]]).returns(T.nilable(T::Hash[Symbol, Object]))
248
250
  end
249
251
  def find_lowest_secure_version(tags)
250
- relevant_tags = Dependabot::UpdateCheckers::VersionFilters
251
- .filter_vulnerable_versions(tags, security_advisories)
252
+ relevant_tags = tags.reject do |tag|
253
+ security_advisories.any? { |advisory| advisory.vulnerable?(T.must(tag_version(tag))) }
254
+ end
252
255
 
253
256
  relevant_tags = filter_lower_tags(relevant_tags)
254
- relevant_tags.min_by { |tag| tag.fetch(:version) }
257
+ relevant_tags.min_by { |tag| T.must(tag_version(tag)) }
255
258
  end
256
259
 
257
260
  sig do
258
- params(tags_array: T::Array[T::Hash[Symbol, T.untyped]]).returns(T::Array[T::Hash[Symbol, T.untyped]])
261
+ params(tags_array: T::Array[T::Hash[Symbol, Object]]).returns(T::Array[T::Hash[Symbol, Object]])
259
262
  end
260
263
  def filter_lower_tags(tags_array)
261
264
  return tags_array unless current_version
262
265
 
263
- tags_array.select { |tag| tag.fetch(:version) > current_version }
266
+ tags_array.select { |tag| T.must(tag_version(tag)) > current_version }
267
+ end
268
+
269
+ sig { params(tag: T::Hash[Symbol, Object]).returns(String) }
270
+ def tag_name(tag)
271
+ return tag.tag if tag.is_a?(Dependabot::GitTagDetails)
272
+
273
+ T.cast(tag.fetch(:tag), String)
274
+ end
275
+
276
+ sig { params(tag: T::Hash[Symbol, Object]).returns(T.nilable(Gem::Version)) }
277
+ def tag_version(tag)
278
+ return tag.version if tag.is_a?(Dependabot::GitTagDetails)
279
+
280
+ version = tag[:version]
281
+ version if version.is_a?(Gem::Version)
264
282
  end
265
283
 
266
284
  sig { returns(T::Boolean) }
@@ -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)