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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +88 -1
- data/README.md +1 -1
- data/exe/kettle-changelog +25 -3
- data/exe/kettle-release +26 -2
- data/exe/kettle-release-secrets-doctor +39 -0
- data/lib/kettle/dev/bundler_env_guard.rb +1 -0
- data/lib/kettle/dev/changelog_cli.rb +43 -1
- data/lib/kettle/dev/ci_monitor.rb +235 -7
- data/lib/kettle/dev/interactive_release_command.rb +16 -5
- data/lib/kettle/dev/lockfile_reset.rb +22 -0
- data/lib/kettle/dev/pre_release_cli.rb +49 -4
- data/lib/kettle/dev/release_cli.rb +342 -18
- data/lib/kettle/dev/release_notifier.rb +27 -0
- data/lib/kettle/dev/release_secrets.rb +16 -1
- data/lib/kettle/dev/release_secrets_doctor.rb +204 -0
- data/lib/kettle/dev/version.rb +1 -1
- data/lib/kettle/dev.rb +2 -0
- data.tar.gz.sig +0 -0
- metadata +10 -6
- metadata.gz.sig +0 -0
|
@@ -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 =
|
|
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!(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: #{
|
|
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
|
-
|
|
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
|
|