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

RUBYGEMS_INVALID_OTP =
/Your OTP code is incorrect\. Please check it and retry\./.freeze
OTP_RETRY_DELAY_SECONDS =
2
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
RELEASE_CHILD_ENV_KEYS =
%w[
  K_RELEASE_CI_CONTINUE
  K_RELEASE_REQUIRED_REMOTES
  KETTLE_FAMILY_CONFIG
  KETTLE_PRE_RELEASE_GHA_SHA_PINS_OFFLINE
  KETTLE_RELEASE_SECRETS_PROVIDER
  KETTLE_RELEASE_SECRETS_BROKER
  KETTLE_RELEASE_GEM_SIGNING_PASSPHRASE
  KETTLE_RELEASE_GEM_SIGNING_PASSPHRASE_SOURCE
  KETTLE_RELEASE_1PASSWORD_ACCOUNT
  KETTLE_RELEASE_1PASSWORD_CLI
  KETTLE_RELEASE_1PASSWORD_ITEM
  KETTLE_RELEASE_1PASSWORD_GEM_SIGNING_PASSPHRASE_FIELD
  KETTLE_RELEASE_1PASSWORD_RUBYGEMS_OTP_FIELD
  KETTLE_RELEASE_1PASSWORD_GEM_SIGNING_PASSPHRASE_REFERENCE
  KETTLE_RELEASE_1PASSWORD_RUBYGEMS_OTP_REFERENCE
].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_changelog: nil, skip_bundle_audit: nil, ci_workflows: nil, skip_remotes: nil, required_remotes: nil, secrets_provider_name: nil, yes: false, **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)


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
# File 'lib/kettle/dev/release_cli.rb', line 193

def initialize(start_step: 0, local_ci: false, version: nil, appraisal_task: nil, skip_steps: nil, skip_changelog: nil, skip_bundle_audit: nil, ci_workflows: nil, skip_remotes: nil, required_remotes: nil, secrets_provider_name: nil, yes: false, **options)
  @root = Kettle::Dev::CIHelpers.project_root
  @git = Kettle::Dev::GitAdapter.new(@root)
  @start_step = (start_step || 0).to_i
  @start_step = 0 if @start_step < 0
  @skip_steps = normalize_skip_steps(skip_steps)
  @skip_changelog = truthy_value?(skip_changelog) || truthy_value?(ENV["KETTLE_DEV_SKIP_CHANGELOG"])
  @ci_workflows = normalize_ci_workflows(ci_workflows || ENV["K_RELEASE_CI_WORKFLOWS"])
  @skip_remotes = normalize_remote_names(skip_remotes || ENV["K_RELEASE_SKIP_REMOTES"], "skip remotes")
  @required_remotes = normalize_required_remotes(required_remotes)
  @local_ci = !!local_ci
  @skip_bundle_audit = truthy_value?(skip_bundle_audit) || truthy_value?(ENV["KETTLE_DEV_SKIP_BUNDLE_AUDIT"])
  @yes = !!yes
  @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: [])
  @secrets_provider = options[:secrets_provider] || Kettle::Dev::ReleaseSecrets::Factory.build(provider_name: secrets_provider_name)
  @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
  @changelog_generated_coverage = false
end

Instance Attribute Details

#finished_reportObject (readonly)

Returns the value of attribute finished_report.



248
249
250
# File 'lib/kettle/dev/release_cli.rb', line 248

def finished_report
  @finished_report
end

Class Method Details

.run_cmd!(cmd) ⇒ Object



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/kettle/dev/release_cli.rb', line 71

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_for(cmd)

  # 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_words = effective_command_words(cmd)
  interactive = interactive_words.first(4) == ["bundle", "exec", "rake", "release"] ||
    interactive_words.first(2) == ["gem", "push"] ||
    interactive_words.first(2) == ["bundle", "exec"] && interactive_words[2] == "kettle-changelog"
  if interactive
    ok = system(env_hash, cmd)
    unless ok
      exit_code = $?.respond_to?(:exitstatus) ? $?.exitstatus : 1
      Kettle::Dev::ExitAdapter.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
    Kettle::Dev::ExitAdapter.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)


1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
# File 'lib/kettle/dev/release_cli.rb', line 1124

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)


2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
# File 'lib/kettle/dev/release_cli.rb', line 2788

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])


1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
# File 'lib/kettle/dev/release_cli.rb', line 1097

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])


1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
# File 'lib/kettle/dev/release_cli.rb', line 1148

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



534
535
536
537
538
539
540
541
# File 'lib/kettle/dev/release_cli.rb', line 534

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



555
556
557
558
# File 'lib/kettle/dev/release_cli.rb', line 555

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_remote_names(value, label) ⇒ Object



560
561
562
563
564
565
566
# File 'lib/kettle/dev/release_cli.rb', line 560

def normalize_remote_names(value, label)
  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 #{label} value #{invalid.inspect}; use comma-separated git remote names.") if invalid

  remotes.uniq
end

#normalize_required_remotes(value) ⇒ Object



568
569
570
571
# File 'lib/kettle/dev/release_cli.rb', line 568

def normalize_required_remotes(value)
  remotes = normalize_remote_names(value.nil? ? ENV["K_RELEASE_REQUIRED_REMOTES"] : value, "required remotes")
  remotes.empty? ? ["origin"] : remotes
end

#normalize_skip_steps(value) ⇒ Object



543
544
545
546
547
548
549
550
551
552
553
# File 'lib/kettle/dev/release_cli.rb', line 543

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)


1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
# File 'lib/kettle/dev/release_cli.rb', line 1173

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.



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
# File 'lib/kettle/dev/release_cli.rb', line 222

def run
  @started_at = monotonic_time
  emit_run_start
  status = "ok"
  error = nil
  with_bundle_audit_skip_env do
    with_skip_changelog_env do
      with_machine_stdout_redirect do
        run_with_release_environment
      end
    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



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
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
# File 'lib/kettle/dev/release_cli.rb', line 250

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] RubyGems.org 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 RubyGems.org 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 RubyGems.org 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 RubyGems.org 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 RubyGems.org (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 RubyGems.org (offline?). Proceeding without sanity check."
      end
    else
      puts "Could not determine latest released version from RubyGems.org (offline?). Proceeding without sanity check."
    end

    confirm_yes!("Have you updated lib/**/version.rb and CHANGELOG.md for v#{version}? [y/N]", "> ", "Aborted: please update version.rb and CHANGELOG.md, then re-run.")

    # 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?
  prepare_release_lockfiles_for_release_tasks! if release_lockfile_preflight_needed?

  # 3. bin/setup
  run_cmd!(release_setup_command) if run_step?(3)
  # 4. bin/rake
  run_cmd!(release_default_task_command) 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!(release_project_command("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!(release_project_command("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 signing_enabled?
      puts "TIP: For local dry-runs or testing the release workflow, set SKIP_GEM_SIGNING=true to avoid PEM password prompts."
      if @yes
        puts "Proceeding with signing enabled because --yes was provided."
      elsif 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!
    ensure_release_secrets_ready_for_signing! if signing_enabled? && release_secrets_configured?
  end

  # 14. build
  if run_step?(14)
    ensure_release_secrets_ready_for_signing! if signing_enabled? && release_secrets_configured?
    if signing_enabled? && release_secrets_configured?
      puts "Running build with gem signing passphrase from configured secrets provider (#{release_secrets_provider_label})..."
    else
      puts "Running build (you may be prompted for the signing key password)..."
    end
    run_cmd!(release_project_command("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
      ensure_release_secrets_ready_for_signing! if signing_enabled? && release_secrets_configured?
      if release_secrets_configured?
        puts "Running release with configured secrets provider (#{release_secrets_provider_label}) for signing and RubyGems MFA prompts..."
      else
        puts "Running release (you may be prompted for signing key password and RubyGems MFA OTP)..."
      end
      with_unpublished_candidate_cleanup do
        run_cmd!(release_project_command("bundle exec rake release"))
        @release_candidate.published = true
        confirm_release_candidate_available!(@release_candidate)
      end
      mark_rubygems_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
    created, message = maybe_create_github_release!(version)
    abort("GitHub release creation failed: #{message}") if github_release_required? && !created
  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)


1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
# File 'lib/kettle/dev/release_cli.rb', line 1027

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)


1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
# File 'lib/kettle/dev/release_cli.rb', line 1041

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.


1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
# File 'lib/kettle/dev/release_cli.rb', line 1006

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.



1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
# File 'lib/kettle/dev/release_cli.rb', line 1057

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