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.
@@ -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
@@ -276,7 +277,7 @@ module Kettle
276
277
  prepare_release_lockfiles_for_release_tasks! if release_lockfile_preflight_needed?
277
278
 
278
279
  # 3. bin/setup
279
- run_cmd!("bin/setup") if run_step?(3)
280
+ run_cmd!(release_setup_command) if run_step?(3)
280
281
  # 4. bin/rake
281
282
  run_cmd!(release_default_task_command) if run_step?(4)
282
283
 
@@ -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
@@ -773,6 +808,11 @@ module Kettle
773
808
  "bin/rake"
774
809
  end
775
810
 
811
+ def release_setup_command
812
+ unbundled_keys = (BundlerEnvGuard::RESET_ENV_KEYS + %w[RUBYLIB RUBYOPT]).uniq
813
+ "env #{unbundled_keys.map { |key| "-u #{Shellwords.escape(key)}" }.join(" ")} bin/setup"
814
+ end
815
+
776
816
  def confirm_yes!(message, prompt, abort_message)
777
817
  puts(message)
778
818
  if @yes
@@ -1004,8 +1044,25 @@ module Kettle
1004
1044
 
1005
1045
  def monitor_workflows_after_push!
1006
1046
  ensure_github_pull_request_for_ci!
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)
1007
1050
  # Use abort-on-failure CI monitor to match historical behavior and specs
1008
- Kettle::Dev::CIMonitor.monitor_all!(restart_hint: "bundle exec kettle-release start_step=10", workflows: @ci_workflows)
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
1009
1066
  end
1010
1067
 
1011
1068
  def ensure_github_pull_request_for_ci!
@@ -1110,8 +1167,12 @@ module Kettle
1110
1167
  def run_command_with_release_secrets!(cmd)
1111
1168
  return self.class.run_cmd!(cmd) unless release_secret_command?(cmd) && release_secrets_configured?
1112
1169
 
1170
+ keep_release_secrets_alive!("prompt-bearing command")
1113
1171
  puts "$ #{cmd}"
1114
- _stdout_str, stderr_str, status = Kettle::Dev::InteractiveReleaseCommand.new(secrets_provider: @secrets_provider).call(
1172
+ _stdout_str, stderr_str, status = Kettle::Dev::InteractiveReleaseCommand.new(
1173
+ secrets_provider: @secrets_provider,
1174
+ secret_event_handler: ->(payload) { emit_secret_event(payload) }
1175
+ ).call(
1115
1176
  self.class.send(:command_env),
1116
1177
  cmd
1117
1178
  )
@@ -1137,12 +1198,41 @@ module Kettle
1137
1198
  end
1138
1199
 
1139
1200
  def ensure_release_secrets_ready_for_signing!
1201
+ keep_release_secrets_alive!("signing preflight")
1140
1202
  value = @secrets_provider.gem_signing_passphrase.to_s
1141
1203
  abort(release_secrets_configuration_message("gem signing passphrase was empty")) if value.empty?
1142
1204
  rescue Kettle::Dev::Error => error
1143
1205
  abort(release_secrets_configuration_message(error.message))
1144
1206
  end
1145
1207
 
1208
+ def keep_release_secrets_alive!(purpose)
1209
+ return unless release_secrets_configured?
1210
+ return unless @secrets_provider.respond_to?(:keepalive!)
1211
+
1212
+ emit_secret_event(action: "keepalive", status: "started", purpose: purpose, source: release_secrets_provider_label, elapsed: release_elapsed_label)
1213
+ @secrets_provider.keepalive!(elapsed: release_elapsed_label)
1214
+ emit_secret_event(action: "keepalive", status: "ok", purpose: purpose, source: release_secrets_provider_label, elapsed: release_elapsed_label)
1215
+ rescue Kettle::Dev::Error => error
1216
+ emit_secret_event(action: "keepalive", status: "failed", purpose: purpose, source: release_secrets_provider_label, elapsed: release_elapsed_label, reason: error.message)
1217
+ abort("Release secrets provider keepalive failed before #{purpose}: #{error.message}")
1218
+ end
1219
+
1220
+ def release_elapsed_label
1221
+ return nil unless @started_at
1222
+
1223
+ format_elapsed_seconds(monotonic_time - @started_at)
1224
+ end
1225
+
1226
+ def format_elapsed_seconds(seconds)
1227
+ total = seconds.to_f.round
1228
+ hours = total / 3600
1229
+ minutes = (total % 3600) / 60
1230
+ remaining_seconds = total % 60
1231
+ return format("%d:%02d:%02d", hours, minutes, remaining_seconds) if hours.positive?
1232
+
1233
+ format("%02d:%02d", minutes, remaining_seconds)
1234
+ end
1235
+
1146
1236
  def release_secrets_configuration_message(reason)
1147
1237
  <<~MSG.strip
1148
1238
  Release secrets provider was requested, but #{reason}.
@@ -1369,6 +1459,7 @@ module Kettle
1369
1459
  sleep(release_availability_probe_initial_delay)
1370
1460
  attempts.times do |index|
1371
1461
  attempt = index + 1
1462
+ emit_release_probe_event(action: "availability", status: "started", candidate: candidate, attempt: attempt, attempts: attempts)
1372
1463
  puts("Validating #{candidate.gem_name} #{candidate.version} from #{RELEASE_VALIDATION_SOURCE} (attempt #{attempt}/#{attempts})")
1373
1464
  stdout_str = nil
1374
1465
  stderr_str = nil
@@ -1377,10 +1468,21 @@ module Kettle
1377
1468
  stdout_str, stderr_str, status = Open3.capture3(self.class.send(:command_env), Gem.ruby, script_path)
1378
1469
  end
1379
1470
  $stdout.print(stdout_str) unless stdout_str.to_s.empty?
1380
- 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
1381
1475
 
1382
1476
  last_stderr = stderr_str
1383
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
+ )
1384
1486
  sleep(release_availability_probe_interval) if attempt < attempts
1385
1487
  end
1386
1488
 
@@ -1547,6 +1649,7 @@ module Kettle
1547
1649
  name: command_event_name(command),
1548
1650
  status: status,
1549
1651
  reason: reason,
1652
+ summary: command_event_summary(command),
1550
1653
  command: command,
1551
1654
  changed_files: []
1552
1655
  }
@@ -1555,7 +1658,80 @@ module Kettle
1555
1658
  end
1556
1659
 
1557
1660
  def command_event_name(command)
1558
- 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)
1559
1735
  end
1560
1736
 
1561
1737
  def record_diagnostic(kind, message, severity:, blocking:)
@@ -1569,6 +1745,120 @@ module Kettle
1569
1745
  Kettle::Ndjson.emit_diagnostic_event(@event_recorder, diagnostic, index: @diagnostics.length)
1570
1746
  end
1571
1747
 
1748
+ def emit_secret_event(payload)
1749
+ status = payload.fetch(:status).to_s
1750
+ mark = case status
1751
+ when "started"
1752
+ ">"
1753
+ when "ok"
1754
+ "."
1755
+ when "failed"
1756
+ "!"
1757
+ else
1758
+ "?"
1759
+ end
1760
+ Kettle::Ndjson.emit_event(
1761
+ @event_recorder,
1762
+ "secret_provider",
1763
+ payload.merge(
1764
+ phase: "release",
1765
+ provider: release_secrets_provider_label,
1766
+ mark: mark
1767
+ )
1768
+ )
1769
+ end
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
+
1572
1862
  def finish_release_report(status:, error:)
1573
1863
  return if @finished_report
1574
1864
 
@@ -1732,6 +2022,10 @@ module Kettle
1732
2022
  @skip_remotes.include?(name)
1733
2023
  end
1734
2024
 
2025
+ def required_remote?(name)
2026
+ @required_remotes.include?(name)
2027
+ end
2028
+
1735
2029
  def active_remotes
1736
2030
  list_remotes.reject { |remote| skipped_remote?(remote) }
1737
2031
  end
@@ -1770,26 +2064,30 @@ module Kettle
1770
2064
  if has_remote?("all")
1771
2065
  remotes = active_remotes
1772
2066
  skipped = list_remotes.select { |remote| skipped_remote?(remote) }
2067
+ required = remotes.select { |remote| required_remote?(remote) }
1773
2068
  puts "Remote 'all' detected. Fetching from active remotes and enforcing strict trunk parity..."
1774
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"])
1775
2073
  remotes.each do |remote|
1776
2074
  next if remote == "all"
1777
2075
 
1778
- fetch_remote_for_parity!(remote)
2076
+ fetched_remotes << remote if fetch_remote_for_parity!(remote)
1779
2077
  end
1780
2078
  missing_from = []
1781
- remotes.each do |r|
1782
- next if r == "all"
1783
-
2079
+ fetched_remotes.each do |r|
1784
2080
  if remote_branch_exists?(r, trunk)
1785
2081
  _ahead, behind = ahead_behind_counts(trunk, "#{r}/#{trunk}")
1786
2082
  missing_from << r if behind.positive?
1787
2083
  end
1788
2084
  end
1789
2085
  unless missing_from.empty?
2086
+ emit_remote_parity_event(action: "missing", status: "failed", trunk: trunk, remotes: missing_from, reason: "missing commits")
1790
2087
  abort("Local #{trunk} is missing commits present on: #{missing_from.join(", ")}. Please sync trunk first.")
1791
2088
  end
1792
- 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)
1793
2091
  return
1794
2092
  end
1795
2093
 
@@ -1858,8 +2156,10 @@ module Kettle
1858
2156
  attempts.times do |index|
1859
2157
  attempt = index + 1
1860
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))
1861
2160
  begin
1862
2161
  run_cmd!(command)
2162
+ emit_remote_parity_event(action: "fetch", remote: remote, status: "ok", attempt: attempt, attempts: attempts, required: required_remote?(remote))
1863
2163
  return true
1864
2164
  rescue SystemExit => error
1865
2165
  last_error = error
@@ -1869,7 +2169,17 @@ module Kettle
1869
2169
  sleep(remote_fetch_parity_interval) if attempt < attempts
1870
2170
  end
1871
2171
 
1872
- 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
1873
2183
  end
1874
2184
 
1875
2185
  def remote_fetch_parity_attempts
@@ -1882,7 +2192,7 @@ module Kettle
1882
2192
 
1883
2193
  def remote_fetch_failure_message(remote, detail)
1884
2194
  <<~MSG
1885
- Unable to fetch git remote '#{remote}' during release parity checks.
2195
+ Unable to fetch required git remote '#{remote}' during release parity checks.
1886
2196
  kettle-release enforces trunk parity across active remotes before publishing.
1887
2197
 
1888
2198
  If this remote is temporarily unavailable or intentionally not part of this release,
@@ -1895,6 +2205,20 @@ module Kettle
1895
2205
  MSG
1896
2206
  end
1897
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
+
1898
2222
  def merge_feature_into_trunk_and_push!(trunk, feature)
1899
2223
  return if feature.nil? || feature == trunk
1900
2224
 
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Dev
5
+ module ReleaseNotifier
6
+ class << self
7
+ def alert(message, stream: $stderr)
8
+ return unless enabled?
9
+
10
+ stream.write("\a") if terminal_bell?
11
+ stream.puts(message)
12
+ stream.flush if stream.respond_to?(:flush)
13
+ end
14
+
15
+ private
16
+
17
+ def enabled?
18
+ !ENV.fetch("KETTLE_RELEASE_SECRET_ALERT", "true").match?(/\A(?:false|0|no|off|disabled)\z/i)
19
+ end
20
+
21
+ def terminal_bell?
22
+ !ENV.fetch("KETTLE_RELEASE_SECRET_BELL", "true").match?(/\A(?:false|0|no|off|disabled)\z/i)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require "kettle/dev/release_notifier"
4
5
 
5
6
  module Kettle
6
7
  module Dev
@@ -15,6 +16,10 @@ module Kettle
15
16
  def rubygems_otp
16
17
  nil
17
18
  end
19
+
20
+ def keepalive!(elapsed: nil)
21
+ nil
22
+ end
18
23
  end
19
24
 
20
25
  class OnePassword < Provider
@@ -64,6 +69,14 @@ module Kettle
64
69
  run_op(argv, purpose: "RubyGems OTP")
65
70
  end
66
71
 
72
+ def keepalive!(elapsed: nil)
73
+ argv = [op_cli, "account", "get"]
74
+ account = string_config("account")
75
+ argv.concat(["--account", account]) unless account.empty?
76
+ run_op(argv, purpose: "authorization keepalive", elapsed: elapsed)
77
+ true
78
+ end
79
+
67
80
  private
68
81
 
69
82
  attr_reader :config
@@ -99,7 +112,9 @@ module Kettle
99
112
  required_config("cli")
100
113
  end
101
114
 
102
- def run_op(argv, purpose:)
115
+ def run_op(argv, purpose:, elapsed: nil)
116
+ elapsed_suffix = elapsed.to_s.empty? ? "" : " (elapsed #{elapsed})"
117
+ Kettle::Dev::ReleaseNotifier.alert("1Password #{purpose} lookup starting#{elapsed_suffix}; watch for authorization prompt.")
103
118
  stdout, stderr, status = Open3.capture3(*argv)
104
119
  return stdout.to_s.strip if status.success? && !stdout.to_s.strip.empty?
105
120