kettle-dev 2.5.14 → 2.5.16

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: 8abbbf030dfd2052366ffd4f0fbbd27bc42973a9e0b71b960a391184df41793f
4
+ data.tar.gz: d1f091974575358acbdd459c9bae2083a7a799c2ca9263604d5f3777aac0de59
5
5
  SHA512:
6
- metadata.gz: 91ae2809e3092c93f6feaa5b20f4dbc562497917244f95f388ababfe72403f9af76f924314a561c85dc39f8e0f987ed4d19580675612bb01c117fa1e8f1be58e
7
- data.tar.gz: 0c77a71e021fe4657903384d45ecf8da00c136b78bc0a6cb7531ecc584606bb9fa7abd0ad7a683ea844ee6d3a0be59ecfe388c3cdce1dafbf65989a72b4b6063
6
+ metadata.gz: 3c348fce5b060edfc8b87e3a51c07e60fece4ee9060faf14b50063b0e70fa20ba1c2e4e4068e58b12c16815e78a65efbe9cc5047e854632d54c29758ba407b9a
7
+ data.tar.gz: a4271b730c428703b39a019c466f123b47625a5e2c912f87216e8d4d4942cf4bcaf2a4651fd5f52e9397cca15d41d240a0704328d651f22f58b0165b9f624a88
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,65 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [2.5.16] - 2026-08-01
34
+
35
+ - TAG: [v2.5.16][2.5.16t]
36
+ - COVERAGE: 91.43% -- 5291/5787 lines in 46 files
37
+ - BRANCH COVERAGE: 75.66% -- 2002/2646 branches in 46 files
38
+ - 50.41% documented
39
+
40
+ ### Changed
41
+
42
+ - kettle-jem-template-20260801-001 - Generated README gem dashboard links now
43
+ use ClickGems instead of BestGems.
44
+
45
+ ### Fixed
46
+
47
+ - Pre-release image validation now permits a current-repository workflow badge when its workflow exists locally and awaits the release push.
48
+
49
+ - Release changelog coverage runs now enforce the checked-in coverage workflow line and branch thresholds before pushing.
50
+
51
+ - kettle-jem-template-20260801-002 - Generated RSpec helpers now normalize
52
+ managed configuration block bindings structurally, preventing mixed block
53
+ parameter names from producing invalid configuration after a merge.
54
+ - kettle-jem-template-20260801-003 - Generated project metadata and
55
+ documentation now normalize configured underscore hostnames to valid
56
+ hyphenated hostnames.
57
+ - kettle-jem-template-20260801-004 - Generated organization README logos now
58
+ use GitHub's stable organization avatar endpoint instead of assuming a
59
+ matching Galtzo-hosted asset exists.
60
+
61
+ - Mark modern changelog coverage-threshold tooling specs pending on legacy Ruby Appraisals.
62
+
63
+ ## [2.5.15] - 2026-08-01
64
+
65
+ - TAG: [v2.5.15][2.5.15t]
66
+ - COVERAGE: 91.55% -- 5266/5752 lines in 46 files
67
+ - BRANCH COVERAGE: 75.74% -- 1986/2622 branches in 46 files
68
+ - 50.41% documented
69
+
70
+ ### Added
71
+
72
+ - Add verified GitHub Release backfill checks and NDJSON support to kettle-gh-release.
73
+
74
+ - Add a changelog reformat mode for historical release metadata repair.
75
+
76
+ - Add historical changelog reformat and GitHub release reconciliation checks.
77
+
78
+ - Add an explicit historical release-section backfill mode for tagged releases.
79
+
80
+ - Add kettle-gh-release update mode for existing GitHub Release notes.
81
+
82
+ ### Fixed
83
+
84
+ - Allow the changelog entry writer to add to malformed Unreleased sections with duplicate headings.
85
+
86
+ - Honor K_CHANGELOG_PATH when adding an Unreleased changelog entry.
87
+
88
+ - Canonicalize legacy release headings and remove redundant historical placeholders during reformat.
89
+
90
+ - Roll back unpublished prepared changelog sections when version.rb returns to the published version.
91
+
33
92
  ## [2.5.14] - 2026-07-31
34
93
 
35
94
  - TAG: [v2.5.14][2.5.14t]
@@ -3173,7 +3232,11 @@ Please file a bug if you notice a violation of semantic versioning.
3173
3232
  - Selecting will run the selected workflow via `act`
3174
3233
  - This may move to its own gem in the future.
3175
3234
 
3176
- [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.14...HEAD
3235
+ [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.16...HEAD
3236
+ [2.5.16]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.15...v2.5.16
3237
+ [2.5.16t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v2.5.16
3238
+ [2.5.15]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.14...v2.5.15
3239
+ [2.5.15t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v2.5.15
3177
3240
  [2.5.14]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.13...v2.5.14
3178
3241
  [2.5.14t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v2.5.14
3179
3242
  [2.5.13]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.12...v2.5.13
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
- <a href="https://github.com/kettle-dev"><img alt="kettle-dev Logo by Aboling0, CC BY-SA 4.0" src="https://logos.galtzo.com/assets/images/kettle-dev/avatar-128px.svg" width="14%" align="right"/></a>
1
+ <a href="https://github.com/kettle-dev"><img alt="kettle-dev Logo by GitHub" src="https://github.com/kettle-dev.png?size=192" width="14%" align="right"/></a>
2
2
 
3
3
  # 🍲 Kettle::Dev
4
4
 
5
- [![Version][👽versioni]][👽version] [![Ruby Users Forum][✉️ruby-forum-top-img]][✉️ruby-forum] [![GitHub tag (latest SemVer)][⛳️tag-img]][⛳️tag] [![License: AGPL-3.0-only][📄license-img]][📄license] [![Downloads Rank][👽dl-ranki]][👽dl-rank] [![CodeCov Test Coverage][🏀codecovi]][🏀codecov] [![Coveralls Test Coverage][🏀coveralls-img]][🏀coveralls] [![QLTY Test Coverage][🏀qlty-covi]][🏀qlty-cov] [![QLTY Maintainability][🏀qlty-mnti]][🏀qlty-mnt] [![CI Heads][🚎3-hd-wfi]][🚎3-hd-wf] [![CI Runtime Dependencies @ HEAD][🚎12-crh-wfi]][🚎12-crh-wf] [![CI Current][🚎11-c-wfi]][🚎11-c-wf] [![CI Truffle Ruby][🚎9-t-wfi]][🚎9-t-wf] [![CI JRuby][🚎10-j-wfi]][🚎10-j-wf] [![Deps Locked][🚎13-🔒️-wfi]][🚎13-🔒️-wf] [![Deps Unlocked][🚎14-🔓️-wfi]][🚎14-🔓️-wf] [![CI Test Coverage][🚎2-cov-wfi]][🚎2-cov-wf] [![CI Style][🚎5-st-wfi]][🚎5-st-wf]
5
+ [![Version][👽versioni]][👽version] [![Ruby Users Forum][✉️ruby-forum-top-img]][✉️ruby-forum] [![GitHub tag (latest SemVer)][⛳️tag-img]][⛳️tag] [![License: AGPL-3.0-only][📄license-img]][📄license] [![Total downloads][👽dl-ranki]][👽dl-rank] [![CodeCov Test Coverage][🏀codecovi]][🏀codecov] [![Coveralls Test Coverage][🏀coveralls-img]][🏀coveralls] [![QLTY Test Coverage][🏀qlty-covi]][🏀qlty-cov] [![QLTY Maintainability][🏀qlty-mnti]][🏀qlty-mnt] [![CI Heads][🚎3-hd-wfi]][🚎3-hd-wf] [![CI Runtime Dependencies @ HEAD][🚎12-crh-wfi]][🚎12-crh-wf] [![CI Current][🚎11-c-wfi]][🚎11-c-wf] [![CI Truffle Ruby][🚎9-t-wfi]][🚎9-t-wf] [![CI JRuby][🚎10-j-wfi]][🚎10-j-wf] [![Deps Locked][🚎13-🔒️-wfi]][🚎13-🔒️-wf] [![Deps Unlocked][🚎14-🔓️-wfi]][🚎14-🔓️-wf] [![CI Test Coverage][🚎2-cov-wfi]][🚎2-cov-wf] [![CI Style][🚎5-st-wfi]][🚎5-st-wf]
6
6
 
7
7
  `if ci_badges.map(&:color).detect { it != "green"}` ☝️ [let me know on Discord][✉️discord-invite] or [RubyForum][✉️ruby-forum], as I may have missed the notification.
8
8
 
@@ -107,11 +107,12 @@ out to `kettle-release` for each member that needs publishing.
107
107
 
108
108
  ### Compatibility
109
109
 
110
- Compatible with MRI Ruby 2.4.0+, and concordant releases of JRuby, and TruffleRuby.
111
- CI workflows and Appraisals are generated for MRI Ruby 2.4+.
112
- This test floor is configured by `ruby.test_minimum` in `.kettle-jem.yml` and
113
- may be higher than the gem's runtime compatibility floor when legacy Rubies are
114
- not practical for the current toolchain.
110
+ The gemspec supports installation on MRI Ruby 2.4.0+ and concordant releases
111
+ of JRuby and TruffleRuby so dependent gems can retain their own legacy runtime
112
+ floors. Kettle Dev's command-line and developer tooling is developed and
113
+ supported on current Ruby releases. CI/Appraisal jobs still exercise the legacy
114
+ installation floor, while specs that require current tooling are explicitly
115
+ pending there via `rspec-pending_for`.
115
116
 
116
117
  <a href="https://github.com/kettle-dev"><img alt="kettle-dev Logo by Aboling0, CC BY-SA 4.0" src="https://logos.galtzo.com/assets/images/kettle-dev/avatar-128px.svg" width="14%" align="right"/></a>
117
118
 
@@ -120,15 +121,15 @@ The _amazing_ test matrix is powered by the kettle-dev stack.
120
121
  <details markdown="1">
121
122
  <summary>How kettle-dev manages complexity in tests</summary>
122
123
 
123
- | Gem | Source | Role | Daily download rank |
124
+ | Gem | Source | Role | Total downloads |
124
125
  |-----|--------|------|---------------------|
125
- | [appraisal2](https://bestgems.org/gems/appraisal2) | [GitHub](https://github.com/appraisal-rb/appraisal2) | multi-dependency Appraisal matrix generation | [![Daily download rank for appraisal2](https://img.shields.io/gem/rd/appraisal2.svg?style=flat-square)](https://bestgems.org/gems/appraisal2) |
126
- | [appraisal2-rubocop](https://bestgems.org/gems/appraisal2-rubocop) | [GitHub](https://github.com/appraisal-rb/appraisal2-rubocop) | RuboCop Appraisal generator integration | [![Daily download rank for appraisal2-rubocop](https://img.shields.io/gem/rd/appraisal2-rubocop.svg?style=flat-square)](https://bestgems.org/gems/appraisal2-rubocop) |
127
- | [kettle-jem](https://bestgems.org/gems/kettle-jem) | [GitHub](https://github.com/kettle-dev/kettle-jem) | Appraisals & CI workflow templates | [![Daily download rank for kettle-jem](https://img.shields.io/gem/rd/kettle-jem.svg?style=flat-square)](https://bestgems.org/gems/kettle-jem) |
128
- | [kettle-soup-cover](https://bestgems.org/gems/kettle-soup-cover) | [GitHub](https://github.com/kettle-dev/kettle-soup-cover) | SimpleCov coverage policy and reporting | [![Daily download rank for kettle-soup-cover](https://img.shields.io/gem/rd/kettle-soup-cover.svg?style=flat-square)](https://bestgems.org/gems/kettle-soup-cover) |
129
- | [kettle-test](https://bestgems.org/gems/kettle-test) | [GitHub](https://github.com/kettle-dev/kettle-test) | standard test runner and coverage harness | [![Daily download rank for kettle-test](https://img.shields.io/gem/rd/kettle-test.svg?style=flat-square)](https://bestgems.org/gems/kettle-test) |
130
- | [rubocop-lts](https://bestgems.org/gems/rubocop-lts) | [GitHub](https://github.com/rubocop-lts/rubocop-lts) | Ruby-version-aware linting | [![Daily download rank for rubocop-lts](https://img.shields.io/gem/rd/rubocop-lts.svg?style=flat-square)](https://bestgems.org/gems/rubocop-lts) |
131
- | [turbo_tests2](https://bestgems.org/gems/turbo_tests2) | [GitHub](https://github.com/galtzo-floss/turbo_tests2) | parallel test execution | [![Daily download rank for turbo_tests2](https://img.shields.io/gem/rd/turbo_tests2.svg?style=flat-square)](https://bestgems.org/gems/turbo_tests2) |
126
+ | [appraisal2](https://clickgems.clickhouse.com/dashboard/appraisal2) | [GitHub](https://github.com/appraisal-rb/appraisal2) | multi-dependency Appraisal matrix generation | [![Total downloads for appraisal2](https://img.shields.io/gem/dt/appraisal2.svg?style=flat-square)](https://clickgems.clickhouse.com/dashboard/appraisal2) |
127
+ | [appraisal2-rubocop](https://clickgems.clickhouse.com/dashboard/appraisal2-rubocop) | [GitHub](https://github.com/appraisal-rb/appraisal2-rubocop) | RuboCop Appraisal generator integration | [![Total downloads for appraisal2-rubocop](https://img.shields.io/gem/dt/appraisal2-rubocop.svg?style=flat-square)](https://clickgems.clickhouse.com/dashboard/appraisal2-rubocop) |
128
+ | [kettle-jem](https://clickgems.clickhouse.com/dashboard/kettle-jem) | [GitHub](https://github.com/kettle-dev/kettle-jem) | Appraisals & CI workflow templates | [![Total downloads for kettle-jem](https://img.shields.io/gem/dt/kettle-jem.svg?style=flat-square)](https://clickgems.clickhouse.com/dashboard/kettle-jem) |
129
+ | [kettle-soup-cover](https://clickgems.clickhouse.com/dashboard/kettle-soup-cover) | [GitHub](https://github.com/kettle-dev/kettle-soup-cover) | SimpleCov coverage policy and reporting | [![Total downloads for kettle-soup-cover](https://img.shields.io/gem/dt/kettle-soup-cover.svg?style=flat-square)](https://clickgems.clickhouse.com/dashboard/kettle-soup-cover) |
130
+ | [kettle-test](https://clickgems.clickhouse.com/dashboard/kettle-test) | [GitHub](https://github.com/kettle-dev/kettle-test) | standard test runner and coverage harness | [![Total downloads for kettle-test](https://img.shields.io/gem/dt/kettle-test.svg?style=flat-square)](https://clickgems.clickhouse.com/dashboard/kettle-test) |
131
+ | [rubocop-lts](https://clickgems.clickhouse.com/dashboard/rubocop-lts) | [GitHub](https://github.com/rubocop-lts/rubocop-lts) | Ruby-version-aware linting | [![Total downloads for rubocop-lts](https://img.shields.io/gem/dt/rubocop-lts.svg?style=flat-square)](https://clickgems.clickhouse.com/dashboard/rubocop-lts) |
132
+ | [turbo_tests2](https://clickgems.clickhouse.com/dashboard/turbo_tests2) | [GitHub](https://github.com/galtzo-floss/turbo_tests2) | parallel test execution | [![Total downloads for turbo_tests2](https://img.shields.io/gem/dt/turbo_tests2.svg?style=flat-square)](https://clickgems.clickhouse.com/dashboard/turbo_tests2) |
132
133
 
133
134
  </details>
134
135
 
@@ -849,14 +850,14 @@ Thanks for RTFM. ☺️
849
850
  [✉️discord-invite-img-ftb]: https://img.shields.io/discord/1373797679469170758?style=for-the-badge&logo=discord
850
851
  [✉️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
852
  [✉️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
853
+ [✉️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
854
+ [✉️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
855
  [✉️ruby-forum]: https://www.rubyforum.org/tag/kettle-dev
855
856
 
856
857
  [✇bundle-group-pattern]: https://gist.github.com/pboling/4564780
857
858
  [⛳️gem-namespace]: https://github.com/kettle-dev/kettle-dev
858
859
  [⛳️namespace-img]: https://img.shields.io/badge/namespace-Kettle::Dev-3C2D2D.svg?style=square&logo=ruby&logoColor=white
859
- [⛳️gem-name]: https://bestgems.org/gems/kettle-dev
860
+ [⛳️gem-name]: https://clickgems.clickhouse.com/dashboard/kettle-dev
860
861
  [⛳️name-img]: https://img.shields.io/badge/name-kettle--dev-3C2D2D.svg?style=square&logo=rubygems&logoColor=red
861
862
  [⛳️tag-img]: https://img.shields.io/github/tag/kettle-dev/kettle-dev.svg
862
863
  [⛳️tag]: https://github.com/kettle-dev/kettle-dev/releases
@@ -905,9 +906,9 @@ Thanks for RTFM. ☺️
905
906
  [📜gh-wiki]: https://github.com/kettle-dev/kettle-dev/wiki
906
907
  [📜gl-wiki-img]: https://img.shields.io/badge/wiki-gitlab-943CD2.svg?style=for-the-badge&logo=gitlab&logoColor=white
907
908
  [📜gh-wiki-img]: https://img.shields.io/badge/wiki-github-943CD2.svg?style=for-the-badge&logo=github&logoColor=white
908
- [👽dl-rank]: https://bestgems.org/gems/kettle-dev
909
- [👽dl-ranki]: https://img.shields.io/gem/rd/kettle-dev.svg
910
- [👽version]: https://bestgems.org/gems/kettle-dev
909
+ [👽dl-rank]: https://clickgems.clickhouse.com/dashboard/kettle-dev
910
+ [👽dl-ranki]: https://img.shields.io/gem/dt/kettle-dev.svg
911
+ [👽version]: https://clickgems.clickhouse.com/dashboard/kettle-dev
911
912
  [👽versioni]: https://img.shields.io/gem/v/kettle-dev.svg
912
913
  [🏀qlty-mnt]: https://qlty.sh/gh/kettle-dev/projects/kettle-dev
913
914
  [🏀qlty-mnti]: https://qlty.sh/gh/kettle-dev/projects/kettle-dev/maintainability.svg
@@ -1005,7 +1006,7 @@ Thanks for RTFM. ☺️
1005
1006
  [📌gitmoji]: https://gitmoji.dev
1006
1007
  [📌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
1008
  [🧮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
1009
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-5.787-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1009
1010
  [🔐security]: https://github.com/kettle-dev/kettle-dev/blob/main/SECURITY.md
1010
1011
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1011
1012
  [📄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"]
@@ -5,6 +5,7 @@ require "json"
5
5
  require "net/http"
6
6
  require "uri"
7
7
  require "fileutils"
8
+ require "yaml"
8
9
  require "kettle/ndjson"
9
10
 
10
11
  module Kettle
@@ -27,9 +28,11 @@ module Kettle
27
28
  # @param strict [Boolean] when true (default), require coverage and yard data; raise errors if unavailable
28
29
  # @param enforce_coverage_thresholds [Boolean] when true, fail strict coverage generation below project thresholds
29
30
  # @param update_prep [Boolean] when true, update the most recent prepared release section in place
31
+ # @param reformat_only [Boolean] when true, normalize structure without release-state planning
32
+ # @param historical_backfill_version [String, nil] tagged release to document without moving Unreleased entries
30
33
  # @param version [String, nil] explicit version override for gems without a literal VERSION constant
31
34
  # @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)
35
+ 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
36
  @root = root
34
37
  @changelog_path = resolved_changelog_path
35
38
  @coverage_root = resolved_coverage_root
@@ -37,6 +40,8 @@ module Kettle
37
40
  @strict = strict
38
41
  @enforce_coverage_thresholds = enforce_coverage_thresholds
39
42
  @update_prep = update_prep
43
+ @reformat_only = reformat_only
44
+ @historical_backfill_version = Kettle::Dev::Versioning.normalize_explicit_version(historical_backfill_version)
40
45
  @version_override = Kettle::Dev::Versioning.normalize_explicit_version(version)
41
46
  @refresh_cache = refresh_cache
42
47
  @yes = !!yes
@@ -50,6 +55,17 @@ module Kettle
50
55
  #
51
56
  # @return [void]
52
57
  def run
58
+ if @reformat_only
59
+ reformat_changelog!(File.read(@changelog_path))
60
+ emit_changelog_event(action: "reformat", status: "ok", plan: "reformat_only")
61
+ return
62
+ end
63
+
64
+ if @historical_backfill_version
65
+ backfill_historical_release!(@historical_backfill_version)
66
+ return
67
+ end
68
+
53
69
  version = detect_version
54
70
  today = Time.now.strftime("%Y-%m-%d")
55
71
  owner, repo = Kettle::Dev::CIHelpers.repo_info
@@ -67,6 +83,12 @@ module Kettle
67
83
  return
68
84
  end
69
85
 
86
+ if plan.fetch(:action) == :rollback_prepared_release
87
+ rollback_prepared_release!(changelog, owner, repo, version)
88
+ emit_changelog_event(action: "rollback", status: "ok", version: plan.fetch(:latest_changelog_version), plan: "rollback_prepared_release")
89
+ return
90
+ end
91
+
70
92
  line_cov_line, branch_cov_line = coverage_lines
71
93
  yard_line = yard_percent_documented
72
94
 
@@ -300,21 +322,23 @@ module Kettle
300
322
  abort("Aborting: version.rb (#{version}) is lower than the latest released version for this release line (#{latest_target}).")
301
323
  end
302
324
 
303
- if section_exists && latest_changelog_version != version
325
+ if latest_target == version && latest_changelog_version && Gem::Version.new(latest_changelog_version) > Gem::Version.new(version)
326
+ action = :rollback_prepared_release
327
+ elsif section_exists && latest_changelog_version != version
304
328
  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
329
  else
317
- :new_release
330
+ action = if section_exists && latest_target == version
331
+ if unreleased_block_has_entries?(unreleased_block)
332
+ 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.")
333
+ end
334
+ :reformat_only
335
+ elsif section_exists
336
+ :update_prepared_release
337
+ elsif latest_target == version
338
+ abort("Aborting: version.rb (#{version}) matches the latest released version, but CHANGELOG.md does not have #{version} as the most recent release section.")
339
+ else
340
+ :new_release
341
+ end
318
342
  end
319
343
 
320
344
  {
@@ -396,6 +420,8 @@ module Kettle
396
420
 
397
421
  def reformat_changelog!(changelog)
398
422
  updated = convert_heading_tag_suffix_to_list(changelog)
423
+ updated = normalize_legacy_release_headings(updated)
424
+ updated = remove_redundant_historical_placeholders(updated)
399
425
  updated = normalize_heading_spacing(updated)
400
426
  updated = ensure_footer_spacing(updated)
401
427
  updated = updated.rstrip + "\n"
@@ -403,6 +429,86 @@ module Kettle
403
429
  puts "CHANGELOG.md reformatted. No new version section added."
404
430
  end
405
431
 
432
+ def normalize_legacy_release_headings(changelog)
433
+ changelog.to_s.lines.map do |line|
434
+ match = line.match(/^##\s+(?!\[)(v?\d+(?:\.\d+)+(?:[.-][0-9A-Za-z]+)?)(\s+-\s+.*)?\s*$/)
435
+ next line unless match
436
+
437
+ version = match[1].delete_prefix("v")
438
+ next line unless Gem::Version.correct?(version)
439
+
440
+ "## [#{version}]#{match[2]}\n"
441
+ end.join
442
+ end
443
+
444
+ def remove_redundant_historical_placeholders(changelog)
445
+ lines = changelog.to_s.lines
446
+ sections = changelog_release_sections(lines)
447
+ removals = sections.group_by { |section| section.fetch(:version) }.values.flat_map do |same_version|
448
+ next [] unless same_version.size > 1
449
+ next [] unless same_version.any? { |section| !section.fetch(:placeholder) }
450
+
451
+ same_version.filter_map do |section|
452
+ (section.fetch(:start)...section.fetch(:finish)) if section.fetch(:placeholder)
453
+ end
454
+ end
455
+ return changelog if removals.empty?
456
+
457
+ lines.each_with_index.reject { |_line, index| removals.any? { |range| range.cover?(index) } }.map(&:first).join
458
+ end
459
+
460
+ def changelog_release_sections(lines)
461
+ headings = lines.each_index.filter_map do |index|
462
+ match = lines[index].match(/^## \[(#{CHANGELOG_VERSION_PATTERN_SOURCE})\]/o)
463
+ {version: match[1], start: index} if match
464
+ end
465
+ headings.each_with_index.map do |heading, index|
466
+ next_heading = headings[index + 1]
467
+ footer = lines.each_index.find do |line_index|
468
+ line_index > heading.fetch(:start) && lines[line_index].start_with?(UNRELEASED_SECTION_HEADING)
469
+ end
470
+ finish = [next_heading&.fetch(:start), footer, lines.length].compact.min
471
+ body = lines[heading.fetch(:start)...finish].join
472
+ heading.merge(finish: finish, placeholder: body.include?("Historical release notes are unavailable in this changelog."))
473
+ end
474
+ end
475
+
476
+ def backfill_historical_release!(version)
477
+ changelog = File.read(@changelog_path)
478
+ abort("CHANGELOG.md already contains a section for #{version}") if release_section_exists?(changelog, version)
479
+
480
+ unreleased_block, before, after = extract_unreleased(changelog)
481
+ abort("Could not find '## [Unreleased]' section in CHANGELOG.md") if unreleased_block.nil?
482
+
483
+ date = tagged_release_date(version)
484
+ owner, repo = Kettle::Dev::CIHelpers.repo_info
485
+ abort("Could not determine GitHub owner/repo from origin remote") unless owner && repo
486
+
487
+ section = <<~MD
488
+ ## [#{version}] - #{date}
489
+ - TAG: [v#{version}][#{version}t]
490
+
491
+ ### Changed
492
+
493
+ - Historical release notes are unavailable in this changelog.
494
+
495
+ MD
496
+ updated = before + "## [Unreleased]\n" + unreleased_block.rstrip + "\n\n" + section + after.to_s
497
+ updated = update_link_refs(updated, owner, repo, nil, version)
498
+ updated = normalize_heading_spacing(updated).rstrip + "\n"
499
+ File.write(@changelog_path, updated)
500
+ emit_changelog_event(action: "backfill", status: "ok", version: version, plan: "historical_release")
501
+ puts "CHANGELOG.md backfilled with historical v#{version} section. Unreleased entries were preserved."
502
+ end
503
+
504
+ def tagged_release_date(version)
505
+ tag = "v#{version}"
506
+ output, status = Open3.capture2("git", "log", "-1", "--format=%as", tag, chdir: @root)
507
+ abort("Local git tag #{tag} is required to backfill a historical release section") unless status.success? && !output.strip.empty?
508
+
509
+ output.strip
510
+ end
511
+
406
512
  def release_section_exists?(changelog, version)
407
513
  changelog.match?(/^## \[#{Regexp.escape(version)}\]/)
408
514
  end
@@ -562,6 +668,25 @@ module Kettle
562
668
  puts "CHANGELOG.md updated in place for v#{prepared_version}."
563
669
  end
564
670
 
671
+ def rollback_prepared_release!(changelog, owner, repo, published_version)
672
+ unreleased_block, before, after = extract_unreleased(changelog)
673
+ release_lines = after.to_s.lines
674
+ heading = release_lines.first.to_s.match(/\A## \[(#{CHANGELOG_VERSION_PATTERN_SOURCE})\]/o)
675
+ abort("Could not find a prepared release section after '## [Unreleased]' in CHANGELOG.md") unless heading
676
+
677
+ finish = release_lines.each_index.drop(1).find do |index|
678
+ release_lines[index].start_with?("## [", UNRELEASED_SECTION_HEADING)
679
+ end || release_lines.length
680
+ prepared_version = heading[1]
681
+ merged = merge_release_body_with_unreleased(release_lines[1...finish].join, unreleased_block)
682
+ unreleased = "## [Unreleased]\n" + merged.rstrip + "\n\n"
683
+ tail = release_lines[finish, release_lines.length].to_a.reject { |line| line.start_with?("[#{prepared_version}]:", "[#{prepared_version}t]:") }.join
684
+ updated = before + unreleased + tail
685
+ updated = update_link_refs(updated, owner, repo, nil, published_version)
686
+ File.write(@changelog_path, normalize_heading_spacing(updated).rstrip + "\n")
687
+ puts "CHANGELOG.md rolled back prepared v#{prepared_version} into Unreleased."
688
+ end
689
+
565
690
  def merge_release_body_with_unreleased(release_body, unreleased_block)
566
691
  existing = strip_release_metadata(release_body)
567
692
  incoming = filter_unreleased_sections(unreleased_block)
@@ -791,11 +916,31 @@ module Kettle
791
916
  "K_SOUP_COV_MULTI_FORMATTERS" => "false",
792
917
  "K_SOUP_COV_OPEN_BIN" => ""
793
918
  )
919
+ env.merge!(changelog_coverage_workflow_thresholds)
794
920
  gemfile = File.join(@coverage_root, "Gemfile")
795
921
  env["BUNDLE_GEMFILE"] = gemfile if File.file?(gemfile)
796
922
  env
797
923
  end
798
924
 
925
+ # The changelog coverage run is the final local test gate before a
926
+ # release commit is pushed. Reuse the checked-in CI workflow thresholds
927
+ # rather than silently falling back to kettle-soup-cover defaults.
928
+ def changelog_coverage_workflow_thresholds
929
+ path = ["coverage.yml", "coverage.yaml"]
930
+ .map { |name| File.join(@coverage_root, ".github", "workflows", name) }
931
+ .find { |candidate| File.file?(candidate) }
932
+ return {} unless path
933
+
934
+ workflow = YAML.safe_load_file(path, permitted_classes: [], aliases: false)
935
+ workflow_env = (workflow.is_a?(Hash) && workflow["env"].is_a?(Hash)) ? workflow["env"] : {}
936
+ %w[K_SOUP_COV_MIN_LINE K_SOUP_COV_MIN_BRANCH].each_with_object({}) do |key, thresholds|
937
+ value = workflow_env[key]
938
+ thresholds[key] = value.to_s unless value.nil? || value.to_s.empty?
939
+ end
940
+ rescue Psych::Exception => error
941
+ raise "Unable to read coverage thresholds from #{path}: #{error.message}"
942
+ end
943
+
799
944
  def ensure_changelog_coverage_json!
800
945
  return if File.file?(@coverage_path)
801
946
 
@@ -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
@@ -4,6 +4,7 @@ require "optparse"
4
4
  require "English"
5
5
  require "json"
6
6
  require "fileutils"
7
+ require "open3"
7
8
  require "yaml"
8
9
  require "uri"
9
10
  require "net/http"
@@ -505,11 +506,49 @@ module Kettle
505
506
  end
506
507
 
507
508
  def image_url_skipped?(url)
509
+ return true if github_actions_badge_for_local_workflow?(url)
510
+
508
511
  @image_url_skip_patterns.any? do |pattern|
509
512
  File.fnmatch?(pattern, url, File::FNM_CASEFOLD)
510
513
  end
511
514
  end
512
515
 
516
+ # A template run can add a workflow and its README badge in the same
517
+ # commit. Before that commit is pushed, GitHub correctly returns 404 for
518
+ # the badge even though the local workflow will make it valid.
519
+ def github_actions_badge_for_local_workflow?(url)
520
+ uri = URI.parse(url)
521
+ return false unless uri.host == "github.com"
522
+
523
+ segments = uri.path.split("/").reject(&:empty?)
524
+ return false unless segments.length == 6
525
+ return false unless segments[2, 2] == %w[actions workflows] && segments.last == "badge.svg"
526
+ return false unless local_github_repository_slug == segments.first(2).join("/")
527
+
528
+ File.file?(File.join(".github", "workflows", segments[4]))
529
+ rescue URI::InvalidURIError
530
+ false
531
+ end
532
+
533
+ def local_github_repository_slug
534
+ return @local_github_repository_slug if defined?(@local_github_repository_slug)
535
+
536
+ remote_url, status = Open3.capture2("git", "config", "--get", "remote.origin.url")
537
+ @local_github_repository_slug = status.success? ? github_repository_slug(remote_url) : nil
538
+ end
539
+
540
+ def github_repository_slug(remote_url)
541
+ value = remote_url.to_s.strip
542
+ return value.split(":", 2).last.delete_suffix(".git") if value.start_with?("git@github.com:")
543
+
544
+ uri = URI.parse(value)
545
+ return unless uri.host == "github.com"
546
+
547
+ uri.path.delete_prefix("/").delete_suffix(".git")
548
+ rescue URI::InvalidURIError
549
+ nil
550
+ end
551
+
513
552
  def stringify_keys(value)
514
553
  case value
515
554
  when Hash
@@ -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.16"
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.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -60,7 +60,7 @@ dependencies:
60
60
  version: '0.3'
61
61
  - - ">="
62
62
  - !ruby/object:Gem::Version
63
- version: 0.3.3
63
+ version: 0.3.5
64
64
  type: :runtime
65
65
  prerelease: false
66
66
  version_requirements: !ruby/object:Gem::Requirement
@@ -70,7 +70,7 @@ dependencies:
70
70
  version: '0.3'
71
71
  - - ">="
72
72
  - !ruby/object:Gem::Version
73
- version: 0.3.3
73
+ version: 0.3.5
74
74
  - !ruby/object:Gem::Dependency
75
75
  name: kettle-ndjson
76
76
  requirement: !ruby/object:Gem::Requirement
@@ -80,7 +80,7 @@ dependencies:
80
80
  version: '0.1'
81
81
  - - ">="
82
82
  - !ruby/object:Gem::Version
83
- version: 0.1.9
83
+ version: 0.1.10
84
84
  type: :runtime
85
85
  prerelease: false
86
86
  version_requirements: !ruby/object:Gem::Requirement
@@ -90,7 +90,7 @@ dependencies:
90
90
  version: '0.1'
91
91
  - - ">="
92
92
  - !ruby/object:Gem::Version
93
- version: 0.1.9
93
+ version: 0.1.10
94
94
  - !ruby/object:Gem::Dependency
95
95
  name: kettle-rb
96
96
  requirement: !ruby/object:Gem::Requirement
@@ -100,7 +100,7 @@ dependencies:
100
100
  version: '0.1'
101
101
  - - ">="
102
102
  - !ruby/object:Gem::Version
103
- version: 0.1.7
103
+ version: 0.1.8
104
104
  type: :runtime
105
105
  prerelease: false
106
106
  version_requirements: !ruby/object:Gem::Requirement
@@ -110,7 +110,7 @@ dependencies:
110
110
  version: '0.1'
111
111
  - - ">="
112
112
  - !ruby/object:Gem::Version
113
- version: 0.1.7
113
+ version: 0.1.8
114
114
  - !ruby/object:Gem::Dependency
115
115
  name: kettle-test
116
116
  requirement: !ruby/object:Gem::Requirement
@@ -120,7 +120,7 @@ dependencies:
120
120
  version: '2.0'
121
121
  - - ">="
122
122
  - !ruby/object:Gem::Version
123
- version: 2.0.12
123
+ version: 2.0.18
124
124
  type: :runtime
125
125
  prerelease: false
126
126
  version_requirements: !ruby/object:Gem::Requirement
@@ -130,7 +130,7 @@ dependencies:
130
130
  version: '2.0'
131
131
  - - ">="
132
132
  - !ruby/object:Gem::Version
133
- version: 2.0.12
133
+ version: 2.0.18
134
134
  - !ruby/object:Gem::Dependency
135
135
  name: ruby-progressbar
136
136
  requirement: !ruby/object:Gem::Requirement
@@ -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.16
405
+ changelog_uri: https://github.com/kettle-dev/kettle-dev/blob/v2.5.16/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.16
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