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