kettle-dev 2.5.11 → 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.
@@ -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, **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)
53
- checks_any |= monitor_gitlab_internal!(restart_hint: restart_hint)
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)
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
@@ -306,27 +330,107 @@ module Kettle
306
330
  start_timeout = github_start_timeout
307
331
  poll_interval = github_poll_interval
308
332
  start_deadline = monotonic_time + start_timeout
333
+ keepalive_timer = keepalive_timer(keepalive)
309
334
  idx = 0
335
+ all_started_emitted = false
310
336
  loop do
337
+ keepalive_timer.call
311
338
  wf = workflows[idx]
312
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
313
341
  if run
314
- 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
315
369
  if Kettle::Dev::CIHelpers.success?(run)
316
370
  unless passed[wf]
317
371
  passed[wf] = true
372
+ completed_workflow = wf
318
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
+ )
319
386
  end
320
387
  elsif Kettle::Dev::CIHelpers.failed?(run)
321
388
  puts
322
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
+ )
323
403
  abort("Workflow failed: #{wf} -> #{wf_url} Fix the workflow, then restart this tool from CI validation with: #{restart_hint}")
324
404
  end
325
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
+ )
326
419
  break if passed.size == total
327
420
  if started.size < total && monotonic_time >= start_deadline
328
421
  missing = (workflows - started.keys).join(", ")
329
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
+ )
330
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}")
331
435
  end
332
436
 
@@ -334,6 +438,15 @@ module Kettle
334
438
  sleep(poll_interval)
335
439
  end
336
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
+ )
337
450
  puts "\nAll GitHub workflows passing (#{passed.size}/#{total})."
338
451
  true
339
452
  end
@@ -364,7 +477,32 @@ module Kettle
364
477
  end
365
478
  module_function :github_poll_interval
366
479
 
367
- def monitor_gitlab_internal!(restart_hint:)
480
+ def keepalive_timer(keepalive)
481
+ interval = keepalive_interval
482
+ last_run = nil
483
+ lambda do
484
+ if keepalive
485
+ now = monotonic_time
486
+ if !last_run || (now - last_run) >= interval
487
+ keepalive.call
488
+ last_run = now
489
+ end
490
+ end
491
+ end
492
+ end
493
+ module_function :keepalive_timer
494
+
495
+ def keepalive_interval
496
+ seconds = begin
497
+ Float(ENV["KETTLE_RELEASE_SECRET_KEEPALIVE_INTERVAL"])
498
+ rescue
499
+ nil
500
+ end
501
+ (seconds && seconds >= 0) ? seconds : 240.0
502
+ end
503
+ module_function :keepalive_interval
504
+
505
+ def monitor_gitlab_internal!(restart_hint:, keepalive: nil, event_recorder: nil)
368
506
  root = Kettle::Dev::CIHelpers.project_root
369
507
  gitlab_ci = File.exist?(File.join(root, ".gitlab-ci.yml"))
370
508
  gl_remote = gitlab_remote_candidates.first
@@ -376,15 +514,39 @@ module Kettle
376
514
  owner, repo = Kettle::Dev::CIHelpers.repo_info_gitlab
377
515
  return false unless owner && repo
378
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
+ )
379
528
  puts "Ensuring GitLab pipeline passes on #{branch} (#{owner}/#{repo}) via remote '#{gl_remote}'"
380
529
  pbar = if defined?(ProgressBar)
381
530
  ProgressBar.create(title: "CI", total: 1, format: "%t %b %c/%C", length: 30)
382
531
  end
532
+ keepalive_timer = keepalive_timer(keepalive)
383
533
  loop do
534
+ keepalive_timer.call
384
535
  pipe = Kettle::Dev::CIHelpers.gitlab_latest_pipeline(owner: owner, repo: repo, branch: branch)
385
536
  if pipe
537
+ pipe_url = pipe["web_url"] || "https://gitlab.com/#{owner}/#{repo}/-/pipelines"
386
538
  if Kettle::Dev::CIHelpers.gitlab_success?(pipe)
387
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
+ )
388
550
  break
389
551
  elsif Kettle::Dev::CIHelpers.gitlab_failed?(pipe)
390
552
  # Special-case: if failure is due to exhausted minutes/insufficient quota, treat as unknown and continue
@@ -392,27 +554,93 @@ module Kettle
392
554
  if /insufficient|quota|minute/i.match?(reason)
393
555
  puts "\nGitLab reports pipeline cannot run due to quota/minutes exhaustion. Result is unknown; continuing."
394
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
+ )
395
568
  break
396
569
  else
397
570
  puts
398
- 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
399
583
  abort("Pipeline failed: #{url} Fix the pipeline, then restart this tool from CI validation with: #{restart_hint}")
400
584
  end
401
585
  elsif pipe["status"] == "blocked"
402
586
  # Blocked pipeline (e.g., awaiting approvals) — treat as unknown and continue
403
587
  puts "\nGitLab pipeline is blocked. Result is unknown; continuing."
404
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
+ )
405
600
  break
406
601
  end
407
602
  end
408
603
  sleep(1)
409
604
  end
410
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
+ )
411
614
  puts "\nGitLab pipeline passing."
412
615
  true
413
616
  end
414
617
  module_function :monitor_gitlab_internal!
415
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
+
416
644
  # -- tiny wrappers around GitAdapter-like helpers used by ReleaseCLI --
417
645
  def remotes_with_urls
418
646
  Kettle::Dev::GitAdapter.new.remotes_with_urls
@@ -5,11 +5,12 @@ require "open3"
5
5
  module Kettle
6
6
  module Dev
7
7
  class InteractiveReleaseCommand
8
- def initialize(secrets_provider:, input: $stdin, output: $stdout, error: $stderr)
8
+ def initialize(secrets_provider:, input: $stdin, output: $stdout, error: $stderr, secret_event_handler: nil)
9
9
  @secrets_provider = secrets_provider
10
10
  @input = input
11
11
  @output = output
12
12
  @error = error
13
+ @secret_event_handler = secret_event_handler
13
14
  @gem_signing_passphrase = nil
14
15
  end
15
16
 
@@ -21,7 +22,7 @@ module Kettle
21
22
 
22
23
  private
23
24
 
24
- attr_reader :secrets_provider
25
+ attr_reader :secrets_provider, :secret_event_handler
25
26
 
26
27
  def call_with_pty(env, cmd)
27
28
  stdout_str = +""
@@ -77,18 +78,20 @@ module Kettle
77
78
 
78
79
  def handle_prompt(input, chunk)
79
80
  if otp_prompt?(chunk)
80
- write_secret(input, secrets_provider.rubygems_otp, label: "RubyGems MFA code") if otp_response_prompt?(chunk)
81
+ write_secret(input, label: "RubyGems MFA code", source: "rubygems_otp") { secrets_provider.rubygems_otp } if otp_response_prompt?(chunk)
81
82
  return
82
83
  end
83
84
 
84
- write_secret(input, gem_signing_passphrase, label: "gem signing passphrase") if signing_password_prompt?(chunk)
85
+ write_secret(input, label: "gem signing passphrase", source: "gem_signing_passphrase") { gem_signing_passphrase } if signing_password_prompt?(chunk)
85
86
  end
86
87
 
87
88
  def gem_signing_passphrase
88
89
  @gem_signing_passphrase ||= secrets_provider.gem_signing_passphrase.to_s
89
90
  end
90
91
 
91
- def write_secret(input, value, label:)
92
+ def write_secret(input, label:, source:)
93
+ emit_secret_event(source: source, action: "prompt_response", status: "started", label: label)
94
+ value = yield
92
95
  secret = value.to_s
93
96
  if secret.empty?
94
97
  raise Kettle::Dev::Error, "#{label} prompt reached, but the configured release secrets provider returned no value. Aborting because secret prompts are not allowed when a secrets provider is configured."
@@ -97,6 +100,14 @@ module Kettle
97
100
  @output.puts("#{label} loaded from configured secrets provider.")
98
101
  input.write("#{secret}\n")
99
102
  input.flush
103
+ emit_secret_event(source: source, action: "prompt_response", status: "ok", label: label)
104
+ rescue Kettle::Dev::Error => error
105
+ emit_secret_event(source: source, action: "prompt_response", status: "failed", label: label, reason: error.message)
106
+ raise
107
+ end
108
+
109
+ def emit_secret_event(payload)
110
+ secret_event_handler&.call(payload)
100
111
  end
101
112
 
102
113
  def otp_prompt?(chunk)
@@ -79,12 +79,34 @@ module Kettle
79
79
  command << " #{key}=#{Shellwords.escape(value)}"
80
80
  end
81
81
  command << " bundle lock"
82
+ reset_platforms(path).each do |platform|
83
+ command << " --add-platform=#{Shellwords.escape(platform)}"
84
+ end
82
85
  command << " --update"
83
86
  command << " #{update_gems.map { |gem_name| Shellwords.escape(gem_name) }.join(" ")}" unless update_gems.empty?
84
87
  command << " --add-checksums"
85
88
  command
86
89
  end
87
90
 
91
+ def reset_platforms(path)
92
+ (lockfile_platforms(path) | [Gem::Platform.local.to_s]).reject(&:empty?).sort
93
+ end
94
+
95
+ def lockfile_platforms(path)
96
+ in_platforms = false
97
+ File.readlines(path).filter_map do |line|
98
+ stripped = line.strip
99
+ if stripped.match?(/\A[A-Z][A-Z ]*\z/)
100
+ in_platforms = stripped == "PLATFORMS"
101
+ next
102
+ end
103
+ next unless in_platforms
104
+ next if stripped.empty?
105
+
106
+ stripped
107
+ end
108
+ end
109
+
88
110
  def reset_update_gems(path)
89
111
  (
90
112
  path_source_gems(path).to_a |
@@ -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
@@ -335,8 +344,7 @@ module Kettle
335
344
  modified = false
336
345
 
337
346
  urls.each do |url_str|
338
- addr = Addressable::URI.parse(url_str)
339
- normalized = addr.normalize.to_s
347
+ normalized = normalized_markdown_image_url(url_str)
340
348
  next if normalized == url_str
341
349
 
342
350
  # Replace exact occurrences of the URL in the markdown content
@@ -356,9 +364,17 @@ module Kettle
356
364
  end
357
365
 
358
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)
359
368
  nil
360
369
  end
361
370
 
371
+ def normalized_markdown_image_url(url_str)
372
+ addr = Addressable::URI.parse(url_str)
373
+ normalized = addr.normalize
374
+ normalized.query = addr.query if addr.query && normalized.query != addr.query
375
+ normalized.to_s
376
+ end
377
+
362
378
  # Check 3: Validate Markdown image links by cached HTTP HEAD/GET.
363
379
  # @return [void]
364
380
  def check_markdown_images_http!
@@ -402,10 +418,12 @@ module Kettle
402
418
  puts "[kettle-pre-release] Image URL checks: #{progress.cached_count} cached, #{progress.live_count} live."
403
419
  puts "[kettle-pre-release] Skipped #{progress.skipped_count} image URL check(s)." if skipped.any?
404
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)
405
422
  warn("[kettle-pre-release] #{failures.size} image URL(s) failed validation:")
406
423
  failures.each { |u| warn(" - #{u}") }
407
424
  Kettle::Dev::ExitAdapter.abort("Image link validation failed")
408
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)
409
427
  puts "[kettle-pre-release] All image links validated."
410
428
  end
411
429
  nil
@@ -413,6 +431,33 @@ module Kettle
413
431
 
414
432
  private
415
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
+
416
461
  def image_url_cache
417
462
  return nil if @image_url_cache_path.to_s.empty?
418
463