kettle-dev 2.1.1 → 2.2.1

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,1225 @@
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 ref_sha(repo_ref, ref, fresh: true)
961
+ action = action_data(repo_ref)
962
+ return nil unless action
963
+
964
+ refs = action.fetch("refs", {})
965
+ entry = refs[ref.to_s]
966
+ return nil unless entry
967
+ return nil if fresh && !fresh_entry?(entry)
968
+
969
+ sha = entry["sha"].to_s
970
+ sha.empty? ? nil : sha
971
+ end
972
+
973
+ def write_ref_sha(repo_ref, ref, sha)
974
+ return if @path.to_s.empty?
975
+ return if repo_ref.to_s.empty? || ref.to_s.empty? || sha.to_s.empty?
976
+
977
+ action = data.fetch("actions")[repo_ref] ||= {}
978
+ refs = action["refs"] ||= {}
979
+ refs[ref.to_s] = {
980
+ "sha" => sha.to_s[0, 40],
981
+ "cached_at" => @clock.call.utc.iso8601
982
+ }
983
+ save!
984
+ end
985
+
986
+ def to_h
987
+ data
988
+ end
989
+
990
+ private
991
+
992
+ def data
993
+ @data ||= load_data
994
+ end
995
+
996
+ def action_data(repo_ref)
997
+ data.fetch("actions")[repo_ref]
998
+ end
999
+
1000
+ def load_data
1001
+ parsed = if @path && File.file?(@path)
1002
+ JSON.parse(File.read(@path))
1003
+ end
1004
+ return empty_data unless parsed.is_a?(Hash)
1005
+
1006
+ parsed["version"] ||= VERSION
1007
+ parsed["actions"] = {} unless parsed["actions"].is_a?(Hash)
1008
+ parsed
1009
+ rescue JSON::ParserError, Errno::EACCES
1010
+ empty_data
1011
+ end
1012
+
1013
+ def empty_data
1014
+ {"version" => VERSION, "actions" => {}}
1015
+ end
1016
+
1017
+ def save!
1018
+ FileUtils.mkdir_p(File.dirname(@path))
1019
+ File.write(@path, JSON.pretty_generate(data) + "\n")
1020
+ end
1021
+
1022
+ def deserialize_version_entry(entry)
1023
+ version = entry["version"].to_s
1024
+ parsed = parse_version(version)
1025
+ return nil unless parsed
1026
+
1027
+ {
1028
+ tag: entry["tag"].to_s,
1029
+ version_obj: parsed,
1030
+ version: version,
1031
+ sha: entry["sha"].to_s
1032
+ }
1033
+ end
1034
+
1035
+ def target_cache(version_entries)
1036
+ entries = version_entries.filter_map do |entry|
1037
+ deserialized = deserialize_version_entry(entry)
1038
+ next unless deserialized
1039
+
1040
+ deserialized.merge(cached_at: entry["cached_at"].to_s)
1041
+ end
1042
+
1043
+ {
1044
+ "patch" => entries.group_by { |entry| entry[:version_obj].segments[0, 2].join(".") }
1045
+ .transform_values { |group| serialize_target(group.max_by { |entry| entry[:version_obj] }) },
1046
+ "minor" => entries.group_by { |entry| entry[:version_obj].segments[0].to_s }
1047
+ .transform_values { |group| serialize_target(group.max_by { |entry| entry[:version_obj] }) },
1048
+ "major" => {"*" => serialize_target(entries.max_by { |entry| entry[:version_obj] })}
1049
+ }
1050
+ end
1051
+
1052
+ def serialize_target(entry)
1053
+ {
1054
+ "tag" => entry[:tag],
1055
+ "version" => entry[:version],
1056
+ "sha" => entry[:sha],
1057
+ "cached_at" => entry[:cached_at]
1058
+ }
1059
+ end
1060
+
1061
+ def parse_version(value)
1062
+ Gem::Version.new(value)
1063
+ rescue ArgumentError
1064
+ nil
1065
+ end
1066
+
1067
+ def fresh_entry?(entry)
1068
+ cached_at = Time.iso8601(entry["cached_at"].to_s)
1069
+ cached_at >= @clock.call - @ttl_seconds
1070
+ rescue ArgumentError
1071
+ false
1072
+ end
1073
+ end
1074
+
1075
+ # Lightweight GitHub API client for commit and release SHA resolution.
1076
+ class GitHubClient
1077
+ def initialize(token:, api_base:, user_agent:, persistent_cache: nil, refresh_cache: false)
1078
+ @token = token
1079
+ @api_base = api_base
1080
+ @user_agent = user_agent
1081
+ @persistent_cache = persistent_cache
1082
+ @refresh_cache = refresh_cache
1083
+ @commit_cache = {}
1084
+ @release_cache = {}
1085
+ end
1086
+
1087
+ def versions_for_repo(repo_ref)
1088
+ return [] if repo_ref.to_s.empty?
1089
+ return @release_cache[repo_ref] if @release_cache.key?(repo_ref)
1090
+
1091
+ unless @refresh_cache
1092
+ cached = @persistent_cache&.versions_for_repo(repo_ref, fresh: true)
1093
+ if cached
1094
+ @release_cache[repo_ref] = cached
1095
+ return cached
1096
+ end
1097
+ end
1098
+
1099
+ data = request_json("/repos/#{repo_ref}/releases?per_page=100")
1100
+ unless data.is_a?(Array)
1101
+ fallback = @persistent_cache&.versions_for_repo(repo_ref, fresh: false)
1102
+ return fallback if fallback
1103
+
1104
+ return []
1105
+ end
1106
+
1107
+ tag_shas = tag_ref_shas(repo_ref)
1108
+ releases = data.filter_map do |release|
1109
+ next unless release.is_a?(Hash)
1110
+ next if release["prerelease"] == true
1111
+
1112
+ tag = release["tag_name"].to_s
1113
+ parsed = parse_release_version_text(tag)
1114
+ next unless parsed
1115
+
1116
+ {
1117
+ tag: tag,
1118
+ version_obj: parsed,
1119
+ version: parsed.to_s,
1120
+ sha: tag_shas[tag]
1121
+ }
1122
+ end
1123
+
1124
+ releases.sort_by! { |release| release[:version_obj] }
1125
+ releases.reverse!
1126
+ @persistent_cache&.write_versions(repo_ref, releases)
1127
+ @release_cache[repo_ref] = releases
1128
+ releases
1129
+ end
1130
+
1131
+ def commit_sha(repo_ref, ref)
1132
+ return nil if repo_ref.to_s.empty? || ref.to_s.empty?
1133
+
1134
+ cache_key = "commit:#{repo_ref}:#{ref}"
1135
+ return @commit_cache[cache_key] if @commit_cache.key?(cache_key)
1136
+
1137
+ unless @refresh_cache
1138
+ cached = @persistent_cache&.ref_sha(repo_ref, ref, fresh: true)
1139
+ if cached
1140
+ @commit_cache[cache_key] = cached
1141
+ return cached
1142
+ end
1143
+ end
1144
+
1145
+ data = request_json("/repos/#{repo_ref}/commits/#{uri_encode(ref)}")
1146
+ sha = if data.is_a?(Hash)
1147
+ data.fetch("sha", "")[0, 40]
1148
+ end
1149
+ if sha.to_s.empty?
1150
+ sha = @persistent_cache&.ref_sha(repo_ref, ref, fresh: false)
1151
+ else
1152
+ @persistent_cache&.write_ref_sha(repo_ref, ref, sha)
1153
+ end
1154
+ @commit_cache[cache_key] = sha
1155
+ sha
1156
+ end
1157
+
1158
+ def release_latest_sha(repo_ref)
1159
+ versions = versions_for_repo(repo_ref)
1160
+ latest = versions.first
1161
+ latest ? version_entry_sha(repo_ref, latest) : nil
1162
+ end
1163
+
1164
+ private
1165
+
1166
+ def parse_release_version_text(value)
1167
+ normalized = value.to_s.sub(/\A[vV]/, "")
1168
+ return nil unless normalized.match?(/\A\d+\.\d+\.\d+(?:[-.]?[0-9A-Za-z.-]+)?\z/)
1169
+
1170
+ Gem::Version.new(normalized)
1171
+ rescue ArgumentError
1172
+ nil
1173
+ end
1174
+
1175
+ def tag_ref_shas(repo_ref)
1176
+ data = request_json("/repos/#{repo_ref}/git/matching-refs/tags/")
1177
+ return {} unless data.is_a?(Array)
1178
+
1179
+ data.each_with_object({}) do |entry, memo|
1180
+ ref = entry["ref"].to_s
1181
+ next unless ref.start_with?("refs/tags/")
1182
+
1183
+ tag = ref.sub(%r{\Arefs/tags/}, "")
1184
+ object = entry["object"]
1185
+ next unless object.is_a?(Hash)
1186
+
1187
+ memo[tag] = object["sha"].to_s[0, 40] if object["type"] == "commit"
1188
+ end
1189
+ end
1190
+
1191
+ def request_json(path)
1192
+ uri = URI.join(@api_base + "/", path)
1193
+ request = Net::HTTP::Get.new(uri)
1194
+ request["Accept"] = "application/vnd.github+json"
1195
+ request["User-Agent"] = @user_agent
1196
+ request["X-GitHub-Api-Version"] = "2022-11-28"
1197
+ request["Authorization"] = "Bearer #{@token}" if @token && !@token.empty?
1198
+
1199
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
1200
+ http.request(request)
1201
+ end
1202
+
1203
+ return nil unless response.code.to_i == 200
1204
+
1205
+ begin
1206
+ JSON.parse(response.body)
1207
+ rescue JSON::ParserError
1208
+ nil
1209
+ end
1210
+ end
1211
+
1212
+ def uri_encode(value)
1213
+ URI.encode_www_form_component(value)
1214
+ end
1215
+
1216
+ def version_entry_sha(repo_ref, entry)
1217
+ return nil unless entry
1218
+ return entry[:sha] unless entry[:sha].to_s.empty?
1219
+
1220
+ entry[:sha] = commit_sha(repo_ref, entry[:tag])
1221
+ end
1222
+ end
1223
+ end
1224
+ end
1225
+ end