Class: Kettle::Dev::ReleaseCLI

Inherits:
Object
  • Object
show all
Defined in:
lib/kettle/dev/release_cli.rb,
sig/kettle/dev.rbs

Defined Under Namespace

Classes: ReleaseCandidate

Constant Summary collapse

QUIET_ENV =
{
  "KETTLE_JEM_QUIET" => "true",
  "KETTLE_JEM_DEBUG" => "false",
  "KETTLE_DEV_DEBUG" => "false",
  "STRUCTUREDMERGE_DEBUG" => "false",
  "DEBUG" => nil,
  "BUNDLE_QUIET" => "true",
  "BUNDLE_DEBUG" => "false",
  "BUNDLER_DEBUG" => "false",
  "BUNDLE_VERBOSE" => "false",
  "DEBUG_RESOLVER" => nil,
  "DEBUG_RESOLVER_TREE" => nil,
  "BUNDLER_DEBUG_RESOLVER" => nil,
  "BUNDLER_DEBUG_RESOLVER_TREE" => nil,
  "DEBUG_COMPACT_INDEX" => nil,
  "MOLINILLO_DEBUG" => nil,
  "BUNDLE_SILENCE_DEPRECATIONS" => "true",
  "BUNDLE_SILENCE_ROOT_WARNING" => "true",
  "BUNDLE_SUPPRESS_INSTALL_USING_MESSAGES" => "true"
}.freeze
DEBUG_TRUE_VALUES =
%w[1 true yes on].freeze
RELEASE_VALIDATION_SOURCE =
"https://gem.coop"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(start_step: 0, local_ci: false, version: nil, appraisal_task: nil, skip_steps: nil, skip_bundle_audit: nil, ci_workflows: nil, skip_remotes: nil, **options) ⇒ ReleaseCLI

Returns a new instance of ReleaseCLI.

Parameters:

  • start_step: (Integer) (defaults to: 0)
  • local_ci: (Boolean) (defaults to: false)
  • version: (String, nil) (defaults to: nil)
  • appraisal_task: (String, nil) (defaults to: nil)
  • skip_steps: (Object) (defaults to: nil)
  • skip_bundle_audit: (Boolean, nil) (defaults to: nil)
  • ci_workflows: (Object) (defaults to: nil)
  • skip_remotes: (Object) (defaults to: nil)
  • (Object)


115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/kettle/dev/release_cli.rb', line 115

def initialize(start_step: 0, local_ci: false, version: nil, appraisal_task: nil, skip_steps: nil, skip_bundle_audit: nil, ci_workflows: nil, skip_remotes: nil, **options)
  @root = Kettle::Dev::CIHelpers.project_root
  @git = Kettle::Dev::GitAdapter.new
  @start_step = (start_step || 0).to_i
  @start_step = 0 if @start_step < 0
  @skip_steps = normalize_skip_steps(skip_steps)
  @ci_workflows = normalize_ci_workflows(ci_workflows || ENV["K_RELEASE_CI_WORKFLOWS"])
  @skip_remotes = normalize_skip_remotes(skip_remotes || ENV["K_RELEASE_SKIP_REMOTES"])
  @local_ci = !!local_ci
  @skip_bundle_audit = truthy_value?(skip_bundle_audit) || truthy_value?(ENV["KETTLE_DEV_SKIP_BUNDLE_AUDIT"])
  @version_override = Kettle::Dev::Versioning.normalize_explicit_version(version)
  @appraisal_task = normalize_appraisal_task(appraisal_task || ENV["KETTLE_RELEASE_APPRAISAL_TASK"])
  @release_candidate = nil
  @event_stream = options[:event_stream]
  @event_recorder = Kettle::Ndjson.event_recorder(@event_stream, phase_timings: [])
  @report_path = options[:report_path]
  @json_output = !!options[:json_output]
  @json_io = options[:json_io] || $stdout
  @command_events = []
  @diagnostics = []
  @started_at = nil
  @finished_report = nil
end

Instance Attribute Details

#finished_reportObject (readonly)

Returns the value of attribute finished_report.



163
164
165
# File 'lib/kettle/dev/release_cli.rb', line 163

def finished_report
  @finished_report
end

Class Method Details

.run_cmd!(cmd) ⇒ Object



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/kettle/dev/release_cli.rb', line 47

def run_cmd!(cmd)
  # For Bundler-invoked build/release, explicitly prefix SKIP_GEM_SIGNING so
  # the signing step is skipped even when Bundler scrubs ENV.
  # Always do this on CI to avoid interactive prompts; locally only when explicitly requested.
  if ENV["SKIP_GEM_SIGNING"] && /\Abundle(\s+exec)?\s+rake\s+(build|release)\b/.match?(cmd)
    cmd = "SKIP_GEM_SIGNING=true #{cmd}"
  end
  puts "$ #{cmd}"
  # Pass a plain Hash for the environment to satisfy tests and avoid ENV object oddities
  env_hash = command_env

  # Some commands are interactive (e.g., `bundle exec rake release` prompting for RubyGems MFA).
  # Using capture3 detaches STDIN, preventing prompts from working. For such commands, use system
  # so they inherit the current TTY and can read the user's input.
  interactive = /\Abundle(\s+exec)?\s+rake\s+release\b/.match?(cmd) ||
    /\Agem\s+push\b/.match?(cmd) ||
    /\A(bundle\s+exec\s+)?kettle-changelog\b/.match?(cmd)
  if interactive
    ok = system(env_hash, cmd)
    unless ok
      exit_code = $?.respond_to?(:exitstatus) ? $?.exitstatus : 1
      abort("Command failed: #{cmd} (exit #{exit_code})")
    end
    return
  end

  # Non-interactive: capture output so we can surface clear diagnostics on failure
  stdout_str, stderr_str, status = Open3.capture3(env_hash, cmd)

  # Echo command output to match prior behavior
  $stdout.print(stdout_str) unless stdout_str.nil? || stdout_str.empty?
  $stderr.print(stderr_str) unless stderr_str.nil? || stderr_str.empty?

  unless status.success?
    exit_code = status.exitstatus
    # Keep the original prefix to avoid breaking any tooling/tests that grep for it,
    # but add the exit status and a brief diagnostic tail from stderr.
    diag = ""
    unless stderr_str.to_s.empty?
      tail = stderr_str.lines.last(20).join
      diag = "\n--- STDERR (last 20 lines) ---\n#{tail}".rstrip
    end
    abort("Command failed: #{cmd} (exit #{exit_code})#{diag}")
  end
end

Instance Method Details

#collapse_years(enum) ⇒ String

Collapse a set/array of years into a canonical, comma-separated string, combining consecutive runs into ranges with a hyphen (YYYY-YYYY) and leaving gaps as commas.

Parameters:

  • enum (::_ToA[Integer])

Returns:

  • (String)


875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
# File 'lib/kettle/dev/release_cli.rb', line 875

def collapse_years(enum)
  arr = enum.to_a.map(&:to_i).uniq.sort
  return "" if arr.empty?

  segments = []
  start = arr.first
  prev = start
  arr[1..-1].to_a.each do |y|
    if y == prev + 1
      prev = y
      next
    else
      segments << ((start == prev) ? start.to_s : "#{start}-#{prev}")
      start = prev = y
    end
  end
  segments << ((start == prev) ? start.to_s : "#{start}-#{prev}")
  segments.join(", ")
end

Returns:

  • (String, nil)


2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
# File 'lib/kettle/dev/release_cli.rb', line 2016

def extract_release_notes_footer
  path = File.join(@root, "FUNDING.md")
  return unless File.file?(path)

  content = File.read(path)
  start_tag = "<!-- RELEASE-NOTES-FOOTER-START -->"
  end_tag = "<!-- RELEASE-NOTES-FOOTER-END -->"
  s = content.index(start_tag)
  e = content.index(end_tag)
  return unless s && e && e > s

  # Extract between tags, excluding the tags themselves
  block = content[(s + start_tag.length)...e]
  # Normalize: trim trailing whitespace but keep internal formatting
  block = block.lstrip # drop leading newline/space
  block.rstrip
rescue => e
  warn("[kettle-release] Failed to extract release notes footer from FUNDING.md: #{e.class}: #{e.message}")
  nil
end

#extract_years_from_file(path) ⇒ ::Set[Integer]

Extract a Set of Integer years from the given file. It searches for lines containing the word "Copyright" (case-insensitive), then parses four-digit years and year ranges like "2012-2015" (hyphen or en dash). Returns Set.

Parameters:

  • path (String)

Returns:

  • (::Set[Integer])


848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
# File 'lib/kettle/dev/release_cli.rb', line 848

def extract_years_from_file(path)
  years = Set.new
  content = File.read(path)
  # Only consider lines that look like copyright notices to reduce false positives
  content.each_line do |line|
    next unless /copyright/i.match?(line)

    # Expand ranges first (supports hyphen-minus and en dash)
    line.scan(/\b(19\d{2}|20\d{2})\s*[-–]\s*(19\d{2}|20\d{2})\b/).each do |a, b|
      s = a.to_i
      e = b.to_i
      if e < s
        s, e = e, s
      end
      (s..e).each { |y| years << y }
    end

    # Then single standalone years
    line.scan(/\b(19\d{2}|20\d{2})\b/).each do |y|
      years << y[0].to_i
    end
  end
  years
end

#inject_years_into_file!(path, years_set) ⇒ void

This method returns an undefined value.

Inject the provided set of years into copyright lines, rewriting them in canonical form.

  • Finds lines containing 'copyright' (case-insensitive) and a years blob.
  • Replaces that blob with the canonical collapsed form of the union of existing years and given years.
  • If multiple copyright lines, updates each consistently.

Parameters:

  • path (String)
  • years_set (::Set[Integer])


899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
# File 'lib/kettle/dev/release_cli.rb', line 899

def inject_years_into_file!(path, years_set)
  content = File.read(path)
  changed = false
  canonical_all = collapse_years(years_set)
  new_lines = content.each_line.map do |line|
    unless /copyright/i.match?(line)
      next line
    end

    m = line.match(/\A(?<pre>.*?copyright[^0-9]*)(?<years>(?:\b(?:19|20)\d{2}\b(?:\s*[-–]\s*\b(?:19|20)\d{2}\b)?)(?:\s*,\s*\b(?:19|20)\d{2}\b(?:\s*[-–]\s*\b(?:19|20)\d{2}\b)?)*)(?<post>.*)\z/i)
    unless m
      next line
    end

    new_line = "#{m[:pre]}#{canonical_all}#{m[:post]}"
    changed ||= (new_line != line)
    new_line
  end
  if changed
    File.write(path, new_lines.join)
  end
end

#normalize_appraisal_task(value) ⇒ Object



437
438
439
440
441
442
443
444
# File 'lib/kettle/dev/release_cli.rb', line 437

def normalize_appraisal_task(value)
  task = value.to_s.strip
  return "appraisal:generate" if task.empty?
  return "appraisal:generate" if task == "generate" || task == "appraisal:generate"
  return "appraisal:update" if task == "update" || task == "appraisal:update"

  abort("Unsupported appraisal task #{value.inspect}; use appraisal:generate or appraisal:update.")
end

#normalize_ci_workflows(value) ⇒ Object



458
459
460
461
# File 'lib/kettle/dev/release_cli.rb', line 458

def normalize_ci_workflows(value)
  workflows = Array(value).flat_map { |part| part.to_s.split(",") }.map(&:strip).reject(&:empty?)
  workflows.map { |workflow| workflow.match?(/\.ya?ml\z/) ? workflow : "#{workflow}.yml" }.uniq
end

#normalize_skip_remotes(value) ⇒ Object



463
464
465
466
467
468
469
# File 'lib/kettle/dev/release_cli.rb', line 463

def normalize_skip_remotes(value)
  remotes = Array(value).flat_map { |part| part.to_s.split(",") }.map(&:strip).reject(&:empty?)
  invalid = remotes.find { |remote| !remote.match?(/\A[A-Za-z0-9_.-]+\z/) }
  abort("Invalid skip remotes value #{invalid.inspect}; use comma-separated git remote names.") if invalid

  remotes.uniq
end

#normalize_skip_steps(value) ⇒ Object



446
447
448
449
450
451
452
453
454
455
456
# File 'lib/kettle/dev/release_cli.rb', line 446

def normalize_skip_steps(value)
  raw_steps = Array(value).flat_map { |part| part.to_s.split(",") }.map(&:strip).reject(&:empty?)
  raw_steps.map do |raw|
    abort("Invalid skip_steps value #{raw.inspect}; use comma-separated release step numbers from 0 to 19.") unless raw.match?(/\A\d+\z/)

    step = raw.to_i
    abort("Invalid skip_steps value #{raw.inspect}; release steps are numbered 0 to 19.") unless step.between?(0, 19)

    step
  end.uniq
end

This method returns an undefined value.

Rewrite copyright lines in-place to collapse years into canonical ranges. Only modifies lines that contain the word "copyright" (case-insensitive).

Parameters:

  • path (String)


924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
# File 'lib/kettle/dev/release_cli.rb', line 924

def reformat_copyright_year_lines!(path)
  content = File.read(path)
  changed = false
  new_lines = content.each_line.map do |line|
    unless /copyright/i.match?(line)
      next line
    end

    # Capture three parts: prefix up to first year, the year blob, and the rest
    m = line.match(/\A(?<pre>.*?copyright[^0-9]*)(?<years>(?:\b(?:19|20)\d{2}\b(?:\s*[-–]\s*\b(?:19|20)\d{2}\b)?)(?:\s*,\s*\b(?:19|20)\d{2}\b(?:\s*[-–]\s*\b(?:19|20)\d{2}\b)?)*)(?<post>.*)\z/i)
    unless m
      # No parsable year sequence on this line; leave as-is
      next line
    end

    years_blob = m[:years]
    # Reuse extraction logic on just the years blob
    years = []
    years_blob.scan(/\b(19\d{2}|20\d{2})\s*[-–]\s*(19\d{2}|20\d{2})\b/).each do |a, b|
      s = a.to_i
      e = b.to_i
      s, e = e, s if e < s
      (s..e).each { |y| years << y }
    end
    years_blob.scan(/\b(19\d{2}|20\d{2})\b/).each { |y| years << y[0].to_i }
    canonical = collapse_years(years)
    new_line = "#{m[:pre]}#{canonical}#{m[:post]}"
    changed ||= (new_line != line)
    new_line
  end
  if changed
    File.write(path, new_lines.join)
  end
end

#runvoid

This method returns an undefined value.



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/kettle/dev/release_cli.rb', line 139

def run
  @started_at = monotonic_time
  emit_run_start
  status = "ok"
  error = nil
  with_bundle_audit_skip_env do
    with_machine_stdout_redirect do
      run_with_release_environment
    end
  end
rescue SystemExit => e
  status = e.status.to_i.zero? ? "ok" : "failed"
  error = e
  record_diagnostic("release_exit", e.message, severity: (status == "ok") ? "info" : "error", blocking: status != "ok")
  raise
rescue => e
  status = "failed"
  error = e
  record_diagnostic("release_error", "#{e.class}: #{e.message}", severity: "error", blocking: true)
  raise
ensure
  finish_release_report(status: status, error: error)
end

#run_with_release_environmentObject



165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# File 'lib/kettle/dev/release_cli.rb', line 165

def run_with_release_environment
  run_pre_release_checks! if run_step?(0)

  # 1. Ensure Bundler version ✓
  ensure_bundler_2_7_plus! if run_step?(1)

  version = nil
  committed = nil
  trunk = nil
  feature = nil
  branch_stack_release = false

  # 2. Version detection and sanity checks + prompt
  if run_step?(2)
    version = detect_version
    puts "Detected version: #{version.inspect}"

    latest_overall = nil
    latest_for_series = nil
    begin
      gem_name = detect_gem_name
      latest_overall, latest_for_series = latest_released_versions(gem_name, version)
    rescue => e
      warn("[kettle-release] gem.coop release check failed: #{e.class}: #{e.message}")
      warn(e.backtrace.first(3).map { |l| "  " + l }.join("\n")) if ENV["KETTLE_DEV_DEBUG"]
      warn("Proceeding without gem.coop latest version info.")
    end

    if latest_overall
      msg = "Latest released: #{latest_overall}"
      if latest_for_series && latest_for_series != latest_overall
        msg += " | Latest for series #{Gem::Version.new(version).segments[0, 2].join(".")}.x: #{latest_for_series}"
      elsif latest_for_series
        msg += " (matches current series)"
      end
      puts msg

      cur = Gem::Version.new(version)
      overall = Gem::Version.new(latest_overall)
      cur_series = cur.segments[0, 2]
      overall_series = overall.segments[0, 2]
      # Ensure latest_for_series actually matches our current series; ignore otherwise.
      if latest_for_series
        lfs_series = Gem::Version.new(latest_for_series).segments[0, 2]
        latest_for_series = nil unless lfs_series == cur_series
      end
      # Determine the sanity-check target correctly for the current series.
      # If gem.coop has a newer overall series than our current series, only compare
      # against the latest published in our current series. If that cannot be determined
      # (e.g., offline), skip the sanity check rather than treating the overall as target.
      target = if (cur_series <=> overall_series) == -1
        latest_for_series
      else
        latest_overall
      end
      # IMPORTANT: Never treat a higher different-series "latest_overall" as a downgrade target.
      # If our current series is behind overall and gem.coop does not report a latest_for_series,
      # then we cannot determine the correct target for this series and should skip the check.
      if (cur_series <=> overall_series) == -1 && target.nil?
        puts "Could not determine latest released version from gem.coop (offline?). Proceeding without sanity check."
      elsif target
        bump = Kettle::Dev::Versioning.classify_bump(target, version)
        case bump
        when :same
          series = cur_series.join(".")
          warn("version.rb (#{version}) matches the latest released version for series #{series} (#{target}).")
          abort("Aborting: version bump required. Bump PATCH/MINOR/MAJOR/EPIC.")
        when :downgrade
          series = cur_series.join(".")
          warn("version.rb (#{version}) is lower than the latest released version for series #{series} (#{target}).")
          abort("Aborting: version must be bumped above #{target}.")
        else
          label = {epic: "EPIC", major: "MAJOR", minor: "MINOR", patch: "PATCH"}[bump] || bump.to_s.upcase
          puts "Proposed bump type: #{label} (from #{target} -> #{version})"
        end
      else
        puts "Could not determine latest released version from gem.coop (offline?). Proceeding without sanity check."
      end
    else
      puts "Could not determine latest released version from gem.coop (offline?). Proceeding without sanity check."
    end

    puts "Have you updated lib/**/version.rb and CHANGELOG.md for v#{version}? [y/N]"
    print("> ")
    ans = Kettle::Dev::InputAdapter.gets&.strip
    abort("Aborted: please update version.rb and CHANGELOG.md, then re-run.") unless ans&.downcase&.start_with?("y")

    # Initial validation: Ensure README.md and LICENSE.txt have identical sets of copyright years; also ensure current year present when matched
    validate_copyright_years!

    # Ensure README KLOC badge reflects current CHANGELOG coverage denominator
    begin
      update_readme_kloc_badge!
    rescue => e
      warn("Failed to update KLOC badge in README: #{e.class}: #{e.message}")
    end

    # Update Rakefile.example header banner with current version and date
    begin
      update_rakefile_example_header!(version)
    rescue => e
      warn("Failed to update Rakefile.example header: #{e.class}: #{e.message}")
    end
  end

  prepare_rubocop_lts_local_branch! if rubocop_lts_release_preflight_needed?

  # 3. bin/setup
  run_cmd!("bin/setup") if run_step?(3)
  # 4. bin/rake
  run_cmd!("bin/rake") if run_step?(4)

  # 5. appraisal:generate (optional) + canonical docs build
  if run_step?(5)
    appraisals_path = File.join(@root, "Appraisals")
    if File.file?(appraisals_path)
      puts "Appraisals detected at #{Kettle::Dev.display_path(appraisals_path)}. Running: bin/rake #{@appraisal_task}"
      run_cmd!("bin/rake #{@appraisal_task}")
    else
      puts "No Appraisals file found; skipping #{@appraisal_task}"
    end

    puts "Generating docs site via canonical task: bin/rake yard"
    run_cmd!("bin/rake yard")
  end

  # 6. git user + commit release prep
  if run_step?(6)
    prepare_release_lockfiles_for_commit!
    ensure_git_user!
    version ||= detect_version
    committed = commit_release_prep!(version)
  end

  # 7. optional local CI via act
  maybe_run_local_ci_before_push!(committed, force: local_ci?) if run_step?(7)

  # 8. ensure trunk synced
  if run_step?(8) && !local_ci?
    trunk = detect_trunk_branch
    feature = current_branch
    branch_stack_release = branch_stack_release_branch?(feature, trunk)
    if branch_stack_release
      puts "Kettle-family branch stack release branch detected: #{feature}; skipping trunk sync/rebase."
    end
    puts "Trunk branch detected: #{trunk}"
    ensure_trunk_synced_before_push!(trunk, feature) unless branch_stack_release
  elsif run_step?(8)
    puts "Local CI release mode: skipping remote trunk sync before publishing."
  end

  # 9. push branches
  if run_step?(9) && !local_ci?
    validate_release_lockfiles!(stage: "before push")
    push!
  end

  # 10. monitor CI after push
  if run_step?(10) && !local_ci?
    validate_release_lockfiles!(stage: "before CI monitoring")
    monitor_workflows_after_push!
  end

  # 11. merge feature into trunk and push
  if run_step?(11) && !local_ci?
    trunk ||= detect_trunk_branch
    feature ||= current_branch
    branch_stack_release ||= branch_stack_release_branch?(feature, trunk)
    if branch_stack_release
      puts "Kettle-family branch stack release branch detected: #{feature}; skipping merge into #{trunk}."
    else
      merge_feature_into_trunk_and_push!(trunk, feature)
    end
  end

  # 12. checkout trunk and pull
  if run_step?(12) && !local_ci?
    trunk ||= detect_trunk_branch
    feature ||= current_branch
    branch_stack_release ||= branch_stack_release_branch?(feature, trunk)
    if branch_stack_release
      puts "Kettle-family branch stack release branch detected: #{feature}; staying on release branch."
    else
      checkout!(trunk)
      pull!(trunk)
    end
  end

  # 13. signing guidance and checks
  if run_step?(13)
    if ENV.fetch("SKIP_GEM_SIGNING", "false").casecmp("false").zero?
      puts "TIP: For local dry-runs or testing the release workflow, set SKIP_GEM_SIGNING=true to avoid PEM password prompts."
      if Kettle::Dev::InputAdapter.tty?
        # In CI, avoid interactive prompts when no TTY is present (e.g., act or GitHub Actions "CI validation").
        # Non-interactive CI runs should not abort here; later signing checks are either stubbed in tests
        # or will be handled explicitly by ensure_signing_setup_or_skip!.
        print("Proceed with signing enabled? This may hang waiting for a PEM password. [y/N]: ")
        ans = Kettle::Dev::InputAdapter.gets&.strip
        unless ans&.downcase&.start_with?("y")
          abort("Aborted. Re-run with SKIP_GEM_SIGNING=true bundle exec kettle-release (or set it in your environment).")
        end
      else
        warn("Non-interactive shell detected (non-TTY); skipping interactive signing confirmation.")
      end
    end

    ensure_signing_setup_or_skip!
  end

  # 14. build
  if run_step?(14)
    puts "Running build (you may be prompted for the signing key password)..."
    run_cmd!("bundle exec rake build")
  end

  # 15. release and tag
  if run_step?(15)
    version ||= detect_version
    gem_name = detect_gem_name
    @release_candidate = build_release_candidate(gem_name, version)
    if local_ci?
      with_unpublished_candidate_cleanup { release_gem_and_tag_locally!(version) }
    else
      puts "Running release (you may be prompted for signing key password and RubyGems MFA OTP)..."
      with_unpublished_candidate_cleanup do
        run_cmd!("bundle exec rake release")
        @release_candidate.published = true
        confirm_release_candidate_available!(@release_candidate)
      end
      mark_gem_coop_release_cache_bust(version)
    end
  end

  # 16. generate checksums
  #    Checksums are generated after release to avoid including checksums/ in gem package
  #    Rationale: Running gem_checksums before release may commit checksums/ and cause Bundler's
  #    release build to include them in the gem, thus altering the artifact, and invalidating the checksums.
  if run_step?(16)
    # Generate checksums for the just-built artifact, commit them, then validate
    version ||= detect_version
    gem_path = checksum_gem_path_for_version!(version)
    run_cmd!("bin/gem_checksums #{Shellwords.escape(gem_path)}")
    validate_checksums!(version, stage: "after release")
  end

  # 17. push checksum commit (gem_checksums already commits)
  if run_step?(17)
    push!
    push_tags! if local_ci?
  end

  # 18. create GitHub release (optional)
  if run_step?(18)
    version ||= detect_version
    maybe_create_github_release!(version)
  end

  # 19. push tags to remotes (final step)
  push_tags! if run_step?(19) && !local_ci?

  # Final success message
  begin
    version ||= detect_version
    gem_name = detect_gem_name
    human_output.puts "\n🚀 Release #{gem_name} v#{version} Complete 🚀"
  rescue => e
    Kettle::Dev.debug_error(e, __method__)
    # Fallback if detection fails for any reason
    human_output.puts "\n🚀 Release v#{version || "unknown"} Complete 🚀"
  end
end

#update_badge_number_in_file(path, kloc_str) ⇒ void

This method returns an undefined value.

Helper to update the [🧮kloc-img] badge in the given file path. Replaces only the numeric portion after "KLOC-" keeping other URL parts intact.

Parameters:

  • path (String)
  • kloc_str (String)


778
779
780
781
782
783
784
785
786
787
788
# File 'lib/kettle/dev/release_cli.rb', line 778

def update_badge_number_in_file(path, kloc_str)
  return unless File.file?(path)

  content = File.read(path)
  # Match the specific reference line, capture groups around the number
  # Example: [🧮kloc-img]: https://img.shields.io/badge/KLOC-2.175-FFDD67.svg?style=...
  new_content = content.gsub(/(\[🧮kloc-img\]:\s*https?:\/\/img\.shields\.io\/badge\/KLOC-)(\d+(?:\.\d+)?)(-[^\s]*)/, "\\1#{kloc_str}\\3")
  if new_content != content
    File.write(path, new_content)
  end
end

#update_rakefile_example_header!(version) ⇒ void

This method returns an undefined value.

Update Rakefile.example banner to include current gem version and current date. Looks for a line starting with "# kettle-dev Rakefile v" and replaces version/date.

Parameters:

  • version (String)


792
793
794
795
796
797
798
799
800
801
802
803
# File 'lib/kettle/dev/release_cli.rb', line 792

def update_rakefile_example_header!(version)
  path = File.join(@root, "Rakefile.example")
  return unless File.file?(path)

  content = File.read(path)
  today = Time.now.strftime("%Y-%m-%d")
  new_line = "# kettle-dev Rakefile v#{version} - #{today}"
  new_content = content.gsub(/^# kettle-dev Rakefile v.*$/, new_line)
  if new_content != content
    File.write(path, new_content)
  end
end

#update_readme_kloc_badge!void

This method returns an undefined value.

Update the README KLOC badge number based on the denominator in the current version's COVERAGE line in CHANGELOG.md.

  • Parses the current version section of CHANGELOG.md
  • Finds a line matching: "- COVERAGE: ... -- / lines ..."
  • Computes KLOC = total / 1000.0
  • Formats with three decimals (e.g., 0.076, 2.175, 10.123)
  • Rewrites the [🧮kloc-img] badge line in README.md (and README.md.example when present) replacing only the numeric portion after "KLOC-" while preserving other URL params.


757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
# File 'lib/kettle/dev/release_cli.rb', line 757

def update_readme_kloc_badge!
  version = detect_version
  # Extract only the current version's section
  section, _compare_ref, _tag_ref = extract_changelog_for_version(version)
  return unless section

  # Example match: "- COVERAGE: 97.70% -- 2125/2175 lines in 20 files"
  m = section.lines.find { |l| /-\s*COVERAGE:\s*.+--\s*\d+\/(\d+)\s+lines/i.match?(l) }
  return unless m

  denom = m.match(/-\s*COVERAGE:\s*.+--\s*\d+\/(\d+)\s+lines/i)[1].to_i
  kloc = denom.to_f / 1000.0
  kloc_str = format("%.3f", kloc)

  update_badge_number_in_file(File.join(@root, "README.md"), kloc_str)
  example_path = File.join(@root, "README.md.example")
  update_badge_number_in_file(example_path, kloc_str) if File.file?(example_path)
end

This method returns an undefined value.

Validate that README.md and CHANGELOG.md contain identical sets of copyright years. This helps ensure docs are kept in sync when bumping the years. Aborts with a helpful message when they differ.



808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
# File 'lib/kettle/dev/release_cli.rb', line 808

def validate_copyright_years!
  readme = File.join(@root, "README.md")
  license = File.join(@root, "LICENSE.txt")
  unless File.file?(readme) && File.file?(license)
    # If either file is missing, skip this check silently (some projects might not have both initially)
    return
  end

  # Normalize year formatting in both files before comparing
  reformat_copyright_year_lines!(readme)
  reformat_copyright_year_lines!(license)

  r_years = extract_years_from_file(readme)
  l_years = extract_years_from_file(license)
  if r_years == l_years
    # If they match, ensure the current year is present; if not, inject it into both files.
    current_year = Time.now.year
    unless r_years.include?(current_year)
      # Update both files by appending current year to the set and rewriting the lines canonically
      updated_years = r_years.dup
      updated_years << current_year
      # Write back to both files using canonical collapse formatting
      inject_years_into_file!(readme, updated_years)
      inject_years_into_file!(license, updated_years)
    end
    return
  end

  abort(<<~MSG)
    Mismatched copyright years between README.md and LICENSE.txt.
      README.md:   #{r_years.to_a.sort.join(", ")}
      LICENSE.txt: #{l_years.to_a.sort.join(", ")}
    Please update both files so they contain the identical set of years.
  MSG
end