kettle-dev 1.0.0 → 1.0.2

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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/.devcontainer/devcontainer.json +26 -0
  4. data/.envrc +42 -0
  5. data/.git-hooks/commit-msg +41 -0
  6. data/.git-hooks/commit-subjects-goalie.txt +8 -0
  7. data/.git-hooks/footer-template.erb.txt +16 -0
  8. data/.git-hooks/prepare-commit-msg +20 -0
  9. data/.github/FUNDING.yml +13 -0
  10. data/.github/dependabot.yml +11 -0
  11. data/.github/workflows/ancient.yml +80 -0
  12. data/.github/workflows/auto-assign.yml +21 -0
  13. data/.github/workflows/codeql-analysis.yml +70 -0
  14. data/.github/workflows/coverage.yml +130 -0
  15. data/.github/workflows/current.yml +88 -0
  16. data/.github/workflows/dependency-review.yml +20 -0
  17. data/.github/workflows/discord-notifier.yml +38 -0
  18. data/.github/workflows/heads.yml +87 -0
  19. data/.github/workflows/jruby.yml +79 -0
  20. data/.github/workflows/legacy.yml +70 -0
  21. data/.github/workflows/locked_deps.yml +88 -0
  22. data/.github/workflows/opencollective.yml +40 -0
  23. data/.github/workflows/style.yml +67 -0
  24. data/.github/workflows/supported.yml +85 -0
  25. data/.github/workflows/truffle.yml +78 -0
  26. data/.github/workflows/unlocked_deps.yml +87 -0
  27. data/.github/workflows/unsupported.yml +78 -0
  28. data/.gitignore +48 -0
  29. data/.gitlab-ci.yml +45 -0
  30. data/.junie/guidelines-rbs.md +49 -0
  31. data/.junie/guidelines.md +132 -0
  32. data/.opencollective.yml +3 -0
  33. data/.qlty/qlty.toml +79 -0
  34. data/.rspec +8 -0
  35. data/.rubocop.yml +13 -0
  36. data/.simplecov +7 -0
  37. data/.tool-versions +1 -0
  38. data/.yard_gfm_support.rb +22 -0
  39. data/.yardopts +11 -0
  40. data/Appraisal.root.gemfile +12 -0
  41. data/Appraisals +120 -0
  42. data/CHANGELOG.md +26 -5
  43. data/Gemfile +32 -0
  44. data/Rakefile +99 -0
  45. data/checksums/kettle-dev-1.0.1.gem.sha256 +1 -0
  46. data/checksums/kettle-dev-1.0.1.gem.sha512 +1 -0
  47. data/checksums/kettle-dev-1.0.2.gem.sha256 +1 -0
  48. data/checksums/kettle-dev-1.0.2.gem.sha512 +1 -0
  49. data/exe/kettle-commit-msg +185 -0
  50. data/exe/kettle-readme-backers +355 -0
  51. data/exe/kettle-release +327 -0
  52. data/gemfiles/modular/coverage.gemfile +6 -0
  53. data/gemfiles/modular/documentation.gemfile +11 -0
  54. data/gemfiles/modular/style.gemfile +16 -0
  55. data/lib/kettle/dev/rakelib/appraisal.rake +40 -0
  56. data/lib/kettle/dev/rakelib/bench.rake +58 -0
  57. data/lib/kettle/dev/rakelib/bundle_audit.rake +18 -0
  58. data/lib/kettle/dev/rakelib/ci.rake +348 -0
  59. data/lib/kettle/dev/rakelib/install.rake +304 -0
  60. data/lib/kettle/dev/rakelib/reek.rake +34 -0
  61. data/lib/kettle/dev/rakelib/require_bench.rake +7 -0
  62. data/lib/kettle/dev/rakelib/rubocop_gradual.rake +9 -0
  63. data/lib/kettle/dev/rakelib/spec_test.rake +42 -0
  64. data/lib/kettle/dev/rakelib/template.rake +413 -0
  65. data/lib/kettle/dev/rakelib/yard.rake +33 -0
  66. data/lib/kettle/dev/version.rb +1 -1
  67. data.tar.gz.sig +0 -0
  68. metadata +74 -5
  69. metadata.gz.sig +0 -0
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Updates README.md backers and sponsors sections using data from Open Collective
5
+ # backers.json and sponsors.json for the configured handle.
6
+ #
7
+ # Backers (individuals) section markers supported (first match wins):
8
+ # <!-- OPENCOLLECTIVE:START --> ... <!-- OPENCOLLECTIVE:END -->
9
+ # <!-- OPENCOLLECTIVE-INDIVIDUALS:START --> ... <!-- OPENCOLLECTIVE-INDIVIDUALS:END -->
10
+ # Sponsors (organizations) section markers:
11
+ # <!-- OPENCOLLECTIVE-ORGANIZATIONS:START --> ... <!-- OPENCOLLECTIVE-ORGANIZATIONS:END -->
12
+ #
13
+ # Handle resolution order:
14
+ # 1. ENV["OPENCOLLECTIVE_HANDLE"] if present
15
+ # 2. .opencollective.yml's `collective:` key in project root if present
16
+ # 3. Abort with error
17
+ #
18
+ # Usage:
19
+ # OPENCOLLECTIVE_HANDLE=kettle-rb exe/kettle-readme-backers
20
+ # # or ensure .opencollective.yml exists with collective: "kettle-rb"
21
+
22
+ require "rubygems"
23
+ require "bundler/setup"
24
+
25
+ require "yaml"
26
+ require "json"
27
+ require "uri"
28
+ require "net/http"
29
+ require "set"
30
+
31
+ module Kettle
32
+ module Dev
33
+ class ReadmeBackers
34
+ DEFAULT_AVATAR = "https://opencollective.com/static/images/default-avatar.png"
35
+ README_PATH = File.expand_path("../README.md", __dir__)
36
+ OC_YML_PATH = File.expand_path("../.opencollective.yml", __dir__)
37
+ README_OSC_TAG_DEFAULT = "OPENCOLLECTIVE"
38
+ COMMIT_SUBJECT_DEFAULT = "💸 Thanks 🙏 to our new backers 🎒 and subscribers 📜"
39
+
40
+ Backer = Struct.new(:name, :image, :website, :profile, keyword_init: true)
41
+
42
+ def initialize(handle: nil, readme_path: README_PATH)
43
+ @handle = handle || resolve_handle
44
+ @readme_path = readme_path
45
+ end
46
+
47
+ def run!
48
+ readme = File.read(@readme_path)
49
+
50
+ # Identify previous entries for diffing/mentions
51
+ b_start, b_end = detect_backer_tags(readme)
52
+ prev_backer_identities = extract_section_identities(readme, b_start, b_end)
53
+ s_start_prev, s_end_prev = detect_sponsor_tags(readme)
54
+ prev_sponsor_identities = extract_section_identities(readme, s_start_prev, s_end_prev)
55
+
56
+ # Backers (individuals)
57
+ backers = fetch_members("backers.json")
58
+ backers_md = generate_markdown(backers, empty_message: "No backers yet. Be the first!", default_name: "Backer")
59
+ updated = replace_between_tags(readme, b_start, b_end, backers_md)
60
+ case updated
61
+ when :not_found
62
+ # Do not exit yet; we may still update sponsors.
63
+ updated_readme = readme
64
+ backers_changed = false
65
+ new_backers = []
66
+ when :no_change
67
+ updated_readme = readme
68
+ backers_changed = false
69
+ new_backers = []
70
+ else
71
+ updated_readme = updated
72
+ backers_changed = true
73
+ new_backers = compute_new_members(prev_backer_identities, backers)
74
+ end
75
+
76
+ # Sponsors (organizations)
77
+ sponsors = fetch_members("sponsors.json")
78
+ sponsors_md = generate_markdown(sponsors, empty_message: "No sponsors yet. Be the first!", default_name: "Sponsor")
79
+ s_start, s_end = detect_sponsor_tags(updated_readme)
80
+ updated2 = replace_between_tags(updated_readme, s_start, s_end, sponsors_md)
81
+ case updated2
82
+ when :not_found
83
+ sponsors_changed = false
84
+ final = updated_readme
85
+ new_sponsors = []
86
+ when :no_change
87
+ sponsors_changed = false
88
+ final = updated_readme
89
+ new_sponsors = []
90
+ else
91
+ sponsors_changed = true
92
+ final = updated2
93
+ new_sponsors = compute_new_members(prev_sponsor_identities, sponsors)
94
+ end
95
+
96
+ if !backers_changed && !sponsors_changed
97
+ if b_start == :not_found && s_start == :not_found
98
+ ts = tag_strings
99
+ warn("No recognized Open Collective tags found in #{@readme_path}. Expected one or more of: " \
100
+ "#{ts[:generic_start]}/#{ts[:generic_end]}, #{ts[:individuals_start]}/#{ts[:individuals_end]}, #{ts[:orgs_start]}/#{ts[:orgs_end]}.")
101
+ exit(2)
102
+ end
103
+ puts "No changes to backers or sponsors sections in #{@readme_path}."
104
+ return
105
+ end
106
+
107
+ File.write(@readme_path, final)
108
+ msgs = []
109
+ msgs << "backers" if backers_changed
110
+ msgs << "sponsors" if sponsors_changed
111
+ puts "Updated #{msgs.join(" and ")} section#{{true => "s", false => ""}[msgs.size > 1]} in #{@readme_path}."
112
+
113
+ # Compose and perform commit with mentions if in a git repo
114
+ perform_git_commit(new_backers, new_sponsors) if git_repo? && (backers_changed || sponsors_changed)
115
+ end
116
+
117
+ private
118
+
119
+ def readme_osc_tag
120
+ env = ENV["KETTLE_DEV_BACKER_README_OSC_TAG"].to_s
121
+ return env unless env.strip.empty?
122
+ if File.file?(OC_YML_PATH)
123
+ begin
124
+ yml = YAML.safe_load(File.read(OC_YML_PATH))
125
+ if yml.is_a?(Hash)
126
+ from_yml = yml["readme-osc-tag"] || yml[:"readme-osc-tag"]
127
+ from_yml = from_yml.to_s if from_yml
128
+ return from_yml unless from_yml.nil? || from_yml.strip.empty?
129
+ end
130
+ rescue StandardError
131
+ # ignore yaml errors and fall back
132
+ end
133
+ end
134
+ README_OSC_TAG_DEFAULT
135
+ end
136
+
137
+ def tag_strings
138
+ base = readme_osc_tag
139
+ {
140
+ generic_start: "<!-- #{base}:START -->",
141
+ generic_end: "<!-- #{base}:END -->",
142
+ individuals_start: "<!-- #{base}-INDIVIDUALS:START -->",
143
+ individuals_end: "<!-- #{base}-INDIVIDUALS:END -->",
144
+ orgs_start: "<!-- #{base}-ORGANIZATIONS:START -->",
145
+ orgs_end: "<!-- #{base}-ORGANIZATIONS:END -->",
146
+ }
147
+ end
148
+
149
+ def resolve_handle
150
+ env = ENV["OPENCOLLECTIVE_HANDLE"]
151
+ return env unless env.nil? || env.strip.empty?
152
+ if File.file?(OC_YML_PATH)
153
+ yml = YAML.safe_load(File.read(OC_YML_PATH))
154
+ handle = yml.is_a?(Hash) ? yml["collective"] || yml[:collective] : nil
155
+ return handle.to_s unless handle.nil? || handle.to_s.strip.empty?
156
+ end
157
+ abort("ERROR: Open Collective handle not provided. Set OPENCOLLECTIVE_HANDLE or add 'collective: <handle>' to .opencollective.yml.")
158
+ end
159
+
160
+ def fetch_members(path)
161
+ url = URI("https://opencollective.com/#{@handle}/#{path}")
162
+ response = Net::HTTP.start(url.host, url.port, use_ssl: url.scheme == "https") do |conn|
163
+ conn.read_timeout = 10
164
+ conn.open_timeout = 5
165
+ req = Net::HTTP::Get.new(url)
166
+ req["User-Agent"] = "kettle-dev/README-backers"
167
+ conn.request(req)
168
+ end
169
+ return [] unless response.is_a?(Net::HTTPSuccess)
170
+ parsed = JSON.parse(response.body)
171
+ Array(parsed).map do |h|
172
+ Backer.new(
173
+ name: h["name"],
174
+ image: (h["image"].to_s.strip.empty? ? nil : h["image"]),
175
+ website: (h["website"].to_s.strip.empty? ? nil : h["website"]),
176
+ profile: (h["profile"].to_s.strip.empty? ? nil : h["profile"]),
177
+ )
178
+ end
179
+ rescue JSON::ParserError => e
180
+ warn("Error parsing #{path} JSON: #{e.message}")
181
+ []
182
+ rescue StandardError => e
183
+ warn("Error fetching #{path}: #{e.class}: #{e.message}")
184
+ []
185
+ end
186
+
187
+ def generate_markdown(members, empty_message:, default_name:)
188
+ return empty_message if members.nil? || members.empty?
189
+ members.map do |m|
190
+ image_url = m.image || DEFAULT_AVATAR
191
+ link = m.website || m.profile || "#"
192
+ name = (m.name && !m.name.strip.empty?) ? m.name : default_name
193
+ "[![#{escape_text(name)}](#{image_url})](#{link})"
194
+ end.join(" ")
195
+ end
196
+
197
+ def replace_between_tags(content, start_tag, end_tag, new_content)
198
+ return :not_found if start_tag == :not_found || end_tag == :not_found
199
+ start_index = content.index(start_tag)
200
+ end_index = content.index(end_tag)
201
+ return :not_found if start_index.nil? || end_index.nil? || end_index < start_index
202
+ before = content[0..start_index + start_tag.length - 1]
203
+ after = content[end_index..-1]
204
+ replacement = "#{start_tag}\n#{new_content}\n#{end_tag}"
205
+ current_block = content[start_index..end_index + end_tag.length - 1]
206
+ return :no_change if current_block == replacement
207
+ trailing = after[end_tag.length..-1] || ""
208
+ "#{before}\n#{new_content}\n#{end_tag}#{trailing}"
209
+ end
210
+
211
+ def detect_backer_tags(content)
212
+ ts = tag_strings
213
+ if content.include?(ts[:generic_start]) && content.include?(ts[:generic_end])
214
+ [ts[:generic_start], ts[:generic_end]]
215
+ elsif content.include?(ts[:individuals_start]) && content.include?(ts[:individuals_end])
216
+ [ts[:individuals_start], ts[:individuals_end]]
217
+ else
218
+ [:not_found, :not_found]
219
+ end
220
+ end
221
+
222
+ def detect_sponsor_tags(content)
223
+ ts = tag_strings
224
+ if content.include?(ts[:orgs_start]) && content.include?(ts[:orgs_end])
225
+ [ts[:orgs_start], ts[:orgs_end]]
226
+ else
227
+ [:not_found, :not_found]
228
+ end
229
+ end
230
+
231
+ # Extract identity tokens from the current README section between start/end tags
232
+ # Identity priority used for comparison:
233
+ # href (profile/website URL) downcased, else alt text (name) downcased
234
+ def extract_section_identities(content, start_tag, end_tag)
235
+ return Set.new unless start_tag && end_tag && start_tag != :not_found && end_tag != :not_found
236
+ start_index = content.index(start_tag)
237
+ end_index = content.index(end_tag)
238
+ return Set.new if start_index.nil? || end_index.nil? || end_index < start_index
239
+ block = content[(start_index + start_tag.length)...end_index]
240
+ identities = Set.new
241
+ # Match patterns like: [![Alt](image_url)](link_url)
242
+ block.to_s.scan(/\[!\[[^\]]*\]\([^\)]*\)\]\(([^\)]+)\)/) do |m|
243
+ href = (m[0] || "").strip
244
+ identities << href.downcase unless href.empty?
245
+ end
246
+ # Also capture alt texts in case links are missing
247
+ block.to_s.scan(/\[!\[([^\]]*)\]\([^\)]*\)\]\([^\)]*\)/) do |m|
248
+ alt = (m[0] || "").strip
249
+ identities << alt.downcase unless alt.empty?
250
+ end
251
+ identities
252
+ end
253
+
254
+ def compute_new_members(previous_identities, members)
255
+ prev = previous_identities || Set.new
256
+ members.select do |m|
257
+ id = identity_for_member(m)
258
+ !prev.include?(id)
259
+ end
260
+ end
261
+
262
+ def identity_for_member(m)
263
+ if m.profile && !m.profile.strip.empty?
264
+ m.profile.strip.downcase
265
+ elsif m.website && !m.website.strip.empty?
266
+ m.website.strip.downcase
267
+ elsif m.name && !m.name.strip.empty?
268
+ m.name.strip.downcase
269
+ else
270
+ ""
271
+ end
272
+ end
273
+
274
+ def mention_for_member(m, default_name: "Member")
275
+ handle = github_handle_from_urls(m.profile, m.website)
276
+ return "@#{handle}" if handle
277
+ name = (m.name && !m.name.strip.empty?) ? m.name.strip : default_name
278
+ name
279
+ end
280
+
281
+ def github_handle_from_urls(*urls)
282
+ urls.compact.each do |u|
283
+ begin
284
+ uri = URI.parse(u)
285
+ rescue URI::InvalidURIError
286
+ next
287
+ end
288
+ next unless uri&.host&.downcase&.end_with?("github.com")
289
+ path = (uri.path || "").sub(%r{^/}, "").sub(%r{/$}, "")
290
+ next if path.empty?
291
+ parts = path.split("/")
292
+ # github.com/sponsors/<handle> or github.com/<handle>/...
293
+ candidate = if parts[0].downcase == "sponsors" && parts[1]
294
+ parts[1]
295
+ else
296
+ parts[0]
297
+ end
298
+ candidate = candidate.gsub(%r{[^a-zA-Z0-9-]}, "")
299
+ return candidate unless candidate.empty?
300
+ end
301
+ nil
302
+ end
303
+
304
+ def perform_git_commit(new_backers, new_sponsors)
305
+ backer_mentions = new_backers.map { |m| mention_for_member(m, default_name: "Backer") }.uniq
306
+ sponsor_mentions = new_sponsors.map { |m| mention_for_member(m, default_name: "Subscriber") }.uniq
307
+ title = commit_subject
308
+ lines = [title]
309
+ lines << ""
310
+ lines << "Backers: #{backer_mentions.join(", ")}" unless backer_mentions.empty?
311
+ lines << "Subscribers: #{sponsor_mentions.join(", ")}" unless sponsor_mentions.empty?
312
+ message = lines.join("\n")
313
+ # Stage and commit README.md
314
+ system("git", "add", @readme_path)
315
+ # Only commit if README is staged/changed
316
+ if system("git", "diff", "--cached", "--quiet")
317
+ # nothing staged; skip commit
318
+ return
319
+ end
320
+ system("git", "commit", "-m", message)
321
+ end
322
+
323
+ def commit_subject
324
+ env = ENV["KETTLE_README_BACKERS_COMMIT_SUBJECT"].to_s
325
+ return env unless env.strip.empty?
326
+ # Fallback to .opencollective.yml key: readme-backers-commit-subject
327
+ if File.file?(OC_YML_PATH)
328
+ begin
329
+ yml = YAML.safe_load(File.read(OC_YML_PATH))
330
+ if yml.is_a?(Hash)
331
+ from_yml = yml["readme-backers-commit-subject"] || yml[:"readme-backers-commit-subject"]
332
+ from_yml = from_yml.to_s if from_yml
333
+ return from_yml unless from_yml.nil? || from_yml.strip.empty?
334
+ end
335
+ rescue StandardError
336
+ # ignore yaml read errors and fall back to default
337
+ end
338
+ end
339
+ COMMIT_SUBJECT_DEFAULT
340
+ end
341
+
342
+ def git_repo?
343
+ system("git", "rev-parse", "--is-inside-work-tree", out: File::NULL, err: File::NULL)
344
+ end
345
+
346
+ def escape_text(text)
347
+ text.gsub("[", "\\[").gsub("]", "\\]")
348
+ end
349
+ end
350
+ end
351
+ end
352
+
353
+ if __FILE__ == $PROGRAM_NAME
354
+ Kettle::Dev::ReadmeBackers.new.run!
355
+ end
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # kettle-release: Automate release steps from CONTRIBUTING.md
5
+ # - Runs sanity checks
6
+ # - Ensures version/changelog updated (with confirmation)
7
+ # - Commits and pushes a release prep commit
8
+ # - Ensures on trunk, up-to-date
9
+ # - Exports SOURCE_DATE_EPOCH for reproducible checksums
10
+ # - Runs `bundle exec rake build` (expects PEM password unless SKIP_GEM_SIGNING)
11
+ # - If signing not skipped and no public cert in certs/<user>.pem, aborts with guidance
12
+ # - Runs bin/gem_checksums
13
+ # - Runs `bundle exec rake release` (expects PEM password and RubyGems MFA OTP)
14
+
15
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
16
+
17
+ require "rubygems"
18
+ require "bundler/setup"
19
+
20
+ require "open3"
21
+ require "shellwords"
22
+ require "time"
23
+ require "fileutils"
24
+ require "net/http"
25
+ require "json"
26
+ require "uri"
27
+ require "kettle/dev/ci_helpers"
28
+ require "ruby-progressbar"
29
+
30
+ module Kettle
31
+ module Dev
32
+ class ReleaseCLI
33
+ def initialize
34
+ @root = File.expand_path("..", __dir__)
35
+ end
36
+
37
+ def run
38
+ puts "== kettle-release =="
39
+
40
+ run_cmd!("bin/setup")
41
+ run_cmd!("bin/rake")
42
+
43
+ version = detect_version
44
+ puts "Detected version: #{version.inspect}"
45
+ puts "Have you updated lib/**/version.rb and CHANGELOG.md for v#{version}? [y/N]"
46
+ print("> ")
47
+ ans = $stdin.gets&.strip
48
+ abort("Aborted: please update version.rb and CHANGELOG.md, then re-run.") unless ans&.downcase&.start_with?("y")
49
+
50
+ # Re-run checks (and refresh Gemfile.lock)
51
+ run_cmd!("bin/setup")
52
+ run_cmd!("bin/rake")
53
+
54
+ # Update Appraisal gemfiles if Appraisals file is present
55
+ appraisals_path = File.join(@root, "Appraisals")
56
+ if File.file?(appraisals_path)
57
+ puts "Appraisals detected at #{appraisals_path}. Running: bin/rake appraisal:update"
58
+ run_cmd!("bin/rake appraisal:update")
59
+ else
60
+ puts "No Appraisals file found; skipping appraisal:update"
61
+ end
62
+
63
+ ensure_git_user!
64
+ commit_release_prep!(version)
65
+
66
+ trunk = detect_trunk_branch
67
+ feature = current_branch
68
+ puts "Trunk branch detected: #{trunk}"
69
+ ensure_trunk_synced_before_push!(trunk, feature)
70
+
71
+ push!
72
+
73
+ # After pushing, ensure the CI workflows for this project are passing
74
+ monitor_workflows_after_push!
75
+
76
+ # If all workflows are passing, merge the feature branch into trunk and push trunk
77
+ merge_feature_into_trunk_and_push!(trunk, feature)
78
+
79
+ # Ensure we are on trunk for the remaining steps
80
+ checkout!(trunk)
81
+ pull!(trunk)
82
+
83
+ export_source_date_epoch!
84
+
85
+ ensure_signing_setup_or_skip!
86
+ # Build: expect PEM password prompt unless SKIP_GEM_SIGNING
87
+ puts "Running build (you may be prompted for the signing key password)..."
88
+ run_cmd!("bundle exec rake build")
89
+
90
+ # Checksums (commits, but does not push)
91
+ run_cmd!("bin/gem_checksums")
92
+
93
+ # Release: expect PEM password + RubyGems MFA OTP
94
+ puts "Running release (you may be prompted for signing key password and RubyGems MFA OTP)..."
95
+ run_cmd!("bundle exec rake release")
96
+
97
+ puts "\nRelease complete. Don't forget to push the checksums commit if needed."
98
+ end
99
+
100
+ private
101
+
102
+ # Monitor GitHub Actions workflows discovered by ci:act logic.
103
+ # Checks one workflow per second in a round-robin loop until all pass, or any fails.
104
+ def monitor_workflows_after_push!
105
+ root = Kettle::Dev::CIHelpers.project_root
106
+ workflows = Kettle::Dev::CIHelpers.workflows_list(root)
107
+ if workflows.empty?
108
+ puts "No workflows detected under .github/workflows; skipping CI checks."
109
+ return
110
+ end
111
+
112
+ owner, repo = Kettle::Dev::CIHelpers.repo_info
113
+ branch = Kettle::Dev::CIHelpers.current_branch
114
+ unless owner && repo && branch
115
+ puts "Unable to determine repository or branch; skipping CI checks."
116
+ return
117
+ end
118
+
119
+ total = workflows.size
120
+ passed = {}
121
+ idx = 0
122
+ puts "Ensuring CI workflows pass on branch #{branch} (#{owner}/#{repo})"
123
+ pbar = ProgressBar.create(title: "CI", total: total, format: "%t %b %c/%C", length: 30)
124
+
125
+ loop do
126
+ wf = workflows[idx]
127
+ run = Kettle::Dev::CIHelpers.latest_run(owner: owner, repo: repo, workflow_file: wf, branch: branch)
128
+ if run
129
+ if Kettle::Dev::CIHelpers.success?(run)
130
+ unless passed[wf]
131
+ passed[wf] = true
132
+ pbar.increment
133
+ end
134
+ elsif Kettle::Dev::CIHelpers.failed?(run)
135
+ # Fail fast with link to the failed run
136
+ puts
137
+ url = run["html_url"] || "https://github.com/#{owner}/#{repo}/actions/workflows/#{wf}"
138
+ abort("Workflow failed: #{wf} -> #{url}")
139
+ end
140
+ end
141
+
142
+ break if passed.size == total
143
+
144
+ idx = (idx + 1) % total
145
+ sleep(1)
146
+ end
147
+ pbar.finish unless pbar.finished?
148
+ puts "\nAll workflows passing (#{passed.size}/#{total})."
149
+ end
150
+
151
+ def run_cmd!(cmd)
152
+ puts "$ #{cmd}"
153
+ success = system(cmd)
154
+ abort("Command failed: #{cmd}") unless success
155
+ end
156
+
157
+ def git_output(args)
158
+ out, status = Open3.capture2("git", *args)
159
+ [out.strip, status.success?]
160
+ end
161
+
162
+ def check_git_clean!
163
+ out, ok = git_output(["status", "--porcelain"])
164
+ abort("Git working tree is not clean. Commit/stash changes before releasing.\n\n#{out}") unless ok && out.empty?
165
+ end
166
+
167
+ def ensure_git_user!
168
+ name, ok1 = git_output(["config", "user.name"])
169
+ email, ok2 = git_output(["config", "user.email"])
170
+ abort("Git user.name or user.email not configured.") unless ok1 && ok2 && !name.empty? && !email.empty?
171
+ end
172
+
173
+ def detect_version
174
+ # Look for lib/**/version.rb and extract VERSION constant string
175
+ candidates = Dir[File.join(@root, "lib", "**", "version.rb")]
176
+ abort("Could not find version.rb under lib/**.") if candidates.empty?
177
+ path = candidates.min
178
+ content = File.read(path)
179
+ m = content.match(/VERSION\s*=\s*(["'])([^"']+)\1/)
180
+ abort("VERSION constant not found in #{path}.") unless m
181
+ m[2]
182
+ end
183
+
184
+ def commit_release_prep!(version)
185
+ msg = "🔖 Prepare release v#{version}"
186
+ # Only commit if there are changes (version/changelog)
187
+ out, _ = git_output(["status", "--porcelain"])
188
+ if out.empty?
189
+ puts "No changes to commit for release prep (continuing)."
190
+ else
191
+ run_cmd!(%(git commit -am #{Shellwords.escape(msg)}))
192
+ end
193
+ end
194
+
195
+ def push!
196
+ puts "$ git push"
197
+ success = system("git push")
198
+ unless success
199
+ warn("Normal push failed; retrying with force push...")
200
+ run_cmd!("git push -f")
201
+ end
202
+ end
203
+
204
+ def detect_trunk_branch
205
+ out, ok = git_output(["remote", "show", "origin"])
206
+ abort("Failed to get origin remote info.") unless ok
207
+ m = out.lines.find { |l| l.include?("HEAD branch") }
208
+ abort("Unable to detect trunk branch from origin.") unless m
209
+ m.split.last
210
+ end
211
+
212
+ def checkout!(branch)
213
+ run_cmd!("git checkout #{Shellwords.escape(branch)}")
214
+ end
215
+
216
+ def pull!(branch)
217
+ run_cmd!("git pull origin #{Shellwords.escape(branch)}")
218
+ end
219
+
220
+ def current_branch
221
+ out, ok = git_output(["rev-parse", "--abbrev-ref", "HEAD"])
222
+ ok ? out : nil
223
+ end
224
+
225
+ def list_remotes
226
+ out, ok = git_output(["remote"])
227
+ ok ? out.split(/\s+/).reject(&:empty?) : []
228
+ end
229
+
230
+ def has_remote?(name)
231
+ list_remotes.include?(name)
232
+ end
233
+
234
+ def remote_branch_exists?(remote, branch)
235
+ _out, ok = git_output(["show-ref", "--verify", "--quiet", "refs/remotes/#{remote}/#{branch}"])
236
+ ok
237
+ end
238
+
239
+ def ahead_behind_counts(local_ref, remote_ref)
240
+ out, ok = git_output(["rev-list", "--left-right", "--count", "#{local_ref}...#{remote_ref}"])
241
+ return [0, 0] unless ok && !out.empty?
242
+ parts = out.split
243
+ left = parts[0].to_i
244
+ right = parts[1].to_i
245
+ [left, right]
246
+ end
247
+
248
+ def trunk_behind_remote?(trunk, remote)
249
+ # If the remote branch doesn't exist, treat as not behind
250
+ return false unless remote_branch_exists?(remote, trunk)
251
+ _ahead, behind = ahead_behind_counts(trunk, "#{remote}/#{trunk}")
252
+ behind.positive?
253
+ end
254
+
255
+ def ensure_trunk_synced_before_push!(trunk, feature)
256
+ if has_remote?("all")
257
+ puts "Remote 'all' detected. Fetching from all remotes and enforcing strict trunk parity..."
258
+ run_cmd!("git fetch --all")
259
+ remotes = list_remotes
260
+ missing_from = []
261
+ remotes.each do |r|
262
+ next if r == "all"
263
+ if remote_branch_exists?(r, trunk)
264
+ _ahead, behind = ahead_behind_counts(trunk, "#{r}/#{trunk}")
265
+ missing_from << r if behind.positive?
266
+ end
267
+ end
268
+ unless missing_from.empty?
269
+ abort("Local #{trunk} is missing commits present on: #{missing_from.join(", ")}. Please sync trunk first.")
270
+ end
271
+ puts "Local #{trunk} has all commits from remotes: #{(remotes - ["all"]).join(", ")}"
272
+ return
273
+ end
274
+
275
+ # Default behavior: ensure local trunk is not behind origin/trunk; if it is, rebase flows
276
+ run_cmd!("git fetch origin #{Shellwords.escape(trunk)}")
277
+ if trunk_behind_remote?(trunk, "origin")
278
+ puts "Local #{trunk} is behind origin/#{trunk}. Rebasing..."
279
+ cur = current_branch
280
+ checkout!(trunk) unless cur == trunk
281
+ run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
282
+ checkout!(feature) unless feature.nil? || feature == trunk
283
+ run_cmd!("git rebase #{Shellwords.escape(trunk)}")
284
+ puts "Rebase complete. Will push updated branch next."
285
+ else
286
+ puts "Local #{trunk} is up to date with origin/#{trunk}."
287
+ end
288
+ end
289
+
290
+ def merge_feature_into_trunk_and_push!(trunk, feature)
291
+ return if feature.nil? || feature == trunk
292
+ puts "Merging #{feature} into #{trunk} (after CI success)..."
293
+ checkout!(trunk)
294
+ run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
295
+ run_cmd!("git merge #{Shellwords.escape(feature)}")
296
+ run_cmd!("git push origin #{Shellwords.escape(trunk)}")
297
+ puts "Merged #{feature} into #{trunk} and pushed. The PR (if any) should auto-close."
298
+ end
299
+
300
+ def export_source_date_epoch!
301
+ epoch = Time.now.to_i
302
+ ENV["SOURCE_DATE_EPOCH"] = epoch.to_s
303
+ puts "Exported SOURCE_DATE_EPOCH=#{epoch}"
304
+ end
305
+
306
+ def ensure_signing_setup_or_skip!
307
+ return if ENV.key?("SKIP_GEM_SIGNING")
308
+
309
+ user = ENV.fetch("GEM_CERT_USER", ENV["USER"])
310
+ cert_path = File.join(@root, "certs", "#{user}.pem")
311
+ unless File.exist?(cert_path)
312
+ abort(<<~MSG)
313
+ Gem signing appears enabled but no public cert found at:
314
+ #{cert_path}
315
+ Add your public key to certs/<USER>.pem (or set GEM_CERT_USER), or set SKIP_GEM_SIGNING to build unsigned.
316
+ MSG
317
+ end
318
+ puts "Found signing cert: #{cert_path}"
319
+ puts "When prompted during build/release, enter the PEM password for ~/.ssh/gem-private_key.pem"
320
+ end
321
+ end
322
+ end
323
+ end
324
+
325
+ if __FILE__ == $PROGRAM_NAME
326
+ Kettle::Dev::ReleaseCLI.new.run
327
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # We run code coverage on the latest version of Ruby only.
4
+
5
+ # Coverage
6
+ gem "kettle-soup-cover", "~> 1.0", ">= 1.0.10", require: false