kettle-family 0.1.19 → 0.1.20

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: 1ffac198401618fcc4e988b09d48941a187bf809f3b0baf7b6261247fa81d8e3
4
- data.tar.gz: 1a139626c8c632ceca9f738678593b0103810f6bf884a9a98a29d1ab8c197edf
3
+ metadata.gz: 8674366c5bb5bff14156cae0e04c58805e3401510d4f6efe865902fe2facf988
4
+ data.tar.gz: b4a1ae4344a06bffc03b8fee3e472bc2f9cd40c7caa3276e30598da9307ba546
5
5
  SHA512:
6
- metadata.gz: cd5ba9086f00e699c138787ffc5c5e8140616238e18713f79a58ae8e215ff4adb2630a356e42476c9314f2324d7027e7f489169882f0359694dc8e7379251687
7
- data.tar.gz: 28ddd6e49f16293a5469dcceb271238077fcb140a85542ae6288f5d5a6072e905677e733a25d6388f3b3aff4ba2bc4ac14ae34a121fe8435972ec9ee98cb18e5
6
+ metadata.gz: 718d504c273ced46600feb4f196db9d13301026f74f96288eaaab960ad0765dba6067944e65f2e9855ba70b4e59e52d33609fce7b3760cc5d752303191184cd2
7
+ data.tar.gz: 5f1648deea0b3e04ca0edb2a453bb5d919a0e7cd1dcc19562b1ec77d15fa1c5870fd03d7b69f328fb04fec3ff70686e23d2fcaff8d1c083c941d36ece6dd28d8
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,37 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [0.1.20] - 2026-06-26
34
+
35
+ - TAG: [v0.1.20][0.1.20t]
36
+ - COVERAGE: 94.99% -- 1802/1897 lines in 21 files
37
+ - BRANCH COVERAGE: 74.39% -- 613/824 branches in 21 files
38
+ - 38.60% documented
39
+
40
+ ### Added
41
+
42
+ - `kettle-family template --execute` now runs member templating in parallel by
43
+ default, with `--jobs` and `template.jobs` controls plus compact live progress
44
+ and changed-file summaries.
45
+ - `kettle-family release --execute` now runs dependency-safe member release
46
+ waves in parallel for distinct Git worktrees, coordinating concurrent
47
+ RubyGems MFA prompts.
48
+ - `kettle-family install --execute` now installs independent local gems in
49
+ dependency-safe waves, using `--jobs` for parallelism.
50
+
51
+ ### Changed
52
+
53
+ - Family templating now invokes `kettle-jem` in quiet JSON mode and suppresses
54
+ noisy Bundler/debug environment by default, overriding inherited debug
55
+ variables unless `--debug` is passed.
56
+
57
+ ### Fixed
58
+
59
+ - Parallel release MFA coordination now shares an entered OTP only with prompts
60
+ already queued at submission time, shows the live queued prompt count as
61
+ `N / Y` for the current release wave capacity, and asks again for later
62
+ RubyGems OTP prompts.
63
+
33
64
  ## [0.1.19] - 2026-06-25
34
65
 
35
66
  - TAG: [v0.1.19][0.1.19t]
@@ -351,7 +382,9 @@ Please file a bug if you notice a violation of semantic versioning.
351
382
  - Fixed CI load failures on engines without compatible `pty` support by falling back to Open3 for interactive release commands.
352
383
  - Fixed Ruby 3.2 version-bump support by loading Prism lazily and wiring the Prism gem only for MRI versions that need it.
353
384
 
354
- [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v0.1.19...HEAD
385
+ [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v0.1.20...HEAD
386
+ [0.1.20]: https://github.com/kettle-dev/kettle-family/compare/v0.1.19...v0.1.20
387
+ [0.1.20t]: https://github.com/kettle-dev/kettle-family/releases/tag/v0.1.20
355
388
  [0.1.19]: https://github.com/kettle-dev/kettle-family/compare/v0.1.18...v0.1.19
356
389
  [0.1.19t]: https://github.com/kettle-dev/kettle-family/releases/tag/v0.1.19
357
390
  [0.1.18]: https://github.com/kettle-dev/kettle-family/compare/v0.1.17...v0.1.18
data/CONTRIBUTING.md CHANGED
@@ -131,9 +131,12 @@ toolchain, and it may be higher than the gemspec runtime floor.
131
131
  They are created and updated with the commands:
132
132
 
133
133
  ```console
134
- bin/rake appraisal:update
134
+ bin/rake appraisal:generate
135
135
  ```
136
136
 
137
+ Use `bin/rake appraisal:update` when you intentionally need to resolve fresh
138
+ appraisal locks.
139
+
137
140
  If you need to reset all gemfiles/*.gemfile.lock files:
138
141
 
139
142
  ```console
data/README.md CHANGED
@@ -571,7 +571,7 @@ Thanks for RTFM. ☺️
571
571
  [📌gitmoji]: https://gitmoji.dev
572
572
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
573
573
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
574
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-1.616-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
574
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-1.897-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
575
575
  [🔐security]: https://github.com/kettle-dev/kettle-family/blob/main/SECURITY.md
576
576
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
577
577
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -81,6 +81,8 @@ module Kettle
81
81
  --report PATH Write JSON report to PATH
82
82
  --execute Execute external workflow commands
83
83
  --dry-run Plan external workflow commands without running them (default)
84
+ --debug Preserve debug environment for workflow commands
85
+ --jobs N Parallel jobs for executed family templating, release, or install
84
86
  --env KEY=VALUE Override an environment variable for each member workflow command
85
87
  --section NAME Changelog section for add-changelog
86
88
  --entry TEXT Changelog entry for add-changelog
@@ -114,6 +116,8 @@ module Kettle
114
116
  json: false,
115
117
  report: nil,
116
118
  execute: false,
119
+ debug: false,
120
+ jobs: nil,
117
121
  workflow_env: {},
118
122
  changelog_section: nil,
119
123
  changelog_entry: nil,
@@ -139,6 +143,8 @@ module Kettle
139
143
  parser.on("--report PATH") { |value| options[:report] = value }
140
144
  parser.on("--execute") { options[:execute] = true }
141
145
  parser.on("--dry-run") { options[:execute] = false }
146
+ parser.on("--debug") { options[:debug] = true }
147
+ parser.on("--jobs N", Integer) { |value| options[:jobs] = value }
142
148
  parser.on("--env KEY=VALUE") { |value| parse_env_override(value, options[:workflow_env]) }
143
149
  parser.on("--section NAME") { |value| options[:changelog_section] = value }
144
150
  parser.on("--entry TEXT") { |value| options[:changelog_entry] = value }
@@ -228,10 +234,21 @@ module Kettle
228
234
  continue_ci_failures: options[:release_continue_ci_failures],
229
235
  gha_sha_pins_upgrade: options[:gha_sha_pins_upgrade],
230
236
  gha_sha_pins_check: options[:check],
231
- env_overrides: options[:workflow_env]
237
+ env_overrides: options[:workflow_env],
238
+ debug: options[:debug],
239
+ jobs: options[:jobs],
240
+ progress_io: progress_io(command, options)
232
241
  ).results
233
242
  end
234
243
 
244
+ def progress_io(command, options)
245
+ return nil unless command == "template"
246
+ return nil unless options[:execute]
247
+ return nil if options[:json]
248
+
249
+ out
250
+ end
251
+
235
252
  def branch_target_command?(command, config)
236
253
  return false if config.release_target_branches.empty?
237
254
  return false if command == "release-state"
@@ -401,7 +418,7 @@ module Kettle
401
418
  end
402
419
 
403
420
  def install_results(config:, members:, options:)
404
- LocalInstall.new(config: config, members: members, execute: options[:execute]).results
421
+ LocalInstall.new(config: config, members: members, execute: options[:execute], jobs: options[:jobs]).results
405
422
  end
406
423
 
407
424
  def release_state_results(config:, members:)
@@ -1,14 +1,114 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require "io/console"
4
5
 
5
6
  module Kettle
6
7
  module Family
7
8
  class CommandRunner
8
- def initialize(execute: false, accept: true, gem_signing_password: nil)
9
+ class OtpCoordinator
10
+ def initialize(input: $stdin, output: $stdout, queue_total: nil)
11
+ @input = input
12
+ @output = output
13
+ @mutex = Mutex.new
14
+ @condition = ConditionVariable.new
15
+ @prompting = false
16
+ @queue_closed = false
17
+ @generation = 0
18
+ @completed_generation = nil
19
+ @response = nil
20
+ @queued_count = 0
21
+ @queue_total = queue_total
22
+ end
23
+
24
+ def queue_total=(value)
25
+ @mutex.synchronize do
26
+ @queue_total = value
27
+ end
28
+ end
29
+
30
+ def request(member_name:, chunk:)
31
+ generation = nil
32
+ @mutex.synchronize do
33
+ @condition.wait(@mutex) while @prompting && @queue_closed
34
+
35
+ if @prompting
36
+ generation = @generation
37
+ @queued_count += 1
38
+ render_queue_status_locked
39
+ return wait_for_response(generation)
40
+ end
41
+
42
+ @prompting = true
43
+ @queue_closed = false
44
+ @queued_count = 1
45
+ @generation += 1
46
+ generation = @generation
47
+ start_prompt_locked(member_name: member_name)
48
+ end
49
+
50
+ response = read_response(chunk: chunk)
51
+ close_queue
52
+ @mutex.synchronize do
53
+ @response = response
54
+ @completed_generation = generation
55
+ @prompting = false
56
+ @queue_closed = false
57
+ @condition.broadcast
58
+ response
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def wait_for_response(generation)
65
+ @condition.wait(@mutex) while @prompting
66
+ return @response if @completed_generation == generation
67
+
68
+ ""
69
+ end
70
+
71
+ def close_queue
72
+ @mutex.synchronize do
73
+ @queue_closed = true
74
+ end
75
+ end
76
+
77
+ def start_prompt_locked(member_name:)
78
+ @output.puts
79
+ @output.puts("[#{member_name}] RubyGems MFA requested.")
80
+ render_queue_status_locked
81
+ @output.puts("Queued prompts at entry will share this code; later prompts will ask again.")
82
+ end
83
+
84
+ def render_queue_status_locked
85
+ suffix = @queue_total ? " / #{@queue_total}" : ""
86
+ @output.puts("RubyGems MFA prompts queued: #{@queued_count}#{suffix}")
87
+ @output.flush if @output.respond_to?(:flush)
88
+ end
89
+
90
+ def read_response(chunk:)
91
+ @output.print("#{otp_prompt_label(chunk)} ")
92
+ @output.flush if @output.respond_to?(:flush)
93
+ if @input.respond_to?(:noecho) && @input.tty?
94
+ @input.noecho(&:gets)&.chomp.to_s
95
+ else
96
+ @input.gets&.chomp.to_s
97
+ end
98
+ ensure
99
+ @output.puts if @output.respond_to?(:puts)
100
+ end
101
+
102
+ def otp_prompt_label(chunk)
103
+ chunk.to_s.lines.last&.strip.to_s.empty? ? "Code:" : chunk.to_s.lines.last.strip
104
+ end
105
+ end
106
+
107
+ def initialize(execute: false, accept: true, gem_signing_password: nil, otp_coordinator: nil)
9
108
  @execute = execute
10
109
  @accept = accept
11
110
  @gem_signing_password = gem_signing_password
111
+ @otp_coordinator = otp_coordinator
12
112
  end
13
113
 
14
114
  def call(member:, phase:, command:, env: {}, interactive: false)
@@ -19,7 +119,7 @@ module Kettle
19
119
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
120
  stdout, stderr, status = with_unbundled_environment do
21
121
  if interactive
22
- run_interactive(env: process_env, argv: argv, chdir: member.root)
122
+ run_interactive(env: process_env, argv: argv, chdir: member.root, member_name: member.name)
23
123
  else
24
124
  Open3.capture3(process_env, *argv, chdir: member.root)
25
125
  end
@@ -44,29 +144,29 @@ module Kettle
44
144
 
45
145
  private
46
146
 
47
- attr_reader :execute, :accept, :gem_signing_password
147
+ attr_reader :execute, :accept, :gem_signing_password, :otp_coordinator
48
148
 
49
- def run_interactive(env:, argv:, chdir:)
50
- return run_interactive_pty(env: env, argv: argv, chdir: chdir) if pty_available?
149
+ def run_interactive(env:, argv:, chdir:, member_name:)
150
+ return run_interactive_pty(env: env, argv: argv, chdir: chdir, member_name: member_name) if pty_available?
51
151
 
52
- run_interactive_open3(env: env, argv: argv, chdir: chdir)
152
+ run_interactive_open3(env: env, argv: argv, chdir: chdir, member_name: member_name)
53
153
  end
54
154
 
55
- def run_interactive_pty(env:, argv:, chdir:)
155
+ def run_interactive_pty(env:, argv:, chdir:, member_name:)
56
156
  stdout = +""
57
157
  status = nil
58
158
  PTY.spawn(env, *argv, chdir: chdir) do |output, input, pid|
59
159
  begin
60
160
  loop do
61
161
  readers = [output]
62
- readers << $stdin if $stdin.tty?
162
+ readers << $stdin if $stdin.tty? && !otp_coordinator
63
163
  ready = IO.select(readers)
64
164
  ready.first.each do |reader|
65
165
  if reader.equal?(output)
66
166
  chunk = output.readpartial(1024)
67
167
  stdout << chunk
68
168
  $stdout.print(chunk)
69
- handle_interactive_prompt(input, chunk)
169
+ handle_interactive_prompt(input, chunk, member_name: member_name)
70
170
  else
71
171
  input.write($stdin.readpartial(1024))
72
172
  end
@@ -80,20 +180,20 @@ module Kettle
80
180
  [stdout, "", status]
81
181
  end
82
182
 
83
- def run_interactive_open3(env:, argv:, chdir:)
183
+ def run_interactive_open3(env:, argv:, chdir:, member_name:)
84
184
  captured_stdout = +""
85
185
  captured_stderr = +""
86
186
  status = nil
87
187
  Open3.popen3(env, *argv, chdir: chdir) do |input, output, error, wait_thread|
88
188
  readers = [output, error]
89
- readers << $stdin if $stdin.tty?
189
+ readers << $stdin if $stdin.tty? && !otp_coordinator
90
190
  until readers.empty?
91
191
  ready = IO.select(readers)
92
192
  ready.first.each do |reader|
93
193
  if reader.equal?($stdin)
94
194
  input.write($stdin.readpartial(1024))
95
195
  else
96
- read_interactive_stream(reader, output, input, captured_stdout, captured_stderr, readers)
196
+ read_interactive_stream(reader, output, input, captured_stdout, captured_stderr, readers, member_name: member_name)
97
197
  end
98
198
  end
99
199
  end
@@ -102,7 +202,7 @@ module Kettle
102
202
  [captured_stdout, captured_stderr, status]
103
203
  end
104
204
 
105
- def read_interactive_stream(reader, output, input, captured_stdout, captured_stderr, readers)
205
+ def read_interactive_stream(reader, output, input, captured_stdout, captured_stderr, readers, member_name:)
106
206
  chunk = reader.readpartial(1024)
107
207
  if reader.equal?(output)
108
208
  captured_stdout << chunk
@@ -111,7 +211,7 @@ module Kettle
111
211
  captured_stderr << chunk
112
212
  $stderr.print(chunk)
113
213
  end
114
- handle_interactive_prompt(input, chunk)
214
+ handle_interactive_prompt(input, chunk, member_name: member_name)
115
215
  rescue EOFError
116
216
  readers.delete(reader)
117
217
  end
@@ -132,8 +232,11 @@ module Kettle
132
232
  input.flush
133
233
  end
134
234
 
135
- def handle_interactive_prompt(input, chunk)
136
- return if otp_prompt?(chunk)
235
+ def handle_interactive_prompt(input, chunk, member_name: nil)
236
+ if otp_prompt?(chunk)
237
+ write_otp_response(input, chunk, member_name: member_name) if otp_coordinator && otp_response_prompt?(chunk)
238
+ return
239
+ end
137
240
 
138
241
  if accept_confirmation_prompt?(chunk)
139
242
  write_accept_response(input) if accept
@@ -148,6 +251,14 @@ module Kettle
148
251
  input.flush
149
252
  end
150
253
 
254
+ def write_otp_response(input, chunk, member_name:)
255
+ response = otp_coordinator.request(member_name: member_name || "release", chunk: chunk)
256
+ return if response.to_s.empty?
257
+
258
+ input.write("#{response}\n")
259
+ input.flush
260
+ end
261
+
151
262
  def accept_confirmation_prompt?(chunk)
152
263
  chunk.match?(/\[[Yy]\/[Nn]\]\s*:?/)
153
264
  end
@@ -156,6 +267,10 @@ module Kettle
156
267
  chunk.match?(/(?:multi-factor authentication|OTP code|one-time password|\bCode:\s*)/i)
157
268
  end
158
269
 
270
+ def otp_response_prompt?(chunk)
271
+ chunk.match?(/(?:OTP code|one-time password|\bCode:\s*)/i)
272
+ end
273
+
159
274
  def signing_password_prompt?(chunk)
160
275
  chunk.match?(/(?:enter\s+)?(?:PEM\s+)?pass(?:\s|-)?phrase\s*(?:for\s+[^:]+)?[:?]\s*\z/i) ||
161
276
  chunk.match?(/(?:PEM|private key) password\s*[:?]\s*\z/i)
@@ -160,6 +160,10 @@ module Kettle
160
160
  fetch_path("template", "repository_topology")
161
161
  end
162
162
 
163
+ def template_jobs
164
+ fetch_path("template", "jobs")
165
+ end
166
+
163
167
  def normalize_lockfiles?
164
168
  fetch_path("template", "normalize_lockfiles") == true
165
169
  end
@@ -204,6 +208,10 @@ module Kettle
204
208
  stringify_env(fetch_path("release", "env") || {})
205
209
  end
206
210
 
211
+ def release_jobs
212
+ fetch_path("release", "jobs")
213
+ end
214
+
207
215
  def release_family_changelog?
208
216
  fetch_path("release", "family_changelog", "enabled") == true
209
217
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "etc"
3
4
  require "fileutils"
4
5
  require "json"
5
6
  require "open3"
@@ -8,16 +9,18 @@ require "time"
8
9
  module Kettle
9
10
  module Family
10
11
  class LocalInstall
11
- def initialize(config:, members:, execute: false)
12
+ def initialize(config:, members:, execute: false, jobs: nil)
12
13
  @config = config
13
14
  @members = members
14
15
  @execute = execute
16
+ @jobs = jobs
15
17
  end
16
18
 
17
19
  def results
18
- results = install_members.each_with_object([]) do |member, memo|
19
- memo << install_member(member)
20
- break memo unless memo.last.ok?
20
+ results = if execute && install_jobs(install_members) > 1
21
+ parallel_install_results
22
+ else
23
+ sequential_install_results(install_members)
21
24
  end
22
25
  write_local_install_marker if execute && results.all?(&:ok?)
23
26
  results
@@ -25,11 +28,26 @@ module Kettle
25
28
 
26
29
  private
27
30
 
28
- attr_reader :config, :members, :execute
31
+ attr_reader :config, :members, :execute, :jobs
29
32
 
30
33
  def install_members
34
+ @install_members ||= unique_members(local_dependency_members + members)
35
+ end
36
+
37
+ def local_dependency_members
38
+ @local_dependency_members ||= unique_members(
39
+ config.install_local_dependencies.map { |path| member_from_path(path) }
40
+ )
41
+ end
42
+
43
+ def selected_install_members
44
+ local_dependency_names = local_dependency_members.map(&:name)
45
+ unique_members(members).reject { |member| local_dependency_names.include?(member.name) }
46
+ end
47
+
48
+ def unique_members(candidate_members)
31
49
  seen = {}
32
- (local_dependency_members + members).each_with_object([]) do |member, memo|
50
+ candidate_members.each_with_object([]) do |member, memo|
33
51
  next if seen.key?(member.name)
34
52
 
35
53
  seen[member.name] = true
@@ -37,8 +55,66 @@ module Kettle
37
55
  end
38
56
  end
39
57
 
40
- def local_dependency_members
41
- config.install_local_dependencies.map { |path| member_from_path(path) }
58
+ def sequential_install_results(candidate_members)
59
+ candidate_members.each_with_object([]) do |member, memo|
60
+ memo << install_member(member)
61
+ break memo unless memo.last.ok?
62
+ end
63
+ end
64
+
65
+ def parallel_install_results
66
+ [local_dependency_members, selected_install_members].each_with_object([]) do |group, memo|
67
+ dependency_waves(group).each do |wave|
68
+ wave_results = run_install_wave(wave)
69
+ memo.concat(wave_results)
70
+ break unless wave_results.all?(&:ok?)
71
+ end
72
+ break memo unless memo.all?(&:ok?)
73
+ end
74
+ end
75
+
76
+ def run_install_wave(wave)
77
+ queue = Queue.new
78
+ wave.each_with_index { |member, index| queue << [index, member] }
79
+ ordered_results = Array.new(wave.length)
80
+ Array.new(install_jobs(wave)) do
81
+ Thread.new do
82
+ loop do
83
+ index, member = queue.pop(true)
84
+ ordered_results[index] = install_member(member)
85
+ rescue ThreadError
86
+ break
87
+ end
88
+ end
89
+ end.each(&:join)
90
+ ordered_results.compact
91
+ end
92
+
93
+ def dependency_waves(candidate_members)
94
+ by_name = candidate_members.to_h { |member| [member.name, member] }
95
+ pending = by_name.keys
96
+ completed = []
97
+ waves = []
98
+ until pending.empty?
99
+ wave_names = pending.select do |name|
100
+ selected_dependencies_for(by_name.fetch(name), by_name).all? { |dependency| completed.include?(dependency) }
101
+ end
102
+ raise Error, "cyclic install dependency order: #{pending.join(", ")}" if wave_names.empty?
103
+
104
+ waves << wave_names.map { |name| by_name.fetch(name) }
105
+ completed.concat(wave_names)
106
+ pending -= wave_names
107
+ end
108
+ waves
109
+ end
110
+
111
+ def selected_dependencies_for(member, by_name)
112
+ Array(member.dependencies).map(&:to_s).select { |dependency| by_name.key?(dependency) }
113
+ end
114
+
115
+ def install_jobs(candidate_members)
116
+ count = jobs ? jobs.to_i : [Etc.nprocessors, 4].min
117
+ [[count, 1].max, candidate_members.length].min
42
118
  end
43
119
 
44
120
  def member_from_path(path)
@@ -77,16 +77,41 @@ module Kettle
77
77
  lines << "results:"
78
78
  results.each do |result|
79
79
  lines << " #{result_state(result)} #{result.member_name} #{result.phase} #{result.reason || ""}".rstrip
80
- append_indented_output(lines, result.stdout) unless result.stdout.to_s.empty?
80
+ append_indented_output(lines, result.stdout) unless suppress_success_output?(result)
81
81
  append_indented_output(lines, result.stderr) if !result.ok? && !result.stderr.to_s.empty?
82
82
  lines << " resume: #{resume_hint_for(result)}" unless result.ok?
83
83
  end
84
+ append_template_summary(lines) if command == "template"
84
85
  end
85
86
 
86
87
  def append_indented_output(lines, output)
87
88
  output.to_s.each_line(chomp: true) { |line| lines << " #{line}" }
88
89
  end
89
90
 
91
+ def suppress_success_output?(result)
92
+ result.stdout.to_s.empty? || (command == "template" && result.ok?)
93
+ end
94
+
95
+ def append_template_summary(lines)
96
+ template_results = results.select { |result| result.phase == "template" }
97
+ return if template_results.empty?
98
+
99
+ changed_files = template_results.sum { |result| template_changed_file_count(result) }
100
+ lines << "template summary:"
101
+ lines << " #{template_results.count(&:ok?)}/#{template_results.length} members ok"
102
+ lines << " #{changed_files} file#{"s" unless changed_files == 1} changed"
103
+ end
104
+
105
+ def template_changed_file_count(result)
106
+ payload = JSON.parse(result.stdout.to_s)
107
+ Array(payload["changed_files"] || payload[:changed_files]).length if payload.is_a?(Hash)
108
+ rescue JSON::ParserError
109
+ match = result.stdout.to_s.match(/(?:install|apply|prepare|template):\s+(\d+)\s+changed file/)
110
+ return match[1].to_i if match
111
+
112
+ 0
113
+ end
114
+
90
115
  def append_member_release_targets(lines)
91
116
  return if member_release_target_branches.empty?
92
117
 
@@ -3,7 +3,7 @@
3
3
  module Kettle
4
4
  module Family
5
5
  module Version
6
- VERSION = "0.1.19"
6
+ VERSION = "0.1.20"
7
7
  end
8
8
  VERSION = Version::VERSION # Traditional Constant Location
9
9
  end
@@ -3,6 +3,8 @@
3
3
  require "io/console"
4
4
  require "json"
5
5
  require "net/http"
6
+ require "etc"
7
+ require "open3"
6
8
  require "uri"
7
9
 
8
10
  module Kettle
@@ -20,8 +22,23 @@ module Kettle
20
22
  "pull" => [["pull", %w[git pull --rebase]]],
21
23
  "up" => [["pull", %w[git pull --rebase]], ["push", %w[git push]]]
22
24
  }.freeze
25
+ TEMPLATE_QUIET_ENV = {
26
+ "KETTLE_JEM_QUIET" => "true",
27
+ "KETTLE_JEM_DEBUG" => "false",
28
+ "KETTLE_DEV_DEBUG" => "false",
29
+ "SMORG_RB_DEBUG" => "false",
30
+ "DEBUG" => "false",
31
+ "BUNDLE_QUIET" => "true",
32
+ "BUNDLE_DEBUG" => "false",
33
+ "BUNDLER_DEBUG" => "false",
34
+ "BUNDLE_VERBOSE" => "false",
35
+ "DEBUG_RESOLVER" => "false",
36
+ "BUNDLE_SILENCE_DEPRECATIONS" => "true",
37
+ "BUNDLE_SILENCE_ROOT_WARNING" => "true",
38
+ "BUNDLE_SUPPRESS_INSTALL_USING_MESSAGES" => "true"
39
+ }.freeze
23
40
 
24
- def initialize(command:, config:, members:, execute: false, accept: true, commit: true, allow_dirty: false, publish: false, push: false, tag: false, start_step: nil, local_ci: false, continue_ci_failures: false, gha_sha_pins_upgrade: "patch", gha_sha_pins_check: false, env_overrides: {}, gem_signing_password: nil)
41
+ def initialize(command:, config:, members:, execute: false, accept: true, commit: true, allow_dirty: false, publish: false, push: false, tag: false, start_step: nil, local_ci: false, continue_ci_failures: false, gha_sha_pins_upgrade: "patch", gha_sha_pins_check: false, env_overrides: {}, debug: false, gem_signing_password: nil, jobs: nil, progress_io: nil)
25
42
  @command = command
26
43
  @config = config
27
44
  @members = members
@@ -38,7 +55,10 @@ module Kettle
38
55
  @gha_sha_pins_upgrade = gha_sha_pins_upgrade
39
56
  @gha_sha_pins_check = gha_sha_pins_check
40
57
  @env_overrides = env_overrides
58
+ @debug = debug
41
59
  @gem_signing_password = gem_signing_password
60
+ @jobs = jobs
61
+ @progress_io = progress_io
42
62
  end
43
63
 
44
64
  def results
@@ -51,7 +71,7 @@ module Kettle
51
71
 
52
72
  private
53
73
 
54
- attr_reader :command, :config, :members, :execute, :accept, :commit, :allow_dirty, :publish, :push, :tag, :start_step, :local_ci, :continue_ci_failures, :gha_sha_pins_upgrade, :gha_sha_pins_check, :env_overrides
74
+ attr_reader :command, :config, :members, :execute, :accept, :commit, :allow_dirty, :publish, :push, :tag, :start_step, :local_ci, :continue_ci_failures, :gha_sha_pins_upgrade, :gha_sha_pins_check, :env_overrides, :debug, :jobs, :progress_io
55
75
 
56
76
  def current_branch_results(workflow_members)
57
77
  return check_results(workflow_members) if command == "check"
@@ -62,6 +82,8 @@ module Kettle
62
82
  end
63
83
 
64
84
  def member_workflow_results(workflow_members)
85
+ return template_member_workflow_results(workflow_members) if command == "template" && execute && template_jobs(workflow_members) > 1
86
+
65
87
  runner = CommandRunner.new(execute: execute, accept: accept)
66
88
  workflow_members.each_with_object([]) do |member, memo|
67
89
  if command == "template" && config.normalize_lockfiles?
@@ -79,6 +101,55 @@ module Kettle
79
101
  end
80
102
  end
81
103
 
104
+ def template_member_workflow_results(workflow_members)
105
+ queue = Queue.new
106
+ workflow_members.each_with_index { |member, index| queue << [index, member] }
107
+ ordered_results = Array.new(workflow_members.length)
108
+ mutex = Mutex.new
109
+ stop = false
110
+ emit_template_progress_start(workflow_members)
111
+ Array.new(template_jobs(workflow_members)) do
112
+ Thread.new do
113
+ loop do
114
+ break if mutex.synchronize { stop }
115
+ index, member = queue.pop(true)
116
+ member_results = template_results_for_member(member)
117
+ mutex.synchronize do
118
+ ordered_results[index] = member_results
119
+ stop = true unless member_results.all?(&:ok?)
120
+ emit_template_progress_mark(member_results)
121
+ end
122
+ rescue ThreadError
123
+ break
124
+ end
125
+ end
126
+ end.each(&:join)
127
+ flattened = ordered_results.compact.flatten
128
+ emit_template_progress_summary(flattened)
129
+ flattened
130
+ end
131
+
132
+ def template_results_for_member(member)
133
+ runner = CommandRunner.new(execute: execute, accept: accept)
134
+ [].tap do |memo|
135
+ if config.normalize_lockfiles?
136
+ normalize_lockfiles(member: member, runner: runner, memo: memo, phase: "prepare_lockfiles")
137
+ return memo unless memo.last.ok?
138
+ end
139
+
140
+ memo << runner.call(member: member, phase: command, command: workflow_command(member), env: workflow_env)
141
+ return memo unless memo.last.ok?
142
+
143
+ normalize_lockfiles(member: member, runner: runner, memo: memo, phase: "normalize_lockfiles")
144
+ end
145
+ end
146
+
147
+ def template_jobs(workflow_members)
148
+ requested = jobs || config.template_jobs
149
+ count = requested ? requested.to_i : [Etc.nprocessors, 4].min
150
+ [[count, 1].max, workflow_members.length].min
151
+ end
152
+
82
153
  def check_results(workflow_members)
83
154
  results = []
84
155
  results.concat(BranchLaneAudit.new(config: config, members: workflow_members).results) unless config.branch_lanes.empty?
@@ -152,32 +223,72 @@ module Kettle
152
223
  gha_sha_pins_upgrade: gha_sha_pins_upgrade,
153
224
  gha_sha_pins_check: gha_sha_pins_check,
154
225
  env_overrides: env_overrides,
155
- gem_signing_password: @gem_signing_password
226
+ gem_signing_password: @gem_signing_password,
227
+ jobs: jobs,
228
+ progress_io: progress_io
156
229
  )
157
230
  end
158
231
 
159
232
  def release_member_results(release_members, include_family_changelog: false)
160
- runner = command_runner
233
+ runner = release_command_runner
161
234
  results = []
162
235
  append_family_changelog_result(runner: runner, memo: results) if include_family_changelog
163
236
  return results unless results.all?(&:ok?)
237
+ return parallel_release_member_results(release_members, results) if parallel_release_members?(release_members)
164
238
 
165
239
  release_members.each_with_object(results) do |member, memo|
240
+ memo.concat(release_results_for_member(member, runner: runner))
241
+ break memo unless memo.last.ok?
242
+ end
243
+ end
244
+
245
+ def parallel_release_member_results(release_members, initial_results)
246
+ results = initial_results.dup
247
+ release_waves(release_members).each do |wave|
248
+ wave_results = run_release_wave(wave)
249
+ results.concat(wave_results.flatten)
250
+ break unless wave_results.all? { |member_results| member_results.all?(&:ok?) }
251
+ end
252
+ results
253
+ end
254
+
255
+ def run_release_wave(wave)
256
+ queue = Queue.new
257
+ wave.each_with_index { |member, index| queue << [index, member] }
258
+ ordered_results = Array.new(wave.length)
259
+ wave_jobs = release_jobs(wave)
260
+ release_otp_coordinator&.queue_total = wave_jobs
261
+ Array.new(wave_jobs) do
262
+ Thread.new do
263
+ runner = release_command_runner
264
+ loop do
265
+ index, member = queue.pop(true)
266
+ ordered_results[index] = release_results_for_member(member, runner: runner)
267
+ rescue ThreadError
268
+ break
269
+ end
270
+ end
271
+ end.each(&:join)
272
+ ordered_results.compact
273
+ end
274
+
275
+ def release_results_for_member(member, runner:)
276
+ [].tap do |memo|
166
277
  if skip_already_released?(member)
167
278
  memo << already_released_result(member)
168
- next
279
+ return memo
169
280
  end
170
281
 
171
282
  if config.release_normalize_lockfiles?
172
283
  normalize_release_lockfiles(member: member, runner: runner, memo: memo)
173
- break memo unless memo.last&.ok?
284
+ return memo unless memo.last&.ok?
174
285
 
175
286
  commit_normalized_lockfiles(branch_members: [member], runner: runner, memo: memo, reason: "release")
176
- break memo unless memo.last&.ok?
287
+ return memo unless memo.last&.ok?
177
288
  end
178
289
 
179
290
  append_release_internal_checks(member: member, memo: memo)
180
- break memo unless memo.last(2).all?(&:ok?)
291
+ return memo unless memo.last(2).all?(&:ok?)
181
292
 
182
293
  memo << runner.call(
183
294
  member: member,
@@ -186,10 +297,9 @@ module Kettle
186
297
  env: release_env,
187
298
  interactive: release_command_interactive?
188
299
  )
189
- break memo unless memo.last.ok?
300
+ return memo unless memo.last.ok?
190
301
 
191
302
  append_release_git_phases(member: member, runner: runner, memo: memo)
192
- break memo unless memo.last.ok?
193
303
  end
194
304
  end
195
305
 
@@ -208,6 +318,66 @@ module Kettle
208
318
  CommandRunner.new(execute: execute, accept: accept, gem_signing_password: @gem_signing_password)
209
319
  end
210
320
 
321
+ def release_command_runner
322
+ CommandRunner.new(
323
+ execute: execute,
324
+ accept: accept,
325
+ gem_signing_password: @gem_signing_password,
326
+ otp_coordinator: release_otp_coordinator
327
+ )
328
+ end
329
+
330
+ def release_otp_coordinator
331
+ return nil unless execute && release_command_interactive?
332
+
333
+ @release_otp_coordinator ||= CommandRunner::OtpCoordinator.new
334
+ end
335
+
336
+ def parallel_release_members?(release_members)
337
+ execute &&
338
+ release_jobs(release_members) > 1 &&
339
+ release_members.length > 1 &&
340
+ distinct_git_roots?(release_members)
341
+ end
342
+
343
+ def release_jobs(release_members)
344
+ requested = jobs || config.release_jobs
345
+ count = requested ? requested.to_i : [Etc.nprocessors, 4].min
346
+ [[count, 1].max, release_members.length].min
347
+ end
348
+
349
+ def release_waves(release_members)
350
+ by_name = release_members.to_h { |member| [member.name, member] }
351
+ pending = by_name.keys
352
+ completed = []
353
+ waves = []
354
+ until pending.empty?
355
+ wave_names = pending.select do |name|
356
+ selected_dependencies_for(by_name.fetch(name), by_name).all? { |dependency| completed.include?(dependency) }
357
+ end
358
+ raise Error, "cyclic release dependency order: #{pending.join(", ")}" if wave_names.empty?
359
+
360
+ waves << wave_names.map { |name| by_name.fetch(name) }
361
+ completed.concat(wave_names)
362
+ pending -= wave_names
363
+ end
364
+ waves
365
+ end
366
+
367
+ def selected_dependencies_for(member, by_name)
368
+ Array(member.dependencies).map(&:to_s).select { |dependency| by_name.key?(dependency) }
369
+ end
370
+
371
+ def distinct_git_roots?(release_members)
372
+ roots = release_members.map { |member| git_root_for(member) }
373
+ roots.uniq.length == roots.length
374
+ end
375
+
376
+ def git_root_for(member)
377
+ stdout, _stderr, status = Open3.capture3("git", "rev-parse", "--show-toplevel", chdir: member.root)
378
+ status.success? ? stdout.strip : File.expand_path(member.root)
379
+ end
380
+
211
381
  def rediscovered_selected_members(selected_names)
212
382
  discovered = Discovery.new(config: config).members
213
383
  ordered = Orderer.new(members: discovered, mode: config.order_mode, hints: config.order_hints).ordered
@@ -394,6 +564,7 @@ module Kettle
394
564
 
395
565
  def template_command(member)
396
566
  command_text = config.template_command || default_template_command(member)
567
+ command_text = append_template_family_args(command_text) if kettle_jem_template_command?(command_text)
397
568
  return command_text if commit
398
569
  return command_text if command_text.is_a?(Array) && command_text.include?("--skip-commit")
399
570
  return [*command_text, "--skip-commit"] if command_text.is_a?(Array)
@@ -423,9 +594,56 @@ module Kettle
423
594
  env["KJ_REPOSITORY_TOPOLOGY"] = config.template_repository_topology if config.template_repository_topology
424
595
  end
425
596
  env.merge!(env_overrides)
597
+ env.merge!(TEMPLATE_QUIET_ENV) if command == "template" && !debug
426
598
  end
427
599
  end
428
600
 
601
+ def kettle_jem_template_command?(command_text)
602
+ command_text.is_a?(Array) ? command_text.map(&:to_s).include?("kettle-jem") : command_text.to_s.include?("kettle-jem")
603
+ end
604
+
605
+ def append_template_family_args(command_text)
606
+ args = []
607
+ args << "--quiet" unless command_includes_arg?(command_text, "--quiet")
608
+ args << "--json" unless command_includes_arg?(command_text, "--json")
609
+ append_command_args(command_text, args)
610
+ end
611
+
612
+ def emit_template_progress_start(workflow_members)
613
+ return unless progress_io
614
+
615
+ progress_io.puts("templating #{workflow_members.length} member#{"s" unless workflow_members.length == 1} with #{template_jobs(workflow_members)} job#{"s" unless template_jobs(workflow_members) == 1}:")
616
+ progress_io.flush if progress_io.respond_to?(:flush)
617
+ end
618
+
619
+ def emit_template_progress_mark(member_results)
620
+ return unless progress_io
621
+
622
+ template_result = member_results.find { |result| result.phase == "template" } || member_results.last
623
+ progress_io.print(template_result.ok? ? "." : "F")
624
+ progress_io.flush if progress_io.respond_to?(:flush)
625
+ end
626
+
627
+ def emit_template_progress_summary(results)
628
+ return unless progress_io
629
+
630
+ template_results = results.select { |result| result.phase == "template" }
631
+ changed_files = template_results.sum { |result| template_changed_file_count(result) }
632
+ progress_io.puts
633
+ progress_io.puts("template summary: #{template_results.count(&:ok?)}/#{template_results.length} members ok, #{changed_files} file#{"s" unless changed_files == 1} changed")
634
+ progress_io.flush if progress_io.respond_to?(:flush)
635
+ end
636
+
637
+ def template_changed_file_count(result)
638
+ payload = JSON.parse(result.stdout.to_s)
639
+ Array(payload["changed_files"] || payload[:changed_files]).length if payload.is_a?(Hash)
640
+ rescue JSON::ParserError
641
+ match = result.stdout.to_s.match(/(?:install|apply|prepare|template):\s+(\d+)\s+changed file/)
642
+ return match[1].to_i if match
643
+
644
+ 0
645
+ end
646
+
429
647
  def normalize_lockfiles(member:, runner:, memo:, phase:)
430
648
  return unless config.normalize_lockfiles?
431
649
 
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kettle-family
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.19
4
+ version: 0.1.20
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -310,10 +310,10 @@ licenses:
310
310
  - AGPL-3.0-only
311
311
  metadata:
312
312
  homepage_uri: https://kettle-family.galtzo.com
313
- source_code_uri: https://github.com/kettle-dev/kettle-family/tree/v0.1.19
314
- changelog_uri: https://github.com/kettle-dev/kettle-family/blob/v0.1.19/CHANGELOG.md
313
+ source_code_uri: https://github.com/kettle-dev/kettle-family/tree/v0.1.20
314
+ changelog_uri: https://github.com/kettle-dev/kettle-family/blob/v0.1.20/CHANGELOG.md
315
315
  bug_tracker_uri: https://github.com/kettle-dev/kettle-family/issues
316
- documentation_uri: https://www.rubydoc.info/gems/kettle-family/0.1.19
316
+ documentation_uri: https://www.rubydoc.info/gems/kettle-family/0.1.20
317
317
  funding_uri: https://github.com/sponsors/pboling
318
318
  wiki_uri: https://github.com/kettle-dev/kettle-family/wiki
319
319
  news_uri: https://www.railsbling.com/tags/kettle-family
metadata.gz.sig CHANGED
Binary file