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.
@@ -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
- # CLI to scan GitHub Action workflow files and pin mutable references in `uses:` to commit SHAs.
19
- class GhaShaPinsCLI
20
- API_BASE = "https://api.github.com"
21
- RELEASE_PATH = "releases/latest"
22
- SHA_RE = /\A[0-9a-f]{40}\z/i
23
- WEAK_SHA_RE = /\A[0-9a-f]{7,39}\z/i
24
-
25
- NON_SHA_REASON = "convert_to_sha"
26
- STALE_SHA_REASON = "upgrade_to_latest_release_sha"
27
- UPGRADE_REASON = "upgrade_to_allowed_release"
28
- COMMENT_REASON = "update_version_comment"
29
- DEFAULT_UPGRADE_LEVEL = "patch"
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