kettle-family 1.2.89 → 1.2.90
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 +22 -1
- data/README.md +44 -7
- data/lib/kettle/family/cli.rb +1 -1
- data/lib/kettle/family/concurrency.rb +41 -0
- data/lib/kettle/family/config.rb +18 -0
- data/lib/kettle/family/local_install.rb +3 -2
- data/lib/kettle/family/release_state_check.rb +3 -4
- data/lib/kettle/family/version.rb +1 -1
- data/lib/kettle/family/workflow.rb +493 -61
- data/lib/kettle/family.rb +1 -0
- data.tar.gz.sig +0 -0
- metadata +5 -4
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fd4de333d6aa3b2bc027ec582b2bcc0b890d61b20a0a57c70bcf25196b2f9c32
|
|
4
|
+
data.tar.gz: 67754f998efad13f47f00f0e07222a17e9413c890b2cee1097ffbccd1114801d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7238680a28fa74944856d3ebe514193ac070df02fa25b319e28090bbb18f8f6a0f989ccf0d73e84f485d5e7d446de692440304dd0e99cf79b9834f4ba1212143
|
|
7
|
+
data.tar.gz: 75c99992e6539a9aa9e37f6ad5ea45afa0692bd9214f10df75f9c604f56af1799099e4cb1fa80b47bd9e843fa8a130103dffae010b14eb8e8f64936834447c35
|
checksums.yaml.gz.sig
CHANGED
|
Binary file
|
data/CHANGELOG.md
CHANGED
|
@@ -30,6 +30,25 @@ Please file a bug if you notice a violation of semantic versioning.
|
|
|
30
30
|
|
|
31
31
|
### Security
|
|
32
32
|
|
|
33
|
+
## [1.2.90] - 2026-09-06
|
|
34
|
+
|
|
35
|
+
- TAG: [v1.2.90][1.2.90t]
|
|
36
|
+
- COVERAGE: 93.13% -- 6048/6494 lines in 35 files
|
|
37
|
+
- BRANCH COVERAGE: 76.05% -- 2407/3165 branches in 35 files
|
|
38
|
+
- 32.41% documented
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- Run sibling test workflows concurrently with dynamic TurboTests2 process budgets.
|
|
43
|
+
|
|
44
|
+
- Support aggregate and disposable-worktree monorepo test execution modes.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- Template ready monorepo sibling gems concurrently in isolated worktrees while materializing only member-scoped changes.
|
|
49
|
+
|
|
50
|
+
- Derive family command concurrency from the executing CPU count and pass template wave width to nested tools.
|
|
51
|
+
|
|
33
52
|
## [1.2.89] - 2026-09-06
|
|
34
53
|
|
|
35
54
|
- TAG: [v1.2.89][1.2.89t]
|
|
@@ -2573,7 +2592,9 @@ Please file a bug if you notice a violation of semantic versioning.
|
|
|
2573
2592
|
- Fixed CI load failures on engines without compatible `pty` support by falling back to Open3 for interactive release commands.
|
|
2574
2593
|
- Fixed Ruby 3.2 version-bump support by loading Prism lazily and wiring the Prism gem only for MRI versions that need it.
|
|
2575
2594
|
|
|
2576
|
-
[Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.2.
|
|
2595
|
+
[Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.2.90...HEAD
|
|
2596
|
+
[1.2.90]: https://github.com/kettle-dev/kettle-family/compare/v1.2.89...v1.2.90
|
|
2597
|
+
[1.2.90t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.2.90
|
|
2577
2598
|
[1.2.89]: https://github.com/kettle-dev/kettle-family/compare/v1.2.88...v1.2.89
|
|
2578
2599
|
[1.2.89t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.2.89
|
|
2579
2600
|
[1.2.88]: https://github.com/kettle-dev/kettle-family/compare/v1.2.87...v1.2.88
|
data/README.md
CHANGED
|
@@ -281,6 +281,37 @@ canonical development lockfile and CI checkout share that path graph. In both
|
|
|
281
281
|
cases, release normalization rejects every path outside the configured allow
|
|
282
282
|
list.
|
|
283
283
|
|
|
284
|
+
### Test concurrency
|
|
285
|
+
|
|
286
|
+
`kettle-family test --execute` runs independent sibling repositories in
|
|
287
|
+
parallel. Its default outer width is half of the CPUs detected on the current
|
|
288
|
+
machine, capped by the number of selected members; `--jobs` and `test.jobs`
|
|
289
|
+
override that width. The same budget is passed to member `kettle-release`
|
|
290
|
+
commands, because their local test phase uses the same process pool.
|
|
291
|
+
|
|
292
|
+
Family sets `TURBO_TESTS2_MAX_PROCESSES` for each active wave. This is an
|
|
293
|
+
inclusive ceiling for TurboTests2: it includes the member's primary test
|
|
294
|
+
process, not merely additional workers. Given `C` detected CPUs and `W` active
|
|
295
|
+
Family wave slots, the ceiling is `min(floor(C / 2), 1 + floor((C - W) / W))`.
|
|
296
|
+
An explicit `turbo_tests2 --count`/`--workers` value, or an explicitly supplied
|
|
297
|
+
`TURBO_TESTS2_MAX_PROCESSES`, takes precedence.
|
|
298
|
+
|
|
299
|
+
Monorepos default to `test.monorepo_mode: aggregate`: one selected member runs
|
|
300
|
+
the shared root suite and the remaining selected subgems are reported as
|
|
301
|
+
skipped. Set `test.aggregate_member` to choose that owner when it is selected.
|
|
302
|
+
For monorepos with genuinely member-scoped suites, opt in to disposable
|
|
303
|
+
worktrees instead:
|
|
304
|
+
|
|
305
|
+
```yaml
|
|
306
|
+
test:
|
|
307
|
+
monorepo_mode: worktrees
|
|
308
|
+
jobs: 4
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Those worktrees isolate coverage, temporary files, and Bundler state. Their
|
|
312
|
+
changes are always discarded; test execution never materializes or commits
|
|
313
|
+
worktree artifacts.
|
|
314
|
+
|
|
284
315
|
### Release waves and dependency refresh
|
|
285
316
|
|
|
286
317
|
Release waves are both a sequencing mechanism and a dependency propagation
|
|
@@ -533,9 +564,9 @@ stops before family changelog generation or coverage and reports the members
|
|
|
533
564
|
that need a version bump. Run `kettle-family bump --execute patch` first, then
|
|
534
565
|
rerun the release.
|
|
535
566
|
|
|
536
|
-
State checks run concurrently by default, using up to
|
|
537
|
-
|
|
538
|
-
limit; results remain in family order.
|
|
567
|
+
State checks run concurrently by default, using up to half of the available CPU
|
|
568
|
+
cores and no more than the selected member count. Use `--jobs N` to set an
|
|
569
|
+
explicit limit; results remain in family order.
|
|
539
570
|
|
|
540
571
|
Most commands accept `--json` to print a machine-readable report instead of the
|
|
541
572
|
text report, and `--report PATH` to write that JSON report while still printing
|
|
@@ -732,9 +763,15 @@ kettle-family bump --execute --only unreleased minor
|
|
|
732
763
|
```
|
|
733
764
|
|
|
734
765
|
Run `kettle-jem` templating across the selected family members. Planning shows
|
|
735
|
-
the commands that would run; `--execute` runs them. Executed templating
|
|
736
|
-
|
|
737
|
-
|
|
766
|
+
the commands that would run; `--execute` runs them. Executed templating uses a
|
|
767
|
+
rolling outer wave of up to half the available CPU cores, bounded by ready
|
|
768
|
+
members; `--jobs` overrides that width.
|
|
769
|
+
|
|
770
|
+
For a Kettle Jem member command, Family exports the active outer width as
|
|
771
|
+
`KETTLE_FAMILY_WAVE_JOBS`. Kettle Jem retains the member's base process and
|
|
772
|
+
uses an equal share of CPUs left by that wave for its recipe and file worker
|
|
773
|
+
pools, capped at half the available CPUs. Explicit `KETTLE_JEM_*_WORKERS`
|
|
774
|
+
settings take precedence over this derived budget.
|
|
738
775
|
|
|
739
776
|
```console
|
|
740
777
|
kettle-family template
|
|
@@ -1084,7 +1121,7 @@ Thanks for RTFM. ☺️
|
|
|
1084
1121
|
[📌gitmoji]: https://gitmoji.dev
|
|
1085
1122
|
[📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
|
|
1086
1123
|
[🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
|
|
1087
|
-
[🧮kloc-img]: https://img.shields.io/badge/KLOC-6.
|
|
1124
|
+
[🧮kloc-img]: https://img.shields.io/badge/KLOC-6.494-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
|
|
1088
1125
|
[🔐security]: https://github.com/kettle-dev/kettle-family/blob/main/SECURITY.md
|
|
1089
1126
|
[🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
|
|
1090
1127
|
[📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
|
data/lib/kettle/family/cli.rb
CHANGED
|
@@ -910,7 +910,7 @@ module Kettle
|
|
|
910
910
|
io: stdout,
|
|
911
911
|
label: "release state",
|
|
912
912
|
total: members.length,
|
|
913
|
-
jobs:
|
|
913
|
+
jobs: Concurrency.wave_jobs(requested: options[:jobs], item_count: members.length),
|
|
914
914
|
members: members,
|
|
915
915
|
heading: "release state #{members.length} member#{"s" unless members.length == 1}:"
|
|
916
916
|
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module Kettle
|
|
6
|
+
module Family
|
|
7
|
+
# Allocates CPU capacity between concurrent family members and optional
|
|
8
|
+
# member-internal workers. A member consumes one wave slot before any
|
|
9
|
+
# command-specific workers are considered.
|
|
10
|
+
module Concurrency
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def default_wave_jobs(cpu_count: Etc.nprocessors)
|
|
14
|
+
[cpu_count / 2, 1].max
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def wave_jobs(requested:, item_count:, cpu_count: Etc.nprocessors)
|
|
18
|
+
return 0 if item_count.zero?
|
|
19
|
+
|
|
20
|
+
count = requested.nil? ? default_wave_jobs(cpu_count: cpu_count) : requested.to_i
|
|
21
|
+
count.clamp(1, item_count)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def internal_worker_limit(wave_jobs:, cpu_count: Etc.nprocessors)
|
|
25
|
+
raise ArgumentError, "wave jobs must be positive" unless wave_jobs.to_i.positive?
|
|
26
|
+
|
|
27
|
+
half_cores = default_wave_jobs(cpu_count: cpu_count)
|
|
28
|
+
available_per_member = (cpu_count - wave_jobs.to_i) / wave_jobs.to_i
|
|
29
|
+
available_per_member.clamp(1, half_cores)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Test runners count their primary worker as part of the requested
|
|
33
|
+
# process pool. Reserve one process for each wave member, then divide
|
|
34
|
+
# the remaining capacity across them. No member receives more than half
|
|
35
|
+
# of the detected CPUs, including that primary process.
|
|
36
|
+
def test_process_ceiling(wave_jobs:, cpu_count: Etc.nprocessors)
|
|
37
|
+
[default_wave_jobs(cpu_count: cpu_count), 1 + internal_worker_limit(wave_jobs: wave_jobs, cpu_count: cpu_count)].min
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/kettle/family/config.rb
CHANGED
|
@@ -208,6 +208,24 @@ module Kettle
|
|
|
208
208
|
fetch_path("template", "jobs")
|
|
209
209
|
end
|
|
210
210
|
|
|
211
|
+
# A monorepo commonly has one root suite that covers every subgem. The
|
|
212
|
+
# configured owner is optional; an --only invocation never expands its
|
|
213
|
+
# own selection solely to reach that owner.
|
|
214
|
+
def test_aggregate_member
|
|
215
|
+
fetch_path("test", "aggregate_member")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def test_monorepo_mode
|
|
219
|
+
mode = (fetch_path("test", "monorepo_mode") || "aggregate").to_s
|
|
220
|
+
return mode if %w[aggregate worktrees].include?(mode)
|
|
221
|
+
|
|
222
|
+
raise Error, "test.monorepo_mode must be aggregate or worktrees"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def test_jobs
|
|
226
|
+
fetch_path("test", "jobs")
|
|
227
|
+
end
|
|
228
|
+
|
|
211
229
|
def readme_corporate_sponsors
|
|
212
230
|
raw = fetch_path("readme", "corporate_sponsors") ||
|
|
213
231
|
fetch_path("sponsorships", "corporate") ||
|
|
@@ -6,6 +6,8 @@ require "json"
|
|
|
6
6
|
require "open3"
|
|
7
7
|
require "time"
|
|
8
8
|
|
|
9
|
+
require_relative "concurrency"
|
|
10
|
+
|
|
9
11
|
module Kettle
|
|
10
12
|
module Family
|
|
11
13
|
class LocalInstall
|
|
@@ -113,8 +115,7 @@ module Kettle
|
|
|
113
115
|
end
|
|
114
116
|
|
|
115
117
|
def install_jobs(candidate_members)
|
|
116
|
-
|
|
117
|
-
count.clamp(1, candidate_members.length)
|
|
118
|
+
Concurrency.wave_jobs(requested: jobs, item_count: candidate_members.length)
|
|
118
119
|
end
|
|
119
120
|
|
|
120
121
|
def member_from_path(path)
|
|
@@ -9,6 +9,8 @@ require "rubygems"
|
|
|
9
9
|
require "securerandom"
|
|
10
10
|
require "yaml"
|
|
11
11
|
|
|
12
|
+
require_relative "concurrency"
|
|
13
|
+
|
|
12
14
|
module Kettle
|
|
13
15
|
module Family
|
|
14
16
|
class ReleaseStateCheck
|
|
@@ -574,10 +576,7 @@ module Kettle
|
|
|
574
576
|
end
|
|
575
577
|
|
|
576
578
|
def state_jobs(items)
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
requested = jobs || [Etc.nprocessors, 4].min
|
|
580
|
-
requested.to_i.clamp(1, items.length)
|
|
579
|
+
Concurrency.wave_jobs(requested: jobs, item_count: items.length)
|
|
581
580
|
end
|
|
582
581
|
|
|
583
582
|
def parallel_map(items, &block)
|
|
@@ -5,7 +5,7 @@ module Kettle
|
|
|
5
5
|
# Version namespace for this gem.
|
|
6
6
|
module Version
|
|
7
7
|
# Current gem version.
|
|
8
|
-
VERSION = "1.2.
|
|
8
|
+
VERSION = "1.2.90"
|
|
9
9
|
end
|
|
10
10
|
# Current gem version exposed at the traditional constant location.
|
|
11
11
|
VERSION = Version::VERSION # Traditional Constant Location
|
|
@@ -11,6 +11,7 @@ require "shellwords"
|
|
|
11
11
|
require "yaml"
|
|
12
12
|
require "kettle/dev"
|
|
13
13
|
|
|
14
|
+
require_relative "concurrency"
|
|
14
15
|
require_relative "workflow_progress"
|
|
15
16
|
|
|
16
17
|
module Kettle
|
|
@@ -569,6 +570,7 @@ module Kettle
|
|
|
569
570
|
|
|
570
571
|
def member_workflow_results(workflow_members)
|
|
571
572
|
return template_member_workflow_results(workflow_members) if command == "template" && execute
|
|
573
|
+
return test_member_workflow_results(workflow_members) if command == "test" && execute
|
|
572
574
|
|
|
573
575
|
runner = CommandRunner.new(execute: execute, accept: accept)
|
|
574
576
|
results = []
|
|
@@ -614,6 +616,215 @@ module Kettle
|
|
|
614
616
|
results
|
|
615
617
|
end
|
|
616
618
|
|
|
619
|
+
# Sibling repositories have isolated working trees and can run their
|
|
620
|
+
# tests concurrently. A monorepo's default kettle-test command normally
|
|
621
|
+
# executes the shared root suite, so it is represented by one selected
|
|
622
|
+
# owner instead of repeating that suite per subgem.
|
|
623
|
+
def test_member_workflow_results(workflow_members)
|
|
624
|
+
test_members, skipped_members = test_members_for(workflow_members)
|
|
625
|
+
return [] if test_members.empty?
|
|
626
|
+
|
|
627
|
+
return monorepo_test_worktree_results(test_members) if monorepo_test_worktrees?(test_members)
|
|
628
|
+
|
|
629
|
+
wave_jobs = test_jobs(test_members)
|
|
630
|
+
queue = Queue.new
|
|
631
|
+
test_members.each_with_index { |member, index| queue << [index, member] }
|
|
632
|
+
ordered_results = Array.new(test_members.length)
|
|
633
|
+
mutex = Mutex.new
|
|
634
|
+
stop = false
|
|
635
|
+
|
|
636
|
+
Array.new(wave_jobs) do
|
|
637
|
+
Thread.new do # rubocop:disable ThreadSafety/NewThread -- sibling test suites run in isolated project roots.
|
|
638
|
+
runner = CommandRunner.new(execute: execute, accept: accept)
|
|
639
|
+
loop do
|
|
640
|
+
break if mutex.synchronize { stop }
|
|
641
|
+
|
|
642
|
+
index, member = queue.pop(true)
|
|
643
|
+
result = runner.call(
|
|
644
|
+
member: member,
|
|
645
|
+
phase: command,
|
|
646
|
+
command: workflow_command(member),
|
|
647
|
+
env: test_command_env(wave_jobs: wave_jobs)
|
|
648
|
+
)
|
|
649
|
+
mutex.synchronize do
|
|
650
|
+
ordered_results[index] = result
|
|
651
|
+
stop = true unless result.ok?
|
|
652
|
+
end
|
|
653
|
+
rescue ThreadError
|
|
654
|
+
break
|
|
655
|
+
rescue => error
|
|
656
|
+
mutex.synchronize do
|
|
657
|
+
ordered_results[index] = test_worker_error_result(member, error)
|
|
658
|
+
stop = true
|
|
659
|
+
end
|
|
660
|
+
break
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
end.each(&:join)
|
|
664
|
+
|
|
665
|
+
results = ordered_results.compact
|
|
666
|
+
return results unless results.all?(&:ok?)
|
|
667
|
+
|
|
668
|
+
results + skipped_members.map { |member| aggregate_test_skip_result(member, owner: test_members.first) }
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
def test_members_for(workflow_members)
|
|
672
|
+
return [workflow_members, []] unless aggregate_monorepo_test?(workflow_members)
|
|
673
|
+
|
|
674
|
+
owner_name = config.test_aggregate_member.to_s.strip
|
|
675
|
+
owner = workflow_members.find { |member| member.name == owner_name } || workflow_members.first
|
|
676
|
+
[[owner], workflow_members - [owner]]
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
def aggregate_monorepo_test?(workflow_members)
|
|
680
|
+
config.family_mode == "monorepo" &&
|
|
681
|
+
config.test_monorepo_mode == "aggregate" &&
|
|
682
|
+
workflow_members.length > 1
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def monorepo_test_worktrees?(test_members)
|
|
686
|
+
config.family_mode == "monorepo" &&
|
|
687
|
+
config.test_monorepo_mode == "worktrees" &&
|
|
688
|
+
test_members.length > 1 &&
|
|
689
|
+
test_jobs(test_members) > 1
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# Member-scoped monorepo suites cannot share the primary checkout: test
|
|
693
|
+
# coverage, temporary files, and Bundler state are all mutable. Unlike
|
|
694
|
+
# template worktrees, test worktrees intentionally discard every change.
|
|
695
|
+
def monorepo_test_worktree_results(test_members)
|
|
696
|
+
entries, setup_results = monorepo_test_worktree_entries(test_members)
|
|
697
|
+
return setup_results unless setup_results.all?(&:ok?)
|
|
698
|
+
|
|
699
|
+
run_monorepo_test_worktree_entries(entries).flatten
|
|
700
|
+
ensure
|
|
701
|
+
cleanup_monorepo_test_worktrees(entries || [])
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def monorepo_test_worktree_entries(test_members)
|
|
705
|
+
runner = command_runner
|
|
706
|
+
control_member = monorepo_template_control_member
|
|
707
|
+
entries = []
|
|
708
|
+
results = []
|
|
709
|
+
test_members.each do |member|
|
|
710
|
+
worktree_root = monorepo_test_worktree_path(member)
|
|
711
|
+
result = runner.call(
|
|
712
|
+
member: control_member,
|
|
713
|
+
phase: "test_member_worktree_add",
|
|
714
|
+
command: ["git", "worktree", "add", "--detach", worktree_root, "HEAD"]
|
|
715
|
+
)
|
|
716
|
+
result.member_name = member.name
|
|
717
|
+
results << result
|
|
718
|
+
break unless result.ok?
|
|
719
|
+
|
|
720
|
+
entries << {
|
|
721
|
+
member: relocate_member_to_worktree(member, worktree_root, source_root: config.root),
|
|
722
|
+
original_member: member,
|
|
723
|
+
worktree_root: worktree_root
|
|
724
|
+
}
|
|
725
|
+
end
|
|
726
|
+
[entries, results]
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def monorepo_test_worktree_path(member)
|
|
730
|
+
safe_member = member.name.to_s.gsub(/[^A-Za-z0-9_.-]+/, "_")
|
|
731
|
+
File.join(config.root, "tmp", "kettle-family", "test-member-worktrees", safe_member, "#{Process.pid}-#{Time.now.to_i}")
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
def run_monorepo_test_worktree_entries(entries)
|
|
735
|
+
wave_jobs = test_jobs(entries)
|
|
736
|
+
queue = Queue.new
|
|
737
|
+
entries.each_with_index { |entry, index| queue << [index, entry] }
|
|
738
|
+
ordered_results = Array.new(entries.length)
|
|
739
|
+
mutex = Mutex.new
|
|
740
|
+
stop = false
|
|
741
|
+
|
|
742
|
+
Array.new(wave_jobs) do
|
|
743
|
+
Thread.new do # rubocop:disable ThreadSafety/NewThread -- detached worktrees isolate concurrent monorepo test suites.
|
|
744
|
+
loop do
|
|
745
|
+
break if mutex.synchronize { stop }
|
|
746
|
+
|
|
747
|
+
index, entry = queue.pop(true)
|
|
748
|
+
member = entry.fetch(:member)
|
|
749
|
+
result = CommandRunner.new(execute: execute, accept: accept).call(
|
|
750
|
+
member: member,
|
|
751
|
+
phase: command,
|
|
752
|
+
command: workflow_command(member),
|
|
753
|
+
env: test_command_env(wave_jobs: wave_jobs)
|
|
754
|
+
)
|
|
755
|
+
result.member_name = entry.fetch(:original_member).name
|
|
756
|
+
mutex.synchronize do
|
|
757
|
+
ordered_results[index] = [result]
|
|
758
|
+
stop = true unless result.ok?
|
|
759
|
+
end
|
|
760
|
+
rescue ThreadError
|
|
761
|
+
break
|
|
762
|
+
rescue => error
|
|
763
|
+
mutex.synchronize do
|
|
764
|
+
ordered_results[index] = [test_worker_error_result(entry.fetch(:original_member), error)]
|
|
765
|
+
stop = true
|
|
766
|
+
end
|
|
767
|
+
break
|
|
768
|
+
end
|
|
769
|
+
end
|
|
770
|
+
end.each(&:join)
|
|
771
|
+
ordered_results.compact
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
def cleanup_monorepo_test_worktrees(entries)
|
|
775
|
+
runner = command_runner
|
|
776
|
+
control_member = monorepo_template_control_member
|
|
777
|
+
entries.each do |entry|
|
|
778
|
+
root = entry.fetch(:worktree_root)
|
|
779
|
+
next unless Dir.exist?(root)
|
|
780
|
+
|
|
781
|
+
runner.call(member: control_member, phase: "test_member_worktree_remove", command: ["git", "worktree", "remove", "--force", root])
|
|
782
|
+
end
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
def aggregate_test_skip_result(member, owner:)
|
|
786
|
+
CommandResult.new(
|
|
787
|
+
member_name: member.name,
|
|
788
|
+
phase: command,
|
|
789
|
+
command: workflow_command(member),
|
|
790
|
+
workdir: member.root,
|
|
791
|
+
status: 0,
|
|
792
|
+
success: true,
|
|
793
|
+
stdout: "",
|
|
794
|
+
stderr: "",
|
|
795
|
+
elapsed_seconds: 0.0,
|
|
796
|
+
skipped: true,
|
|
797
|
+
reason: "shared monorepo suite owned by #{owner.name}"
|
|
798
|
+
)
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
def test_worker_error_result(member, error)
|
|
802
|
+
CommandResult.new(
|
|
803
|
+
member_name: member.name,
|
|
804
|
+
phase: command,
|
|
805
|
+
command: ["internal", "test-worker"],
|
|
806
|
+
workdir: member.root,
|
|
807
|
+
status: 1,
|
|
808
|
+
success: false,
|
|
809
|
+
stdout: "",
|
|
810
|
+
stderr: "#{error.class}: #{error.message}\n",
|
|
811
|
+
elapsed_seconds: 0.0,
|
|
812
|
+
skipped: false,
|
|
813
|
+
reason: error.message
|
|
814
|
+
)
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
def test_jobs(workflow_members)
|
|
818
|
+
requested = jobs || config.test_jobs
|
|
819
|
+
Concurrency.wave_jobs(requested: requested, item_count: workflow_members.length)
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
def test_command_env(wave_jobs:)
|
|
823
|
+
env = command_env
|
|
824
|
+
env["TURBO_TESTS2_MAX_PROCESSES"] ||= Concurrency.test_process_ceiling(wave_jobs: wave_jobs).to_s
|
|
825
|
+
env
|
|
826
|
+
end
|
|
827
|
+
|
|
617
828
|
def review_gha_sha_pins(workflow_members, runner:, memo:, env: command_env)
|
|
618
829
|
return true if workflow_members.empty?
|
|
619
830
|
|
|
@@ -719,17 +930,20 @@ module Kettle
|
|
|
719
930
|
end
|
|
720
931
|
|
|
721
932
|
def template_results_for_wave(wave, progress:)
|
|
933
|
+
return monorepo_template_worktree_wave_results(wave, progress: progress) if monorepo_template_worktrees?(wave)
|
|
934
|
+
|
|
935
|
+
wave_jobs = template_jobs(wave)
|
|
722
936
|
queue = Queue.new
|
|
723
937
|
wave.each_with_index { |member, index| queue << [index, member] }
|
|
724
938
|
ordered_results = Array.new(wave.length)
|
|
725
939
|
mutex = Mutex.new
|
|
726
940
|
stop = false
|
|
727
|
-
Array.new(
|
|
941
|
+
Array.new(wave_jobs) do
|
|
728
942
|
Thread.new do # rubocop:disable ThreadSafety/NewThread -- family templating intentionally runs independent members concurrently.
|
|
729
943
|
loop do
|
|
730
944
|
break if mutex.synchronize { stop }
|
|
731
945
|
index, member = queue.pop(true)
|
|
732
|
-
member_results = template_results_for_member(member, progress: progress)
|
|
946
|
+
member_results = template_results_for_member(member, progress: progress, wave_jobs: wave_jobs)
|
|
733
947
|
mutex.synchronize do
|
|
734
948
|
ordered_results[index] = member_results
|
|
735
949
|
stop = true unless member_results.all?(&:ok?)
|
|
@@ -751,10 +965,15 @@ module Kettle
|
|
|
751
965
|
ready = remaining.select do |member|
|
|
752
966
|
(member.dependencies & by_name.keys).all? { |name| completed.include?(name) }
|
|
753
967
|
end
|
|
754
|
-
# A
|
|
755
|
-
#
|
|
756
|
-
#
|
|
757
|
-
|
|
968
|
+
# A shared checkout remains a transaction boundary unless each
|
|
969
|
+
# ready monorepo member will receive an isolated worktree. The
|
|
970
|
+
# latter keeps the normal dependency graph intact while allowing
|
|
971
|
+
# independent sibling gems to use the requested worker budget.
|
|
972
|
+
wave = if monorepo_template_worktrees_enabled?
|
|
973
|
+
ready
|
|
974
|
+
else
|
|
975
|
+
ready.group_by { |member| git_root_for(member) }.values.map(&:first)
|
|
976
|
+
end
|
|
758
977
|
raise Error, "template dependency wave could not resolve: #{remaining.map(&:name).join(", ")}" if wave.empty?
|
|
759
978
|
|
|
760
979
|
waves << wave
|
|
@@ -764,6 +983,218 @@ module Kettle
|
|
|
764
983
|
end
|
|
765
984
|
end
|
|
766
985
|
|
|
986
|
+
# A monorepo has one mutable primary checkout, but its ready sibling
|
|
987
|
+
# gems can template independently. Run them in detached worktrees and
|
|
988
|
+
# materialize only changes beneath each member root before the existing
|
|
989
|
+
# serialized commit step runs in the primary checkout.
|
|
990
|
+
def monorepo_template_worktree_wave_results(wave, progress:)
|
|
991
|
+
entries, setup_results = monorepo_template_worktree_entries(wave)
|
|
992
|
+
return setup_results unless setup_results.all?(&:ok?)
|
|
993
|
+
|
|
994
|
+
worker_results = run_monorepo_template_worktree_entries(entries, progress: progress)
|
|
995
|
+
results = []
|
|
996
|
+
worker_results.each_with_index do |member_results, index|
|
|
997
|
+
next unless member_results
|
|
998
|
+
|
|
999
|
+
results.concat(member_results)
|
|
1000
|
+
next unless member_results.all?(&:ok?)
|
|
1001
|
+
|
|
1002
|
+
entry = entries.fetch(index)
|
|
1003
|
+
materialized = materialize_monorepo_template_changes(entry)
|
|
1004
|
+
results << materialized
|
|
1005
|
+
next unless materialized.ok?
|
|
1006
|
+
|
|
1007
|
+
commit_results = []
|
|
1008
|
+
commit_template_changes(member: entry.fetch(:original_member), runner: command_runner, memo: commit_results)
|
|
1009
|
+
results.concat(commit_results)
|
|
1010
|
+
end
|
|
1011
|
+
results
|
|
1012
|
+
ensure
|
|
1013
|
+
cleanup_monorepo_template_worktrees(entries || [])
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
def monorepo_template_worktrees?(wave)
|
|
1017
|
+
monorepo_template_worktrees_enabled? && wave.length > 1
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
def monorepo_template_worktrees_enabled?
|
|
1021
|
+
command_text = config.template_command || DEFAULT_COMMANDS.fetch("template")
|
|
1022
|
+
execute && monorepo_template? && kettle_jem_template_command?(command_text) && members.length > 1 && template_jobs(members) > 1
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
def monorepo_template_worktree_entries(wave)
|
|
1026
|
+
runner = command_runner
|
|
1027
|
+
control_member = monorepo_template_control_member
|
|
1028
|
+
entries = []
|
|
1029
|
+
results = []
|
|
1030
|
+
wave.each do |member|
|
|
1031
|
+
worktree_root = monorepo_template_worktree_path(member)
|
|
1032
|
+
result = runner.call(
|
|
1033
|
+
member: control_member,
|
|
1034
|
+
phase: "template_member_worktree_add",
|
|
1035
|
+
command: ["git", "worktree", "add", "--detach", worktree_root, "HEAD"]
|
|
1036
|
+
)
|
|
1037
|
+
result.member_name = member.name
|
|
1038
|
+
results << result
|
|
1039
|
+
break unless result.ok?
|
|
1040
|
+
|
|
1041
|
+
entries << {
|
|
1042
|
+
member: relocate_member_to_worktree(member, worktree_root, source_root: config.root),
|
|
1043
|
+
original_member: member,
|
|
1044
|
+
worktree_root: worktree_root
|
|
1045
|
+
}
|
|
1046
|
+
end
|
|
1047
|
+
[entries, results]
|
|
1048
|
+
end
|
|
1049
|
+
|
|
1050
|
+
def monorepo_template_worktree_path(member)
|
|
1051
|
+
safe_member = member.name.to_s.gsub(/[^A-Za-z0-9_.-]+/, "_")
|
|
1052
|
+
File.join(config.root, "tmp", "kettle-family", "template-member-worktrees", safe_member, "#{Process.pid}-#{Time.now.to_i}")
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
def monorepo_template_control_member
|
|
1056
|
+
family_member.dup.tap { |member| member.root = config.root }
|
|
1057
|
+
end
|
|
1058
|
+
|
|
1059
|
+
def run_monorepo_template_worktree_entries(entries, progress:)
|
|
1060
|
+
wave_jobs = template_jobs(entries)
|
|
1061
|
+
queue = Queue.new
|
|
1062
|
+
entries.each_with_index { |entry, index| queue << [index, entry] }
|
|
1063
|
+
ordered_results = Array.new(entries.length)
|
|
1064
|
+
mutex = Mutex.new
|
|
1065
|
+
stop = false
|
|
1066
|
+
Array.new(wave_jobs) do
|
|
1067
|
+
Thread.new do # rubocop:disable ThreadSafety/NewThread -- detached worktrees isolate concurrent monorepo template members.
|
|
1068
|
+
loop do
|
|
1069
|
+
break if mutex.synchronize { stop }
|
|
1070
|
+
|
|
1071
|
+
index, entry = queue.pop(true)
|
|
1072
|
+
member_results = template_results_for_member(
|
|
1073
|
+
entry.fetch(:member),
|
|
1074
|
+
progress: progress,
|
|
1075
|
+
commit_changes: false,
|
|
1076
|
+
wave_jobs: wave_jobs
|
|
1077
|
+
)
|
|
1078
|
+
mutex.synchronize do
|
|
1079
|
+
ordered_results[index] = member_results
|
|
1080
|
+
stop = true unless member_results.all?(&:ok?)
|
|
1081
|
+
end
|
|
1082
|
+
rescue ThreadError
|
|
1083
|
+
break
|
|
1084
|
+
rescue => error
|
|
1085
|
+
mutex.synchronize do
|
|
1086
|
+
ordered_results[index] = [template_worktree_failure_result(entry.fetch(:original_member), error)]
|
|
1087
|
+
stop = true
|
|
1088
|
+
end
|
|
1089
|
+
break
|
|
1090
|
+
end
|
|
1091
|
+
end
|
|
1092
|
+
end.each(&:join)
|
|
1093
|
+
ordered_results
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
def materialize_monorepo_template_changes(entry)
|
|
1097
|
+
original_member = entry.fetch(:original_member)
|
|
1098
|
+
worktree_root = entry.fetch(:worktree_root)
|
|
1099
|
+
relative_root = Pathname.new(original_member.root).relative_path_from(Pathname.new(config.root)).to_s
|
|
1100
|
+
paths = monorepo_template_worktree_changed_paths(worktree_root)
|
|
1101
|
+
return template_worktree_failure_result(original_member, paths) if paths.is_a?(String)
|
|
1102
|
+
|
|
1103
|
+
invalid_paths = paths.reject { |path| path == relative_root || path.start_with?("#{relative_root}/") }
|
|
1104
|
+
unless invalid_paths.empty?
|
|
1105
|
+
return template_worktree_failure_result(
|
|
1106
|
+
original_member,
|
|
1107
|
+
"isolated template worker modified shared path(s): #{invalid_paths.sort.join(", ")}; only #{relative_root}/ may be materialized"
|
|
1108
|
+
)
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
tracked_paths = monorepo_template_worktree_tracked_paths(worktree_root)
|
|
1112
|
+
return template_worktree_failure_result(original_member, tracked_paths) if tracked_paths.is_a?(String)
|
|
1113
|
+
|
|
1114
|
+
runner = command_runner
|
|
1115
|
+
control_member = monorepo_template_control_member.dup
|
|
1116
|
+
control_member.name = original_member.name
|
|
1117
|
+
command = if tracked_paths.empty?
|
|
1118
|
+
["true"]
|
|
1119
|
+
else
|
|
1120
|
+
source = Shellwords.escape(worktree_root)
|
|
1121
|
+
scope = Shellwords.escape(relative_root)
|
|
1122
|
+
["sh", "-lc", "git -C #{source} diff --binary HEAD -- #{scope} | git apply --whitespace=nowarn"]
|
|
1123
|
+
end
|
|
1124
|
+
result = runner.call(member: control_member, phase: "template_worktree_materialize", command: command)
|
|
1125
|
+
return result unless result.ok?
|
|
1126
|
+
|
|
1127
|
+
copy_monorepo_template_untracked_paths(worktree_root, config.root, relative_root)
|
|
1128
|
+
result.stdout = "#{paths.length} path(s) materialized from isolated worktree"
|
|
1129
|
+
result
|
|
1130
|
+
rescue => error
|
|
1131
|
+
template_worktree_failure_result(original_member, error)
|
|
1132
|
+
end
|
|
1133
|
+
|
|
1134
|
+
def monorepo_template_worktree_changed_paths(worktree_root)
|
|
1135
|
+
tracked_paths = monorepo_template_worktree_tracked_paths(worktree_root)
|
|
1136
|
+
return tracked_paths if tracked_paths.is_a?(String)
|
|
1137
|
+
|
|
1138
|
+
untracked_paths = monorepo_template_worktree_untracked_paths(worktree_root)
|
|
1139
|
+
return untracked_paths if untracked_paths.is_a?(String)
|
|
1140
|
+
|
|
1141
|
+
(tracked_paths + untracked_paths).uniq
|
|
1142
|
+
end
|
|
1143
|
+
|
|
1144
|
+
def monorepo_template_worktree_tracked_paths(worktree_root)
|
|
1145
|
+
git_worktree_paths(worktree_root, %w[diff --name-only -z HEAD])
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
def monorepo_template_worktree_untracked_paths(worktree_root)
|
|
1149
|
+
git_worktree_paths(worktree_root, %w[ls-files --others --exclude-standard -z])
|
|
1150
|
+
end
|
|
1151
|
+
|
|
1152
|
+
def git_worktree_paths(worktree_root, arguments)
|
|
1153
|
+
stdout, stderr, status = Open3.capture3("git", *arguments, chdir: worktree_root)
|
|
1154
|
+
return stdout.split("\0").reject(&:empty?) if status.success?
|
|
1155
|
+
|
|
1156
|
+
"git #{arguments.join(" ")} failed in #{worktree_root}: #{stderr.strip}"
|
|
1157
|
+
end
|
|
1158
|
+
|
|
1159
|
+
def copy_monorepo_template_untracked_paths(worktree_root, primary_root, relative_root)
|
|
1160
|
+
monorepo_template_worktree_untracked_paths(worktree_root).each do |path|
|
|
1161
|
+
next unless path == relative_root || path.start_with?("#{relative_root}/")
|
|
1162
|
+
|
|
1163
|
+
source = File.join(worktree_root, path)
|
|
1164
|
+
destination = File.join(primary_root, path)
|
|
1165
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
1166
|
+
FileUtils.cp_r(source, destination, preserve: true)
|
|
1167
|
+
end
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
def cleanup_monorepo_template_worktrees(entries)
|
|
1171
|
+
runner = command_runner
|
|
1172
|
+
control_member = monorepo_template_control_member
|
|
1173
|
+
entries.each do |entry|
|
|
1174
|
+
root = entry.fetch(:worktree_root)
|
|
1175
|
+
next unless Dir.exist?(root)
|
|
1176
|
+
|
|
1177
|
+
runner.call(member: control_member, phase: "template_member_worktree_remove", command: ["git", "worktree", "remove", "--force", root])
|
|
1178
|
+
end
|
|
1179
|
+
end
|
|
1180
|
+
|
|
1181
|
+
def template_worktree_failure_result(member, error)
|
|
1182
|
+
message = error.is_a?(Exception) ? "#{error.class}: #{error.message}" : error.to_s
|
|
1183
|
+
CommandResult.new(
|
|
1184
|
+
member_name: member.name,
|
|
1185
|
+
phase: "template_member_worktree",
|
|
1186
|
+
command: ["internal", "template-member-worktree"],
|
|
1187
|
+
workdir: member.root,
|
|
1188
|
+
status: 1,
|
|
1189
|
+
success: false,
|
|
1190
|
+
stdout: "",
|
|
1191
|
+
stderr: message,
|
|
1192
|
+
elapsed_seconds: 0.0,
|
|
1193
|
+
skipped: false,
|
|
1194
|
+
reason: "template member worktree failed"
|
|
1195
|
+
)
|
|
1196
|
+
end
|
|
1197
|
+
|
|
767
1198
|
def template_bootstrap_dependency_results(workflow_members)
|
|
768
1199
|
return [] if workflow_members.empty?
|
|
769
1200
|
|
|
@@ -856,17 +1287,23 @@ module Kettle
|
|
|
856
1287
|
)
|
|
857
1288
|
end
|
|
858
1289
|
|
|
859
|
-
def template_results_for_member(member, progress: nil)
|
|
1290
|
+
def template_results_for_member(member, progress: nil, commit_changes: true, wave_jobs: 1)
|
|
860
1291
|
progress&.start_member(member, total: template_phase_total(member), status: template_initial_status(member))
|
|
861
1292
|
runner = CommandRunner.new(execute: execute, accept: accept)
|
|
862
1293
|
[].tap do |memo|
|
|
863
1294
|
if config.normalize_lockfiles?
|
|
864
|
-
normalize_lockfiles(member: member, runner: runner, memo: memo, phase: "prepare_lockfiles")
|
|
1295
|
+
normalize_lockfiles(member: member, runner: runner, memo: memo, phase: "prepare_lockfiles", commit_changes: commit_changes)
|
|
865
1296
|
emit_member_result_progress(member, memo.last, progress: progress)
|
|
866
1297
|
return memo unless memo.last.ok?
|
|
867
1298
|
end
|
|
868
1299
|
|
|
869
|
-
prepared = prepare_template_dependencies(
|
|
1300
|
+
prepared = prepare_template_dependencies(
|
|
1301
|
+
member: member,
|
|
1302
|
+
runner: runner,
|
|
1303
|
+
memo: memo,
|
|
1304
|
+
commit_changes: commit_changes,
|
|
1305
|
+
wave_jobs: wave_jobs
|
|
1306
|
+
)
|
|
870
1307
|
emit_member_result_progress(member, memo.last, progress: progress) if memo.last&.phase == "prepare_template_dependencies"
|
|
871
1308
|
return memo if prepared == false
|
|
872
1309
|
|
|
@@ -874,15 +1311,15 @@ module Kettle
|
|
|
874
1311
|
member: member,
|
|
875
1312
|
phase: command,
|
|
876
1313
|
command: workflow_command(member),
|
|
877
|
-
env: template_command_env,
|
|
1314
|
+
env: template_command_env(wave_jobs: wave_jobs),
|
|
878
1315
|
stdout_line_handler: template_event_line_handler(member, progress: progress)
|
|
879
1316
|
)
|
|
880
1317
|
emit_member_result_progress(member, memo.last, progress: progress)
|
|
881
1318
|
return memo unless memo.last.ok?
|
|
882
1319
|
|
|
883
|
-
normalize_lockfiles(member: member, runner: runner, memo: memo, phase: "normalize_lockfiles")
|
|
1320
|
+
normalize_lockfiles(member: member, runner: runner, memo: memo, phase: "normalize_lockfiles", commit_changes: commit_changes)
|
|
884
1321
|
emit_member_result_progress(member, memo.last, progress: progress)
|
|
885
|
-
commit_template_changes(member: member, runner: runner, memo: memo)
|
|
1322
|
+
commit_template_changes(member: member, runner: runner, memo: memo) if commit_changes
|
|
886
1323
|
emit_member_result_progress(member, memo.last, progress: progress) if memo.last&.phase == "commit_template"
|
|
887
1324
|
ensure
|
|
888
1325
|
template_result = memo.find { |result| result.phase == "template" } || memo.last
|
|
@@ -898,8 +1335,7 @@ module Kettle
|
|
|
898
1335
|
|
|
899
1336
|
def template_jobs(workflow_members)
|
|
900
1337
|
requested = jobs || config.template_jobs
|
|
901
|
-
|
|
902
|
-
count.clamp(1, workflow_members.length)
|
|
1338
|
+
Concurrency.wave_jobs(requested: requested, item_count: workflow_members.length)
|
|
903
1339
|
end
|
|
904
1340
|
|
|
905
1341
|
def check_results(workflow_members)
|
|
@@ -1056,17 +1492,18 @@ module Kettle
|
|
|
1056
1492
|
end
|
|
1057
1493
|
|
|
1058
1494
|
def template_branch_worktree_entries_results(entries)
|
|
1495
|
+
wave_jobs = template_jobs(entries)
|
|
1059
1496
|
queue = Queue.new
|
|
1060
1497
|
entries.each_with_index { |entry, index| queue << [index, entry] }
|
|
1061
1498
|
ordered_results = Array.new(entries.length)
|
|
1062
1499
|
mutex = Mutex.new
|
|
1063
1500
|
stop = false
|
|
1064
|
-
Array.new(
|
|
1501
|
+
Array.new(wave_jobs) do
|
|
1065
1502
|
Thread.new do # rubocop:disable ThreadSafety/NewThread -- independent branch worktrees are safe to template concurrently.
|
|
1066
1503
|
loop do
|
|
1067
1504
|
break if mutex.synchronize { stop }
|
|
1068
1505
|
index, entry = queue.pop(true)
|
|
1069
|
-
branch_results = template_branch_worktree_entry_results(entry)
|
|
1506
|
+
branch_results = template_branch_worktree_entry_results(entry, wave_jobs: wave_jobs)
|
|
1070
1507
|
mutex.synchronize do
|
|
1071
1508
|
ordered_results[index] = branch_results
|
|
1072
1509
|
stop = true unless branch_results.all?(&:ok?)
|
|
@@ -1079,7 +1516,7 @@ module Kettle
|
|
|
1079
1516
|
ordered_results.compact.flatten
|
|
1080
1517
|
end
|
|
1081
1518
|
|
|
1082
|
-
def template_branch_worktree_entry_results(entry)
|
|
1519
|
+
def template_branch_worktree_entry_results(entry, wave_jobs: 1)
|
|
1083
1520
|
member = entry.fetch(:member)
|
|
1084
1521
|
branch = entry.fetch(:branch)
|
|
1085
1522
|
runner = command_runner
|
|
@@ -1087,7 +1524,7 @@ module Kettle
|
|
|
1087
1524
|
if git_upstream_for(member)
|
|
1088
1525
|
results << runner.call(member: member, phase: "template_branch_sync", command: ["git", "rebase", "@{upstream}"])
|
|
1089
1526
|
end
|
|
1090
|
-
results.concat(template_results_for_member(member)) if results.all?(&:ok?)
|
|
1527
|
+
results.concat(template_results_for_member(member, wave_jobs: wave_jobs)) if results.all?(&:ok?)
|
|
1091
1528
|
tag_branch_results(results, branch)
|
|
1092
1529
|
emit_template_event_line(member, results.all?(&:ok?) ? "." : "F", "branch #{branch}")
|
|
1093
1530
|
results
|
|
@@ -1452,7 +1889,7 @@ module Kettle
|
|
|
1452
1889
|
waves.each_with_index do |wave, index|
|
|
1453
1890
|
results << release_wave_result(wave, index: index, total: waves.length, jobs: 1) if show_wave_markers
|
|
1454
1891
|
wave.each do |member|
|
|
1455
|
-
results.concat(release_results_for_member(member, runner: runner))
|
|
1892
|
+
results.concat(release_results_for_member(member, runner: runner, wave_jobs: 1))
|
|
1456
1893
|
break unless results.last.ok?
|
|
1457
1894
|
|
|
1458
1895
|
remaining_members = ordered_members.drop(ordered_members.index(member) + 1)
|
|
@@ -1481,7 +1918,7 @@ module Kettle
|
|
|
1481
1918
|
begin
|
|
1482
1919
|
waves.each_with_index do |wave, index|
|
|
1483
1920
|
results << release_wave_result(wave, index: index, total: waves.length)
|
|
1484
|
-
wave_results = run_release_wave(wave)
|
|
1921
|
+
wave_results = run_release_wave(wave, wave_jobs: release_jobs(wave))
|
|
1485
1922
|
results.concat(wave_results.flatten)
|
|
1486
1923
|
break unless wave_results.all? { |member_results| member_results.all?(&:ok?) }
|
|
1487
1924
|
|
|
@@ -1498,11 +1935,10 @@ module Kettle
|
|
|
1498
1935
|
results
|
|
1499
1936
|
end
|
|
1500
1937
|
|
|
1501
|
-
def run_release_wave(wave)
|
|
1938
|
+
def run_release_wave(wave, wave_jobs: release_jobs(wave))
|
|
1502
1939
|
queue = Queue.new
|
|
1503
1940
|
wave.each_with_index { |member, index| queue << [index, member] }
|
|
1504
1941
|
ordered_results = Array.new(wave.length)
|
|
1505
|
-
wave_jobs = release_jobs(wave)
|
|
1506
1942
|
mutex = Mutex.new
|
|
1507
1943
|
stop = false
|
|
1508
1944
|
Array.new(wave_jobs) do
|
|
@@ -1512,7 +1948,7 @@ module Kettle
|
|
|
1512
1948
|
break if mutex.synchronize { stop }
|
|
1513
1949
|
|
|
1514
1950
|
index, member = queue.pop(true)
|
|
1515
|
-
member_results = release_results_for_member(member, runner: runner)
|
|
1951
|
+
member_results = release_results_for_member(member, runner: runner, wave_jobs: wave_jobs)
|
|
1516
1952
|
mutex.synchronize do
|
|
1517
1953
|
ordered_results[index] = member_results
|
|
1518
1954
|
stop = true unless member_results.all?(&:ok?)
|
|
@@ -1543,7 +1979,7 @@ module Kettle
|
|
|
1543
1979
|
aggregate_members, independent_members = wave.partition { |member| aggregate_release_member?(member) }
|
|
1544
1980
|
|
|
1545
1981
|
unless aggregate_members.empty?
|
|
1546
|
-
worker_results = run_monorepo_release_wave(aggregate_members)
|
|
1982
|
+
worker_results = run_monorepo_release_wave(aggregate_members, wave_jobs: release_jobs(aggregate_members))
|
|
1547
1983
|
results.concat(worker_results.flatten)
|
|
1548
1984
|
break unless worker_results.all? { |member_results| member_results.all?(&:ok?) }
|
|
1549
1985
|
|
|
@@ -1553,7 +1989,7 @@ module Kettle
|
|
|
1553
1989
|
end
|
|
1554
1990
|
|
|
1555
1991
|
independent_members.each do |member|
|
|
1556
|
-
results.concat(release_results_for_member(member, runner: release_command_runner))
|
|
1992
|
+
results.concat(release_results_for_member(member, runner: release_command_runner, wave_jobs: 1))
|
|
1557
1993
|
break unless results.last.ok?
|
|
1558
1994
|
end
|
|
1559
1995
|
break unless results.last&.ok?
|
|
@@ -1571,13 +2007,13 @@ module Kettle
|
|
|
1571
2007
|
results
|
|
1572
2008
|
end
|
|
1573
2009
|
|
|
1574
|
-
def run_monorepo_release_wave(members)
|
|
2010
|
+
def run_monorepo_release_wave(members, wave_jobs: release_jobs(members))
|
|
1575
2011
|
entries, setup_results = monorepo_release_worktree_entries(members)
|
|
1576
2012
|
unless setup_results.all?(&:ok?)
|
|
1577
2013
|
return setup_results.zip
|
|
1578
2014
|
end
|
|
1579
2015
|
|
|
1580
|
-
worker_results = run_monorepo_release_worktree_entries(entries)
|
|
2016
|
+
worker_results = run_monorepo_release_worktree_entries(entries, wave_jobs: wave_jobs)
|
|
1581
2017
|
materialization_results = materialize_monorepo_release_artifacts(entries, worker_results)
|
|
1582
2018
|
worker_results.each_with_index.map do |member_results, index|
|
|
1583
2019
|
artifact_result = materialization_results[index]
|
|
@@ -1616,19 +2052,19 @@ module Kettle
|
|
|
1616
2052
|
family_member.dup.tap { |member| member.root = config.root }
|
|
1617
2053
|
end
|
|
1618
2054
|
|
|
1619
|
-
def run_monorepo_release_worktree_entries(entries)
|
|
2055
|
+
def run_monorepo_release_worktree_entries(entries, wave_jobs: release_jobs(entries))
|
|
1620
2056
|
queue = Queue.new
|
|
1621
2057
|
entries.each_with_index { |entry, index| queue << [index, entry] }
|
|
1622
2058
|
ordered_results = Array.new(entries.length)
|
|
1623
2059
|
mutex = Mutex.new
|
|
1624
2060
|
stop = false
|
|
1625
|
-
Array.new(
|
|
2061
|
+
Array.new(wave_jobs) do
|
|
1626
2062
|
Thread.new do # rubocop:disable ThreadSafety/NewThread -- each member has an isolated detached monorepo worktree.
|
|
1627
2063
|
loop do
|
|
1628
2064
|
break if mutex.synchronize { stop }
|
|
1629
2065
|
|
|
1630
2066
|
index, entry = queue.pop(true)
|
|
1631
|
-
member_results = release_results_for_monorepo_worktree(entry)
|
|
2067
|
+
member_results = release_results_for_monorepo_worktree(entry, wave_jobs: wave_jobs)
|
|
1632
2068
|
mutex.synchronize do
|
|
1633
2069
|
ordered_results[index] = member_results
|
|
1634
2070
|
stop = true unless member_results.all?(&:ok?)
|
|
@@ -1647,7 +2083,7 @@ module Kettle
|
|
|
1647
2083
|
ordered_results.compact
|
|
1648
2084
|
end
|
|
1649
2085
|
|
|
1650
|
-
def release_results_for_monorepo_worktree(entry)
|
|
2086
|
+
def release_results_for_monorepo_worktree(entry, wave_jobs: 1)
|
|
1651
2087
|
member = entry.fetch(:member)
|
|
1652
2088
|
original_member = entry.fetch(:original_member)
|
|
1653
2089
|
progress = @release_progress
|
|
@@ -1668,7 +2104,7 @@ module Kettle
|
|
|
1668
2104
|
member: member,
|
|
1669
2105
|
phase: release_phase,
|
|
1670
2106
|
command: release_command_for(member),
|
|
1671
|
-
env: release_env_for_member(member, family_root: entry.fetch(:worktree_root)).merge("KETTLE_RELEASE_FAMILY_MEMBER_PUBLISH" => "true"),
|
|
2107
|
+
env: release_env_for_member(member, family_root: entry.fetch(:worktree_root), wave_jobs: wave_jobs).merge("KETTLE_RELEASE_FAMILY_MEMBER_PUBLISH" => "true"),
|
|
1672
2108
|
interactive: release_command_interactive?,
|
|
1673
2109
|
stdout_line_handler: release_event_line_handler(original_member, progress: progress, state: release_event_state),
|
|
1674
2110
|
log_path: release_command_log_path(original_member, release_phase),
|
|
@@ -1805,7 +2241,7 @@ module Kettle
|
|
|
1805
2241
|
)
|
|
1806
2242
|
end
|
|
1807
2243
|
|
|
1808
|
-
def release_results_for_member(member, runner:)
|
|
2244
|
+
def release_results_for_member(member, runner:, wave_jobs: 1)
|
|
1809
2245
|
progress = @release_progress
|
|
1810
2246
|
progress&.start_member(member, total: release_phase_total(member), status: "check")
|
|
1811
2247
|
[].tap do |memo|
|
|
@@ -1840,7 +2276,7 @@ module Kettle
|
|
|
1840
2276
|
member: member,
|
|
1841
2277
|
phase: release_phase,
|
|
1842
2278
|
command: release_command_for(member),
|
|
1843
|
-
env: release_env_for_member(member),
|
|
2279
|
+
env: release_env_for_member(member, wave_jobs: wave_jobs),
|
|
1844
2280
|
interactive: release_command_interactive?,
|
|
1845
2281
|
stdout_line_handler: release_event_line_handler(member, progress: progress, state: release_event_state),
|
|
1846
2282
|
log_path: release_command_log_path(member, release_phase),
|
|
@@ -1917,8 +2353,7 @@ module Kettle
|
|
|
1917
2353
|
return 1 if truffleruby?
|
|
1918
2354
|
|
|
1919
2355
|
requested = jobs || config.release_jobs
|
|
1920
|
-
|
|
1921
|
-
count.clamp(1, release_members.length)
|
|
2356
|
+
Concurrency.wave_jobs(requested: requested, item_count: release_members.length)
|
|
1922
2357
|
end
|
|
1923
2358
|
|
|
1924
2359
|
def truffleruby?
|
|
@@ -2963,7 +3398,7 @@ module Kettle
|
|
|
2963
3398
|
# A monorepo member runs kettle-release from its own checkout, while its
|
|
2964
3399
|
# changelog may live at the family root. Pass the shared paths to the
|
|
2965
3400
|
# member release phase as well as to the separate family phase.
|
|
2966
|
-
def release_env_for_member(member, family_root: config.root)
|
|
3401
|
+
def release_env_for_member(member, family_root: config.root, wave_jobs: 1)
|
|
2967
3402
|
env = release_env
|
|
2968
3403
|
env.merge!(release_wave_local_path_env_for(member, family_root: family_root))
|
|
2969
3404
|
env.merge!(release_local_path_policy_env(family_root: family_root))
|
|
@@ -2997,6 +3432,10 @@ module Kettle
|
|
|
2997
3432
|
if aggregate_validation_member?(member)
|
|
2998
3433
|
env["KETTLE_RELEASE_FAMILY_CI_MODE"] = aggregate_validation_member_name?(member) ? "validation" : "member"
|
|
2999
3434
|
end
|
|
3435
|
+
# kettle-release invokes kettle-test as part of its member lifecycle.
|
|
3436
|
+
# Preserve an operator-supplied ceiling, otherwise budget the complete
|
|
3437
|
+
# TurboTests2 process pool for this active Family wave.
|
|
3438
|
+
env["TURBO_TESTS2_MAX_PROCESSES"] ||= Concurrency.test_process_ceiling(wave_jobs: wave_jobs).to_s
|
|
3000
3439
|
return env unless config.shared_changelog?
|
|
3001
3440
|
if config.member_local_changelog?(member)
|
|
3002
3441
|
return env.merge(
|
|
@@ -3592,7 +4031,6 @@ module Kettle
|
|
|
3592
4031
|
env["BUNDLE_DISABLE_CHECKSUM_VALIDATION"] = "true"
|
|
3593
4032
|
env["KETTLE_JEM_TEMPLATE_PROFILE"] = config.template_profile if config.template_profile
|
|
3594
4033
|
env["KJ_REPOSITORY_TOPOLOGY"] = config.template_repository_topology if config.template_repository_topology
|
|
3595
|
-
env["KETTLE_JEM_THREAD_WORKERS"] ||= template_thread_worker_budget.to_s
|
|
3596
4034
|
if monorepo_template?
|
|
3597
4035
|
template_git_lock_path = template_git_commit_lock_path
|
|
3598
4036
|
env["KETTLE_JEM_GIT_LOCK"] = template_git_lock_path
|
|
@@ -4348,7 +4786,7 @@ module Kettle
|
|
|
4348
4786
|
"#{outcomes.fetch(:checksum_hits)} checksum hits#{protected_label}, #{outcomes.fetch(:unchanged)} unchanged, #{changed_label}"
|
|
4349
4787
|
end
|
|
4350
4788
|
|
|
4351
|
-
def normalize_lockfiles(member:, runner:, memo:, phase:)
|
|
4789
|
+
def normalize_lockfiles(member:, runner:, memo:, phase:, commit_changes: true)
|
|
4352
4790
|
return unless config.normalize_lockfiles?
|
|
4353
4791
|
|
|
4354
4792
|
env = template_lockfile_phase?(phase) ? template_prepare_env : workflow_env
|
|
@@ -4384,7 +4822,7 @@ module Kettle
|
|
|
4384
4822
|
)
|
|
4385
4823
|
end
|
|
4386
4824
|
if template_lockfile_phase?(phase) && recoverable_bundle_failure?(result)
|
|
4387
|
-
recover_template_lockfiles(member: member, runner: runner, memo: memo, phase: "#{phase}_recovery")
|
|
4825
|
+
recover_template_lockfiles(member: member, runner: runner, memo: memo, phase: "#{phase}_recovery", commit_changes: commit_changes)
|
|
4388
4826
|
return unless memo.last&.ok?
|
|
4389
4827
|
|
|
4390
4828
|
result = runner.call(
|
|
@@ -4397,7 +4835,7 @@ module Kettle
|
|
|
4397
4835
|
memo << result
|
|
4398
4836
|
end
|
|
4399
4837
|
|
|
4400
|
-
def recover_template_lockfiles(member:, runner:, memo:, phase:)
|
|
4838
|
+
def recover_template_lockfiles(member:, runner:, memo:, phase:, commit_changes: true)
|
|
4401
4839
|
result = runner.call(
|
|
4402
4840
|
member: member,
|
|
4403
4841
|
phase: phase,
|
|
@@ -4407,13 +4845,9 @@ module Kettle
|
|
|
4407
4845
|
memo << result
|
|
4408
4846
|
return unless memo.last&.ok?
|
|
4409
4847
|
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
memo: memo,
|
|
4414
|
-
reason: "reset",
|
|
4415
|
-
force: true
|
|
4416
|
-
)
|
|
4848
|
+
return unless commit_changes
|
|
4849
|
+
|
|
4850
|
+
commit_normalized_lockfiles(branch_members: [member], runner: runner, memo: memo, reason: "reset", force: true)
|
|
4417
4851
|
end
|
|
4418
4852
|
|
|
4419
4853
|
def recoverable_bundle_failure?(result)
|
|
@@ -4555,7 +4989,7 @@ module Kettle
|
|
|
4555
4989
|
output.to_s.scan(/from the lockfile CHECKSUMS at Gemfile\.lock:(\d+):\d+/).flatten.map(&:to_i).uniq
|
|
4556
4990
|
end
|
|
4557
4991
|
|
|
4558
|
-
def prepare_template_dependencies(member:, runner:, memo:)
|
|
4992
|
+
def prepare_template_dependencies(member:, runner:, memo:, commit_changes: true, wave_jobs: 1)
|
|
4559
4993
|
command_text = config.template_command || default_template_command(member)
|
|
4560
4994
|
return true unless kettle_jem_template_command?(command_text)
|
|
4561
4995
|
|
|
@@ -4563,14 +4997,15 @@ module Kettle
|
|
|
4563
4997
|
member: member,
|
|
4564
4998
|
phase: "prepare_template_dependencies",
|
|
4565
4999
|
command: template_prepare_command(member),
|
|
4566
|
-
env: template_prepare_env
|
|
5000
|
+
env: template_prepare_env(wave_jobs: wave_jobs)
|
|
4567
5001
|
)
|
|
4568
5002
|
if recoverable_bundle_failure?(result)
|
|
4569
5003
|
recover_template_lockfiles(
|
|
4570
5004
|
member: member,
|
|
4571
5005
|
runner: runner,
|
|
4572
5006
|
memo: memo,
|
|
4573
|
-
phase: "prepare_template_dependencies_recovery"
|
|
5007
|
+
phase: "prepare_template_dependencies_recovery",
|
|
5008
|
+
commit_changes: commit_changes
|
|
4574
5009
|
)
|
|
4575
5010
|
if no_release_lockfiles_failure?(memo.last)
|
|
4576
5011
|
memo.pop
|
|
@@ -4583,7 +5018,7 @@ module Kettle
|
|
|
4583
5018
|
member: member,
|
|
4584
5019
|
phase: "prepare_template_dependencies",
|
|
4585
5020
|
command: template_prepare_command(member),
|
|
4586
|
-
env: template_prepare_env
|
|
5021
|
+
env: template_prepare_env(wave_jobs: wave_jobs)
|
|
4587
5022
|
)
|
|
4588
5023
|
end
|
|
4589
5024
|
memo << result
|
|
@@ -4635,15 +5070,17 @@ module Kettle
|
|
|
4635
5070
|
end
|
|
4636
5071
|
end
|
|
4637
5072
|
|
|
4638
|
-
def template_prepare_env
|
|
5073
|
+
def template_prepare_env(wave_jobs: 1)
|
|
4639
5074
|
# Templating is development work. It must resolve the configured
|
|
4640
5075
|
# family graph exactly as template application does; release-only
|
|
4641
5076
|
# lockfile cleanup owns disabling local path sources.
|
|
4642
|
-
execution_profile(template_execution_profile, workflow_env)
|
|
5077
|
+
execution_profile(template_execution_profile, workflow_env).merge(
|
|
5078
|
+
"KETTLE_FAMILY_WAVE_JOBS" => wave_jobs.to_s
|
|
5079
|
+
)
|
|
4643
5080
|
end
|
|
4644
5081
|
|
|
4645
|
-
def template_command_env
|
|
4646
|
-
(command == "template") ? template_prepare_env : command_env
|
|
5082
|
+
def template_command_env(wave_jobs: 1)
|
|
5083
|
+
(command == "template") ? template_prepare_env(wave_jobs: wave_jobs) : command_env
|
|
4647
5084
|
end
|
|
4648
5085
|
|
|
4649
5086
|
def template_execution_profile
|
|
@@ -4680,11 +5117,6 @@ module Kettle
|
|
|
4680
5117
|
env_overrides.key?(name) || ENV.key?(name)
|
|
4681
5118
|
end
|
|
4682
5119
|
|
|
4683
|
-
def template_thread_worker_budget
|
|
4684
|
-
member_jobs = template_jobs(members)
|
|
4685
|
-
[1, (Etc.nprocessors / member_jobs) - 1].max
|
|
4686
|
-
end
|
|
4687
|
-
|
|
4688
5120
|
def normalize_release_lockfiles(member:, runner:, memo:)
|
|
4689
5121
|
result = runner.call(
|
|
4690
5122
|
member: member,
|
data/lib/kettle/family.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "family/secrets"
|
|
|
13
13
|
require_relative "family/secrets_broker"
|
|
14
14
|
require_relative "family/branch_target_config"
|
|
15
15
|
require_relative "family/release_state_event_tape"
|
|
16
|
+
require_relative "family/concurrency"
|
|
16
17
|
require_relative "family/release_state_check"
|
|
17
18
|
require_relative "family/release_reconciler"
|
|
18
19
|
require_relative "family/unreleased_gem_cleanup"
|
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: 1.2.
|
|
4
|
+
version: 1.2.90
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Peter H. Boling
|
|
@@ -410,6 +410,7 @@ files:
|
|
|
410
410
|
- lib/kettle/family/cli.rb
|
|
411
411
|
- lib/kettle/family/command_result.rb
|
|
412
412
|
- lib/kettle/family/command_runner.rb
|
|
413
|
+
- lib/kettle/family/concurrency.rb
|
|
413
414
|
- lib/kettle/family/config.rb
|
|
414
415
|
- lib/kettle/family/debugger_bootstrap.rb
|
|
415
416
|
- lib/kettle/family/dependency_floor.rb
|
|
@@ -442,10 +443,10 @@ licenses:
|
|
|
442
443
|
- AGPL-3.0-only
|
|
443
444
|
metadata:
|
|
444
445
|
homepage_uri: https://kettle-family.galtzo.com
|
|
445
|
-
source_code_uri: https://github.com/kettle-dev/kettle-family/tree/v1.2.
|
|
446
|
-
changelog_uri: https://github.com/kettle-dev/kettle-family/blob/v1.2.
|
|
446
|
+
source_code_uri: https://github.com/kettle-dev/kettle-family/tree/v1.2.90
|
|
447
|
+
changelog_uri: https://github.com/kettle-dev/kettle-family/blob/v1.2.90/CHANGELOG.md
|
|
447
448
|
bug_tracker_uri: https://github.com/kettle-dev/kettle-family/issues
|
|
448
|
-
documentation_uri: https://www.rubydoc.info/gems/kettle-family/1.2.
|
|
449
|
+
documentation_uri: https://www.rubydoc.info/gems/kettle-family/1.2.90
|
|
449
450
|
funding_uri: https://github.com/sponsors/pboling
|
|
450
451
|
wiki_uri: https://github.com/kettle-dev/kettle-family/wiki
|
|
451
452
|
news_uri: https://www.railsbling.com/tags/kettle-family
|
metadata.gz.sig
CHANGED
|
Binary file
|