kettle-dev 2.3.9 → 2.3.10
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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +30 -1
- data/README.md +3 -1
- data/exe/kettle-gha-sha-pins +5 -31
- data/lib/kettle/dev/gha_sha_pins_cli.rb +12 -1357
- data/lib/kettle/dev/version.rb +1 -1
- data.tar.gz.sig +0 -0
- metadata +27 -7
- metadata.gz.sig +0 -0
|
@@ -1,1364 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
|
-
require "fileutils"
|
|
5
|
-
require "net/http"
|
|
6
|
-
require "open3"
|
|
7
|
-
require "optparse"
|
|
8
|
-
require "pathname"
|
|
9
|
-
require "time"
|
|
10
|
-
require "uri"
|
|
11
|
-
|
|
12
|
-
require "psych"
|
|
13
|
-
require "ruby-progressbar"
|
|
14
|
-
require_relative "cache_progress"
|
|
15
|
-
|
|
16
3
|
module Kettle
|
|
17
4
|
module Dev
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
DEFAULT_CACHE_TTL_SECONDS = 24 * 60 * 60
|
|
31
|
-
DEFAULT_HTTP_OPEN_TIMEOUT_SECONDS = 5
|
|
32
|
-
DEFAULT_HTTP_READ_TIMEOUT_SECONDS = 10
|
|
33
|
-
DEFAULT_HTTP_REFRESH_TIMEOUT_SECONDS = 20
|
|
34
|
-
VALID_UPGRADE_LEVELS = %w[major minor patch].freeze
|
|
35
|
-
VERSION_COMMENT_SUFFIX_RE = /\A\s+#\s*v?(?<version>\d+(?:\.\d+\.\d+(?:[-.]?[0-9A-Za-z.-]+)?)?)/
|
|
36
|
-
VERSION_COMMENT_REPLACEMENT_RE = /\A(?<prefix>\s+#\s*)v?\d+(?:\.\d+\.\d+(?:[-.]?[0-9A-Za-z.-]+)?)?/
|
|
37
|
-
|
|
38
|
-
def self.release_version_sort_key(entry)
|
|
39
|
-
[entry.fetch(:version_obj), *release_version_specificity(entry)]
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def self.release_version_specificity(entry)
|
|
43
|
-
text = entry.fetch(:tag).to_s.sub(/\A[vV]/, "")
|
|
44
|
-
release_text, suffix = text.split(/[-.](?=[A-Za-z])/, 2)
|
|
45
|
-
numeric_segments = release_text.to_s.split(".").take_while { |part| part.match?(/\A\d+\z/) }
|
|
46
|
-
|
|
47
|
-
[
|
|
48
|
-
numeric_segments.length,
|
|
49
|
-
suffix ? 0 : 1,
|
|
50
|
-
text.length,
|
|
51
|
-
entry.fetch(:tag).to_s
|
|
52
|
-
]
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def initialize(argv, err: $stderr)
|
|
56
|
-
@argv = argv
|
|
57
|
-
@err = err
|
|
58
|
-
@options = {
|
|
59
|
-
root: File.join(Dir.pwd, ".github", "workflows"),
|
|
60
|
-
dry_run: true,
|
|
61
|
-
token: ENV["GITHUB_TOKEN"] || ENV["GH_TOKEN"],
|
|
62
|
-
json: false,
|
|
63
|
-
validate: true,
|
|
64
|
-
write: false,
|
|
65
|
-
check: false,
|
|
66
|
-
api_base: API_BASE,
|
|
67
|
-
user_agent: "kettle-gha-sha-pins",
|
|
68
|
-
upgrade: DEFAULT_UPGRADE_LEVEL,
|
|
69
|
-
cache_path: ENV["KETTLE_GHA_SHA_PINS_CACHE"] || PersistentActionCache.default_path,
|
|
70
|
-
refresh_cache: false,
|
|
71
|
-
reject_patterns: Set.new,
|
|
72
|
-
progress: nil
|
|
73
|
-
}
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def run!
|
|
77
|
-
parse!
|
|
78
|
-
|
|
79
|
-
@options[:token] ||= gh_auth_token if @options[:api_base] == API_BASE
|
|
80
|
-
persistent_cache = if @options[:cache_path].to_s.empty?
|
|
81
|
-
nil
|
|
82
|
-
else
|
|
83
|
-
PersistentActionCache.new(path: @options[:cache_path])
|
|
84
|
-
end
|
|
85
|
-
client = GitHubClient.new(
|
|
86
|
-
token: @options[:token],
|
|
87
|
-
api_base: @options[:api_base],
|
|
88
|
-
user_agent: @options[:user_agent],
|
|
89
|
-
persistent_cache: persistent_cache,
|
|
90
|
-
refresh_cache: @options[:refresh_cache]
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
state = {
|
|
94
|
-
files_scanned: 0,
|
|
95
|
-
files_with_changes: 0,
|
|
96
|
-
updates: 0,
|
|
97
|
-
failures: 0,
|
|
98
|
-
errors: [],
|
|
99
|
-
changed_files: [],
|
|
100
|
-
planned_changes: [],
|
|
101
|
-
outdated_pins: []
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
progress_message("Discovering workflow files under #{Kettle::Dev.display_path(@options[:root])}...")
|
|
105
|
-
workflow_files = discover_workflow_files(@options[:root], @options[:reject_patterns])
|
|
106
|
-
progress_message("Discovered #{workflow_files.length} workflow file(s).")
|
|
107
|
-
|
|
108
|
-
workflows = load_workflows(workflow_files, state)
|
|
109
|
-
action_count = workflows.sum { |workflow| workflow[:uses_nodes].count { |node| classify_action_ref(node[:value].to_s) } }
|
|
110
|
-
progress_message("Resolving #{action_count} GitHub action reference(s)...") if action_count.positive?
|
|
111
|
-
action_progress = CacheProgress.new(
|
|
112
|
-
total: action_count,
|
|
113
|
-
cached_title: "Actions cached",
|
|
114
|
-
live_title: "Actions live",
|
|
115
|
-
output: @err,
|
|
116
|
-
enabled: progress_enabled?
|
|
117
|
-
)
|
|
118
|
-
action_plan_cache = {}
|
|
119
|
-
|
|
120
|
-
workflows.each do |workflow|
|
|
121
|
-
path = workflow.fetch(:path)
|
|
122
|
-
text = workflow.fetch(:text)
|
|
123
|
-
uses_nodes = workflow.fetch(:uses_nodes)
|
|
124
|
-
|
|
125
|
-
edits = []
|
|
126
|
-
uses_nodes.each do |node|
|
|
127
|
-
value = node[:value].to_s
|
|
128
|
-
parsed_ref = classify_action_ref(value)
|
|
129
|
-
next unless parsed_ref
|
|
130
|
-
|
|
131
|
-
begin
|
|
132
|
-
action = parsed_ref[:action]
|
|
133
|
-
repo_ref = "#{action[:owner]}/#{action[:repo]}"
|
|
134
|
-
old_ref = action[:ref]
|
|
135
|
-
upgrade_plan = resolve_action_plan(
|
|
136
|
-
cache: action_plan_cache,
|
|
137
|
-
client: client,
|
|
138
|
-
progress: action_progress,
|
|
139
|
-
repo_ref: repo_ref,
|
|
140
|
-
old_ref: old_ref
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
updates = nil
|
|
144
|
-
if upgrade_plan[:updates]
|
|
145
|
-
updates = compute_updates(old_ref, upgrade_plan[:updates][:sha], upgrade_plan[:updates][:reason], repo_ref)
|
|
146
|
-
updates[:new_version] = upgrade_plan[:updates][:version]
|
|
147
|
-
updates[:old_version] = upgrade_plan[:current_version]
|
|
148
|
-
end
|
|
149
|
-
if updates.nil? && upgrade_plan[:current_version]
|
|
150
|
-
comment_version = version_comment_from_line(text, node[:line], node[:col], parsed_ref[:value])
|
|
151
|
-
if comment_version && comment_version != upgrade_plan[:current_version]
|
|
152
|
-
updates = {
|
|
153
|
-
new_ref: old_ref,
|
|
154
|
-
new_version: upgrade_plan[:current_version],
|
|
155
|
-
old_version: comment_version,
|
|
156
|
-
reason: COMMENT_REASON,
|
|
157
|
-
action: repo_ref
|
|
158
|
-
}
|
|
159
|
-
end
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
if upgrade_plan[:is_outdated]
|
|
163
|
-
state[:outdated_pins] << {
|
|
164
|
-
path: path,
|
|
165
|
-
line: node[:line] + 1,
|
|
166
|
-
action: repo_ref,
|
|
167
|
-
old_ref: old_ref,
|
|
168
|
-
old_version: upgrade_plan[:current_version],
|
|
169
|
-
new_ref: upgrade_plan[:latest_outdated] ? upgrade_plan[:latest_outdated][:sha] : nil,
|
|
170
|
-
new_version: upgrade_plan[:latest_outdated] ? upgrade_plan[:latest_outdated][:version] : nil,
|
|
171
|
-
upgrade_level: @options[:upgrade],
|
|
172
|
-
reason: upgrade_plan[:reason]
|
|
173
|
-
}
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
next unless updates
|
|
177
|
-
|
|
178
|
-
replacement = build_replacement_from_line(text, node[:line], node[:col], parsed_ref[:value], updates[:new_ref], updates[:new_version])
|
|
179
|
-
unless replacement
|
|
180
|
-
record_failure(
|
|
181
|
-
state,
|
|
182
|
-
path: path,
|
|
183
|
-
line: node[:line] + 1,
|
|
184
|
-
error: "token_parse_failed",
|
|
185
|
-
value: value
|
|
186
|
-
)
|
|
187
|
-
next
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
edits << {
|
|
191
|
-
path: path,
|
|
192
|
-
line: node[:line],
|
|
193
|
-
col: node[:col],
|
|
194
|
-
old_ref: old_ref,
|
|
195
|
-
old_version: updates[:old_version],
|
|
196
|
-
new_ref: updates[:new_ref],
|
|
197
|
-
new_version: updates[:new_version],
|
|
198
|
-
reason: updates[:reason],
|
|
199
|
-
start: replacement[:start],
|
|
200
|
-
end: replacement[:end],
|
|
201
|
-
old_value: value,
|
|
202
|
-
new_value: replacement[:new_scalar],
|
|
203
|
-
new_scalar: replacement[:new_scalar],
|
|
204
|
-
action: repo_ref
|
|
205
|
-
}
|
|
206
|
-
end
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
if edits.any?
|
|
210
|
-
edited = apply_edits(text, edits)
|
|
211
|
-
if edited[:changed]
|
|
212
|
-
state[:changed_files] << path
|
|
213
|
-
state[:files_with_changes] += 1
|
|
214
|
-
state[:updates] += edits.length
|
|
215
|
-
state[:planned_changes].concat(edited[:edits].map do |entry|
|
|
216
|
-
{
|
|
217
|
-
path: entry[:path],
|
|
218
|
-
line: entry[:line] + 1,
|
|
219
|
-
old_ref: entry[:old_ref],
|
|
220
|
-
old_version: entry[:old_version],
|
|
221
|
-
new_ref: entry[:new_ref],
|
|
222
|
-
new_version: entry[:new_version],
|
|
223
|
-
reason: entry[:reason],
|
|
224
|
-
old_value: entry[:old_value],
|
|
225
|
-
new_value: entry[:new_value],
|
|
226
|
-
action: entry[:action]
|
|
227
|
-
}
|
|
228
|
-
end)
|
|
229
|
-
|
|
230
|
-
if @options[:write]
|
|
231
|
-
File.write(path, edited[:text])
|
|
232
|
-
validate_yaml!(path) if @options[:validate]
|
|
233
|
-
end
|
|
234
|
-
end
|
|
235
|
-
end
|
|
236
|
-
end
|
|
237
|
-
progress_message("Action resolution checks: #{action_progress.cached_count} cached, #{action_progress.live_count} live.") if action_count.positive?
|
|
238
|
-
|
|
239
|
-
print_report(state)
|
|
240
|
-
return 2 unless state[:failures].zero?
|
|
241
|
-
return 3 if @options[:check] && state[:updates].positive?
|
|
242
|
-
|
|
243
|
-
0
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
private
|
|
247
|
-
|
|
248
|
-
def parse!
|
|
249
|
-
parser = OptionParser.new do |opt|
|
|
250
|
-
opt.banner = "Usage: kettle-gha-sha-pins [options]"
|
|
251
|
-
opt.separator ""
|
|
252
|
-
opt.separator "Normalize GitHub Actions workflow action refs to immutable commit SHAs."
|
|
253
|
-
opt.on("-r", "--root PATH", "Directory to scan (defaults to .github/workflows under cwd)") do |root|
|
|
254
|
-
@options[:root] = root
|
|
255
|
-
end
|
|
256
|
-
opt.on("-w", "--write", "Write edits (dry-run is default)") do
|
|
257
|
-
@options[:write] = true
|
|
258
|
-
@options[:dry_run] = false
|
|
259
|
-
end
|
|
260
|
-
opt.on("--check", "Fail when workflow action pins are stale or mutable") do
|
|
261
|
-
@options[:check] = true
|
|
262
|
-
end
|
|
263
|
-
opt.on("--upgrade LEVEL", "Upgrade strategy: major, minor, patch (default: #{DEFAULT_UPGRADE_LEVEL})") do |level|
|
|
264
|
-
normalized = level.to_s.downcase
|
|
265
|
-
unless VALID_UPGRADE_LEVELS.include?(normalized)
|
|
266
|
-
Kettle::Dev::ExitAdapter.abort("Invalid --upgrade value #{level.inspect}; use one of: #{VALID_UPGRADE_LEVELS.join(", ")}")
|
|
267
|
-
end
|
|
268
|
-
@options[:upgrade] = normalized
|
|
269
|
-
end
|
|
270
|
-
opt.on("--token VALUE", "GitHub token to increase API rate-limit") do |token|
|
|
271
|
-
@options[:token] = token
|
|
272
|
-
end
|
|
273
|
-
opt.on("--refresh-cache", "Bypass cached action release data and refresh discovered actions") do
|
|
274
|
-
@options[:refresh_cache] = true
|
|
275
|
-
end
|
|
276
|
-
opt.on("--cache-path PATH", "Action release cache path (default: #{@options[:cache_path]})") do |path|
|
|
277
|
-
@options[:cache_path] = path
|
|
278
|
-
end
|
|
279
|
-
opt.on("--json", "Emit JSON report") do
|
|
280
|
-
@options[:json] = true
|
|
281
|
-
end
|
|
282
|
-
opt.on("--[no-]progress", "Show progress feedback on STDERR (default: on unless --json)") do |bool|
|
|
283
|
-
@options[:progress] = bool
|
|
284
|
-
end
|
|
285
|
-
opt.on("--skip-pattern PATTERN", "Skip workflow paths matching pattern (repeatable)") do |pattern|
|
|
286
|
-
begin
|
|
287
|
-
@options[:reject_patterns] << Regexp.new(pattern)
|
|
288
|
-
rescue RegexpError => e
|
|
289
|
-
Kettle::Dev::ExitAdapter.abort("Invalid --skip-pattern #{pattern.inspect}: #{e.message}")
|
|
290
|
-
end
|
|
291
|
-
end
|
|
292
|
-
opt.on("--[no-]validate", "Validate YAML after editing") do |bool|
|
|
293
|
-
@options[:validate] = bool
|
|
294
|
-
end
|
|
295
|
-
opt.on("-h", "--help", "Show this help") do
|
|
296
|
-
puts opt
|
|
297
|
-
Kettle::Dev::ExitAdapter.exit(0)
|
|
298
|
-
end
|
|
299
|
-
end
|
|
300
|
-
parser.parse!(@argv)
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
def load_workflows(paths, state)
|
|
304
|
-
file_progress = progress_bar(title: "Files", total: paths.length)
|
|
305
|
-
paths.each_with_object([]) do |path, workflows|
|
|
306
|
-
begin
|
|
307
|
-
state[:files_scanned] += 1
|
|
308
|
-
text = begin
|
|
309
|
-
File.read(path)
|
|
310
|
-
rescue Errno::EACCES => e
|
|
311
|
-
record_failure(state, path: path, error: "read_error: #{e.message}")
|
|
312
|
-
next
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
parsed = begin
|
|
316
|
-
Psych.parse_stream(text)
|
|
317
|
-
rescue Psych::Exception => e
|
|
318
|
-
record_failure(state, path: path, error: "yaml_parse_error: #{e.message}")
|
|
319
|
-
next
|
|
320
|
-
end
|
|
321
|
-
|
|
322
|
-
uses_nodes = extract_uses_nodes(parsed, text)
|
|
323
|
-
workflows << {path: path, text: text, uses_nodes: uses_nodes} unless uses_nodes.empty?
|
|
324
|
-
ensure
|
|
325
|
-
file_progress&.increment
|
|
326
|
-
end
|
|
327
|
-
end
|
|
328
|
-
end
|
|
329
|
-
|
|
330
|
-
def resolve_action_plan(cache:, client:, progress:, repo_ref:, old_ref:)
|
|
331
|
-
if cache.key?(repo_ref)
|
|
332
|
-
versions = cache.fetch(repo_ref)
|
|
333
|
-
progress.cached
|
|
334
|
-
return determine_upgrade_plan(
|
|
335
|
-
old_ref: old_ref,
|
|
336
|
-
repo_ref: repo_ref,
|
|
337
|
-
versions: versions,
|
|
338
|
-
upgrade_level: @options[:upgrade],
|
|
339
|
-
client: client
|
|
340
|
-
)
|
|
341
|
-
end
|
|
342
|
-
|
|
343
|
-
versions = client.versions_for_repo(repo_ref)
|
|
344
|
-
cache[repo_ref] = versions
|
|
345
|
-
plan = determine_upgrade_plan(
|
|
346
|
-
old_ref: old_ref,
|
|
347
|
-
repo_ref: repo_ref,
|
|
348
|
-
versions: versions,
|
|
349
|
-
upgrade_level: @options[:upgrade],
|
|
350
|
-
client: client
|
|
351
|
-
)
|
|
352
|
-
progress.live
|
|
353
|
-
plan
|
|
354
|
-
end
|
|
355
|
-
|
|
356
|
-
def progress_enabled?
|
|
357
|
-
return @options[:progress] unless @options[:progress].nil?
|
|
358
|
-
|
|
359
|
-
!@options[:json]
|
|
360
|
-
end
|
|
361
|
-
|
|
362
|
-
def progress_message(message)
|
|
363
|
-
return unless progress_enabled?
|
|
364
|
-
|
|
365
|
-
@err.puts("[kettle-gha-sha-pins] #{message}")
|
|
366
|
-
end
|
|
367
|
-
|
|
368
|
-
def gh_auth_token
|
|
369
|
-
stdout, _stderr, status = Open3.capture3("gh", "auth", "token")
|
|
370
|
-
return nil unless status.success?
|
|
371
|
-
|
|
372
|
-
token = stdout.to_s.strip
|
|
373
|
-
token.empty? ? nil : token
|
|
374
|
-
rescue Errno::ENOENT
|
|
375
|
-
nil
|
|
376
|
-
end
|
|
377
|
-
|
|
378
|
-
def progress_bar(title:, total:)
|
|
379
|
-
return unless progress_enabled?
|
|
380
|
-
return unless total.positive?
|
|
381
|
-
|
|
382
|
-
ProgressBar.create(title: title, total: total, format: "%t %b %c/%C", length: 30, output: @err)
|
|
383
|
-
end
|
|
384
|
-
|
|
385
|
-
def discover_workflow_files(root, reject_patterns)
|
|
386
|
-
expanded_root = workflow_analysis_root(root)
|
|
387
|
-
patterns = [
|
|
388
|
-
File.join(expanded_root.to_s, "*.yml"),
|
|
389
|
-
File.join(expanded_root.to_s, "*.yaml")
|
|
390
|
-
]
|
|
391
|
-
files = Dir.glob(patterns, File::FNM_PATHNAME).uniq.sort
|
|
392
|
-
files.select do |path|
|
|
393
|
-
next false unless File.file?(path)
|
|
394
|
-
next false if reject_patterns.any? { |pattern| pattern.match?(path) }
|
|
395
|
-
true
|
|
396
|
-
end
|
|
397
|
-
end
|
|
398
|
-
|
|
399
|
-
def workflow_analysis_root(root)
|
|
400
|
-
expanded_root = Pathname.new(root).expand_path
|
|
401
|
-
workflow_root = expanded_root.join(".github", "workflows")
|
|
402
|
-
return workflow_root if workflow_root.directory?
|
|
403
|
-
|
|
404
|
-
expanded_root
|
|
405
|
-
end
|
|
406
|
-
|
|
407
|
-
def extract_uses_nodes(parsed, text = nil)
|
|
408
|
-
mapping_node = Psych::Nodes::Mapping
|
|
409
|
-
scalar_node = Psych::Nodes::Scalar
|
|
410
|
-
sequence_node = Psych::Nodes::Sequence
|
|
411
|
-
|
|
412
|
-
nodes = []
|
|
413
|
-
fallback_locations = {}
|
|
414
|
-
walk = lambda do |node|
|
|
415
|
-
case node
|
|
416
|
-
when mapping_node
|
|
417
|
-
node.children.each_slice(2) do |key_node, value_node|
|
|
418
|
-
next unless key_node.is_a?(scalar_node)
|
|
419
|
-
if key_node.value == "uses" && value_node.is_a?(scalar_node)
|
|
420
|
-
line, col = if value_node.respond_to?(:start_line) && value_node.respond_to?(:start_column)
|
|
421
|
-
[value_node.start_line, value_node.start_column]
|
|
422
|
-
else
|
|
423
|
-
fallback_uses_location(text, value_node.value, fallback_locations)
|
|
424
|
-
end
|
|
425
|
-
nodes << {
|
|
426
|
-
line: line,
|
|
427
|
-
col: col,
|
|
428
|
-
value: value_node.value
|
|
429
|
-
}
|
|
430
|
-
next
|
|
431
|
-
end
|
|
432
|
-
walk.call(value_node)
|
|
433
|
-
end
|
|
434
|
-
when sequence_node
|
|
435
|
-
node.children.each { |child| walk.call(child) }
|
|
436
|
-
else
|
|
437
|
-
if node.respond_to?(:children) && node.children
|
|
438
|
-
node.children.each { |child| walk.call(child) }
|
|
439
|
-
end
|
|
440
|
-
end
|
|
441
|
-
end
|
|
442
|
-
|
|
443
|
-
parsed.children.each { |node| walk.call(node) }
|
|
444
|
-
nodes.compact
|
|
445
|
-
end
|
|
446
|
-
|
|
447
|
-
def fallback_uses_location(text, value, used_locations)
|
|
448
|
-
return [0, 0] unless text
|
|
449
|
-
|
|
450
|
-
text.each_line.with_index do |line, index|
|
|
451
|
-
next if used_locations[index]
|
|
452
|
-
|
|
453
|
-
marker = line.index("uses:")
|
|
454
|
-
next unless marker
|
|
455
|
-
|
|
456
|
-
value_index = line.index(value.to_s, marker + 5)
|
|
457
|
-
next unless value_index
|
|
458
|
-
|
|
459
|
-
used_locations[index] = true
|
|
460
|
-
return [index, value_index]
|
|
461
|
-
end
|
|
462
|
-
|
|
463
|
-
[0, 0]
|
|
464
|
-
end
|
|
465
|
-
|
|
466
|
-
def classify_action_ref(value)
|
|
467
|
-
return nil unless value.is_a?(String)
|
|
468
|
-
trimmed = value.strip
|
|
469
|
-
|
|
470
|
-
return nil if trimmed.empty?
|
|
471
|
-
return nil if trimmed.start_with?("./", "../", "/")
|
|
472
|
-
return nil if trimmed.start_with?("docker://")
|
|
473
|
-
return nil if trimmed.include?("${{")
|
|
474
|
-
return nil unless trimmed.include?("@")
|
|
475
|
-
|
|
476
|
-
repo_part, delimiter, ref = trimmed.rpartition("@")
|
|
477
|
-
return nil unless delimiter == "@"
|
|
478
|
-
return nil if repo_part.to_s.empty? || ref.to_s.empty?
|
|
479
|
-
|
|
480
|
-
parts = repo_part.split("/")
|
|
481
|
-
return nil if parts.length < 2
|
|
482
|
-
return nil if parts[0].empty? || parts[1].empty?
|
|
483
|
-
|
|
484
|
-
{
|
|
485
|
-
value: trimmed,
|
|
486
|
-
action: {
|
|
487
|
-
owner: parts[0],
|
|
488
|
-
repo: parts[1],
|
|
489
|
-
path: (parts.length > 2) ? parts[2..-1].join("/") : nil,
|
|
490
|
-
ref: ref
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
end
|
|
494
|
-
|
|
495
|
-
def parse_release_version(value)
|
|
496
|
-
normalized = value.to_s.sub(/\A[vV]/, "")
|
|
497
|
-
return nil unless normalized.match?(/\A(?:\d+|\d+\.\d+\.\d+(?:[-.]?[0-9A-Za-z.-]+)?)\z/)
|
|
498
|
-
|
|
499
|
-
Gem::Version.new(normalized)
|
|
500
|
-
rescue ArgumentError
|
|
501
|
-
nil
|
|
502
|
-
end
|
|
503
|
-
|
|
504
|
-
def matching_version_entry(versions, current_ref, current_sha, client, repo_ref)
|
|
505
|
-
parsed = parse_release_version(current_ref)
|
|
506
|
-
if parsed
|
|
507
|
-
direct = versions.find { |entry| entry[:tag] == current_ref }
|
|
508
|
-
return direct if direct
|
|
509
|
-
end
|
|
510
|
-
|
|
511
|
-
return nil unless current_sha
|
|
512
|
-
|
|
513
|
-
prefix = current_sha[0, 40]
|
|
514
|
-
versions.find do |entry|
|
|
515
|
-
sha = version_entry_sha(entry, client, repo_ref)
|
|
516
|
-
sha.to_s.start_with?(prefix)
|
|
517
|
-
end
|
|
518
|
-
end
|
|
519
|
-
|
|
520
|
-
def choose_upgrade_target(current_version, versions, level)
|
|
521
|
-
current = parse_release_version(current_version)
|
|
522
|
-
return nil if current.nil?
|
|
523
|
-
return nil if level != "major" && major_line_version?(current_version)
|
|
524
|
-
|
|
525
|
-
candidates = versions.select do |entry|
|
|
526
|
-
next false unless entry[:version_obj].is_a?(Gem::Version)
|
|
527
|
-
next false unless entry[:version_obj] > current
|
|
528
|
-
next false if entry[:version_obj].prerelease? && !current.prerelease?
|
|
529
|
-
next false if level != "major" && major_line_version?(entry[:version])
|
|
530
|
-
|
|
531
|
-
case level
|
|
532
|
-
when "patch"
|
|
533
|
-
entry[:version_obj].segments[0, 2] == current.segments[0, 2]
|
|
534
|
-
when "minor"
|
|
535
|
-
entry[:version_obj].segments[0] == current.segments[0]
|
|
536
|
-
else
|
|
537
|
-
true
|
|
538
|
-
end
|
|
539
|
-
end
|
|
540
|
-
|
|
541
|
-
candidates.max_by { |entry| release_version_sort_key(entry) }
|
|
542
|
-
end
|
|
543
|
-
|
|
544
|
-
def major_line_version?(value)
|
|
545
|
-
value.to_s.match?(/\A\d+\z/)
|
|
546
|
-
end
|
|
547
|
-
|
|
548
|
-
def latest_outdated_target(current_version, versions)
|
|
549
|
-
current = parse_release_version(current_version)
|
|
550
|
-
return nil if current.nil?
|
|
551
|
-
|
|
552
|
-
versions
|
|
553
|
-
.select do |entry|
|
|
554
|
-
entry[:version_obj].is_a?(Gem::Version) &&
|
|
555
|
-
entry[:version_obj] > current &&
|
|
556
|
-
(!entry[:version_obj].prerelease? || current.prerelease?)
|
|
557
|
-
end
|
|
558
|
-
.max_by { |entry| release_version_sort_key(entry) }
|
|
559
|
-
end
|
|
560
|
-
|
|
561
|
-
def determine_upgrade_plan(old_ref:, repo_ref:, versions:, upgrade_level:, client:)
|
|
562
|
-
level = upgrade_level.to_s.downcase
|
|
563
|
-
level = DEFAULT_UPGRADE_LEVEL unless VALID_UPGRADE_LEVELS.include?(level)
|
|
564
|
-
|
|
565
|
-
current_ref = old_ref.to_s.strip
|
|
566
|
-
return {is_outdated: false, updates: nil, reason: nil, current_version: nil} if current_ref.empty?
|
|
567
|
-
|
|
568
|
-
available_versions = versions || []
|
|
569
|
-
latest = available_versions.first
|
|
570
|
-
|
|
571
|
-
current_sha = if SHA_RE.match?(current_ref) || WEAK_SHA_RE.match?(current_ref)
|
|
572
|
-
current_ref
|
|
573
|
-
else
|
|
574
|
-
client.commit_sha(repo_ref, current_ref)
|
|
575
|
-
end
|
|
576
|
-
parsed_current_ref = parse_release_version(current_ref)
|
|
577
|
-
version_equivalent_entry = if parsed_current_ref
|
|
578
|
-
available_versions.find { |entry| entry[:version_obj] == parsed_current_ref }
|
|
579
|
-
end
|
|
580
|
-
matched_entry = matching_version_entry(available_versions, current_ref, current_sha, client, repo_ref)
|
|
581
|
-
unresolved_version_ref = false
|
|
582
|
-
if matched_entry.nil? && current_sha.to_s.empty? && version_equivalent_entry && non_sha?(current_ref)
|
|
583
|
-
matched_entry = version_equivalent_entry
|
|
584
|
-
unresolved_version_ref = true
|
|
585
|
-
end
|
|
586
|
-
current_version = matched_entry ? matched_entry[:version] : nil
|
|
587
|
-
|
|
588
|
-
updates = nil
|
|
589
|
-
reason = nil
|
|
590
|
-
is_outdated = false
|
|
591
|
-
latest_outdated = nil
|
|
592
|
-
|
|
593
|
-
if current_version
|
|
594
|
-
latest_outdated = latest_outdated_target(current_version, available_versions)
|
|
595
|
-
target = choose_upgrade_target(current_version, available_versions, level)
|
|
596
|
-
target_sha = target ? version_entry_sha(target, client, repo_ref) : nil
|
|
597
|
-
latest_outdated_sha = latest_outdated ? version_entry_sha(latest_outdated, client, repo_ref) : nil
|
|
598
|
-
if latest_outdated && stale_sha?(current_ref, latest_outdated_sha)
|
|
599
|
-
latest_outdated = latest_outdated.merge(sha: latest_outdated_sha)
|
|
600
|
-
is_outdated = true
|
|
601
|
-
reason = UPGRADE_REASON
|
|
602
|
-
end
|
|
603
|
-
if target && stale_sha?(current_ref, target_sha)
|
|
604
|
-
updates = {
|
|
605
|
-
sha: target_sha,
|
|
606
|
-
version: target[:version],
|
|
607
|
-
reason: UPGRADE_REASON
|
|
608
|
-
}
|
|
609
|
-
reason ||= UPGRADE_REASON
|
|
610
|
-
end
|
|
611
|
-
if updates.nil? && unresolved_version_ref
|
|
612
|
-
matched_sha = version_entry_sha(matched_entry, client, repo_ref)
|
|
613
|
-
if stale_sha?(current_ref, matched_sha)
|
|
614
|
-
updates = {
|
|
615
|
-
sha: matched_sha,
|
|
616
|
-
version: nil,
|
|
617
|
-
reason: NON_SHA_REASON
|
|
618
|
-
}
|
|
619
|
-
latest_outdated ||= matched_entry.merge(sha: matched_sha)
|
|
620
|
-
is_outdated = true
|
|
621
|
-
reason ||= NON_SHA_REASON
|
|
622
|
-
end
|
|
623
|
-
end
|
|
624
|
-
elsif current_sha && non_sha?(current_ref)
|
|
625
|
-
if stale_sha?(current_ref, current_sha)
|
|
626
|
-
updates = {
|
|
627
|
-
sha: current_sha,
|
|
628
|
-
version: nil,
|
|
629
|
-
reason: NON_SHA_REASON
|
|
630
|
-
}
|
|
631
|
-
reason = NON_SHA_REASON
|
|
632
|
-
end
|
|
633
|
-
elsif current_sha
|
|
634
|
-
latest_sha = latest ? version_entry_sha(latest, client, repo_ref) : nil
|
|
635
|
-
if latest && stale_sha?(current_ref, latest_sha)
|
|
636
|
-
latest_outdated = latest.merge(sha: latest_sha)
|
|
637
|
-
updates = {
|
|
638
|
-
sha: latest_sha,
|
|
639
|
-
version: latest[:version],
|
|
640
|
-
reason: STALE_SHA_REASON
|
|
641
|
-
}
|
|
642
|
-
reason = STALE_SHA_REASON
|
|
643
|
-
is_outdated = true
|
|
644
|
-
end
|
|
645
|
-
end
|
|
646
|
-
|
|
647
|
-
{
|
|
648
|
-
is_outdated: is_outdated,
|
|
649
|
-
updates: updates,
|
|
650
|
-
reason: reason,
|
|
651
|
-
current_version: current_version,
|
|
652
|
-
latest_outdated: latest_outdated
|
|
653
|
-
}
|
|
654
|
-
end
|
|
655
|
-
|
|
656
|
-
def version_entry_sha(entry, client, repo_ref)
|
|
657
|
-
return nil unless entry
|
|
658
|
-
return entry[:sha] unless entry[:sha].to_s.empty?
|
|
659
|
-
|
|
660
|
-
sha = client.commit_sha(repo_ref, entry[:tag])
|
|
661
|
-
entry[:sha] = sha
|
|
662
|
-
sha
|
|
663
|
-
end
|
|
664
|
-
|
|
665
|
-
def release_version_sort_key(entry)
|
|
666
|
-
self.class.release_version_sort_key(entry)
|
|
667
|
-
end
|
|
668
|
-
|
|
669
|
-
def short_sha?(candidate)
|
|
670
|
-
return false unless candidate
|
|
671
|
-
WEAK_SHA_RE.match?(candidate)
|
|
672
|
-
end
|
|
673
|
-
|
|
674
|
-
def non_sha?(candidate)
|
|
675
|
-
!SHA_RE.match?(candidate) && !WEAK_SHA_RE.match?(candidate)
|
|
676
|
-
end
|
|
677
|
-
|
|
678
|
-
def stale_sha?(current, latest)
|
|
679
|
-
return false if current.nil? || latest.nil?
|
|
680
|
-
current_down = current.downcase
|
|
681
|
-
latest_down = latest.downcase
|
|
682
|
-
|
|
683
|
-
# If `current` is shorter than full SHA, treat prefixes as equal when they match the head
|
|
684
|
-
if current_down.length < latest_down.length
|
|
685
|
-
!latest_down.start_with?(current_down)
|
|
686
|
-
else
|
|
687
|
-
current_down != latest_down
|
|
688
|
-
end
|
|
689
|
-
end
|
|
690
|
-
|
|
691
|
-
def compute_updates(old_ref, replacement, reason, action)
|
|
692
|
-
return nil if replacement.nil? || replacement.empty?
|
|
693
|
-
return nil if old_ref == replacement
|
|
694
|
-
|
|
695
|
-
{
|
|
696
|
-
new_ref: replacement,
|
|
697
|
-
reason: reason,
|
|
698
|
-
action: action
|
|
699
|
-
}
|
|
700
|
-
end
|
|
701
|
-
|
|
702
|
-
def extract_scalar_token(raw_text)
|
|
703
|
-
return nil if raw_text.nil? || raw_text.empty?
|
|
704
|
-
|
|
705
|
-
if (match = raw_text.match(/\A"((?:\\.|[^"\\])*)"/))
|
|
706
|
-
return {
|
|
707
|
-
token: match[1].gsub(/\\./) { |frag| frag[1] },
|
|
708
|
-
span: match[0].length,
|
|
709
|
-
quote: :double,
|
|
710
|
-
raw: match[0]
|
|
711
|
-
}
|
|
712
|
-
end
|
|
713
|
-
|
|
714
|
-
if (match = raw_text.match(/\A'((?:''|[^'])*)'/))
|
|
715
|
-
return {
|
|
716
|
-
token: match[1].gsub("''", "'"),
|
|
717
|
-
span: match[0].length,
|
|
718
|
-
quote: :single,
|
|
719
|
-
raw: match[0]
|
|
720
|
-
}
|
|
721
|
-
end
|
|
722
|
-
|
|
723
|
-
match = raw_text.match(/\A([^\s#]+)(?=\s*(?:#|$))/)
|
|
724
|
-
return nil unless match
|
|
725
|
-
|
|
726
|
-
{
|
|
727
|
-
token: match[1],
|
|
728
|
-
span: match[0].length,
|
|
729
|
-
quote: :plain,
|
|
730
|
-
raw: match[0]
|
|
731
|
-
}
|
|
732
|
-
end
|
|
733
|
-
|
|
734
|
-
def normalize_quote_scalar(value, quote)
|
|
735
|
-
case quote
|
|
736
|
-
when :single
|
|
737
|
-
"'#{value.gsub("'", "''")}'"
|
|
738
|
-
when :double
|
|
739
|
-
%("#{value.gsub("\\", "\\\\").gsub('"', '\\"')}")
|
|
740
|
-
else
|
|
741
|
-
value
|
|
742
|
-
end
|
|
743
|
-
end
|
|
744
|
-
|
|
745
|
-
def render_replacement(old_token, new_ref, quote)
|
|
746
|
-
at_index = old_token.rindex("@")
|
|
747
|
-
return nil if at_index.nil?
|
|
748
|
-
|
|
749
|
-
replacement_token = old_token[0...at_index + 1] + new_ref
|
|
750
|
-
{
|
|
751
|
-
token: replacement_token,
|
|
752
|
-
quoted: normalize_quote_scalar(replacement_token, quote)
|
|
753
|
-
}
|
|
754
|
-
end
|
|
755
|
-
|
|
756
|
-
def version_comment_from_line(text, line, col, old_token)
|
|
757
|
-
line_text = text.lines[line]
|
|
758
|
-
return nil if line_text.nil?
|
|
759
|
-
|
|
760
|
-
raw = line_text[col..-1]
|
|
761
|
-
return nil if raw.nil?
|
|
762
|
-
|
|
763
|
-
token_info = extract_scalar_token(raw)
|
|
764
|
-
return nil unless token_info
|
|
765
|
-
return nil unless token_info[:token] == old_token
|
|
766
|
-
|
|
767
|
-
suffix = raw[token_info[:span]..-1].to_s
|
|
768
|
-
match = suffix.match(VERSION_COMMENT_SUFFIX_RE)
|
|
769
|
-
match && match[:version]
|
|
770
|
-
end
|
|
771
|
-
|
|
772
|
-
def build_replacement_from_line(text, line, col, old_token, new_ref, new_version = nil)
|
|
773
|
-
line_text = text.lines[line]
|
|
774
|
-
return nil if line_text.nil?
|
|
775
|
-
|
|
776
|
-
raw = line_text[col..-1]
|
|
777
|
-
return nil if raw.nil?
|
|
778
|
-
|
|
779
|
-
token_info = extract_scalar_token(raw)
|
|
780
|
-
return nil unless token_info
|
|
781
|
-
return nil unless token_info[:token] == old_token
|
|
782
|
-
|
|
783
|
-
rendered = render_replacement(old_token, new_ref, token_info[:quote])
|
|
784
|
-
return nil unless rendered
|
|
785
|
-
|
|
786
|
-
span = token_info[:span]
|
|
787
|
-
new_scalar = rendered[:quoted]
|
|
788
|
-
if new_version && token_info[:quote] == :plain
|
|
789
|
-
suffix = raw[span..-1].to_s
|
|
790
|
-
comment = suffix.match(VERSION_COMMENT_REPLACEMENT_RE)
|
|
791
|
-
if comment
|
|
792
|
-
span += comment[0].length
|
|
793
|
-
new_scalar += "#{comment[:prefix]}v#{new_version}"
|
|
794
|
-
end
|
|
795
|
-
end
|
|
796
|
-
|
|
797
|
-
{
|
|
798
|
-
start: col,
|
|
799
|
-
end: col + span,
|
|
800
|
-
new_scalar: new_scalar,
|
|
801
|
-
new_ref: new_ref,
|
|
802
|
-
old_token: old_token
|
|
803
|
-
}
|
|
804
|
-
end
|
|
805
|
-
|
|
806
|
-
def apply_edits(original_text, edits)
|
|
807
|
-
lines = original_text.lines
|
|
808
|
-
grouped = edits.group_by { |entry| entry[:line] }
|
|
809
|
-
updated = lines.dup
|
|
810
|
-
|
|
811
|
-
grouped.each_value do |entries|
|
|
812
|
-
entries = entries.sort_by { |entry| -entry[:start] }
|
|
813
|
-
line_num = entries[0][:line]
|
|
814
|
-
line = updated[line_num]
|
|
815
|
-
next if line.nil?
|
|
816
|
-
|
|
817
|
-
entries.each do |entry|
|
|
818
|
-
line = line[0...entry[:start]].to_s + entry[:new_scalar] + line[entry[:end]..-1].to_s
|
|
819
|
-
end
|
|
820
|
-
updated[line_num] = line
|
|
821
|
-
end
|
|
822
|
-
|
|
823
|
-
new_text = updated.join
|
|
824
|
-
{
|
|
825
|
-
text: new_text,
|
|
826
|
-
changed: new_text != original_text,
|
|
827
|
-
edits: edits
|
|
828
|
-
}
|
|
829
|
-
end
|
|
830
|
-
|
|
831
|
-
def validate_yaml!(path)
|
|
832
|
-
Psych.parse_stream(File.read(path))
|
|
833
|
-
end
|
|
834
|
-
|
|
835
|
-
def record_failure(state, path:, error:, line: nil, value: nil)
|
|
836
|
-
state[:failures] += 1
|
|
837
|
-
state[:errors] << {
|
|
838
|
-
path: path,
|
|
839
|
-
line: line,
|
|
840
|
-
error: error,
|
|
841
|
-
value: value
|
|
842
|
-
}.delete_if { |_key, value| value.nil? }
|
|
843
|
-
end
|
|
844
|
-
|
|
845
|
-
def print_report(state)
|
|
846
|
-
mode = @options[:write] ? "write" : "dry-run"
|
|
847
|
-
if @options[:json]
|
|
848
|
-
payload = {
|
|
849
|
-
mode: mode,
|
|
850
|
-
dry_run: @options[:dry_run],
|
|
851
|
-
root: @options[:root],
|
|
852
|
-
files_scanned: state[:files_scanned],
|
|
853
|
-
files_with_changes: state[:files_with_changes],
|
|
854
|
-
updates: state[:updates],
|
|
855
|
-
failures: state[:failures],
|
|
856
|
-
outdated_pins: state[:outdated_pins],
|
|
857
|
-
changed_files: state[:changed_files].sort,
|
|
858
|
-
planned_changes: state[:planned_changes].sort_by { |c| [c[:path], c[:line], c[:new_ref]] },
|
|
859
|
-
errors: state[:errors]
|
|
860
|
-
}
|
|
861
|
-
puts JSON.pretty_generate(payload)
|
|
862
|
-
return
|
|
863
|
-
end
|
|
864
|
-
|
|
865
|
-
lines = []
|
|
866
|
-
lines << "kettle-gha-sha-pins report"
|
|
867
|
-
lines << " mode: #{mode}"
|
|
868
|
-
lines << " check: #{@options[:check]}"
|
|
869
|
-
lines << " root: #{@options[:root]}"
|
|
870
|
-
lines << " scanned: #{state[:files_scanned]}"
|
|
871
|
-
lines << " changed_files: #{state[:changed_files].length}"
|
|
872
|
-
lines << " planned_updates: #{state[:updates]}"
|
|
873
|
-
lines << " outdated_pins: #{state[:outdated_pins].length}"
|
|
874
|
-
lines << " failures: #{state[:failures]}"
|
|
875
|
-
lines << ""
|
|
876
|
-
|
|
877
|
-
if state[:errors].any?
|
|
878
|
-
lines << "Errors:"
|
|
879
|
-
state[:errors].sort_by { |error| [error[:path], error[:line].to_i] }.each do |error|
|
|
880
|
-
lines << if error[:line]
|
|
881
|
-
"- #{error[:path]}:#{error[:line]} #{error[:error]}"
|
|
882
|
-
else
|
|
883
|
-
"- #{error[:path]} #{error[:error]}"
|
|
884
|
-
end
|
|
885
|
-
end
|
|
886
|
-
lines << ""
|
|
887
|
-
end
|
|
888
|
-
|
|
889
|
-
if state[:outdated_pins].empty?
|
|
890
|
-
lines << "Outdated pins: none"
|
|
891
|
-
else
|
|
892
|
-
lines << "Outdated pins (#{state[:outdated_pins].length}):"
|
|
893
|
-
state[:outdated_pins].sort_by { |c| [c[:path], c[:line], c[:old_ref]] }.each do |pin|
|
|
894
|
-
from = pin[:old_version] || pin[:old_ref]
|
|
895
|
-
to = pin[:new_version] || pin[:new_ref]
|
|
896
|
-
lines << "- #{pin[:path]}:#{pin[:line]} #{pin[:action]} #{from} -> #{to} #{pin[:reason]}"
|
|
897
|
-
end
|
|
898
|
-
lines << ""
|
|
899
|
-
end
|
|
900
|
-
|
|
901
|
-
if state[:planned_changes].empty?
|
|
902
|
-
lines << "Outdated actions: none"
|
|
903
|
-
else
|
|
904
|
-
lines << "Outdated actions (#{state[:planned_changes].length}):"
|
|
905
|
-
lines << "Action Current Latest Location Reason"
|
|
906
|
-
state[:planned_changes].sort_by { |c| [c[:action], c[:path], c[:line]] }.each do |change|
|
|
907
|
-
current = change[:old_version] || change[:old_ref]
|
|
908
|
-
latest = change[:new_version] || change[:new_ref]
|
|
909
|
-
location = "#{change[:path]}:#{change[:line]}"
|
|
910
|
-
lines << "#{change[:action]} #{current} #{latest} #{location} #{change[:reason]}"
|
|
911
|
-
end
|
|
912
|
-
lines << ""
|
|
913
|
-
end
|
|
914
|
-
|
|
915
|
-
if state[:planned_changes].empty?
|
|
916
|
-
lines << "No change candidates found."
|
|
917
|
-
else
|
|
918
|
-
lines << "Planned changes (#{state[:planned_changes].length}):"
|
|
919
|
-
state[:planned_changes].sort_by { |c| [c[:path], c[:line], c[:old_ref]] }.each do |change|
|
|
920
|
-
from = change[:old_version] || change[:old_ref]
|
|
921
|
-
to = change[:new_version] || change[:new_ref]
|
|
922
|
-
lines << "- #{change[:path]}:#{change[:line]} #{from} -> #{to} #{change[:reason]}"
|
|
923
|
-
end
|
|
924
|
-
end
|
|
925
|
-
if @options[:check] && state[:planned_changes].any?
|
|
926
|
-
lines << ""
|
|
927
|
-
lines << "Recommended fix: kettle-gha-sha-pins --write --upgrade #{@options[:upgrade]}"
|
|
928
|
-
end
|
|
929
|
-
|
|
930
|
-
puts lines.join("
|
|
931
|
-
")
|
|
932
|
-
end
|
|
933
|
-
|
|
934
|
-
# Persistent cache of GitHub Action release versions and target SHAs.
|
|
935
|
-
class PersistentActionCache
|
|
936
|
-
VERSION = 2
|
|
937
|
-
|
|
938
|
-
def self.default_path
|
|
939
|
-
state_home = ENV["XDG_STATE_HOME"]
|
|
940
|
-
state_home = File.join(Dir.home, ".local", "state") if state_home.to_s.empty?
|
|
941
|
-
File.join(state_home, "kettle-dev", "gha-sha-pins-cache.json")
|
|
942
|
-
rescue ArgumentError
|
|
943
|
-
nil
|
|
944
|
-
end
|
|
945
|
-
|
|
946
|
-
def initialize(path:, ttl_seconds: DEFAULT_CACHE_TTL_SECONDS, clock: -> { Time.now })
|
|
947
|
-
@path = path
|
|
948
|
-
@ttl_seconds = ttl_seconds
|
|
949
|
-
@clock = clock
|
|
950
|
-
@data = nil
|
|
951
|
-
end
|
|
952
|
-
|
|
953
|
-
def versions_for_repo(repo_ref, fresh: true)
|
|
954
|
-
action = action_data(repo_ref)
|
|
955
|
-
return nil unless action
|
|
956
|
-
|
|
957
|
-
versions = action.fetch("versions", {}).values
|
|
958
|
-
return nil if versions.empty?
|
|
959
|
-
|
|
960
|
-
entries = if fresh
|
|
961
|
-
versions.select { |entry| fresh_entry?(entry) }
|
|
962
|
-
else
|
|
963
|
-
versions
|
|
964
|
-
end
|
|
965
|
-
return nil if entries.empty?
|
|
966
|
-
return nil if fresh && entries.length != versions.length
|
|
967
|
-
|
|
968
|
-
entries.filter_map { |entry| deserialize_version_entry(entry) }
|
|
969
|
-
.sort_by { |entry| entry[:version_obj] }
|
|
970
|
-
.reverse
|
|
971
|
-
end
|
|
972
|
-
|
|
973
|
-
def write_versions(repo_ref, versions)
|
|
974
|
-
return if @path.to_s.empty?
|
|
975
|
-
return if repo_ref.to_s.empty?
|
|
976
|
-
|
|
977
|
-
action = data.fetch("actions")[repo_ref] ||= {}
|
|
978
|
-
stored_versions = action["versions"] ||= {}
|
|
979
|
-
timestamp = @clock.call.utc.iso8601
|
|
980
|
-
|
|
981
|
-
versions.each do |entry|
|
|
982
|
-
version = entry[:version].to_s
|
|
983
|
-
next if version.empty?
|
|
984
|
-
|
|
985
|
-
stored_versions[version] = {
|
|
986
|
-
"tag" => entry[:tag].to_s,
|
|
987
|
-
"version" => version,
|
|
988
|
-
"sha" => entry[:sha].to_s,
|
|
989
|
-
"cached_at" => timestamp
|
|
990
|
-
}
|
|
991
|
-
end
|
|
992
|
-
|
|
993
|
-
action["targets"] = target_cache(stored_versions.values)
|
|
994
|
-
save!
|
|
995
|
-
end
|
|
996
|
-
|
|
997
|
-
def ref_sha(repo_ref, ref, fresh: true)
|
|
998
|
-
action = action_data(repo_ref)
|
|
999
|
-
return nil unless action
|
|
1000
|
-
|
|
1001
|
-
refs = action.fetch("refs", {})
|
|
1002
|
-
entry = refs[ref.to_s]
|
|
1003
|
-
return nil unless entry
|
|
1004
|
-
return nil if fresh && !fresh_entry?(entry)
|
|
1005
|
-
|
|
1006
|
-
sha = entry["sha"].to_s
|
|
1007
|
-
sha.empty? ? nil : sha
|
|
1008
|
-
end
|
|
1009
|
-
|
|
1010
|
-
def write_ref_sha(repo_ref, ref, sha)
|
|
1011
|
-
return if @path.to_s.empty?
|
|
1012
|
-
return if repo_ref.to_s.empty? || ref.to_s.empty? || sha.to_s.empty?
|
|
1013
|
-
|
|
1014
|
-
action = data.fetch("actions")[repo_ref] ||= {}
|
|
1015
|
-
refs = action["refs"] ||= {}
|
|
1016
|
-
refs[ref.to_s] = {
|
|
1017
|
-
"sha" => sha.to_s[0, 40],
|
|
1018
|
-
"cached_at" => @clock.call.utc.iso8601
|
|
1019
|
-
}
|
|
1020
|
-
save!
|
|
1021
|
-
end
|
|
1022
|
-
|
|
1023
|
-
def to_h
|
|
1024
|
-
data
|
|
1025
|
-
end
|
|
1026
|
-
|
|
1027
|
-
private
|
|
1028
|
-
|
|
1029
|
-
def data
|
|
1030
|
-
@data ||= load_data
|
|
1031
|
-
end
|
|
1032
|
-
|
|
1033
|
-
def action_data(repo_ref)
|
|
1034
|
-
data.fetch("actions")[repo_ref]
|
|
1035
|
-
end
|
|
1036
|
-
|
|
1037
|
-
def load_data
|
|
1038
|
-
parsed = if @path && File.file?(@path)
|
|
1039
|
-
JSON.parse(File.read(@path))
|
|
1040
|
-
end
|
|
1041
|
-
return empty_data unless parsed.is_a?(Hash)
|
|
1042
|
-
return empty_data unless parsed["version"].to_i == VERSION
|
|
1043
|
-
|
|
1044
|
-
parsed["version"] ||= VERSION
|
|
1045
|
-
parsed["actions"] = {} unless parsed["actions"].is_a?(Hash)
|
|
1046
|
-
parsed
|
|
1047
|
-
rescue JSON::ParserError, Errno::EACCES
|
|
1048
|
-
empty_data
|
|
1049
|
-
end
|
|
1050
|
-
|
|
1051
|
-
def empty_data
|
|
1052
|
-
{"version" => VERSION, "actions" => {}}
|
|
1053
|
-
end
|
|
1054
|
-
|
|
1055
|
-
def save!
|
|
1056
|
-
FileUtils.mkdir_p(File.dirname(@path))
|
|
1057
|
-
File.write(@path, JSON.pretty_generate(data) + "\n")
|
|
1058
|
-
end
|
|
1059
|
-
|
|
1060
|
-
def deserialize_version_entry(entry)
|
|
1061
|
-
version = entry["version"].to_s
|
|
1062
|
-
parsed = parse_version(version)
|
|
1063
|
-
return nil unless parsed
|
|
1064
|
-
|
|
1065
|
-
{
|
|
1066
|
-
tag: entry["tag"].to_s,
|
|
1067
|
-
version_obj: parsed,
|
|
1068
|
-
version: version,
|
|
1069
|
-
sha: entry["sha"].to_s
|
|
1070
|
-
}
|
|
1071
|
-
end
|
|
1072
|
-
|
|
1073
|
-
def target_cache(version_entries)
|
|
1074
|
-
entries = version_entries.filter_map do |entry|
|
|
1075
|
-
deserialized = deserialize_version_entry(entry)
|
|
1076
|
-
next unless deserialized
|
|
1077
|
-
|
|
1078
|
-
deserialized.merge(cached_at: entry["cached_at"].to_s)
|
|
1079
|
-
end
|
|
1080
|
-
return {} if entries.empty?
|
|
1081
|
-
|
|
1082
|
-
full_semver_entries = entries.reject { |entry| major_line_version?(entry[:version]) }
|
|
1083
|
-
{
|
|
1084
|
-
"patch" => full_semver_entries.group_by { |entry| entry[:version_obj].segments[0, 2].join(".") }
|
|
1085
|
-
.transform_values { |group| serialize_target(group.max_by { |entry| GhaShaPinsCLI.release_version_sort_key(entry) }) },
|
|
1086
|
-
"minor" => full_semver_entries.group_by { |entry| entry[:version_obj].segments[0].to_s }
|
|
1087
|
-
.transform_values { |group| serialize_target(group.max_by { |entry| GhaShaPinsCLI.release_version_sort_key(entry) }) },
|
|
1088
|
-
"major" => {"*" => serialize_target(entries.max_by { |entry| GhaShaPinsCLI.release_version_sort_key(entry) })}
|
|
1089
|
-
}
|
|
1090
|
-
end
|
|
1091
|
-
|
|
1092
|
-
def serialize_target(entry)
|
|
1093
|
-
{
|
|
1094
|
-
"tag" => entry[:tag],
|
|
1095
|
-
"version" => entry[:version],
|
|
1096
|
-
"sha" => entry[:sha],
|
|
1097
|
-
"cached_at" => entry[:cached_at]
|
|
1098
|
-
}
|
|
1099
|
-
end
|
|
1100
|
-
|
|
1101
|
-
def parse_version(value)
|
|
1102
|
-
Gem::Version.new(value)
|
|
1103
|
-
rescue ArgumentError
|
|
1104
|
-
nil
|
|
1105
|
-
end
|
|
1106
|
-
|
|
1107
|
-
def major_line_version?(value)
|
|
1108
|
-
value.to_s.match?(/\A\d+\z/)
|
|
1109
|
-
end
|
|
1110
|
-
|
|
1111
|
-
def fresh_entry?(entry)
|
|
1112
|
-
cached_at = Time.iso8601(entry["cached_at"].to_s)
|
|
1113
|
-
cached_at >= @clock.call - @ttl_seconds
|
|
1114
|
-
rescue ArgumentError
|
|
1115
|
-
false
|
|
1116
|
-
end
|
|
1117
|
-
end
|
|
1118
|
-
|
|
1119
|
-
# Lightweight GitHub API client for commit and release SHA resolution.
|
|
1120
|
-
class GitHubClient
|
|
1121
|
-
def initialize(token:, api_base:, user_agent:, persistent_cache: nil, refresh_cache: false, open_timeout: DEFAULT_HTTP_OPEN_TIMEOUT_SECONDS, read_timeout: DEFAULT_HTTP_READ_TIMEOUT_SECONDS, refresh_timeout: DEFAULT_HTTP_REFRESH_TIMEOUT_SECONDS)
|
|
1122
|
-
@token = token
|
|
1123
|
-
@api_base = api_base
|
|
1124
|
-
@user_agent = user_agent
|
|
1125
|
-
@persistent_cache = persistent_cache
|
|
1126
|
-
@refresh_cache = refresh_cache
|
|
1127
|
-
@open_timeout = open_timeout
|
|
1128
|
-
@read_timeout = read_timeout
|
|
1129
|
-
@refresh_timeout = refresh_timeout
|
|
1130
|
-
@commit_cache = {}
|
|
1131
|
-
@release_cache = {}
|
|
1132
|
-
end
|
|
1133
|
-
|
|
1134
|
-
def versions_for_repo(repo_ref)
|
|
1135
|
-
return [] if repo_ref.to_s.empty?
|
|
1136
|
-
return @release_cache[repo_ref] if @release_cache.key?(repo_ref)
|
|
1137
|
-
|
|
1138
|
-
stale = nil
|
|
1139
|
-
unless @refresh_cache
|
|
1140
|
-
cached = @persistent_cache&.versions_for_repo(repo_ref, fresh: true)
|
|
1141
|
-
if cached
|
|
1142
|
-
@release_cache[repo_ref] = cached
|
|
1143
|
-
return cached
|
|
1144
|
-
end
|
|
1145
|
-
stale = @persistent_cache&.versions_for_repo(repo_ref, fresh: false)
|
|
1146
|
-
end
|
|
1147
|
-
|
|
1148
|
-
releases = nil
|
|
1149
|
-
Timeout.timeout(@refresh_timeout) do
|
|
1150
|
-
data = request_json("/repos/#{repo_ref}/releases?per_page=100")
|
|
1151
|
-
return cached_versions(repo_ref, stale) unless data.is_a?(Array)
|
|
1152
|
-
|
|
1153
|
-
tag_shas = tag_ref_shas(repo_ref)
|
|
1154
|
-
return cached_versions(repo_ref, stale) unless tag_shas
|
|
1155
|
-
|
|
1156
|
-
releases = build_release_versions(data, tag_shas)
|
|
1157
|
-
end
|
|
1158
|
-
@persistent_cache&.write_versions(repo_ref, releases)
|
|
1159
|
-
@release_cache[repo_ref] = releases
|
|
1160
|
-
releases
|
|
1161
|
-
rescue Timeout::Error
|
|
1162
|
-
cached_versions(repo_ref, stale)
|
|
1163
|
-
end
|
|
1164
|
-
|
|
1165
|
-
def commit_sha(repo_ref, ref)
|
|
1166
|
-
return nil if repo_ref.to_s.empty? || ref.to_s.empty?
|
|
1167
|
-
|
|
1168
|
-
cache_key = "commit:#{repo_ref}:#{ref}"
|
|
1169
|
-
return @commit_cache[cache_key] if @commit_cache.key?(cache_key)
|
|
1170
|
-
|
|
1171
|
-
unless @refresh_cache
|
|
1172
|
-
cached = @persistent_cache&.ref_sha(repo_ref, ref, fresh: true)
|
|
1173
|
-
if cached
|
|
1174
|
-
@commit_cache[cache_key] = cached
|
|
1175
|
-
return cached
|
|
1176
|
-
end
|
|
1177
|
-
end
|
|
1178
|
-
|
|
1179
|
-
data = request_json("/repos/#{repo_ref}/commits/#{uri_encode(ref)}")
|
|
1180
|
-
sha = if data.is_a?(Hash)
|
|
1181
|
-
data.fetch("sha", "")[0, 40]
|
|
1182
|
-
end
|
|
1183
|
-
if sha.to_s.empty?
|
|
1184
|
-
sha = @persistent_cache&.ref_sha(repo_ref, ref, fresh: false)
|
|
1185
|
-
else
|
|
1186
|
-
@persistent_cache&.write_ref_sha(repo_ref, ref, sha)
|
|
1187
|
-
end
|
|
1188
|
-
@commit_cache[cache_key] = sha
|
|
1189
|
-
sha
|
|
1190
|
-
end
|
|
1191
|
-
|
|
1192
|
-
def release_latest_sha(repo_ref)
|
|
1193
|
-
versions = versions_for_repo(repo_ref)
|
|
1194
|
-
latest = versions.first
|
|
1195
|
-
latest ? version_entry_sha(repo_ref, latest) : nil
|
|
1196
|
-
end
|
|
1197
|
-
|
|
1198
|
-
private
|
|
1199
|
-
|
|
1200
|
-
def cached_versions(repo_ref, stale)
|
|
1201
|
-
versions = stale || []
|
|
1202
|
-
@release_cache[repo_ref] = versions
|
|
1203
|
-
versions
|
|
1204
|
-
end
|
|
1205
|
-
|
|
1206
|
-
def build_release_versions(data, tag_shas)
|
|
1207
|
-
releases = data.filter_map do |release|
|
|
1208
|
-
next unless release.is_a?(Hash)
|
|
1209
|
-
|
|
1210
|
-
tag = release["tag_name"].to_s
|
|
1211
|
-
parsed = parse_release_version_text(tag)
|
|
1212
|
-
next unless parsed
|
|
1213
|
-
|
|
1214
|
-
{
|
|
1215
|
-
tag: tag,
|
|
1216
|
-
version_obj: parsed,
|
|
1217
|
-
version: parsed.to_s,
|
|
1218
|
-
sha: tag_shas[tag]
|
|
1219
|
-
}
|
|
1220
|
-
end
|
|
1221
|
-
released_tags = releases.each_with_object({}) { |release, memo| memo[release[:tag]] = true }
|
|
1222
|
-
tag_versions = tag_shas.filter_map do |tag, sha|
|
|
1223
|
-
next if released_tags[tag]
|
|
1224
|
-
|
|
1225
|
-
parsed = parse_release_version_text(tag)
|
|
1226
|
-
next unless parsed
|
|
1227
|
-
|
|
1228
|
-
{
|
|
1229
|
-
tag: tag,
|
|
1230
|
-
version_obj: parsed,
|
|
1231
|
-
version: parsed.to_s,
|
|
1232
|
-
sha: sha
|
|
1233
|
-
}
|
|
1234
|
-
end
|
|
1235
|
-
releases.concat(tag_versions)
|
|
1236
|
-
releases = canonicalize_equivalent_release_versions(releases)
|
|
1237
|
-
|
|
1238
|
-
releases.sort_by! { |release| GhaShaPinsCLI.release_version_sort_key(release) }
|
|
1239
|
-
releases.reverse!
|
|
1240
|
-
releases
|
|
1241
|
-
end
|
|
1242
|
-
|
|
1243
|
-
def canonicalize_equivalent_release_versions(releases)
|
|
1244
|
-
groups = []
|
|
1245
|
-
releases.each do |release|
|
|
1246
|
-
group = groups.find { |entries| equivalent_release_tag?(entries.first, release) }
|
|
1247
|
-
if group
|
|
1248
|
-
group << release
|
|
1249
|
-
else
|
|
1250
|
-
groups << [release]
|
|
1251
|
-
end
|
|
1252
|
-
end
|
|
1253
|
-
|
|
1254
|
-
groups.map { |entries| entries.max_by { |entry| GhaShaPinsCLI.release_version_sort_key(entry) } }
|
|
1255
|
-
end
|
|
1256
|
-
|
|
1257
|
-
def equivalent_release_tag?(left, right)
|
|
1258
|
-
left[:version_obj] == right[:version_obj] &&
|
|
1259
|
-
left[:sha] &&
|
|
1260
|
-
right[:sha] &&
|
|
1261
|
-
left[:sha] == right[:sha]
|
|
1262
|
-
end
|
|
1263
|
-
|
|
1264
|
-
def parse_release_version_text(value)
|
|
1265
|
-
normalized = value.to_s.sub(/\A[vV]/, "")
|
|
1266
|
-
return nil unless normalized.match?(/\A(?:\d+|\d+\.\d+\.\d+(?:[-.]?[0-9A-Za-z.-]+)?)\z/)
|
|
1267
|
-
|
|
1268
|
-
Gem::Version.new(normalized)
|
|
1269
|
-
rescue ArgumentError
|
|
1270
|
-
nil
|
|
1271
|
-
end
|
|
1272
|
-
|
|
1273
|
-
def tag_ref_shas(repo_ref)
|
|
1274
|
-
data = request_json("/repos/#{repo_ref}/git/matching-refs/tags/")
|
|
1275
|
-
return nil unless data.is_a?(Array)
|
|
1276
|
-
|
|
1277
|
-
data.each_with_object({}) do |entry, memo|
|
|
1278
|
-
ref = entry["ref"].to_s
|
|
1279
|
-
next unless ref.start_with?("refs/tags/")
|
|
1280
|
-
|
|
1281
|
-
tag = ref.sub(%r{\Arefs/tags/}, "")
|
|
1282
|
-
next unless parse_release_version_text(tag)
|
|
1283
|
-
|
|
1284
|
-
object = entry["object"]
|
|
1285
|
-
next unless object.is_a?(Hash)
|
|
1286
|
-
|
|
1287
|
-
sha = object["sha"].to_s[0, 40]
|
|
1288
|
-
case object["type"]
|
|
1289
|
-
when "commit"
|
|
1290
|
-
memo[tag] = sha
|
|
1291
|
-
when "tag"
|
|
1292
|
-
memo[tag] = nil
|
|
1293
|
-
end
|
|
1294
|
-
end
|
|
1295
|
-
end
|
|
1296
|
-
|
|
1297
|
-
def annotated_tag_commit_sha(repo_ref, tag_sha)
|
|
1298
|
-
return nil if tag_sha.to_s.empty?
|
|
1299
|
-
|
|
1300
|
-
data = request_json("/repos/#{repo_ref}/git/tags/#{tag_sha}")
|
|
1301
|
-
return nil unless data.is_a?(Hash)
|
|
1302
|
-
|
|
1303
|
-
object = data["object"]
|
|
1304
|
-
return nil unless object.is_a?(Hash)
|
|
1305
|
-
return nil unless object["type"] == "commit"
|
|
1306
|
-
|
|
1307
|
-
object["sha"].to_s[0, 40]
|
|
1308
|
-
end
|
|
1309
|
-
|
|
1310
|
-
def request_json(path, redirects: 3)
|
|
1311
|
-
uri = URI.join(@api_base + "/", path)
|
|
1312
|
-
|
|
1313
|
-
response = nil
|
|
1314
|
-
loop do
|
|
1315
|
-
request = Net::HTTP::Get.new(uri)
|
|
1316
|
-
request["Accept"] = "application/vnd.github+json"
|
|
1317
|
-
request["User-Agent"] = @user_agent
|
|
1318
|
-
request["X-GitHub-Api-Version"] = "2022-11-28"
|
|
1319
|
-
request["Authorization"] = "Bearer #{@token}" if @token && !@token.empty?
|
|
1320
|
-
|
|
1321
|
-
response = http_request(uri, request)
|
|
1322
|
-
|
|
1323
|
-
break unless response.code.to_i.between?(300, 399)
|
|
1324
|
-
redirects -= 1
|
|
1325
|
-
return nil if redirects.negative?
|
|
1326
|
-
|
|
1327
|
-
location = response["location"].to_s
|
|
1328
|
-
return nil if location.empty?
|
|
1329
|
-
|
|
1330
|
-
uri = URI.join(uri.to_s, location)
|
|
1331
|
-
end
|
|
1332
|
-
|
|
1333
|
-
return nil unless response.code.to_i == 200
|
|
1334
|
-
|
|
1335
|
-
begin
|
|
1336
|
-
JSON.parse(response.body)
|
|
1337
|
-
rescue JSON::ParserError
|
|
1338
|
-
nil
|
|
1339
|
-
end
|
|
1340
|
-
rescue IOError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout
|
|
1341
|
-
nil
|
|
1342
|
-
end
|
|
1343
|
-
|
|
1344
|
-
def http_request(uri, request)
|
|
1345
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
1346
|
-
http.use_ssl = uri.scheme == "https"
|
|
1347
|
-
http.open_timeout = @open_timeout
|
|
1348
|
-
http.read_timeout = @read_timeout
|
|
1349
|
-
http.ssl_timeout = @open_timeout if http.respond_to?(:ssl_timeout=)
|
|
1350
|
-
http.start { |connection| connection.request(request) }
|
|
1351
|
-
end
|
|
1352
|
-
|
|
1353
|
-
def uri_encode(value)
|
|
1354
|
-
URI.encode_www_form_component(value)
|
|
1355
|
-
end
|
|
1356
|
-
|
|
1357
|
-
def version_entry_sha(repo_ref, entry)
|
|
1358
|
-
return nil unless entry
|
|
1359
|
-
return entry[:sha] unless entry[:sha].to_s.empty?
|
|
1360
|
-
|
|
1361
|
-
entry[:sha] = commit_sha(repo_ref, entry[:tag])
|
|
5
|
+
begin
|
|
6
|
+
require "kettle/gha/pins/cli"
|
|
7
|
+
GhaShaPinsCLI = Kettle::Gha::Pins::CLI
|
|
8
|
+
rescue LoadError
|
|
9
|
+
# Compatibility wrapper for unusual installs that omit the runtime dependency.
|
|
10
|
+
class GhaShaPinsCLI
|
|
11
|
+
def initialize(*)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run!
|
|
15
|
+
warn("kettle-gha-pins is not available; install the kettle-gha-pins gem to validate GitHub Actions SHA pins.")
|
|
16
|
+
1
|
|
1362
17
|
end
|
|
1363
18
|
end
|
|
1364
19
|
end
|