kettle-gha-pins 0.1.0 → 0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b8c85ccd81571bf3a9bb1108fbe1edfbaaf1c777beec567590ae8bd04374f16
4
- data.tar.gz: 901a2f1cee2404a9a711cb20bbfb5b5aafcf1246671c35f51612d202d887d130
3
+ metadata.gz: 5ee4e694080aa9ce9e860fdd085015793830efc399a8ff295dd3fb6fa9dcf4dc
4
+ data.tar.gz: d9b39c2192b77c942ffcf055abc30efd7a6ae6a78c8baf274776c1473bab179f
5
5
  SHA512:
6
- metadata.gz: 853b5e757f54963d7c75edccd20529ed7196745636685f50980e855de27dc03be0262ef84918b3ec48b40c5dab3fae4a28236695a237ea75f96f7fcf23569b37
7
- data.tar.gz: bb434ac6c8219892ea94fd09a2352954fa2c71ff658a32ff77a0d2a6292cecc30853e581f56802c539697b36abc0ec318e4fc1566d68c56823ec645f4728a4ca
6
+ metadata.gz: 90f1f8ccee7b91f7fb72d4459577f0e145565a8897463ac64afa765213fb90e90af4ba2b945148e61a3a4f67e0c4718fb5a2c751ddbfa8d74d13a8b5b0082f61
7
+ data.tar.gz: a8a087d884e121de676bb70eff44f5524368fe2890afab896ff18fcd775f78887d64b482a3e395cf576e4d39023f7daef93a02412012da8788669492f16c01d9
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.2.0] - 2026-07-22
34
+
35
+ - TAG: [v0.2.0][0.2.0t]
36
+ - COVERAGE: 99.07% -- 849/857 lines in 8 files
37
+ - BRANCH COVERAGE: 92.55% -- 348/376 branches in 8 files
38
+ - 22.22% documented
39
+
40
+ ### Added
41
+
42
+ - Added the shared `PersistentActionCache`, `GitHubClient`, and action ref
43
+ resolver API so GitHub Actions pin tools can reuse cache, network, and upgrade
44
+ planning behavior without shelling out to each other.
45
+ - Added the `kettle-gha-pins` executable for scanning standard GitHub Actions
46
+ workflow YAML files and updating action refs to immutable SHAs.
47
+
33
48
  ## [0.1.0] - 2026-07-22
34
49
 
35
50
  - TAG: [v0.1.0][0.1.0t]
@@ -61,6 +76,8 @@ Please file a bug if you notice a violation of semantic versioning.
61
76
  - kettle-jem-template-20260720-005 - Generated README Support & Community rows
62
77
  now include a RubyForum help badge.
63
78
 
64
- [Unreleased]: https://github.com/kettle-dev/kettle-gha-pins/compare/v0.1.0...HEAD
79
+ [Unreleased]: https://github.com/kettle-dev/kettle-gha-pins/compare/v0.2.0...HEAD
80
+ [0.2.0]: https://github.com/kettle-dev/kettle-gha-pins/compare/v0.1.0...v0.2.0
81
+ [0.2.0t]: https://github.com/kettle-dev/kettle-gha-pins/releases/tag/v0.2.0
65
82
  [0.1.0]: https://github.com/kettle-dev/kettle-gha-pins/compare/c633526495c7db0a8721a94a71c3def0f3cc71bb...v0.1.0
66
83
  [0.1.0t]: https://github.com/kettle-dev/kettle-gha-pins/releases/tag/v0.1.0
data/README.md CHANGED
@@ -21,14 +21,22 @@ I've summarized my thoughts in [this blog post](https://dev.to/galtzo/hostile-ta
21
21
 
22
22
  ## 🌻 Synopsis <a href="https://discord.gg/3qme4XHNKN"><img alt="Galtzo FLOSS Logo by Aboling0, CC BY-SA 4.0" src="https://logos.galtzo.com/assets/images/galtzo-floss/avatar-128px.svg" width="8%" align="right"/></a> <a href="https://ruby-toolbox.com"><img alt="ruby-lang Logo, Yukihiro Matsumoto, Ruby Visual Identity Team, CC BY-SA 2.5" src="https://logos.galtzo.com/assets/images/ruby-lang/avatar-128px.svg" width="8%" align="right"/></a>
23
23
 
24
- `kettle-gha-pins` is the shared version-rubric library for kettle-dev GitHub
25
- Actions pin maintenance. It owns the deterministic rules for parsing action
26
- release tags, ordering equivalent release spellings, and selecting patch,
27
- minor, or major upgrade targets.
24
+ `kettle-gha-pins` is the shared GitHub Actions pin maintenance library for
25
+ kettle-dev tooling. It owns the action-release version rubric, persistent action
26
+ metadata cache, GitHub release/tag/commit resolution client, and ref upgrade
27
+ planner used by pin maintenance commands.
28
28
 
29
- The gem is intentionally small and API-focused. Command-line tools such as
29
+ The gem is intentionally API-focused. Command-line tools such as
30
30
  `kettle-gha-sha-pins` and template maintenance scripts can depend on this gem
31
- instead of each carrying their own subtly different version comparison logic.
31
+ instead of each carrying their own subtly different cache, network, and version
32
+ comparison logic.
33
+
34
+ For ordinary workflow pin maintenance, install this gem and run:
35
+
36
+ ```console
37
+ kettle-gha-pins --check
38
+ kettle-gha-pins --write --upgrade patch
39
+ ```
32
40
 
33
41
  ## 💡 Info you can shake a stick at
34
42
 
@@ -129,8 +137,8 @@ gem install kettle-gha-pins
129
137
 
130
138
  ## ⚙️ Configuration
131
139
 
132
- There is no global configuration for the library API. Callers pass release-tag
133
- data directly to `Kettle::Gha::Pins::VersionRubric`.
140
+ There is no global configuration for the library API. Callers pass options to
141
+ the client and cache objects they create.
134
142
 
135
143
  The shared upgrade policies are:
136
144
 
@@ -142,27 +150,37 @@ The shared upgrade policies are:
142
150
  Invalid upgrade levels normalize to `patch`, matching the historical
143
151
  `kettle-gha-sha-pins` default.
144
152
 
153
+ `Kettle::Gha::Pins::PersistentActionCache.default_path` intentionally preserves
154
+ the historical `kettle-gha-sha-pins` cache location so command-line tools can
155
+ share action metadata without coupling to each other.
156
+
145
157
  ## 🔧 Basic Usage
146
158
 
147
159
  ```ruby
148
160
  require "kettle/gha/pins"
149
161
 
150
- versions = Kettle::Gha::Pins::VersionRubric.build_release_versions(
151
- release_tags: ["v7.0.0"],
152
- tag_shas: {
153
- "v7" => "abc123...",
154
- "v7.0.0" => "abc123..."
155
- }
162
+ cache = Kettle::Gha::Pins::PersistentActionCache.new(
163
+ path: Kettle::Gha::Pins::PersistentActionCache.default_path
164
+ )
165
+
166
+ client = Kettle::Gha::Pins::GitHubClient.new(
167
+ token: ENV["GITHUB_TOKEN"],
168
+ api_base: Kettle::Gha::Pins::API_BASE,
169
+ user_agent: "my-gha-pin-tool",
170
+ persistent_cache: cache
156
171
  )
157
172
 
158
- target = Kettle::Gha::Pins::VersionRubric.choose_upgrade_target(
159
- "v6.5.0",
160
- versions,
161
- "major"
173
+ versions_by_repo = {}
174
+ plan = Kettle::Gha::Pins.resolve_action_plan(
175
+ cache: versions_by_repo,
176
+ client: client,
177
+ repo_ref: "codecov/codecov-action",
178
+ old_ref: "fb8b3582c8e4def4969c97caa2f19720cb33a72f",
179
+ upgrade_level: "major"
162
180
  )
163
181
 
164
- target[:tag]
165
- # => "v7.0.0"
182
+ plan[:updates]
183
+ # => {sha: "...", version: "7.0.0", reason: "upgrade_to_allowed_release"}
166
184
  ```
167
185
 
168
186
  When two version-equivalent tags point at the same SHA, the rubric keeps the
@@ -514,7 +532,7 @@ Thanks for RTFM. ☺️
514
532
  [📌gitmoji]: https://gitmoji.dev
515
533
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
516
534
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
517
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-0.085-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
535
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-0.857-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
518
536
  [🔐security]: https://github.com/kettle-dev/kettle-gha-pins/blob/main/SECURITY.md
519
537
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
520
538
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $stdout.sync = true
5
+ $stderr.sync = true
6
+
7
+ script_basename = File.basename(__FILE__)
8
+
9
+ begin
10
+ require "kettle/gha/pins"
11
+ rescue LoadError
12
+ repo_lib = File.expand_path("../lib", __dir__)
13
+ $LOAD_PATH.unshift(repo_lib) unless $LOAD_PATH.include?(repo_lib)
14
+ begin
15
+ require "kettle/gha/pins"
16
+ rescue LoadError => e
17
+ warn("#{script_basename}: could not load dependency: #{e.class}: #{e.message}")
18
+ warn("Hint: Install the kettle-gha-pins gem (`gem install kettle-gha-pins`) or add it to your Gemfile.")
19
+ exit(1)
20
+ end
21
+ end
22
+
23
+ begin
24
+ status = Kettle::Gha::Pins::CLI.new(ARGV).run!
25
+ exit(status)
26
+ rescue => e
27
+ warn("#{script_basename}: unexpected error: #{e.class}: #{e.message}")
28
+ warn(e.backtrace.join("\n"))
29
+ exit(1)
30
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Gha
5
+ module Pins
6
+ # Shared planning rules for converting and upgrading GitHub Action refs.
7
+ module ActionResolver
8
+ module_function
9
+
10
+ def resolve_action_plan(cache:, client:, repo_ref:, old_ref:, upgrade_level: DEFAULT_UPGRADE_LEVEL)
11
+ versions = if cache.key?(repo_ref)
12
+ cache.fetch(repo_ref)
13
+ else
14
+ cache[repo_ref] = client.versions_for_repo(repo_ref)
15
+ end
16
+
17
+ determine_upgrade_plan(
18
+ old_ref: old_ref,
19
+ repo_ref: repo_ref,
20
+ versions: versions,
21
+ upgrade_level: upgrade_level,
22
+ client: client
23
+ )
24
+ end
25
+
26
+ def determine_upgrade_plan(old_ref:, repo_ref:, versions:, upgrade_level:, client:)
27
+ level = VersionRubric.normalize_upgrade_level(upgrade_level)
28
+
29
+ current_ref = old_ref.to_s.strip
30
+ return {is_outdated: false, updates: nil, reason: nil, current_version: nil} if current_ref.empty?
31
+
32
+ available_versions = versions || []
33
+ latest = available_versions.first
34
+
35
+ current_sha = if SHA_RE.match?(current_ref) || WEAK_SHA_RE.match?(current_ref)
36
+ current_ref
37
+ else
38
+ client.commit_sha(repo_ref, current_ref)
39
+ end
40
+ parsed_current_ref = VersionRubric.parse(current_ref)
41
+ version_equivalent_entry = if parsed_current_ref
42
+ available_versions.find { |entry| entry[:version_obj] == parsed_current_ref }
43
+ end
44
+ matched_entry = matching_version_entry(available_versions, current_ref, current_sha, client, repo_ref)
45
+ unresolved_version_ref = false
46
+ if matched_entry.nil? && current_sha.to_s.empty? && version_equivalent_entry && non_sha?(current_ref)
47
+ matched_entry = version_equivalent_entry
48
+ unresolved_version_ref = true
49
+ end
50
+ current_version = matched_entry ? matched_entry[:version] : nil
51
+
52
+ updates = nil
53
+ reason = nil
54
+ is_outdated = false
55
+ latest_outdated = nil
56
+
57
+ if current_version
58
+ latest_outdated = VersionRubric.latest_outdated_target(current_version, available_versions)
59
+ target = VersionRubric.choose_upgrade_target(current_version, available_versions, level)
60
+ target_sha = target ? version_entry_sha(target, client, repo_ref) : nil
61
+ latest_outdated_sha = latest_outdated ? version_entry_sha(latest_outdated, client, repo_ref) : nil
62
+ if latest_outdated && stale_sha?(current_ref, latest_outdated_sha)
63
+ latest_outdated = latest_outdated.merge(sha: latest_outdated_sha)
64
+ is_outdated = true
65
+ reason = UPGRADE_REASON
66
+ end
67
+ if target && stale_sha?(current_ref, target_sha)
68
+ updates = {
69
+ sha: target_sha,
70
+ version: target[:version],
71
+ reason: UPGRADE_REASON
72
+ }
73
+ reason ||= UPGRADE_REASON
74
+ end
75
+ if updates.nil? && unresolved_version_ref
76
+ matched_sha = version_entry_sha(matched_entry, client, repo_ref)
77
+ if stale_sha?(current_ref, matched_sha)
78
+ updates = {
79
+ sha: matched_sha,
80
+ version: nil,
81
+ reason: NON_SHA_REASON
82
+ }
83
+ latest_outdated ||= matched_entry.merge(sha: matched_sha)
84
+ is_outdated = true
85
+ reason ||= NON_SHA_REASON
86
+ end
87
+ end
88
+ elsif current_sha && non_sha?(current_ref)
89
+ if stale_sha?(current_ref, current_sha)
90
+ updates = {
91
+ sha: current_sha,
92
+ version: nil,
93
+ reason: NON_SHA_REASON
94
+ }
95
+ reason = NON_SHA_REASON
96
+ end
97
+ elsif current_sha
98
+ latest_sha = latest ? version_entry_sha(latest, client, repo_ref) : nil
99
+ if latest && stale_sha?(current_ref, latest_sha)
100
+ latest_outdated = latest.merge(sha: latest_sha)
101
+ updates = {
102
+ sha: latest_sha,
103
+ version: latest[:version],
104
+ reason: STALE_SHA_REASON
105
+ }
106
+ reason = STALE_SHA_REASON
107
+ is_outdated = true
108
+ end
109
+ end
110
+
111
+ {
112
+ is_outdated: is_outdated,
113
+ updates: updates,
114
+ reason: reason,
115
+ current_version: current_version,
116
+ latest_outdated: latest_outdated
117
+ }
118
+ end
119
+
120
+ def matching_version_entry(versions, current_ref, current_sha, client, repo_ref)
121
+ parsed = VersionRubric.parse(current_ref)
122
+ if parsed
123
+ direct = versions.find { |entry| entry[:tag] == current_ref }
124
+ return direct if direct
125
+ end
126
+
127
+ return nil unless current_sha
128
+
129
+ prefix = current_sha[0, 40]
130
+ versions.find do |entry|
131
+ sha = version_entry_sha(entry, client, repo_ref)
132
+ sha.to_s.start_with?(prefix)
133
+ end
134
+ end
135
+
136
+ def version_entry_sha(entry, client, repo_ref)
137
+ return nil unless entry
138
+ return entry[:sha] unless entry[:sha].to_s.empty?
139
+
140
+ sha = client.commit_sha(repo_ref, entry[:tag])
141
+ entry[:sha] = sha
142
+ sha
143
+ end
144
+
145
+ def short_sha?(candidate)
146
+ return false unless candidate
147
+
148
+ WEAK_SHA_RE.match?(candidate)
149
+ end
150
+
151
+ def non_sha?(candidate)
152
+ !SHA_RE.match?(candidate) && !WEAK_SHA_RE.match?(candidate)
153
+ end
154
+
155
+ def stale_sha?(current, latest)
156
+ return false if current.nil? || latest.nil?
157
+
158
+ current_down = current.downcase
159
+ latest_down = latest.downcase
160
+
161
+ if current_down.length < latest_down.length
162
+ !latest_down.start_with?(current_down)
163
+ else
164
+ current_down != latest_down
165
+ end
166
+ end
167
+ end
168
+
169
+ class << self
170
+ def resolve_action_plan(...)
171
+ ActionResolver.resolve_action_plan(...)
172
+ end
173
+
174
+ def determine_upgrade_plan(...)
175
+ ActionResolver.determine_upgrade_plan(...)
176
+ end
177
+
178
+ def matching_version_entry(...)
179
+ ActionResolver.matching_version_entry(...)
180
+ end
181
+
182
+ def version_entry_sha(...)
183
+ ActionResolver.version_entry_sha(...)
184
+ end
185
+
186
+ def short_sha?(...)
187
+ ActionResolver.short_sha?(...)
188
+ end
189
+
190
+ def non_sha?(...)
191
+ ActionResolver.non_sha?(...)
192
+ end
193
+
194
+ def stale_sha?(...)
195
+ ActionResolver.stale_sha?(...)
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby-progressbar"
4
+
5
+ module Kettle
6
+ module Gha
7
+ module Pins
8
+ class CacheProgress
9
+ FORMAT = "%t %b %c/%C"
10
+ LENGTH = 30
11
+
12
+ def initialize(total:, cached_title:, live_title:, output:, enabled: true, skipped_title: nil)
13
+ @total = total.to_i
14
+ @cached_count = 0
15
+ @live_count = 0
16
+ @skipped_count = 0
17
+ @cached_bar = progress_bar(cached_title, output, enabled)
18
+ @live_bar = progress_bar(live_title, output, enabled)
19
+ @skipped_bar = skipped_title ? progress_bar(skipped_title, output, enabled) : nil
20
+ end
21
+
22
+ attr_reader :cached_count, :live_count, :skipped_count
23
+
24
+ def cached
25
+ @cached_count += 1
26
+ @cached_bar&.increment
27
+ end
28
+
29
+ def live
30
+ @live_count += 1
31
+ @live_bar&.increment
32
+ end
33
+
34
+ def skipped
35
+ @skipped_count += 1
36
+ @skipped_bar&.increment
37
+ end
38
+
39
+ private
40
+
41
+ def progress_bar(title, output, enabled)
42
+ return unless enabled
43
+ return unless @total.positive?
44
+
45
+ ProgressBar.create(title: title, total: @total, format: FORMAT, length: LENGTH, output: output)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end