kettle-dev 2.5.14 → 2.5.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd42849120d31c48755d543918cd96ea2abb0f61d550983853ae6827940099f6
4
- data.tar.gz: 5bfc7a99f63937031b7999db6d6a35aa9583f7fc3547db425196fb18d3819458
3
+ metadata.gz: 561f0712647c4d27b03caa44fe76613f2d68602b056d2e0ada052c6c13334f51
4
+ data.tar.gz: 148358784c948bfa8368b36e683834e21636277539ad1b084450829a57721af7
5
5
  SHA512:
6
- metadata.gz: 91ae2809e3092c93f6feaa5b20f4dbc562497917244f95f388ababfe72403f9af76f924314a561c85dc39f8e0f987ed4d19580675612bb01c117fa1e8f1be58e
7
- data.tar.gz: 0c77a71e021fe4657903384d45ecf8da00c136b78bc0a6cb7531ecc584606bb9fa7abd0ad7a683ea844ee6d3a0be59ecfe388c3cdce1dafbf65989a72b4b6063
6
+ metadata.gz: db47e6daf32deff2da6749a388a8c802e65e492ce63d35f0cd606f8cdc8ddf73ab4fda2d137c0c834bff64043f3739440bae25b567391838f43eaf376c0031d9
7
+ data.tar.gz: d40265dd3c8eb72654de1cec852753e2f6deaa5f996809ad348b4ae90d00d65ad70a5fb24a82570e30764bd9425ebc23e394bf2bdfee9d47f8939a6efb635559
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,35 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [2.5.15] - 2026-08-01
34
+
35
+ - TAG: [v2.5.15][2.5.15t]
36
+ - COVERAGE: 91.55% -- 5266/5752 lines in 46 files
37
+ - BRANCH COVERAGE: 75.74% -- 1986/2622 branches in 46 files
38
+ - 50.41% documented
39
+
40
+ ### Added
41
+
42
+ - Add verified GitHub Release backfill checks and NDJSON support to kettle-gh-release.
43
+
44
+ - Add a changelog reformat mode for historical release metadata repair.
45
+
46
+ - Add historical changelog reformat and GitHub release reconciliation checks.
47
+
48
+ - Add an explicit historical release-section backfill mode for tagged releases.
49
+
50
+ - Add kettle-gh-release update mode for existing GitHub Release notes.
51
+
52
+ ### Fixed
53
+
54
+ - Allow the changelog entry writer to add to malformed Unreleased sections with duplicate headings.
55
+
56
+ - Honor K_CHANGELOG_PATH when adding an Unreleased changelog entry.
57
+
58
+ - Canonicalize legacy release headings and remove redundant historical placeholders during reformat.
59
+
60
+ - Roll back unpublished prepared changelog sections when version.rb returns to the published version.
61
+
33
62
  ## [2.5.14] - 2026-07-31
34
63
 
35
64
  - TAG: [v2.5.14][2.5.14t]
@@ -3173,7 +3202,9 @@ Please file a bug if you notice a violation of semantic versioning.
3173
3202
  - Selecting will run the selected workflow via `act`
3174
3203
  - This may move to its own gem in the future.
3175
3204
 
3176
- [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.14...HEAD
3205
+ [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.15...HEAD
3206
+ [2.5.15]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.14...v2.5.15
3207
+ [2.5.15t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v2.5.15
3177
3208
  [2.5.14]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.13...v2.5.14
3178
3209
  [2.5.14t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v2.5.14
3179
3210
  [2.5.13]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.12...v2.5.13
data/README.md CHANGED
@@ -849,8 +849,8 @@ Thanks for RTFM. ☺️
849
849
  [✉️discord-invite-img-ftb]: https://img.shields.io/discord/1373797679469170758?style=for-the-badge&logo=discord
850
850
  [✉️ruby-friends-img]: https://img.shields.io/badge/daily.dev-%F0%9F%92%8E_Ruby_Friends-0A0A0A?style=for-the-badge&logo=dailydotdev&logoColor=white
851
851
  [✉️ruby-friends]: https://app.daily.dev/squads/rubyfriends
852
- [✉️ruby-forum-top-img]: https://img.shields.io/discourse/topics?server=https://www.rubyforum.org&style=flat&logo=discourse&label=Ruby%20Users%20Forum
853
- [✉️ruby-forum-img]: https://img.shields.io/discourse/topics?server=https://www.rubyforum.org&style=for-the-badge&logo=discourse&label=Ruby%20Users%20Forum
852
+ [✉️ruby-forum-top-img]: https://img.shields.io/discourse/topics?server=https%3A%2F%2Fwww.rubyforum.org&style=flat&logo=discourse&label=Ruby%20Users%20Forum
853
+ [✉️ruby-forum-img]: https://img.shields.io/discourse/topics?server=https%3A%2F%2Fwww.rubyforum.org&style=for-the-badge&logo=discourse&label=Ruby%20Users%20Forum
854
854
  [✉️ruby-forum]: https://www.rubyforum.org/tag/kettle-dev
855
855
 
856
856
  [✇bundle-group-pattern]: https://gist.github.com/pboling/4564780
@@ -1005,7 +1005,7 @@ Thanks for RTFM. ☺️
1005
1005
  [📌gitmoji]: https://gitmoji.dev
1006
1006
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
1007
1007
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1008
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-5.597-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1008
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-5.752-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1009
1009
  [🔐security]: https://github.com/kettle-dev/kettle-dev/blob/main/SECURITY.md
1010
1010
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1011
1011
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
data/exe/kettle-changelog CHANGED
@@ -63,7 +63,7 @@ end
63
63
 
64
64
  if ARGV.include?("-h") || ARGV.include?("--help")
65
65
  puts <<~USAGE
66
- Usage: kettle-changelog [--version VERSION] [--yes] [--update-prep] [--pending-release] [--release-state] [--refresh-cache] [--add-unreleased-entry --section SECTION --entry TEXT] [--json|--events[=TYPES]] [--no-strict] [--no-coverage-threshold]
66
+ Usage: kettle-changelog [--version VERSION] [--yes] [--update-prep|--reformat|--backfill-released-version VERSION] [--pending-release] [--release-state] [--refresh-cache] [--add-unreleased-entry --section SECTION --entry TEXT] [--json|--events[=TYPES]] [--no-strict] [--no-coverage-threshold]
67
67
 
68
68
  Detects the current version from lib/**/version.rb, the latest live release, and
69
69
  the most recent CHANGELOG.md release section, then prompts to confirm the selected plan:
@@ -76,6 +76,9 @@ if ARGV.include?("-h") || ARGV.include?("--help")
76
76
  --version VERSION Use this version instead of detecting VERSION from lib/**/version.rb
77
77
  --yes Auto-approve the selected release plan
78
78
  --update-prep Force updating the most recent prepared release section in place
79
+ --reformat Normalize CHANGELOG.md structure without release-state planning
80
+ --backfill-released-version VERSION
81
+ Add a tagged historical section without moving Unreleased entries
79
82
  --pending-release Query whether the changelog has release work pending; exits 0 for yes, 1 for no
80
83
  --release-state,
81
84
  --release-status Print changelog release state, including latest published release and pending sources
@@ -159,6 +162,7 @@ begin
159
162
  # Determine if strict mode is enabled (default: true)
160
163
  strict_mode = !ARGV.include?("--no-strict") && ENV.fetch("K_CHANGELOG_STRICT", "true").downcase != "false"
161
164
  update_prep = ARGV.delete("--update-prep")
165
+ reformat_only = ARGV.delete("--reformat")
162
166
  yes = !!ARGV.delete("--yes")
163
167
  pending_release_query = ARGV.delete("--pending-release")
164
168
  release_state_query = ARGV.delete("--release-state") || ARGV.delete("--release-status")
@@ -189,6 +193,8 @@ begin
189
193
  value
190
194
  end
191
195
 
196
+ historical_backfill_version = extract_option_arg!(ARGV, "--backfill-released-version")
197
+
192
198
  if add_unreleased_entry
193
199
  section = extract_option_arg!(ARGV, "--section")
194
200
  entry = extract_option_arg!(ARGV, "--entry")
@@ -202,7 +208,7 @@ begin
202
208
  end
203
209
 
204
210
  event_stream = events_output ? Kettle::Ndjson.event_stream($stdout, types: event_types) : nil
205
- cli = Kettle::Dev::ChangelogCLI.new(strict: strict_mode, enforce_coverage_thresholds: coverage_hard, update_prep: update_prep, version: version_override, refresh_cache: refresh_cache, yes: yes, event_stream: event_stream)
211
+ cli = Kettle::Dev::ChangelogCLI.new(strict: strict_mode, enforce_coverage_thresholds: coverage_hard, update_prep: update_prep, reformat_only: reformat_only, historical_backfill_version: historical_backfill_version, version: version_override, refresh_cache: refresh_cache, yes: yes, event_stream: event_stream)
206
212
  if release_state_query
207
213
  state = cli.release_state
208
214
  puts(json_output ? JSON.pretty_generate(state) : cli.release_state_table(state))
@@ -39,7 +39,7 @@ end
39
39
  # Do not guard with __FILE__ == $PROGRAM_NAME because binstubs use Kernel.load.
40
40
  if ARGV.include?("-h") || ARGV.include?("--help")
41
41
  puts <<~USAGE
42
- Usage: #{script_basename} [version=<VERSION>]
42
+ Usage: #{script_basename} [--release-version VERSION|version=<VERSION>] [--check|--update] [--events] [--allow-unpublished]
43
43
 
44
44
  Creates a GitHub release for a gem version.
45
45
 
@@ -48,29 +48,99 @@ if ARGV.include?("-h") || ARGV.include?("--help")
48
48
  failed (e.g., due to token issues or network problems).
49
49
 
50
50
  Arguments:
51
- version=<VERSION> # Explicit version (e.g., version=1.2.3)
52
- # If omitted, auto-detects from lib/**/version.rb
51
+ --release-version VERSION # Explicit version (e.g., --release-version 1.2.3)
52
+ version=<VERSION> # Backward-compatible explicit version form
53
+ --check # Verify RubyGems, remote tag, changelog, and token without creating a release
54
+ --update # Update an existing GitHub Release title/body from CHANGELOG.md
55
+ --events # Emit the final result as one NDJSON event
56
+ --allow-unpublished # Skip the RubyGems publication requirement (for intentionally non-RubyGems projects)
53
57
 
54
58
  Environment:
55
59
  GITHUB_TOKEN / GH_TOKEN # Required; needs repo:public_repo (classic) or contents:write scope
56
60
  DEBUG=true # Print backtraces on errors
57
61
 
58
62
  Examples:
59
- #{script_basename} # auto-detect version, create release
60
- #{script_basename} version=2.0.0 # create release for specific version
61
- GITHUB_TOKEN=ghp_xxx #{script_basename} # explicit token
63
+ #{script_basename} --check --release-version 2.0.0
64
+ #{script_basename} --release-version 2.0.0
65
+ #{script_basename} version=2.0.0 # backward-compatible form
62
66
  USAGE
63
67
  exit 0
64
68
  end
65
69
 
66
- # Parse version=<v> from ARGV
67
- version_arg = ARGV.find { |a| a.start_with?("version=") }
68
- explicit_version = version_arg ? version_arg.split("=", 2)[1].strip : nil
70
+ check_only = ARGV.delete("--check")
71
+ update_only = ARGV.delete("--update")
72
+ if check_only && update_only
73
+ warn("#{script_basename}: --check and --update cannot be combined")
74
+ exit 1
75
+ end
76
+ events = ARGV.delete("--events")
77
+ allow_unpublished = ARGV.delete("--allow-unpublished")
78
+ version_arg = ARGV.find { |arg| arg.start_with?("version=") }
79
+ ARGV.delete(version_arg) if version_arg
80
+ explicit_version = version_arg&.split("=", 2)&.last&.strip
81
+
82
+ if (index = ARGV.index("--release-version"))
83
+ explicit_version = ARGV.fetch(index + 1, nil).to_s.strip
84
+ ARGV.slice!(index, 2)
85
+ end
86
+
87
+ unless ARGV.empty?
88
+ warn("#{script_basename}: unexpected argument(s): #{ARGV.join(" ")}")
89
+ exit 1
90
+ end
91
+
92
+ if !explicit_version.nil? && explicit_version.empty?
93
+ warn("#{script_basename}: --release-version requires a version")
94
+ exit 1
95
+ end
69
96
 
70
97
  begin
71
98
  cli = Kettle::Dev::ReleaseCLI.new(start_step: 18)
72
99
  version = explicit_version || cli.send(:detect_version)
73
- cli.send(:maybe_create_github_release!, version)
100
+ result = cli.send(:github_release_backfill_check, version, require_rubygems: !allow_unpublished)
101
+
102
+ if events
103
+ action = if check_only
104
+ "check"
105
+ elsif update_only
106
+ "update"
107
+ else
108
+ "create"
109
+ end
110
+ puts JSON.generate(
111
+ event_version: 1,
112
+ type: "github_release",
113
+ action: action,
114
+ status: result.fetch("ok") ? "ok" : "failed",
115
+ **result
116
+ )
117
+ elsif result.fetch("ok")
118
+ puts "GitHub release prerequisites satisfied for #{result.fetch("tag")}."
119
+ else
120
+ warn("GitHub release prerequisites failed for #{result.fetch("tag")}:")
121
+ result.fetch("diagnostics").each { |diagnostic| warn(" - #{diagnostic}") }
122
+ end
123
+
124
+ exit 1 unless result.fetch("ok")
125
+ exit 0 if check_only
126
+
127
+ created, message = if update_only
128
+ cli.send(:maybe_update_github_release!, version)
129
+ else
130
+ cli.send(:maybe_create_github_release!, version)
131
+ end
132
+ if events
133
+ puts JSON.generate(
134
+ event_version: 1,
135
+ type: "github_release",
136
+ action: update_only ? "update" : "create",
137
+ status: created ? "ok" : "failed",
138
+ version: result.fetch("version"),
139
+ tag: result.fetch("tag"),
140
+ result: message
141
+ )
142
+ end
143
+ exit(created ? 0 : 1)
74
144
  rescue LoadError => e
75
145
  warn("#{script_basename}: could not load dependency: #{e.class}: #{e.message}")
76
146
  warn(Array(e.backtrace).join("\n")) if ENV["DEBUG"]
@@ -27,9 +27,11 @@ module Kettle
27
27
  # @param strict [Boolean] when true (default), require coverage and yard data; raise errors if unavailable
28
28
  # @param enforce_coverage_thresholds [Boolean] when true, fail strict coverage generation below project thresholds
29
29
  # @param update_prep [Boolean] when true, update the most recent prepared release section in place
30
+ # @param reformat_only [Boolean] when true, normalize structure without release-state planning
31
+ # @param historical_backfill_version [String, nil] tagged release to document without moving Unreleased entries
30
32
  # @param version [String, nil] explicit version override for gems without a literal VERSION constant
31
33
  # @param yes [Boolean] when true, approve the selected release plan without prompting
32
- def initialize(strict: true, enforce_coverage_thresholds: true, update_prep: false, version: nil, root: Kettle::Dev::CIHelpers.project_root, refresh_cache: false, yes: false, event_stream: nil)
34
+ def initialize(strict: true, enforce_coverage_thresholds: true, update_prep: false, reformat_only: false, historical_backfill_version: nil, version: nil, root: Kettle::Dev::CIHelpers.project_root, refresh_cache: false, yes: false, event_stream: nil)
33
35
  @root = root
34
36
  @changelog_path = resolved_changelog_path
35
37
  @coverage_root = resolved_coverage_root
@@ -37,6 +39,8 @@ module Kettle
37
39
  @strict = strict
38
40
  @enforce_coverage_thresholds = enforce_coverage_thresholds
39
41
  @update_prep = update_prep
42
+ @reformat_only = reformat_only
43
+ @historical_backfill_version = Kettle::Dev::Versioning.normalize_explicit_version(historical_backfill_version)
40
44
  @version_override = Kettle::Dev::Versioning.normalize_explicit_version(version)
41
45
  @refresh_cache = refresh_cache
42
46
  @yes = !!yes
@@ -50,6 +54,17 @@ module Kettle
50
54
  #
51
55
  # @return [void]
52
56
  def run
57
+ if @reformat_only
58
+ reformat_changelog!(File.read(@changelog_path))
59
+ emit_changelog_event(action: "reformat", status: "ok", plan: "reformat_only")
60
+ return
61
+ end
62
+
63
+ if @historical_backfill_version
64
+ backfill_historical_release!(@historical_backfill_version)
65
+ return
66
+ end
67
+
53
68
  version = detect_version
54
69
  today = Time.now.strftime("%Y-%m-%d")
55
70
  owner, repo = Kettle::Dev::CIHelpers.repo_info
@@ -67,6 +82,12 @@ module Kettle
67
82
  return
68
83
  end
69
84
 
85
+ if plan.fetch(:action) == :rollback_prepared_release
86
+ rollback_prepared_release!(changelog, owner, repo, version)
87
+ emit_changelog_event(action: "rollback", status: "ok", version: plan.fetch(:latest_changelog_version), plan: "rollback_prepared_release")
88
+ return
89
+ end
90
+
70
91
  line_cov_line, branch_cov_line = coverage_lines
71
92
  yard_line = yard_percent_documented
72
93
 
@@ -300,21 +321,23 @@ module Kettle
300
321
  abort("Aborting: version.rb (#{version}) is lower than the latest released version for this release line (#{latest_target}).")
301
322
  end
302
323
 
303
- if section_exists && latest_changelog_version != version
324
+ if latest_target == version && latest_changelog_version && Gem::Version.new(latest_changelog_version) > Gem::Version.new(version)
325
+ action = :rollback_prepared_release
326
+ elsif section_exists && latest_changelog_version != version
304
327
  abort("Aborting: CHANGELOG.md already contains a #{version} section, but the most recent release section is #{latest_changelog_version || "missing"}.")
305
- end
306
-
307
- action = if section_exists && latest_target == version
308
- if unreleased_block_has_entries?(unreleased_block)
309
- abort("Aborting: version.rb (#{version}) matches the latest released version for this release line (#{latest_target}); bump version.rb before moving Unreleased entries into a release section.")
310
- end
311
- :reformat_only
312
- elsif section_exists
313
- :update_prepared_release
314
- elsif latest_target == version
315
- abort("Aborting: version.rb (#{version}) matches the latest released version, but CHANGELOG.md does not have #{version} as the most recent release section.")
316
328
  else
317
- :new_release
329
+ action = if section_exists && latest_target == version
330
+ if unreleased_block_has_entries?(unreleased_block)
331
+ abort("Aborting: version.rb (#{version}) matches the latest released version for this release line (#{latest_target}); bump version.rb before moving Unreleased entries into a release section.")
332
+ end
333
+ :reformat_only
334
+ elsif section_exists
335
+ :update_prepared_release
336
+ elsif latest_target == version
337
+ abort("Aborting: version.rb (#{version}) matches the latest released version, but CHANGELOG.md does not have #{version} as the most recent release section.")
338
+ else
339
+ :new_release
340
+ end
318
341
  end
319
342
 
320
343
  {
@@ -396,6 +419,8 @@ module Kettle
396
419
 
397
420
  def reformat_changelog!(changelog)
398
421
  updated = convert_heading_tag_suffix_to_list(changelog)
422
+ updated = normalize_legacy_release_headings(updated)
423
+ updated = remove_redundant_historical_placeholders(updated)
399
424
  updated = normalize_heading_spacing(updated)
400
425
  updated = ensure_footer_spacing(updated)
401
426
  updated = updated.rstrip + "\n"
@@ -403,6 +428,86 @@ module Kettle
403
428
  puts "CHANGELOG.md reformatted. No new version section added."
404
429
  end
405
430
 
431
+ def normalize_legacy_release_headings(changelog)
432
+ changelog.to_s.lines.map do |line|
433
+ match = line.match(/^##\s+(?!\[)(v?\d+(?:\.\d+)+(?:[.-][0-9A-Za-z]+)?)(\s+-\s+.*)?\s*$/)
434
+ next line unless match
435
+
436
+ version = match[1].delete_prefix("v")
437
+ next line unless Gem::Version.correct?(version)
438
+
439
+ "## [#{version}]#{match[2]}\n"
440
+ end.join
441
+ end
442
+
443
+ def remove_redundant_historical_placeholders(changelog)
444
+ lines = changelog.to_s.lines
445
+ sections = changelog_release_sections(lines)
446
+ removals = sections.group_by { |section| section.fetch(:version) }.values.flat_map do |same_version|
447
+ next [] unless same_version.size > 1
448
+ next [] unless same_version.any? { |section| !section.fetch(:placeholder) }
449
+
450
+ same_version.filter_map do |section|
451
+ (section.fetch(:start)...section.fetch(:finish)) if section.fetch(:placeholder)
452
+ end
453
+ end
454
+ return changelog if removals.empty?
455
+
456
+ lines.each_with_index.reject { |_line, index| removals.any? { |range| range.cover?(index) } }.map(&:first).join
457
+ end
458
+
459
+ def changelog_release_sections(lines)
460
+ headings = lines.each_index.filter_map do |index|
461
+ match = lines[index].match(/^## \[(#{CHANGELOG_VERSION_PATTERN_SOURCE})\]/o)
462
+ {version: match[1], start: index} if match
463
+ end
464
+ headings.each_with_index.map do |heading, index|
465
+ next_heading = headings[index + 1]
466
+ footer = lines.each_index.find do |line_index|
467
+ line_index > heading.fetch(:start) && lines[line_index].start_with?(UNRELEASED_SECTION_HEADING)
468
+ end
469
+ finish = [next_heading&.fetch(:start), footer, lines.length].compact.min
470
+ body = lines[heading.fetch(:start)...finish].join
471
+ heading.merge(finish: finish, placeholder: body.include?("Historical release notes are unavailable in this changelog."))
472
+ end
473
+ end
474
+
475
+ def backfill_historical_release!(version)
476
+ changelog = File.read(@changelog_path)
477
+ abort("CHANGELOG.md already contains a section for #{version}") if release_section_exists?(changelog, version)
478
+
479
+ unreleased_block, before, after = extract_unreleased(changelog)
480
+ abort("Could not find '## [Unreleased]' section in CHANGELOG.md") if unreleased_block.nil?
481
+
482
+ date = tagged_release_date(version)
483
+ owner, repo = Kettle::Dev::CIHelpers.repo_info
484
+ abort("Could not determine GitHub owner/repo from origin remote") unless owner && repo
485
+
486
+ section = <<~MD
487
+ ## [#{version}] - #{date}
488
+ - TAG: [v#{version}][#{version}t]
489
+
490
+ ### Changed
491
+
492
+ - Historical release notes are unavailable in this changelog.
493
+
494
+ MD
495
+ updated = before + "## [Unreleased]\n" + unreleased_block.rstrip + "\n\n" + section + after.to_s
496
+ updated = update_link_refs(updated, owner, repo, nil, version)
497
+ updated = normalize_heading_spacing(updated).rstrip + "\n"
498
+ File.write(@changelog_path, updated)
499
+ emit_changelog_event(action: "backfill", status: "ok", version: version, plan: "historical_release")
500
+ puts "CHANGELOG.md backfilled with historical v#{version} section. Unreleased entries were preserved."
501
+ end
502
+
503
+ def tagged_release_date(version)
504
+ tag = "v#{version}"
505
+ output, status = Open3.capture2("git", "log", "-1", "--format=%as", tag, chdir: @root)
506
+ abort("Local git tag #{tag} is required to backfill a historical release section") unless status.success? && !output.strip.empty?
507
+
508
+ output.strip
509
+ end
510
+
406
511
  def release_section_exists?(changelog, version)
407
512
  changelog.match?(/^## \[#{Regexp.escape(version)}\]/)
408
513
  end
@@ -562,6 +667,25 @@ module Kettle
562
667
  puts "CHANGELOG.md updated in place for v#{prepared_version}."
563
668
  end
564
669
 
670
+ def rollback_prepared_release!(changelog, owner, repo, published_version)
671
+ unreleased_block, before, after = extract_unreleased(changelog)
672
+ release_lines = after.to_s.lines
673
+ heading = release_lines.first.to_s.match(/\A## \[(#{CHANGELOG_VERSION_PATTERN_SOURCE})\]/o)
674
+ abort("Could not find a prepared release section after '## [Unreleased]' in CHANGELOG.md") unless heading
675
+
676
+ finish = release_lines.each_index.drop(1).find do |index|
677
+ release_lines[index].start_with?("## [", UNRELEASED_SECTION_HEADING)
678
+ end || release_lines.length
679
+ prepared_version = heading[1]
680
+ merged = merge_release_body_with_unreleased(release_lines[1...finish].join, unreleased_block)
681
+ unreleased = "## [Unreleased]\n" + merged.rstrip + "\n\n"
682
+ tail = release_lines[finish, release_lines.length].to_a.reject { |line| line.start_with?("[#{prepared_version}]:", "[#{prepared_version}t]:") }.join
683
+ updated = before + unreleased + tail
684
+ updated = update_link_refs(updated, owner, repo, nil, published_version)
685
+ File.write(@changelog_path, normalize_heading_spacing(updated).rstrip + "\n")
686
+ puts "CHANGELOG.md rolled back prepared v#{prepared_version} into Unreleased."
687
+ end
688
+
565
689
  def merge_release_body_with_unreleased(release_body, unreleased_block)
566
690
  existing = strip_release_metadata(release_body)
567
691
  incoming = filter_unreleased_sections(unreleased_block)
@@ -9,7 +9,7 @@ module Kettle
9
9
  @root = root
10
10
  @section = section.to_s
11
11
  @entry = normalize_entry(entry)
12
- @changelog_path = File.join(@root, "CHANGELOG.md")
12
+ @changelog_path = ENV.fetch("K_CHANGELOG_PATH", File.join(@root, "CHANGELOG.md"))
13
13
  end
14
14
 
15
15
  def run
@@ -68,7 +68,7 @@ module Kettle
68
68
  owner.location.start_line > unreleased.location.start_line &&
69
69
  owner.location.end_line <= unreleased.location.end_line
70
70
  end
71
- raise Error, "expected exactly one #{heading(3, @section)} section under ## [Unreleased] in CHANGELOG.md, found #{matches.length}" unless matches.length == 1
71
+ raise Error, "expected at least one #{heading(3, @section)} section under ## [Unreleased] in CHANGELOG.md, found none" if matches.empty?
72
72
 
73
73
  matches.first
74
74
  end
@@ -436,7 +436,8 @@ module Kettle
436
436
  # 18. create GitHub release (optional)
437
437
  if run_step?(18)
438
438
  version ||= detect_version
439
- maybe_create_github_release!(version)
439
+ created, message = maybe_create_github_release!(version)
440
+ abort("GitHub release creation failed: #{message}") if github_release_required? && !created
440
441
  end
441
442
 
442
443
  # 19. push tags to remotes (final step)
@@ -2340,24 +2341,27 @@ module Kettle
2340
2341
  # Title: v<version>
2341
2342
  # Body: the CHANGELOG section for this version, followed by the two link references for this version.
2342
2343
  def maybe_create_github_release!(version)
2343
- token = ENV.fetch("GITHUB_TOKEN", "").strip.to_s
2344
+ token = github_token
2344
2345
  if token.empty?
2345
- warn("GITHUB_TOKEN not set; skipping GitHub release creation. Set GITHUB_TOKEN with repo:public_repo (classic) or contents:write scope.")
2346
- return
2346
+ message = "GITHUB_TOKEN or GH_TOKEN is not set; skipping GitHub release creation. Set a token with repo:public_repo (classic) or contents:write scope."
2347
+ warn(message)
2348
+ return [false, message]
2347
2349
  end
2348
2350
 
2349
2351
  gh_remote = preferred_github_remote
2350
2352
  url = remote_url(gh_remote || "origin")
2351
2353
  owner, repo = parse_github_owner_repo(url)
2352
2354
  unless owner && repo
2353
- warn("GITHUB_TOKEN present but could not determine GitHub owner/repo from remotes. Skipping release creation.")
2354
- return
2355
+ message = "GitHub token present but could not determine GitHub owner/repo from remotes. Skipping release creation."
2356
+ warn(message)
2357
+ return [false, message]
2355
2358
  end
2356
2359
 
2357
2360
  section, compare_ref, tag_ref = extract_changelog_for_version(version)
2358
2361
  unless section
2359
- warn("CHANGELOG.md does not contain a section for #{version}. Skipping GitHub release creation.")
2360
- return
2362
+ message = "CHANGELOG.md does not contain a section for #{version}. Skipping GitHub release creation."
2363
+ warn(message)
2364
+ return [false, message]
2361
2365
  end
2362
2366
 
2363
2367
  body = +""
@@ -2377,6 +2381,97 @@ module Kettle
2377
2381
  else
2378
2382
  warn("GitHub release creation skipped/failed: #{msg}")
2379
2383
  end
2384
+ [ok, msg]
2385
+ end
2386
+
2387
+ def maybe_update_github_release!(version)
2388
+ token = github_token
2389
+ return [false, "GITHUB_TOKEN or GH_TOKEN is not set"] if token.empty?
2390
+
2391
+ owner, repo = parse_github_owner_repo(remote_url(preferred_github_remote || "origin"))
2392
+ return [false, "could not determine GitHub owner/repo from remotes"] unless owner && repo
2393
+
2394
+ body = github_release_body(version)
2395
+ return [false, "CHANGELOG.md does not contain a section for #{version}"] unless body
2396
+
2397
+ github_update_release(owner: owner, repo: repo, token: token, tag: "v#{version}", title: "v#{version}", body: body)
2398
+ end
2399
+
2400
+ def github_release_body(version)
2401
+ section, compare_ref, tag_ref = extract_changelog_for_version(version)
2402
+ return unless section
2403
+
2404
+ body = +section.rstrip
2405
+ body << "\n\n" << compare_ref if compare_ref
2406
+ body << tag_ref if tag_ref
2407
+ footer = extract_release_notes_footer
2408
+ body << "\n" << footer if footer && !footer.strip.empty?
2409
+ body
2410
+ end
2411
+
2412
+ # Validate the immutable inputs required to backfill a GitHub Release.
2413
+ # This must never create a tag or publish a gem as a side effect.
2414
+ def github_release_backfill_check(version, require_rubygems: true)
2415
+ normalized_version = version.to_s.delete_prefix("v")
2416
+ tag = "v#{normalized_version}"
2417
+ diagnostics = []
2418
+ token = github_token
2419
+ diagnostics << "GITHUB_TOKEN is not set" if token.empty?
2420
+
2421
+ gem_name = detect_gem_name
2422
+ if require_rubygems && !rubygems_version_published?(gem_name, normalized_version)
2423
+ diagnostics << "RubyGems.org does not list #{gem_name} #{normalized_version}"
2424
+ end
2425
+
2426
+ remote = preferred_github_remote
2427
+ if remote.nil?
2428
+ diagnostics << "no GitHub remote is configured"
2429
+ elsif !remote_tag_exists?(remote, tag)
2430
+ diagnostics << "remote #{remote.inspect} does not contain tag #{tag}"
2431
+ end
2432
+
2433
+ section, = extract_changelog_for_version(normalized_version)
2434
+ diagnostics << "CHANGELOG.md does not contain a section for #{normalized_version}" unless section
2435
+
2436
+ {
2437
+ "ok" => diagnostics.empty?,
2438
+ "version" => normalized_version,
2439
+ "tag" => tag,
2440
+ "gem_name" => gem_name,
2441
+ "remote" => remote,
2442
+ "diagnostics" => diagnostics
2443
+ }
2444
+ end
2445
+
2446
+ def github_release_token_configured?
2447
+ !github_token.empty?
2448
+ end
2449
+
2450
+ def github_release_required?
2451
+ github_release_token_configured? && truthy_value?(ENV["KETTLE_RELEASE_REQUIRE_GITHUB_RELEASE"])
2452
+ end
2453
+
2454
+ def github_token
2455
+ token = ENV.fetch("GITHUB_TOKEN", "").strip
2456
+ return token unless token.empty?
2457
+
2458
+ ENV.fetch("GH_TOKEN", "").strip
2459
+ end
2460
+
2461
+ def rubygems_version_published?(gem_name, version)
2462
+ versions = Kettle::Dev::RubyGemsVersions.fetch(gem_name, version_hint: version, refresh: true)
2463
+ Array(versions).any? { |entry| entry.is_a?(Hash) && entry["number"].to_s == version.to_s }
2464
+ rescue => error
2465
+ warn("Could not verify RubyGems.org publication for #{gem_name} #{version}: #{error.class}: #{error.message}")
2466
+ false
2467
+ end
2468
+
2469
+ def remote_tag_exists?(remote, tag)
2470
+ _stdout, _stderr, status = Open3.capture3("git", "ls-remote", "--exit-code", "--tags", remote, "refs/tags/#{tag}", chdir: @root)
2471
+ status.success?
2472
+ rescue SystemCallError => error
2473
+ warn("Could not verify remote tag #{tag}: #{error.class}: #{error.message}")
2474
+ false
2380
2475
  end
2381
2476
 
2382
2477
  # Returns [section_text, compare_ref_line, tag_ref_line]
@@ -2465,6 +2560,28 @@ module Kettle
2465
2560
  rescue => e
2466
2561
  [false, "#{e.class}: #{e.message}"]
2467
2562
  end
2563
+
2564
+ def github_update_release(owner:, repo:, token:, tag:, title:, body:)
2565
+ lookup = URI("https://api.github.com/repos/#{owner}/#{repo}/releases/tags/#{tag}")
2566
+ get = Net::HTTP::Get.new(lookup)
2567
+ get["Accept"] = "application/vnd.github+json"
2568
+ get["Authorization"] = "token #{token}"
2569
+ get["User-Agent"] = "kettle-dev-release-cli"
2570
+ release = Net::HTTP.start(lookup.host, lookup.port, use_ssl: true) { |http| http.request(get) }
2571
+ return [false, "release #{tag} was not found (HTTP #{release.code})"] unless release.is_a?(Net::HTTPSuccess)
2572
+
2573
+ id = JSON.parse(release.body).fetch("id")
2574
+ uri = URI("https://api.github.com/repos/#{owner}/#{repo}/releases/#{id}")
2575
+ request = Net::HTTP::Patch.new(uri)
2576
+ request["Accept"] = "application/vnd.github+json"
2577
+ request["Authorization"] = "token #{token}"
2578
+ request["User-Agent"] = "kettle-dev-release-cli"
2579
+ request.body = JSON.dump(name: title, body: body)
2580
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
2581
+ response.is_a?(Net::HTTPSuccess) ? [true, "updated"] : [false, "HTTP #{response.code}: #{response.body}"]
2582
+ rescue => e
2583
+ [false, "#{e.class}: #{e.message}"]
2584
+ end
2468
2585
  end
2469
2586
  end
2470
2587
  end
@@ -5,7 +5,7 @@ module Kettle
5
5
  # Version namespace for this gem.
6
6
  module Version
7
7
  # Current gem version.
8
- VERSION = "2.5.14"
8
+ VERSION = "2.5.15"
9
9
  end
10
10
  # Current gem version exposed at the traditional constant location.
11
11
  VERSION = Version::VERSION # Traditional Constant Location
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kettle-dev
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.5.14
4
+ version: 2.5.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -256,7 +256,7 @@ dependencies:
256
256
  version: '3.2'
257
257
  - - ">="
258
258
  - !ruby/object:Gem::Version
259
- version: 3.2.3
259
+ version: 3.2.4
260
260
  type: :development
261
261
  prerelease: false
262
262
  version_requirements: !ruby/object:Gem::Requirement
@@ -266,7 +266,7 @@ dependencies:
266
266
  version: '3.2'
267
267
  - - ">="
268
268
  - !ruby/object:Gem::Version
269
- version: 3.2.3
269
+ version: 3.2.4
270
270
  - !ruby/object:Gem::Dependency
271
271
  name: stone_checksums
272
272
  requirement: !ruby/object:Gem::Requirement
@@ -276,7 +276,7 @@ dependencies:
276
276
  version: '1.0'
277
277
  - - ">="
278
278
  - !ruby/object:Gem::Version
279
- version: 1.0.7
279
+ version: 1.0.8
280
280
  type: :development
281
281
  prerelease: false
282
282
  version_requirements: !ruby/object:Gem::Requirement
@@ -286,7 +286,7 @@ dependencies:
286
286
  version: '1.0'
287
287
  - - ">="
288
288
  - !ruby/object:Gem::Version
289
- version: 1.0.7
289
+ version: 1.0.8
290
290
  - !ruby/object:Gem::Dependency
291
291
  name: gitmoji-regex
292
292
  requirement: !ruby/object:Gem::Requirement
@@ -296,7 +296,7 @@ dependencies:
296
296
  version: '2.0'
297
297
  - - ">="
298
298
  - !ruby/object:Gem::Version
299
- version: 2.0.10
299
+ version: 2.0.11
300
300
  type: :development
301
301
  prerelease: false
302
302
  version_requirements: !ruby/object:Gem::Requirement
@@ -306,7 +306,7 @@ dependencies:
306
306
  version: '2.0'
307
307
  - - ">="
308
308
  - !ruby/object:Gem::Version
309
- version: 2.0.10
309
+ version: 2.0.11
310
310
  description: "\U0001F372 Kettle::Dev is a meta tool from kettle-dev to streamline
311
311
  development and testing. Acts as a shim dependency, pulling in many other dependencies,
312
312
  to give you OOTB productivity with a RubyGem, or Ruby app project. Configures a
@@ -401,10 +401,10 @@ licenses:
401
401
  - AGPL-3.0-only
402
402
  metadata:
403
403
  homepage_uri: https://kettle-dev.galtzo.com
404
- source_code_uri: https://github.com/kettle-dev/kettle-dev/tree/v2.5.14
405
- changelog_uri: https://github.com/kettle-dev/kettle-dev/blob/v2.5.14/CHANGELOG.md
404
+ source_code_uri: https://github.com/kettle-dev/kettle-dev/tree/v2.5.15
405
+ changelog_uri: https://github.com/kettle-dev/kettle-dev/blob/v2.5.15/CHANGELOG.md
406
406
  bug_tracker_uri: https://github.com/kettle-dev/kettle-dev/issues
407
- documentation_uri: https://www.rubydoc.info/gems/kettle-dev/2.5.14
407
+ documentation_uri: https://www.rubydoc.info/gems/kettle-dev/2.5.15
408
408
  funding_uri: https://github.com/sponsors/pboling
409
409
  wiki_uri: https://github.com/kettle-dev/kettle-dev/wiki
410
410
  news_uri: https://www.railsbling.com/tags/kettle-dev
metadata.gz.sig CHANGED
Binary file