kettle-family 1.3.3 → 1.3.5

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: 2fd15138050afedb68be9d6d294f7e3a01dc1554949c8607b1176ddf8d061581
4
- data.tar.gz: 272997658ef8c5a96b14fbc1dcc8d124b545abe50d5bc0ca423823c8dffc3ad8
3
+ metadata.gz: 3e18a444e22f9d52b19ef8396ce75bc80b85bb579fb353889fecb8547ddc8bec
4
+ data.tar.gz: b91bb8aa9018297be574e1eda7ddc26c6b8f314692cd4c4881c95fa4fe52062a
5
5
  SHA512:
6
- metadata.gz: a5bac59bea82eec800e67069ae00f8810c54a29a7fe3119b4c9ef93f94924af677b0da411635928841d9c0df47d4bfb11853d622cccaf1c72faf6f6419a552d6
7
- data.tar.gz: c4d9b06b6fed1f0822bdc62ec6d22b3cb379bc1fe7b49e388038e58780c567ecbe3f589e11faa7845e92b2b6b7d365499271f17d0ad8e942636d632f25dbf950
6
+ metadata.gz: 50d83077bd30c1c673772be79765f02d263cf499b382355f8b647889255abb9608155e8a0434d6c98d549ddad5188186f50e56b9608537e47b1cb5f2c1cfec5a
7
+ data.tar.gz: e81978953b9a9d94d2c518d770cd72e6b6efbcd5a3fd2c010da97f72cb9107d71624bd126cdf1799c1e17276f3546adaca3246089e151b6bccc7edc04db4cef2
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,60 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [1.3.5] - 2026-10-05
34
+
35
+ - TAG: [v1.3.5][1.3.5t]
36
+ - COVERAGE: 93.16% -- 6636/7123 lines in 37 files
37
+ - BRANCH COVERAGE: 77.64% -- 2701/3479 branches in 37 files
38
+ - 33.64% documented
39
+
40
+ ### Added
41
+
42
+ - `Kettle::Family::ReleaseStateCheck` accepts a `branches:` scope, and `Kettle::Family::Selection.branches_matching` reports which of a member branch stack currently satisfies a release-state token. Both are the seams for per-branch release and bump decisions.
43
+
44
+ ### Changed
45
+
46
+ - A blocked release now reports phase `release_blocked` instead of `release_skip`, so a hard failure is never reported as a skipped member in the progress summary or report.
47
+
48
+ - [kc] kettle-jem/prepare: updated 12 project files:
49
+ - dependencies (12)
50
+
51
+ - [kc] kettle-jem/template: updated 6 project files:
52
+ - code and tests (1)
53
+ - dependencies (1)
54
+ - documentation (1)
55
+ - other (2)
56
+ - workflows (1)
57
+
58
+ ### Fixed
59
+
60
+ - Already-released detection for branch-stack members is now scoped to the branch being released instead of the whole stack, so a clean published branch is no longer reported as having unreleased changes and blocked with a release_skip failure.
61
+
62
+ - A published version whose release tag is followed only by post-release maintenance commits (checksums, dependency-floor lockfile reconciliation) is now skipped as already released; only changes to the gemspec, version file, or changelog after the tag make the published version stale.
63
+
64
+ - Release-state status-token selection (`--only unreleased|prepared|pending|bump`) no longer collapses a branch-stack member to whichever branch happened to be evaluated last; a member is selected when any of its branches matches, and multiple tokens are still ANDed within a single branch.
65
+
66
+ - `kettle-family bump` now bumps each branch of a member branch stack independently: branches with no pending version bump are reported as skipped instead of being bumped, so an already-released branch is no longer given version bumps it never needed. An explicit `--only MEMBER` remains an unconditional request and is not filtered.
67
+
68
+ - Release-state worktrees orphaned by a killed run are reaped on the next run. Directories are named `worktree-PID-RANDOM`, so only those whose owning PID is no longer running are removed; live worktrees from concurrent runs are left alone.
69
+
70
+ ## [1.3.4] - 2026-10-02
71
+
72
+ - TAG: [v1.3.4][1.3.4t]
73
+ - COVERAGE: 93.26% -- 6571/7046 lines in 37 files
74
+ - BRANCH COVERAGE: 77.70% -- 2666/3431 branches in 37 files
75
+ - 33.33% documented
76
+
77
+ ### Added
78
+
79
+ - kettle-family installs the latest stable Bundler and updates family lockfiles with `update-bundler`.
80
+
81
+ ### Fixed
82
+
83
+ - Lock each repository's worktree registry across process setup, parallel branch probes, and cleanup to prevent intermittent Git worktree failures.
84
+
85
+ - Family reports now include results from the update-bundler command.
86
+
33
87
  ## [1.3.3] - 2026-09-28
34
88
 
35
89
  - TAG: [v1.3.3][1.3.3t]
@@ -2830,7 +2884,11 @@ Please file a bug if you notice a violation of semantic versioning.
2830
2884
  - Fixed CI load failures on engines without compatible `pty` support by falling back to Open3 for interactive release commands.
2831
2885
  - Fixed Ruby 3.2 version-bump support by loading Prism lazily and wiring the Prism gem only for MRI versions that need it.
2832
2886
 
2833
- [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.3.3...HEAD
2887
+ [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.3.5...HEAD
2888
+ [1.3.5]: https://github.com/kettle-dev/kettle-family/compare/v1.3.4...v1.3.5
2889
+ [1.3.5t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.5
2890
+ [1.3.4]: https://github.com/kettle-dev/kettle-family/compare/v1.3.3...v1.3.4
2891
+ [1.3.4t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.4
2834
2892
  [1.3.3]: https://github.com/kettle-dev/kettle-family/compare/v1.3.2...v1.3.3
2835
2893
  [1.3.3t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.3
2836
2894
  [1.3.2]: https://github.com/kettle-dev/kettle-family/compare/v1.3.1...v1.3.2
data/README.md CHANGED
@@ -510,6 +510,7 @@ The command names below are available from `kettle-family --help`:
510
510
  | `gha-sha-pins` | Inspect or update GitHub Actions SHA pins. |
511
511
  | `bup` | Run `bundle update --all`, or update a named dependency, at the family root and per member. |
512
512
  | `bupb` | Run `bundle update --bundler` at the family root and per member. |
513
+ | `update-bundler` | Install the latest stable Bundler from RubyGems, then update family lockfiles. |
513
514
  | `bex` | Run an arbitrary `bundle exec` command per member. |
514
515
  | `install` | Build and install selected local family gems. |
515
516
  | `bump` | Check, plan, or execute family version alignment. |
@@ -614,6 +615,11 @@ root-first order with `bundle update --bundler`. A root that is itself a member
614
615
  is updated only once. A gem name may be passed to `bup` to update only that
615
616
  dependency, for example `kettle-family bup --execute kettle-test`.
616
617
 
618
+ `update-bundler` installs the latest stable Bundler from `https://rubygems.org`
619
+ without enabling prereleases, then runs the same root-first lockfile update as
620
+ `bupb`. Family mise tasks select an installed stable Bundler explicitly to
621
+ start the command, so an installed prerelease cannot intercept startup.
622
+
617
623
  Executed member updates commit successful lockfile changes by default. The
618
624
  family-root lockfile is also validated and committed for monorepos, where the
619
625
  root and members share one Git repository. A sibling-repository family root is
@@ -1186,7 +1192,7 @@ Thanks for RTFM. ☺️
1186
1192
  [📌gitmoji]: https://gitmoji.dev
1187
1193
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
1188
1194
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1189
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-7.011-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1195
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-7.123-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1190
1196
  [🔐security]: https://github.com/kettle-dev/kettle-family/blob/main/SECURITY.md
1191
1197
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1192
1198
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -4,14 +4,15 @@ require "command_kit"
4
4
  require "command_kit/commands"
5
5
  require "fileutils"
6
6
  require "optparse"
7
+ require "rubygems/dependency_installer"
7
8
 
8
9
  module Kettle
9
10
  module Family
10
11
  class CLI < CommandKit::Command
11
12
  include CommandKit::Commands
12
13
 
13
- COMMANDS = %w[version mise-trust discover plan report metadata check clean-unreleased reconcile-releases reset test lint docs template gha-sha-pins bup bupb bex install bump bump-version add-changelog release push pull sync up branch-lanes release-state state].freeze
14
- WORKFLOW_COMMANDS = %w[check reset test lint docs template gha-sha-pins bup bupb bex release push pull sync up].freeze
14
+ COMMANDS = %w[version mise-trust discover plan report metadata check clean-unreleased reconcile-releases reset test lint docs template gha-sha-pins bup bupb update-bundler bex install bump bump-version add-changelog release push pull sync up branch-lanes release-state state].freeze
15
+ WORKFLOW_COMMANDS = %w[check reset test lint docs template gha-sha-pins bup bupb update-bundler bex release push pull sync up].freeze
15
16
 
16
17
  command_name "kettle-family"
17
18
  usage "[options] COMMAND [ARGS...]"
@@ -27,6 +28,16 @@ module Kettle
27
28
  main(argv, stdout: out, stderr: err)
28
29
  end
29
30
 
31
+ # Defined as a singleton method above the `private` keyword: a `private`
32
+ # instance modifier does not apply to `def self.` definitions.
33
+ def self.default_only_filter(command:, only:)
34
+ return only unless only.to_s.empty?
35
+ return "bump" if %w[bump bump-version].include?(command)
36
+ return "pending" if command == "release"
37
+
38
+ only
39
+ end
40
+
30
41
  module SharedOptions
31
42
  def self.included(base)
32
43
  base.option :root, value: {type: String, usage: "PATH"}, desc: "Workspace or family root"
@@ -187,7 +198,12 @@ module Kettle
187
198
  end
188
199
 
189
200
  def run_family(command, overrides = {})
190
- Kettle::Family::CLI.new(stdout: stdout, stderr: stderr).run_command(command, family_options(overrides))
201
+ effective = family_options(overrides)
202
+ # Thread the *effective* --only (including the command's default
203
+ # release-state token) downstream so per-branch logic sees the same
204
+ # selection the member selector used.
205
+ effective[:only] = Kettle::Family::CLI.default_only_filter(command: command, only: effective[:only])
206
+ Kettle::Family::CLI.new(stdout: stdout, stderr: stderr).run_command(command, effective)
191
207
  end
192
208
 
193
209
  def truthy_option?(name)
@@ -434,6 +450,31 @@ module Kettle
434
450
  description "Plan or execute bundle update --bundler."
435
451
  end
436
452
 
453
+ class UpdateBundler < WorkflowCommand
454
+ command_name "update-bundler"
455
+ usage "[options]"
456
+ description "Install the latest stable Bundler, then update family lockfiles."
457
+
458
+ def run(*args)
459
+ unexpected_arguments!(args)
460
+ install_latest_stable_bundler if truthy_option?(:execute)
461
+ run_family("update-bundler")
462
+ end
463
+
464
+ private
465
+
466
+ def install_latest_stable_bundler
467
+ original_sources = Gem.sources.to_a
468
+ Gem.sources = ["https://rubygems.org"]
469
+ Gem::DependencyInstaller.new(domain: :remote, prerelease: false, document: []).install("bundler")
470
+ Gem::Specification.reset
471
+ rescue => error
472
+ raise Error, "could not install latest stable Bundler from RubyGems: #{error.message}"
473
+ ensure
474
+ Gem.sources = original_sources if original_sources
475
+ end
476
+ end
477
+
437
478
  class Bex < WorkflowCommand
438
479
  command_name "bex"
439
480
  usage "[options] -- COMMAND [ARGS...]"
@@ -626,6 +667,7 @@ module Kettle
626
667
  command GhaShaPins
627
668
  command Bup
628
669
  command Bupb
670
+ command UpdateBundler
629
671
  command Bex
630
672
  command Install
631
673
  command Bump
@@ -795,11 +837,7 @@ module Kettle
795
837
  end
796
838
 
797
839
  def default_only_filter(command:, only:)
798
- return only unless only.to_s.empty?
799
- return "bump" if %w[bump bump-version].include?(command)
800
- return "pending" if command == "release"
801
-
802
- only
840
+ self.class.default_only_filter(command: command, only: only)
803
841
  end
804
842
 
805
843
  def direct_member_only_names(only)
@@ -1095,6 +1133,17 @@ module Kettle
1095
1133
  memo.last.branch = branch
1096
1134
  break unless memo.last.ok?
1097
1135
 
1136
+ if (skipped = branch_bump_skip_result(
1137
+ member: member,
1138
+ branch: branch,
1139
+ command: command,
1140
+ options: options,
1141
+ release_state_results: release_state_results
1142
+ ))
1143
+ memo << skipped
1144
+ next
1145
+ end
1146
+
1098
1147
  branch_members = rediscovered_selected_members(config: member_config, selected_names: [member.name], command: command)
1099
1148
  branch_members = [member] if branch_members.empty?
1100
1149
  branch_results = command_results_for_current_branch(
@@ -1113,6 +1162,41 @@ module Kettle
1113
1162
  end
1114
1163
  end
1115
1164
 
1165
+ BUMP_COMMANDS = %w[bump bump-version].freeze
1166
+
1167
+ # Bumping a whole branch stack must be per-branch: a branch already
1168
+ # released with nothing pending has nothing to bump. `--only bump` is a
1169
+ # status-driven selection, so it is filtered by each branch's own
1170
+ # release-state. An explicit `--only MEMBER` is a direct request and is
1171
+ # honored as given.
1172
+ def branch_bump_skip_result(member:, branch:, command:, options:, release_state_results:)
1173
+ return unless BUMP_COMMANDS.include?(command)
1174
+ return unless status_token_only?(options[:only])
1175
+
1176
+ matching = Selection.branches_matching(release_state_results, member_name: member.name, tokens: %w[bump])
1177
+ return if matching.empty? || matching.include?(branch)
1178
+
1179
+ CommandResult.new(
1180
+ member_name: member.name,
1181
+ phase: command,
1182
+ command: ["internal", "branch-bump-skip", branch],
1183
+ workdir: member.root,
1184
+ status: 0,
1185
+ success: true,
1186
+ stdout: "#{member.name}@#{branch} has no unreleased entries at its released version; skipping bump on this branch",
1187
+ stderr: "",
1188
+ elapsed_seconds: 0.0,
1189
+ skipped: true,
1190
+ reason: "no version bump pending on #{branch}",
1191
+ branch: branch
1192
+ )
1193
+ end
1194
+
1195
+ def status_token_only?(only)
1196
+ names = only.to_s.split(",").map(&:strip).reject(&:empty?)
1197
+ !names.empty? && names.all? { |name| Selection.status_token?(name) }
1198
+ end
1199
+
1116
1200
  def rediscovered_selected_members(config:, selected_names:, command:)
1117
1201
  discovered = Discovery.new(config: config).members
1118
1202
  ordered = (command == "install") ? install_order(discovered, config) : Orderer.new(members: discovered, mode: config.order_mode, hints: config.order_hints).ordered
@@ -23,11 +23,18 @@ module Kettle
23
23
  ].freeze
24
24
  TRANSFER_CHANGELOG_TOTAL_UNSET = Object.new.freeze
25
25
  CACHE_MISS = Object.new.freeze
26
-
27
- def initialize(members:, config: nil, jobs: nil)
26
+ WORKTREE_ADMIN_MUTEX = Mutex.new
27
+
28
+ # +branches+ scopes the check to specific release target branches. A
29
+ # branch-stack member produces one result per branch; a per-branch
30
+ # decision (is this branch already published? does it need a bump?) must
31
+ # never consult the whole stack, or every branch inherits the state of
32
+ # the branch that happens to have unreleased entries.
33
+ def initialize(members:, config: nil, jobs: nil, branches: nil)
28
34
  @members = members
29
35
  @config = config
30
36
  @jobs = jobs
37
+ @branches = branches&.map(&:to_s)&.reject(&:empty?)
31
38
  @github_latest_release_by_repo = {}
32
39
  @transfer_changelog_status_by_root = {}
33
40
  @transfer_changelog_lag_by_key = {}
@@ -37,6 +44,7 @@ module Kettle
37
44
  end
38
45
 
39
46
  def results(event_handler: nil)
47
+ return scoped_checkout_results(event_handler: event_handler) if checked_out_scoped_results?
40
48
  return branch_results(event_handler: event_handler) unless release_target_branches.empty?
41
49
  if shared_changelog?
42
50
  results = parallel_map(members) { |member| check_shared_changelog_member_or_local(member, event_handler: event_handler) }
@@ -52,26 +60,53 @@ module Kettle
52
60
 
53
61
  private
54
62
 
55
- attr_reader :members, :config, :jobs
63
+ attr_reader :members, :config, :jobs, :branches
64
+
65
+ # A single-branch scope whose checkout already sits on that branch needs
66
+ # no worktree: the primary checkout is the branch under test.
67
+ def checked_out_scoped_results?
68
+ return false unless branches&.one?
69
+
70
+ members.any? { |member| current_branch_for(member) == branches.first }
71
+ end
72
+
73
+ def scoped_checkout_results(event_handler: nil)
74
+ branch = branches.first
75
+ results = parallel_map(members) { |member| check_member(member, branch: branch, event_handler: event_handler) }
76
+ results.flatten
77
+ end
78
+
79
+ def current_branch_for(member)
80
+ git_output(member.root, "branch", "--show-current")
81
+ end
56
82
 
57
83
  def branch_results(event_handler: nil)
58
84
  root = git_root
59
85
  selected_names = members.map(&:name)
60
- parallel_map(release_target_branches) do |branch|
61
- branch_results_for(branch: branch, root: root, selected_names: selected_names, event_handler: event_handler)
62
- end.flatten
86
+ with_branch_worktrees(root: root, branches: release_target_branches) do |entries|
87
+ parallel_map(entries) do |entry|
88
+ if entry[:error]
89
+ error_result(branch: entry[:branch], error: entry[:error])
90
+ else
91
+ branch_results_for_worktree(
92
+ branch: entry[:branch],
93
+ worktree_root: entry[:worktree_root],
94
+ selected_names: selected_names,
95
+ event_handler: event_handler
96
+ )
97
+ end
98
+ end.flatten
99
+ end
63
100
  end
64
101
 
65
- def branch_results_for(branch:, root:, selected_names:, event_handler:)
66
- with_branch_worktree(root: root, branch: branch) do |worktree_root|
67
- branch_members = discover_branch_members(worktree_root: worktree_root, selected_names: selected_names)
68
- if shared_changelog?
69
- results = parallel_map(branch_members) { |member| check_shared_changelog_member_or_local(member, branch: branch, event_handler: event_handler) }
70
- next normalize_shared_version_bump(results)
71
- end
72
-
73
- parallel_map(branch_members) { |member| check_member(member, branch: branch, event_handler: event_handler) }
102
+ def branch_results_for_worktree(branch:, worktree_root:, selected_names:, event_handler:)
103
+ branch_members = discover_branch_members(worktree_root: worktree_root, selected_names: selected_names)
104
+ if shared_changelog?
105
+ results = parallel_map(branch_members) { |member| check_shared_changelog_member_or_local(member, branch: branch, event_handler: event_handler) }
106
+ return normalize_shared_version_bump(results)
74
107
  end
108
+
109
+ parallel_map(branch_members) { |member| check_member(member, branch: branch, event_handler: event_handler) }
75
110
  rescue Error => error
76
111
  [error_result(branch: branch, error: error)]
77
112
  end
@@ -759,6 +794,7 @@ module Kettle
759
794
 
760
795
  def release_target_branches
761
796
  return [] unless config
797
+ return @branches if @branches
762
798
 
763
799
  config.release_target_branches
764
800
  end
@@ -826,25 +862,87 @@ module Kettle
826
862
  File.realpath(stdout.strip)
827
863
  end
828
864
 
829
- def with_branch_worktree(root:, branch:)
865
+ def with_branch_worktrees(root:, branches:)
866
+ with_worktree_registry_lock(root: root) do
867
+ base = File.join(root, "tmp", "kettle-family-release-state")
868
+ FileUtils.mkdir_p(base)
869
+ prune_stale_worktrees(root: root)
870
+ entries = branches.map do |branch|
871
+ {branch: branch, worktree_root: File.join(base, "worktree-#{Process.pid}-#{SecureRandom.hex(8)}")}
872
+ end
873
+ entries.each do |entry|
874
+ add_branch_worktree(root: root, branch: entry[:branch], worktree_root: entry[:worktree_root])
875
+ rescue Error => error
876
+ entry[:error] = error
877
+ end
878
+ yield entries
879
+ ensure
880
+ entries&.reverse_each do |entry|
881
+ remove_branch_worktree(root: root, worktree_root: entry[:worktree_root])
882
+ end
883
+ end
884
+ end
885
+
886
+ def with_worktree_registry_lock(root:)
887
+ stdout, stderr, status = Open3.capture3("git", "rev-parse", "--git-common-dir", chdir: root)
888
+ raise Error, "could not determine git common directory for #{root}: #{stderr}" unless status.success?
889
+
890
+ lock_path = File.join(File.expand_path(stdout.strip, root), "kettle-family-worktree.lock")
891
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock_file|
892
+ lock_file.flock(File::LOCK_EX)
893
+ yield
894
+ ensure
895
+ lock_file&.flock(File::LOCK_UN)
896
+ end
897
+ end
898
+
899
+ # A killed run (SIGKILL, terminal close) skips the ensure-block cleanup and
900
+ # leaves registered worktrees behind. Registered-but-ownerless worktrees
901
+ # are pinned refs that accumulate forever, so reap them on the way in.
902
+ def prune_stale_worktrees(root:)
830
903
  base = File.join(root, "tmp", "kettle-family-release-state")
831
- FileUtils.mkdir_p(base)
832
- worktree_root = File.join(base, "worktree-#{Process.pid}-#{SecureRandom.hex(8)}")
833
- add_branch_worktree(root: root, branch: branch, worktree_root: worktree_root)
834
- yield worktree_root
835
- ensure
836
- remove_branch_worktree(root: root, worktree_root: worktree_root)
904
+ WORKTREE_ADMIN_MUTEX.synchronize do
905
+ Open3.capture3("git", "worktree", "prune", chdir: root)
906
+ orphaned_worktree_paths(base).each do |path|
907
+ Open3.capture3("git", "worktree", "remove", "--force", path, chdir: root)
908
+ FileUtils.rm_rf(path)
909
+ end
910
+ end
911
+ end
912
+
913
+ # Worktree directories are named worktree-PID-RANDOM. A PID that is no
914
+ # longer running cannot still own a live worktree.
915
+ def orphaned_worktree_paths(base)
916
+ Dir[File.join(base, "worktree-*")].select do |path|
917
+ pid = File.basename(path)[/worktree-(\d+)-/, 1]
918
+ next false if pid.nil? || pid == Process.pid.to_s
919
+
920
+ !process_alive?(pid)
921
+ end
922
+ end
923
+
924
+ def process_alive?(pid)
925
+ Process.kill(0, pid)
926
+ true
927
+ rescue Errno::ESRCH
928
+ false
929
+ rescue Errno::EPERM
930
+ true
837
931
  end
838
932
 
839
933
  def add_branch_worktree(root:, branch:, worktree_root:)
840
- _stdout, stderr, status = Open3.capture3("git", "worktree", "add", "--detach", worktree_root, branch, chdir: root)
841
- raise Error, "could not add worktree for #{branch}: #{stderr}" unless status.success?
934
+ WORKTREE_ADMIN_MUTEX.synchronize do
935
+ _stdout, stderr, status = Open3.capture3("git", "worktree", "add", "--detach", worktree_root, branch, chdir: root)
936
+ raise Error, "could not add worktree for #{branch}: #{stderr}" unless status.success?
937
+ end
842
938
  end
843
939
 
844
940
  def remove_branch_worktree(root:, worktree_root:)
845
941
  return unless worktree_root && Dir.exist?(worktree_root)
846
942
 
847
- Open3.capture3("git", "worktree", "remove", "--force", worktree_root, chdir: root)
943
+ WORKTREE_ADMIN_MUTEX.synchronize do
944
+ Open3.capture3("git", "worktree", "remove", "--force", worktree_root, chdir: root)
945
+ end
848
946
  end
849
947
 
850
948
  def discover_branch_members(worktree_root:, selected_names:)
@@ -29,6 +29,7 @@ module Kettle
29
29
  template
30
30
  test
31
31
  up
32
+ update-bundler
32
33
  ].freeze
33
34
 
34
35
  attr_reader :family_name, :family_mode, :order_mode, :members, :selected_members, :config_path, :command, :results, :branch_lanes, :release_target_branches, :member_release_target_branches, :release_mode, :release_resume_arguments, :warnings, :event_log_dirs
@@ -516,6 +517,7 @@ module Kettle
516
517
 
517
518
  def release_terminal_result?(result)
518
519
  result.phase == "release_skip" ||
520
+ result.phase == "release_blocked" ||
519
521
  result.phase == "release_publish" ||
520
522
  result.phase == "release_build"
521
523
  end
@@ -21,6 +21,21 @@ module Kettle
21
21
  STATUS_TOKEN_KEYS.key?(value.to_s)
22
22
  end
23
23
 
24
+ # Defined as a singleton method above the `private` keyword: a `private`
25
+ # instance modifier does not apply to `def self.` definitions.
26
+ #
27
+ # Branches of one member that currently satisfy +tokens+. Used to decide
28
+ # per-branch bump work, so a bump never touches a branch that has nothing
29
+ # to release.
30
+ def self.branches_matching(release_state_results, member_name:, tokens:)
31
+ keys = Array(tokens).map(&:to_s).filter_map { |token| STATUS_TOKEN_KEYS[token] }
32
+ return [] if keys.empty?
33
+
34
+ Array(release_state_results).select do |result|
35
+ result.member_name == member_name && keys.all? { |key| result.state[key] == true }
36
+ end.map(&:branch).compact.uniq
37
+ end
38
+
24
39
  def self.validate_release_state_only_filter!(only)
25
40
  names = only.to_s.split(",").map(&:strip).reject(&:empty?)
26
41
  status_tokens = names.select { |name| status_token?(name) }
@@ -83,22 +98,29 @@ module Kettle
83
98
  selected.drop(index)
84
99
  end
85
100
 
101
+ # A branch-stack member yields one release-state result per branch, all
102
+ # sharing a member_name. Collapsing them by member_name alone would let
103
+ # whichever branch came last decide selection for every branch, so a
104
+ # member qualifies when *any* of its branches matches the token.
86
105
  def select_release_state_status(selected, status_tokens)
87
106
  results_by_member = release_state_results_by_member
88
- failed = results_by_member.values.select { |result| !result.ok? }
89
- raise Error, "release-state check failed for: #{failed.map(&:member_name).join(", ")}" unless failed.empty?
107
+ failed = results_by_member.values.flatten.select { |result| !result.ok? }
108
+ raise Error, "release-state check failed for: #{failed.map(&:member_name).uniq.join(", ")}" unless failed.empty?
90
109
 
91
110
  selected.select do |candidate|
92
- result = results_by_member[candidate.name]
93
- result && status_tokens.all? { |token| truthy_state?(result.state[STATUS_TOKEN_KEYS.fetch(token)]) }
111
+ Array(results_by_member[candidate.name]).any? do |result|
112
+ status_tokens.all? { |token| truthy_state?(result.state[STATUS_TOKEN_KEYS.fetch(token)]) }
113
+ end
94
114
  end
95
115
  end
96
116
 
117
+ # Branch-stack members report one result per branch, all sharing a member
118
+ # name, so results are grouped per member instead of collapsed to one.
97
119
  def release_state_results_by_member
98
120
  raise Error, "--only release-state tokens require release-state results" unless release_state_results
99
121
 
100
122
  release_state_results.each_with_object({}) do |result, memo|
101
- memo[result.member_name] = result
123
+ (memo[result.member_name] ||= []) << result
102
124
  end
103
125
  end
104
126
 
@@ -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.3.3"
8
+ VERSION = "1.3.5"
9
9
  end
10
10
  # Current gem version exposed at the traditional constant location.
11
11
  VERSION = Version::VERSION # Traditional Constant Location
@@ -574,7 +574,7 @@ module Kettle
574
574
 
575
575
  runner = CommandRunner.new(execute: execute, accept: accept)
576
576
  results = []
577
- update_family_root_bundle(runner: runner, memo: results) if %w[bup bupb].include?(command)
577
+ update_family_root_bundle(runner: runner, memo: results) if %w[bup bupb update-bundler].include?(command)
578
578
  return results unless results.all?(&:ok?)
579
579
 
580
580
  if command == "gha-sha-pins" && execute
@@ -607,10 +607,10 @@ module Kettle
607
607
 
608
608
  normalize_lockfiles(member: member, runner: runner, memo: memo, phase: "normalize_lockfiles") if command == "template"
609
609
  commit_gha_sha_pins(member: member, runner: runner, memo: memo) if command == "gha-sha-pins"
610
- if command == "bupb"
610
+ if %w[bupb update-bundler].include?(command)
611
611
  bupb_appraisal_results(member: member, runner: runner, memo: memo)
612
612
  end
613
- if %w[bup bupb].include?(command) && memo.last&.ok? && validate_bundle_update_lockfile(member: member, memo: memo)
613
+ if %w[bup bupb update-bundler].include?(command) && memo.last&.ok? && validate_bundle_update_lockfile(member: member, memo: memo)
614
614
  commit_bundle_update(member: member, runner: runner, memo: memo)
615
615
  end
616
616
  commit_bex_changes(member: member, runner: runner, memo: memo) if command == "bex"
@@ -3978,28 +3978,38 @@ module Kettle
3978
3978
  @release_progress&.update(member, status: "ci bundle #{relative} #{attempt}/#{REGISTRY_WAIT_ATTEMPTS}", mark: ">")
3979
3979
  end
3980
3980
 
3981
+ # A published version only means "already released" when nothing
3982
+ # releasable landed after its tag. kettle-release commits checksums
3983
+ # (and the family commits dependency-floor lockfiles) *after* tagging,
3984
+ # so comparing HEAD to the tag directly never matches; compare the
3985
+ # release-relevant paths instead.
3981
3986
  def already_released_result(member)
3982
3987
  tag = release_tag_name(member.version)
3983
- current_release_head = released_version_current_head?(member, tag)
3984
- if current_release_head
3985
- stdout = "#{member.name} #{member.version} is already published and current HEAD matches #{tag}; skipping release"
3988
+ branch = current_member_branch(member)
3989
+ scope = branch ? "#{member.name}@#{branch}" : member.name
3990
+ if no_releasable_changes_since_tag?(member, tag)
3991
+ stdout = "#{scope} #{member.version} is already published#{branch_suffix(branch)} and nothing releasable " \
3992
+ "landed after #{tag}; skipping release"
3986
3993
  reason = "already released"
3987
3994
  skipped = true
3988
- elsif unreleased_changes_pending?(member)
3989
- stdout = "#{member.name} #{member.version} is already published, but release-state reports unreleased changes. " \
3990
- "Bump the version with `kettle-family bump patch --execute --only #{member.name}` before releasing."
3995
+ elsif unreleased_changes_pending?(member, branch: branch)
3996
+ stdout = "#{scope} #{member.version} is already published#{branch_suffix(branch)}, but release-state reports " \
3997
+ "unreleased changes on this branch. Bump only this branch with " \
3998
+ "`kettle-family bump patch --execute --only #{member.name}` before releasing."
3991
3999
  reason = "published version has unreleased changes"
3992
4000
  skipped = false
3993
4001
  else
3994
- stdout = "#{member.name} #{member.version} is already published, current HEAD is newer than #{tag}, " \
3995
- "and release-state reports no unreleased changes; skipping release"
4002
+ stdout = "#{scope} #{member.version} is already published#{branch_suffix(branch)}, and release-state reports " \
4003
+ "no unreleased changes on this branch; skipping release"
3996
4004
  reason = "already released; no unreleased changes"
3997
4005
  skipped = true
3998
4006
  end
3999
4007
 
4000
4008
  CommandResult.new(
4001
4009
  member_name: member.name,
4002
- phase: "release_skip",
4010
+ # A blocked release is a distinct terminal phase from a skip: it is a
4011
+ # hard failure and must never be reported as "skipped".
4012
+ phase: skipped ? "release_skip" : "release_blocked",
4003
4013
  command: ["internal", "released-version-check", member.version],
4004
4014
  workdir: member.root,
4005
4015
  status: skipped ? 0 : 1,
@@ -4008,12 +4018,34 @@ module Kettle
4008
4018
  stderr: "",
4009
4019
  elapsed_seconds: 0.0,
4010
4020
  skipped: skipped,
4011
- reason: reason
4021
+ reason: reason,
4022
+ branch: branch
4012
4023
  )
4013
4024
  end
4014
4025
 
4015
- def unreleased_changes_pending?(member)
4016
- results = ReleaseStateCheck.new(members: [member], config: config).results
4026
+ def branch_suffix(branch)
4027
+ branch ? " on #{branch}" : ""
4028
+ end
4029
+
4030
+ def current_member_branch(member)
4031
+ return unless git_work_tree?(member.root)
4032
+
4033
+ stdout, _stderr, status = Open3.capture3("git", "branch", "--show-current", chdir: member.root)
4034
+ return unless status.success?
4035
+
4036
+ branch = stdout.strip
4037
+ branch.empty? ? nil : branch
4038
+ end
4039
+
4040
+ # Scope the check to the branch being released. Consulting the whole
4041
+ # branch stack here would report every already-published branch as
4042
+ # having unreleased changes whenever any other branch in the stack does.
4043
+ def unreleased_changes_pending?(member, branch: nil)
4044
+ results = ReleaseStateCheck.new(
4045
+ members: [member],
4046
+ config: config,
4047
+ branches: branch ? [branch] : nil
4048
+ ).results
4017
4049
  return true unless results.all?(&:ok?)
4018
4050
 
4019
4051
  results.any? { |result| result.state.fetch("unreleased_entries", true) }
@@ -4023,12 +4055,42 @@ module Kettle
4023
4055
  "v#{version}"
4024
4056
  end
4025
4057
 
4026
- def released_version_current_head?(member, tag)
4027
- return true unless git_work_tree?(member.root)
4058
+ # Post-release maintenance commits (checksums, lockfile reconciliation,
4059
+ # docs) land after the tag by design, so "HEAD != tag" cannot mean
4060
+ # "needs a release". Only a change to the gemspec, version file, or
4061
+ # changelog between the tag and HEAD makes the published version stale.
4062
+ def no_releasable_changes_since_tag?(member, tag)
4063
+ return false unless git_work_tree?(member.root)
4028
4064
 
4029
4065
  tag_sha = git_rev_parse(member.root, "refs/tags/#{tag}^{}")
4030
- head_sha = git_rev_parse(member.root, "HEAD")
4031
- !tag_sha.to_s.empty? && tag_sha == head_sha
4066
+ return false if tag_sha.to_s.empty?
4067
+ return true if tag_sha == git_rev_parse(member.root, "HEAD")
4068
+
4069
+ releasable_changes_since_tag(member, tag).empty?
4070
+ end
4071
+
4072
+ def releasable_changes_since_tag(member, tag)
4073
+ paths = releasable_paths(member)
4074
+ return [] if paths.empty?
4075
+
4076
+ stdout, _stderr, status = Open3.capture3(
4077
+ "git", "diff", "--name-only", "#{tag}^{}", "HEAD", "--", *paths, chdir: member.root
4078
+ )
4079
+ # An unreadable diff is not evidence of "nothing to release".
4080
+ return [tag] unless status.success?
4081
+
4082
+ stdout.lines.map(&:strip).reject(&:empty?)
4083
+ end
4084
+
4085
+ def releasable_paths(member)
4086
+ paths = [member.gemspec_path, member.version_file, config.changelog_full_path(member)]
4087
+ paths.compact.filter_map do |path|
4088
+ next unless File.file?(path)
4089
+
4090
+ Pathname.new(path).relative_path_from(Pathname.new(member.root)).to_s
4091
+ rescue ArgumentError
4092
+ path.to_s
4093
+ end.uniq
4032
4094
  end
4033
4095
 
4034
4096
  def git_work_tree?(root)
@@ -4083,7 +4145,7 @@ module Kettle
4083
4145
  return gha_sha_pins_command(command_text: command_text)
4084
4146
  end
4085
4147
  return bup_command if command == "bup"
4086
- return bupb_command if command == "bupb"
4148
+ return bupb_command if %w[bupb update-bundler].include?(command)
4087
4149
  return bex_command if command == "bex"
4088
4150
 
4089
4151
  command_for(command)
@@ -4108,11 +4170,11 @@ module Kettle
4108
4170
 
4109
4171
  def stable_bundler_version
4110
4172
  active_version = Gem.loaded_specs["bundler"]&.version
4111
- return active_version if active_version && !active_version.prerelease?
4112
-
4113
4173
  Gem::Specification
4114
4174
  .find_all_by_name("bundler")
4115
4175
  .map(&:version)
4176
+ .push(active_version)
4177
+ .compact
4116
4178
  .reject(&:prerelease?)
4117
4179
  .max
4118
4180
  end
@@ -4358,7 +4420,7 @@ module Kettle
4358
4420
  end
4359
4421
 
4360
4422
  def command_env
4361
- return bundle_update_env if %w[bup bupb].include?(command)
4423
+ return bundle_update_env if %w[bup bupb update-bundler].include?(command)
4362
4424
 
4363
4425
  workflow_env
4364
4426
  end
@@ -4634,7 +4696,9 @@ module Kettle
4634
4696
  def emit_release_progress_summary(results, progress:)
4635
4697
  return unless progress
4636
4698
 
4637
- release_results = results.select { |result| result.phase == release_phase || result.phase == "release_skip" }
4699
+ release_results = results.select do |result|
4700
+ result.phase == release_phase || result.phase == "release_skip" || result.phase == "release_blocked"
4701
+ end
4638
4702
  progress.stop
4639
4703
  progress.summary("release summary: #{release_results.count(&:ok?)}/#{release_results.length} members ok")
4640
4704
  end
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.3.3
4
+ version: 1.3.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -94,7 +94,7 @@ dependencies:
94
94
  version: '3.1'
95
95
  - - ">="
96
96
  - !ruby/object:Gem::Version
97
- version: 3.1.4
97
+ version: 3.1.7
98
98
  type: :runtime
99
99
  prerelease: false
100
100
  version_requirements: !ruby/object:Gem::Requirement
@@ -104,7 +104,7 @@ dependencies:
104
104
  version: '3.1'
105
105
  - - ">="
106
106
  - !ruby/object:Gem::Version
107
- version: 3.1.4
107
+ version: 3.1.7
108
108
  - !ruby/object:Gem::Dependency
109
109
  name: kettle-gha-pins
110
110
  requirement: !ruby/object:Gem::Requirement
@@ -304,7 +304,7 @@ dependencies:
304
304
  version: '3.2'
305
305
  - - ">="
306
306
  - !ruby/object:Gem::Version
307
- version: 3.2.4
307
+ version: 3.2.5
308
308
  type: :development
309
309
  prerelease: false
310
310
  version_requirements: !ruby/object:Gem::Requirement
@@ -314,7 +314,7 @@ dependencies:
314
314
  version: '3.2'
315
315
  - - ">="
316
316
  - !ruby/object:Gem::Version
317
- version: 3.2.4
317
+ version: 3.2.5
318
318
  - !ruby/object:Gem::Dependency
319
319
  name: turbo_tests2
320
320
  requirement: !ruby/object:Gem::Requirement
@@ -324,7 +324,7 @@ dependencies:
324
324
  version: '3.2'
325
325
  - - ">="
326
326
  - !ruby/object:Gem::Version
327
- version: 3.2.11
327
+ version: 3.2.13
328
328
  type: :development
329
329
  prerelease: false
330
330
  version_requirements: !ruby/object:Gem::Requirement
@@ -334,21 +334,7 @@ dependencies:
334
334
  version: '3.2'
335
335
  - - ">="
336
336
  - !ruby/object:Gem::Version
337
- version: 3.2.11
338
- - !ruby/object:Gem::Dependency
339
- name: webrick
340
- requirement: !ruby/object:Gem::Requirement
341
- requirements:
342
- - - "~>"
343
- - !ruby/object:Gem::Version
344
- version: '1.9'
345
- type: :development
346
- prerelease: false
347
- version_requirements: !ruby/object:Gem::Requirement
348
- requirements:
349
- - - "~>"
350
- - !ruby/object:Gem::Version
351
- version: '1.9'
337
+ version: 3.2.13
352
338
  - !ruby/object:Gem::Dependency
353
339
  name: ruby-progressbar
354
340
  requirement: !ruby/object:Gem::Requirement
@@ -392,7 +378,7 @@ dependencies:
392
378
  version: '2.0'
393
379
  - - ">="
394
380
  - !ruby/object:Gem::Version
395
- version: 2.0.13
381
+ version: 2.0.15
396
382
  type: :development
397
383
  prerelease: false
398
384
  version_requirements: !ruby/object:Gem::Requirement
@@ -402,7 +388,21 @@ dependencies:
402
388
  version: '2.0'
403
389
  - - ">="
404
390
  - !ruby/object:Gem::Version
405
- version: 2.0.13
391
+ version: 2.0.15
392
+ - !ruby/object:Gem::Dependency
393
+ name: webrick
394
+ requirement: !ruby/object:Gem::Requirement
395
+ requirements:
396
+ - - "~>"
397
+ - !ruby/object:Gem::Version
398
+ version: '1.9'
399
+ type: :development
400
+ prerelease: false
401
+ version_requirements: !ruby/object:Gem::Requirement
402
+ requirements:
403
+ - - "~>"
404
+ - !ruby/object:Gem::Version
405
+ version: '1.9'
406
406
  description: "\U0001F469‍\U0001F469‍\U0001F467‍\U0001F467 Kettle::Family provides
407
407
  scripts and conventions for coordinating related Ruby gems as one family."
408
408
  email:
@@ -459,10 +459,10 @@ licenses:
459
459
  - AGPL-3.0-only
460
460
  metadata:
461
461
  homepage_uri: https://kettle-family.galtzo.com
462
- source_code_uri: https://github.com/kettle-dev/kettle-family/tree/v1.3.3
463
- changelog_uri: https://github.com/kettle-dev/kettle-family/blob/v1.3.3/CHANGELOG.md
462
+ source_code_uri: https://github.com/kettle-dev/kettle-family/tree/v1.3.5
463
+ changelog_uri: https://github.com/kettle-dev/kettle-family/blob/v1.3.5/CHANGELOG.md
464
464
  bug_tracker_uri: https://github.com/kettle-dev/kettle-family/issues
465
- documentation_uri: https://www.rubydoc.info/gems/kettle-family/1.3.3
465
+ documentation_uri: https://www.rubydoc.info/gems/kettle-family/1.3.5
466
466
  funding_uri: https://github.com/sponsors/pboling
467
467
  wiki_uri: https://github.com/kettle-dev/kettle-family/wiki
468
468
  news_uri: https://www.railsbling.com/tags/kettle-family
metadata.gz.sig CHANGED
Binary file