kettle-dev 2.5.11 → 2.5.12

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: f2c0c759c751e79d77e5cf8495f9c461d514d0b120c97cd95b4ae7222afe7f0c
4
- data.tar.gz: 95f799d83838660c1fa6200fe4783368a59456f1e8c6221bfdccff0eb967cecf
3
+ metadata.gz: 60069f78cc45c33bf7d2616ea76c456e98cf9885da75331a553f5c9e2b96bbe8
4
+ data.tar.gz: d58499d2f31cbffc408e15af977b93b3254f310d021822d90726c7407f4436c3
5
5
  SHA512:
6
- metadata.gz: 6e95a43755c42b2cf9e45e6d5df069aa0fd5cea4291aeae0ef9f9020a43eed84959a7c734bf8dbd0f719f9d78f6b3f135399cddb9374b616e74abbdb200c6017
7
- data.tar.gz: 9bee1f5843526fc154502c909c0144c4440da1610611d6eaf9ef23a4d7f72386071e420cbe267b0ce08d0a82c59abe094b990c85e0d2a813077ab08fe8a147ec
6
+ metadata.gz: b34e918fa0cce724b098ab97e46abd768a65938d52f908f87515a5f64b8c3c8d68e6717c05529045da5ce1670e407fa1fa955cfb3c9c2cbd6a4c72d46b64577b
7
+ data.tar.gz: d9dcb4194c6f22c6f6ba1b3311b8f7e644766b52bb36f80d4f59130e48f32d2d4917541c3aebf25e253adb4e3914485b9d66b7f77ebf64b73e56804686dc4276
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,47 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [2.5.12] - 2026-07-31
34
+
35
+ - TAG: [v2.5.12][2.5.12t]
36
+ - COVERAGE: 91.73% -- 4961/5408 lines in 46 files
37
+ - BRANCH COVERAGE: 75.98% -- 1850/2435 branches in 46 files
38
+ - 50.68% documented
39
+
40
+ ### Added
41
+
42
+ - `kettle-release-secrets-doctor` can probe 1Password release secret
43
+ authorization across same-process, child-process, and threaded shapes.
44
+
45
+ - `kettle-release-secrets-doctor` now probes additional 1Password
46
+ authorization boundaries, including parent-child, Bundler child, and
47
+ Bundler-env-reset child process shapes.
48
+
49
+ - `kettle-release --events` now emits `secret_provider` events around release
50
+ secret keepalives and provider-backed prompt responses.
51
+
52
+ ### Fixed
53
+
54
+ - `kettle-release` now keeps configured release secrets providers warm during
55
+ CI waits and alerts before provider lookups, reducing missed 1Password
56
+ authorization prompts during long release runs.
57
+
58
+ - `kettle-release` keepalive alerts now include the elapsed release time so
59
+ delayed 1Password prompts are easier to correlate with the run.
60
+
61
+ - `kettle-dev` now requires `kettle-ndjson` 0.1.4 or newer so
62
+ `kettle-release --events=secret_provider` cannot resolve against an older
63
+ event protocol.
64
+ - `kettle-release` lockfile reset now preserves existing bundle platforms and
65
+ adds the current host platform, preventing release-prep lockfiles from losing
66
+ Linux support during full rebuilds.
67
+ - `kettle-pre-release` Markdown image URL normalization now keeps encoded query
68
+ values stable, avoiding release-time churn in shields URLs with nested URL
69
+ parameters.
70
+ - `kettle-release` now runs `bin/setup` with inherited Bundler bootstrap
71
+ variables unset, so a release lockfile reset can install newly selected gems
72
+ before later release tasks run.
73
+
33
74
  ## [2.5.11] - 2026-07-30
34
75
 
35
76
  - TAG: [v2.5.11][2.5.11t]
@@ -3077,7 +3118,9 @@ Please file a bug if you notice a violation of semantic versioning.
3077
3118
  - Selecting will run the selected workflow via `act`
3078
3119
  - This may move to its own gem in the future.
3079
3120
 
3080
- [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.11...HEAD
3121
+ [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.12...HEAD
3122
+ [2.5.12]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.11...v2.5.12
3123
+ [2.5.12t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v2.5.12
3081
3124
  [2.5.11]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.10...v2.5.11
3082
3125
  [2.5.11t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v2.5.11
3083
3126
  [2.5.10]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.9...v2.5.10
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.189-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1008
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-5.408-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
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+
6
+ $stdout.sync = true
7
+ $stderr.sync = true
8
+
9
+ script_basename = File.basename(__FILE__)
10
+ repo_lib = File.expand_path("../lib", __dir__)
11
+ $LOAD_PATH.unshift(repo_lib) if File.directory?(repo_lib) && !$LOAD_PATH.include?(repo_lib)
12
+
13
+ begin
14
+ require "kettle/dev"
15
+ require "kettle/dev/executable_version"
16
+ Kettle::Dev::ExecutableVersion.print_and_exit!(script_basename, ARGV, value_option: true)
17
+ Kettle::Dev::ExecutableVersion.print_header(script_basename)
18
+ rescue LoadError
19
+ warn "kettle-release-secrets-doctor must run with kettle-dev on the load path"
20
+ exit 1
21
+ end
22
+
23
+ options = Kettle::Dev::ReleaseSecretsDoctor.options_from_env
24
+
25
+ OptionParser.new do |parser|
26
+ parser.banner = "Usage: kettle-release-secrets-doctor [options]"
27
+ parser.on("--provider NAME", "Secrets provider name, default: op") { |value| options[:provider] = value }
28
+ parser.on("--sleep SECONDS", Float, "Seconds to wait between probes") { |value| options[:sleep_seconds] = value }
29
+ parser.on("--keepalive SECONDS", Float, "Run keepalive every N seconds while waiting; 0 disables") { |value| options[:keepalive_seconds] = value }
30
+ parser.on("--shape SHAPE", "Shape: #{Kettle::Dev::ReleaseSecretsDoctor::SHAPES.join(", ")}") { |value| options[:shape] = value }
31
+ parser.on("--otp", "Fetch RubyGems OTP after the wait") { options[:otp] = true }
32
+ end.parse!
33
+
34
+ begin
35
+ Kettle::Dev::ReleaseSecretsDoctor.new(options: options, program_name: $PROGRAM_NAME).run
36
+ rescue Kettle::Dev::Error => e
37
+ warn e.message
38
+ exit 1
39
+ end
@@ -19,6 +19,7 @@ module Kettle
19
19
  BUNDLE_SILENCE_ROOT_WARNING
20
20
  BUNDLE_SUPPRESS_INSTALL_USING_MESSAGES
21
21
  BUNDLE_VERBOSE
22
+ BUNDLER_DEBUG
22
23
  ].freeze
23
24
 
24
25
  INERT_ENV_PREFIXES = %w[
@@ -47,10 +47,10 @@ module Kettle
47
47
  #
48
48
  # @param restart_hint [String] guidance command shown on failure
49
49
  # @return [void]
50
- def monitor_all!(restart_hint: "bundle exec kettle-release start_step=10", workflows: nil, **options)
50
+ def monitor_all!(restart_hint: "bundle exec kettle-release start_step=10", workflows: nil, keepalive: nil, **options)
51
51
  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)
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)
54
54
  abort("CI configuration not detected (GitHub or GitLab). Ensure CI is configured and remotes point to the correct hosts.") unless checks_any
55
55
  end
56
56
 
@@ -267,7 +267,7 @@ module Kettle
267
267
  end
268
268
  module_function :collect_gitlab
269
269
 
270
- def monitor_github_internal!(restart_hint:, workflows: nil)
270
+ def monitor_github_internal!(restart_hint:, workflows: nil, keepalive: nil)
271
271
  root = Kettle::Dev::CIHelpers.project_root
272
272
  workflows = Array(workflows).empty? ? Kettle::Dev::CIHelpers.workflows_list(root) : Array(workflows)
273
273
  gh_remote = preferred_github_remote
@@ -306,8 +306,10 @@ module Kettle
306
306
  start_timeout = github_start_timeout
307
307
  poll_interval = github_poll_interval
308
308
  start_deadline = monotonic_time + start_timeout
309
+ keepalive_timer = keepalive_timer(keepalive)
309
310
  idx = 0
310
311
  loop do
312
+ keepalive_timer.call
311
313
  wf = workflows[idx]
312
314
  run = Kettle::Dev::CIHelpers.latest_run(owner: owner, repo: repo, workflow_file: wf, branch: branch, require_head: true, head_sha: head_sha)
313
315
  if run
@@ -364,7 +366,32 @@ module Kettle
364
366
  end
365
367
  module_function :github_poll_interval
366
368
 
367
- def monitor_gitlab_internal!(restart_hint:)
369
+ def keepalive_timer(keepalive)
370
+ interval = keepalive_interval
371
+ last_run = nil
372
+ lambda do
373
+ if keepalive
374
+ now = monotonic_time
375
+ if !last_run || (now - last_run) >= interval
376
+ keepalive.call
377
+ last_run = now
378
+ end
379
+ end
380
+ end
381
+ end
382
+ module_function :keepalive_timer
383
+
384
+ def keepalive_interval
385
+ seconds = begin
386
+ Float(ENV["KETTLE_RELEASE_SECRET_KEEPALIVE_INTERVAL"])
387
+ rescue
388
+ nil
389
+ end
390
+ (seconds && seconds >= 0) ? seconds : 240.0
391
+ end
392
+ module_function :keepalive_interval
393
+
394
+ def monitor_gitlab_internal!(restart_hint:, keepalive: nil)
368
395
  root = Kettle::Dev::CIHelpers.project_root
369
396
  gitlab_ci = File.exist?(File.join(root, ".gitlab-ci.yml"))
370
397
  gl_remote = gitlab_remote_candidates.first
@@ -380,7 +407,9 @@ module Kettle
380
407
  pbar = if defined?(ProgressBar)
381
408
  ProgressBar.create(title: "CI", total: 1, format: "%t %b %c/%C", length: 30)
382
409
  end
410
+ keepalive_timer = keepalive_timer(keepalive)
383
411
  loop do
412
+ keepalive_timer.call
384
413
  pipe = Kettle::Dev::CIHelpers.gitlab_latest_pipeline(owner: owner, repo: repo, branch: branch)
385
414
  if pipe
386
415
  if Kettle::Dev::CIHelpers.gitlab_success?(pipe)
@@ -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 |
@@ -335,8 +335,7 @@ module Kettle
335
335
  modified = false
336
336
 
337
337
  urls.each do |url_str|
338
- addr = Addressable::URI.parse(url_str)
339
- normalized = addr.normalize.to_s
338
+ normalized = normalized_markdown_image_url(url_str)
340
339
  next if normalized == url_str
341
340
 
342
341
  # Replace exact occurrences of the URL in the markdown content
@@ -359,6 +358,13 @@ module Kettle
359
358
  nil
360
359
  end
361
360
 
361
+ def normalized_markdown_image_url(url_str)
362
+ addr = Addressable::URI.parse(url_str)
363
+ normalized = addr.normalize
364
+ normalized.query = addr.query if addr.query && normalized.query != addr.query
365
+ normalized.to_s
366
+ end
367
+
362
368
  # Check 3: Validate Markdown image links by cached HTTP HEAD/GET.
363
369
  # @return [void]
364
370
  def check_markdown_images_http!
@@ -276,7 +276,7 @@ module Kettle
276
276
  prepare_release_lockfiles_for_release_tasks! if release_lockfile_preflight_needed?
277
277
 
278
278
  # 3. bin/setup
279
- run_cmd!("bin/setup") if run_step?(3)
279
+ run_cmd!(release_setup_command) if run_step?(3)
280
280
  # 4. bin/rake
281
281
  run_cmd!(release_default_task_command) if run_step?(4)
282
282
 
@@ -773,6 +773,11 @@ module Kettle
773
773
  "bin/rake"
774
774
  end
775
775
 
776
+ def release_setup_command
777
+ unbundled_keys = (BundlerEnvGuard::RESET_ENV_KEYS + %w[RUBYLIB RUBYOPT]).uniq
778
+ "env #{unbundled_keys.map { |key| "-u #{Shellwords.escape(key)}" }.join(" ")} bin/setup"
779
+ end
780
+
776
781
  def confirm_yes!(message, prompt, abort_message)
777
782
  puts(message)
778
783
  if @yes
@@ -1004,8 +1009,13 @@ module Kettle
1004
1009
 
1005
1010
  def monitor_workflows_after_push!
1006
1011
  ensure_github_pull_request_for_ci!
1012
+ keep_release_secrets_alive!("CI monitoring")
1007
1013
  # 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)
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
+ )
1009
1019
  end
1010
1020
 
1011
1021
  def ensure_github_pull_request_for_ci!
@@ -1110,8 +1120,12 @@ module Kettle
1110
1120
  def run_command_with_release_secrets!(cmd)
1111
1121
  return self.class.run_cmd!(cmd) unless release_secret_command?(cmd) && release_secrets_configured?
1112
1122
 
1123
+ keep_release_secrets_alive!("prompt-bearing command")
1113
1124
  puts "$ #{cmd}"
1114
- _stdout_str, stderr_str, status = Kettle::Dev::InteractiveReleaseCommand.new(secrets_provider: @secrets_provider).call(
1125
+ _stdout_str, stderr_str, status = Kettle::Dev::InteractiveReleaseCommand.new(
1126
+ secrets_provider: @secrets_provider,
1127
+ secret_event_handler: ->(payload) { emit_secret_event(payload) }
1128
+ ).call(
1115
1129
  self.class.send(:command_env),
1116
1130
  cmd
1117
1131
  )
@@ -1137,12 +1151,41 @@ module Kettle
1137
1151
  end
1138
1152
 
1139
1153
  def ensure_release_secrets_ready_for_signing!
1154
+ keep_release_secrets_alive!("signing preflight")
1140
1155
  value = @secrets_provider.gem_signing_passphrase.to_s
1141
1156
  abort(release_secrets_configuration_message("gem signing passphrase was empty")) if value.empty?
1142
1157
  rescue Kettle::Dev::Error => error
1143
1158
  abort(release_secrets_configuration_message(error.message))
1144
1159
  end
1145
1160
 
1161
+ def keep_release_secrets_alive!(purpose)
1162
+ return unless release_secrets_configured?
1163
+ return unless @secrets_provider.respond_to?(:keepalive!)
1164
+
1165
+ emit_secret_event(action: "keepalive", status: "started", purpose: purpose, source: release_secrets_provider_label, elapsed: release_elapsed_label)
1166
+ @secrets_provider.keepalive!(elapsed: release_elapsed_label)
1167
+ emit_secret_event(action: "keepalive", status: "ok", purpose: purpose, source: release_secrets_provider_label, elapsed: release_elapsed_label)
1168
+ rescue Kettle::Dev::Error => error
1169
+ emit_secret_event(action: "keepalive", status: "failed", purpose: purpose, source: release_secrets_provider_label, elapsed: release_elapsed_label, reason: error.message)
1170
+ abort("Release secrets provider keepalive failed before #{purpose}: #{error.message}")
1171
+ end
1172
+
1173
+ def release_elapsed_label
1174
+ return nil unless @started_at
1175
+
1176
+ format_elapsed_seconds(monotonic_time - @started_at)
1177
+ end
1178
+
1179
+ def format_elapsed_seconds(seconds)
1180
+ total = seconds.to_f.round
1181
+ hours = total / 3600
1182
+ minutes = (total % 3600) / 60
1183
+ remaining_seconds = total % 60
1184
+ return format("%d:%02d:%02d", hours, minutes, remaining_seconds) if hours.positive?
1185
+
1186
+ format("%02d:%02d", minutes, remaining_seconds)
1187
+ end
1188
+
1146
1189
  def release_secrets_configuration_message(reason)
1147
1190
  <<~MSG.strip
1148
1191
  Release secrets provider was requested, but #{reason}.
@@ -1569,6 +1612,29 @@ module Kettle
1569
1612
  Kettle::Ndjson.emit_diagnostic_event(@event_recorder, diagnostic, index: @diagnostics.length)
1570
1613
  end
1571
1614
 
1615
+ def emit_secret_event(payload)
1616
+ status = payload.fetch(:status).to_s
1617
+ mark = case status
1618
+ when "started"
1619
+ ">"
1620
+ when "ok"
1621
+ "."
1622
+ when "failed"
1623
+ "!"
1624
+ else
1625
+ "?"
1626
+ end
1627
+ Kettle::Ndjson.emit_event(
1628
+ @event_recorder,
1629
+ "secret_provider",
1630
+ payload.merge(
1631
+ phase: "release",
1632
+ provider: release_secrets_provider_label,
1633
+ mark: mark
1634
+ )
1635
+ )
1636
+ end
1637
+
1572
1638
  def finish_release_report(status:, error:)
1573
1639
  return if @finished_report
1574
1640
 
@@ -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
 
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require "shellwords"
5
+ require "time"
6
+
7
+ module Kettle
8
+ module Dev
9
+ class ReleaseSecretsDoctor
10
+ DEFAULT_SLEEP_SECONDS = 30.0
11
+ DEFAULT_KEEPALIVE_SECONDS = 0.0
12
+ DEFAULT_PROVIDER = "op"
13
+ DEFAULT_SHAPE = "same-process"
14
+ CHILD_SHAPE = "same-process"
15
+ SHAPES = %w[same-process child-process parent-child thread bundler-child env-reset-child].freeze
16
+ BUNDLER_ENV_UNSETS = %w[
17
+ BUNDLE_BIN_PATH
18
+ BUNDLE_FROZEN
19
+ BUNDLE_GEMFILE
20
+ BUNDLE_LOCKFILE
21
+ BUNDLER_SETUP
22
+ BUNDLER_VERSION
23
+ RUBYLIB
24
+ RUBYOPT
25
+ ].freeze
26
+
27
+ def self.options_from_env(env = ENV)
28
+ {
29
+ provider: env.fetch("KETTLE_RELEASE_SECRETS_PROVIDER", DEFAULT_PROVIDER),
30
+ sleep_seconds: Float(env.fetch("KETTLE_RELEASE_SECRETS_DOCTOR_SLEEP", DEFAULT_SLEEP_SECONDS.to_s)),
31
+ keepalive_seconds: Float(env.fetch("KETTLE_RELEASE_SECRETS_DOCTOR_KEEPALIVE", DEFAULT_KEEPALIVE_SECONDS.to_s)),
32
+ shape: env.fetch("KETTLE_RELEASE_SECRETS_DOCTOR_SHAPE", DEFAULT_SHAPE),
33
+ otp: false
34
+ }
35
+ end
36
+
37
+ def initialize(options:, program_name:, output: $stdout, system_runner: Kernel)
38
+ @options = normalize_options(options)
39
+ @program_name = program_name
40
+ @output = output
41
+ @system_runner = system_runner
42
+ end
43
+
44
+ def run
45
+ case options.fetch(:shape)
46
+ when "same-process"
47
+ run_same_process(options)
48
+ when "child-process"
49
+ run_child_process(options)
50
+ when "parent-child"
51
+ run_parent_child(options)
52
+ when "thread"
53
+ run_thread(options)
54
+ when "bundler-child"
55
+ run_bundler_child(options)
56
+ when "env-reset-child"
57
+ run_env_reset_child(options)
58
+ else
59
+ raise Kettle::Dev::Error, "unknown shape #{options.fetch(:shape).inspect}; expected #{SHAPES.join(", ")}"
60
+ end
61
+ end
62
+
63
+ def child_env(shape:, options: self.options)
64
+ env = {
65
+ "KETTLE_RELEASE_SECRETS_PROVIDER" => options.fetch(:provider),
66
+ "KETTLE_RELEASE_SECRETS_DOCTOR_SHAPE" => shape,
67
+ "KETTLE_RELEASE_SECRETS_DOCTOR_SLEEP" => options.fetch(:sleep_seconds).to_s,
68
+ "KETTLE_RELEASE_SECRETS_DOCTOR_KEEPALIVE" => options.fetch(:keepalive_seconds).to_s
69
+ }
70
+ env["KETTLE_RELEASE_SECRETS_DOCTOR_CHILD"] = "true"
71
+ env
72
+ end
73
+
74
+ def child_args(otp: options.fetch(:otp))
75
+ args = [RbConfig.ruby, program_name]
76
+ args << "--otp" if otp
77
+ args
78
+ end
79
+
80
+ def bundler_child_args(otp: options.fetch(:otp))
81
+ args = ["bundle", "exec", RbConfig.ruby, program_name]
82
+ args << "--otp" if otp
83
+ args
84
+ end
85
+
86
+ private
87
+
88
+ attr_reader :options, :program_name, :output, :system_runner
89
+
90
+ def normalize_options(raw_options)
91
+ {
92
+ provider: raw_options.fetch(:provider, DEFAULT_PROVIDER).to_s,
93
+ sleep_seconds: Float(raw_options.fetch(:sleep_seconds, DEFAULT_SLEEP_SECONDS)),
94
+ keepalive_seconds: Float(raw_options.fetch(:keepalive_seconds, DEFAULT_KEEPALIVE_SECONDS)),
95
+ shape: raw_options.fetch(:shape, DEFAULT_SHAPE).to_s,
96
+ otp: !!raw_options.fetch(:otp, false)
97
+ }
98
+ end
99
+
100
+ def provider_for(current_options)
101
+ Kettle::Dev::ReleaseSecrets::Factory.build(provider_name: current_options.fetch(:provider))
102
+ end
103
+
104
+ def run_same_process(current_options)
105
+ provider = provider_for(current_options)
106
+ output.puts("[#{timestamp}] initial keepalive")
107
+ provider.keepalive!
108
+ wait_with_keepalive(provider, current_options)
109
+ output.puts("[#{timestamp}] final keepalive")
110
+ provider.keepalive!
111
+ run_otp_lookup(provider) if current_options.fetch(:otp)
112
+ true
113
+ end
114
+
115
+ def run_parent_child(current_options)
116
+ provider = provider_for(current_options)
117
+ output.puts("[#{timestamp}] parent initial keepalive")
118
+ provider.keepalive!
119
+ wait_with_keepalive(provider, current_options)
120
+ output.puts("[#{timestamp}] parent spawning child after keepalive")
121
+ run_child_process(current_options)
122
+ end
123
+
124
+ def run_child_process(current_options)
125
+ env = child_env(shape: CHILD_SHAPE, options: current_options)
126
+ args = child_args(otp: current_options.fetch(:otp))
127
+ output.puts("[#{timestamp}] spawning child: #{args.shelljoin}")
128
+ run_system(env, args)
129
+ end
130
+
131
+ def run_bundler_child(current_options)
132
+ env = child_env(shape: CHILD_SHAPE, options: current_options)
133
+ args = bundler_child_args(otp: current_options.fetch(:otp))
134
+ output.puts("[#{timestamp}] spawning bundler child: #{args.shelljoin}")
135
+ run_system(env, args)
136
+ end
137
+
138
+ def run_env_reset_child(current_options)
139
+ env = child_env(shape: CHILD_SHAPE, options: current_options)
140
+ BUNDLER_ENV_UNSETS.each { |key| env[key] = nil }
141
+ args = child_args(otp: current_options.fetch(:otp))
142
+ output.puts("[#{timestamp}] spawning env-reset child: #{args.shelljoin}")
143
+ run_system(env, args)
144
+ end
145
+
146
+ def run_thread(current_options)
147
+ error = nil
148
+ # rubocop:disable ThreadSafety/NewThread -- this doctor intentionally probes threaded secret-provider behavior.
149
+ thread = Thread.new do
150
+ begin
151
+ run_same_process(current_options)
152
+ rescue => e
153
+ error = e
154
+ end
155
+ end
156
+ # rubocop:enable ThreadSafety/NewThread
157
+ thread.join
158
+ raise error if error
159
+
160
+ true
161
+ end
162
+
163
+ def run_system(env, args)
164
+ success = system_runner.system(env, *args)
165
+ raise Kettle::Dev::Error, "child shape failed: #{args.shelljoin}" unless success
166
+
167
+ true
168
+ end
169
+
170
+ def wait_with_keepalive(provider, current_options)
171
+ sleep_seconds = current_options.fetch(:sleep_seconds)
172
+ keepalive_seconds = current_options.fetch(:keepalive_seconds)
173
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + sleep_seconds
174
+ if keepalive_seconds <= 0
175
+ output.puts("[#{timestamp}] sleeping #{sleep_seconds}s without keepalive")
176
+ sleep(sleep_seconds)
177
+ return
178
+ end
179
+
180
+ output.puts("[#{timestamp}] sleeping #{sleep_seconds}s with #{keepalive_seconds}s keepalive")
181
+ loop do
182
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
183
+ break if remaining <= 0
184
+
185
+ sleep([remaining, keepalive_seconds].min)
186
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
187
+
188
+ output.puts("[#{timestamp}] keepalive")
189
+ provider.keepalive!
190
+ end
191
+ end
192
+
193
+ def run_otp_lookup(provider)
194
+ output.puts("[#{timestamp}] OTP lookup")
195
+ otp = provider.rubygems_otp
196
+ output.puts("[#{timestamp}] OTP lookup returned #{otp.to_s.empty? ? "empty" : "#{otp.length} chars"}")
197
+ end
198
+
199
+ def timestamp
200
+ Time.now.iso8601
201
+ end
202
+ end
203
+ end
204
+ end
@@ -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.11"
8
+ VERSION = "2.5.12"
9
9
  end
10
10
  # Current gem version exposed at the traditional constant location.
11
11
  VERSION = Version::VERSION # Traditional Constant Location
data/lib/kettle/dev.rb CHANGED
@@ -46,8 +46,10 @@ module Kettle
46
46
  autoload :LockfileReset, "kettle/dev/lockfile_reset"
47
47
  autoload :ReadmeBackers, "kettle/dev/readme_backers"
48
48
  autoload :OpenCollectiveConfig, "kettle/dev/open_collective_config"
49
+ autoload :ReleaseNotifier, "kettle/dev/release_notifier"
49
50
  autoload :ReleaseCLI, "kettle/dev/release_cli"
50
51
  autoload :ReleaseSecrets, "kettle/dev/release_secrets"
52
+ autoload :ReleaseSecretsDoctor, "kettle/dev/release_secrets_doctor"
51
53
  autoload :ResetCLI, "kettle/dev/reset_cli"
52
54
  autoload :PreReleaseCLI, "kettle/dev/pre_release_cli"
53
55
  autoload :Version, "kettle/dev/version"
data.tar.gz.sig CHANGED
@@ -1,2 +1,3 @@
1
- cPG����/�@ثM��'e nj3<� f����ƵYS��SR&�\�Ζ�R\����(��". ���� ���Έ���AY��1H`����8G�$�œ/��2}�+L��I6��c(H/�׻;P�G�V���^:�#� �M��
2
- ���=`٤�V4Q�������bw�Yc�����<5����$�9�u���*��<2w�=*��HcY�Ǭ0��=7l[BM���|GRj,\�
1
+ w2��YSs��E�xx���阻Qc��X���_�%�%X.��C⹎���f�,~�d
2
+ OOE�_m�Ф�P���s �!j���=�4� ��q�{��0Ӕ�Ʈ}���:��� ܪ}ȿ����7yO?�����q׊y��z';�%��j5ˠ�����%F��}��*���0fVk2Cp{�
3
+ ��#H�٩��� 嫕�d KgHV� ��'Ꮍ����.�!�s�U=���� @�����|pq����&P���Dqd߹� ?�3j2�s�}�� $�ҳ�?R|�2���T
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.11
4
+ version: 2.5.12
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.3
83
+ version: 0.1.4
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.3
93
+ version: 0.1.4
94
94
  - !ruby/object:Gem::Dependency
95
95
  name: kettle-rb
96
96
  requirement: !ruby/object:Gem::Requirement
@@ -327,6 +327,7 @@ executables:
327
327
  - kettle-pre-release
328
328
  - kettle-readme-backers
329
329
  - kettle-release
330
+ - kettle-release-secrets-doctor
330
331
  - kettle-reset
331
332
  extensions: []
332
333
  extra_rdoc_files: []
@@ -346,6 +347,7 @@ files:
346
347
  - exe/kettle-pre-release
347
348
  - exe/kettle-readme-backers
348
349
  - exe/kettle-release
350
+ - exe/kettle-release-secrets-doctor
349
351
  - exe/kettle-reset
350
352
  - lib/kettle-dev.rb
351
353
  - lib/kettle/dev.rb
@@ -381,7 +383,9 @@ files:
381
383
  - lib/kettle/dev/rakelib/yard.rake
382
384
  - lib/kettle/dev/readme_backers.rb
383
385
  - lib/kettle/dev/release_cli.rb
386
+ - lib/kettle/dev/release_notifier.rb
384
387
  - lib/kettle/dev/release_secrets.rb
388
+ - lib/kettle/dev/release_secrets_doctor.rb
385
389
  - lib/kettle/dev/reset_cli.rb
386
390
  - lib/kettle/dev/ruby_gems_versions.rb
387
391
  - lib/kettle/dev/tasks.rb
@@ -397,10 +401,10 @@ licenses:
397
401
  - AGPL-3.0-only
398
402
  metadata:
399
403
  homepage_uri: https://kettle-dev.galtzo.com
400
- source_code_uri: https://github.com/kettle-dev/kettle-dev/tree/v2.5.11
401
- changelog_uri: https://github.com/kettle-dev/kettle-dev/blob/v2.5.11/CHANGELOG.md
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
402
406
  bug_tracker_uri: https://github.com/kettle-dev/kettle-dev/issues
403
- documentation_uri: https://www.rubydoc.info/gems/kettle-dev/2.5.11
407
+ documentation_uri: https://www.rubydoc.info/gems/kettle-dev/2.5.12
404
408
  funding_uri: https://github.com/sponsors/pboling
405
409
  wiki_uri: https://github.com/kettle-dev/kettle-dev/wiki
406
410
  news_uri: https://www.railsbling.com/tags/kettle-dev
metadata.gz.sig CHANGED
Binary file