kettle-dev 1.0.9 → 1.0.11

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 (68) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +2 -2
  3. data/.envrc +4 -3
  4. data/.github/workflows/coverage.yml +3 -3
  5. data/.github/workflows/coverage.yml.example +127 -0
  6. data/.github/workflows/discord-notifier.yml +2 -1
  7. data/.github/workflows/truffle.yml +0 -8
  8. data/.junie/guidelines.md +4 -3
  9. data/.simplecov +5 -1
  10. data/Appraisals +5 -0
  11. data/Appraisals.example +102 -0
  12. data/CHANGELOG.md +80 -25
  13. data/CHANGELOG.md.example +4 -4
  14. data/CONTRIBUTING.md +43 -1
  15. data/Gemfile +3 -0
  16. data/README.md +65 -14
  17. data/README.md.example +515 -0
  18. data/{Rakefile → Rakefile.example} +17 -35
  19. data/exe/kettle-changelog +401 -0
  20. data/exe/kettle-commit-msg +11 -143
  21. data/exe/kettle-readme-backers +8 -352
  22. data/exe/kettle-release +7 -706
  23. data/gemfiles/modular/optional.gemfile +5 -0
  24. data/lib/kettle/dev/ci_helpers.rb +1 -0
  25. data/lib/kettle/dev/commit_msg.rb +39 -0
  26. data/lib/kettle/dev/exit_adapter.rb +36 -0
  27. data/lib/kettle/dev/git_adapter.rb +185 -0
  28. data/lib/kettle/dev/git_commit_footer.rb +130 -0
  29. data/lib/kettle/dev/input_adapter.rb +40 -0
  30. data/lib/kettle/dev/rakelib/appraisal.rake +8 -9
  31. data/lib/kettle/dev/rakelib/bench.rake +2 -7
  32. data/lib/kettle/dev/rakelib/bundle_audit.rake +2 -0
  33. data/lib/kettle/dev/rakelib/ci.rake +4 -396
  34. data/lib/kettle/dev/rakelib/install.rake +1 -295
  35. data/lib/kettle/dev/rakelib/reek.rake +2 -0
  36. data/lib/kettle/dev/rakelib/rubocop_gradual.rake +2 -0
  37. data/lib/kettle/dev/rakelib/spec_test.rake +2 -0
  38. data/lib/kettle/dev/rakelib/template.rake +3 -465
  39. data/lib/kettle/dev/readme_backers.rb +340 -0
  40. data/lib/kettle/dev/release_cli.rb +674 -0
  41. data/lib/kettle/dev/tasks/ci_task.rb +337 -0
  42. data/lib/kettle/dev/tasks/install_task.rb +516 -0
  43. data/lib/kettle/dev/tasks/template_task.rb +593 -0
  44. data/lib/kettle/dev/template_helpers.rb +65 -12
  45. data/lib/kettle/dev/version.rb +1 -1
  46. data/lib/kettle/dev/versioning.rb +68 -0
  47. data/lib/kettle/dev.rb +30 -1
  48. data/lib/kettle-dev.rb +2 -3
  49. data/sig/kettle/dev/ci_helpers.rbs +8 -17
  50. data/sig/kettle/dev/commit_msg.rbs +8 -0
  51. data/sig/kettle/dev/exit_adapter.rbs +8 -0
  52. data/sig/kettle/dev/git_adapter.rbs +15 -0
  53. data/sig/kettle/dev/git_commit_footer.rbs +16 -0
  54. data/sig/kettle/dev/input_adapter.rbs +8 -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/template_helpers.rbs +3 -1
  62. data/sig/kettle/dev/version.rbs +0 -0
  63. data/sig/kettle/emoji_regex.rbs +5 -0
  64. data/sig/kettle-dev.rbs +0 -0
  65. data.tar.gz.sig +0 -0
  66. metadata +59 -10
  67. metadata.gz.sig +0 -0
  68. data/.gitlab-ci.yml +0 -45
@@ -0,0 +1,674 @@
1
+ # frozen_string_literal: true
2
+
3
+ # External stdlib
4
+ require "digest"
5
+ require "open3"
6
+ require "shellwords"
7
+ require "time"
8
+ require "fileutils"
9
+ require "net/http"
10
+ require "json"
11
+ require "uri"
12
+
13
+ # External gems
14
+ require "ruby-progressbar"
15
+
16
+ # Internal
17
+ require "kettle/dev/git_adapter"
18
+ require "kettle/dev/exit_adapter"
19
+ require "kettle/dev/input_adapter"
20
+ require "kettle/dev/versioning"
21
+
22
+ module Kettle
23
+ module Dev
24
+ class ReleaseCLI
25
+ private
26
+
27
+ def abort(msg)
28
+ Kettle::Dev::ExitAdapter.abort(msg)
29
+ end
30
+
31
+ public
32
+
33
+ def initialize
34
+ @root = Kettle::Dev::CIHelpers.project_root
35
+ @git = Kettle::Dev::GitAdapter.new
36
+ end
37
+
38
+ def run
39
+ ensure_bundler_2_7_plus!
40
+
41
+ version = detect_version
42
+ puts "Detected version: #{version.inspect}"
43
+
44
+ latest_overall = nil
45
+ latest_for_series = nil
46
+ begin
47
+ gem_name = detect_gem_name
48
+ latest_overall, latest_for_series = latest_released_versions(gem_name, version)
49
+ rescue StandardError => e
50
+ warn("Warning: failed to check RubyGems for latest version (#{e.class}: #{e.message}). Proceeding.")
51
+ end
52
+
53
+ if latest_overall
54
+ msg = "Latest released: #{latest_overall}"
55
+ if latest_for_series && latest_for_series != latest_overall
56
+ msg += " | Latest for series #{Gem::Version.new(version).segments[0, 2].join(".")}.x: #{latest_for_series}"
57
+ elsif latest_for_series
58
+ msg += " (matches current series)"
59
+ end
60
+ puts msg
61
+
62
+ cur = Gem::Version.new(version)
63
+ overall = Gem::Version.new(latest_overall)
64
+ cur_series = cur.segments[0, 2]
65
+ overall_series = overall.segments[0, 2]
66
+ target = if (cur_series <=> overall_series) == -1
67
+ latest_for_series
68
+ else
69
+ latest_overall
70
+ end
71
+ if target
72
+ bump = Kettle::Dev::Versioning.classify_bump(target, version)
73
+ case bump
74
+ when :same
75
+ series = cur_series.join(".")
76
+ warn("version.rb (#{version}) matches the latest released version for series #{series} (#{target}).")
77
+ abort("Aborting: version bump required. Bump PATCH/MINOR/MAJOR/EPIC.")
78
+ when :downgrade
79
+ series = cur_series.join(".")
80
+ warn("version.rb (#{version}) is lower than the latest released version for series #{series} (#{target}).")
81
+ abort("Aborting: version must be bumped above #{target}.")
82
+ else
83
+ label = {epic: "EPIC", major: "MAJOR", minor: "MINOR", patch: "PATCH"}[bump] || bump.to_s.upcase
84
+ puts "Proposed bump type: #{label} (from #{target} -> #{version})"
85
+ end
86
+ else
87
+ puts "Could not determine latest released version from RubyGems (offline?). Proceeding without sanity check."
88
+ end
89
+ else
90
+ puts "Could not determine latest released version from RubyGems (offline?). Proceeding without sanity check."
91
+ end
92
+
93
+ puts "Have you updated lib/**/version.rb and CHANGELOG.md for v#{version}? [y/N]"
94
+ print("> ")
95
+ ans = Kettle::Dev::InputAdapter.gets&.strip
96
+ abort("Aborted: please update version.rb and CHANGELOG.md, then re-run.") unless ans&.downcase&.start_with?("y")
97
+
98
+ run_cmd!("bin/setup")
99
+ run_cmd!("bin/rake")
100
+
101
+ appraisals_path = File.join(@root, "Appraisals")
102
+ if File.file?(appraisals_path)
103
+ puts "Appraisals detected at #{appraisals_path}. Running: bin/rake appraisal:update"
104
+ run_cmd!("bin/rake appraisal:update")
105
+ else
106
+ puts "No Appraisals file found; skipping appraisal:update"
107
+ end
108
+
109
+ ensure_git_user!
110
+ committed = commit_release_prep!(version)
111
+
112
+ maybe_run_local_ci_before_push!(committed)
113
+
114
+ trunk = detect_trunk_branch
115
+ feature = current_branch
116
+ puts "Trunk branch detected: #{trunk}"
117
+ ensure_trunk_synced_before_push!(trunk, feature)
118
+
119
+ push!
120
+
121
+ monitor_workflows_after_push!
122
+
123
+ merge_feature_into_trunk_and_push!(trunk, feature)
124
+
125
+ checkout!(trunk)
126
+ pull!(trunk)
127
+
128
+ # Strong reminder for local runs: skip signing when testing a release flow
129
+ if ENV["SKIP_GEM_SIGNING"].to_s.strip == ""
130
+ puts "TIP: For local dry-runs or testing the release workflow, set SKIP_GEM_SIGNING=true to avoid PEM password prompts."
131
+ # Prompt on CI to allow an explicit abort when signing would otherwise hang
132
+ if ENV.fetch("CI", "false").casecmp("true").zero?
133
+ print("Proceed with signing enabled? This may hang waiting for a PEM password. [y/N]: ")
134
+ ans = Kettle::Dev::InputAdapter.gets&.strip
135
+ unless ans&.downcase&.start_with?("y")
136
+ abort("Aborted. Re-run with SKIP_GEM_SIGNING=true bundle exec kettle-release (or set it in your environment).")
137
+ end
138
+ end
139
+ end
140
+
141
+ ensure_signing_setup_or_skip!
142
+ puts "Running build (you may be prompted for the signing key password)..."
143
+ run_cmd!("bundle exec rake build")
144
+
145
+ run_cmd!("bin/gem_checksums")
146
+ validate_checksums!(version, stage: "after build + gem_checksums")
147
+
148
+ puts "Running release (you may be prompted for signing key password and RubyGems MFA OTP)..."
149
+ run_cmd!("bundle exec rake release")
150
+ validate_checksums!(version, stage: "after release")
151
+
152
+ puts "\nRelease complete. Don't forget to push the checksums commit if needed."
153
+ end
154
+
155
+ private
156
+
157
+ def monitor_workflows_after_push!
158
+ root = Kettle::Dev::CIHelpers.project_root
159
+ workflows = Kettle::Dev::CIHelpers.workflows_list(root)
160
+ gitlab_ci = File.exist?(File.join(root, ".gitlab-ci.yml"))
161
+
162
+ branch = Kettle::Dev::CIHelpers.current_branch
163
+ abort("Could not determine current branch for CI checks.") unless branch
164
+
165
+ gh_remote = preferred_github_remote
166
+ gh_owner = nil
167
+ gh_repo = nil
168
+ if gh_remote && !workflows.empty?
169
+ url = remote_url(gh_remote)
170
+ gh_owner, gh_repo = parse_github_owner_repo(url)
171
+ end
172
+
173
+ checks_any = false
174
+
175
+ if gh_owner && gh_repo && !workflows.empty?
176
+ checks_any = true
177
+ total = workflows.size
178
+ abort("No GitHub workflows found under .github/workflows; aborting.") if total.zero?
179
+
180
+ passed = {}
181
+ idx = 0
182
+ puts "Ensuring GitHub Actions workflows pass on #{branch} (#{gh_owner}/#{gh_repo}) via remote '#{gh_remote}'"
183
+ pbar = if defined?(ProgressBar)
184
+ ProgressBar.create(title: "CI", total: total, format: "%t %b %c/%C", length: 30)
185
+ end
186
+
187
+ loop do
188
+ wf = workflows[idx]
189
+ run = Kettle::Dev::CIHelpers.latest_run(owner: gh_owner, repo: gh_repo, workflow_file: wf, branch: branch)
190
+ if run
191
+ if Kettle::Dev::CIHelpers.success?(run)
192
+ unless passed[wf]
193
+ passed[wf] = true
194
+ pbar&.increment
195
+ end
196
+ elsif Kettle::Dev::CIHelpers.failed?(run)
197
+ puts
198
+ url = run["html_url"] || "https://github.com/#{gh_owner}/#{gh_repo}/actions/workflows/#{wf}"
199
+ abort("Workflow failed: #{wf} -> #{url}")
200
+ end
201
+ end
202
+ break if passed.size == total
203
+ idx = (idx + 1) % total
204
+ sleep(1)
205
+ end
206
+ pbar&.finish unless pbar&.finished?
207
+ puts "\nAll GitHub workflows passing (#{passed.size}/#{total})."
208
+ end
209
+
210
+ gl_remote = gitlab_remote_candidates.first
211
+ if gitlab_ci && gl_remote
212
+ owner, repo = Kettle::Dev::CIHelpers.repo_info_gitlab
213
+ if owner && repo
214
+ checks_any = true
215
+ puts "Ensuring GitLab pipeline passes on #{branch} (#{owner}/#{repo}) via remote '#{gl_remote}'"
216
+ pbar = if defined?(ProgressBar)
217
+ ProgressBar.create(title: "CI", total: 1, format: "%t %b %c/%C", length: 30)
218
+ end
219
+ loop do
220
+ pipe = Kettle::Dev::CIHelpers.gitlab_latest_pipeline(owner: owner, repo: repo, branch: branch)
221
+ if pipe
222
+ if Kettle::Dev::CIHelpers.gitlab_success?(pipe)
223
+ pbar&.increment unless pbar&.finished?
224
+ break
225
+ elsif Kettle::Dev::CIHelpers.gitlab_failed?(pipe)
226
+ puts
227
+ url = pipe["web_url"] || "https://gitlab.com/#{owner}/#{repo}/-/pipelines"
228
+ abort("Pipeline failed: #{url}")
229
+ end
230
+ end
231
+ sleep(1)
232
+ end
233
+ pbar&.finish unless pbar&.finished?
234
+ puts "\nGitLab pipeline passing."
235
+ end
236
+ end
237
+
238
+ abort("CI configuration not detected (GitHub or GitLab). Ensure CI is configured and remotes point to the correct hosts.") unless checks_any
239
+ end
240
+
241
+ def run_cmd!(cmd)
242
+ # For Bundler-invoked build/release, explicitly prefix SKIP_GEM_SIGNING so
243
+ # the signing step is skipped even when Bundler scrubs ENV.
244
+ if ENV["SKIP_GEM_SIGNING"] && cmd =~ /\Abundle(\s+exec)?\s+rake\s+(build|release)\b/
245
+ cmd = "SKIP_GEM_SIGNING=true #{cmd}"
246
+ end
247
+ puts "$ #{cmd}"
248
+ # Pass a plain Hash for the environment to satisfy tests and avoid ENV object oddities
249
+ env_hash = ENV.respond_to?(:to_hash) ? ENV.to_hash : ENV.to_h
250
+ success = system(env_hash, cmd)
251
+ abort("Command failed: #{cmd}") unless success
252
+ end
253
+
254
+ def git_output(args)
255
+ out, status = Open3.capture2("git", *args)
256
+ [out.strip, status.success?]
257
+ end
258
+
259
+ def ensure_git_user!
260
+ name, ok1 = git_output(["config", "user.name"])
261
+ email, ok2 = git_output(["config", "user.email"])
262
+ abort("Git user.name or user.email not configured.") unless ok1 && ok2 && !name.empty? && !email.empty?
263
+ end
264
+
265
+ def ensure_bundler_2_7_plus!
266
+ begin
267
+ require "bundler"
268
+ rescue LoadError
269
+ abort("Bundler is required. Please install bundler >= 2.7.0 and try again.")
270
+ end
271
+ ver = Gem::Version.new(Bundler::VERSION)
272
+ min = Gem::Version.new("2.7.0")
273
+ if ver < min
274
+ abort("kettle-release requires Bundler >= 2.7.0 for reproducible builds by default. Current: #{Bundler::VERSION}. Please upgrade bundler.")
275
+ end
276
+ end
277
+
278
+ def maybe_run_local_ci_before_push!(committed)
279
+ mode = (ENV["K_RELEASE_LOCAL_CI"] || "").strip.downcase
280
+ run_it = case mode
281
+ when "true", "1", "yes", "y" then true
282
+ when "ask"
283
+ print("Run local CI with 'act' before pushing? [Y/n] ")
284
+ ans = Kettle::Dev::InputAdapter.gets&.strip
285
+ ans.nil? || ans.empty? || ans =~ /\Ay(es)?\z/i
286
+ else
287
+ false
288
+ end
289
+ return unless run_it
290
+
291
+ act_ok = begin
292
+ system("act", "--version", out: File::NULL, err: File::NULL)
293
+ rescue StandardError
294
+ false
295
+ end
296
+ unless act_ok
297
+ puts "Skipping local CI: 'act' command not found. Install https://github.com/nektos/act to enable."
298
+ return
299
+ end
300
+
301
+ root = Kettle::Dev::CIHelpers.project_root
302
+ workflows_dir = File.join(root, ".github", "workflows")
303
+ candidates = Kettle::Dev::CIHelpers.workflows_list(root)
304
+
305
+ chosen = (ENV["K_RELEASE_LOCAL_CI_WORKFLOW"] || "").strip
306
+ if !chosen.empty?
307
+ chosen = "#{chosen}.yml" unless chosen =~ /\.ya?ml\z/
308
+ else
309
+ chosen = if candidates.include?("locked_deps.yml")
310
+ "locked_deps.yml"
311
+ elsif candidates.include?("locked_deps.yaml")
312
+ "locked_deps.yaml"
313
+ else
314
+ candidates.first
315
+ end
316
+ end
317
+
318
+ unless chosen
319
+ puts "Skipping local CI: no workflows found under .github/workflows."
320
+ return
321
+ end
322
+
323
+ file_path = File.join(workflows_dir, chosen)
324
+ unless File.file?(file_path)
325
+ puts "Skipping local CI: selected workflow not found: #{file_path}"
326
+ return
327
+ end
328
+
329
+ puts "== Running local CI with act on #{chosen} =="
330
+ ok = system("act", "-W", file_path)
331
+ if ok
332
+ puts "Local CI succeeded for #{chosen}."
333
+ else
334
+ puts "Local CI failed for #{chosen}."
335
+ if committed
336
+ puts "Rolling back release prep commit (soft reset)..."
337
+ system("git", "reset", "--soft", "HEAD^")
338
+ end
339
+ abort("Aborting due to local CI failure.")
340
+ end
341
+ end
342
+
343
+ def detect_version
344
+ Kettle::Dev::Versioning.detect_version(@root)
345
+ end
346
+
347
+ def detect_gem_name
348
+ gemspecs = Dir[File.join(@root, "*.gemspec")]
349
+ abort("Could not find a .gemspec in project root.") if gemspecs.empty?
350
+ path = gemspecs.min
351
+ content = File.read(path)
352
+ m = content.match(/spec\.name\s*=\s*(["'])([^"']+)\1/)
353
+ abort("Could not determine gem name from #{path}.") unless m
354
+ m[2]
355
+ end
356
+
357
+ def latest_released_versions(gem_name, current_version)
358
+ uri = URI("https://rubygems.org/api/v1/versions/#{gem_name}.json")
359
+ res = Net::HTTP.get_response(uri)
360
+ return [nil, nil] unless res.is_a?(Net::HTTPSuccess)
361
+ data = JSON.parse(res.body)
362
+ versions = data.map { |h| h["number"] }.compact
363
+ versions.reject! { |v| v.to_s.include?("-pre") || v.to_s.include?(".pre") || v.to_s =~ /[a-zA-Z]/ }
364
+ gversions = versions.map { |s| Gem::Version.new(s) }.sort
365
+ latest_overall = gversions.last&.to_s
366
+
367
+ cur = Gem::Version.new(current_version)
368
+ series = cur.segments[0, 2]
369
+ series_versions = gversions.select { |gv| gv.segments[0, 2] == series }
370
+ latest_series = series_versions.last&.to_s
371
+ [latest_overall, latest_series]
372
+ rescue StandardError
373
+ [nil, nil]
374
+ end
375
+
376
+ def commit_release_prep!(version)
377
+ msg = "🔖 Prepare release v#{version}"
378
+ # Stage all changes (including new/untracked files) prior to committing
379
+ run_cmd!("git add -A")
380
+ out, _ = git_output(["status", "--porcelain"])
381
+ if out.empty?
382
+ puts "No changes to commit for release prep (continuing)."
383
+ false
384
+ else
385
+ run_cmd!(%(git commit -am #{Shellwords.escape(msg)}))
386
+ true
387
+ end
388
+ end
389
+
390
+ def push!
391
+ branch = current_branch
392
+ abort("Could not determine current branch to push.") unless branch
393
+
394
+ if has_remote?("all")
395
+ puts "$ git push all #{branch}"
396
+ success = @git.push("all", branch)
397
+ unless success
398
+ warn("Normal push to 'all' failed; retrying with force push...")
399
+ @git.push("all", branch, force: true)
400
+ end
401
+ return
402
+ end
403
+
404
+ remotes = []
405
+ remotes << "origin" if has_remote?("origin")
406
+ remotes |= github_remote_candidates
407
+ remotes |= gitlab_remote_candidates
408
+ remotes |= codeberg_remote_candidates
409
+ remotes.uniq!
410
+
411
+ if remotes.empty?
412
+ puts "$ git push #{branch}"
413
+ success = @git.push(nil, branch)
414
+ unless success
415
+ warn("Normal push failed; retrying with force push...")
416
+ @git.push(nil, branch, force: true)
417
+ end
418
+ return
419
+ end
420
+
421
+ remotes.each do |remote|
422
+ puts "$ git push #{remote} #{branch}"
423
+ success = @git.push(remote, branch)
424
+ unless success
425
+ warn("Push to #{remote} failed; retrying with force push...")
426
+ @git.push(remote, branch, force: true)
427
+ end
428
+ end
429
+ end
430
+
431
+ def detect_trunk_branch
432
+ out, ok = git_output(["remote", "show", "origin"])
433
+ abort("Failed to get origin remote info.") unless ok
434
+ m = out.lines.find { |l| l.include?("HEAD branch") }
435
+ abort("Unable to detect trunk branch from origin.") unless m
436
+ m.split.last
437
+ end
438
+
439
+ def checkout!(branch)
440
+ ok = @git.checkout(branch)
441
+ abort("Failed to checkout #{branch}") unless ok
442
+ end
443
+
444
+ def pull!(branch)
445
+ ok = @git.pull("origin", branch)
446
+ abort("Failed to pull origin #{branch}") unless ok
447
+ end
448
+
449
+ def current_branch
450
+ @git.current_branch
451
+ end
452
+
453
+ def list_remotes
454
+ @git.remotes
455
+ end
456
+
457
+ def remotes_with_urls
458
+ @git.remotes_with_urls
459
+ end
460
+
461
+ def remote_url(name)
462
+ @git.remote_url(name)
463
+ end
464
+
465
+ def github_remote_candidates
466
+ remotes_with_urls.select { |n, u| u.include?("github.com") }.keys
467
+ end
468
+
469
+ def gitlab_remote_candidates
470
+ remotes_with_urls.select { |n, u| u.include?("gitlab.com") }.keys
471
+ end
472
+
473
+ def codeberg_remote_candidates
474
+ remotes_with_urls.select { |n, u| u.include?("codeberg.org") }.keys
475
+ end
476
+
477
+ def preferred_github_remote
478
+ cands = github_remote_candidates
479
+ return if cands.empty?
480
+ # Prefer explicitly named GitHub remotes first, then origin (only if it points to GitHub), else the first candidate
481
+ explicit = cands.find { |n| n == "github" } || cands.find { |n| n == "gh" }
482
+ return explicit if explicit
483
+ return "origin" if cands.include?("origin")
484
+ cands.first
485
+ end
486
+
487
+ def parse_github_owner_repo(url)
488
+ return [nil, nil] unless url
489
+ if url =~ %r{git@github.com:(.+?)/(.+?)(\.git)?$}
490
+ [Regexp.last_match(1), Regexp.last_match(2).sub(/\.git\z/, "")]
491
+ elsif url =~ %r{https://github.com/(.+?)/(.+?)(\.git)?$}
492
+ [Regexp.last_match(1), Regexp.last_match(2).sub(/\.git\z/, "")]
493
+ else
494
+ [nil, nil]
495
+ end
496
+ end
497
+
498
+ def has_remote?(name)
499
+ list_remotes.include?(name)
500
+ end
501
+
502
+ def remote_branch_exists?(remote, branch)
503
+ _out, ok = git_output(["show-ref", "--verify", "--quiet", "refs/remotes/#{remote}/#{branch}"])
504
+ ok
505
+ end
506
+
507
+ def ahead_behind_counts(local_ref, remote_ref)
508
+ out, ok = git_output(["rev-list", "--left-right", "--count", "#{local_ref}...#{remote_ref}"])
509
+ return [0, 0] unless ok && !out.empty?
510
+ parts = out.split
511
+ left = parts[0].to_i
512
+ right = parts[1].to_i
513
+ [left, right]
514
+ end
515
+
516
+ def trunk_behind_remote?(trunk, remote)
517
+ return false unless remote_branch_exists?(remote, trunk)
518
+ _ahead, behind = ahead_behind_counts(trunk, "#{remote}/#{trunk}")
519
+ behind.positive?
520
+ end
521
+
522
+ def ensure_trunk_synced_before_push!(trunk, feature)
523
+ if has_remote?("all")
524
+ puts "Remote 'all' detected. Fetching from all remotes and enforcing strict trunk parity..."
525
+ run_cmd!("git fetch --all")
526
+ remotes = list_remotes
527
+ missing_from = []
528
+ remotes.each do |r|
529
+ next if r == "all"
530
+ if remote_branch_exists?(r, trunk)
531
+ _ahead, behind = ahead_behind_counts(trunk, "#{r}/#{trunk}")
532
+ missing_from << r if behind.positive?
533
+ end
534
+ end
535
+ unless missing_from.empty?
536
+ abort("Local #{trunk} is missing commits present on: #{missing_from.join(", ")}. Please sync trunk first.")
537
+ end
538
+ puts "Local #{trunk} has all commits from remotes: #{(remotes - ["all"]).join(", ")}"
539
+ return
540
+ end
541
+
542
+ run_cmd!("git fetch origin #{Shellwords.escape(trunk)}")
543
+ if trunk_behind_remote?(trunk, "origin")
544
+ puts "Local #{trunk} is behind origin/#{trunk}. Rebasing..."
545
+ cur = current_branch
546
+ checkout!(trunk) unless cur == trunk
547
+ run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
548
+ checkout!(feature) unless feature.nil? || feature == trunk
549
+ run_cmd!("git rebase #{Shellwords.escape(trunk)}")
550
+ puts "Rebase complete. Will push updated branch next."
551
+ else
552
+ puts "Local #{trunk} is up to date with origin/#{trunk}."
553
+ end
554
+
555
+ gh_remote = preferred_github_remote
556
+ if gh_remote && gh_remote != "origin"
557
+ puts "GitHub remote detected: #{gh_remote}. Fetching #{trunk}..."
558
+ run_cmd!("git fetch #{gh_remote} #{Shellwords.escape(trunk)}")
559
+
560
+ left, right = ahead_behind_counts("origin/#{trunk}", "#{gh_remote}/#{trunk}")
561
+ if left.zero? && right.zero?
562
+ puts "origin/#{trunk} and #{gh_remote}/#{trunk} are already in sync."
563
+ return
564
+ end
565
+
566
+ checkout!(trunk)
567
+ run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
568
+
569
+ if left.positive? && right.positive?
570
+ puts "origin/#{trunk} and #{gh_remote}/#{trunk} have diverged (#{left} ahead of GH, #{right} behind GH)."
571
+ puts "Choose how to reconcile:"
572
+ puts " [r] Rebase local/#{trunk} on top of #{gh_remote}/#{trunk} (push to origin)"
573
+ puts " [m] Merge --no-ff #{gh_remote}/#{trunk} into #{trunk} (push to origin and #{gh_remote})"
574
+ puts " [a] Abort"
575
+ print("> ")
576
+ choice = Kettle::Dev::InputAdapter.gets&.strip&.downcase
577
+ case choice
578
+ when "r"
579
+ run_cmd!("git rebase #{Shellwords.escape("#{gh_remote}/#{trunk}")}")
580
+ run_cmd!("git push origin #{Shellwords.escape(trunk)}")
581
+ puts "Rebased #{trunk} onto #{gh_remote}/#{trunk} and pushed to origin."
582
+ when "m"
583
+ run_cmd!("git merge --no-ff #{Shellwords.escape("#{gh_remote}/#{trunk}")}")
584
+ run_cmd!("git push origin #{Shellwords.escape(trunk)}")
585
+ run_cmd!("git push #{Shellwords.escape(gh_remote)} #{Shellwords.escape(trunk)}")
586
+ puts "Merged #{gh_remote}/#{trunk} into #{trunk} and pushed to origin and #{gh_remote}."
587
+ else
588
+ abort("Aborted by user. Please reconcile trunks and re-run.")
589
+ end
590
+ elsif right.positive? && left.zero?
591
+ puts "Fast-forwarding #{trunk} to include #{gh_remote}/#{trunk}..."
592
+ run_cmd!("git merge --ff-only #{Shellwords.escape("#{gh_remote}/#{trunk}")}")
593
+ run_cmd!("git push origin #{Shellwords.escape(trunk)}")
594
+ elsif left.positive? && right.zero?
595
+ puts "origin/#{trunk} is ahead of #{gh_remote}/#{trunk}; no action required before push."
596
+ end
597
+ end
598
+ end
599
+
600
+ def merge_feature_into_trunk_and_push!(trunk, feature)
601
+ return if feature.nil? || feature == trunk
602
+ puts "Merging #{feature} into #{trunk} (after CI success)..."
603
+ checkout!(trunk)
604
+ run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
605
+ run_cmd!("git merge #{Shellwords.escape(feature)}")
606
+ run_cmd!("git push origin #{Shellwords.escape(trunk)}")
607
+ puts "Merged #{feature} into #{trunk} and pushed. The PR (if any) should auto-close."
608
+ end
609
+
610
+ def ensure_signing_setup_or_skip!
611
+ # Treat any non-empty value as an explicit skip signal (more robust across Ruby versions and ENV adapters)
612
+ return if ENV["SKIP_GEM_SIGNING"].to_s.strip != ""
613
+
614
+ user = ENV.fetch("GEM_CERT_USER", ENV["USER"])
615
+ cert_path = File.join(@root, "certs", "#{user}.pem")
616
+ unless File.exist?(cert_path)
617
+ abort(<<~MSG)
618
+ Gem signing appears enabled but no public cert found at:
619
+ #{cert_path}
620
+ Add your public key to certs/<USER>.pem (or set GEM_CERT_USER), or set SKIP_GEM_SIGNING to build unsigned.
621
+ MSG
622
+ end
623
+ puts "Found signing cert: #{cert_path}"
624
+ puts "When prompted during build/release, enter the PEM password for ~/.ssh/gem-private_key.pem"
625
+ end
626
+
627
+ def validate_checksums!(version, stage: "")
628
+ gem_path = gem_file_for_version(version)
629
+ unless gem_path && File.file?(gem_path)
630
+ abort("Unable to locate built gem for version #{version} in pkg/. Did the build succeed?")
631
+ end
632
+ actual = compute_sha256(gem_path)
633
+ checks_path = File.join(@root, "checksums", "#{File.basename(gem_path)}.sha256")
634
+ unless File.file?(checks_path)
635
+ abort("Expected checksum file not found: #{checks_path}. Did bin/gem_checksums run?")
636
+ end
637
+ expected = File.read(checks_path).strip
638
+ if actual != expected
639
+ abort(<<~MSG)
640
+ SHA256 mismatch #{stage}:
641
+ gem: #{gem_path}
642
+ sha256sum: #{actual}
643
+ file: #{checks_path}
644
+ file: #{expected}
645
+ The artifact being released must match the checksummed artifact exactly.
646
+ Retry locally: bundle exec rake build && bin/gem_checksums && bundle exec rake release
647
+ MSG
648
+ else
649
+ puts "Checksum OK #{stage}: #{File.basename(gem_path)}"
650
+ end
651
+ end
652
+
653
+ def gem_file_for_version(version)
654
+ pkg = File.join(@root, "pkg")
655
+ pattern = File.join(pkg, "*.gem")
656
+ gems = Dir[pattern].select { |p| File.basename(p).include?("-#{version}.gem") }
657
+ gems.sort.last
658
+ end
659
+
660
+ def compute_sha256(path)
661
+ if system("which sha256sum > /dev/null 2>&1")
662
+ out, _ = Open3.capture2e("sha256sum", path)
663
+ out.split.first
664
+ elsif system("which shasum > /dev/null 2>&1")
665
+ out, _ = Open3.capture2e("shasum", "-a", "256", path)
666
+ out.split.first
667
+ else
668
+ require "digest"
669
+ Digest::SHA256.file(path).hexdigest
670
+ end
671
+ end
672
+ end
673
+ end
674
+ end