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