Class: Kettle::Dev::ReleaseCLI

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

Constant Summary collapse

QUIET_ENV =
{
  "KETTLE_JEM_QUIET" => "true",
  "KETTLE_JEM_DEBUG" => "false",
  "KETTLE_DEV_DEBUG" => "false",
  "SMORG_RB_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

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) ⇒ ReleaseCLI

Returns a new instance of ReleaseCLI.

Parameters:

  • start_step: (Integer) (defaults to: 0)


111
112
113
114
115
116
117
118
119
120
# File 'lib/kettle/dev/release_cli.rb', line 111

def initialize(start_step: 0, local_ci: false, version: nil, appraisal_task: nil, skip_steps: nil)
  @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)
  @local_ci = !!local_ci
  @version_override = Kettle::Dev::Versioning.normalize_explicit_version(version)
  @appraisal_task = normalize_appraisal_task(appraisal_task || ENV["KETTLE_RELEASE_APPRAISAL_TASK"])
end

Class Method Details

.run_cmd!(cmd) ⇒ Object



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

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)


614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
# File 'lib/kettle/dev/release_cli.rb', line 614

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)


1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
# File 'lib/kettle/dev/release_cli.rb', line 1301

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


587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
# File 'lib/kettle/dev/release_cli.rb', line 587

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


638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
# File 'lib/kettle/dev/release_cli.rb', line 638

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



380
381
382
383
384
385
386
387
# File 'lib/kettle/dev/release_cli.rb', line 380

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_skip_steps(value) ⇒ Object



389
390
391
392
393
394
395
396
397
398
399
# File 'lib/kettle/dev/release_cli.rb', line 389

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)


663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
# File 'lib/kettle/dev/release_cli.rb', line 663

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.



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
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
# File 'lib/kettle/dev/release_cli.rb', line 122

def run
  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)
    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
  push! if run_step?(9) && !local_ci?

  # 10. monitor CI after push
  monitor_workflows_after_push! if run_step?(10) && !local_ci?

  # 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)
    if local_ci?
      version ||= detect_version
      release_gem_and_tag_locally!(version)
    else
      puts "Running release (you may be prompted for signing key password and RubyGems MFA OTP)..."
      run_cmd!("bundle exec rake release")
    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
    puts "\n🚀 Release #{gem_name} v#{version} Complete 🚀"
  rescue => e
    Kettle::Dev.debug_error(e, __method__)
    # Fallback if detection fails for any reason
    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)


517
518
519
520
521
522
523
524
525
526
527
# File 'lib/kettle/dev/release_cli.rb', line 517

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)


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

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.


496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
# File 'lib/kettle/dev/release_cli.rb', line 496

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.



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
575
576
577
578
579
580
581
# File 'lib/kettle/dev/release_cli.rb', line 547

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