kettle-gha-pins 0.3.9 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 40a359f61e73b1b17ee62829c001c9690844f8ccfb944b776ecb21cbd402b382
4
- data.tar.gz: c5f15b58492474aefb12423903e34e3b4107f8e01dc92d964a0f0ad9c8320408
3
+ metadata.gz: e39d2df910b12d4b40d0978f63481a73d87a5efcdf6ea58c4966b397d3319470
4
+ data.tar.gz: 584ac1b59952635a279806c03aba51460a757e9edd2dbcafc5b7f158b8a302dd
5
5
  SHA512:
6
- metadata.gz: 786283e4de7a16524b78e7c30552a8622465d559df4edeb8cbfd1808dda7c3cd7c5ad730c19f5d3c35cc328c11cb9829f0fc056f531e787997961bb442ee97ca
7
- data.tar.gz: c8d901235c8b8ebb5e9aae9fa0918a58965abdd1a61f612a76985df9068ddfa5aaac871e7ace466a2732a0a03821cb3352be232641e692283c4083287b8b4beb
6
+ metadata.gz: 43c208e30cd0c6501479f4cf52a48a3e4df4b9192ada22b05017681a4761ffe01ca4abdb0398cf8c523828a32814ef9fcd51cf57f2be46f7ff55b81c89e05237
7
+ data.tar.gz: a47e19be556a73215a786b4e9fe23b50ac1890e5087ed0b5eea1a14e8e7f9dd1ce8da62d8e95a329949a6e339aacda9c1b8e12cabd0debe167e301ed12ca96f9
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,21 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [0.3.10] - 2026-08-09
34
+
35
+ - TAG: [v0.3.10][0.3.10t]
36
+ - COVERAGE: 98.08% -- 1022/1042 lines in 8 files
37
+ - BRANCH COVERAGE: 88.03% -- 412/468 branches in 8 files
38
+ - 26.17% documented
39
+
40
+ ### Added
41
+
42
+ - Add JSON action inventory and cache review modes, strict offline pin resolution, and configurable action metadata TTL handling.
43
+
44
+ ### Fixed
45
+
46
+ - Keep JSON action inventory and review parsing compatible with Ruby 2.5.
47
+
33
48
  ## [0.3.9] - 2026-08-05
34
49
 
35
50
  - TAG: [v0.3.9][0.3.9t]
@@ -282,7 +297,9 @@ Please file a bug if you notice a violation of semantic versioning.
282
297
  - kettle-jem-template-20260720-005 - Generated README Support & Community rows
283
298
  now include a RubyForum help badge.
284
299
 
285
- [Unreleased]: https://github.com/kettle-dev/kettle-gha-pins/compare/v0.3.9...HEAD
300
+ [Unreleased]: https://github.com/kettle-dev/kettle-gha-pins/compare/v0.3.10...HEAD
301
+ [0.3.10]: https://github.com/kettle-dev/kettle-gha-pins/compare/v0.3.9...v0.3.10
302
+ [0.3.10t]: https://github.com/kettle-dev/kettle-gha-pins/releases/tag/v0.3.10
286
303
  [0.3.9]: https://github.com/kettle-dev/kettle-gha-pins/compare/v0.3.8...v0.3.9
287
304
  [0.3.9t]: https://github.com/kettle-dev/kettle-gha-pins/releases/tag/v0.3.9
288
305
  [0.3.8]: https://github.com/kettle-dev/kettle-gha-pins/compare/v0.3.7...v0.3.8
data/README.md CHANGED
@@ -155,6 +155,17 @@ cooldown window. The same default can be supplied with
155
155
  `KETTLE_GHA_PINS_COOLDOWN_DAYS`. The default is `0`, so checks remain strict
156
156
  unless a project opts into a warning window.
157
157
 
158
+ The action metadata cache uses a one-day TTL by default. Set `--ttl DAYS` to
159
+ change it; `--ttl 0` forces a refresh. `--offline` trusts the cache without
160
+ checking TTL or contacting GitHub and fails if required repository metadata is
161
+ missing.
162
+
163
+ For family orchestration, `kettle-gha-pins --list --json` emits the action
164
+ repositories used by a project without network access. A separate
165
+ `kettle-gha-pins --review --input PATH` refreshes the repositories listed in
166
+ that JSON and updates the shared cache. Review refreshes metadata independently
167
+ of the selected major/minor/patch upgrade policy.
168
+
158
169
  `Kettle::Gha::Pins::PersistentActionCache.default_path` intentionally preserves
159
170
  the historical `kettle-gha-sha-pins` cache location so command-line tools can
160
171
  share action metadata without coupling to each other.
@@ -188,6 +199,14 @@ plan[:updates]
188
199
  # => {sha: "...", version: "7.0.0", reason: "upgrade_to_allowed_release"}
189
200
  ```
190
201
 
202
+ The family-oriented cache workflow is:
203
+
204
+ ```console
205
+ kettle-gha-pins --list --json
206
+ kettle-gha-pins --review --input pins.json
207
+ kettle-gha-pins --offline --write --upgrade major
208
+ ```
209
+
191
210
  When two version-equivalent tags point at the same SHA, the rubric keeps the
192
211
  more explicit release spelling. For example, `v7` and `v7.0.0` pointing at the
193
212
  same commit canonicalize to `v7.0.0`; if they point at different SHAs, both
@@ -563,7 +582,7 @@ Thanks for RTFM. ☺️
563
582
  [📌gitmoji]: https://gitmoji.dev
564
583
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
565
584
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
566
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-0.977-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
585
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-1.042-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
567
586
  [🔐security]: https://github.com/kettle-dev/kettle-gha-pins/blob/main/SECURITY.md
568
587
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
569
588
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -67,9 +67,13 @@ module Kettle
67
67
  user_agent: "kettle-gha-pins",
68
68
  upgrade: DEFAULT_UPGRADE_LEVEL,
69
69
  cache_path: ENV["KETTLE_GHA_SHA_PINS_CACHE"] || PersistentActionCache.default_path,
70
+ ttl_days: DEFAULT_CACHE_TTL_SECONDS / 86_400.0,
70
71
  cooldown_days: cooldown_days,
71
72
  clock: clock,
72
73
  refresh_cache: false,
74
+ offline: false,
75
+ mode: :project,
76
+ input: nil,
73
77
  reject_patterns: Set.new,
74
78
  progress: nil
75
79
  }
@@ -78,20 +82,43 @@ module Kettle
78
82
  def run!
79
83
  parse!
80
84
 
81
- @options[:token] ||= gh_auth_token if @options[:api_base] == API_BASE
85
+ return list_workflows! if @options[:mode] == :list
86
+
87
+ client, persistent_cache = build_client
88
+ return review_cache!(client: client, persistent_cache: persistent_cache) if @options[:mode] == :review
89
+
90
+ run_project!(client: client)
91
+ rescue Error, GitHubClient::CacheMissError, GitHubClient::RefreshError => error
92
+ @err.puts("kettle-gha-pins: #{error.message}")
93
+ 2
94
+ end
95
+
96
+ private
97
+
98
+ def build_client
99
+ @options[:token] ||= gh_auth_token if @options[:api_base] == API_BASE && !@options[:offline]
82
100
  persistent_cache = if @options[:cache_path].to_s.empty?
83
101
  nil
84
102
  else
85
- PersistentActionCache.new(path: @options[:cache_path])
103
+ PersistentActionCache.new(
104
+ path: @options[:cache_path],
105
+ ttl_seconds: (@options[:ttl_days] * 86_400).to_i,
106
+ clock: @options[:clock]
107
+ )
86
108
  end
87
109
  client = GitHubClient.new(
88
110
  token: @options[:token],
89
111
  api_base: @options[:api_base],
90
112
  user_agent: @options[:user_agent],
91
113
  persistent_cache: persistent_cache,
92
- refresh_cache: @options[:refresh_cache]
114
+ refresh_cache: @options[:refresh_cache],
115
+ offline: @options[:offline],
116
+ fail_on_refresh_error: @options[:mode] == :review
93
117
  )
118
+ [client, persistent_cache]
119
+ end
94
120
 
121
+ def run_project!(client:)
95
122
  state = {
96
123
  files_scanned: 0,
97
124
  files_with_changes: 0,
@@ -293,7 +320,79 @@ module Kettle
293
320
  0
294
321
  end
295
322
 
296
- private
323
+ def list_workflows!
324
+ state = {files_scanned: 0, failures: 0, errors: []}
325
+ workflow_files = discover_workflow_files(@options[:root], @options[:reject_patterns])
326
+ workflows = load_workflows(workflow_files, state)
327
+ pins = workflows.flat_map do |workflow|
328
+ workflow.fetch(:uses_nodes).each_with_object([]) do |node, entries|
329
+ parsed = classify_action_ref(node[:value].to_s)
330
+ next unless parsed
331
+
332
+ action = parsed.fetch(:action)
333
+ entries << {
334
+ "repository" => "#{action[:owner]}/#{action[:repo]}",
335
+ "ref" => action[:ref],
336
+ "value" => parsed[:value],
337
+ "path" => workflow[:path],
338
+ "line" => node[:line].to_i + 1
339
+ }
340
+ end
341
+ end
342
+ repositories = pins.map { |pin| pin.fetch("repository") }.uniq.sort
343
+ puts JSON.pretty_generate(
344
+ "schema_version" => 1,
345
+ "root" => @options[:root],
346
+ "repositories" => repositories,
347
+ "pins" => pins
348
+ )
349
+ state[:failures].zero? ? 0 : 2
350
+ end
351
+
352
+ def review_cache!(client:, persistent_cache:)
353
+ raise Error, "--review requires a writable cache; do not use --cache-path \"\"" unless persistent_cache
354
+
355
+ repositories = review_repositories
356
+ refreshed = repositories.map do |repository|
357
+ versions = client.versions_for_repo(repository)
358
+ {
359
+ "repository" => repository,
360
+ "versions" => versions.length,
361
+ "cache" => client.last_versions_cache_hit ? "hit" : "refreshed"
362
+ }
363
+ end
364
+ puts JSON.pretty_generate(
365
+ "schema_version" => 1,
366
+ "mode" => "review",
367
+ "ttl_days" => @options[:ttl_days],
368
+ "repositories" => refreshed
369
+ )
370
+ 0
371
+ end
372
+
373
+ def review_repositories
374
+ source = @options[:input].to_s
375
+ source = if source.empty? || source == "-"
376
+ $stdin.read
377
+ else
378
+ File.read(source)
379
+ end
380
+ payload = JSON.parse(source)
381
+ entries = if payload.is_a?(Array)
382
+ payload
383
+ elsif payload.is_a?(Hash)
384
+ payload["repositories"] || payload["actions"] || payload["pins"] || []
385
+ else
386
+ []
387
+ end
388
+ entries.each_with_object([]) do |entry, repositories|
389
+ value = entry.is_a?(Hash) ? (entry["repository"] || entry["action"] || entry["value"]) : entry
390
+ repository = value.to_s.split("@", 2).first
391
+ repositories << repository if repository.match?(%r{\A[^/\s]+/[^/\s]+\z})
392
+ end.uniq.sort
393
+ rescue JSON::ParserError => error
394
+ raise Error, "invalid review JSON: #{error.message}"
395
+ end
297
396
 
298
397
  def parse!
299
398
  parser = OptionParser.new do |opt|
@@ -328,6 +427,27 @@ module Kettle
328
427
  opt.on("--refresh-cache", "Bypass cached action release data and refresh discovered actions") do
329
428
  @options[:refresh_cache] = true
330
429
  end
430
+ opt.on("--ttl DAYS", Float, "Cache TTL in days (default: #{@options[:ttl_days]})") do |days|
431
+ Kernel.abort("Invalid --ttl value #{days.inspect}; use a non-negative number") if days.negative?
432
+
433
+ @options[:ttl_days] = days
434
+ end
435
+ opt.on("--offline", "Use only the persistent cache; fail on a cache miss") do
436
+ @options[:offline] = true
437
+ end
438
+ opt.on("--list", "List project action pins as JSON without network access") do
439
+ raise OptionParser::InvalidOption, "--list cannot be combined with --review" if @options[:mode] == :review
440
+
441
+ @options[:mode] = :list
442
+ end
443
+ opt.on("--review", "Refresh action metadata from JSON input and update the cache") do
444
+ raise OptionParser::InvalidOption, "--review cannot be combined with --list" if @options[:mode] == :list
445
+
446
+ @options[:mode] = :review
447
+ end
448
+ opt.on("--input PATH", "Review JSON input path, or - for stdin") do |path|
449
+ @options[:input] = path
450
+ end
331
451
  opt.on("--cache-path PATH", "Action release cache path (default: #{@options[:cache_path]})") do |path|
332
452
  @options[:cache_path] = path
333
453
  end
@@ -356,6 +476,9 @@ module Kettle
356
476
  end
357
477
  end
358
478
  parser.parse!(@argv)
479
+ if @options[:mode] == :review && @options[:offline]
480
+ raise OptionParser::InvalidOption, "--review cannot be combined with --offline"
481
+ end
359
482
  end
360
483
 
361
484
  def display_path(path)
@@ -10,12 +10,17 @@ module Kettle
10
10
  module Pins
11
11
  # Lightweight GitHub API client for commit and release SHA resolution.
12
12
  class GitHubClient
13
- 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)
13
+ class CacheMissError < Error; end
14
+ class RefreshError < Error; end
15
+
16
+ def initialize(token:, api_base:, user_agent:, persistent_cache: nil, refresh_cache: false, offline: false, fail_on_refresh_error: false, open_timeout: DEFAULT_HTTP_OPEN_TIMEOUT_SECONDS, read_timeout: DEFAULT_HTTP_READ_TIMEOUT_SECONDS, refresh_timeout: DEFAULT_HTTP_REFRESH_TIMEOUT_SECONDS)
14
17
  @token = token
15
18
  @api_base = api_base
16
19
  @user_agent = user_agent
17
20
  @persistent_cache = persistent_cache
18
21
  @refresh_cache = refresh_cache
22
+ @offline = offline
23
+ @fail_on_refresh_error = fail_on_refresh_error
19
24
  @open_timeout = open_timeout
20
25
  @read_timeout = read_timeout
21
26
  @refresh_timeout = refresh_timeout
@@ -34,15 +39,22 @@ module Kettle
34
39
  return @release_cache[repo_ref]
35
40
  end
36
41
 
42
+ if @offline
43
+ cached = @persistent_cache&.versions_for_repo(repo_ref, fresh: false)
44
+ raise CacheMissError, "offline cache miss for #{repo_ref}" if cached.nil?
45
+
46
+ @last_versions_cache_hit = true
47
+ @release_cache[repo_ref] = cached
48
+ return cached
49
+ end
50
+
37
51
  stale = nil
38
52
  unless @refresh_cache
39
53
  cached = @persistent_cache&.versions_for_repo(repo_ref, fresh: true)
40
54
  if cached
41
- unless cache_requires_refresh?(repo_ref, cached)
42
- @last_versions_cache_hit = true
43
- @release_cache[repo_ref] = cached
44
- return cached
45
- end
55
+ @last_versions_cache_hit = true
56
+ @release_cache[repo_ref] = cached
57
+ return cached
46
58
  end
47
59
  stale = @persistent_cache&.versions_for_repo(repo_ref, fresh: false)
48
60
  end
@@ -50,10 +62,10 @@ module Kettle
50
62
  releases = nil
51
63
  Timeout.timeout(@refresh_timeout) do
52
64
  data = request_json("/repos/#{repo_ref}/releases?per_page=100")
53
- return cached_versions(repo_ref, stale) unless data.is_a?(Array)
65
+ return refresh_failed!(repo_ref, stale, "invalid releases response") unless data.is_a?(Array)
54
66
 
55
67
  tag_shas = tag_ref_shas(repo_ref)
56
- return cached_versions(repo_ref, stale) unless tag_shas
68
+ return refresh_failed!(repo_ref, stale, "invalid tag response") unless tag_shas
57
69
 
58
70
  releases = build_release_versions(data, tag_shas)
59
71
  end
@@ -61,7 +73,7 @@ module Kettle
61
73
  @release_cache[repo_ref] = releases
62
74
  releases
63
75
  rescue Timeout::Error
64
- cached_versions(repo_ref, stale)
76
+ refresh_failed!(repo_ref, stale, "GitHub refresh timed out")
65
77
  end
66
78
 
67
79
  def commit_sha(repo_ref, ref, refresh: false)
@@ -71,13 +83,15 @@ module Kettle
71
83
  return @commit_cache[cache_key] if @commit_cache.key?(cache_key) && !refresh
72
84
 
73
85
  unless @refresh_cache || refresh
74
- cached = @persistent_cache&.ref_sha(repo_ref, ref, fresh: true)
86
+ cached = @persistent_cache&.ref_sha(repo_ref, ref, fresh: !@offline)
75
87
  if cached
76
88
  @commit_cache[cache_key] = cached
77
89
  return cached
78
90
  end
79
91
  end
80
92
 
93
+ raise CacheMissError, "offline cache miss for #{repo_ref}@#{ref}" if @offline
94
+
81
95
  data = request_json("/repos/#{repo_ref}/commits/#{uri_encode(ref)}")
82
96
  sha = if data.is_a?(Hash)
83
97
  data.fetch("sha", "")[0, 40]
@@ -106,24 +120,10 @@ module Kettle
106
120
  versions
107
121
  end
108
122
 
109
- # Release tags can be added or retargeted inside the cache TTL. Probe
110
- # the latest release before trusting a cached inventory, and refresh
111
- # only when that probe identifies a tag or target the cache lacks.
112
- def cache_requires_refresh?(repo_ref, cached)
113
- latest = request_json("/repos/#{repo_ref}/releases/latest")
114
- return false unless latest.is_a?(Hash)
115
-
116
- tag = latest["tag_name"].to_s
117
- return false unless VersionRubric.parse(tag)
118
-
119
- cached_entry = cached.find { |entry| entry[:tag].to_s == tag }
120
- return true unless cached_entry
121
-
122
- latest_sha = commit_sha(repo_ref, tag, refresh: true)
123
- cached_sha = cached_entry[:sha].to_s
124
- return !latest_sha.to_s.empty? if cached_sha.empty?
123
+ def refresh_failed!(repo_ref, stale, message)
124
+ return cached_versions(repo_ref, stale) unless @fail_on_refresh_error
125
125
 
126
- !latest_sha.to_s.empty? && latest_sha != cached_sha
126
+ raise RefreshError, "#{message} for #{repo_ref}"
127
127
  end
128
128
 
129
129
  def build_release_versions(data, tag_shas)
@@ -6,7 +6,7 @@ module Kettle
6
6
  # Version namespace for this gem.
7
7
  module Version
8
8
  # Current gem version.
9
- VERSION = "0.3.9"
9
+ VERSION = "0.3.10"
10
10
  end
11
11
  # Current gem version exposed at the traditional constant location.
12
12
  VERSION = Version::VERSION # Traditional Constant Location
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kettle-gha-pins
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.9
4
+ version: 0.3.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -301,10 +301,10 @@ licenses:
301
301
  - MIT
302
302
  metadata:
303
303
  homepage_uri: https://github.com/kettle-dev/kettle-gha-pins
304
- source_code_uri: https://github.com/kettle-dev/kettle-gha-pins/tree/v0.3.9
305
- changelog_uri: https://github.com/kettle-dev/kettle-gha-pins/blob/v0.3.9/CHANGELOG.md
304
+ source_code_uri: https://github.com/kettle-dev/kettle-gha-pins/tree/v0.3.10
305
+ changelog_uri: https://github.com/kettle-dev/kettle-gha-pins/blob/v0.3.10/CHANGELOG.md
306
306
  bug_tracker_uri: https://github.com/kettle-dev/kettle-gha-pins/issues
307
- documentation_uri: https://www.rubydoc.info/gems/kettle-gha-pins/0.3.9
307
+ documentation_uri: https://www.rubydoc.info/gems/kettle-gha-pins/0.3.10
308
308
  funding_uri: https://github.com/sponsors/pboling
309
309
  wiki_uri: https://github.com/kettle-dev/kettle-gha-pins/wiki
310
310
  news_uri: https://www.railsbling.com/tags/kettle-gha-pins
metadata.gz.sig CHANGED
@@ -1,2 +1 @@
1
- �딩���?ӽ���>&![����q@�۰�1�,����G�zeX��k@~���'PDW'�&���k�󷵠+�E"�z��gE
2
- @[5A
1
+ ���8����)Dz-P��k�0��W��[Xq�{j�)9܉��m��Ԥ=oh��[���+��A�)�G�oQ[�̆��vvw�&�n���CM�^�q��0����pc�_rO������G6mċF�@z]m�@CJ�&�L��v���