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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +67 -1
- data/README.md +46 -3
- data/SECURITY.md +1 -1
- data/exe/kettle-bump +41 -0
- data/exe/kettle-gha-sha-pins +40 -0
- data/exe/kettle-pre-release +4 -2
- data/exe/kettle-release +4 -0
- data/lib/kettle/dev/bump_cli.rb +209 -0
- data/lib/kettle/dev/ci_helpers.rb +14 -6
- data/lib/kettle/dev/ci_monitor.rb +41 -2
- data/lib/kettle/dev/gha_sha_pins_cli.rb +1186 -0
- data/lib/kettle/dev/pre_release_cli.rb +18 -6
- data/lib/kettle/dev/rakelib/spec_test.rake +22 -14
- data/lib/kettle/dev/release_cli.rb +7 -0
- data/lib/kettle/dev/version.rb +1 -1
- data/lib/kettle/dev.rb +12 -0
- data/sig/kettle/dev/ci_helpers.rbs +5 -2
- data/sig/kettle/dev/ci_monitor.rbs +3 -0
- data.tar.gz.sig +1 -1
- metadata +14 -8
- metadata.gz.sig +0 -0
|
@@ -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
|