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
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,715 +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
- version = detect_version
55
- puts "Detected version: #{version.inspect}"
56
-
57
- # Sanity check against latest released gem version (RubyGems)
58
- begin
59
- gem_name = detect_gem_name
60
- latest_overall, latest_for_series = latest_released_versions(gem_name, version)
61
- if latest_overall
62
- msg = "Latest released: #{latest_overall}"
63
- if latest_for_series && latest_for_series != latest_overall
64
- msg += " | Latest for series #{Gem::Version.new(version).segments[0, 2].join(".")}.x: #{latest_for_series}"
65
- elsif latest_for_series
66
- msg += " (matches current series)"
67
- end
68
- puts msg
69
-
70
- # Choose the comparison target:
71
- # - If current series (MAJOR.MINOR) is behind overall latest, compare against the latest for the current series.
72
- # - Otherwise (same or ahead), compare against overall latest.
73
- cur = Gem::Version.new(version)
74
- overall = Gem::Version.new(latest_overall)
75
- cur_series = cur.segments[0, 2]
76
- overall_series = overall.segments[0, 2]
77
- target = if (cur_series <=> overall_series) == -1
78
- latest_for_series # may be nil if no prior release in this series
79
- else
80
- latest_overall
81
- end
82
- if target && Gem::Version.new(version) <= Gem::Version.new(target)
83
- series = cur_series.join(".")
84
- warn("version.rb (#{version}) must be greater than the latest released version for series #{series}. Latest for series: #{target}.")
85
- warn("Tip: bump PATCH for a stable branch release, or bump MINOR/MAJOR when on trunk.")
86
- abort("Aborting: version bump required.")
87
- end
88
- else
89
- puts "Could not determine latest released version from RubyGems (offline?). Proceeding without sanity check."
90
- end
91
- rescue StandardError => e
92
- warn("Warning: failed to check RubyGems for latest version (#{e.class}: #{e.message}). Proceeding.")
93
- end
94
-
95
- puts "Have you updated lib/**/version.rb and CHANGELOG.md for v#{version}? [y/N]"
96
- print("> ")
97
- ans = $stdin.gets&.strip
98
- abort("Aborted: please update version.rb and CHANGELOG.md, then re-run.") unless ans&.downcase&.start_with?("y")
99
-
100
- # Re-run checks (and refresh Gemfile.lock)
101
- run_cmd!("bin/setup")
102
- run_cmd!("bin/rake")
103
-
104
- # Update Appraisal gemfiles if Appraisals file is present
105
- appraisals_path = File.join(@root, "Appraisals")
106
- if File.file?(appraisals_path)
107
- puts "Appraisals detected at #{appraisals_path}. Running: bin/rake appraisal:update"
108
- run_cmd!("bin/rake appraisal:update")
109
- else
110
- puts "No Appraisals file found; skipping appraisal:update"
111
- end
112
-
113
- ensure_git_user!
114
- committed = commit_release_prep!(version)
115
-
116
- # Optional: run a local CI check with 'act' prior to pushing
117
- maybe_run_local_ci_before_push!(committed)
118
-
119
- trunk = detect_trunk_branch
120
- feature = current_branch
121
- puts "Trunk branch detected: #{trunk}"
122
- ensure_trunk_synced_before_push!(trunk, feature)
123
-
124
- push!
125
-
126
- # After pushing, ensure the CI workflows for this project are passing
127
- monitor_workflows_after_push!
128
-
129
- # If all workflows are passing, merge the feature branch into trunk and push trunk
130
- merge_feature_into_trunk_and_push!(trunk, feature)
131
-
132
- # Ensure we are on trunk for the remaining steps
133
- checkout!(trunk)
134
- pull!(trunk)
135
-
136
- ensure_signing_setup_or_skip!
137
- # Build: expect PEM password prompt unless SKIP_GEM_SIGNING
138
- puts "Running build (you may be prompted for the signing key password)..."
139
- run_cmd!("bundle exec rake build")
140
-
141
- # Checksums (commits, but does not push)
142
- run_cmd!("bin/gem_checksums")
143
- validate_checksums!(version, stage: "after build + gem_checksums")
144
-
145
- # Release: expect PEM password + RubyGems MFA OTP
146
- puts "Running release (you may be prompted for signing key password and RubyGems MFA OTP)..."
147
- run_cmd!("bundle exec rake release")
148
- # Some release tasks rebuild the gem; re-validate to ensure reproducibility
149
- validate_checksums!(version, stage: "after release")
150
-
151
- puts "\nRelease complete. Don't forget to push the checksums commit if needed."
152
- end
153
-
154
- private
155
-
156
- # Monitor GitHub Actions workflows discovered by ci:act logic.
157
- # Checks one workflow per second in a round-robin loop until all pass, or any fails.
158
- def monitor_workflows_after_push!
159
- root = Kettle::Dev::CIHelpers.project_root
160
- workflows = Kettle::Dev::CIHelpers.workflows_list(root)
161
- gitlab_ci = File.exist?(File.join(root, ".gitlab-ci.yml"))
162
-
163
- branch = Kettle::Dev::CIHelpers.current_branch
164
- abort("Could not determine current branch for CI checks.") unless branch
165
-
166
- # Prefer an explicit GitHub remote if available; fall back to origin repo_info
167
- gh_remote = preferred_github_remote
168
- gh_owner = nil
169
- gh_repo = nil
170
- if gh_remote && !workflows.empty?
171
- url = remote_url(gh_remote)
172
- gh_owner, gh_repo = parse_github_owner_repo(url)
173
- end
174
-
175
- checks_any = false
176
-
177
- if gh_owner && gh_repo && !workflows.empty?
178
- checks_any = true
179
- total = workflows.size
180
- abort("No GitHub workflows found under .github/workflows; aborting.") if total.zero?
181
-
182
- passed = {}
183
- idx = 0
184
- puts "Ensuring GitHub Actions workflows pass on #{branch} (#{gh_owner}/#{gh_repo}) via remote '#{gh_remote}'"
185
- pbar = ProgressBar.create(title: "CI", total: total, format: "%t %b %c/%C", length: 30)
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
- # Additionally, check GitLab if configured
211
- gl_remote = gitlab_remote_candidates.first
212
- if gitlab_ci && gl_remote
213
- owner, repo = Kettle::Dev::CIHelpers.repo_info_gitlab
214
- if owner && repo
215
- checks_any = true
216
- puts "Ensuring GitLab pipeline passes on #{branch} (#{owner}/#{repo}) via remote '#{gl_remote}'"
217
- pbar = ProgressBar.create(title: "CI", total: 1, format: "%t %b %c/%C", length: 30)
218
- loop do
219
- pipe = Kettle::Dev::CIHelpers.gitlab_latest_pipeline(owner: owner, repo: repo, branch: branch)
220
- if pipe
221
- if Kettle::Dev::CIHelpers.gitlab_success?(pipe)
222
- pbar.increment unless pbar.finished?
223
- break
224
- elsif Kettle::Dev::CIHelpers.gitlab_failed?(pipe)
225
- puts
226
- url = pipe["web_url"] || "https://gitlab.com/#{owner}/#{repo}/-/pipelines"
227
- abort("Pipeline failed: #{url}")
228
- end
229
- end
230
- sleep(1)
231
- end
232
- pbar.finish unless pbar.finished?
233
- puts "\nGitLab pipeline passing."
234
- end
235
- end
236
-
237
- abort("CI configuration not detected (GitHub or GitLab). Ensure CI is configured and remotes point to the correct hosts.") unless checks_any
238
- end
239
-
240
- def run_cmd!(cmd)
241
- puts "$ #{cmd}"
242
- # Execute commands with the current environment
243
- success = system(ENV, cmd)
244
- abort("Command failed: #{cmd}") unless success
245
- end
246
-
247
- def git_output(args)
248
- out, status = Open3.capture2("git", *args)
249
- [out.strip, status.success?]
250
- end
251
-
252
- def check_git_clean!
253
- out, ok = git_output(["status", "--porcelain"])
254
- abort("Git working tree is not clean. Commit/stash changes before releasing.\n\n#{out}") unless ok && out.empty?
255
- end
256
-
257
- def ensure_git_user!
258
- name, ok1 = git_output(["config", "user.name"])
259
- email, ok2 = git_output(["config", "user.email"])
260
- abort("Git user.name or user.email not configured.") unless ok1 && ok2 && !name.empty? && !email.empty?
261
- end
262
-
263
- def ensure_bundler_2_7_plus!
264
- begin
265
- require "bundler"
266
- rescue LoadError
267
- abort("Bundler is required. Please install bundler >= 2.7.0 and try again.")
268
- end
269
- ver = Gem::Version.new(Bundler::VERSION)
270
- min = Gem::Version.new("2.7.0")
271
- if ver < min
272
- abort("kettle-release requires Bundler >= 2.7.0 for reproducible builds by default. Current: #{Bundler::VERSION}. Please upgrade bundler.")
273
- end
274
- end
275
-
276
- # Run a local CI workflow using 'act' before pushing the release prep commit, if enabled.
277
- # Controlled by env var K_RELEASE_LOCAL_CI:
278
- # - "true" => run without prompt
279
- # - "ask" => prompt user [Y/n]
280
- # - anything else or unset => skip
281
- # Workflow selection:
282
- # - If .github/workflows/locked_deps.yml (or .yaml) exists, default to it.
283
- # - Else, use the first workflow from CIHelpers.workflows_list unless K_RELEASE_LOCAL_CI_WORKFLOW points to another file.
284
- # On failure, soft reset the last commit if one was created, then abort.
285
- def maybe_run_local_ci_before_push!(committed)
286
- mode = (ENV["K_RELEASE_LOCAL_CI"] || "").strip.downcase
287
- run_it = case mode
288
- when "true", "1", "yes", "y" then true
289
- when "ask"
290
- print("Run local CI with 'act' before pushing? [Y/n] ")
291
- ans = $stdin.gets&.strip
292
- ans.nil? || ans.empty? || ans =~ /\Ay(es)?\z/i
293
- else
294
- false
295
- end
296
- return unless run_it
297
-
298
- # Check for 'act'
299
- act_ok = begin
300
- system("act", "--version", out: File::NULL, err: File::NULL)
301
- rescue StandardError
302
- false
303
- end
304
- unless act_ok
305
- puts "Skipping local CI: 'act' command not found. Install https://github.com/nektos/act to enable."
306
- return
307
- end
308
-
309
- root = Kettle::Dev::CIHelpers.project_root
310
- workflows_dir = File.join(root, ".github", "workflows")
311
- candidates = Kettle::Dev::CIHelpers.workflows_list(root)
312
-
313
- # Explicit override via env
314
- chosen = (ENV["K_RELEASE_LOCAL_CI_WORKFLOW"] || "").strip
315
- if !chosen.empty?
316
- # Normalize to a basename with extension
317
- if chosen !~ /\.ya?ml\z/
318
- chosen = "#{chosen}.yml"
319
- end
320
- else
321
- # Default to locked_deps if present
322
- chosen = if candidates.include?("locked_deps.yml")
323
- "locked_deps.yml"
324
- elsif candidates.include?("locked_deps.yaml")
325
- "locked_deps.yaml"
326
- else
327
- candidates.first
328
- end
329
- end
330
-
331
- unless chosen
332
- puts "Skipping local CI: no workflows found under .github/workflows."
333
- return
334
- end
335
-
336
- file_path = File.join(workflows_dir, chosen)
337
- unless File.file?(file_path)
338
- puts "Skipping local CI: selected workflow not found: #{file_path}"
339
- return
340
- end
341
-
342
- puts "== Running local CI with act on #{chosen} =="
343
- ok = system("act", "-W", file_path)
344
- if ok
345
- puts "Local CI succeeded for #{chosen}."
346
- else
347
- puts "Local CI failed for #{chosen}."
348
- if committed
349
- puts "Rolling back release prep commit (soft reset)..."
350
- system("git", "reset", "--soft", "HEAD^")
351
- end
352
- abort("Aborting due to local CI failure.")
353
- end
354
- end
355
-
356
- def detect_version
357
- # Look for lib/**/version.rb and extract VERSION constant string
358
- candidates = Dir[File.join(@root, "lib", "**", "version.rb")]
359
- abort("Could not find version.rb under lib/**.") if candidates.empty?
360
- path = candidates.min
361
- content = File.read(path)
362
- m = content.match(/VERSION\s*=\s*(["'])([^"']+)\1/)
363
- abort("VERSION constant not found in #{path}.") unless m
364
- m[2]
365
- end
366
-
367
- def detect_gem_name
368
- # Find a .gemspec in project root and extract spec.name
369
- gemspecs = Dir[File.join(@root, "*.gemspec")]
370
- abort("Could not find a .gemspec in project root.") if gemspecs.empty?
371
- path = gemspecs.min
372
- content = File.read(path)
373
- m = content.match(/spec\.name\s*=\s*(["'])([^"']+)\1/)
374
- abort("Could not determine gem name from #{path}.") unless m
375
- m[2]
376
- end
377
-
378
- # Returns [latest_overall, latest_for_series] as strings (or nils)
379
- def latest_released_versions(gem_name, current_version)
380
- uri = URI("https://rubygems.org/api/v1/versions/#{gem_name}.json")
381
- res = Net::HTTP.get_response(uri)
382
- return [nil, nil] unless res.is_a?(Net::HTTPSuccess)
383
- data = JSON.parse(res.body)
384
- versions = data.map { |h| h["number"] }.compact
385
- # Drop pre-releases if API indicates; fall back to simple filter by '-' pattern
386
- versions.reject! { |v| v.to_s.include?("-pre") || v.to_s.include?(".pre") || v.to_s =~ /[a-zA-Z]/ }
387
- gversions = versions.map { |s| Gem::Version.new(s) }.sort
388
- latest_overall = gversions.last&.to_s
389
-
390
- cur = Gem::Version.new(current_version)
391
- series = cur.segments[0, 2]
392
- series_versions = gversions.select { |gv| gv.segments[0, 2] == series }
393
- latest_series = series_versions.last&.to_s
394
- [latest_overall, latest_series]
395
- rescue StandardError
396
- [nil, nil]
397
- end
398
-
399
- def commit_release_prep!(version)
400
- msg = "🔖 Prepare release v#{version}"
401
- # Only commit if there are changes (version/changelog)
402
- out, _ = git_output(["status", "--porcelain"])
403
- if out.empty?
404
- puts "No changes to commit for release prep (continuing)."
405
- false
406
- else
407
- run_cmd!(%(git commit -am #{Shellwords.escape(msg)}))
408
- true
409
- end
410
- end
411
-
412
- def push!
413
- branch = current_branch
414
- abort("Could not determine current branch to push.") unless branch
415
-
416
- if has_remote?("all")
417
- puts "$ git push all #{branch}"
418
- success = system("git push all #{Shellwords.escape(branch)}")
419
- unless success
420
- warn("Normal push to 'all' failed; retrying with force push...")
421
- run_cmd!("git push -f all #{Shellwords.escape(branch)}")
422
- end
423
- return
424
- end
425
-
426
- # Build the list of remotes to push to
427
- remotes = []
428
- remotes << "origin" if has_remote?("origin")
429
- remotes |= github_remote_candidates
430
- remotes |= gitlab_remote_candidates
431
- remotes |= codeberg_remote_candidates
432
- remotes.uniq!
433
-
434
- if remotes.empty?
435
- # Fallback to default behavior if we couldn't detect any remotes
436
- puts "$ git push #{branch}"
437
- success = system("git push #{Shellwords.escape(branch)}")
438
- unless success
439
- warn("Normal push failed; retrying with force push...")
440
- run_cmd!("git push -f #{Shellwords.escape(branch)}")
441
- end
442
- return
443
- end
444
-
445
- remotes.each do |remote|
446
- puts "$ git push #{remote} #{branch}"
447
- success = system("git push #{Shellwords.escape(remote)} #{Shellwords.escape(branch)}")
448
- unless success
449
- warn("Push to #{remote} failed; retrying with force push...")
450
- run_cmd!("git push -f #{Shellwords.escape(remote)} #{Shellwords.escape(branch)}")
451
- end
452
- end
453
- end
454
-
455
- def detect_trunk_branch
456
- out, ok = git_output(["remote", "show", "origin"])
457
- abort("Failed to get origin remote info.") unless ok
458
- m = out.lines.find { |l| l.include?("HEAD branch") }
459
- abort("Unable to detect trunk branch from origin.") unless m
460
- m.split.last
461
- end
462
-
463
- def checkout!(branch)
464
- run_cmd!("git checkout #{Shellwords.escape(branch)}")
465
- end
466
-
467
- def pull!(branch)
468
- run_cmd!("git pull origin #{Shellwords.escape(branch)}")
469
- end
470
-
471
- def current_branch
472
- out, ok = git_output(["rev-parse", "--abbrev-ref", "HEAD"])
473
- ok ? out : nil
474
- end
475
-
476
- def list_remotes
477
- out, ok = git_output(["remote"])
478
- ok ? out.split(/\s+/).reject(&:empty?) : []
479
- end
480
-
481
- def remotes_with_urls
482
- out, ok = git_output(["remote", "-v"])
483
- return {} unless ok
484
- urls = {}
485
- out.each_line do |line|
486
- if line =~ /(\S+)\s+(\S+)\s+\((fetch|push)\)/
487
- name = Regexp.last_match(1)
488
- url = Regexp.last_match(2)
489
- kind = Regexp.last_match(3)
490
- # prefer fetch URL when available
491
- urls[name] = url if kind == "fetch" || !urls.key?(name)
492
- end
493
- end
494
- urls
495
- end
496
-
497
- def remote_url(name)
498
- remotes_with_urls[name]
499
- end
500
-
501
- def github_remote_candidates
502
- remotes_with_urls.select { |n, u| u.include?("github.com") }.keys
503
- end
504
-
505
- def gitlab_remote_candidates
506
- remotes_with_urls.select { |n, u| u.include?("gitlab.com") }.keys
507
- end
508
-
509
- def codeberg_remote_candidates
510
- remotes_with_urls.select { |n, u| u.include?("codeberg.org") }.keys
511
- end
512
-
513
- def preferred_github_remote
514
- cands = github_remote_candidates
515
- return if cands.empty?
516
- # Prefer a remote literally named 'github', otherwise the first
517
- cands.find { |n| n == "github" } || cands.first
518
- end
519
-
520
- def parse_github_owner_repo(url)
521
- return [nil, nil] unless url
522
- if url =~ %r{git@github.com:(.+?)/(.+?)(\.git)?$}
523
- [Regexp.last_match(1), Regexp.last_match(2).sub(/\.git\z/, "")]
524
- elsif url =~ %r{https://github.com/(.+?)/(.+?)(\.git)?$}
525
- [Regexp.last_match(1), Regexp.last_match(2).sub(/\.git\z/, "")]
526
- else
527
- [nil, nil]
528
- end
529
- end
530
-
531
- def has_remote?(name)
532
- list_remotes.include?(name)
533
- end
534
-
535
- def remote_branch_exists?(remote, branch)
536
- _out, ok = git_output(["show-ref", "--verify", "--quiet", "refs/remotes/#{remote}/#{branch}"])
537
- ok
538
- end
539
-
540
- def ahead_behind_counts(local_ref, remote_ref)
541
- out, ok = git_output(["rev-list", "--left-right", "--count", "#{local_ref}...#{remote_ref}"])
542
- return [0, 0] unless ok && !out.empty?
543
- parts = out.split
544
- left = parts[0].to_i
545
- right = parts[1].to_i
546
- [left, right]
547
- end
548
-
549
- def trunk_behind_remote?(trunk, remote)
550
- # If the remote branch doesn't exist, treat as not behind
551
- return false unless remote_branch_exists?(remote, trunk)
552
- _ahead, behind = ahead_behind_counts(trunk, "#{remote}/#{trunk}")
553
- behind.positive?
554
- end
555
-
556
- def ensure_trunk_synced_before_push!(trunk, feature)
557
- if has_remote?("all")
558
- puts "Remote 'all' detected. Fetching from all remotes and enforcing strict trunk parity..."
559
- run_cmd!("git fetch --all")
560
- remotes = list_remotes
561
- missing_from = []
562
- remotes.each do |r|
563
- next if r == "all"
564
- if remote_branch_exists?(r, trunk)
565
- _ahead, behind = ahead_behind_counts(trunk, "#{r}/#{trunk}")
566
- missing_from << r if behind.positive?
567
- end
568
- end
569
- unless missing_from.empty?
570
- abort("Local #{trunk} is missing commits present on: #{missing_from.join(", ")}. Please sync trunk first.")
571
- end
572
- puts "Local #{trunk} has all commits from remotes: #{(remotes - ["all"]).join(", ")}"
573
- return
574
- end
575
-
576
- # Ensure local trunk is in sync with origin/trunk
577
- run_cmd!("git fetch origin #{Shellwords.escape(trunk)}")
578
- if trunk_behind_remote?(trunk, "origin")
579
- puts "Local #{trunk} is behind origin/#{trunk}. Rebasing..."
580
- cur = current_branch
581
- checkout!(trunk) unless cur == trunk
582
- run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
583
- checkout!(feature) unless feature.nil? || feature == trunk
584
- run_cmd!("git rebase #{Shellwords.escape(trunk)}")
585
- puts "Rebase complete. Will push updated branch next."
586
- else
587
- puts "Local #{trunk} is up to date with origin/#{trunk}."
588
- end
589
-
590
- # If there is a GitHub remote that is not origin, ensure origin/#{trunk} incorporates it
591
- gh_remote = preferred_github_remote
592
- if gh_remote && gh_remote != "origin"
593
- puts "GitHub remote detected: #{gh_remote}. Fetching #{trunk}..."
594
- run_cmd!("git fetch #{gh_remote} #{Shellwords.escape(trunk)}")
595
-
596
- # Compare origin/trunk vs github/trunk to see if they differ
597
- left, right = ahead_behind_counts("origin/#{trunk}", "#{gh_remote}/#{trunk}")
598
- if left.zero? && right.zero?
599
- puts "origin/#{trunk} and #{gh_remote}/#{trunk} are already in sync."
600
- return
601
- end
602
-
603
- checkout!(trunk)
604
- run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
605
-
606
- if left.positive? && right.positive?
607
- # Histories have diverged -> let user choose
608
- puts "origin/#{trunk} and #{gh_remote}/#{trunk} have diverged (#{left} ahead of GH, #{right} behind GH)."
609
- puts "Choose how to reconcile:"
610
- puts " [r] Rebase local/#{trunk} on top of #{gh_remote}/#{trunk} (push to origin)"
611
- puts " [m] Merge --no-ff #{gh_remote}/#{trunk} into #{trunk} (push to origin and #{gh_remote})"
612
- puts " [a] Abort"
613
- print("> ")
614
- choice = $stdin.gets&.strip&.downcase
615
- case choice
616
- when "r"
617
- run_cmd!("git rebase #{Shellwords.escape("#{gh_remote}/#{trunk}")}")
618
- run_cmd!("git push origin #{Shellwords.escape(trunk)}")
619
- puts "Rebased #{trunk} onto #{gh_remote}/#{trunk} and pushed to origin."
620
- when "m"
621
- run_cmd!("git merge --no-ff #{Shellwords.escape("#{gh_remote}/#{trunk}")}")
622
- run_cmd!("git push origin #{Shellwords.escape(trunk)}")
623
- run_cmd!("git push #{Shellwords.escape(gh_remote)} #{Shellwords.escape(trunk)}")
624
- puts "Merged #{gh_remote}/#{trunk} into #{trunk} and pushed to origin and #{gh_remote}."
625
- else
626
- abort("Aborted by user. Please reconcile trunks and re-run.")
627
- end
628
- elsif right.positive? && left.zero?
629
- # One side can be fast-forwarded
630
- puts "Fast-forwarding #{trunk} to include #{gh_remote}/#{trunk}..."
631
- run_cmd!("git merge --ff-only #{Shellwords.escape("#{gh_remote}/#{trunk}")}")
632
- run_cmd!("git push origin #{Shellwords.escape(trunk)}")
633
- # origin is behind GH -> fast-forward merge
634
- elsif left.positive? && right.zero?
635
- # origin ahead of GH -> nothing required for origin, optionally inform user
636
- puts "origin/#{trunk} is ahead of #{gh_remote}/#{trunk}; no action required before push."
637
- end
638
- end
639
- end
640
-
641
- def merge_feature_into_trunk_and_push!(trunk, feature)
642
- return if feature.nil? || feature == trunk
643
- puts "Merging #{feature} into #{trunk} (after CI success)..."
644
- checkout!(trunk)
645
- run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
646
- run_cmd!("git merge #{Shellwords.escape(feature)}")
647
- run_cmd!("git push origin #{Shellwords.escape(trunk)}")
648
- puts "Merged #{feature} into #{trunk} and pushed. The PR (if any) should auto-close."
649
- end
650
-
651
- def ensure_signing_setup_or_skip!
652
- return if ENV.key?("SKIP_GEM_SIGNING")
653
-
654
- user = ENV.fetch("GEM_CERT_USER", ENV["USER"])
655
- cert_path = File.join(@root, "certs", "#{user}.pem")
656
- unless File.exist?(cert_path)
657
- abort(<<~MSG)
658
- Gem signing appears enabled but no public cert found at:
659
- #{cert_path}
660
- Add your public key to certs/<USER>.pem (or set GEM_CERT_USER), or set SKIP_GEM_SIGNING to build unsigned.
661
- MSG
662
- end
663
- puts "Found signing cert: #{cert_path}"
664
- puts "When prompted during build/release, enter the PEM password for ~/.ssh/gem-private_key.pem"
665
- end
666
-
667
- # --- Checksum validation ---
668
- # Validate that the sha256 of the built gem in pkg/ matches the recorded
669
- # checksum stored under checksums/<gem>.gem.sha256. Abort with guidance if not.
670
- # @param version [String]
671
- # @param stage [String] human-readable context (e.g., "after release")
672
- def validate_checksums!(version, stage: "")
673
- gem_path = gem_file_for_version(version)
674
- unless gem_path && File.file?(gem_path)
675
- abort("Unable to locate built gem for version #{version} in pkg/. Did the build succeed?")
676
- end
677
- actual = compute_sha256(gem_path)
678
- checks_path = File.join(@root, "checksums", "#{File.basename(gem_path)}.sha256")
679
- unless File.file?(checks_path)
680
- abort("Expected checksum file not found: #{checks_path}. Did bin/gem_checksums run?")
681
- end
682
- expected = File.read(checks_path).strip
683
- if actual != expected
684
- abort(<<~MSG)
685
- SHA256 mismatch #{stage}:
686
- gem: #{gem_path}
687
- sha256sum: #{actual}
688
- file: #{checks_path}
689
- file: #{expected}
690
- The artifact being released must match the checksummed artifact exactly.
691
- Retry locally: bundle exec rake build && bin/gem_checksums && bundle exec rake release
692
- MSG
693
- else
694
- puts "Checksum OK #{stage}: #{File.basename(gem_path)}"
695
- end
696
- end
697
-
698
- # Find the gem file in pkg/ that matches the given version
699
- def gem_file_for_version(version)
700
- pkg = File.join(@root, "pkg")
701
- pattern = File.join(pkg, "*.gem")
702
- gems = Dir[pattern].select { |p| File.basename(p).include?("-#{version}.gem") }
703
- gems.sort.last
704
- end
705
-
706
- # Compute sha256 using system utilities (sha256sum or shasum -a 256),
707
- # falling back to Ruby Digest if neither is available.
708
- def compute_sha256(path)
709
- if system("which sha256sum > /dev/null 2>&1")
710
- out, _ = Open3.capture2e("sha256sum", path)
711
- out.split.first
712
- elsif system("which shasum > /dev/null 2>&1")
713
- out, _ = Open3.capture2e("shasum", "-a", "256", path)
714
- out.split.first
715
- else
716
- require "digest"
717
- Digest::SHA256.file(path).hexdigest
718
- end
719
- end
720
- end
721
- end
722
- end
723
-
724
30
  # Always execute when this file is loaded (e.g., via a Bundler binstub).
725
31
  # Do not guard with __FILE__ == $PROGRAM_NAME because binstubs use Kernel.load.
726
32
  if ARGV.include?("-h") || ARGV.include?("--help")