kettle-dev 1.0.8 → 1.0.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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/.envrc +4 -3
  4. data/.github/workflows/ancient.yml +2 -4
  5. data/.github/workflows/coverage.yml +5 -7
  6. data/.github/workflows/current.yml +2 -4
  7. data/.github/workflows/heads.yml +2 -4
  8. data/.github/workflows/jruby.yml +2 -4
  9. data/.github/workflows/legacy.yml +2 -4
  10. data/.github/workflows/locked_deps.yml +1 -4
  11. data/.github/workflows/style.yml +2 -4
  12. data/.github/workflows/supported.yml +2 -4
  13. data/.github/workflows/truffle.yml +2 -4
  14. data/.github/workflows/unlocked_deps.yml +1 -4
  15. data/.github/workflows/unsupported.yml +2 -4
  16. data/.junie/guidelines.md +4 -3
  17. data/.simplecov +5 -1
  18. data/Appraisals +3 -0
  19. data/CHANGELOG.md +50 -3
  20. data/CHANGELOG.md.example +47 -0
  21. data/CONTRIBUTING.md +6 -0
  22. data/README.md +23 -5
  23. data/Rakefile +43 -54
  24. data/exe/kettle-commit-msg +8 -140
  25. data/exe/kettle-readme-backers +6 -348
  26. data/exe/kettle-release +8 -549
  27. data/lib/kettle/dev/ci_helpers.rb +1 -0
  28. data/lib/kettle/dev/commit_msg.rb +39 -0
  29. data/lib/kettle/dev/exit_adapter.rb +36 -0
  30. data/lib/kettle/dev/git_adapter.rb +120 -0
  31. data/lib/kettle/dev/git_commit_footer.rb +130 -0
  32. data/lib/kettle/dev/rakelib/appraisal.rake +8 -9
  33. data/lib/kettle/dev/rakelib/bench.rake +2 -7
  34. data/lib/kettle/dev/rakelib/bundle_audit.rake +2 -0
  35. data/lib/kettle/dev/rakelib/ci.rake +4 -343
  36. data/lib/kettle/dev/rakelib/install.rake +1 -295
  37. data/lib/kettle/dev/rakelib/reek.rake +2 -0
  38. data/lib/kettle/dev/rakelib/rubocop_gradual.rake +2 -0
  39. data/lib/kettle/dev/rakelib/spec_test.rake +2 -0
  40. data/lib/kettle/dev/rakelib/template.rake +3 -454
  41. data/lib/kettle/dev/readme_backers.rb +340 -0
  42. data/lib/kettle/dev/release_cli.rb +672 -0
  43. data/lib/kettle/dev/tasks/ci_task.rb +334 -0
  44. data/lib/kettle/dev/tasks/install_task.rb +298 -0
  45. data/lib/kettle/dev/tasks/template_task.rb +491 -0
  46. data/lib/kettle/dev/template_helpers.rb +4 -4
  47. data/lib/kettle/dev/version.rb +1 -1
  48. data/lib/kettle/dev.rb +30 -1
  49. data/lib/kettle-dev.rb +2 -3
  50. data/sig/kettle/dev/ci_helpers.rbs +18 -8
  51. data/sig/kettle/dev/commit_msg.rbs +8 -0
  52. data/sig/kettle/dev/exit_adapter.rbs +8 -0
  53. data/sig/kettle/dev/git_adapter.rbs +15 -0
  54. data/sig/kettle/dev/git_commit_footer.rbs +16 -0
  55. data/sig/kettle/dev/readme_backers.rbs +20 -0
  56. data/sig/kettle/dev/release_cli.rbs +8 -0
  57. data/sig/kettle/dev/tasks/ci_task.rbs +9 -0
  58. data/sig/kettle/dev/tasks/install_task.rbs +10 -0
  59. data/sig/kettle/dev/tasks/template_task.rbs +10 -0
  60. data/sig/kettle/dev/tasks.rbs +0 -0
  61. data/sig/kettle/dev/version.rbs +0 -0
  62. data/sig/kettle/emoji_regex.rbs +5 -0
  63. data/sig/kettle-dev.rbs +0 -0
  64. data.tar.gz.sig +0 -0
  65. metadata +56 -5
  66. metadata.gz.sig +4 -2
data/exe/kettle-release CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ # vim: set syntax=ruby
5
+
4
6
  # kettle-release: Automate release steps from CONTRIBUTING.md
5
7
  # - Runs sanity checks
6
8
  # - Ensures version/changelog updated (with confirmation)
@@ -12,562 +14,19 @@
12
14
  # - Runs bin/gem_checksums
13
15
  # - Runs `bundle exec rake release` (expects PEM password and RubyGems MFA OTP)
14
16
 
17
+ # Immediate, unbuffered output
15
18
  $stdout.sync = true
16
- require "rubygems"
17
- # Ensure we run within the host project's Bundler context (do not override BUNDLE_GEMFILE)
18
- begin
19
- require "bundler/setup"
20
- rescue LoadError
21
- # Allow running outside of Bundler; runtime deps should still be available via rubygems
22
- end
19
+ # Depending library or project must be using bundler
20
+ require "bundler/setup"
23
21
 
24
22
  begin
25
- require "open3"
26
- require "shellwords"
27
- require "time"
28
- require "fileutils"
29
- require "net/http"
30
- require "json"
31
- require "uri"
32
- require "kettle/dev/ci_helpers"
33
- require "ruby-progressbar"
23
+ require "kettle/dev"
34
24
  rescue LoadError => e
35
- warn("kettle-release: failed to load a required library: #{e.message}")
36
- warn("Hint: Ensure the host project includes kettle-dev and its runtime deps, then run bundle install.")
37
- warn(e.backtrace.join("\n")) if ENV["DEBUG"]
25
+ warn("kettle/dev: failed to load: #{e.message}")
26
+ warn("Hint: Ensure the host project includes kettle-dev and run bundle install.")
38
27
  exit(1)
39
28
  end
40
29
 
41
- module Kettle
42
- module Dev
43
- class ReleaseCLI
44
- def initialize
45
- # Use the host project's root, not the installed gem's directory
46
- @root = Kettle::Dev::CIHelpers.project_root
47
- end
48
-
49
- def run
50
- puts "== kettle-release =="
51
-
52
- ensure_bundler_2_7_plus!
53
-
54
- run_cmd!("bin/setup")
55
- run_cmd!("bin/rake")
56
-
57
- version = detect_version
58
- puts "Detected version: #{version.inspect}"
59
- puts "Have you updated lib/**/version.rb and CHANGELOG.md for v#{version}? [y/N]"
60
- print("> ")
61
- ans = $stdin.gets&.strip
62
- abort("Aborted: please update version.rb and CHANGELOG.md, then re-run.") unless ans&.downcase&.start_with?("y")
63
-
64
- # Re-run checks (and refresh Gemfile.lock)
65
- run_cmd!("bin/setup")
66
- run_cmd!("bin/rake")
67
-
68
- # Update Appraisal gemfiles if Appraisals file is present
69
- appraisals_path = File.join(@root, "Appraisals")
70
- if File.file?(appraisals_path)
71
- puts "Appraisals detected at #{appraisals_path}. Running: bin/rake appraisal:update"
72
- run_cmd!("bin/rake appraisal:update")
73
- else
74
- puts "No Appraisals file found; skipping appraisal:update"
75
- end
76
-
77
- ensure_git_user!
78
- commit_release_prep!(version)
79
-
80
- trunk = detect_trunk_branch
81
- feature = current_branch
82
- puts "Trunk branch detected: #{trunk}"
83
- ensure_trunk_synced_before_push!(trunk, feature)
84
-
85
- push!
86
-
87
- # After pushing, ensure the CI workflows for this project are passing
88
- monitor_workflows_after_push!
89
-
90
- # If all workflows are passing, merge the feature branch into trunk and push trunk
91
- merge_feature_into_trunk_and_push!(trunk, feature)
92
-
93
- # Ensure we are on trunk for the remaining steps
94
- checkout!(trunk)
95
- pull!(trunk)
96
-
97
- ensure_signing_setup_or_skip!
98
- # Build: expect PEM password prompt unless SKIP_GEM_SIGNING
99
- puts "Running build (you may be prompted for the signing key password)..."
100
- run_cmd!("bundle exec rake build")
101
-
102
- # Checksums (commits, but does not push)
103
- run_cmd!("bin/gem_checksums")
104
- validate_checksums!(version, stage: "after build + gem_checksums")
105
-
106
- # Release: expect PEM password + RubyGems MFA OTP
107
- puts "Running release (you may be prompted for signing key password and RubyGems MFA OTP)..."
108
- run_cmd!("bundle exec rake release")
109
- # Some release tasks rebuild the gem; re-validate to ensure reproducibility
110
- validate_checksums!(version, stage: "after release")
111
-
112
- puts "\nRelease complete. Don't forget to push the checksums commit if needed."
113
- end
114
-
115
- private
116
-
117
- # Monitor GitHub Actions workflows discovered by ci:act logic.
118
- # Checks one workflow per second in a round-robin loop until all pass, or any fails.
119
- def monitor_workflows_after_push!
120
- root = Kettle::Dev::CIHelpers.project_root
121
- workflows = Kettle::Dev::CIHelpers.workflows_list(root)
122
- gitlab_ci = File.exist?(File.join(root, ".gitlab-ci.yml"))
123
-
124
- branch = Kettle::Dev::CIHelpers.current_branch
125
- abort("Could not determine current branch for CI checks.") unless branch
126
-
127
- # Prefer an explicit GitHub remote if available; fall back to origin repo_info
128
- gh_remote = preferred_github_remote
129
- gh_owner = nil
130
- gh_repo = nil
131
- if gh_remote && !workflows.empty?
132
- url = remote_url(gh_remote)
133
- gh_owner, gh_repo = parse_github_owner_repo(url)
134
- end
135
-
136
- checks_any = false
137
-
138
- if gh_owner && gh_repo && !workflows.empty?
139
- checks_any = true
140
- total = workflows.size
141
- abort("No GitHub workflows found under .github/workflows; aborting.") if total.zero?
142
-
143
- passed = {}
144
- idx = 0
145
- puts "Ensuring GitHub Actions workflows pass on #{branch} (#{gh_owner}/#{gh_repo}) via remote '#{gh_remote}'"
146
- pbar = ProgressBar.create(title: "CI", total: total, format: "%t %b %c/%C", length: 30)
147
-
148
- loop do
149
- wf = workflows[idx]
150
- run = Kettle::Dev::CIHelpers.latest_run(owner: gh_owner, repo: gh_repo, workflow_file: wf, branch: branch)
151
- if run
152
- if Kettle::Dev::CIHelpers.success?(run)
153
- unless passed[wf]
154
- passed[wf] = true
155
- pbar.increment
156
- end
157
- elsif Kettle::Dev::CIHelpers.failed?(run)
158
- puts
159
- url = run["html_url"] || "https://github.com/#{gh_owner}/#{gh_repo}/actions/workflows/#{wf}"
160
- abort("Workflow failed: #{wf} -> #{url}")
161
- end
162
- end
163
- break if passed.size == total
164
- idx = (idx + 1) % total
165
- sleep(1)
166
- end
167
- pbar.finish unless pbar.finished?
168
- puts "\nAll GitHub workflows passing (#{passed.size}/#{total})."
169
- end
170
-
171
- # Additionally, check GitLab if configured
172
- gl_remote = gitlab_remote_candidates.first
173
- if gitlab_ci && gl_remote
174
- owner, repo = Kettle::Dev::CIHelpers.repo_info_gitlab
175
- if owner && repo
176
- checks_any = true
177
- puts "Ensuring GitLab pipeline passes on #{branch} (#{owner}/#{repo}) via remote '#{gl_remote}'"
178
- pbar = ProgressBar.create(title: "CI", total: 1, format: "%t %b %c/%C", length: 30)
179
- loop do
180
- pipe = Kettle::Dev::CIHelpers.gitlab_latest_pipeline(owner: owner, repo: repo, branch: branch)
181
- if pipe
182
- if Kettle::Dev::CIHelpers.gitlab_success?(pipe)
183
- pbar.increment unless pbar.finished?
184
- break
185
- elsif Kettle::Dev::CIHelpers.gitlab_failed?(pipe)
186
- puts
187
- url = pipe["web_url"] || "https://gitlab.com/#{owner}/#{repo}/-/pipelines"
188
- abort("Pipeline failed: #{url}")
189
- end
190
- end
191
- sleep(1)
192
- end
193
- pbar.finish unless pbar.finished?
194
- puts "\nGitLab pipeline passing."
195
- end
196
- end
197
-
198
- abort("CI configuration not detected (GitHub or GitLab). Ensure CI is configured and remotes point to the correct hosts.") unless checks_any
199
- end
200
-
201
- def run_cmd!(cmd)
202
- puts "$ #{cmd}"
203
- # Execute commands with the current environment
204
- success = system(ENV, cmd)
205
- abort("Command failed: #{cmd}") unless success
206
- end
207
-
208
- def git_output(args)
209
- out, status = Open3.capture2("git", *args)
210
- [out.strip, status.success?]
211
- end
212
-
213
- def check_git_clean!
214
- out, ok = git_output(["status", "--porcelain"])
215
- abort("Git working tree is not clean. Commit/stash changes before releasing.\n\n#{out}") unless ok && out.empty?
216
- end
217
-
218
- def ensure_git_user!
219
- name, ok1 = git_output(["config", "user.name"])
220
- email, ok2 = git_output(["config", "user.email"])
221
- abort("Git user.name or user.email not configured.") unless ok1 && ok2 && !name.empty? && !email.empty?
222
- end
223
-
224
- def ensure_bundler_2_7_plus!
225
- begin
226
- require "bundler"
227
- rescue LoadError
228
- abort("Bundler is required. Please install bundler >= 2.7.0 and try again.")
229
- end
230
- ver = Gem::Version.new(Bundler::VERSION)
231
- min = Gem::Version.new("2.7.0")
232
- if ver < min
233
- abort("kettle-release requires Bundler >= 2.7.0 for reproducible builds by default. Current: #{Bundler::VERSION}. Please upgrade bundler.")
234
- end
235
- end
236
-
237
- def detect_version
238
- # Look for lib/**/version.rb and extract VERSION constant string
239
- candidates = Dir[File.join(@root, "lib", "**", "version.rb")]
240
- abort("Could not find version.rb under lib/**.") if candidates.empty?
241
- path = candidates.min
242
- content = File.read(path)
243
- m = content.match(/VERSION\s*=\s*(["'])([^"']+)\1/)
244
- abort("VERSION constant not found in #{path}.") unless m
245
- m[2]
246
- end
247
-
248
- def commit_release_prep!(version)
249
- msg = "🔖 Prepare release v#{version}"
250
- # Only commit if there are changes (version/changelog)
251
- out, _ = git_output(["status", "--porcelain"])
252
- if out.empty?
253
- puts "No changes to commit for release prep (continuing)."
254
- else
255
- run_cmd!(%(git commit -am #{Shellwords.escape(msg)}))
256
- end
257
- end
258
-
259
- def push!
260
- branch = current_branch
261
- abort("Could not determine current branch to push.") unless branch
262
-
263
- if has_remote?("all")
264
- puts "$ git push all #{branch}"
265
- success = system("git push all #{Shellwords.escape(branch)}")
266
- unless success
267
- warn("Normal push to 'all' failed; retrying with force push...")
268
- run_cmd!("git push -f all #{Shellwords.escape(branch)}")
269
- end
270
- return
271
- end
272
-
273
- # Build the list of remotes to push to
274
- remotes = []
275
- remotes << "origin" if has_remote?("origin")
276
- remotes |= github_remote_candidates
277
- remotes |= gitlab_remote_candidates
278
- remotes |= codeberg_remote_candidates
279
- remotes.uniq!
280
-
281
- if remotes.empty?
282
- # Fallback to default behavior if we couldn't detect any remotes
283
- puts "$ git push #{branch}"
284
- success = system("git push #{Shellwords.escape(branch)}")
285
- unless success
286
- warn("Normal push failed; retrying with force push...")
287
- run_cmd!("git push -f #{Shellwords.escape(branch)}")
288
- end
289
- return
290
- end
291
-
292
- remotes.each do |remote|
293
- puts "$ git push #{remote} #{branch}"
294
- success = system("git push #{Shellwords.escape(remote)} #{Shellwords.escape(branch)}")
295
- unless success
296
- warn("Push to #{remote} failed; retrying with force push...")
297
- run_cmd!("git push -f #{Shellwords.escape(remote)} #{Shellwords.escape(branch)}")
298
- end
299
- end
300
- end
301
-
302
- def detect_trunk_branch
303
- out, ok = git_output(["remote", "show", "origin"])
304
- abort("Failed to get origin remote info.") unless ok
305
- m = out.lines.find { |l| l.include?("HEAD branch") }
306
- abort("Unable to detect trunk branch from origin.") unless m
307
- m.split.last
308
- end
309
-
310
- def checkout!(branch)
311
- run_cmd!("git checkout #{Shellwords.escape(branch)}")
312
- end
313
-
314
- def pull!(branch)
315
- run_cmd!("git pull origin #{Shellwords.escape(branch)}")
316
- end
317
-
318
- def current_branch
319
- out, ok = git_output(["rev-parse", "--abbrev-ref", "HEAD"])
320
- ok ? out : nil
321
- end
322
-
323
- def list_remotes
324
- out, ok = git_output(["remote"])
325
- ok ? out.split(/\s+/).reject(&:empty?) : []
326
- end
327
-
328
- def remotes_with_urls
329
- out, ok = git_output(["remote", "-v"])
330
- return {} unless ok
331
- urls = {}
332
- out.each_line do |line|
333
- if line =~ /(\S+)\s+(\S+)\s+\((fetch|push)\)/
334
- name = Regexp.last_match(1)
335
- url = Regexp.last_match(2)
336
- kind = Regexp.last_match(3)
337
- # prefer fetch URL when available
338
- urls[name] = url if kind == "fetch" || !urls.key?(name)
339
- end
340
- end
341
- urls
342
- end
343
-
344
- def remote_url(name)
345
- remotes_with_urls[name]
346
- end
347
-
348
- def github_remote_candidates
349
- remotes_with_urls.select { |n, u| u.include?("github.com") }.keys
350
- end
351
-
352
- def gitlab_remote_candidates
353
- remotes_with_urls.select { |n, u| u.include?("gitlab.com") }.keys
354
- end
355
-
356
- def codeberg_remote_candidates
357
- remotes_with_urls.select { |n, u| u.include?("codeberg.org") }.keys
358
- end
359
-
360
- def preferred_github_remote
361
- cands = github_remote_candidates
362
- return if cands.empty?
363
- # Prefer a remote literally named 'github', otherwise the first
364
- cands.find { |n| n == "github" } || cands.first
365
- end
366
-
367
- def parse_github_owner_repo(url)
368
- return [nil, nil] unless url
369
- if url =~ %r{git@github.com:(.+?)/(.+?)(\.git)?$}
370
- [Regexp.last_match(1), Regexp.last_match(2).sub(/\.git\z/, "")]
371
- elsif url =~ %r{https://github.com/(.+?)/(.+?)(\.git)?$}
372
- [Regexp.last_match(1), Regexp.last_match(2).sub(/\.git\z/, "")]
373
- else
374
- [nil, nil]
375
- end
376
- end
377
-
378
- def has_remote?(name)
379
- list_remotes.include?(name)
380
- end
381
-
382
- def remote_branch_exists?(remote, branch)
383
- _out, ok = git_output(["show-ref", "--verify", "--quiet", "refs/remotes/#{remote}/#{branch}"])
384
- ok
385
- end
386
-
387
- def ahead_behind_counts(local_ref, remote_ref)
388
- out, ok = git_output(["rev-list", "--left-right", "--count", "#{local_ref}...#{remote_ref}"])
389
- return [0, 0] unless ok && !out.empty?
390
- parts = out.split
391
- left = parts[0].to_i
392
- right = parts[1].to_i
393
- [left, right]
394
- end
395
-
396
- def trunk_behind_remote?(trunk, remote)
397
- # If the remote branch doesn't exist, treat as not behind
398
- return false unless remote_branch_exists?(remote, trunk)
399
- _ahead, behind = ahead_behind_counts(trunk, "#{remote}/#{trunk}")
400
- behind.positive?
401
- end
402
-
403
- def ensure_trunk_synced_before_push!(trunk, feature)
404
- if has_remote?("all")
405
- puts "Remote 'all' detected. Fetching from all remotes and enforcing strict trunk parity..."
406
- run_cmd!("git fetch --all")
407
- remotes = list_remotes
408
- missing_from = []
409
- remotes.each do |r|
410
- next if r == "all"
411
- if remote_branch_exists?(r, trunk)
412
- _ahead, behind = ahead_behind_counts(trunk, "#{r}/#{trunk}")
413
- missing_from << r if behind.positive?
414
- end
415
- end
416
- unless missing_from.empty?
417
- abort("Local #{trunk} is missing commits present on: #{missing_from.join(", ")}. Please sync trunk first.")
418
- end
419
- puts "Local #{trunk} has all commits from remotes: #{(remotes - ["all"]).join(", ")}"
420
- return
421
- end
422
-
423
- # Ensure local trunk is in sync with origin/trunk
424
- run_cmd!("git fetch origin #{Shellwords.escape(trunk)}")
425
- if trunk_behind_remote?(trunk, "origin")
426
- puts "Local #{trunk} is behind origin/#{trunk}. Rebasing..."
427
- cur = current_branch
428
- checkout!(trunk) unless cur == trunk
429
- run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
430
- checkout!(feature) unless feature.nil? || feature == trunk
431
- run_cmd!("git rebase #{Shellwords.escape(trunk)}")
432
- puts "Rebase complete. Will push updated branch next."
433
- else
434
- puts "Local #{trunk} is up to date with origin/#{trunk}."
435
- end
436
-
437
- # If there is a GitHub remote that is not origin, ensure origin/#{trunk} incorporates it
438
- gh_remote = preferred_github_remote
439
- if gh_remote && gh_remote != "origin"
440
- puts "GitHub remote detected: #{gh_remote}. Fetching #{trunk}..."
441
- run_cmd!("git fetch #{gh_remote} #{Shellwords.escape(trunk)}")
442
-
443
- # Compare origin/trunk vs github/trunk to see if they differ
444
- left, right = ahead_behind_counts("origin/#{trunk}", "#{gh_remote}/#{trunk}")
445
- if left.zero? && right.zero?
446
- puts "origin/#{trunk} and #{gh_remote}/#{trunk} are already in sync."
447
- return
448
- end
449
-
450
- checkout!(trunk)
451
- run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
452
-
453
- if left.positive? && right.positive?
454
- # Histories have diverged -> let user choose
455
- puts "origin/#{trunk} and #{gh_remote}/#{trunk} have diverged (#{left} ahead of GH, #{right} behind GH)."
456
- puts "Choose how to reconcile:"
457
- puts " [r] Rebase local/#{trunk} on top of #{gh_remote}/#{trunk} (push to origin)"
458
- puts " [m] Merge --no-ff #{gh_remote}/#{trunk} into #{trunk} (push to origin and #{gh_remote})"
459
- puts " [a] Abort"
460
- print("> ")
461
- choice = $stdin.gets&.strip&.downcase
462
- case choice
463
- when "r"
464
- run_cmd!("git rebase #{Shellwords.escape("#{gh_remote}/#{trunk}")}")
465
- run_cmd!("git push origin #{Shellwords.escape(trunk)}")
466
- puts "Rebased #{trunk} onto #{gh_remote}/#{trunk} and pushed to origin."
467
- when "m"
468
- run_cmd!("git merge --no-ff #{Shellwords.escape("#{gh_remote}/#{trunk}")}")
469
- run_cmd!("git push origin #{Shellwords.escape(trunk)}")
470
- run_cmd!("git push #{Shellwords.escape(gh_remote)} #{Shellwords.escape(trunk)}")
471
- puts "Merged #{gh_remote}/#{trunk} into #{trunk} and pushed to origin and #{gh_remote}."
472
- else
473
- abort("Aborted by user. Please reconcile trunks and re-run.")
474
- end
475
- elsif right.positive? && left.zero?
476
- # One side can be fast-forwarded
477
- puts "Fast-forwarding #{trunk} to include #{gh_remote}/#{trunk}..."
478
- run_cmd!("git merge --ff-only #{Shellwords.escape("#{gh_remote}/#{trunk}")}")
479
- run_cmd!("git push origin #{Shellwords.escape(trunk)}")
480
- # origin is behind GH -> fast-forward merge
481
- elsif left.positive? && right.zero?
482
- # origin ahead of GH -> nothing required for origin, optionally inform user
483
- puts "origin/#{trunk} is ahead of #{gh_remote}/#{trunk}; no action required before push."
484
- end
485
- end
486
- end
487
-
488
- def merge_feature_into_trunk_and_push!(trunk, feature)
489
- return if feature.nil? || feature == trunk
490
- puts "Merging #{feature} into #{trunk} (after CI success)..."
491
- checkout!(trunk)
492
- run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
493
- run_cmd!("git merge #{Shellwords.escape(feature)}")
494
- run_cmd!("git push origin #{Shellwords.escape(trunk)}")
495
- puts "Merged #{feature} into #{trunk} and pushed. The PR (if any) should auto-close."
496
- end
497
-
498
- def ensure_signing_setup_or_skip!
499
- return if ENV.key?("SKIP_GEM_SIGNING")
500
-
501
- user = ENV.fetch("GEM_CERT_USER", ENV["USER"])
502
- cert_path = File.join(@root, "certs", "#{user}.pem")
503
- unless File.exist?(cert_path)
504
- abort(<<~MSG)
505
- Gem signing appears enabled but no public cert found at:
506
- #{cert_path}
507
- Add your public key to certs/<USER>.pem (or set GEM_CERT_USER), or set SKIP_GEM_SIGNING to build unsigned.
508
- MSG
509
- end
510
- puts "Found signing cert: #{cert_path}"
511
- puts "When prompted during build/release, enter the PEM password for ~/.ssh/gem-private_key.pem"
512
- end
513
-
514
- # --- Checksum validation ---
515
- # Validate that the sha256 of the built gem in pkg/ matches the recorded
516
- # checksum stored under checksums/<gem>.gem.sha256. Abort with guidance if not.
517
- # @param version [String]
518
- # @param stage [String] human-readable context (e.g., "after release")
519
- def validate_checksums!(version, stage: "")
520
- gem_path = gem_file_for_version(version)
521
- unless gem_path && File.file?(gem_path)
522
- abort("Unable to locate built gem for version #{version} in pkg/. Did the build succeed?")
523
- end
524
- actual = compute_sha256(gem_path)
525
- checks_path = File.join(@root, "checksums", "#{File.basename(gem_path)}.sha256")
526
- unless File.file?(checks_path)
527
- abort("Expected checksum file not found: #{checks_path}. Did bin/gem_checksums run?")
528
- end
529
- expected = File.read(checks_path).strip
530
- if actual != expected
531
- abort(<<~MSG)
532
- SHA256 mismatch #{stage}:
533
- gem: #{gem_path}
534
- sha256sum: #{actual}
535
- file: #{checks_path}
536
- file: #{expected}
537
- The artifact being released must match the checksummed artifact exactly.
538
- Retry locally: bundle exec rake build && bin/gem_checksums && bundle exec rake release
539
- MSG
540
- else
541
- puts "Checksum OK #{stage}: #{File.basename(gem_path)}"
542
- end
543
- end
544
-
545
- # Find the gem file in pkg/ that matches the given version
546
- def gem_file_for_version(version)
547
- pkg = File.join(@root, "pkg")
548
- pattern = File.join(pkg, "*.gem")
549
- gems = Dir[pattern].select { |p| File.basename(p).include?("-#{version}.gem") }
550
- gems.sort.last
551
- end
552
-
553
- # Compute sha256 using system utilities (sha256sum or shasum -a 256),
554
- # falling back to Ruby Digest if neither is available.
555
- def compute_sha256(path)
556
- if system("which sha256sum > /dev/null 2>&1")
557
- out, _ = Open3.capture2e("sha256sum", path)
558
- out.split.first
559
- elsif system("which shasum > /dev/null 2>&1")
560
- out, _ = Open3.capture2e("shasum", "-a", "256", path)
561
- out.split.first
562
- else
563
- require "digest"
564
- Digest::SHA256.file(path).hexdigest
565
- end
566
- end
567
- end
568
- end
569
- end
570
-
571
30
  # Always execute when this file is loaded (e.g., via a Bundler binstub).
572
31
  # Do not guard with __FILE__ == $PROGRAM_NAME because binstubs use Kernel.load.
573
32
  if ARGV.include?("-h") || ARGV.include?("--help")
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # External stdlib
3
4
  require "open3"
4
5
  require "net/http"
5
6
  require "json"
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Branch rule enforcement and commit message footer support for commit-msg hook.
4
+ # Provides a lib entrypoint so the exe wrapper can be minimal.
5
+
6
+ module Kettle
7
+ module Dev
8
+ module CommitMsg
9
+ module_function
10
+
11
+ BRANCH_RULES = {
12
+ "jira" => /^(?<story_type>(hotfix)|(bug)|(feature)|(candy))\/(?<story_id>\d{8,})-.+\Z/,
13
+ }.freeze
14
+
15
+ # Enforce branch rule by appending [type][id] to the commit message when missing.
16
+ # @param path [String] path to commit message file (ARGV[0] from git)
17
+ def enforce_branch_rule!(path)
18
+ validate = ENV.fetch("GIT_HOOK_BRANCH_VALIDATE", "false")
19
+ branch_rule_type = (!validate.casecmp("false").zero? && validate) || nil
20
+ return unless branch_rule_type
21
+ branch_rule = BRANCH_RULES[branch_rule_type]
22
+ return unless branch_rule
23
+
24
+ branch = %x(git branch 2> /dev/null | grep -e ^* | awk '{print $2}')
25
+ match_data = branch.match(branch_rule)
26
+ return unless match_data
27
+
28
+ commit_msg = File.read(path)
29
+ unless commit_msg.include?(match_data[:story_id])
30
+ commit_msg = <<~EOS
31
+ #{commit_msg.strip}
32
+ [#{match_data[:story_type]}][#{match_data[:story_id]}]
33
+ EOS
34
+ File.open(path, "w") { |file| file.print(commit_msg) }
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Dev
5
+ # Exit/abort indirection layer to allow controllable behavior in tests.
6
+ #
7
+ # Production/default behavior delegates to Kernel.abort / Kernel.exit,
8
+ # which raise SystemExit. Specs can stub these methods to avoid terminating
9
+ # the process or to assert on arguments without coupling to Kernel.
10
+ #
11
+ # Example (RSpec):
12
+ # allow(Kettle::Dev::ExitAdapter).to receive(:abort).and_raise(SystemExit.new(1))
13
+ #
14
+ # This adapter mirrors the "mockable adapter" approach used for GitAdapter.
15
+ module ExitAdapter
16
+ module_function
17
+
18
+ # Abort the current execution with a message. By default this calls Kernel.abort,
19
+ # which raises SystemExit after printing the message to STDERR.
20
+ #
21
+ # @param msg [String]
22
+ # @return [void]
23
+ def abort(msg)
24
+ Kernel.abort(msg)
25
+ end
26
+
27
+ # Exit the current process with a given status code. By default this calls Kernel.exit.
28
+ #
29
+ # @param status [Integer]
30
+ # @return [void]
31
+ def exit(status = 0)
32
+ Kernel.exit(status)
33
+ end
34
+ end
35
+ end
36
+ end