kettle-dev 2.4.0 → 2.4.2

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: dc6dd6b412d11d659d2f5ed0916372e599642a82640335700f3e723937638cba
4
- data.tar.gz: 86114a4e096dd7da0934477c06aee521372d6eeb5f2114377d0a035b32be42f1
3
+ metadata.gz: a251345bb56780a5c5b9efcfb53d394ca469581b5798f10839ac8ed5b4cae682
4
+ data.tar.gz: 4a81578792afe11fd09299ea7d1836d66f0cd8c76af00235491b8234fcec1553
5
5
  SHA512:
6
- metadata.gz: f056c2d67dee284c734c2b0bf5cb06ce957d981cc7c8f922108b2cf1c3b3a8acf5c0c2a1fe8b7b3347ae92c85f780f82ab1e14cf677e12591ece125522bdf967
7
- data.tar.gz: 1c5db4b16afe9840dca06f5a6a1528fe3afa3dadb68b727c2b95c43bfa3240d19625aad892a792000841b42640d07451136f0f87a18f2e711aba5f7107721368
6
+ metadata.gz: 0cfa39888af23e14856d9874d965248c8d68a83b88c42bd3cc722b1a84fe8564b3c1381aa714aaebe0df531822365b70e4f2e7e2eff913f5e781e201e5f49b13
7
+ data.tar.gz: aefab75e861bc0c2132385b4e7411904b01cb6562c82d3485cdbdbb27d407004450b8e0a7030f33abf869bc73df4074f2cbdd1c3ef7abcfb21e9222b5c1c7f9e
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,34 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [2.4.2] - 2026-07-24
34
+
35
+ - TAG: [v2.4.2][2.4.2t]
36
+ - COVERAGE: 92.36% -- 3991/4321 lines in 38 files
37
+ - BRANCH COVERAGE: 76.06% -- 1563/2055 branches in 38 files
38
+ - 59.00% documented
39
+
40
+ ### Fixed
41
+
42
+ - `kettle-release` now validates published gem availability with an isolated
43
+ Bundler probe against gem.coop before continuing past the publish step, and
44
+ uninstalls newly introduced local candidate gems when the release did not
45
+ happen or cannot be validated.
46
+
47
+ ## [2.4.1] - 2026-07-24
48
+
49
+ - TAG: [v2.4.1][2.4.1t]
50
+ - COVERAGE: 92.29% -- 3928/4256 lines in 38 files
51
+ - BRANCH COVERAGE: 76.09% -- 1550/2037 branches in 38 files
52
+ - 58.66% documented
53
+
54
+ ### Fixed
55
+
56
+ - `kettle-release` now normalizes and revalidates release lockfiles after
57
+ mutating setup/appraisal/doc steps, before committing or pushing, so local
58
+ path remotes and locally installed registry gems with empty lockfile
59
+ checksums cannot leak into CI.
60
+
33
61
  ## [2.4.0] - 2026-07-23
34
62
 
35
63
  - TAG: [v2.4.0][2.4.0t]
@@ -2646,7 +2674,11 @@ Please file a bug if you notice a violation of semantic versioning.
2646
2674
  - Selecting will run the selected workflow via `act`
2647
2675
  - This may move to its own gem in the future.
2648
2676
 
2649
- [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v2.4.0...HEAD
2677
+ [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v2.4.2...HEAD
2678
+ [2.4.2]: https://github.com/kettle-dev/kettle-dev/compare/v2.4.1...v2.4.2
2679
+ [2.4.2t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v2.4.2
2680
+ [2.4.1]: https://github.com/kettle-dev/kettle-dev/compare/v2.4.0...v2.4.1
2681
+ [2.4.1t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v2.4.1
2650
2682
  [2.4.0]: https://github.com/kettle-dev/kettle-dev/compare/v2.3.11...v2.4.0
2651
2683
  [2.4.0t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v2.4.0
2652
2684
  [2.3.11]: https://github.com/kettle-dev/kettle-dev/compare/v2.3.10...v2.3.11
data/README.md CHANGED
@@ -891,7 +891,7 @@ Thanks for RTFM. ☺️
891
891
  [📌gitmoji]: https://gitmoji.dev
892
892
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
893
893
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
894
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-4.141-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
894
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-4.321-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
895
895
  [🔐security]: https://github.com/kettle-dev/kettle-dev/blob/main/SECURITY.md
896
896
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
897
897
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -10,6 +10,7 @@ require "net/http"
10
10
  require "json"
11
11
  require "uri"
12
12
  require "yaml"
13
+ require "set"
13
14
 
14
15
  # External gems
15
16
  require "kettle/rb/compat_matrix"
@@ -39,6 +40,8 @@ module Kettle
39
40
  "BUNDLE_SUPPRESS_INSTALL_USING_MESSAGES" => "true"
40
41
  }.freeze
41
42
  DEBUG_TRUE_VALUES = %w[1 true yes on].freeze
43
+ RELEASE_VALIDATION_SOURCE = "https://gem.coop"
44
+
42
45
  class << self
43
46
  def run_cmd!(cmd)
44
47
  # For Bundler-invoked build/release, explicitly prefix SKIP_GEM_SIGNING so
@@ -120,6 +123,7 @@ module Kettle
120
123
  @skip_bundle_audit = truthy_value?(skip_bundle_audit) || truthy_value?(ENV["KETTLE_DEV_SKIP_BUNDLE_AUDIT"])
121
124
  @version_override = Kettle::Dev::Versioning.normalize_explicit_version(version)
122
125
  @appraisal_task = normalize_appraisal_task(appraisal_task || ENV["KETTLE_RELEASE_APPRAISAL_TASK"])
126
+ @release_candidate = nil
123
127
  end
124
128
 
125
129
  def run
@@ -256,6 +260,7 @@ module Kettle
256
260
 
257
261
  # 6. git user + commit release prep
258
262
  if run_step?(6)
263
+ prepare_release_lockfiles_for_commit!
259
264
  ensure_git_user!
260
265
  version ||= detect_version
261
266
  committed = commit_release_prep!(version)
@@ -279,10 +284,16 @@ module Kettle
279
284
  end
280
285
 
281
286
  # 9. push branches
282
- push! if run_step?(9) && !local_ci?
287
+ if run_step?(9) && !local_ci?
288
+ validate_release_lockfiles!(stage: "before push")
289
+ push!
290
+ end
283
291
 
284
292
  # 10. monitor CI after push
285
- monitor_workflows_after_push! if run_step?(10) && !local_ci?
293
+ if run_step?(10) && !local_ci?
294
+ validate_release_lockfiles!(stage: "before CI monitoring")
295
+ monitor_workflows_after_push!
296
+ end
286
297
 
287
298
  # 11. merge feature into trunk and push
288
299
  if run_step?(11) && !local_ci?
@@ -338,13 +349,18 @@ module Kettle
338
349
 
339
350
  # 15. release and tag
340
351
  if run_step?(15)
352
+ version ||= detect_version
353
+ gem_name = detect_gem_name
354
+ @release_candidate = build_release_candidate(gem_name, version)
341
355
  if local_ci?
342
- version ||= detect_version
343
- release_gem_and_tag_locally!(version)
356
+ with_unpublished_candidate_cleanup { release_gem_and_tag_locally!(version) }
344
357
  else
345
358
  puts "Running release (you may be prompted for signing key password and RubyGems MFA OTP)..."
346
- run_cmd!("bundle exec rake release")
347
- mark_gem_coop_release_cache_bust(version || detect_version)
359
+ with_unpublished_candidate_cleanup do
360
+ run_cmd!("bundle exec rake release")
361
+ confirm_release_candidate_available!(@release_candidate)
362
+ end
363
+ mark_gem_coop_release_cache_bust(version)
348
364
  end
349
365
  end
350
366
 
@@ -505,6 +521,187 @@ module Kettle
505
521
  run_changelog!
506
522
  end
507
523
 
524
+ def prepare_release_lockfiles_for_commit!
525
+ dirty_lockfiles = release_lockfile_paths.select { |path| release_lockfile_normalization_needed?(path) }
526
+ unless dirty_lockfiles.empty?
527
+ puts "Normalizing release lockfiles with local path dependencies disabled..."
528
+ dirty_lockfiles.each { |path| normalize_release_lockfile!(path) }
529
+ end
530
+
531
+ validate_release_lockfiles!(stage: "before release prep commit")
532
+ end
533
+
534
+ def validate_release_lockfiles!(stage:)
535
+ diagnostics = release_lockfile_paths.flat_map { |path| release_lockfile_diagnostics(path) }
536
+ return if diagnostics.empty?
537
+
538
+ abort(<<~MSG)
539
+ Release lockfile validation failed #{stage}:
540
+ #{diagnostics.map { |diagnostic| " - #{diagnostic}" }.join("\n")}
541
+ Re-run bundle lock/update with local path development env disabled before releasing.
542
+ MSG
543
+ end
544
+
545
+ def release_lockfile_paths
546
+ candidates = [
547
+ File.join(@root, "Gemfile.lock"),
548
+ File.join(@root, "Appraisal.root.gemfile.lock")
549
+ ]
550
+ candidates.select { |path| File.file?(path) }.sort
551
+ end
552
+
553
+ def release_lockfile_normalization_needed?(path)
554
+ release_lockfile_has_local_path_remote?(path) || !release_lockfile_empty_registry_checksums(path).empty?
555
+ end
556
+
557
+ def normalize_release_lockfile!(path)
558
+ gemfile = release_gemfile_for_lockfile(path)
559
+ unless gemfile && File.file?(gemfile)
560
+ warn("Cannot normalize #{Kettle::Dev.display_path(path)} because its Gemfile was not found.")
561
+ return
562
+ end
563
+
564
+ update_gems = release_lockfile_empty_registry_checksums(path).map(&:first).uniq.sort
565
+ env = release_lockfile_normalization_env.merge("BUNDLE_GEMFILE" => gemfile)
566
+ command = +"env"
567
+ env.each do |key, value|
568
+ command << " #{key}=#{Shellwords.escape(value)}"
569
+ end
570
+ command << " bundle lock"
571
+ command << " --update #{update_gems.map { |gem_name| Shellwords.escape(gem_name) }.join(" ")}" unless update_gems.empty?
572
+ run_cmd!(command)
573
+ end
574
+
575
+ def release_gemfile_for_lockfile(path)
576
+ basename = File.basename(path)
577
+ return File.join(@root, "Gemfile") if basename == "Gemfile.lock"
578
+
579
+ path.delete_suffix(".lock")
580
+ end
581
+
582
+ def release_lockfile_normalization_env
583
+ {
584
+ "KETTLE_DEV_DEV" => "false",
585
+ "K_JEM_TEMPLATING" => "false",
586
+ "STRUCTUREDMERGE_DEV" => "false",
587
+ "TREE_SITTER_LANGUAGE_PACK_DEV" => "false",
588
+ "RUBOCOP_LTS_LOCAL" => "false",
589
+ "GALTZO_FLOSS_DEV" => "false",
590
+ "UR_BRAIN_DEV" => "false"
591
+ }
592
+ end
593
+
594
+ def release_lockfile_diagnostics(path)
595
+ diagnostics = []
596
+ release_lockfile_local_path_remote_lines(path).each do |line_number|
597
+ diagnostics << "#{release_lockfile_label(path)} has local path remote at line #{line_number}"
598
+ end
599
+ release_lockfile_empty_registry_checksums(path).each do |name, version, line_number|
600
+ diagnostics << "#{release_lockfile_label(path)} CHECKSUMS has no sha256 for #{name} #{version} at line #{line_number}"
601
+ end
602
+ diagnostics
603
+ end
604
+
605
+ def release_lockfile_has_local_path_remote?(path)
606
+ !release_lockfile_local_path_remote_lines(path).empty?
607
+ end
608
+
609
+ def release_lockfile_local_path_remote_lines(path)
610
+ File.readlines(path).filter_map.with_index(1) do |line, index|
611
+ next unless line.start_with?(" remote: /", " remote: ./", " remote: ../")
612
+
613
+ index
614
+ end
615
+ end
616
+
617
+ def release_lockfile_empty_registry_checksums(path)
618
+ path_gems = release_lockfile_path_source_gems(path) | release_lockfile_path_dependency_gems(path)
619
+ in_checksums = false
620
+ entries = File.readlines(path).filter_map.with_index(1) do |line, index|
621
+ stripped = line.strip
622
+ if stripped == "CHECKSUMS"
623
+ in_checksums = true
624
+ next
625
+ end
626
+ next unless in_checksums
627
+ next if stripped.empty?
628
+ next if stripped == "BUNDLED WITH"
629
+
630
+ match = stripped.match(/\A([A-Za-z0-9_.-]+) \(([^)]+)\)(?:\s+(sha256=.*))?\z/)
631
+ next unless match
632
+
633
+ name = match[1]
634
+ checksum = match[3].to_s
635
+ next if !checksum.empty?
636
+ next if path_gems.include?(name)
637
+
638
+ [name, match[2], index]
639
+ end
640
+ return [] unless release_lockfile_has_any_sha_checksum?(path)
641
+
642
+ entries
643
+ end
644
+
645
+ def release_lockfile_has_any_sha_checksum?(path)
646
+ in_checksums = false
647
+ File.readlines(path).any? do |line|
648
+ stripped = line.strip
649
+ if stripped == "CHECKSUMS"
650
+ in_checksums = true
651
+ next false
652
+ end
653
+ next false unless in_checksums
654
+
655
+ stripped.include?("sha256=")
656
+ end
657
+ end
658
+
659
+ def release_lockfile_path_source_gems(path)
660
+ gems = Set.new
661
+ in_path = false
662
+ in_specs = false
663
+ File.readlines(path).each do |line|
664
+ header = line.strip
665
+ if header.match?(/\A[A-Z][A-Z ]*\z/)
666
+ in_path = header == "PATH"
667
+ in_specs = false
668
+ next
669
+ end
670
+ next unless in_path
671
+
672
+ if header == "specs:"
673
+ in_specs = true
674
+ next
675
+ end
676
+ next unless in_specs
677
+
678
+ match = line.match(/\A ([A-Za-z0-9_.-]+) \([^)]+\)/)
679
+ gems << match[1] if match
680
+ end
681
+ gems
682
+ end
683
+
684
+ def release_lockfile_path_dependency_gems(path)
685
+ gems = Set.new
686
+ in_dependencies = false
687
+ File.readlines(path).each do |line|
688
+ header = line.strip
689
+ if header.match?(/\A[A-Z][A-Z ]*\z/)
690
+ in_dependencies = header == "DEPENDENCIES"
691
+ next
692
+ end
693
+ next unless in_dependencies
694
+
695
+ match = line.match(/\A ([A-Za-z0-9_.-]+)!\z/)
696
+ gems << match[1] if match
697
+ end
698
+ gems
699
+ end
700
+
701
+ def release_lockfile_label(path)
702
+ Kettle::Dev.display_path(path)
703
+ end
704
+
508
705
  def run_changelog!
509
706
  cmd = "bundle exec kettle-changelog"
510
707
  cmd = "#{cmd} --version #{Shellwords.escape(@version_override)}" if @version_override
@@ -955,9 +1152,123 @@ module Kettle
955
1152
 
956
1153
  puts "Publishing #{File.basename(gem_path)} to RubyGems without pushing git refs..."
957
1154
  run_cmd!("gem push #{Shellwords.escape(gem_path)}")
1155
+ confirm_release_candidate_available!(@release_candidate || build_release_candidate(gem_name_from_gem_path(gem_path, version), version))
958
1156
  mark_gem_coop_release_cache_bust(version, gem_name: gem_name_from_gem_path(gem_path, version))
959
1157
  end
960
1158
 
1159
+ def build_release_candidate(gem_name, version)
1160
+ ReleaseCandidate.new(
1161
+ gem_name: gem_name.to_s,
1162
+ version: version.to_s,
1163
+ installed_before: gem_version_installed?(gem_name, version),
1164
+ published: false
1165
+ )
1166
+ end
1167
+
1168
+ def with_unpublished_candidate_cleanup
1169
+ yield
1170
+ rescue SystemExit, StandardError
1171
+ cleanup_unpublished_release_candidate(@release_candidate)
1172
+ raise
1173
+ end
1174
+
1175
+ def cleanup_unpublished_release_candidate(candidate)
1176
+ return unless candidate
1177
+ return if candidate.published
1178
+ return if candidate.installed_before
1179
+ return unless gem_version_installed?(candidate.gem_name, candidate.version)
1180
+
1181
+ warn(
1182
+ "Release of #{candidate.gem_name} #{candidate.version} was not validated; " \
1183
+ "uninstalling the local candidate gem so future bundle updates cannot resolve an unreleased version."
1184
+ )
1185
+ uninstall_release_candidate(candidate)
1186
+ end
1187
+
1188
+ def uninstall_release_candidate(candidate)
1189
+ run_cmd!(
1190
+ "gem uninstall #{Shellwords.escape(candidate.gem_name)} " \
1191
+ "-v #{Shellwords.escape(candidate.version)} -x -I --ignore-dependencies"
1192
+ )
1193
+ rescue SystemExit, StandardError => error
1194
+ warn("Could not uninstall local candidate #{candidate.gem_name} #{candidate.version}: #{error.message}")
1195
+ warn("Manual cleanup: gem uninstall #{candidate.gem_name} -v #{candidate.version} -x -I --ignore-dependencies")
1196
+ end
1197
+
1198
+ def gem_version_installed?(gem_name, version)
1199
+ Gem::Specification.reset
1200
+ Gem::Specification.find_all_by_name(gem_name.to_s, "= #{version}").any?
1201
+ rescue Gem::LoadError
1202
+ false
1203
+ end
1204
+
1205
+ def confirm_release_candidate_available!(candidate)
1206
+ run_release_availability_probe(candidate)
1207
+ candidate.published = true
1208
+ true
1209
+ rescue SystemExit
1210
+ raise
1211
+ rescue => error
1212
+ abort(<<~MSG)
1213
+ Published gem availability could not be validated for #{candidate.gem_name} #{candidate.version}: #{error.class}: #{error.message}
1214
+ kettle-release will clean up any local candidate install that was introduced by this failed release attempt.
1215
+ If the push actually succeeded, resolve the registry/checksum state manually before resuming.
1216
+ MSG
1217
+ end
1218
+
1219
+ def run_release_availability_probe(candidate)
1220
+ script_path = write_release_availability_probe(candidate)
1221
+ stdout_str = nil
1222
+ stderr_str = nil
1223
+ status = nil
1224
+ with_unbundled_release_probe_env do
1225
+ stdout_str, stderr_str, status = Open3.capture3(self.class.send(:command_env), Gem.ruby, script_path)
1226
+ end
1227
+ $stdout.print(stdout_str) unless stdout_str.to_s.empty?
1228
+ return true if status.success?
1229
+
1230
+ diag = stderr_str.to_s.empty? ? "" : "\n--- STDERR ---\n#{stderr_str}".rstrip
1231
+ abort("Published gem availability probe failed for #{candidate.gem_name} #{candidate.version} (exit #{status.exitstatus})#{diag}")
1232
+ ensure
1233
+ FileUtils.rm_f(script_path) if script_path
1234
+ end
1235
+
1236
+ def with_unbundled_release_probe_env(&block)
1237
+ if defined?(Bundler)
1238
+ Bundler.with_unbundled_env(&block)
1239
+ else
1240
+ yield
1241
+ end
1242
+ end
1243
+
1244
+ def write_release_availability_probe(candidate)
1245
+ dir = File.join(@root, "tmp", "kettle-release")
1246
+ FileUtils.mkdir_p(dir)
1247
+ path = File.join(dir, "validate-#{candidate.gem_name}-#{candidate.version}-#{$$}.rb")
1248
+ File.write(path, release_availability_probe_script(candidate))
1249
+ path
1250
+ end
1251
+
1252
+ def release_availability_probe_script(candidate)
1253
+ <<~RUBY
1254
+ # frozen_string_literal: true
1255
+
1256
+ gem_name = #{candidate.gem_name.dump}
1257
+ version = #{candidate.version.dump}
1258
+
1259
+ require "bundler/inline"
1260
+
1261
+ gemfile(true) do
1262
+ source #{RELEASE_VALIDATION_SOURCE.dump}
1263
+ gem gem_name, "= \#{version}"
1264
+ end
1265
+
1266
+ spec = Gem.loaded_specs.fetch(gem_name)
1267
+ abort("loaded \#{gem_name} \#{spec.version}, expected \#{version}") unless spec.version.to_s == version
1268
+ puts "Validated \#{gem_name} \#{version} from #{RELEASE_VALIDATION_SOURCE}"
1269
+ RUBY
1270
+ end
1271
+
961
1272
  def detect_version
962
1273
  Kettle::Dev::Versioning.detect_version(@root, override: @version_override)
963
1274
  end
@@ -1520,3 +1831,14 @@ module Kettle
1520
1831
  end
1521
1832
  end
1522
1833
  end
1834
+
1835
+ class Kettle::Dev::ReleaseCLI::ReleaseCandidate
1836
+ attr_accessor :gem_name, :version, :installed_before, :published
1837
+
1838
+ def initialize(gem_name:, version:, installed_before:, published:)
1839
+ @gem_name = gem_name
1840
+ @version = version
1841
+ @installed_before = installed_before
1842
+ @published = published
1843
+ end
1844
+ end
@@ -3,7 +3,7 @@
3
3
  module Kettle
4
4
  module Dev
5
5
  module Version
6
- VERSION = "2.4.0"
6
+ VERSION = "2.4.2"
7
7
  end
8
8
  VERSION = Version::VERSION # Traditional Constant Location
9
9
  end
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.4.0
4
+ version: 2.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -349,10 +349,10 @@ licenses:
349
349
  - AGPL-3.0-only
350
350
  metadata:
351
351
  homepage_uri: https://kettle-dev.galtzo.com
352
- source_code_uri: https://github.com/kettle-dev/kettle-dev/tree/v2.4.0
353
- changelog_uri: https://github.com/kettle-dev/kettle-dev/blob/v2.4.0/CHANGELOG.md
352
+ source_code_uri: https://github.com/kettle-dev/kettle-dev/tree/v2.4.2
353
+ changelog_uri: https://github.com/kettle-dev/kettle-dev/blob/v2.4.2/CHANGELOG.md
354
354
  bug_tracker_uri: https://github.com/kettle-dev/kettle-dev/issues
355
- documentation_uri: https://www.rubydoc.info/gems/kettle-dev/2.4.0
355
+ documentation_uri: https://www.rubydoc.info/gems/kettle-dev/2.4.2
356
356
  funding_uri: https://github.com/sponsors/pboling
357
357
  wiki_uri: https://github.com/kettle-dev/kettle-dev/wiki
358
358
  news_uri: https://www.railsbling.com/tags/kettle-dev
metadata.gz.sig CHANGED
Binary file