kettle-dev 1.0.10 → 1.0.12

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 (44) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/.envrc +1 -1
  4. data/.github/workflows/coverage.yml +2 -2
  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 +1 -0
  9. data/Appraisals +4 -1
  10. data/Appraisals.example +104 -0
  11. data/CHANGELOG.md +88 -29
  12. data/CHANGELOG.md.example +4 -4
  13. data/CONTRIBUTING.md +37 -1
  14. data/Gemfile +3 -0
  15. data/Gemfile.example +35 -0
  16. data/README.md +48 -10
  17. data/README.md.example +515 -0
  18. data/{Rakefile → Rakefile.example} +13 -27
  19. data/exe/kettle-changelog +404 -0
  20. data/exe/kettle-commit-msg +2 -0
  21. data/exe/kettle-readme-backers +2 -0
  22. data/exe/kettle-release +10 -9
  23. data/gemfiles/modular/optional.gemfile +1 -0
  24. data/lib/kettle/dev/ci_helpers.rb +19 -0
  25. data/lib/kettle/dev/ci_monitor.rb +192 -0
  26. data/lib/kettle/dev/git_adapter.rb +98 -33
  27. data/lib/kettle/dev/git_commit_footer.rb +1 -1
  28. data/lib/kettle/dev/input_adapter.rb +44 -0
  29. data/lib/kettle/dev/release_cli.rb +154 -177
  30. data/lib/kettle/dev/tasks/ci_task.rb +22 -1
  31. data/lib/kettle/dev/tasks/install_task.rb +313 -95
  32. data/lib/kettle/dev/tasks/template_task.rb +176 -74
  33. data/lib/kettle/dev/template_helpers.rb +61 -8
  34. data/lib/kettle/dev/version.rb +1 -1
  35. data/lib/kettle/dev/versioning.rb +68 -0
  36. data/sig/kettle/dev/ci_helpers.rbs +1 -1
  37. data/sig/kettle/dev/ci_monitor.rbs +8 -0
  38. data/sig/kettle/dev/input_adapter.rbs +8 -0
  39. data/sig/kettle/dev/release_cli.rbs +1 -1
  40. data/sig/kettle/dev/template_helpers.rbs +3 -1
  41. data.tar.gz.sig +0 -0
  42. metadata +24 -22
  43. metadata.gz.sig +0 -0
  44. data/.gitlab-ci.yml +0 -45
@@ -1,13 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "open3"
4
+
3
5
  module Kettle
4
6
  module Dev
5
7
  # Minimal Git adapter used by kettle-dev to avoid invoking live shell commands
6
- # directly from the library code. In tests, mock this adapter's methods to
7
- # prevent any real network or repository mutations.
8
+ # directly from the higher-level library code. In tests, mock this adapter's
9
+ # methods to prevent any real network or repository mutations.
8
10
  #
9
- # This adapter requires the 'git' gem at runtime and does not shell out to
10
- # the system git. Specs should stub the git gem API to avoid real pushes.
11
+ # Behavior:
12
+ # - Prefer the 'git' gem when available.
13
+ # - If the 'git' gem is not present (LoadError), fall back to shelling out to
14
+ # the system `git` executable for the small set of operations we need.
11
15
  #
12
16
  # Public API is intentionally small and only includes what we need right now.
13
17
  class GitAdapter
@@ -15,10 +19,21 @@ module Kettle
15
19
  # @return [void]
16
20
  def initialize
17
21
  begin
18
- require "git"
19
- @git = ::Git.open(Dir.pwd)
22
+ # Allow users/CI to opt out of using the 'git' gem even when available.
23
+ # Set KETTLE_DEV_DISABLE_GIT_GEM to a truthy value ("1", "true", "yes") to force CLI backend.
24
+ env_val = ENV["KETTLE_DEV_DISABLE_GIT_GEM"]
25
+ # Ruby 2.3 compatibility: String#match? was added in 2.4; use Regexp#=== / =~ instead
26
+ disable_gem = env_val && !!(/\A(1|true|yes)\z/i =~ env_val)
27
+ if disable_gem
28
+ @backend = :cli
29
+ else
30
+ Kernel.require "git"
31
+ @backend = :gem
32
+ @git = ::Git.open(Dir.pwd)
33
+ end
20
34
  rescue LoadError
21
- raise Kettle::Dev::Error, "The 'git' gem is required at runtime. Please add it as a dependency."
35
+ # Optional dependency: fall back to CLI
36
+ @backend = :cli
22
37
  rescue StandardError => e
23
38
  raise Kettle::Dev::Error, "Failed to open git repository: #{e.message}"
24
39
  end
@@ -30,42 +45,73 @@ module Kettle
30
45
  # @param force [Boolean] whether to force push
31
46
  # @return [Boolean] true when the push is reported successful
32
47
  def push(remote, branch, force: false)
33
- # git gem supports force: true option on push
34
- begin
48
+ if @backend == :gem
49
+ begin
50
+ if remote
51
+ @git.push(remote, branch, force: force)
52
+ else
53
+ # Default remote according to repo config
54
+ @git.push(nil, branch, force: force)
55
+ end
56
+ true
57
+ rescue StandardError
58
+ false
59
+ end
60
+ else
61
+ args = ["git", "push"]
62
+ args << "--force" if force
35
63
  if remote
36
- @git.push(remote, branch, force: force)
37
- else
38
- # Default remote according to repo config
39
- @git.push(nil, branch, force: force)
64
+ args << remote.to_s << branch.to_s
40
65
  end
41
- true
42
- rescue StandardError
43
- false
66
+ system(*args)
44
67
  end
45
68
  end
46
69
 
47
70
  # @return [String, nil] current branch name, or nil on error
48
71
  def current_branch
49
- @git.current_branch
72
+ if @backend == :gem
73
+ @git.current_branch
74
+ else
75
+ out, status = Open3.capture2("git", "rev-parse", "--abbrev-ref", "HEAD")
76
+ status.success? ? out.strip : nil
77
+ end
50
78
  rescue StandardError
51
79
  nil
52
80
  end
53
81
 
54
82
  # @return [Array<String>] list of remote names
55
83
  def remotes
56
- @git.remotes.map(&:name)
84
+ if @backend == :gem
85
+ @git.remotes.map(&:name)
86
+ else
87
+ out, status = Open3.capture2("git", "remote")
88
+ status.success? ? out.split(/\r?\n/).map(&:strip).reject(&:empty?) : []
89
+ end
57
90
  rescue StandardError
58
91
  []
59
92
  end
60
93
 
61
94
  # @return [Hash{String=>String}] remote name => fetch URL
62
95
  def remotes_with_urls
63
- @git.remotes.each_with_object({}) do |r, h|
64
- begin
65
- h[r.name] = r.url
66
- rescue StandardError
67
- # ignore
96
+ if @backend == :gem
97
+ @git.remotes.each_with_object({}) do |r, h|
98
+ begin
99
+ h[r.name] = r.url
100
+ rescue StandardError
101
+ # ignore
102
+ end
103
+ end
104
+ else
105
+ out, status = Open3.capture2("git", "remote", "-v")
106
+ return {} unless status.success?
107
+ urls = {}
108
+ out.each_line do |line|
109
+ # Example: origin https://github.com/me/repo.git (fetch)
110
+ if line =~ /^(\S+)\s+(\S+)\s+\(fetch\)/
111
+ urls[Regexp.last_match(1)] = Regexp.last_match(2)
112
+ end
68
113
  end
114
+ urls
69
115
  end
70
116
  rescue StandardError
71
117
  {}
@@ -74,8 +120,13 @@ module Kettle
74
120
  # @param name [String]
75
121
  # @return [String, nil]
76
122
  def remote_url(name)
77
- r = @git.remotes.find { |x| x.name == name }
78
- r&.url
123
+ if @backend == :gem
124
+ r = @git.remotes.find { |x| x.name == name }
125
+ r&.url
126
+ else
127
+ out, status = Open3.capture2("git", "config", "--get", "remote.#{name}.url")
128
+ status.success? ? out.strip : nil
129
+ end
79
130
  rescue StandardError
80
131
  nil
81
132
  end
@@ -84,8 +135,12 @@ module Kettle
84
135
  # @param branch [String]
85
136
  # @return [Boolean]
86
137
  def checkout(branch)
87
- @git.checkout(branch)
88
- true
138
+ if @backend == :gem
139
+ @git.checkout(branch)
140
+ true
141
+ else
142
+ system("git", "checkout", branch.to_s)
143
+ end
89
144
  rescue StandardError
90
145
  false
91
146
  end
@@ -95,8 +150,12 @@ module Kettle
95
150
  # @param branch [String]
96
151
  # @return [Boolean]
97
152
  def pull(remote, branch)
98
- @git.pull(remote, branch)
99
- true
153
+ if @backend == :gem
154
+ @git.pull(remote, branch)
155
+ true
156
+ else
157
+ system("git", "pull", remote.to_s, branch.to_s)
158
+ end
100
159
  rescue StandardError
101
160
  false
102
161
  end
@@ -106,12 +165,18 @@ module Kettle
106
165
  # @param ref [String, nil]
107
166
  # @return [Boolean]
108
167
  def fetch(remote, ref = nil)
109
- if ref
110
- @git.fetch(remote, ref)
168
+ if @backend == :gem
169
+ if ref
170
+ @git.fetch(remote, ref)
171
+ else
172
+ @git.fetch(remote)
173
+ end
174
+ true
175
+ elsif ref
176
+ system("git", "fetch", remote.to_s, ref.to_s)
111
177
  else
112
- @git.fetch(remote)
178
+ system("git", "fetch", remote.to_s)
113
179
  end
114
- true
115
180
  rescue StandardError
116
181
  false
117
182
  end
@@ -77,7 +77,7 @@ module Kettle
77
77
 
78
78
  if footer_append && goalie_allows_footer?(subject_line)
79
79
  if commit_msg.include?(sentinel)
80
- exit(0)
80
+ Kettle::Dev::ExitAdapter.exit(0)
81
81
  else
82
82
  footer_binding = GitCommitFooter.new
83
83
  File.open(argv[0], "w") do |file|
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Dev
5
+ # Input indirection layer to make interactive prompts safe in tests.
6
+ #
7
+ # Production/default behavior delegates to $stdin.gets (or Kernel#gets)
8
+ # so application code does not read from STDIN directly. In specs, mock
9
+ # this adapter's methods to return deterministic answers without touching
10
+ # global IO.
11
+ #
12
+ # Example (RSpec):
13
+ # allow(Kettle::Dev::InputAdapter).to receive(:gets).and_return("y\n")
14
+ #
15
+ # This mirrors the "mockable adapter" approach used for GitAdapter and ExitAdapter.
16
+ module InputAdapter
17
+ module_function
18
+
19
+ # Read one line from the standard input, including the trailing newline if
20
+ # present. Returns nil on EOF, consistent with IO#gets.
21
+ #
22
+ # @param args [Array] any args are forwarded to $stdin.gets for compatibility
23
+ # @return [String, nil]
24
+ def gets(*args)
25
+ $stdin.gets(*args)
26
+ end
27
+
28
+ def tty?
29
+ $stdin.tty?
30
+ end
31
+
32
+ # Read one line from standard input, raising EOFError on end-of-file.
33
+ # Provided for convenience symmetry with IO#readline when needed.
34
+ #
35
+ # @param args [Array]
36
+ # @return [String]
37
+ def readline(*args)
38
+ line = gets(*args)
39
+ raise EOFError, "end of file reached" if line.nil?
40
+ line
41
+ end
42
+ end
43
+ end
44
+ end
@@ -16,6 +16,8 @@ require "ruby-progressbar"
16
16
  # Internal
17
17
  require "kettle/dev/git_adapter"
18
18
  require "kettle/dev/exit_adapter"
19
+ require "kettle/dev/input_adapter"
20
+ require "kettle/dev/versioning"
19
21
 
20
22
  module Kettle
21
23
  module Dev
@@ -28,114 +30,177 @@ module Kettle
28
30
 
29
31
  public
30
32
 
31
- def initialize
33
+ def initialize(start_step: 1)
32
34
  @root = Kettle::Dev::CIHelpers.project_root
33
35
  @git = Kettle::Dev::GitAdapter.new
36
+ @start_step = (start_step || 1).to_i
37
+ @start_step = 1 if @start_step < 1
34
38
  end
35
39
 
36
40
  def run
37
- puts "== kettle-release =="
38
-
41
+ # 1. Ensure Bundler version ✓
39
42
  ensure_bundler_2_7_plus!
40
43
 
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)"
44
+ version = nil
45
+ committed = nil
46
+ trunk = nil
47
+ feature = nil
48
+
49
+ # 2. Version detection and sanity checks + prompt
50
+ if @start_step <= 2
51
+ version = detect_version
52
+ puts "Detected version: #{version.inspect}"
53
+
54
+ latest_overall = nil
55
+ latest_for_series = nil
56
+ begin
57
+ gem_name = detect_gem_name
58
+ latest_overall, latest_for_series = latest_released_versions(gem_name, version)
59
+ rescue StandardError => e
60
+ warn("Warning: failed to check RubyGems for latest version (#{e.class}: #{e.message}). Proceeding.")
59
61
  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
62
+
63
+ if latest_overall
64
+ msg = "Latest released: #{latest_overall}"
65
+ if latest_for_series && latest_for_series != latest_overall
66
+ msg += " | Latest for series #{Gem::Version.new(version).segments[0, 2].join(".")}.x: #{latest_for_series}"
67
+ elsif latest_for_series
68
+ msg += " (matches current series)"
69
+ end
70
+ puts msg
71
+
72
+ cur = Gem::Version.new(version)
73
+ overall = Gem::Version.new(latest_overall)
74
+ cur_series = cur.segments[0, 2]
75
+ overall_series = overall.segments[0, 2]
76
+ target = if (cur_series <=> overall_series) == -1
77
+ latest_for_series
78
+ else
79
+ latest_overall
80
+ end
81
+ if target
82
+ bump = Kettle::Dev::Versioning.classify_bump(target, version)
83
+ case bump
84
+ when :same
85
+ series = cur_series.join(".")
86
+ warn("version.rb (#{version}) matches the latest released version for series #{series} (#{target}).")
87
+ abort("Aborting: version bump required. Bump PATCH/MINOR/MAJOR/EPIC.")
88
+ when :downgrade
89
+ series = cur_series.join(".")
90
+ warn("version.rb (#{version}) is lower than the latest released version for series #{series} (#{target}).")
91
+ abort("Aborting: version must be bumped above #{target}.")
92
+ else
93
+ label = {epic: "EPIC", major: "MAJOR", minor: "MINOR", patch: "PATCH"}[bump] || bump.to_s.upcase
94
+ puts "Proposed bump type: #{label} (from #{target} -> #{version})"
95
+ end
96
+ else
97
+ puts "Could not determine latest released version from RubyGems (offline?). Proceeding without sanity check."
98
+ end
68
99
  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.")
100
+ puts "Could not determine latest released version from RubyGems (offline?). Proceeding without sanity check."
76
101
  end
77
- else
78
- puts "Could not determine latest released version from RubyGems (offline?). Proceeding without sanity check."
79
- end
80
102
 
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")
103
+ puts "Have you updated lib/**/version.rb and CHANGELOG.md for v#{version}? [y/N]"
104
+ print("> ")
105
+ ans = Kettle::Dev::InputAdapter.gets&.strip
106
+ abort("Aborted: please update version.rb and CHANGELOG.md, then re-run.") unless ans&.downcase&.start_with?("y")
107
+ end
85
108
 
86
- run_cmd!("bin/setup")
87
- run_cmd!("bin/rake")
109
+ # 3. bin/setup
110
+ run_cmd!("bin/setup") if @start_step <= 3
111
+ # 4. bin/rake
112
+ run_cmd!("bin/rake") if @start_step <= 4
113
+
114
+ # 5. appraisal:update (optional)
115
+ if @start_step <= 5
116
+ appraisals_path = File.join(@root, "Appraisals")
117
+ if File.file?(appraisals_path)
118
+ puts "Appraisals detected at #{appraisals_path}. Running: bin/rake appraisal:update"
119
+ run_cmd!("bin/rake appraisal:update")
120
+ else
121
+ puts "No Appraisals file found; skipping appraisal:update"
122
+ end
123
+ end
88
124
 
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"
125
+ # 6. git user + commit release prep
126
+ if @start_step <= 6
127
+ ensure_git_user!
128
+ version ||= detect_version
129
+ committed = commit_release_prep!(version)
95
130
  end
96
131
 
97
- ensure_git_user!
98
- committed = commit_release_prep!(version)
132
+ # 7. optional local CI via act
133
+ maybe_run_local_ci_before_push!(committed) if @start_step <= 7
99
134
 
100
- maybe_run_local_ci_before_push!(committed)
135
+ # 8. ensure trunk synced
136
+ if @start_step <= 8
137
+ trunk = detect_trunk_branch
138
+ feature = current_branch
139
+ puts "Trunk branch detected: #{trunk}"
140
+ ensure_trunk_synced_before_push!(trunk, feature)
141
+ end
101
142
 
102
- trunk = detect_trunk_branch
103
- feature = current_branch
104
- puts "Trunk branch detected: #{trunk}"
105
- ensure_trunk_synced_before_push!(trunk, feature)
143
+ # 9. push branches
144
+ push! if @start_step <= 9
106
145
 
107
- push!
146
+ # 10. monitor CI after push
147
+ monitor_workflows_after_push! if @start_step <= 10
108
148
 
109
- monitor_workflows_after_push!
149
+ # 11. merge feature into trunk and push
150
+ if @start_step <= 11
151
+ trunk ||= detect_trunk_branch
152
+ feature ||= current_branch
153
+ merge_feature_into_trunk_and_push!(trunk, feature)
154
+ end
110
155
 
111
- merge_feature_into_trunk_and_push!(trunk, feature)
156
+ # 12. checkout trunk and pull
157
+ if @start_step <= 12
158
+ trunk ||= detect_trunk_branch
159
+ checkout!(trunk)
160
+ pull!(trunk)
161
+ end
112
162
 
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).")
163
+ # 13. signing guidance and checks
164
+ if @start_step <= 13
165
+ if ENV.fetch("SKIP_GEM_SIGNING", "false").casecmp("false").zero?
166
+ puts "TIP: For local dry-runs or testing the release workflow, set SKIP_GEM_SIGNING=true to avoid PEM password prompts."
167
+ if Kettle::Dev::InputAdapter.tty?
168
+ # In CI, avoid interactive prompts when no TTY is present (e.g., act or GitHub Actions "CI validation").
169
+ # Non-interactive CI runs should not abort here; later signing checks are either stubbed in tests
170
+ # or will be handled explicitly by ensure_signing_setup_or_skip!.
171
+ print("Proceed with signing enabled? This may hang waiting for a PEM password. [y/N]: ")
172
+ ans = Kettle::Dev::InputAdapter.gets&.strip
173
+ unless ans&.downcase&.start_with?("y")
174
+ abort("Aborted. Re-run with SKIP_GEM_SIGNING=true bundle exec kettle-release (or set it in your environment).")
175
+ end
176
+ else
177
+ warn("Non-interactive shell detected (non-TTY); skipping interactive signing confirmation.")
125
178
  end
126
179
  end
180
+
181
+ ensure_signing_setup_or_skip!
127
182
  end
128
183
 
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")
184
+ # 14. build
185
+ if @start_step <= 14
186
+ puts "Running build (you may be prompted for the signing key password)..."
187
+ run_cmd!("bundle exec rake build")
188
+ end
132
189
 
133
- run_cmd!("bin/gem_checksums")
134
- validate_checksums!(version, stage: "after build + gem_checksums")
190
+ # 15. checksums validate
191
+ if @start_step <= 15
192
+ run_cmd!("bin/gem_checksums")
193
+ version ||= detect_version
194
+ validate_checksums!(version, stage: "after build + gem_checksums")
195
+ end
135
196
 
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")
197
+ # 16. release and validate
198
+ if @start_step <= 16
199
+ puts "Running release (you may be prompted for signing key password and RubyGems MFA OTP)..."
200
+ run_cmd!("bundle exec rake release")
201
+ version ||= detect_version
202
+ validate_checksums!(version, stage: "after release")
203
+ end
139
204
 
140
205
  puts "\nRelease complete. Don't forget to push the checksums commit if needed."
141
206
  end
@@ -143,87 +208,9 @@ module Kettle
143
208
  private
144
209
 
145
210
  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
211
+ # Delegate to shared CI monitor to keep logic DRY across release flow and rake tasks
212
+ require "kettle/dev/ci_monitor"
213
+ Kettle::Dev::CIMonitor.monitor_all!(restart_hint: "bundle exec kettle-release start_step=10")
227
214
  end
228
215
 
229
216
  def run_cmd!(cmd)
@@ -269,7 +256,7 @@ module Kettle
269
256
  when "true", "1", "yes", "y" then true
270
257
  when "ask"
271
258
  print("Run local CI with 'act' before pushing? [Y/n] ")
272
- ans = $stdin.gets&.strip
259
+ ans = Kettle::Dev::InputAdapter.gets&.strip
273
260
  ans.nil? || ans.empty? || ans =~ /\Ay(es)?\z/i
274
261
  else
275
262
  false
@@ -329,17 +316,7 @@ module Kettle
329
316
  end
330
317
 
331
318
  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
319
+ Kettle::Dev::Versioning.detect_version(@root)
343
320
  end
344
321
 
345
322
  def detect_gem_name
@@ -571,7 +548,7 @@ module Kettle
571
548
  puts " [m] Merge --no-ff #{gh_remote}/#{trunk} into #{trunk} (push to origin and #{gh_remote})"
572
549
  puts " [a] Abort"
573
550
  print("> ")
574
- choice = $stdin.gets&.strip&.downcase
551
+ choice = Kettle::Dev::InputAdapter.gets&.strip&.downcase
575
552
  case choice
576
553
  when "r"
577
554
  run_cmd!("git rebase #{Shellwords.escape("#{gh_remote}/#{trunk}")}")
@@ -606,8 +583,8 @@ module Kettle
606
583
  end
607
584
 
608
585
  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 != ""
586
+ # Treat any non-/true/i value as an explicit skip signal
587
+ return if ENV.fetch("SKIP_GEM_SIGNING", "").casecmp("true").zero?
611
588
 
612
589
  user = ENV.fetch("GEM_CERT_USER", ENV["USER"])
613
590
  cert_path = File.join(@root, "certs", "#{user}.pem")