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
GITHUB_RELEASE_ASSET_UPLOAD_ATTEMPTS =
3
GITHUB_RELEASE_ASSET_UPLOAD_RETRY_DELAY_SECONDS =
1
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"
FAMILY_MEMBER_PUBLISH_MODE_ENV =
"KETTLE_RELEASE_FAMILY_MEMBER_PUBLISH"
FAMILY_MEMBER_FINALIZE_MODE_ENV =
"KETTLE_RELEASE_FAMILY_MEMBER_FINALIZE"

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_appraisals: 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)


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

def initialize(start_step: 0, local_ci: false, version: nil, appraisal_task: nil, skip_steps: nil, skip_changelog: nil, skip_appraisals: 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"])
  @skip_appraisals = truthy_value?(skip_appraisals) || truthy_value?(ENV["KETTLE_DEV_SKIP_APPRAISALS"])
  @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
  @release_ci_pull_request = 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
  @release_task_lockfile_paths = {}
  @family_member_publish = truthy_value?(ENV[FAMILY_MEMBER_PUBLISH_MODE_ENV])
  @family_member_finalize = truthy_value?(ENV[FAMILY_MEMBER_FINALIZE_MODE_ENV])
end

Instance Attribute Details

#finished_reportObject (readonly)

Returns the value of attribute finished_report.



261
262
263
# File 'lib/kettle/dev/release_cli.rb', line 261

def finished_report
  @finished_report
end

Class Method Details

.run_cmd!(cmd) ⇒ Object



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
117
118
119
120
121
122
# File 'lib/kettle/dev/release_cli.rb', line 77

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)


1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
# File 'lib/kettle/dev/release_cli.rb', line 1336

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)


3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
# File 'lib/kettle/dev/release_cli.rb', line 3170

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


1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
# File 'lib/kettle/dev/release_cli.rb', line 1309

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


1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
# File 'lib/kettle/dev/release_cli.rb', line 1360

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



576
577
578
579
580
581
582
583
# File 'lib/kettle/dev/release_cli.rb', line 576

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



597
598
599
600
# File 'lib/kettle/dev/release_cli.rb', line 597

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



602
603
604
605
606
607
608
# File 'lib/kettle/dev/release_cli.rb', line 602

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



610
611
612
613
# File 'lib/kettle/dev/release_cli.rb', line 610

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



585
586
587
588
589
590
591
592
593
594
595
# File 'lib/kettle/dev/release_cli.rb', line 585

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)


1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
# File 'lib/kettle/dev/release_cli.rb', line 1385

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.



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

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
  cleanup_release_task_lockfile!
  finish_release_report(status: status, error: error)
end

#run_with_release_environmentObject



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
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
# File 'lib/kettle/dev/release_cli.rb', line 263

def run_with_release_environment
  return run_family_member_publish if family_member_publish?
  return run_family_member_finalize if family_member_finalize?

  # Changelog generation at step 0 runs the target project's test bundle
  # to collect coverage. Normalize its canonical release lockfile before
  # that subprocess starts; otherwise a local development PATH lock can
  # force Bundler to resolve an invalid mixed local/released graph.
  if release_lockfile_preflight_needed?
    prepare_release_lockfiles_for_release_tasks!
    materialize_release_lockfiles_for_release_tasks!
  end
  run_pre_release_checks! if run_step?(0)

  # 1. Ensure Bundler version and record its current release in the
  # development lockfiles before any release-preparation commit.
  if run_step?(1)
    ensure_bundler_2_7_plus!
    update_bundler_and_commit!
  end

  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?

  # 3. bin/setup
  with_release_resume_step(3) { run_cmd!(release_setup_command) } if run_step?(3)
  # 4. bin/rake
  with_release_resume_step(4) { run_cmd!(release_default_task_command) } if run_step?(4)

  # 5. appraisal:generate (optional) + canonical docs build
  with_release_resume_step(5) do
    if run_step?(5)
      appraisals_path = File.join(@root, "Appraisals")
      if skip_appraisals?
        puts "Skipping #{@appraisal_task} because --skip-appraisals was provided."
      elsif 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
  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
  with_release_resume_step(14) do
    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
  end

  # 15. release and tag
  with_release_resume_step(15) do
    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
  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.
  with_release_resume_step(16) do
    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!(release_child_command("bin/gem_checksums #{Shellwords.escape(gem_path)}"))
      validate_checksums!(version, stage: "after release")
    end
  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?

  # Branch-stack PRs exist only to trigger pull-request-only CI. They are
  # deliberately not merged into trunk, so close the release-created PR
  # after the publication has completed successfully.
  close_generated_branch_stack_pull_request!

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


1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
# File 'lib/kettle/dev/release_cli.rb', line 1239

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)


1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
# File 'lib/kettle/dev/release_cli.rb', line 1253

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.


1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
# File 'lib/kettle/dev/release_cli.rb', line 1218

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.



1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
# File 'lib/kettle/dev/release_cli.rb', line 1269

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("    Mismatched copyright years between README.md and LICENSE.txt.\n      README.md:   \#{r_years.to_a.sort.join(\", \")}\n      LICENSE.txt: \#{l_years.to_a.sort.join(\", \")}\n    Please update both files so they contain the identical set of years.\n  MSG\nend\n")