kettle-family 1.2.82 → 1.2.83

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: 30f24abb8d6167fc028b11ee8a675b9aefeb7268690bc70f6d016e5dcc43d65f
4
- data.tar.gz: a46437f03a8571fa67b059dad4148a82de5c8707f94bcedd355c55a52dfcfddf
3
+ metadata.gz: 4597b93884181bec791afb1965da15957f01eec43876c13835191eb9419ff0fe
4
+ data.tar.gz: 57e1d3dd4799d826baed78d1fb258ce8fe658eae94d163681b20d1af718b4151
5
5
  SHA512:
6
- metadata.gz: deb096f7906dc799b1c83c9d4a4e7bf2adbf37704318da50e080d5ecf92b7d3728cdf34cc3e5e9e1c618045f5846994ebdd780017925e081fe6e2f9e9b87902c
7
- data.tar.gz: 2e40c9f326e2481d12143676aa14596741a33eb4a8567defe153e521d6195f6b68ff54456f360b5d9467facf5aa9628a91d9ba892d1ab76e26f5c6875544ad70
6
+ metadata.gz: dabdf35f9aa98339e0443282e1bd5ed26c61ec9ebdb9a71b7238c8b4282cf26b87a54888e0cf853874cb6becf165050e218d6ac11708bcea0306dbf9d97a8ff6
7
+ data.tar.gz: 0da1b0a6fb5f9f4391613c7431d4b4e54827e90c137f87df16b06399fbfe009236d4dbbc9bc20bef450930be11dcf78f13f76f18e121b9a658fe1c8596f4e632
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,17 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [1.2.83] - 2026-09-02
34
+
35
+ - TAG: [v1.2.83][1.2.83t]
36
+ - COVERAGE: 93.08% -- 5782/6212 lines in 33 files
37
+ - BRANCH COVERAGE: 76.04% -- 2345/3084 branches in 33 files
38
+ - 30.69% documented
39
+
40
+ ### Fixed
41
+
42
+ - Run aggregate monorepo release members in isolated worktrees and serialize shared release finalization.
43
+
33
44
  ## [1.2.82] - 2026-09-01
34
45
 
35
46
  - TAG: [v1.2.82][1.2.82t]
@@ -2488,7 +2499,9 @@ Please file a bug if you notice a violation of semantic versioning.
2488
2499
  - Fixed CI load failures on engines without compatible `pty` support by falling back to Open3 for interactive release commands.
2489
2500
  - Fixed Ruby 3.2 version-bump support by loading Prism lazily and wiring the Prism gem only for MRI versions that need it.
2490
2501
 
2491
- [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.2.82...HEAD
2502
+ [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.2.83...HEAD
2503
+ [1.2.83]: https://github.com/kettle-dev/kettle-family/compare/v1.2.82...v1.2.83
2504
+ [1.2.83t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.2.83
2492
2505
  [1.2.82]: https://github.com/kettle-dev/kettle-family/compare/v1.2.81...v1.2.82
2493
2506
  [1.2.82t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.2.82
2494
2507
  [1.2.81]: https://github.com/kettle-dev/kettle-family/compare/v1.2.80...v1.2.81
data/README.md CHANGED
@@ -1049,7 +1049,7 @@ Thanks for RTFM. ☺️
1049
1049
  [📌gitmoji]: https://gitmoji.dev
1050
1050
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
1051
1051
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1052
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-6.064-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1052
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-6.212-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1053
1053
  [🔐security]: https://github.com/kettle-dev/kettle-family/blob/main/SECURITY.md
1054
1054
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1055
1055
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -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.82"
8
+ VERSION = "1.2.83"
9
9
  end
10
10
  # Current gem version exposed at the traditional constant location.
11
11
  VERSION = Version::VERSION # Traditional Constant Location
@@ -1014,15 +1014,15 @@ module Kettle
1014
1014
  File.join(config.root, "tmp", "kettle-family", "template-worktrees", safe_member, "#{Process.pid}-#{safe_branch}")
1015
1015
  end
1016
1016
 
1017
- def relocate_member_to_worktree(member, worktree_root)
1017
+ def relocate_member_to_worktree(member, worktree_root, source_root: member.root)
1018
1018
  relocate = lambda do |path|
1019
1019
  next unless path
1020
1020
 
1021
- relative = Pathname.new(path).relative_path_from(Pathname.new(member.root))
1021
+ relative = Pathname.new(path).relative_path_from(Pathname.new(source_root))
1022
1022
  File.join(worktree_root, relative)
1023
1023
  end
1024
1024
  member.dup.tap do |worktree_member|
1025
- worktree_member.root = worktree_root
1025
+ worktree_member.root = relocate.call(member.root)
1026
1026
  worktree_member.gemspec_path = relocate.call(member.gemspec_path)
1027
1027
  worktree_member.version_file = relocate.call(member.version_file)
1028
1028
  end
@@ -1443,6 +1443,8 @@ module Kettle
1443
1443
  end
1444
1444
 
1445
1445
  def parallel_release_member_results(release_members, initial_results)
1446
+ return parallel_monorepo_release_member_results(release_members, initial_results) if monorepo_release_workers?
1447
+
1446
1448
  results = initial_results.dup
1447
1449
  waves = release_waves(release_members)
1448
1450
  completed_members = []
@@ -1502,6 +1504,248 @@ module Kettle
1502
1504
  ordered_results.compact
1503
1505
  end
1504
1506
 
1507
+ def parallel_monorepo_release_member_results(release_members, initial_results)
1508
+ results = initial_results.dup
1509
+ completed_members = []
1510
+ @release_completed_member_names = []
1511
+ release_progress = start_release_progress(release_members)
1512
+ @release_progress = release_progress
1513
+ begin
1514
+ release_waves(release_members).each_with_index do |wave, index|
1515
+ results << release_wave_result(wave, index: index, total: release_waves(release_members).length)
1516
+ aggregate_members, independent_members = wave.partition { |member| aggregate_release_member?(member) }
1517
+
1518
+ unless aggregate_members.empty?
1519
+ worker_results = run_monorepo_release_wave(aggregate_members)
1520
+ results.concat(worker_results.flatten)
1521
+ break unless worker_results.all? { |member_results| member_results.all?(&:ok?) }
1522
+
1523
+ finalization_results = finalize_monorepo_release_wave(aggregate_members)
1524
+ results.concat(finalization_results)
1525
+ break unless finalization_results.all?(&:ok?)
1526
+ end
1527
+
1528
+ independent_members.each do |member|
1529
+ results.concat(release_results_for_member(member, runner: release_command_runner))
1530
+ break unless results.last.ok?
1531
+ end
1532
+ break unless results.last&.ok?
1533
+
1534
+ completed_members.concat(wave)
1535
+ @release_completed_member_names.concat(wave.map(&:name))
1536
+ remaining_members = release_members - completed_members
1537
+ append_dependency_floor_results(released_members: wave, dependent_members: remaining_members, runner: release_command_runner, memo: results)
1538
+ break unless results.last&.ok?
1539
+ end
1540
+ ensure
1541
+ emit_release_progress_summary(results, progress: release_progress)
1542
+ @release_progress = nil
1543
+ end
1544
+ results
1545
+ end
1546
+
1547
+ def run_monorepo_release_wave(members)
1548
+ entries, setup_results = monorepo_release_worktree_entries(members)
1549
+ unless setup_results.all?(&:ok?)
1550
+ return setup_results.zip
1551
+ end
1552
+
1553
+ worker_results = run_monorepo_release_worktree_entries(entries)
1554
+ materialization_results = materialize_monorepo_release_artifacts(entries, worker_results)
1555
+ worker_results.each_with_index.map do |member_results, index|
1556
+ artifact_result = materialization_results[index]
1557
+ artifact_result ? [*member_results, artifact_result] : member_results
1558
+ end
1559
+ ensure
1560
+ cleanup_monorepo_release_worktrees(entries || [])
1561
+ end
1562
+
1563
+ def monorepo_release_worktree_entries(members)
1564
+ runner = command_runner
1565
+ control_member = monorepo_release_control_member
1566
+ entries = []
1567
+ results = []
1568
+ members.each do |member|
1569
+ root = monorepo_release_worktree_path(member)
1570
+ result = runner.call(
1571
+ member: control_member,
1572
+ phase: "release_worktree_add",
1573
+ command: ["git", "worktree", "add", "--detach", root, "HEAD"]
1574
+ )
1575
+ results << result
1576
+ break unless result.ok?
1577
+
1578
+ entries << {member: relocate_member_to_worktree(member, root, source_root: config.root), original_member: member, worktree_root: root}
1579
+ end
1580
+ [entries, results]
1581
+ end
1582
+
1583
+ def monorepo_release_worktree_path(member)
1584
+ safe_member = member.name.to_s.gsub(/[^A-Za-z0-9_.-]+/, "_")
1585
+ File.join(config.root, "tmp", "kettle-family", "release-worktrees", safe_member, "#{Process.pid}-#{Time.now.to_i}")
1586
+ end
1587
+
1588
+ def monorepo_release_control_member
1589
+ family_member.dup.tap { |member| member.root = config.root }
1590
+ end
1591
+
1592
+ def run_monorepo_release_worktree_entries(entries)
1593
+ queue = Queue.new
1594
+ entries.each_with_index { |entry, index| queue << [index, entry] }
1595
+ ordered_results = Array.new(entries.length)
1596
+ mutex = Mutex.new
1597
+ stop = false
1598
+ Array.new(release_jobs(entries)) do
1599
+ Thread.new do # rubocop:disable ThreadSafety/NewThread -- each member has an isolated detached monorepo worktree.
1600
+ loop do
1601
+ break if mutex.synchronize { stop }
1602
+
1603
+ index, entry = queue.pop(true)
1604
+ member_results = release_results_for_monorepo_worktree(entry)
1605
+ mutex.synchronize do
1606
+ ordered_results[index] = member_results
1607
+ stop = true unless member_results.all?(&:ok?)
1608
+ end
1609
+ rescue ThreadError
1610
+ break
1611
+ rescue => error
1612
+ mutex.synchronize do
1613
+ ordered_results[index] = [release_worker_error_result(entry.fetch(:original_member), error)]
1614
+ stop = true
1615
+ end
1616
+ break
1617
+ end
1618
+ end
1619
+ end.each(&:join)
1620
+ ordered_results.compact
1621
+ end
1622
+
1623
+ def release_results_for_monorepo_worktree(entry)
1624
+ member = entry.fetch(:member)
1625
+ original_member = entry.fetch(:original_member)
1626
+ progress = @release_progress
1627
+ progress&.start_member(original_member, total: release_phase_total(original_member), status: "check")
1628
+ [].tap do |memo|
1629
+ if skip_already_released?(original_member)
1630
+ memo << already_released_result(original_member)
1631
+ emit_member_result_progress(original_member, memo.last, progress: progress)
1632
+ return memo
1633
+ end
1634
+
1635
+ append_release_internal_checks(member: member, memo: memo)
1636
+ memo.last(2).each { |result| emit_member_result_progress(original_member, result, progress: progress) }
1637
+ return memo unless memo.last(2).all?(&:ok?)
1638
+
1639
+ release_event_state = {}
1640
+ result = release_command_runner.call(
1641
+ member: member,
1642
+ phase: release_phase,
1643
+ command: release_command_for(member),
1644
+ env: release_env_for_member(member, family_root: entry.fetch(:worktree_root)).merge("KETTLE_RELEASE_FAMILY_MEMBER_PUBLISH" => "true"),
1645
+ interactive: release_command_interactive?,
1646
+ stdout_line_handler: release_event_line_handler(original_member, progress: progress, state: release_event_state),
1647
+ log_path: release_command_log_path(original_member, release_phase),
1648
+ passthrough_output: release_command_passthrough_output?
1649
+ )
1650
+ result.member_name = original_member.name
1651
+ result.resume_step = release_event_state[:failed_step] unless result.ok?
1652
+ memo << result
1653
+ emit_member_result_progress(original_member, result, progress: progress)
1654
+ ensure
1655
+ finished_result = memo.find { |result| result.phase == release_phase } || memo.last
1656
+ progress&.finish_member(original_member, success: memo.all?(&:ok?), status: release_member_finish_status(finished_result)) if finished_result
1657
+ end
1658
+ end
1659
+
1660
+ def materialize_monorepo_release_artifacts(entries, worker_results)
1661
+ entries.each_with_index.map do |entry, index|
1662
+ next unless worker_results[index]&.all?(&:ok?)
1663
+
1664
+ copy_monorepo_release_artifact(entry)
1665
+ end
1666
+ end
1667
+
1668
+ def copy_monorepo_release_artifact(entry)
1669
+ member = entry.fetch(:member)
1670
+ original_member = entry.fetch(:original_member)
1671
+ version = original_member.version.to_s
1672
+ source = Dir[File.join(member.root, "pkg", "#{original_member.name}-#{version}.gem")].first
1673
+ destination = File.join(original_member.root, "pkg", "#{original_member.name}-#{version}.gem")
1674
+ unless source && File.file?(source)
1675
+ return release_worker_error_result(original_member, Error.new("worker did not produce #{File.basename(destination)}"))
1676
+ end
1677
+
1678
+ FileUtils.mkdir_p(File.dirname(destination))
1679
+ FileUtils.cp(source, destination)
1680
+ CommandResult.new(
1681
+ member_name: original_member.name,
1682
+ phase: "release_artifact_materialize",
1683
+ command: ["internal", "copy-release-artifact"],
1684
+ workdir: original_member.root,
1685
+ status: 0,
1686
+ success: true,
1687
+ stdout: File.basename(destination),
1688
+ stderr: "",
1689
+ elapsed_seconds: 0.0,
1690
+ skipped: false,
1691
+ reason: "copied from isolated release worktree"
1692
+ )
1693
+ rescue => error
1694
+ release_worker_error_result(original_member, error)
1695
+ end
1696
+
1697
+ def cleanup_monorepo_release_worktrees(entries)
1698
+ runner = command_runner
1699
+ control_member = monorepo_release_control_member
1700
+ entries.each do |entry|
1701
+ root = entry.fetch(:worktree_root)
1702
+ next unless Dir.exist?(root)
1703
+
1704
+ runner.call(member: control_member, phase: "release_worktree_remove", command: ["git", "worktree", "remove", "--force", root])
1705
+ end
1706
+ end
1707
+
1708
+ def finalize_monorepo_release_wave(members)
1709
+ runner = release_command_runner
1710
+ results = []
1711
+ members.each do |member|
1712
+ results << ensure_monorepo_release_tag(member, runner: runner)
1713
+ break unless results.last.ok?
1714
+
1715
+ results << runner.call(
1716
+ member: member,
1717
+ phase: "release_finalize",
1718
+ command: release_command_for(member),
1719
+ env: release_env_for_member(member).merge("KETTLE_RELEASE_FAMILY_MEMBER_FINALIZE" => "true"),
1720
+ interactive: release_command_interactive?,
1721
+ stdout_line_handler: release_event_line_handler(member, progress: @release_progress, state: {}),
1722
+ log_path: release_command_log_path(member, "release_finalize"),
1723
+ passthrough_output: release_command_passthrough_output?
1724
+ )
1725
+ break unless results.last.ok?
1726
+ end
1727
+ return results unless results.all?(&:ok?)
1728
+
1729
+ results << runner.call(
1730
+ member: monorepo_release_control_member,
1731
+ phase: "release_shared_push",
1732
+ command: config.release_push_command
1733
+ )
1734
+ results
1735
+ end
1736
+
1737
+ def ensure_monorepo_release_tag(member, runner:)
1738
+ version = member.version.to_s
1739
+ tag = release_tag_name(version)
1740
+ escaped_tag = Shellwords.escape(tag)
1741
+ runner.call(
1742
+ member: monorepo_release_control_member,
1743
+ phase: "release_shared_tag",
1744
+ command: ["sh", "-lc", "git rev-parse -q --verify refs/tags/#{escaped_tag} >/dev/null || git tag -a #{escaped_tag} -m #{escaped_tag}"],
1745
+ env: release_env
1746
+ )
1747
+ end
1748
+
1505
1749
  def release_wave_result(wave, index:, total:, jobs: nil)
1506
1750
  CommandResult.new(
1507
1751
  member_name: "wave #{index + 1}",
@@ -1626,7 +1870,19 @@ module Kettle
1626
1870
  execute &&
1627
1871
  release_jobs(release_members) > 1 &&
1628
1872
  release_members.length > 1 &&
1629
- distinct_git_roots?(release_members)
1873
+ (distinct_git_roots?(release_members) || monorepo_release_workers?)
1874
+ end
1875
+
1876
+ # Aggregate monorepo members share one Git checkout, so their complete
1877
+ # kettle-release workflows cannot safely run concurrently. They are
1878
+ # instead published from disposable worktrees using the explicit
1879
+ # member-only contract, then finalized serially in the primary checkout.
1880
+ def monorepo_release_workers?
1881
+ execute &&
1882
+ publish &&
1883
+ config.configured_monorepo_release? &&
1884
+ aggregate_monorepo_github_release? &&
1885
+ kettle_release_command?(raw_release_command)
1630
1886
  end
1631
1887
 
1632
1888
  def release_jobs(release_members)
@@ -2680,10 +2936,10 @@ module Kettle
2680
2936
  # A monorepo member runs kettle-release from its own checkout, while its
2681
2937
  # changelog may live at the family root. Pass the shared paths to the
2682
2938
  # member release phase as well as to the separate family phase.
2683
- def release_env_for_member(member)
2939
+ def release_env_for_member(member, family_root: config.root)
2684
2940
  env = release_env
2685
- env.merge!(release_wave_local_path_env_for(member))
2686
- env.merge!(release_local_path_policy_env)
2941
+ env.merge!(release_wave_local_path_env_for(member, family_root: family_root))
2942
+ env.merge!(release_local_path_policy_env(family_root: family_root))
2687
2943
  # Family release performs one live pin review before member releases.
2688
2944
  # Child kettle-release must consume that reviewed cache instead of
2689
2945
  # repeating the GitHub API lookup for every member.
@@ -2703,7 +2959,7 @@ module Kettle
2703
2959
  if config.family_mode == "monorepo"
2704
2960
  # Monorepo members release from subdirectories, while CI workflows
2705
2961
  # live at the shared repository root.
2706
- env["K_RELEASE_CI_ROOT"] = config.root
2962
+ env["K_RELEASE_CI_ROOT"] = family_root
2707
2963
  env["K_RELEASE_CI_WORKFLOWS"] ||= "current.yml"
2708
2964
  # Explicit multi-gem monorepos publish one aggregate GitHub Release
2709
2965
  # after their member gems. A standalone gem uses the default
@@ -2763,13 +3019,13 @@ module Kettle
2763
3019
  # release for the next member. Dependencies outside the selected set are
2764
3020
  # already treated as registry dependencies; this is what makes resume
2765
3021
  # runs work after an earlier wave has been published.
2766
- def release_wave_local_path_env_for(member)
3022
+ def release_wave_local_path_env_for(member, family_root: config.root)
2767
3023
  # Monorepo members and their sibling dependencies share the same CI
2768
3024
  # checkout, so their canonical development lockfile may keep those
2769
3025
  # paths throughout every release wave.
2770
3026
  if config.configured_monorepo_release?
2771
3027
  env_name = config.family_local_path_env_name
2772
- return env_name ? {env_name => config.family_local_path_root} : {}
3028
+ return env_name ? {env_name => family_local_path_root_for(family_root)} : {}
2773
3029
  end
2774
3030
 
2775
3031
  return {} unless config.release_local_path_strategy == "waves"
@@ -4430,14 +4686,28 @@ module Kettle
4430
4686
  config.release_allowed_local_path_roots
4431
4687
  end
4432
4688
 
4689
+ def family_local_path_root_for(family_root)
4690
+ return config.family_local_path_root if File.expand_path(family_root) == File.expand_path(config.root)
4691
+
4692
+ relative = Pathname.new(config.family_local_path_root).relative_path_from(Pathname.new(config.root))
4693
+ File.expand_path(relative, family_root)
4694
+ end
4695
+
4433
4696
  def release_allowed_local_path_env_names
4434
4697
  names = config.release_allowed_local_path_env_names
4435
4698
  names << "K_JEM_TEMPLATING" if preserve_monorepo_template_context?
4436
4699
  names.uniq
4437
4700
  end
4438
4701
 
4439
- def release_local_path_policy_env
4440
- roots = release_allowed_local_path_roots
4702
+ def release_local_path_policy_env(family_root: config.root)
4703
+ roots = release_allowed_local_path_roots.map do |root|
4704
+ if File.expand_path(family_root) == File.expand_path(config.root)
4705
+ root
4706
+ else
4707
+ relative = Pathname.new(root).relative_path_from(Pathname.new(config.root))
4708
+ File.expand_path(relative, family_root)
4709
+ end
4710
+ end
4441
4711
  env_names = release_allowed_local_path_env_names
4442
4712
  return {} if roots.empty? && env_names.empty?
4443
4713
 
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.82
4
+ version: 1.2.83
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -91,7 +91,7 @@ dependencies:
91
91
  requirements:
92
92
  - - ">="
93
93
  - !ruby/object:Gem::Version
94
- version: 3.0.28
94
+ version: 3.0.30
95
95
  - - "<"
96
96
  - !ruby/object:Gem::Version
97
97
  version: 4.0.0
@@ -101,7 +101,7 @@ dependencies:
101
101
  requirements:
102
102
  - - ">="
103
103
  - !ruby/object:Gem::Version
104
- version: 3.0.28
104
+ version: 3.0.30
105
105
  - - "<"
106
106
  - !ruby/object:Gem::Version
107
107
  version: 4.0.0
@@ -441,10 +441,10 @@ licenses:
441
441
  - AGPL-3.0-only
442
442
  metadata:
443
443
  homepage_uri: https://kettle-family.galtzo.com
444
- source_code_uri: https://github.com/kettle-dev/kettle-family/tree/v1.2.82
445
- changelog_uri: https://github.com/kettle-dev/kettle-family/blob/v1.2.82/CHANGELOG.md
444
+ source_code_uri: https://github.com/kettle-dev/kettle-family/tree/v1.2.83
445
+ changelog_uri: https://github.com/kettle-dev/kettle-family/blob/v1.2.83/CHANGELOG.md
446
446
  bug_tracker_uri: https://github.com/kettle-dev/kettle-family/issues
447
- documentation_uri: https://www.rubydoc.info/gems/kettle-family/1.2.82
447
+ documentation_uri: https://www.rubydoc.info/gems/kettle-family/1.2.83
448
448
  funding_uri: https://github.com/sponsors/pboling
449
449
  wiki_uri: https://github.com/kettle-dev/kettle-family/wiki
450
450
  news_uri: https://www.railsbling.com/tags/kettle-family
metadata.gz.sig CHANGED
Binary file