kettle-dev 2.5.12 → 2.5.13

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: 60069f78cc45c33bf7d2616ea76c456e98cf9885da75331a553f5c9e2b96bbe8
4
- data.tar.gz: d58499d2f31cbffc408e15af977b93b3254f310d021822d90726c7407f4436c3
3
+ metadata.gz: 76cf339458385418bcf752559dbb9c6278f3b95ebfa27b26781591564179f263
4
+ data.tar.gz: 66cae3bfb62ecd922bf2b6ecc16049389a860a20dbaa8492fd6578bdcf3b4922
5
5
  SHA512:
6
- metadata.gz: b34e918fa0cce724b098ab97e46abd768a65938d52f908f87515a5f64b8c3c8d68e6717c05529045da5ce1670e407fa1fa955cfb3c9c2cbd6a4c72d46b64577b
7
- data.tar.gz: d9dcb4194c6f22c6f6ba1b3311b8f7e644766b52bb36f80d4f59130e48f32d2d4917541c3aebf25e253adb4e3914485b9d66b7f77ebf64b73e56804686dc4276
6
+ metadata.gz: 0ae8aa5d0113e226dab9dbb332b42328bfe738c84ce88a21d919c3a0249d2ad3f70d905aa4cbd76c903608846da8a485a6e68c710934e7eac2443517d71d6a96
7
+ data.tar.gz: 4470d0bd47c7c95a616b8513a6b590911a4086e6d6f31330e96c45059774ac4d172a0c98ccf5aad53384208cf40fb02de237c68c6326a430acaf89adfe9a89c2
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,48 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [2.5.13] - 2026-07-31
34
+
35
+ - TAG: [v2.5.13][2.5.13t]
36
+ - COVERAGE: 91.81% -- 5137/5595 lines in 46 files
37
+ - BRANCH COVERAGE: 76.06% -- 1944/2556 branches in 46 files
38
+ - 50.41% documented
39
+
40
+ ### Fixed
41
+
42
+ - `kettle-release` now treats `origin` as the default required parity remote
43
+ while allowing optional mirror remotes to be skipped after fetch retries,
44
+ so a transient Codeberg/GitLab mirror outage does not block publishing.
45
+
46
+ ### Added
47
+
48
+ - `kettle-release --events` now emits `remote_parity` events for remote parity
49
+ fetch attempts, skipped optional remotes, required failures, and parity
50
+ completion.
51
+ - `kettle-release --events` now emits `ci_monitor` events around CI monitoring
52
+ start, per-workflow GitHub Actions progress, GitLab pipeline outcomes,
53
+ success, and failure.
54
+ - `kettle-release --events` now emits `pre_release` events around
55
+ `kettle-pre-release` checks and selected check summaries.
56
+ - `kettle-changelog --events` now emits `changelog` events around release plan,
57
+ coverage generation, and changelog update activity, and `kettle-release
58
+ --events` forwards those child events.
59
+ - `kettle-release --events` now emits `release_lockfile` and `release_probe`
60
+ events for release lockfile reset/validation and published-gem availability
61
+ probes.
62
+ - `kettle-dev` now requires `kettle-ndjson` 0.1.9 or newer so release event
63
+ streams resolve against the protocol that includes remote parity and CI
64
+ monitor, pre-release, changelog, release lockfile, and release probe events.
65
+
66
+ - `kettle-release --events` now emits GitHub CI wait/start/tick monitor events
67
+ while waiting for workflow runs to appear and complete.
68
+
69
+ ### Changed
70
+
71
+ - `kettle-release --events` now emits clearer `command_step` names and compact
72
+ per-command summaries for common release commands instead of shell-derived
73
+ labels such as `env__u`.
74
+
33
75
  ## [2.5.12] - 2026-07-31
34
76
 
35
77
  - TAG: [v2.5.12][2.5.12t]
@@ -3118,7 +3160,9 @@ Please file a bug if you notice a violation of semantic versioning.
3118
3160
  - Selecting will run the selected workflow via `act`
3119
3161
  - This may move to its own gem in the future.
3120
3162
 
3121
- [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.12...HEAD
3163
+ [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.13...HEAD
3164
+ [2.5.13]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.12...v2.5.13
3165
+ [2.5.13t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v2.5.13
3122
3166
  [2.5.12]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.11...v2.5.12
3123
3167
  [2.5.12t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v2.5.12
3124
3168
  [2.5.11]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.10...v2.5.11
data/README.md CHANGED
@@ -1005,7 +1005,7 @@ Thanks for RTFM. ☺️
1005
1005
  [📌gitmoji]: https://gitmoji.dev
1006
1006
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
1007
1007
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1008
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-5.408-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1008
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-5.595-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1009
1009
  [🔐security]: https://github.com/kettle-dev/kettle-dev/blob/main/SECURITY.md
1010
1010
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1011
1011
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
data/exe/kettle-changelog CHANGED
@@ -50,9 +50,11 @@ begin
50
50
 
51
51
  # This library
52
52
  require "kettle/dev"
53
+ require "kettle/ndjson"
53
54
  require "kettle/dev/executable_version"
54
55
  Kettle::Dev::ExecutableVersion.print_and_exit!(script_basename, ARGV, value_option: true)
55
- Kettle::Dev::ExecutableVersion.print_header(script_basename) unless ARGV.include?("--json")
56
+ machine_output = ARGV.any? { |arg| arg == "--json" || arg.start_with?("--events") }
57
+ Kettle::Dev::ExecutableVersion.print_header(script_basename) unless machine_output
56
58
  rescue LoadError => e
57
59
  warn("#{script_basename}: could not load dependency: #{e.class}: #{e.message}")
58
60
  warn("Hint: Ensure the host project has kettle-dev as a dependency and run bundle install.")
@@ -61,7 +63,7 @@ end
61
63
 
62
64
  if ARGV.include?("-h") || ARGV.include?("--help")
63
65
  puts <<~USAGE
64
- Usage: kettle-changelog [--version VERSION] [--yes] [--update-prep] [--pending-release] [--release-state] [--refresh-cache] [--add-unreleased-entry --section SECTION --entry TEXT] [--json] [--no-strict] [--no-coverage-threshold]
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]
65
67
 
66
68
  Detects the current version from lib/**/version.rb, the latest live release, and
67
69
  the most recent CHANGELOG.md release section, then prompts to confirm the selected plan:
@@ -82,6 +84,7 @@ if ARGV.include?("-h") || ARGV.include?("--help")
82
84
  --section SECTION Unreleased section to receive the entry (Added, Changed, Deprecated, Removed, Fixed, Security)
83
85
  --entry TEXT Changelog entry text; "- " is added when omitted
84
86
  --json Print query output as JSON
87
+ --events[=TYPES] Print newline-delimited JSON changelog events
85
88
  --no-strict Allow missing coverage and yard data (warnings only, no errors)
86
89
  --no-coverage-threshold Generate coverage without hard-failing below configured thresholds
87
90
 
@@ -137,6 +140,22 @@ begin
137
140
  Kettle::Dev::Versioning.normalize_explicit_version(version)
138
141
  end
139
142
 
143
+ def extract_events_arg!(argv)
144
+ events = false
145
+ event_types = nil
146
+ argv.delete_if do |arg|
147
+ if arg == "--events"
148
+ events = true
149
+ elsif arg.start_with?("--events=")
150
+ events = true
151
+ event_types = arg.split("=", 2)[1]
152
+ else
153
+ false
154
+ end
155
+ end
156
+ [events, event_types]
157
+ end
158
+
140
159
  # Determine if strict mode is enabled (default: true)
141
160
  strict_mode = !ARGV.include?("--no-strict") && ENV.fetch("K_CHANGELOG_STRICT", "true").downcase != "false"
142
161
  update_prep = ARGV.delete("--update-prep")
@@ -146,6 +165,8 @@ begin
146
165
  refresh_cache = ARGV.delete("--refresh-cache")
147
166
  add_unreleased_entry = ARGV.delete("--add-unreleased-entry") || ARGV.delete("--add-changelog-entry")
148
167
  json_output = ARGV.delete("--json")
168
+ events_output, event_types = extract_events_arg!(ARGV)
169
+ Kettle::Dev::ExitAdapter.abort("--events cannot be combined with --json") if events_output && json_output
149
170
  coverage_threshold_disabled = ARGV.include?("--no-coverage-threshold") || ARGV.include?("--no-coverage-thresholds")
150
171
  coverage_hard = !coverage_threshold_disabled && ENV.fetch("K_CHANGELOG_COVERAGE_HARD", "true").downcase != "false"
151
172
  version_override = extract_version_arg!(ARGV)
@@ -180,7 +201,8 @@ begin
180
201
  exit(0)
181
202
  end
182
203
 
183
- 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)
204
+ 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)
184
206
  if release_state_query
185
207
  state = cli.release_state
186
208
  puts(json_output ? JSON.pretty_generate(state) : cli.release_state_table(state))
data/exe/kettle-release CHANGED
@@ -49,7 +49,7 @@ end
49
49
  # Do not guard with __FILE__ == $PROGRAM_NAME because binstubs use Kernel.load.
50
50
  if ARGV.include?("-h") || ARGV.include?("--help")
51
51
  puts <<~USAGE
52
- Usage: kettle-release [--version VERSION] [--local-ci] [--yes] [--appraisal-update] [--skip-bundle-audit] [--skip-steps STEPS] [--ci-workflows WORKFLOWS] [--skip-remotes REMOTES] [--secrets-provider NAME] [--json|--events[=TYPES]] [--report PATH] [start_step=<0-19>]
52
+ Usage: kettle-release [--version VERSION] [--local-ci] [--yes] [--appraisal-update] [--skip-bundle-audit] [--skip-steps STEPS] [--ci-workflows WORKFLOWS] [--skip-remotes REMOTES] [--required-remotes REMOTES] [--secrets-provider NAME] [--json|--events[=TYPES]] [--report PATH] [start_step=<0-19>]
53
53
 
54
54
  Automates the release flow for a Ruby gem in the host project.
55
55
 
@@ -90,6 +90,7 @@ if ARGV.include?("-h") || ARGV.include?("--help")
90
90
  --skip-bundle-audit # Skip bundle:audit/update during release rake checks
91
91
  --ci-workflows WORKFLOWS # Comma-separated workflow files or stems to monitor at CI step 10
92
92
  --skip-remotes REMOTES # Comma-separated git remote names to exclude from release fetch/push parity
93
+ --required-remotes REMOTES # Comma-separated remote names that must fetch for release parity; defaults to origin
93
94
  --secrets-provider NAME # Release secrets provider: interactive, 1password
94
95
  --json # Print final machine-readable release report as JSON
95
96
  --events[=TYPES] # Print newline-delimited JSON release events, optionally filtered by type
@@ -104,6 +105,7 @@ if ARGV.include?("-h") || ARGV.include?("--help")
104
105
  K_RELEASE_LOCAL_CI_WORKFLOW # Name of workflow (without .yml) for local CI; defaults to locked_deps or first
105
106
  K_RELEASE_CI_WORKFLOWS # Comma-separated workflow files or stems to monitor at CI step 10
106
107
  K_RELEASE_SKIP_REMOTES # Comma-separated git remote names to exclude from release fetch/push parity
108
+ K_RELEASE_REQUIRED_REMOTES # Comma-separated remotes that must fetch for release parity; defaults to origin
107
109
  KETTLE_RELEASE_SECRETS_PROVIDER=1password
108
110
  # Load release secrets through the configured provider
109
111
  KETTLE_RELEASE_1PASSWORD_ITEM=Rubygems
@@ -215,6 +217,27 @@ def extract_skip_remotes_arg!(argv)
215
217
  remotes
216
218
  end
217
219
 
220
+ def extract_required_remotes_arg!(argv)
221
+ remotes = nil
222
+ if (idx = argv.index("--required-remotes"))
223
+ remotes = argv[idx + 1]
224
+ if remotes.to_s.empty? || remotes.to_s.start_with?("-")
225
+ Kettle::Dev::ExitAdapter.abort("--required-remotes requires a comma-separated remote list")
226
+ end
227
+ argv.slice!(idx, 2)
228
+ end
229
+ argv.delete_if do |arg|
230
+ if arg.start_with?("--required-remotes=", "required_remotes=")
231
+ remotes = arg.split("=", 2)[1]
232
+ Kettle::Dev::ExitAdapter.abort("--required-remotes requires a comma-separated remote list") if remotes.to_s.empty?
233
+ true
234
+ else
235
+ false
236
+ end
237
+ end
238
+ remotes
239
+ end
240
+
218
241
  def extract_report_arg!(argv)
219
242
  report_path = nil
220
243
  if (idx = argv.index("--report"))
@@ -286,12 +309,13 @@ version_override = extract_version_arg!(ARGV)
286
309
  skip_steps = extract_skip_steps_arg!(ARGV)
287
310
  ci_workflows = extract_ci_workflows_arg!(ARGV)
288
311
  skip_remotes = extract_skip_remotes_arg!(ARGV)
312
+ required_remotes = extract_required_remotes_arg!(ARGV)
289
313
  report_path = extract_report_arg!(ARGV)
290
314
  secrets_provider = extract_secrets_provider_arg!(ARGV)
291
315
 
292
316
  begin
293
317
  event_stream = events_output ? Kettle::Ndjson.event_stream($stdout, types: event_types) : nil
294
- 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, secrets_provider_name: secrets_provider, yes: yes, event_stream: event_stream, json_output: json_output, json_io: $stdout, report_path: report_path).run
318
+ 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, required_remotes: required_remotes, secrets_provider_name: secrets_provider, yes: yes, event_stream: event_stream, json_output: json_output, json_io: $stdout, report_path: report_path).run
295
319
  rescue LoadError => e
296
320
  warn("#{script_basename}: could not load dependency: #{e.class}: #{e.message}")
297
321
  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 "kettle/ndjson"
8
9
 
9
10
  module Kettle
10
11
  module Dev
@@ -28,7 +29,7 @@ module Kettle
28
29
  # @param update_prep [Boolean] when true, update the most recent prepared release section in place
29
30
  # @param version [String, nil] explicit version override for gems without a literal VERSION constant
30
31
  # @param yes [Boolean] when true, approve the selected release plan without prompting
31
- def initialize(strict: true, enforce_coverage_thresholds: true, update_prep: false, version: nil, root: Kettle::Dev::CIHelpers.project_root, refresh_cache: false, yes: false)
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)
32
33
  @root = root
33
34
  @changelog_path = resolved_changelog_path
34
35
  @coverage_root = resolved_coverage_root
@@ -39,6 +40,7 @@ module Kettle
39
40
  @version_override = Kettle::Dev::Versioning.normalize_explicit_version(version)
40
41
  @refresh_cache = refresh_cache
41
42
  @yes = !!yes
43
+ @event_recorder = Kettle::Ndjson.event_recorder(event_stream, phase_timings: [])
42
44
  end
43
45
 
44
46
  # Main entry point to update CHANGELOG.md
@@ -70,6 +72,7 @@ module Kettle
70
72
 
71
73
  if plan.fetch(:action) == :update_prepared_release
72
74
  update_prepared_release!(changelog, today, owner, repo, line_cov_line, branch_cov_line, yard_line)
75
+ emit_changelog_event(action: "update", status: "ok", version: version, plan: plan.fetch(:action).to_s)
73
76
  return
74
77
  end
75
78
 
@@ -140,6 +143,7 @@ module Kettle
140
143
  updated = updated.rstrip + "\n"
141
144
 
142
145
  File.write(@changelog_path, updated)
146
+ emit_changelog_event(action: "update", status: "ok", version: version, plan: plan.fetch(:action).to_s)
143
147
  puts "CHANGELOG.md updated with v#{version} section."
144
148
  end
145
149
 
@@ -348,6 +352,16 @@ module Kettle
348
352
  puts " latest released for current series: #{plan.fetch(:latest_for_series) || "unknown"}"
349
353
  puts " latest CHANGELOG.md release: #{plan.fetch(:latest_changelog_version) || "none"}"
350
354
  puts " gem: #{plan.fetch(:gem_name) || "unknown"}"
355
+ emit_changelog_event(
356
+ action: "plan",
357
+ status: "ok",
358
+ plan: plan.fetch(:action).to_s,
359
+ version: plan.fetch(:version),
360
+ gem_name: plan.fetch(:gem_name),
361
+ latest_overall: plan.fetch(:latest_overall),
362
+ latest_for_series: plan.fetch(:latest_for_series),
363
+ latest_changelog_version: plan.fetch(:latest_changelog_version)
364
+ )
351
365
  if @yes
352
366
  puts("Continue with this plan? [y/N]: y")
353
367
  return
@@ -665,20 +679,25 @@ module Kettle
665
679
  coverage_dir = File.dirname(@coverage_path)
666
680
  if Dir.exist?(coverage_dir)
667
681
  puts "Cleaning old coverage data from #{Kettle::Dev.display_path(coverage_dir)}..."
682
+ emit_changelog_event(action: "coverage_clean", status: "started", path: Kettle::Dev.display_path(coverage_dir))
668
683
  Dir.glob(File.join(coverage_dir, "*")).each do |file|
669
684
  File.delete(file) if File.file?(file)
670
685
  end
686
+ emit_changelog_event(action: "coverage_clean", status: "ok", path: Kettle::Dev.display_path(coverage_dir))
671
687
  end
672
688
 
673
689
  puts "Generating fresh coverage data by running: bundle exec kettle-test"
690
+ emit_changelog_event(action: "coverage", status: "started", command: "bundle exec kettle-test", root: Kettle::Dev.display_path(@coverage_root))
674
691
 
675
692
  success = system(changelog_coverage_env, "bundle", "exec", "kettle-test", chdir: @coverage_root)
676
693
 
677
694
  unless success
695
+ emit_changelog_event(action: "coverage", status: "failed", command: "bundle exec kettle-test", root: Kettle::Dev.display_path(@coverage_root), reason: "exit status #{$?.exitstatus || "unknown"}")
678
696
  raise "bundle exec kettle-test failed with exit status #{$?.exitstatus || "unknown"}"
679
697
  end
680
698
 
681
699
  puts "Coverage generation complete."
700
+ emit_changelog_event(action: "coverage", status: "ok", command: "bundle exec kettle-test", root: Kettle::Dev.display_path(@coverage_root))
682
701
 
683
702
  ensure_changelog_coverage_json!
684
703
  else
@@ -739,6 +758,29 @@ module Kettle
739
758
  end
740
759
  end
741
760
 
761
+ def emit_changelog_event(action:, status:, **payload)
762
+ mark = case status.to_s
763
+ when "started"
764
+ ">"
765
+ when "ok", "skipped"
766
+ "."
767
+ when "failed"
768
+ "!"
769
+ else
770
+ "?"
771
+ end
772
+ Kettle::Ndjson.emit_event(
773
+ @event_recorder,
774
+ "changelog",
775
+ payload.merge(
776
+ action: action,
777
+ phase: "release",
778
+ status: status,
779
+ mark: mark
780
+ )
781
+ )
782
+ end
783
+
742
784
  def changelog_coverage_env
743
785
  env = BundlerEnvGuard.unbundled_env.merge(
744
786
  "PATH" => ENV.fetch("PATH", ""),
@@ -4,6 +4,7 @@
4
4
  require "uri"
5
5
  require "json"
6
6
  require "net/http"
7
+ require "kettle/ndjson"
7
8
 
8
9
  module Kettle
9
10
  module Dev
@@ -47,10 +48,10 @@ module Kettle
47
48
  #
48
49
  # @param restart_hint [String] guidance command shown on failure
49
50
  # @return [void]
50
- def monitor_all!(restart_hint: "bundle exec kettle-release start_step=10", workflows: nil, keepalive: nil, **options)
51
+ def monitor_all!(restart_hint: "bundle exec kettle-release start_step=10", workflows: nil, keepalive: nil, event_recorder: nil, **options)
51
52
  checks_any = false
52
- checks_any |= monitor_github_internal!(restart_hint: restart_hint, workflows: workflows, keepalive: keepalive)
53
- checks_any |= monitor_gitlab_internal!(restart_hint: restart_hint, keepalive: keepalive)
53
+ checks_any |= monitor_github_internal!(restart_hint: restart_hint, workflows: workflows, keepalive: keepalive, event_recorder: event_recorder)
54
+ checks_any |= monitor_gitlab_internal!(restart_hint: restart_hint, keepalive: keepalive, event_recorder: event_recorder)
54
55
  abort("CI configuration not detected (GitHub or GitLab). Ensure CI is configured and remotes point to the correct hosts.") unless checks_any
55
56
  end
56
57
 
@@ -267,7 +268,7 @@ module Kettle
267
268
  end
268
269
  module_function :collect_gitlab
269
270
 
270
- def monitor_github_internal!(restart_hint:, workflows: nil, keepalive: nil)
271
+ def monitor_github_internal!(restart_hint:, workflows: nil, keepalive: nil, event_recorder: nil)
271
272
  root = Kettle::Dev::CIHelpers.project_root
272
273
  workflows = Array(workflows).empty? ? Kettle::Dev::CIHelpers.workflows_list(root) : Array(workflows)
273
274
  gh_remote = preferred_github_remote
@@ -288,8 +289,31 @@ module Kettle
288
289
 
289
290
  passed = {}
290
291
  started = {}
292
+ emit_ci_monitor_event(
293
+ event_recorder,
294
+ action: "github_discover",
295
+ status: "started",
296
+ provider: "github",
297
+ remote: gh_remote,
298
+ owner: owner,
299
+ repo: repo,
300
+ branch: branch,
301
+ head_sha: head_sha,
302
+ workflows: workflows,
303
+ total: total
304
+ )
291
305
  puts "Ensuring GitHub Actions workflows pass on #{branch} (#{owner}/#{repo}) via remote '#{gh_remote}'"
292
306
  puts "Waiting for GitHub Actions runs to start for HEAD #{head_sha[0, 12]}."
307
+ emit_ci_monitor_event(
308
+ event_recorder,
309
+ action: "github_wait",
310
+ status: "started",
311
+ provider: "github",
312
+ branch: branch,
313
+ head_sha: head_sha,
314
+ completed: 0,
315
+ total: total
316
+ )
293
317
  pbar = if defined?(ProgressBar)
294
318
  ProgressBar.create(title: "CI", total: total, format: "%t %b %c/%C", length: 30)
295
319
  end
@@ -308,27 +332,105 @@ module Kettle
308
332
  start_deadline = monotonic_time + start_timeout
309
333
  keepalive_timer = keepalive_timer(keepalive)
310
334
  idx = 0
335
+ all_started_emitted = false
311
336
  loop do
312
337
  keepalive_timer.call
313
338
  wf = workflows[idx]
314
339
  run = Kettle::Dev::CIHelpers.latest_run(owner: owner, repo: repo, workflow_file: wf, branch: branch, require_head: true, head_sha: head_sha)
340
+ completed_workflow = nil
315
341
  if run
316
- started[wf] = true
342
+ unless started[wf]
343
+ started[wf] = true
344
+ emit_ci_monitor_event(
345
+ event_recorder,
346
+ action: "github_workflow",
347
+ status: "started",
348
+ provider: "github",
349
+ workflow: wf,
350
+ url: run["html_url"],
351
+ run_status: run["status"],
352
+ conclusion: run["conclusion"],
353
+ completed: passed.size,
354
+ total: total
355
+ )
356
+ end
357
+ if started.size == total && !all_started_emitted
358
+ all_started_emitted = true
359
+ emit_ci_monitor_event(
360
+ event_recorder,
361
+ action: "github_started",
362
+ status: "ok",
363
+ provider: "github",
364
+ completed: passed.size,
365
+ total: total,
366
+ started: started.size
367
+ )
368
+ end
317
369
  if Kettle::Dev::CIHelpers.success?(run)
318
370
  unless passed[wf]
319
371
  passed[wf] = true
372
+ completed_workflow = wf
320
373
  pbar&.increment
374
+ emit_ci_monitor_event(
375
+ event_recorder,
376
+ action: "github_workflow",
377
+ status: "ok",
378
+ provider: "github",
379
+ workflow: wf,
380
+ url: run["html_url"],
381
+ run_status: run["status"],
382
+ conclusion: run["conclusion"],
383
+ completed: passed.size,
384
+ total: total
385
+ )
321
386
  end
322
387
  elsif Kettle::Dev::CIHelpers.failed?(run)
323
388
  puts
324
389
  wf_url = run["html_url"] || "https://github.com/#{owner}/#{repo}/actions/workflows/#{wf}"
390
+ emit_ci_monitor_event(
391
+ event_recorder,
392
+ action: "github_workflow",
393
+ status: "failed",
394
+ provider: "github",
395
+ workflow: wf,
396
+ url: wf_url,
397
+ run_status: run["status"],
398
+ conclusion: run["conclusion"],
399
+ completed: passed.size,
400
+ total: total,
401
+ reason: "Workflow failed"
402
+ )
325
403
  abort("Workflow failed: #{wf} -> #{wf_url} Fix the workflow, then restart this tool from CI validation with: #{restart_hint}")
326
404
  end
327
405
  end
406
+ emit_ci_monitor_event(
407
+ event_recorder,
408
+ action: "github_tick",
409
+ status: "started",
410
+ provider: "github",
411
+ workflow: wf,
412
+ run_status: run && run["status"],
413
+ conclusion: run && run["conclusion"],
414
+ completed_workflow: completed_workflow,
415
+ completed: passed.size,
416
+ total: total,
417
+ started: started.size
418
+ )
328
419
  break if passed.size == total
329
420
  if started.size < total && monotonic_time >= start_deadline
330
421
  missing = (workflows - started.keys).join(", ")
331
422
  puts
423
+ emit_ci_monitor_event(
424
+ event_recorder,
425
+ action: "github_timeout",
426
+ status: "failed",
427
+ provider: "github",
428
+ missing: workflows - started.keys,
429
+ head_sha: head_sha,
430
+ total: total,
431
+ completed: passed.size,
432
+ reason: "Timed out waiting for workflows to start"
433
+ )
332
434
  abort("Timed out after #{start_timeout}s waiting for GitHub Actions workflows to start for HEAD #{head_sha[0, 12]}: #{missing}. Confirm GitHub Actions started, then restart this tool from CI validation with: #{restart_hint}")
333
435
  end
334
436
 
@@ -336,6 +438,15 @@ module Kettle
336
438
  sleep(poll_interval)
337
439
  end
338
440
  pbar&.finish unless pbar&.finished?
441
+ emit_ci_monitor_event(
442
+ event_recorder,
443
+ action: "github_complete",
444
+ status: "ok",
445
+ provider: "github",
446
+ workflows: workflows,
447
+ completed: passed.size,
448
+ total: total
449
+ )
339
450
  puts "\nAll GitHub workflows passing (#{passed.size}/#{total})."
340
451
  true
341
452
  end
@@ -391,7 +502,7 @@ module Kettle
391
502
  end
392
503
  module_function :keepalive_interval
393
504
 
394
- def monitor_gitlab_internal!(restart_hint:, keepalive: nil)
505
+ def monitor_gitlab_internal!(restart_hint:, keepalive: nil, event_recorder: nil)
395
506
  root = Kettle::Dev::CIHelpers.project_root
396
507
  gitlab_ci = File.exist?(File.join(root, ".gitlab-ci.yml"))
397
508
  gl_remote = gitlab_remote_candidates.first
@@ -403,6 +514,17 @@ module Kettle
403
514
  owner, repo = Kettle::Dev::CIHelpers.repo_info_gitlab
404
515
  return false unless owner && repo
405
516
 
517
+ emit_ci_monitor_event(
518
+ event_recorder,
519
+ action: "gitlab_discover",
520
+ status: "started",
521
+ provider: "gitlab",
522
+ remote: gl_remote,
523
+ owner: owner,
524
+ repo: repo,
525
+ branch: branch,
526
+ total: 1
527
+ )
406
528
  puts "Ensuring GitLab pipeline passes on #{branch} (#{owner}/#{repo}) via remote '#{gl_remote}'"
407
529
  pbar = if defined?(ProgressBar)
408
530
  ProgressBar.create(title: "CI", total: 1, format: "%t %b %c/%C", length: 30)
@@ -412,8 +534,19 @@ module Kettle
412
534
  keepalive_timer.call
413
535
  pipe = Kettle::Dev::CIHelpers.gitlab_latest_pipeline(owner: owner, repo: repo, branch: branch)
414
536
  if pipe
537
+ pipe_url = pipe["web_url"] || "https://gitlab.com/#{owner}/#{repo}/-/pipelines"
415
538
  if Kettle::Dev::CIHelpers.gitlab_success?(pipe)
416
539
  pbar&.increment unless pbar&.finished?
540
+ emit_ci_monitor_event(
541
+ event_recorder,
542
+ action: "gitlab_pipeline",
543
+ status: "ok",
544
+ provider: "gitlab",
545
+ url: pipe_url,
546
+ run_status: pipe["status"],
547
+ completed: 1,
548
+ total: 1
549
+ )
417
550
  break
418
551
  elsif Kettle::Dev::CIHelpers.gitlab_failed?(pipe)
419
552
  # Special-case: if failure is due to exhausted minutes/insufficient quota, treat as unknown and continue
@@ -421,27 +554,93 @@ module Kettle
421
554
  if /insufficient|quota|minute/i.match?(reason)
422
555
  puts "\nGitLab reports pipeline cannot run due to quota/minutes exhaustion. Result is unknown; continuing."
423
556
  pbar&.finish unless pbar&.finished?
557
+ emit_ci_monitor_event(
558
+ event_recorder,
559
+ action: "gitlab_pipeline",
560
+ status: "skipped",
561
+ provider: "gitlab",
562
+ url: pipe_url,
563
+ run_status: pipe["status"],
564
+ reason: reason,
565
+ completed: 0,
566
+ total: 1
567
+ )
424
568
  break
425
569
  else
426
570
  puts
427
- url = pipe["web_url"] || "https://gitlab.com/#{owner}/#{repo}/-/pipelines"
571
+ emit_ci_monitor_event(
572
+ event_recorder,
573
+ action: "gitlab_pipeline",
574
+ status: "failed",
575
+ provider: "gitlab",
576
+ url: pipe_url,
577
+ run_status: pipe["status"],
578
+ reason: reason,
579
+ completed: 0,
580
+ total: 1
581
+ )
582
+ url = pipe_url
428
583
  abort("Pipeline failed: #{url} Fix the pipeline, then restart this tool from CI validation with: #{restart_hint}")
429
584
  end
430
585
  elsif pipe["status"] == "blocked"
431
586
  # Blocked pipeline (e.g., awaiting approvals) — treat as unknown and continue
432
587
  puts "\nGitLab pipeline is blocked. Result is unknown; continuing."
433
588
  pbar&.finish unless pbar&.finished?
589
+ emit_ci_monitor_event(
590
+ event_recorder,
591
+ action: "gitlab_pipeline",
592
+ status: "skipped",
593
+ provider: "gitlab",
594
+ url: pipe_url,
595
+ run_status: pipe["status"],
596
+ reason: "blocked",
597
+ completed: 0,
598
+ total: 1
599
+ )
434
600
  break
435
601
  end
436
602
  end
437
603
  sleep(1)
438
604
  end
439
605
  pbar&.finish unless pbar&.finished?
606
+ emit_ci_monitor_event(
607
+ event_recorder,
608
+ action: "gitlab_complete",
609
+ status: "ok",
610
+ provider: "gitlab",
611
+ completed: 1,
612
+ total: 1
613
+ )
440
614
  puts "\nGitLab pipeline passing."
441
615
  true
442
616
  end
443
617
  module_function :monitor_gitlab_internal!
444
618
 
619
+ def emit_ci_monitor_event(event_recorder, action:, status:, provider:, **payload)
620
+ mark = case status.to_s
621
+ when "started"
622
+ ">"
623
+ when "ok", "skipped"
624
+ "."
625
+ when "failed"
626
+ "!"
627
+ else
628
+ "?"
629
+ end
630
+ Kettle::Ndjson.emit_event(
631
+ event_recorder,
632
+ "ci_monitor",
633
+ payload.merge(
634
+ action: action,
635
+ phase: "release",
636
+ provider: provider,
637
+ status: status,
638
+ mark: mark
639
+ )
640
+ )
641
+ end
642
+ module_function :emit_ci_monitor_event
643
+
445
644
  # -- tiny wrappers around GitAdapter-like helpers used by ReleaseCLI --
446
645
  def remotes_with_urls
447
646
  Kettle::Dev::GitAdapter.new.remotes_with_urls
@@ -10,6 +10,7 @@ require "net/http"
10
10
  require "openssl"
11
11
  require "time"
12
12
  require_relative "cache_progress"
13
+ require "kettle/ndjson"
13
14
  begin
14
15
  require "addressable/uri"
15
16
  rescue LoadError
@@ -271,9 +272,10 @@ module Kettle
271
272
  end
272
273
 
273
274
  # @param check_num [Integer] 1-based index to resume from
274
- def initialize(check_num: 1)
275
+ def initialize(check_num: 1, event_recorder: nil)
275
276
  @check_num = (check_num || 1).to_i
276
277
  @check_num = 1 if @check_num < 1
278
+ @event_recorder = event_recorder
277
279
  @image_url_cache_path = configured_image_url_cache_path
278
280
  @refresh_image_url_cache = env_truthy?(ENV["KETTLE_IMAGE_URL_CACHE_REFRESH"])
279
281
  @image_url_skip_patterns = configured_image_url_skip_patterns
@@ -294,7 +296,14 @@ module Kettle
294
296
  checks[begin_idx..-1].each_with_index do |check, i|
295
297
  idx = begin_idx + i + 1
296
298
  puts "[kettle-pre-release] Running check ##{idx} of #{checks.size}"
297
- check.call
299
+ emit_pre_release_event(action: "check", status: "started", check: check_name(check), index: idx, total: checks.size)
300
+ begin
301
+ check.call
302
+ emit_pre_release_event(action: "check", status: "ok", check: check_name(check), index: idx, total: checks.size)
303
+ rescue => error
304
+ emit_pre_release_event(action: "check", status: "failed", check: check_name(check), index: idx, total: checks.size, reason: "#{error.class}: #{error.message}")
305
+ raise
306
+ end
298
307
  end
299
308
  nil
300
309
  end
@@ -355,6 +364,7 @@ module Kettle
355
364
  end
356
365
 
357
366
  puts "[kettle-pre-release] Normalization candidates: #{total_candidates}. Files changed: #{changed.uniq.size}."
367
+ emit_pre_release_event(action: "markdown_urls", status: "ok", candidates: total_candidates, changed_files: changed.uniq.size)
358
368
  nil
359
369
  end
360
370
 
@@ -408,10 +418,12 @@ module Kettle
408
418
  puts "[kettle-pre-release] Image URL checks: #{progress.cached_count} cached, #{progress.live_count} live."
409
419
  puts "[kettle-pre-release] Skipped #{progress.skipped_count} image URL check(s)." if skipped.any?
410
420
  if failures.any?
421
+ emit_pre_release_event(action: "image_links", status: "failed", urls: urls.size, cached: progress.cached_count, live: progress.live_count, skipped: progress.skipped_count, failures: failures.size)
411
422
  warn("[kettle-pre-release] #{failures.size} image URL(s) failed validation:")
412
423
  failures.each { |u| warn(" - #{u}") }
413
424
  Kettle::Dev::ExitAdapter.abort("Image link validation failed")
414
425
  else
426
+ emit_pre_release_event(action: "image_links", status: "ok", urls: urls.size, cached: progress.cached_count, live: progress.live_count, skipped: progress.skipped_count, failures: 0)
415
427
  puts "[kettle-pre-release] All image links validated."
416
428
  end
417
429
  nil
@@ -419,6 +431,33 @@ module Kettle
419
431
 
420
432
  private
421
433
 
434
+ def check_name(check)
435
+ check.name.to_s.sub(/\Acheck_/, "").sub(/!\z/, "")
436
+ end
437
+
438
+ def emit_pre_release_event(action:, status:, **payload)
439
+ mark = case status.to_s
440
+ when "started"
441
+ ">"
442
+ when "ok", "skipped"
443
+ "."
444
+ when "failed"
445
+ "!"
446
+ else
447
+ "?"
448
+ end
449
+ Kettle::Ndjson.emit_event(
450
+ @event_recorder,
451
+ "pre_release",
452
+ payload.merge(
453
+ action: action,
454
+ phase: "release",
455
+ status: status,
456
+ mark: mark
457
+ )
458
+ )
459
+ end
460
+
422
461
  def image_url_cache
423
462
  return nil if @image_url_cache_path.to_s.empty?
424
463
 
@@ -117,14 +117,15 @@ module Kettle
117
117
 
118
118
  public
119
119
 
120
- def initialize(start_step: 0, local_ci: false, version: nil, appraisal_task: nil, skip_steps: nil, skip_bundle_audit: nil, ci_workflows: nil, skip_remotes: nil, secrets_provider_name: nil, yes: false, **options)
120
+ def initialize(start_step: 0, local_ci: false, version: nil, appraisal_task: nil, skip_steps: nil, skip_bundle_audit: nil, ci_workflows: nil, skip_remotes: nil, required_remotes: nil, secrets_provider_name: nil, yes: false, **options)
121
121
  @root = Kettle::Dev::CIHelpers.project_root
122
122
  @git = Kettle::Dev::GitAdapter.new(@root)
123
123
  @start_step = (start_step || 0).to_i
124
124
  @start_step = 0 if @start_step < 0
125
125
  @skip_steps = normalize_skip_steps(skip_steps)
126
126
  @ci_workflows = normalize_ci_workflows(ci_workflows || ENV["K_RELEASE_CI_WORKFLOWS"])
127
- @skip_remotes = normalize_skip_remotes(skip_remotes || ENV["K_RELEASE_SKIP_REMOTES"])
127
+ @skip_remotes = normalize_remote_names(skip_remotes || ENV["K_RELEASE_SKIP_REMOTES"], "skip remotes")
128
+ @required_remotes = normalize_required_remotes(required_remotes)
128
129
  @local_ci = !!local_ci
129
130
  @skip_bundle_audit = truthy_value?(skip_bundle_audit) || truthy_value?(ENV["KETTLE_DEV_SKIP_BUNDLE_AUDIT"])
130
131
  @yes = !!yes
@@ -479,14 +480,19 @@ module Kettle
479
480
  workflows.map { |workflow| workflow.match?(/\.ya?ml\z/) ? workflow : "#{workflow}.yml" }.uniq
480
481
  end
481
482
 
482
- def normalize_skip_remotes(value)
483
+ def normalize_remote_names(value, label)
483
484
  remotes = Array(value).flat_map { |part| part.to_s.split(",") }.map(&:strip).reject(&:empty?)
484
485
  invalid = remotes.find { |remote| !remote.match?(/\A[A-Za-z0-9_.-]+\z/) }
485
- abort("Invalid skip remotes value #{invalid.inspect}; use comma-separated git remote names.") if invalid
486
+ abort("Invalid #{label} value #{invalid.inspect}; use comma-separated git remote names.") if invalid
486
487
 
487
488
  remotes.uniq
488
489
  end
489
490
 
491
+ def normalize_required_remotes(value)
492
+ remotes = normalize_remote_names(value.nil? ? ENV["K_RELEASE_REQUIRED_REMOTES"] : value, "required remotes")
493
+ remotes.empty? ? ["origin"] : remotes
494
+ end
495
+
490
496
  private
491
497
 
492
498
  def local_ci?
@@ -571,7 +577,7 @@ module Kettle
571
577
 
572
578
  def run_pre_release_checks!
573
579
  puts "Running pre-release checks via kettle-pre-release..."
574
- Kettle::Dev::PreReleaseCLI.new(check_num: 1).run
580
+ Kettle::Dev::PreReleaseCLI.new(check_num: 1, event_recorder: @event_recorder).run
575
581
  run_changelog!
576
582
  end
577
583
 
@@ -590,9 +596,11 @@ module Kettle
590
596
  attempts = release_lockfile_reset_attempts(stage)
591
597
  attempts.times do |index|
592
598
  attempt = index + 1
599
+ emit_release_lockfile_event(action: "reset", status: "started", stage: stage, attempt: attempt, attempts: attempts)
593
600
  puts "Resetting release lockfiles with local path dependencies disabled #{stage} (attempt #{attempt}/#{attempts})..."
594
601
  begin
595
602
  lockfile_reset.reset(Kettle::Dev::LockfileReset::RELEASE_LOCKFILES_TARGET)
603
+ emit_release_lockfile_event(action: "reset", status: "ok", stage: stage, attempt: attempt, attempts: attempts)
596
604
  break
597
605
  rescue Kettle::Dev::Error => error
598
606
  raise unless error.message.start_with?("Reset #{Kettle::Dev::LockfileReset::RELEASE_LOCKFILES_TARGET} failed validation:")
@@ -600,9 +608,26 @@ module Kettle
600
608
  # Keep release-facing failures shaped like the lockfile validation
601
609
  # guard, even when the shared reset helper is the component that
602
610
  # detects the unrepaired lockfile.
611
+ emit_release_lockfile_event(
612
+ action: "reset",
613
+ status: "blocked",
614
+ stage: stage,
615
+ attempt: attempt,
616
+ attempts: attempts,
617
+ reason: error.message
618
+ )
603
619
  break
604
620
  rescue => error
605
- raise unless retryable_release_lockfile_reset_error?(error) && attempt < attempts
621
+ retryable = retryable_release_lockfile_reset_error?(error) && attempt < attempts
622
+ emit_release_lockfile_event(
623
+ action: "reset",
624
+ status: retryable ? "retrying" : "failed",
625
+ stage: stage,
626
+ attempt: attempt,
627
+ attempts: attempts,
628
+ reason: error.message
629
+ )
630
+ raise unless retryable
606
631
 
607
632
  puts "Release lockfile reset could not resolve a gem from #{RELEASE_VALIDATION_SOURCE}; waiting before retry #{attempt + 1}/#{attempts}."
608
633
  sleep(release_availability_probe_interval)
@@ -611,7 +636,16 @@ module Kettle
611
636
 
612
637
  diagnostics = release_lockfile_paths.flat_map { |path| release_lockfile_diagnostics(path) }
613
638
  if diagnostics.empty?
639
+ emit_release_lockfile_event(action: "validate", status: "ok", stage: stage, count: release_lockfile_paths.length)
614
640
  puts "Release lockfile reset complete: #{release_lockfile_paths.length} lockfile(s) checked, no diagnostics remain."
641
+ else
642
+ emit_release_lockfile_event(
643
+ action: "validate",
644
+ status: "failed",
645
+ stage: stage,
646
+ count: diagnostics.length,
647
+ reason: "#{diagnostics.length} diagnostic(s)"
648
+ )
615
649
  end
616
650
  @release_lockfiles_reset_for_release_tasks = true if stage == "before release task bundle installs"
617
651
  end
@@ -758,6 +792,7 @@ module Kettle
758
792
  cmd = "bundle exec kettle-changelog"
759
793
  cmd = "#{cmd} --version #{Shellwords.escape(@version_override)}" if @version_override
760
794
  cmd = "#{cmd} --yes" if @yes
795
+ cmd = "#{cmd} --events=changelog" if @event_stream
761
796
  run_cmd!(cmd)
762
797
  @changelog_generated_coverage = true
763
798
  end
@@ -1010,12 +1045,24 @@ module Kettle
1010
1045
  def monitor_workflows_after_push!
1011
1046
  ensure_github_pull_request_for_ci!
1012
1047
  keep_release_secrets_alive!("CI monitoring")
1048
+ restart_hint = "bundle exec kettle-release start_step=10"
1049
+ emit_ci_monitor_event(action: "start", status: "started", workflows: @ci_workflows, restart_hint: restart_hint)
1013
1050
  # Use abort-on-failure CI monitor to match historical behavior and specs
1014
- Kettle::Dev::CIMonitor.monitor_all!(
1015
- restart_hint: "bundle exec kettle-release start_step=10",
1016
- workflows: @ci_workflows,
1017
- keepalive: release_secrets_configured? ? -> { keep_release_secrets_alive!("CI monitoring") } : nil
1018
- )
1051
+ begin
1052
+ Kettle::Dev::CIMonitor.monitor_all!(
1053
+ restart_hint: restart_hint,
1054
+ workflows: @ci_workflows,
1055
+ keepalive: release_secrets_configured? ? -> { keep_release_secrets_alive!("CI monitoring") } : nil,
1056
+ event_recorder: @event_recorder
1057
+ )
1058
+ emit_ci_monitor_event(action: "finish", status: "ok", workflows: @ci_workflows, restart_hint: restart_hint)
1059
+ rescue SystemExit => error
1060
+ emit_ci_monitor_event(action: "finish", status: "failed", workflows: @ci_workflows, restart_hint: restart_hint, reason: error.message)
1061
+ raise
1062
+ rescue => error
1063
+ emit_ci_monitor_event(action: "finish", status: "failed", workflows: @ci_workflows, restart_hint: restart_hint, reason: "#{error.class}: #{error.message}")
1064
+ raise
1065
+ end
1019
1066
  end
1020
1067
 
1021
1068
  def ensure_github_pull_request_for_ci!
@@ -1412,6 +1459,7 @@ module Kettle
1412
1459
  sleep(release_availability_probe_initial_delay)
1413
1460
  attempts.times do |index|
1414
1461
  attempt = index + 1
1462
+ emit_release_probe_event(action: "availability", status: "started", candidate: candidate, attempt: attempt, attempts: attempts)
1415
1463
  puts("Validating #{candidate.gem_name} #{candidate.version} from #{RELEASE_VALIDATION_SOURCE} (attempt #{attempt}/#{attempts})")
1416
1464
  stdout_str = nil
1417
1465
  stderr_str = nil
@@ -1420,10 +1468,21 @@ module Kettle
1420
1468
  stdout_str, stderr_str, status = Open3.capture3(self.class.send(:command_env), Gem.ruby, script_path)
1421
1469
  end
1422
1470
  $stdout.print(stdout_str) unless stdout_str.to_s.empty?
1423
- return true if status.success?
1471
+ if status.success?
1472
+ emit_release_probe_event(action: "availability", status: "ok", candidate: candidate, attempt: attempt, attempts: attempts)
1473
+ return true
1474
+ end
1424
1475
 
1425
1476
  last_stderr = stderr_str
1426
1477
  last_status = status
1478
+ emit_release_probe_event(
1479
+ action: "availability",
1480
+ status: (attempt < attempts) ? "retrying" : "failed",
1481
+ candidate: candidate,
1482
+ attempt: attempt,
1483
+ attempts: attempts,
1484
+ reason: "exit #{status.exitstatus}"
1485
+ )
1427
1486
  sleep(release_availability_probe_interval) if attempt < attempts
1428
1487
  end
1429
1488
 
@@ -1590,6 +1649,7 @@ module Kettle
1590
1649
  name: command_event_name(command),
1591
1650
  status: status,
1592
1651
  reason: reason,
1652
+ summary: command_event_summary(command),
1593
1653
  command: command,
1594
1654
  changed_files: []
1595
1655
  }
@@ -1598,7 +1658,80 @@ module Kettle
1598
1658
  end
1599
1659
 
1600
1660
  def command_event_name(command)
1601
- command.to_s.split(/\s+/, 3).first(2).join("_").gsub(/[^A-Za-z0-9_]+/, "_").sub(/_+\z/, "")
1661
+ words = Shellwords.split(command.to_s)
1662
+ effective_words = command_event_effective_words(words)
1663
+ joined = effective_words.join(" ")
1664
+
1665
+ return "kettle_changelog" if joined.start_with?("bundle exec kettle-changelog")
1666
+ return "gem_build" if joined == "bundle exec rake build"
1667
+ return "gem_release" if joined == "bundle exec rake release"
1668
+ return "gem_push" if effective_words.first(2) == ["gem", "push"]
1669
+ return "gem_checksums" if effective_words.first == "bin/gem_checksums"
1670
+ return "bundle_lock" if effective_words.first(2) == ["bundle", "lock"]
1671
+ return "bin_setup" if effective_words == ["bin/setup"]
1672
+ return "default_task" if effective_words == ["bin/rake"]
1673
+ return "appraisal_generate" if effective_words == ["bin/rake", "appraisal:generate"]
1674
+ return "appraisal_update" if effective_words == ["bin/rake", "appraisal:update"]
1675
+ return "yard" if effective_words == ["bin/rake", "yard"]
1676
+ return "git_fetch" if effective_words.first(2) == ["git", "fetch"]
1677
+ return "git_pull" if effective_words.first(2) == ["git", "pull"]
1678
+ return "git_rebase" if effective_words.first(2) == ["git", "rebase"]
1679
+ return "git_merge" if effective_words.first(2) == ["git", "merge"]
1680
+ return "git_push" if effective_words.first(2) == ["git", "push"]
1681
+
1682
+ effective_words.first(2).join("_").gsub(/[^A-Za-z0-9_]+/, "_").sub(/_+\z/, "")
1683
+ end
1684
+
1685
+ def command_event_effective_words(words)
1686
+ words = words.dup
1687
+ words.shift while words.first&.match?(/\A[A-Za-z_][A-Za-z0-9_]*=/)
1688
+
1689
+ if words.first == "env"
1690
+ words.shift
1691
+ while words.any?
1692
+ if words.first == "-u"
1693
+ words.shift(2)
1694
+ elsif words.first&.include?("=")
1695
+ words.shift
1696
+ else
1697
+ break
1698
+ end
1699
+ end
1700
+ end
1701
+
1702
+ words
1703
+ end
1704
+
1705
+ def command_event_summary(command)
1706
+ words = Shellwords.split(command.to_s)
1707
+ effective_words = command_event_effective_words(words)
1708
+ joined = effective_words.join(" ")
1709
+
1710
+ return "changelog" if joined.start_with?("bundle exec kettle-changelog")
1711
+ return command_event_bundle_lock_summary(words) if effective_words.first(2) == ["bundle", "lock"]
1712
+ return "setup" if effective_words == ["bin/setup"]
1713
+ return "default task" if effective_words == ["bin/rake"]
1714
+ return "appraisals" if effective_words == ["bin/rake", "appraisal:generate"]
1715
+ return "appraisals" if effective_words == ["bin/rake", "appraisal:update"]
1716
+ return "documentation" if effective_words == ["bin/rake", "yard"]
1717
+ return "build gem" if joined == "bundle exec rake build"
1718
+ return "publish gem" if joined == "bundle exec rake release"
1719
+ return "push gem" if effective_words.first(2) == ["gem", "push"]
1720
+ return "checksums" if effective_words.first == "bin/gem_checksums"
1721
+ return effective_words[2] if effective_words.first(2) == ["git", "fetch"] && effective_words[2]
1722
+ return effective_words[2] if effective_words.first(2) == ["git", "push"] && effective_words[2]
1723
+ return effective_words[2] if effective_words.first(2) == ["git", "pull"] && effective_words[2]
1724
+ return effective_words[2] if effective_words.first(2) == ["git", "rebase"] && effective_words[2]
1725
+ return effective_words[2] if effective_words.first(2) == ["git", "merge"] && effective_words[2]
1726
+
1727
+ nil
1728
+ end
1729
+
1730
+ def command_event_bundle_lock_summary(words)
1731
+ gemfile = words.find { |word| word.start_with?("BUNDLE_GEMFILE=") }&.split("=", 2)&.last
1732
+ return "lockfile" unless gemfile
1733
+
1734
+ File.basename(gemfile)
1602
1735
  end
1603
1736
 
1604
1737
  def record_diagnostic(kind, message, severity:, blocking:)
@@ -1635,6 +1768,97 @@ module Kettle
1635
1768
  )
1636
1769
  end
1637
1770
 
1771
+ def emit_remote_parity_event(action:, remote: nil, status: nil, trunk: nil, attempt: nil, attempts: nil, required: nil, reason: nil, remotes: nil)
1772
+ mark = release_progress_mark(status)
1773
+ Kettle::Ndjson.emit_event(
1774
+ @event_recorder,
1775
+ "remote_parity",
1776
+ action: action,
1777
+ phase: "release",
1778
+ remote: remote,
1779
+ status: status,
1780
+ trunk: trunk,
1781
+ attempt: attempt,
1782
+ attempts: attempts,
1783
+ required: required,
1784
+ reason: reason,
1785
+ remotes: remotes,
1786
+ mark: mark
1787
+ )
1788
+ end
1789
+
1790
+ def emit_release_lockfile_event(action:, status:, stage:, attempt: nil, attempts: nil, count: nil, reason: nil)
1791
+ Kettle::Ndjson.emit_event(
1792
+ @event_recorder,
1793
+ "release_lockfile",
1794
+ action: action,
1795
+ phase: "release",
1796
+ status: status,
1797
+ stage: stage,
1798
+ attempt: attempt,
1799
+ attempts: attempts,
1800
+ count: count,
1801
+ reason: reason,
1802
+ mark: release_progress_mark(status)
1803
+ )
1804
+ end
1805
+
1806
+ def emit_release_probe_event(action:, status:, candidate:, attempt:, attempts:, reason: nil)
1807
+ Kettle::Ndjson.emit_event(
1808
+ @event_recorder,
1809
+ "release_probe",
1810
+ action: action,
1811
+ phase: "release",
1812
+ status: status,
1813
+ gem: candidate.gem_name,
1814
+ version: candidate.version,
1815
+ source: RELEASE_VALIDATION_SOURCE,
1816
+ attempt: attempt,
1817
+ attempts: attempts,
1818
+ reason: reason,
1819
+ mark: release_progress_mark(status)
1820
+ )
1821
+ end
1822
+
1823
+ def release_progress_mark(status)
1824
+ case status.to_s
1825
+ when "started"
1826
+ ">"
1827
+ when "ok", "skipped"
1828
+ "."
1829
+ when "failed", "blocked"
1830
+ "!"
1831
+ when "retrying"
1832
+ ">"
1833
+ else
1834
+ ">"
1835
+ end
1836
+ end
1837
+
1838
+ def emit_ci_monitor_event(action:, status:, workflows:, restart_hint:, reason: nil)
1839
+ mark = case status.to_s
1840
+ when "started"
1841
+ ">"
1842
+ when "ok"
1843
+ "."
1844
+ when "failed"
1845
+ "!"
1846
+ else
1847
+ "?"
1848
+ end
1849
+ Kettle::Ndjson.emit_event(
1850
+ @event_recorder,
1851
+ "ci_monitor",
1852
+ action: action,
1853
+ phase: "release",
1854
+ status: status,
1855
+ workflows: workflows,
1856
+ restart_hint: restart_hint,
1857
+ reason: reason,
1858
+ mark: mark
1859
+ )
1860
+ end
1861
+
1638
1862
  def finish_release_report(status:, error:)
1639
1863
  return if @finished_report
1640
1864
 
@@ -1798,6 +2022,10 @@ module Kettle
1798
2022
  @skip_remotes.include?(name)
1799
2023
  end
1800
2024
 
2025
+ def required_remote?(name)
2026
+ @required_remotes.include?(name)
2027
+ end
2028
+
1801
2029
  def active_remotes
1802
2030
  list_remotes.reject { |remote| skipped_remote?(remote) }
1803
2031
  end
@@ -1836,26 +2064,30 @@ module Kettle
1836
2064
  if has_remote?("all")
1837
2065
  remotes = active_remotes
1838
2066
  skipped = list_remotes.select { |remote| skipped_remote?(remote) }
2067
+ required = remotes.select { |remote| required_remote?(remote) }
1839
2068
  puts "Remote 'all' detected. Fetching from active remotes and enforcing strict trunk parity..."
1840
2069
  puts "Skipping configured remotes: #{skipped.join(", ")}" unless skipped.empty?
2070
+ puts "Required release parity remotes: #{required.join(", ")}" unless required.empty?
2071
+ fetched_remotes = []
2072
+ emit_remote_parity_event(action: "start", status: "started", trunk: trunk, remotes: remotes - ["all"])
1841
2073
  remotes.each do |remote|
1842
2074
  next if remote == "all"
1843
2075
 
1844
- fetch_remote_for_parity!(remote)
2076
+ fetched_remotes << remote if fetch_remote_for_parity!(remote)
1845
2077
  end
1846
2078
  missing_from = []
1847
- remotes.each do |r|
1848
- next if r == "all"
1849
-
2079
+ fetched_remotes.each do |r|
1850
2080
  if remote_branch_exists?(r, trunk)
1851
2081
  _ahead, behind = ahead_behind_counts(trunk, "#{r}/#{trunk}")
1852
2082
  missing_from << r if behind.positive?
1853
2083
  end
1854
2084
  end
1855
2085
  unless missing_from.empty?
2086
+ emit_remote_parity_event(action: "missing", status: "failed", trunk: trunk, remotes: missing_from, reason: "missing commits")
1856
2087
  abort("Local #{trunk} is missing commits present on: #{missing_from.join(", ")}. Please sync trunk first.")
1857
2088
  end
1858
- puts "Local #{trunk} has all commits from remotes: #{(remotes - ["all"]).join(", ")}"
2089
+ puts "Local #{trunk} has all commits from remotes: #{fetched_remotes.join(", ")}"
2090
+ emit_remote_parity_event(action: "ok", status: "ok", trunk: trunk, remotes: fetched_remotes)
1859
2091
  return
1860
2092
  end
1861
2093
 
@@ -1924,8 +2156,10 @@ module Kettle
1924
2156
  attempts.times do |index|
1925
2157
  attempt = index + 1
1926
2158
  puts "Fetching remote '#{remote}' for release parity (attempt #{attempt}/#{attempts})"
2159
+ emit_remote_parity_event(action: "fetch", remote: remote, status: "started", attempt: attempt, attempts: attempts, required: required_remote?(remote))
1927
2160
  begin
1928
2161
  run_cmd!(command)
2162
+ emit_remote_parity_event(action: "fetch", remote: remote, status: "ok", attempt: attempt, attempts: attempts, required: required_remote?(remote))
1929
2163
  return true
1930
2164
  rescue SystemExit => error
1931
2165
  last_error = error
@@ -1935,7 +2169,17 @@ module Kettle
1935
2169
  sleep(remote_fetch_parity_interval) if attempt < attempts
1936
2170
  end
1937
2171
 
1938
- abort(remote_fetch_failure_message(remote, last_error&.message.to_s))
2172
+ detail = last_error&.message.to_s
2173
+ if required_remote?(remote)
2174
+ emit_remote_parity_event(action: "fetch", remote: remote, status: "failed", attempts: attempts, required: true, reason: detail)
2175
+ abort(remote_fetch_failure_message(remote, detail))
2176
+ end
2177
+
2178
+ message = optional_remote_fetch_failure_message(remote, detail)
2179
+ warn(message)
2180
+ emit_remote_parity_event(action: "skip", remote: remote, status: "skipped", attempts: attempts, required: false, reason: detail)
2181
+ record_diagnostic("release_remote_skipped", message, severity: "warning", blocking: false)
2182
+ false
1939
2183
  end
1940
2184
 
1941
2185
  def remote_fetch_parity_attempts
@@ -1948,7 +2192,7 @@ module Kettle
1948
2192
 
1949
2193
  def remote_fetch_failure_message(remote, detail)
1950
2194
  <<~MSG
1951
- Unable to fetch git remote '#{remote}' during release parity checks.
2195
+ Unable to fetch required git remote '#{remote}' during release parity checks.
1952
2196
  kettle-release enforces trunk parity across active remotes before publishing.
1953
2197
 
1954
2198
  If this remote is temporarily unavailable or intentionally not part of this release,
@@ -1961,6 +2205,20 @@ module Kettle
1961
2205
  MSG
1962
2206
  end
1963
2207
 
2208
+ def optional_remote_fetch_failure_message(remote, detail)
2209
+ <<~MSG
2210
+ Unable to fetch optional git remote '#{remote}' during release parity checks; skipping this remote for this release.
2211
+ Required remotes still block release parity checks.
2212
+
2213
+ To make this remote block releases, configure:
2214
+ kettle-release --required-remotes #{remote}
2215
+ K_RELEASE_REQUIRED_REMOTES=#{remote} kettle-release
2216
+
2217
+ Original failure:
2218
+ #{detail}
2219
+ MSG
2220
+ end
2221
+
1964
2222
  def merge_feature_into_trunk_and_push!(trunk, feature)
1965
2223
  return if feature.nil? || feature == trunk
1966
2224
 
@@ -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.12"
8
+ VERSION = "2.5.13"
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.12
4
+ version: 2.5.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -80,7 +80,7 @@ dependencies:
80
80
  version: '0.1'
81
81
  - - ">="
82
82
  - !ruby/object:Gem::Version
83
- version: 0.1.4
83
+ version: 0.1.9
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.4
93
+ version: 0.1.9
94
94
  - !ruby/object:Gem::Dependency
95
95
  name: kettle-rb
96
96
  requirement: !ruby/object:Gem::Requirement
@@ -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.12
405
- changelog_uri: https://github.com/kettle-dev/kettle-dev/blob/v2.5.12/CHANGELOG.md
404
+ source_code_uri: https://github.com/kettle-dev/kettle-dev/tree/v2.5.13
405
+ changelog_uri: https://github.com/kettle-dev/kettle-dev/blob/v2.5.13/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.12
407
+ documentation_uri: https://www.rubydoc.info/gems/kettle-dev/2.5.13
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