kettle-dev 2.4.1 → 2.4.3

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: 77a7621fa0d906b716e933205ff585081b8746345530f540a8c83f6e67d3f347
4
- data.tar.gz: ae54a1e1f33347caa8c6d07eae632db0b88c95583a838fece942bd9bb1d84bfb
3
+ metadata.gz: 0764aba0c8669f0c075868db0109a393fb508835db1540ac2b1f3cdf0c1b4cd0
4
+ data.tar.gz: a5d418a258af62045142025f314820330eaa18dba697036e2c4861bbb3874fad
5
5
  SHA512:
6
- metadata.gz: 03b528fd8d85c047d18606f023a0f9f217ca88556ed26ddbc62504a282bceda6a42ea315c0cd0fdd0e2e4307fefeda357d34fcf4fab98d83dc5214c9d202bbb2
7
- data.tar.gz: d630c32d5f8cfbb28b74cdc552a9d331aa73d25de2d80298736f759f2bfe3181649c845a1d812c5ead41f9792a3169091f07ae33a2afb1610028b191effb08e7
6
+ metadata.gz: f4edf261a1f8819f89890b26eb1eb3666e388e771dde67bee0aa70d97d7920a7719493c0786267a6e651744c0a9c62462efa690105d0f7e124b612f386da9d09
7
+ data.tar.gz: 32423487dc0b9ea6af5d22695785f7d2ba2620b8128d6629f922f93433090d71600c4105ca5a1eecb96640c55699405edb78fea174e8bac46ef3eee1439e36cd
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,52 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [2.4.3] - 2026-07-25
34
+
35
+ - TAG: [v2.4.3][2.4.3t]
36
+ - COVERAGE: 92.17% -- 4082/4429 lines in 38 files
37
+ - BRANCH COVERAGE: 76.38% -- 1594/2087 branches in 38 files
38
+ - 59.16% documented
39
+
40
+ ### Added
41
+
42
+ - `kettle-release` now supports `--events[=TYPES]`, `--json`, and
43
+ `--report PATH` machine-readable release reporting backed by the shared
44
+ `kettle-ndjson` event toolkit.
45
+
46
+ ### Changed
47
+
48
+ - kettle-jem-template-20260725-001 - Generated JRuby and TruffleRuby workflow
49
+ files now run when pull request head branches start with `feature/release`,
50
+ so release CI monitoring does not report intentionally skipped engine
51
+ workflows as failures.
52
+
53
+ ### Fixed
54
+
55
+ - `kettle-release` now retries transient git remote fetch failures during
56
+ strict pre-push trunk parity checks, and failures now include an explicit
57
+ `--skip-remotes` recovery hint for intentionally unavailable remotes.
58
+ - `kettle-release` now waits briefly and retries post-publish gem.coop
59
+ availability validation before failing, so normal registry mirror lag does
60
+ not abort immediately after a successful RubyGems push.
61
+
62
+ - Raised the `kettle-ndjson` runtime dependency floor to v0.1.1 so the
63
+ dependency remains installable on kettle-dev's Ruby 2.4+ support floor.
64
+
65
+ ## [2.4.2] - 2026-07-24
66
+
67
+ - TAG: [v2.4.2][2.4.2t]
68
+ - COVERAGE: 92.36% -- 3991/4321 lines in 38 files
69
+ - BRANCH COVERAGE: 76.06% -- 1563/2055 branches in 38 files
70
+ - 59.00% documented
71
+
72
+ ### Fixed
73
+
74
+ - `kettle-release` now validates published gem availability with an isolated
75
+ Bundler probe against gem.coop before continuing past the publish step, and
76
+ uninstalls newly introduced local candidate gems when the release did not
77
+ happen or cannot be validated.
78
+
33
79
  ## [2.4.1] - 2026-07-24
34
80
 
35
81
  - TAG: [v2.4.1][2.4.1t]
@@ -2660,7 +2706,11 @@ Please file a bug if you notice a violation of semantic versioning.
2660
2706
  - Selecting will run the selected workflow via `act`
2661
2707
  - This may move to its own gem in the future.
2662
2708
 
2663
- [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v2.4.1...HEAD
2709
+ [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v2.4.3...HEAD
2710
+ [2.4.3]: https://github.com/kettle-dev/kettle-dev/compare/v2.4.2...v2.4.3
2711
+ [2.4.3t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v2.4.3
2712
+ [2.4.2]: https://github.com/kettle-dev/kettle-dev/compare/v2.4.1...v2.4.2
2713
+ [2.4.2t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v2.4.2
2664
2714
  [2.4.1]: https://github.com/kettle-dev/kettle-dev/compare/v2.4.0...v2.4.1
2665
2715
  [2.4.1t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v2.4.1
2666
2716
  [2.4.0]: https://github.com/kettle-dev/kettle-dev/compare/v2.3.11...v2.4.0
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.256-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
894
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-4.429-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
data/exe/kettle-release CHANGED
@@ -35,8 +35,10 @@ $LOAD_PATH.unshift(repo_lib) if File.directory?(repo_lib) && !$LOAD_PATH.include
35
35
  begin
36
36
  require "kettle/dev"
37
37
  require "kettle/dev/executable_version"
38
+ require "kettle/dev/release_cli"
38
39
  Kettle::Dev::ExecutableVersion.print_and_exit!(script_basename, ARGV, value_option: true)
39
- Kettle::Dev::ExecutableVersion.print_header(script_basename)
40
+ machine_output = ARGV.any? { |arg| arg == "--json" || arg.start_with?("--events") }
41
+ Kettle::Dev::ExecutableVersion.print_header(script_basename) unless machine_output
40
42
  rescue LoadError => e
41
43
  warn("#{script_basename}: could not load dependency: #{e.class}: #{e.message}")
42
44
  warn("Hint: Ensure the host project has kettle-dev as a dependency and run bundle install.")
@@ -47,7 +49,7 @@ end
47
49
  # Do not guard with __FILE__ == $PROGRAM_NAME because binstubs use Kernel.load.
48
50
  if ARGV.include?("-h") || ARGV.include?("--help")
49
51
  puts <<~USAGE
50
- Usage: kettle-release [--version VERSION] [--local-ci] [--appraisal-update] [--skip-bundle-audit] [--skip-steps STEPS] [--ci-workflows WORKFLOWS] [--skip-remotes REMOTES] [start_step=<0-19>]
52
+ Usage: kettle-release [--version VERSION] [--local-ci] [--appraisal-update] [--skip-bundle-audit] [--skip-steps STEPS] [--ci-workflows WORKFLOWS] [--skip-remotes REMOTES] [--json|--events[=TYPES]] [--report PATH] [start_step=<0-19>]
51
53
 
52
54
  Automates the release flow for a Ruby gem in the host project.
53
55
 
@@ -87,6 +89,9 @@ if ARGV.include?("-h") || ARGV.include?("--help")
87
89
  --skip-bundle-audit # Skip bundle:audit/update during release rake checks
88
90
  --ci-workflows WORKFLOWS # Comma-separated workflow files or stems to monitor at CI step 10
89
91
  --skip-remotes REMOTES # Comma-separated git remote names to exclude from release fetch/push parity
92
+ --json # Print final machine-readable release report as JSON
93
+ --events[=TYPES] # Print newline-delimited JSON release events, optionally filtered by type
94
+ --report PATH # Write final machine-readable release report as JSON
90
95
  --local-ci # Sensitive release mode: run act locally, publish before any git push,
91
96
  # create the git tag locally, then push commits and tags after publish
92
97
 
@@ -195,18 +200,60 @@ def extract_skip_remotes_arg!(argv)
195
200
  remotes
196
201
  end
197
202
 
203
+ def extract_report_arg!(argv)
204
+ report_path = nil
205
+ if (idx = argv.index("--report"))
206
+ report_path = argv[idx + 1]
207
+ if report_path.to_s.empty? || report_path.to_s.start_with?("-")
208
+ Kettle::Dev::ExitAdapter.abort("--report requires a path")
209
+ end
210
+ argv.slice!(idx, 2)
211
+ end
212
+ argv.delete_if do |arg|
213
+ if arg.start_with?("--report=", "report=")
214
+ report_path = arg.split("=", 2)[1]
215
+ Kettle::Dev::ExitAdapter.abort("--report requires a path") if report_path.to_s.empty?
216
+ true
217
+ else
218
+ false
219
+ end
220
+ end
221
+ report_path
222
+ end
223
+
224
+ def extract_events_arg!(argv)
225
+ events = false
226
+ event_types = nil
227
+ argv.delete_if do |arg|
228
+ if arg == "--events"
229
+ events = true
230
+ elsif arg.start_with?("--events=")
231
+ events = true
232
+ event_types = arg.split("=", 2)[1]
233
+ else
234
+ false
235
+ end
236
+ end
237
+ [events, event_types]
238
+ end
239
+
198
240
  local_ci = ARGV.include?("--local-ci")
199
241
  appraisal_task = ARGV.delete("--appraisal-update") ? "appraisal:update" : nil
200
242
  skip_bundle_audit = !!ARGV.delete("--skip-bundle-audit")
243
+ json_output = !!ARGV.delete("--json")
244
+ events_output, event_types = extract_events_arg!(ARGV)
245
+ Kettle::Dev::ExitAdapter.abort("--events cannot be combined with --json") if events_output && json_output
201
246
  start_step_arg = ARGV.find { |a| a.start_with?("start_step=") }
202
247
  start_step = start_step_arg ? start_step_arg.split("=", 2)[1].to_i : 0
203
248
  version_override = extract_version_arg!(ARGV)
204
249
  skip_steps = extract_skip_steps_arg!(ARGV)
205
250
  ci_workflows = extract_ci_workflows_arg!(ARGV)
206
251
  skip_remotes = extract_skip_remotes_arg!(ARGV)
252
+ report_path = extract_report_arg!(ARGV)
207
253
 
208
254
  begin
209
- Kettle::Dev::ReleaseCLI.new(start_step: start_step, local_ci: local_ci, version: version_override, appraisal_task: appraisal_task, skip_steps: skip_steps, skip_bundle_audit: skip_bundle_audit, ci_workflows: ci_workflows, skip_remotes: skip_remotes).run
255
+ event_stream = events_output ? Kettle::Ndjson.event_stream($stdout, types: event_types) : nil
256
+ Kettle::Dev::ReleaseCLI.new(start_step: start_step, local_ci: local_ci, version: version_override, appraisal_task: appraisal_task, skip_steps: skip_steps, skip_bundle_audit: skip_bundle_audit, ci_workflows: ci_workflows, skip_remotes: skip_remotes, event_stream: event_stream, json_output: json_output, json_io: $stdout, report_path: report_path).run
210
257
  rescue LoadError => e
211
258
  warn("#{script_basename}: could not load dependency: #{e.class}: #{e.message}")
212
259
  warn(Array(e.backtrace).join("\n")) if ENV["DEBUG"]
@@ -13,6 +13,7 @@ require "yaml"
13
13
  require "set"
14
14
 
15
15
  # External gems
16
+ require "kettle/ndjson"
16
17
  require "kettle/rb/compat_matrix"
17
18
  require "ruby-progressbar"
18
19
 
@@ -40,6 +41,8 @@ module Kettle
40
41
  "BUNDLE_SUPPRESS_INSTALL_USING_MESSAGES" => "true"
41
42
  }.freeze
42
43
  DEBUG_TRUE_VALUES = %w[1 true yes on].freeze
44
+ RELEASE_VALIDATION_SOURCE = "https://gem.coop"
45
+
43
46
  class << self
44
47
  def run_cmd!(cmd)
45
48
  # For Bundler-invoked build/release, explicitly prefix SKIP_GEM_SIGNING so
@@ -121,14 +124,44 @@ module Kettle
121
124
  @skip_bundle_audit = truthy_value?(skip_bundle_audit) || truthy_value?(ENV["KETTLE_DEV_SKIP_BUNDLE_AUDIT"])
122
125
  @version_override = Kettle::Dev::Versioning.normalize_explicit_version(version)
123
126
  @appraisal_task = normalize_appraisal_task(appraisal_task || ENV["KETTLE_RELEASE_APPRAISAL_TASK"])
127
+ @release_candidate = nil
128
+ @event_stream = options[:event_stream]
129
+ @event_recorder = Kettle::Ndjson.event_recorder(@event_stream, phase_timings: [])
130
+ @report_path = options[:report_path]
131
+ @json_output = !!options[:json_output]
132
+ @json_io = options[:json_io] || $stdout
133
+ @command_events = []
134
+ @diagnostics = []
135
+ @started_at = nil
136
+ @finished_report = nil
124
137
  end
125
138
 
126
139
  def run
140
+ @started_at = monotonic_time
141
+ emit_run_start
142
+ status = "ok"
143
+ error = nil
127
144
  with_bundle_audit_skip_env do
128
- run_with_release_environment
145
+ with_machine_stdout_redirect do
146
+ run_with_release_environment
147
+ end
129
148
  end
149
+ rescue SystemExit => e
150
+ status = e.status.to_i.zero? ? "ok" : "failed"
151
+ error = e
152
+ record_diagnostic("release_exit", e.message, severity: (status == "ok") ? "info" : "error", blocking: status != "ok")
153
+ raise
154
+ rescue => e
155
+ status = "failed"
156
+ error = e
157
+ record_diagnostic("release_error", "#{e.class}: #{e.message}", severity: "error", blocking: true)
158
+ raise
159
+ ensure
160
+ finish_release_report(status: status, error: error)
130
161
  end
131
162
 
163
+ attr_reader :finished_report
164
+
132
165
  def run_with_release_environment
133
166
  run_pre_release_checks! if run_step?(0)
134
167
 
@@ -346,13 +379,18 @@ module Kettle
346
379
 
347
380
  # 15. release and tag
348
381
  if run_step?(15)
382
+ version ||= detect_version
383
+ gem_name = detect_gem_name
384
+ @release_candidate = build_release_candidate(gem_name, version)
349
385
  if local_ci?
350
- version ||= detect_version
351
- release_gem_and_tag_locally!(version)
386
+ with_unpublished_candidate_cleanup { release_gem_and_tag_locally!(version) }
352
387
  else
353
388
  puts "Running release (you may be prompted for signing key password and RubyGems MFA OTP)..."
354
- run_cmd!("bundle exec rake release")
355
- mark_gem_coop_release_cache_bust(version || detect_version)
389
+ with_unpublished_candidate_cleanup do
390
+ run_cmd!("bundle exec rake release")
391
+ confirm_release_candidate_available!(@release_candidate)
392
+ end
393
+ mark_gem_coop_release_cache_bust(version)
356
394
  end
357
395
  end
358
396
 
@@ -387,11 +425,11 @@ module Kettle
387
425
  begin
388
426
  version ||= detect_version
389
427
  gem_name = detect_gem_name
390
- puts "\n🚀 Release #{gem_name} v#{version} Complete 🚀"
428
+ human_output.puts "\n🚀 Release #{gem_name} v#{version} Complete 🚀"
391
429
  rescue => e
392
430
  Kettle::Dev.debug_error(e, __method__)
393
431
  # Fallback if detection fails for any reason
394
- puts "\n🚀 Release v#{version || "unknown"} Complete 🚀"
432
+ human_output.puts "\n🚀 Release v#{version || "unknown"} Complete 🚀"
395
433
  end
396
434
  end
397
435
 
@@ -999,9 +1037,19 @@ module Kettle
999
1037
 
1000
1038
  def run_cmd!(cmd)
1001
1039
  cmd = bundle_audit_skip_command(cmd)
1040
+ emit_command_event(cmd, "started")
1002
1041
  with_bundle_audit_skip_env do
1003
- self.class.run_cmd!(cmd)
1042
+ with_machine_stdout_redirect do
1043
+ self.class.run_cmd!(cmd)
1044
+ end
1004
1045
  end
1046
+ emit_command_event(cmd, "ok")
1047
+ rescue SystemExit => e
1048
+ emit_command_event(cmd, "failed", reason: e.message)
1049
+ raise
1050
+ rescue => e
1051
+ emit_command_event(cmd, "failed", reason: "#{e.class}: #{e.message}")
1052
+ raise
1005
1053
  end
1006
1054
 
1007
1055
  def bundle_audit_skip_command(cmd)
@@ -1144,9 +1192,147 @@ module Kettle
1144
1192
 
1145
1193
  puts "Publishing #{File.basename(gem_path)} to RubyGems without pushing git refs..."
1146
1194
  run_cmd!("gem push #{Shellwords.escape(gem_path)}")
1195
+ confirm_release_candidate_available!(@release_candidate || build_release_candidate(gem_name_from_gem_path(gem_path, version), version))
1147
1196
  mark_gem_coop_release_cache_bust(version, gem_name: gem_name_from_gem_path(gem_path, version))
1148
1197
  end
1149
1198
 
1199
+ def build_release_candidate(gem_name, version)
1200
+ ReleaseCandidate.new(
1201
+ gem_name: gem_name.to_s,
1202
+ version: version.to_s,
1203
+ installed_before: gem_version_installed?(gem_name, version),
1204
+ published: false
1205
+ )
1206
+ end
1207
+
1208
+ def with_unpublished_candidate_cleanup
1209
+ yield
1210
+ rescue SystemExit, StandardError
1211
+ cleanup_unpublished_release_candidate(@release_candidate)
1212
+ raise
1213
+ end
1214
+
1215
+ def cleanup_unpublished_release_candidate(candidate)
1216
+ return unless candidate
1217
+ return if candidate.published
1218
+ return if candidate.installed_before
1219
+ return unless gem_version_installed?(candidate.gem_name, candidate.version)
1220
+
1221
+ warn(
1222
+ "Release of #{candidate.gem_name} #{candidate.version} was not validated; " \
1223
+ "uninstalling the local candidate gem so future bundle updates cannot resolve an unreleased version."
1224
+ )
1225
+ uninstall_release_candidate(candidate)
1226
+ end
1227
+
1228
+ def uninstall_release_candidate(candidate)
1229
+ run_cmd!(
1230
+ "gem uninstall #{Shellwords.escape(candidate.gem_name)} " \
1231
+ "-v #{Shellwords.escape(candidate.version)} -x -I --ignore-dependencies"
1232
+ )
1233
+ rescue SystemExit, StandardError => error
1234
+ warn("Could not uninstall local candidate #{candidate.gem_name} #{candidate.version}: #{error.message}")
1235
+ warn("Manual cleanup: gem uninstall #{candidate.gem_name} -v #{candidate.version} -x -I --ignore-dependencies")
1236
+ end
1237
+
1238
+ def gem_version_installed?(gem_name, version)
1239
+ Gem::Specification.reset
1240
+ Gem::Specification.find_all_by_name(gem_name.to_s, "= #{version}").any?
1241
+ rescue Gem::LoadError
1242
+ false
1243
+ end
1244
+
1245
+ def confirm_release_candidate_available!(candidate)
1246
+ run_release_availability_probe(candidate)
1247
+ candidate.published = true
1248
+ true
1249
+ rescue SystemExit
1250
+ raise
1251
+ rescue => error
1252
+ abort(<<~MSG)
1253
+ Published gem availability could not be validated for #{candidate.gem_name} #{candidate.version}: #{error.class}: #{error.message}
1254
+ kettle-release will clean up any local candidate install that was introduced by this failed release attempt.
1255
+ If the push actually succeeded, resolve the registry/checksum state manually before resuming.
1256
+ MSG
1257
+ end
1258
+
1259
+ def run_release_availability_probe(candidate)
1260
+ script_path = write_release_availability_probe(candidate)
1261
+ attempts = release_availability_probe_attempts
1262
+ last_stderr = nil
1263
+ last_status = nil
1264
+ sleep(release_availability_probe_initial_delay)
1265
+ attempts.times do |index|
1266
+ attempt = index + 1
1267
+ puts("Validating #{candidate.gem_name} #{candidate.version} from #{RELEASE_VALIDATION_SOURCE} (attempt #{attempt}/#{attempts})")
1268
+ stdout_str = nil
1269
+ stderr_str = nil
1270
+ status = nil
1271
+ with_unbundled_release_probe_env do
1272
+ stdout_str, stderr_str, status = Open3.capture3(self.class.send(:command_env), Gem.ruby, script_path)
1273
+ end
1274
+ $stdout.print(stdout_str) unless stdout_str.to_s.empty?
1275
+ return true if status.success?
1276
+
1277
+ last_stderr = stderr_str
1278
+ last_status = status
1279
+ sleep(release_availability_probe_interval) if attempt < attempts
1280
+ end
1281
+
1282
+ diag = last_stderr.to_s.empty? ? "" : "\n--- STDERR ---\n#{last_stderr}".rstrip
1283
+ abort("Published gem availability probe failed for #{candidate.gem_name} #{candidate.version} after #{attempts} attempt(s) (exit #{last_status.exitstatus})#{diag}")
1284
+ ensure
1285
+ FileUtils.rm_f(script_path) if script_path
1286
+ end
1287
+
1288
+ def release_availability_probe_attempts
1289
+ 15
1290
+ end
1291
+
1292
+ def release_availability_probe_initial_delay
1293
+ 5
1294
+ end
1295
+
1296
+ def release_availability_probe_interval
1297
+ 10
1298
+ end
1299
+
1300
+ def with_unbundled_release_probe_env(&block)
1301
+ if defined?(Bundler)
1302
+ Bundler.with_unbundled_env(&block)
1303
+ else
1304
+ yield
1305
+ end
1306
+ end
1307
+
1308
+ def write_release_availability_probe(candidate)
1309
+ dir = File.join(@root, "tmp", "kettle-release")
1310
+ FileUtils.mkdir_p(dir)
1311
+ path = File.join(dir, "validate-#{candidate.gem_name}-#{candidate.version}-#{$$}.rb")
1312
+ File.write(path, release_availability_probe_script(candidate))
1313
+ path
1314
+ end
1315
+
1316
+ def release_availability_probe_script(candidate)
1317
+ <<~RUBY
1318
+ # frozen_string_literal: true
1319
+
1320
+ gem_name = #{candidate.gem_name.dump}
1321
+ version = #{candidate.version.dump}
1322
+
1323
+ require "bundler/inline"
1324
+
1325
+ gemfile(true) do
1326
+ source #{RELEASE_VALIDATION_SOURCE.dump}
1327
+ gem gem_name, "= \#{version}"
1328
+ end
1329
+
1330
+ spec = Gem.loaded_specs.fetch(gem_name)
1331
+ abort("loaded \#{gem_name} \#{spec.version}, expected \#{version}") unless spec.version.to_s == version
1332
+ puts "Validated \#{gem_name} \#{version} from #{RELEASE_VALIDATION_SOURCE}"
1333
+ RUBY
1334
+ end
1335
+
1150
1336
  def detect_version
1151
1337
  Kettle::Dev::Versioning.detect_version(@root, override: @version_override)
1152
1338
  end
@@ -1247,6 +1433,118 @@ module Kettle
1247
1433
  end
1248
1434
  end
1249
1435
 
1436
+ def emit_run_start
1437
+ Kettle::Ndjson.emit_event(
1438
+ @event_recorder,
1439
+ "run_start",
1440
+ command: "release",
1441
+ root: @root,
1442
+ start_step: @start_step,
1443
+ skipped_steps: @skip_steps,
1444
+ local_ci: @local_ci,
1445
+ ci_workflows: @ci_workflows,
1446
+ skipped_remotes: @skip_remotes
1447
+ )
1448
+ end
1449
+
1450
+ def emit_command_event(command, status, reason: nil)
1451
+ event = {
1452
+ phase: "release",
1453
+ index: @command_events.length + 1,
1454
+ name: command_event_name(command),
1455
+ status: status,
1456
+ reason: reason,
1457
+ command: command,
1458
+ changed_files: []
1459
+ }
1460
+ @command_events << event unless status == "started"
1461
+ Kettle::Ndjson.emit_step_event(@event_recorder, "command_step", event, phase: "release", index: event[:index])
1462
+ end
1463
+
1464
+ def command_event_name(command)
1465
+ command.to_s.split(/\s+/, 3).first(2).join("_").gsub(/[^A-Za-z0-9_]+/, "_").sub(/_+\z/, "")
1466
+ end
1467
+
1468
+ def record_diagnostic(kind, message, severity:, blocking:)
1469
+ diagnostic = {
1470
+ kind: kind,
1471
+ severity: severity,
1472
+ message: message.to_s,
1473
+ blocking: blocking
1474
+ }
1475
+ @diagnostics << diagnostic
1476
+ Kettle::Ndjson.emit_diagnostic_event(@event_recorder, diagnostic, index: @diagnostics.length)
1477
+ end
1478
+
1479
+ def finish_release_report(status:, error:)
1480
+ return if @finished_report
1481
+
1482
+ duration_ms = @started_at ? duration_ms_since(@started_at) : nil
1483
+ @finished_report = {
1484
+ command: "release",
1485
+ root: @root,
1486
+ version: safe_detect_version,
1487
+ gem_name: safe_detect_gem_name,
1488
+ start_step: @start_step,
1489
+ skipped_steps: @skip_steps,
1490
+ local_ci: @local_ci,
1491
+ ci_workflows: @ci_workflows,
1492
+ skipped_remotes: @skip_remotes,
1493
+ status: status,
1494
+ error_class: error&.class&.name,
1495
+ error_message: error&.message,
1496
+ duration_ms: duration_ms,
1497
+ command_events: @command_events,
1498
+ diagnostics: @diagnostics,
1499
+ diagnostics_count: @diagnostics.length,
1500
+ phase_timings: @event_recorder.phase_timings
1501
+ }.compact
1502
+ Kettle::Ndjson.emit_summary_event(@event_recorder, @finished_report)
1503
+ write_release_report(@finished_report)
1504
+ @json_io.puts(JSON.pretty_generate(@finished_report)) if @json_output
1505
+ end
1506
+
1507
+ def write_release_report(report)
1508
+ return unless @report_path
1509
+
1510
+ FileUtils.mkdir_p(File.dirname(@report_path))
1511
+ File.write(@report_path, "#{JSON.pretty_generate(report)}\n")
1512
+ end
1513
+
1514
+ def with_machine_stdout_redirect
1515
+ return yield unless @json_output || @event_stream
1516
+
1517
+ previous_stdout = $stdout
1518
+ $stdout = $stderr
1519
+ yield
1520
+ ensure
1521
+ $stdout = previous_stdout if previous_stdout
1522
+ end
1523
+
1524
+ def human_output
1525
+ (@json_output || @event_stream) ? $stderr : $stdout
1526
+ end
1527
+
1528
+ def safe_detect_version
1529
+ detect_version
1530
+ rescue
1531
+ nil
1532
+ end
1533
+
1534
+ def safe_detect_gem_name
1535
+ detect_gem_name
1536
+ rescue
1537
+ nil
1538
+ end
1539
+
1540
+ def monotonic_time
1541
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
1542
+ end
1543
+
1544
+ def duration_ms_since(started_at)
1545
+ ((monotonic_time - started_at) * 1000).round(3)
1546
+ end
1547
+
1250
1548
  def push_tags!
1251
1549
  # After release, push tags to remotes according to policy:
1252
1550
  # 1) If a remote named "all" exists, push tags only to it.
@@ -1384,7 +1682,7 @@ module Kettle
1384
1682
  remotes.each do |remote|
1385
1683
  next if remote == "all"
1386
1684
 
1387
- run_cmd!("git fetch #{Shellwords.escape(remote)}")
1685
+ fetch_remote_for_parity!(remote)
1388
1686
  end
1389
1687
  missing_from = []
1390
1688
  remotes.each do |r|
@@ -1460,6 +1758,50 @@ module Kettle
1460
1758
  end
1461
1759
  end
1462
1760
 
1761
+ def fetch_remote_for_parity!(remote)
1762
+ command = "git fetch #{Shellwords.escape(remote)}"
1763
+ attempts = remote_fetch_parity_attempts
1764
+ last_error = nil
1765
+ attempts.times do |index|
1766
+ attempt = index + 1
1767
+ puts "Fetching remote '#{remote}' for release parity (attempt #{attempt}/#{attempts})"
1768
+ begin
1769
+ run_cmd!(command)
1770
+ return true
1771
+ rescue SystemExit => error
1772
+ last_error = error
1773
+ rescue => error
1774
+ last_error = error
1775
+ end
1776
+ sleep(remote_fetch_parity_interval) if attempt < attempts
1777
+ end
1778
+
1779
+ abort(remote_fetch_failure_message(remote, last_error&.message.to_s))
1780
+ end
1781
+
1782
+ def remote_fetch_parity_attempts
1783
+ 3
1784
+ end
1785
+
1786
+ def remote_fetch_parity_interval
1787
+ 5
1788
+ end
1789
+
1790
+ def remote_fetch_failure_message(remote, detail)
1791
+ <<~MSG
1792
+ Unable to fetch git remote '#{remote}' during release parity checks.
1793
+ kettle-release enforces trunk parity across active remotes before publishing.
1794
+
1795
+ If this remote is temporarily unavailable or intentionally not part of this release,
1796
+ rerun with one of:
1797
+ kettle-release --skip-remotes #{remote}
1798
+ K_RELEASE_SKIP_REMOTES=#{remote} kettle-release
1799
+
1800
+ Original failure:
1801
+ #{detail}
1802
+ MSG
1803
+ end
1804
+
1463
1805
  def merge_feature_into_trunk_and_push!(trunk, feature)
1464
1806
  return if feature.nil? || feature == trunk
1465
1807
 
@@ -1709,3 +2051,14 @@ module Kettle
1709
2051
  end
1710
2052
  end
1711
2053
  end
2054
+
2055
+ class Kettle::Dev::ReleaseCLI::ReleaseCandidate
2056
+ attr_accessor :gem_name, :version, :installed_before, :published
2057
+
2058
+ def initialize(gem_name:, version:, installed_before:, published:)
2059
+ @gem_name = gem_name
2060
+ @version = version
2061
+ @installed_before = installed_before
2062
+ @published = published
2063
+ end
2064
+ end
@@ -3,7 +3,7 @@
3
3
  module Kettle
4
4
  module Dev
5
5
  module Version
6
- VERSION = "2.4.1"
6
+ VERSION = "2.4.3"
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.1
4
+ version: 2.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -71,6 +71,26 @@ dependencies:
71
71
  - - ">="
72
72
  - !ruby/object:Gem::Version
73
73
  version: 0.3.1
74
+ - !ruby/object:Gem::Dependency
75
+ name: kettle-ndjson
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '0.1'
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: 0.1.1
84
+ type: :runtime
85
+ prerelease: false
86
+ version_requirements: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - "~>"
89
+ - !ruby/object:Gem::Version
90
+ version: '0.1'
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: 0.1.1
74
94
  - !ruby/object:Gem::Dependency
75
95
  name: kettle-rb
76
96
  requirement: !ruby/object:Gem::Requirement
@@ -349,10 +369,10 @@ licenses:
349
369
  - AGPL-3.0-only
350
370
  metadata:
351
371
  homepage_uri: https://kettle-dev.galtzo.com
352
- source_code_uri: https://github.com/kettle-dev/kettle-dev/tree/v2.4.1
353
- changelog_uri: https://github.com/kettle-dev/kettle-dev/blob/v2.4.1/CHANGELOG.md
372
+ source_code_uri: https://github.com/kettle-dev/kettle-dev/tree/v2.4.3
373
+ changelog_uri: https://github.com/kettle-dev/kettle-dev/blob/v2.4.3/CHANGELOG.md
354
374
  bug_tracker_uri: https://github.com/kettle-dev/kettle-dev/issues
355
- documentation_uri: https://www.rubydoc.info/gems/kettle-dev/2.4.1
375
+ documentation_uri: https://www.rubydoc.info/gems/kettle-dev/2.4.3
356
376
  funding_uri: https://github.com/sponsors/pboling
357
377
  wiki_uri: https://github.com/kettle-dev/kettle-dev/wiki
358
378
  news_uri: https://www.railsbling.com/tags/kettle-dev
metadata.gz.sig CHANGED
Binary file