kettle-family 1.3.4 → 1.3.6

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: 99e6506531c29efe6259ee3f592eb50d654eeef9f7074a1179f4f61960d7efdd
4
- data.tar.gz: d3424e4481d1ced32dbb89228de517e3a0adf30a099883879c16fbb9f6feec8e
3
+ metadata.gz: 8d912f3b7a33740ace19f7f0568177189b662819bec18bf94312de460f640fd9
4
+ data.tar.gz: d70a03c67ed0a96ce645d00ff769a920b500b3d1d3b231324a2d8170ea683cf1
5
5
  SHA512:
6
- metadata.gz: dfe9f450d1c65671d3599bfe3625952dc7106cfbfe896960bc236d0df04c033dd73116a94b49b5dba82502022ff503339e7a1457ff6c507888df0736489b5dd7
7
- data.tar.gz: 4fc7889e44bd08e673899b0f3ea040ecec5129e4b90ef2ef166f11c51de0e4a05c2066036a8a600bf9dc9e35fd495f95ac60f603799709ec08e0e74522f22ef8
6
+ metadata.gz: ca24fe1d3dff219117a561ecd8ff6d45edd6c1a4d4fb6db9172c874f8964676162747de8d3d45fa0caf1aa7707ec2733141eda5a993bbbcbc8d0fd81d5f8543c
7
+ data.tar.gz: b3f48464d35121fdee21d6d23158e81630ac373676a6581d876f2887e735cc60d5cb70c4a87359090b124a56707b544d13dd1b30f5745ecf93ef81775eb03bed
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,64 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [1.3.6] - 2026-10-07
34
+
35
+ - TAG: [v1.3.6][1.3.6t]
36
+ - COVERAGE: 93.25% -- 6743/7231 lines in 38 files
37
+ - BRANCH COVERAGE: 77.82% -- 2740/3521 branches in 38 files
38
+ - 34.02% documented
39
+
40
+ ### Added
41
+
42
+ - [kc] published-version-check: `Kettle::Family::PublishedVersionCheck` detects lockfile pins that no registry serves — the signature of a locally built and installed gem leaking into a lockfile. Bundler resolves against the local gem dir, so an ordinary `bundle install` can pin such a version, and the entry it writes is a normal GEM-section line carrying a valid checksum taken from the installed spec, so nothing in the lockfile text distinguishes it and there is no offline marker either (installed specs look the same as fetched ones, and `Gem::Specification` has no `#remote`). Only asking the registry can tell. A pinned native gem is compared after splitting off its platform suffix, because a lockfile pins `nokogiri (1.19.4-x86_64-linux-gnu)` while the registry lists bare `1.19.4` once per platform; comparing the pinned string directly reported every native gem as unpublished, adding 10 false positives to one real lockfile. Prerelease versions are unaffected because they are dot-separated (`1.0.0.rc1`) and RubyGems version strings cannot contain a hyphen, so the first hyphen is always the version/platform boundary. Runs in `bup`/`bupb`/`update-bundler` before committing a lockfile update, and in release preflight, where a bad pin previously surfaced much later as an opaque bundler error during lockfile reset.
43
+
44
+ - [kc] published-version-memo: The published-version check fails open: when a registry cannot be consulted the version counts as unverifiable rather than unpublished, so a registry outage cannot turn every family member into a false failure. Registry answers are memoized per process keyed by gem, remote and version, so members sharing toolchain and sibling dependencies query the registry once for the whole run rather than once each. The memo is subordinate to the on-disk release marker that `kettle-release` writes after confirming a gem is available: a gem published during the current run is always re-queried rather than answered from a pre-publish memo entry, which would otherwise report the just-published version as unpublished and block a legitimate release.
45
+
46
+ - [kc] dependency-floor-published-version-gate: `kettle-family release` now gates the "Reconcile family dependencies" commit on the lockfile it is about to record. The preceding `bundle install` resolves against the local gem dir, so it can pin a locally built and installed version, and committing that produces a lockfile no isolated environment can resolve. The failure previously surfaced much later as an opaque bundler error during release lockfile reset ("Your bundle is locked to kettle-jem (7.1.29) ... but that version can no longer be found") rather than at the moment the bad pin was introduced; observed live, a release run's own reconcile commit flipped kettle-jem 7.1.28 to 7.1.29 in the tracked `Gemfile.lock` and then aborted at step 0. The existing floor diagnostics do not catch this, because they validate checksums only for released family members, while the pins that break resolution are typically toolchain dependencies that are not members at all and carry a valid checksum. Gems published during the same run are not blocked, since the check consults the on-disk release marker ahead of its process memo.
47
+
48
+ ### Fixed
49
+
50
+ - [kc] clean-unreleased-batch: `kettle-family clean-unreleased` now removes every unreleased installed version across the family in a single `gem uninstall` invocation instead of one invocation per gem. This is not merely fewer processes: RubyGems' `uninstall_specific` builds a `Gem::DependencyList` from every requested gem and removes them via `strongly_connected_components.flatten.reverse`, a topological sort of the whole set that removes dependents before their dependencies. A per-gem invocation sees only one gem and cannot sort, so it raises `Gem::DependencyRemovalException` ("Uninstallation aborted due to dependent gem(s)") whenever another installed gem still depends on it. Because family members are interdependent and the release order is dependency order, per-gem removal failed on the foundations: observed live, removing tree_haver first failed while ast-merge still required it, and four repeated passes still left two gems installed. One batched invocation removed both. Versions are passed as `name:version` arguments since RubyGems rejects `--version` alongside multiple gems, and `--all` is deliberately omitted because with gem arguments it is redundant while with none it means `uninstall_all`, which would remove every gem in the gem home.
51
+
52
+ - [kc] clean-unreleased-source-checkouts: `kettle-family clean-unreleased` no longer reports source checkouts as unreleased installed gems. Under `bundle exec`, bundler adds PATH and git sources to the load path, so `Gem::Specification.find_all_by_name` also returns specs whose `full_gem_path` points at a working copy rather than a gem home — for example tree_haver 7.1.10 resolving to `gems/tree_haver`. Those are not installed gems and `gem uninstall` cannot remove them, so the command reported work that could never succeed; after uninstalling all 31 genuinely installed unreleased gems in the structuredmerge family, it still listed all 31. Candidates are now filtered to specs under `Gem.path`.
53
+
54
+ ## [1.3.5] - 2026-10-05
55
+
56
+ - TAG: [v1.3.5][1.3.5t]
57
+ - COVERAGE: 93.16% -- 6636/7123 lines in 37 files
58
+ - BRANCH COVERAGE: 77.64% -- 2701/3479 branches in 37 files
59
+ - 33.64% documented
60
+
61
+ ### Added
62
+
63
+ - `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.
64
+
65
+ ### Changed
66
+
67
+ - 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.
68
+
69
+ - [kc] kettle-jem/prepare: updated 12 project files:
70
+ - dependencies (12)
71
+
72
+ - [kc] kettle-jem/template: updated 6 project files:
73
+ - code and tests (1)
74
+ - dependencies (1)
75
+ - documentation (1)
76
+ - other (2)
77
+ - workflows (1)
78
+
79
+ ### Fixed
80
+
81
+ - 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.
82
+
83
+ - 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.
84
+
85
+ - 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.
86
+
87
+ - `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.
88
+
89
+ - 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.
90
+
33
91
  ## [1.3.4] - 2026-10-02
34
92
 
35
93
  - TAG: [v1.3.4][1.3.4t]
@@ -2847,7 +2905,11 @@ Please file a bug if you notice a violation of semantic versioning.
2847
2905
  - Fixed CI load failures on engines without compatible `pty` support by falling back to Open3 for interactive release commands.
2848
2906
  - Fixed Ruby 3.2 version-bump support by loading Prism lazily and wiring the Prism gem only for MRI versions that need it.
2849
2907
 
2850
- [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.3.4...HEAD
2908
+ [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.3.6...HEAD
2909
+ [1.3.6]: https://github.com/kettle-dev/kettle-family/compare/v1.3.5...v1.3.6
2910
+ [1.3.6t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.6
2911
+ [1.3.5]: https://github.com/kettle-dev/kettle-family/compare/v1.3.4...v1.3.5
2912
+ [1.3.5t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.5
2851
2913
  [1.3.4]: https://github.com/kettle-dev/kettle-family/compare/v1.3.3...v1.3.4
2852
2914
  [1.3.4t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.4
2853
2915
  [1.3.3]: https://github.com/kettle-dev/kettle-family/compare/v1.3.2...v1.3.3
data/README.md CHANGED
@@ -1192,7 +1192,7 @@ Thanks for RTFM. ☺️
1192
1192
  [📌gitmoji]: https://gitmoji.dev
1193
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
1194
1194
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1195
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-7.046-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1195
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-7.231-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1196
1196
  [🔐security]: https://github.com/kettle-dev/kettle-family/blob/main/SECURITY.md
1197
1197
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1198
1198
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -28,6 +28,16 @@ module Kettle
28
28
  main(argv, stdout: out, stderr: err)
29
29
  end
30
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
+
31
41
  module SharedOptions
32
42
  def self.included(base)
33
43
  base.option :root, value: {type: String, usage: "PATH"}, desc: "Workspace or family root"
@@ -188,7 +198,12 @@ module Kettle
188
198
  end
189
199
 
190
200
  def run_family(command, overrides = {})
191
- 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)
192
207
  end
193
208
 
194
209
  def truthy_option?(name)
@@ -822,11 +837,7 @@ module Kettle
822
837
  end
823
838
 
824
839
  def default_only_filter(command:, only:)
825
- return only unless only.to_s.empty?
826
- return "bump" if %w[bump bump-version].include?(command)
827
- return "pending" if command == "release"
828
-
829
- only
840
+ self.class.default_only_filter(command: command, only: only)
830
841
  end
831
842
 
832
843
  def direct_member_only_names(only)
@@ -1122,6 +1133,17 @@ module Kettle
1122
1133
  memo.last.branch = branch
1123
1134
  break unless memo.last.ok?
1124
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
+
1125
1147
  branch_members = rediscovered_selected_members(config: member_config, selected_names: [member.name], command: command)
1126
1148
  branch_members = [member] if branch_members.empty?
1127
1149
  branch_results = command_results_for_current_branch(
@@ -1140,6 +1162,41 @@ module Kettle
1140
1162
  end
1141
1163
  end
1142
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
+
1143
1200
  def rediscovered_selected_members(config:, selected_names:, command:)
1144
1201
  discovered = Discovery.new(config: config).members
1145
1202
  ordered = (command == "install") ? install_order(discovered, config) : Orderer.new(members: discovered, mode: config.order_mode, hints: config.order_hints).ordered
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kettle/dev/lockfile_reset"
4
+ require "kettle/dev/ruby_gems_versions"
5
+
6
+ module Kettle
7
+ module Family
8
+ # Detects versions pinned in a release lockfile that no registry actually
9
+ # serves — the signature of a locally built and installed gem leaking into
10
+ # a lockfile.
11
+ #
12
+ # Why this is needed at all: bundler resolves against the local gem dir, so
13
+ # an ordinary `bundle install` will happily pin a locally installed version.
14
+ # The resulting entry is a normal GEM-section line carrying a valid checksum
15
+ # taken from the installed spec, so it is indistinguishable from a published
16
+ # gem by inspection. There is also no offline marker for a local build —
17
+ # installed specs look the same as fetched ones, and Gem::Specification has
18
+ # no #remote — so the only reliable signal is asking the registry.
19
+ #
20
+ # Deliberately NOT applied to templating. Templating injects its own
21
+ # (often unreleased) version into a destination so it can run itself, and
22
+ # local templating from unreleased source is a routine workflow; gating that
23
+ # would break it. So this runs only where a lockfile is about to be
24
+ # committed or published, and templating's transient local pins get cleaned
25
+ # up later by a release-mode dependency update.
26
+ #
27
+ # Fails open: when the registry cannot be consulted the version counts as
28
+ # unverifiable rather than unpublished, so offline runs are never blocked by
29
+ # network conditions.
30
+ class PublishedVersionCheck
31
+ PHASE = "published_version_check"
32
+
33
+ # Process-local memo of registry answers, keyed by [gem name, remote].
34
+ #
35
+ # Two reasons this is safe and desirable:
36
+ #
37
+ # * Family members share almost all their toolchain and sibling
38
+ # dependencies, so one member's lookups serve every later member.
39
+ # Without this, a 30-member release re-queries the same gems 30
40
+ # times.
41
+ # * A single command invocation should see one consistent snapshot of
42
+ # the registry. Re-querying per member could otherwise report a gem
43
+ # as unpublished for early members and published for later ones if it
44
+ # lands mid-run.
45
+ #
46
+ # The memo is subordinate to the on-disk release marker, never above it.
47
+ # kettle-release writes ~/.local/state/kettle-dev/rubygems-cache-bust.json
48
+ # after publishing, so the filesystem is the authority on what this
49
+ # machine just released. A gem published *during* the current run — say
50
+ # kettle-dev 3.1.8, released before its dependents' floors are raised to
51
+ # it — would otherwise be answered from a pre-publish memo entry and
52
+ # falsely reported unpublished, blocking a legitimate release. So
53
+ # #published_versions always consults the marker first and skips the memo
54
+ # when it says that exact gem+version was just released. Verified: a
55
+ # pre-publish memo entry yields 0 live queries without the marker check
56
+ # and reports the new version missing.
57
+ #
58
+ # nil is memoized too, so an unreachable registry is asked once rather
59
+ # than once per pin. Lifetime is one process; nothing persists.
60
+ REGISTRY_MEMO = {}
61
+
62
+ def self.call(member:)
63
+ new(member: member).call
64
+ end
65
+
66
+ def initialize(member:)
67
+ @member = member
68
+ end
69
+
70
+ def call
71
+ result(diagnostics)
72
+ end
73
+
74
+ private
75
+
76
+ attr_reader :member
77
+
78
+ def diagnostics
79
+ lockfile = File.join(member.root, "Gemfile.lock")
80
+ return [] unless File.file?(lockfile)
81
+
82
+ registry_lockfile_specs(lockfile).filter_map { |spec| diagnostic_for(spec) }
83
+ end
84
+
85
+ def registry_lockfile_specs(lockfile)
86
+ Kettle::Dev::LockfileReset.registry_gem_specs_from_source(File.read(lockfile))
87
+ end
88
+
89
+ # A message for an unpublished pin, or nil when the version is published
90
+ # or the registry could not be consulted.
91
+ def diagnostic_for(spec)
92
+ return nil unless unpublished?(spec)
93
+
94
+ "release lockfile pins #{spec.fetch(:name)} #{spec.fetch(:version)}, " \
95
+ "which is not published on #{spec.fetch(:remote)} " \
96
+ "(locally installed but unreleased; run a release-mode dependency update to re-resolve)"
97
+ end
98
+
99
+ def unpublished?(spec)
100
+ remote = spec.fetch(:remote).to_s
101
+ return false if remote.empty?
102
+
103
+ pinned = version_without_platform(spec.fetch(:version))
104
+ versions = published_versions(spec.fetch(:name), remote, pinned)
105
+ return false if versions.nil?
106
+
107
+ !versions.include?(pinned)
108
+ end
109
+
110
+ # A lockfile pins a native gem as "1.19.4-x86_64-linux-gnu" while the
111
+ # registry lists its versions as bare "1.19.4", one entry per platform.
112
+ # Comparing the pinned string directly would report every native gem as
113
+ # unpublished, so split off the platform first.
114
+ #
115
+ # Splitting on the first "-" matches Bundler::LockfileParser. It is safe
116
+ # because RubyGems version strings cannot themselves contain a hyphen
117
+ # (prerelease segments are dot-separated, e.g. "1.0.0.rc1"), so the first
118
+ # hyphen is always the version/platform boundary.
119
+ def version_without_platform(pinned)
120
+ pinned.to_s.split("-", 2).first
121
+ end
122
+
123
+ # nil when the registry could not be consulted. Memoized per process,
124
+ # except for gems the on-disk release marker says were just published —
125
+ # see REGISTRY_MEMO for why bypassing the memo there is required.
126
+ def published_versions(name, remote, version)
127
+ key = [name, remote, version]
128
+ unless just_released?(name, version)
129
+ return REGISTRY_MEMO[key] if REGISTRY_MEMO.key?(key)
130
+ end
131
+
132
+ REGISTRY_MEMO[key] = Kettle::Dev::RubyGemsVersions.published_version_numbers(
133
+ name,
134
+ source: remote,
135
+ version: version
136
+ )
137
+ end
138
+
139
+ def just_released?(name, version)
140
+ return false if version.to_s.empty?
141
+
142
+ Kettle::Dev::RubyGemsVersions.recently_released?(name, version)
143
+ rescue
144
+ false
145
+ end
146
+
147
+ def result(found)
148
+ clean = found.empty?
149
+ CommandResult.new(
150
+ member_name: member.name,
151
+ phase: PHASE,
152
+ command: ["internal", PHASE],
153
+ workdir: member.root,
154
+ status: clean ? 0 : 1,
155
+ success: clean,
156
+ stdout: found.join("\n"),
157
+ stderr: "",
158
+ elapsed_seconds: 0.0,
159
+ skipped: false,
160
+ reason: clean ? nil : "lockfile pins versions no registry serves"
161
+ )
162
+ end
163
+ end
164
+ end
165
+ end
@@ -25,10 +25,16 @@ module Kettle
25
25
  CACHE_MISS = Object.new.freeze
26
26
  WORKTREE_ADMIN_MUTEX = Mutex.new
27
27
 
28
- def initialize(members:, config: nil, jobs: nil)
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)
29
34
  @members = members
30
35
  @config = config
31
36
  @jobs = jobs
37
+ @branches = branches&.map(&:to_s)&.reject(&:empty?)
32
38
  @github_latest_release_by_repo = {}
33
39
  @transfer_changelog_status_by_root = {}
34
40
  @transfer_changelog_lag_by_key = {}
@@ -38,6 +44,7 @@ module Kettle
38
44
  end
39
45
 
40
46
  def results(event_handler: nil)
47
+ return scoped_checkout_results(event_handler: event_handler) if checked_out_scoped_results?
41
48
  return branch_results(event_handler: event_handler) unless release_target_branches.empty?
42
49
  if shared_changelog?
43
50
  results = parallel_map(members) { |member| check_shared_changelog_member_or_local(member, event_handler: event_handler) }
@@ -53,7 +60,25 @@ module Kettle
53
60
 
54
61
  private
55
62
 
56
- 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
57
82
 
58
83
  def branch_results(event_handler: nil)
59
84
  root = git_root
@@ -769,6 +794,7 @@ module Kettle
769
794
 
770
795
  def release_target_branches
771
796
  return [] unless config
797
+ return @branches if @branches
772
798
 
773
799
  config.release_target_branches
774
800
  end
@@ -840,6 +866,7 @@ module Kettle
840
866
  with_worktree_registry_lock(root: root) do
841
867
  base = File.join(root, "tmp", "kettle-family-release-state")
842
868
  FileUtils.mkdir_p(base)
869
+ prune_stale_worktrees(root: root)
843
870
  entries = branches.map do |branch|
844
871
  {branch: branch, worktree_root: File.join(base, "worktree-#{Process.pid}-#{SecureRandom.hex(8)}")}
845
872
  end
@@ -869,6 +896,40 @@ module Kettle
869
896
  end
870
897
  end
871
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:)
903
+ base = File.join(root, "tmp", "kettle-family-release-state")
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
931
+ end
932
+
872
933
  def add_branch_worktree(root:, branch:, worktree_root:)
873
934
  WORKTREE_ADMIN_MUTEX.synchronize do
874
935
  _stdout, stderr, status = Open3.capture3("git", "worktree", "add", "--detach", worktree_root, branch, chdir: root)
@@ -517,6 +517,7 @@ module Kettle
517
517
 
518
518
  def release_terminal_result?(result)
519
519
  result.phase == "release_skip" ||
520
+ result.phase == "release_blocked" ||
520
521
  result.phase == "release_publish" ||
521
522
  result.phase == "release_build"
522
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
 
@@ -2,8 +2,49 @@
2
2
 
3
3
  module Kettle
4
4
  module Family
5
+ # Removes locally installed gem versions that no registry has published.
6
+ #
7
+ # Why this exists: bundler resolves against the local gem dir and picks the
8
+ # highest version satisfying a constraint, so a locally built unreleased
9
+ # version always wins over the published one. Floor constraints are `>=`, so
10
+ # there is no ceiling that stops it, and every `bundle install` or
11
+ # `bundle lock` re-pins the offender. Detection cannot fix resolution — the
12
+ # offenders have to be removed. Left in place they produce lockfiles that no
13
+ # isolated environment can resolve, which surfaces much later as an opaque
14
+ # bundler error ("locked to kettle-jem (7.1.29) ... can no longer be
15
+ # found").
16
+ #
17
+ # All candidates across the whole family are removed in ONE `gem uninstall`
18
+ # invocation rather than one invocation per gem. That is not merely fewer
19
+ # processes: RubyGems' `uninstall_specific` builds a Gem::DependencyList from
20
+ # every requested gem and removes them via
21
+ # `strongly_connected_components.flatten.reverse`, a topological sort of the
22
+ # whole set that removes dependents before their dependencies. A per-gem
23
+ # invocation sees only one gem and cannot sort, so it raises
24
+ # Gem::DependencyRemovalException ("Uninstallation aborted due to dependent
25
+ # gem(s)") whenever another installed gem still depends on it. Family
26
+ # members are heavily interdependent, so per-gem removal fails on the
27
+ # foundations: observed live, removing tree_haver first failed because
28
+ # ast-merge still required it, and four iterative passes still left two gems
29
+ # installed. One batched invocation removed both.
30
+ #
31
+ # The single run is still reported per member. Report keys results by member
32
+ # name for commands in MEMBER_RESULT_COMMANDS, and treats a member with no
33
+ # result as pending, which fails the whole summary. So attributing the batch
34
+ # to one synthetic family-level name left every member pending and reported
35
+ # `outcome: failure` with `failed: none`. Each contributing member instead
36
+ # receives the shared outcome of the one run.
37
+ #
38
+ # `--all` is deliberately not passed. With gem arguments present it is
39
+ # redundant (RubyGems routes to `uninstall_specific` either way), and with no
40
+ # arguments it means `uninstall_all`, which removes every gem in the gem
41
+ # home. Omitting it makes an empty argument list a plain usage error instead
42
+ # of a wipe. `--version` cannot be used either, because RubyGems rejects it
43
+ # alongside multiple gems; versions are given as `name:version` arguments,
44
+ # the form its own error message prescribes.
5
45
  class UnreleasedGemCleanup
6
46
  PHASE = "clean_unreleased"
47
+ UNKNOWN_RELEASED_MESSAGE = "latest released version is unknown; no cleanup attempted"
7
48
 
8
49
  def initialize(config:, members:, execute: false, runner: nil)
9
50
  @config = config
@@ -14,9 +55,24 @@ module Kettle
14
55
 
15
56
  def results
16
57
  release_states = release_state_by_member
17
- members.flat_map do |member|
18
- cleanup_results_for(member: member, release_state: release_states[member.name])
58
+ # Resolved once per member: candidates and the member's non-uninstall
59
+ # diagnostics both derive from the same release state and installed spec
60
+ # scan, so pairing them in one pass keeps installed specs enumerated a
61
+ # single time and avoids re-reading release_states per member.
62
+ planned = members.map do |member|
63
+ release_state = release_states[member.name]
64
+ candidates = unreleased_candidates(member: member, release_state: release_state)
65
+ {
66
+ member: member,
67
+ candidates: candidates,
68
+ diagnostics: member_diagnostics(member: member, release_state: release_state, candidates: candidates)
69
+ }
19
70
  end
71
+ diagnostics = planned.flat_map { |entry| entry.fetch(:diagnostics) }
72
+ contributors = planned.reject { |entry| entry.fetch(:candidates).empty? }
73
+ return diagnostics if contributors.empty?
74
+
75
+ diagnostics + batched_cleanup_results(contributors)
20
76
  end
21
77
 
22
78
  private
@@ -29,16 +85,40 @@ module Kettle
29
85
  end
30
86
  end
31
87
 
32
- def cleanup_results_for(member:, release_state:)
88
+ # Per-member outcomes that are not an uninstall: a release state that could
89
+ # not be read, a version that cannot be compared, or nothing to do. These
90
+ # stay per member so a failure names the member it came from. Members that
91
+ # do have candidates are reported by the batch result instead.
92
+ def member_diagnostics(member:, release_state:, candidates:)
33
93
  return [release_state_failure(member: member, release_state: release_state)] unless release_state&.ok?
34
94
 
35
95
  latest_released = latest_released_version(release_state.state)
36
- return [informational_result(member: member, message: "latest released version is unknown; no cleanup attempted")] unless latest_released
96
+ return [informational_result(member: member, message: UNKNOWN_RELEASED_MESSAGE)] unless latest_released
97
+ return [] unless candidates.empty?
98
+
99
+ [informational_result(member: member, message: "no unreleased installed versions found")]
100
+ end
37
101
 
38
- candidates = installed_versions(member.name).select { |version| version > latest_released }
39
- return [informational_result(member: member, message: "no unreleased installed versions found")] if candidates.empty?
102
+ # Installed versions newer than anything published, as [name, version] pairs.
103
+ #
104
+ # Only genuinely installed gems count. Under `bundle exec`, bundler adds
105
+ # PATH and git sources to the load path, so
106
+ # Gem::Specification.find_all_by_name also returns specs whose
107
+ # full_gem_path points at a source checkout rather than a gem home — e.g.
108
+ # tree_haver 7.1.10 resolving to gems/tree_haver. Those are not installed
109
+ # gems and `gem uninstall` cannot remove them, so treating them as
110
+ # candidates would report work that can never succeed. Filtering on
111
+ # Gem.path membership excludes them.
112
+ def unreleased_candidates(member:, release_state:)
113
+ return [] unless release_state&.ok?
114
+
115
+ latest_released = latest_released_version(release_state.state)
116
+ return [] unless latest_released
40
117
 
41
- candidates.map { |version| cleanup_result(member: member, version: version) }
118
+ name = member.name
119
+ installed_versions(name).filter_map do |version|
120
+ [name, version] if version > latest_released
121
+ end
42
122
  end
43
123
 
44
124
  def latest_released_version(state)
@@ -51,21 +131,50 @@ module Kettle
51
131
  end
52
132
 
53
133
  def installed_versions(name)
54
- Gem::Specification.find_all_by_name(name).map(&:version).uniq.sort
134
+ Gem::Specification.find_all_by_name(name)
135
+ .select { |spec| installed_spec?(spec) }
136
+ .map(&:version)
137
+ .uniq
138
+ .sort
55
139
  end
56
140
 
57
- def cleanup_result(member:, version:)
58
- command = ["gem", "uninstall", member.name, "--version", version.to_s, "--executables", "--all"]
59
- return runner.call(member: member, phase: PHASE, command: command) if execute
141
+ def installed_spec?(spec)
142
+ path = spec.full_gem_path.to_s
143
+ return false if path.empty?
144
+
145
+ Gem.path.any? { |root| path.start_with?("#{root}#{File::SEPARATOR}") }
146
+ rescue
147
+ false
148
+ end
60
149
 
150
+ # Runs the batch once, then reports that one outcome against every member
151
+ # whose gems were in it. See the class comment for why per-member results
152
+ # are required even though only one command runs.
153
+ def batched_cleanup_results(contributors)
154
+ candidates = contributors.flat_map { |entry| entry.fetch(:candidates) }
155
+ # Versions are given as `name:version` arguments rather than `--version`,
156
+ # which RubyGems rejects alongside multiple gems. See the class comment
157
+ # for why `--all` is omitted.
158
+ command = ["gem", "uninstall", *candidates.map { |name, version| "#{name}:#{version}" }, "--executables"]
159
+ # gem uninstall does not depend on the working directory, so the first
160
+ # contributing member provides a real directory to execute in.
161
+ host = contributors.first.fetch(:member)
162
+ outcome = execute ? runner.call(member: host, phase: PHASE, command: command) : dry_run_outcome(command)
163
+
164
+ contributors.map do |entry|
165
+ member_result(entry.fetch(:member), outcome, entry.fetch(:candidates))
166
+ end
167
+ end
168
+
169
+ def dry_run_outcome(command)
61
170
  CommandResult.new(
62
- member_name: member.name,
171
+ member_name: nil,
63
172
  phase: PHASE,
64
173
  command: command,
65
- workdir: member.root,
174
+ workdir: nil,
66
175
  status: nil,
67
176
  success: true,
68
- stdout: "would uninstall #{member.name} #{version}",
177
+ stdout: nil,
69
178
  stderr: "",
70
179
  elapsed_seconds: 0.0,
71
180
  skipped: true,
@@ -73,6 +182,32 @@ module Kettle
73
182
  )
74
183
  end
75
184
 
185
+ # One member's view of the shared batch outcome. The command and exit state
186
+ # are common to the whole batch, but each member reports only its own gems,
187
+ # so the per-member report stays readable and a failure names every gem
188
+ # that member contributed.
189
+ def member_result(member, outcome, own_candidates)
190
+ own = describe(own_candidates)
191
+ outcome_reason = outcome.reason
192
+ # Equivalent to `ok? ? reason : (own.empty? ? reason : batch message)`,
193
+ # with outcome.reason read once instead of twice.
194
+ reason = if outcome.ok? || own_candidates.empty?
195
+ outcome_reason
196
+ else
197
+ "batched gem uninstall including #{own} failed"
198
+ end
199
+ outcome.dup.tap do |copy|
200
+ copy.member_name = member.name
201
+ copy.workdir = member.root
202
+ copy.stdout = outcome.skipped ? "would uninstall #{own}" : outcome.stdout
203
+ copy.reason = reason
204
+ end
205
+ end
206
+
207
+ def describe(candidates)
208
+ candidates.map { |name, version| "#{name} #{version}" }.join(", ")
209
+ end
210
+
76
211
  def informational_result(member:, message:)
77
212
  CommandResult.new(
78
213
  member_name: member.name,
@@ -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.4"
8
+ VERSION = "1.3.6"
9
9
  end
10
10
  # Current gem version exposed at the traditional constant location.
11
11
  VERSION = Version::VERSION # Traditional Constant Location
@@ -2343,9 +2343,9 @@ module Kettle
2343
2343
  return memo
2344
2344
  end
2345
2345
 
2346
- append_release_internal_checks(member: member, memo: memo)
2347
- memo.last(2).each { |result| emit_member_result_progress(original_member, result, progress: progress) }
2348
- return memo unless memo.last(2).all?(&:ok?)
2346
+ internal_check_results = append_release_internal_checks(member: member, memo: memo)
2347
+ internal_check_results.each { |result| emit_member_result_progress(original_member, result, progress: progress) }
2348
+ return memo unless internal_check_results.all?(&:ok?)
2349
2349
 
2350
2350
  release_event_state = {}
2351
2351
  result = release_command_runner.call(
@@ -2526,9 +2526,9 @@ module Kettle
2526
2526
  end
2527
2527
  end
2528
2528
 
2529
- append_release_internal_checks(member: member, memo: memo)
2530
- memo.last(2).each { |result| emit_member_result_progress(member, result, progress: progress) }
2531
- return memo unless memo.last(2).all?(&:ok?)
2529
+ internal_check_results = append_release_internal_checks(member: member, memo: memo)
2530
+ internal_check_results.each { |result| emit_member_result_progress(member, result, progress: progress) }
2531
+ return memo unless internal_check_results.all?(&:ok?)
2532
2532
 
2533
2533
  release_event_state = {}
2534
2534
  release_result = runner.call(
@@ -2744,7 +2744,38 @@ module Kettle
2744
2744
  end
2745
2745
 
2746
2746
  reconciled_members = (floor_results.map(&:member_name) + lockfile_refresh_members.map { |member, _released_members| member.name }).uniq
2747
- commit_dependency_floor_changes(dependent_members: reconciled_members, runner: runner, memo: memo) if reconciled_members.any? && execute && commit
2747
+ return unless reconciled_members.any? && execute && commit
2748
+
2749
+ return unless validate_dependency_floor_published_versions(member_names: reconciled_members, memo: memo)
2750
+
2751
+ commit_dependency_floor_changes(dependent_members: reconciled_members, runner: runner, memo: memo)
2752
+ end
2753
+
2754
+ # Gates the "Reconcile family dependencies" commit on the lockfile it is
2755
+ # about to record.
2756
+ #
2757
+ # The preceding bundle install resolves against the local gem dir, so it
2758
+ # happily pins a locally built and installed version. Committing that
2759
+ # produces a lockfile no isolated environment can resolve, and the failure
2760
+ # surfaces much later as an opaque bundler error during release lockfile
2761
+ # reset ("locked to kettle-jem (7.1.29) ... can no longer be found")
2762
+ # rather than at the moment the bad pin was introduced. Observed live: a
2763
+ # release run's own reconcile commit flipped kettle-jem 7.1.28 -> 7.1.29
2764
+ # in the tracked Gemfile.lock and then aborted at step 0.
2765
+ #
2766
+ # Gems published during this very run are not a false positive, because
2767
+ # PublishedVersionCheck consults kettle-release's on-disk release marker
2768
+ # ahead of its process memo, and that marker is written only after the gem
2769
+ # is confirmed available on the registry.
2770
+ def validate_dependency_floor_published_versions(member_names:, memo:)
2771
+ by_name = members.to_h { |member| [member.name, member] }
2772
+ results = member_names.filter_map do |member_name|
2773
+ member = by_name[member_name]
2774
+ next unless member
2775
+
2776
+ PublishedVersionCheck.call(member: member).tap { |result| memo << result }
2777
+ end
2778
+ results.all?(&:ok?)
2748
2779
  end
2749
2780
 
2750
2781
  def dependent_members_depending_on(released_members:, dependent_members:)
@@ -3275,13 +3306,34 @@ module Kettle
3275
3306
  results.each { |result| result.branch = branch if result.respond_to?(:branch=) }
3276
3307
  end
3277
3308
 
3309
+ # Runs the internal pre-release checks for one member, appending each
3310
+ # result to +memo+. Returns the results appended by this call so callers
3311
+ # can inspect exactly them instead of relying on a positional
3312
+ # `memo.last(N)` window.
3313
+ #
3314
+ # Short-circuits on the first failure: a member with a broken lockfile
3315
+ # should not spend a release on its changelog, and the returned array
3316
+ # then holds only the failing result.
3278
3317
  def append_release_internal_checks(member:, memo:)
3279
- memo << ReadinessCheck.call(
3318
+ appended = []
3319
+ readiness = ReadinessCheck.call(
3280
3320
  member: member,
3281
3321
  config: config,
3282
3322
  allowed_local_path_roots: release_readiness_allowed_local_path_roots(member)
3283
3323
  )
3284
- memo << ChangelogCheck.call(member: member, config: config) if memo.last.ok?
3324
+ memo << readiness
3325
+ appended << readiness
3326
+ return appended unless readiness.ok?
3327
+
3328
+ published_versions = PublishedVersionCheck.call(member: member)
3329
+ memo << published_versions
3330
+ appended << published_versions
3331
+ return appended unless published_versions.ok?
3332
+
3333
+ changelog = ChangelogCheck.call(member: member, config: config)
3334
+ memo << changelog
3335
+ appended << changelog
3336
+ appended
3285
3337
  end
3286
3338
 
3287
3339
  def release_readiness_allowed_local_path_roots(member)
@@ -3978,28 +4030,38 @@ module Kettle
3978
4030
  @release_progress&.update(member, status: "ci bundle #{relative} #{attempt}/#{REGISTRY_WAIT_ATTEMPTS}", mark: ">")
3979
4031
  end
3980
4032
 
4033
+ # A published version only means "already released" when nothing
4034
+ # releasable landed after its tag. kettle-release commits checksums
4035
+ # (and the family commits dependency-floor lockfiles) *after* tagging,
4036
+ # so comparing HEAD to the tag directly never matches; compare the
4037
+ # release-relevant paths instead.
3981
4038
  def already_released_result(member)
3982
4039
  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"
4040
+ branch = current_member_branch(member)
4041
+ scope = branch ? "#{member.name}@#{branch}" : member.name
4042
+ if no_releasable_changes_since_tag?(member, tag)
4043
+ stdout = "#{scope} #{member.version} is already published#{branch_suffix(branch)} and nothing releasable " \
4044
+ "landed after #{tag}; skipping release"
3986
4045
  reason = "already released"
3987
4046
  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."
4047
+ elsif unreleased_changes_pending?(member, branch: branch)
4048
+ stdout = "#{scope} #{member.version} is already published#{branch_suffix(branch)}, but release-state reports " \
4049
+ "unreleased changes on this branch. Bump only this branch with " \
4050
+ "`kettle-family bump patch --execute --only #{member.name}` before releasing."
3991
4051
  reason = "published version has unreleased changes"
3992
4052
  skipped = false
3993
4053
  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"
4054
+ stdout = "#{scope} #{member.version} is already published#{branch_suffix(branch)}, and release-state reports " \
4055
+ "no unreleased changes on this branch; skipping release"
3996
4056
  reason = "already released; no unreleased changes"
3997
4057
  skipped = true
3998
4058
  end
3999
4059
 
4000
4060
  CommandResult.new(
4001
4061
  member_name: member.name,
4002
- phase: "release_skip",
4062
+ # A blocked release is a distinct terminal phase from a skip: it is a
4063
+ # hard failure and must never be reported as "skipped".
4064
+ phase: skipped ? "release_skip" : "release_blocked",
4003
4065
  command: ["internal", "released-version-check", member.version],
4004
4066
  workdir: member.root,
4005
4067
  status: skipped ? 0 : 1,
@@ -4008,12 +4070,34 @@ module Kettle
4008
4070
  stderr: "",
4009
4071
  elapsed_seconds: 0.0,
4010
4072
  skipped: skipped,
4011
- reason: reason
4073
+ reason: reason,
4074
+ branch: branch
4012
4075
  )
4013
4076
  end
4014
4077
 
4015
- def unreleased_changes_pending?(member)
4016
- results = ReleaseStateCheck.new(members: [member], config: config).results
4078
+ def branch_suffix(branch)
4079
+ branch ? " on #{branch}" : ""
4080
+ end
4081
+
4082
+ def current_member_branch(member)
4083
+ return unless git_work_tree?(member.root)
4084
+
4085
+ stdout, _stderr, status = Open3.capture3("git", "branch", "--show-current", chdir: member.root)
4086
+ return unless status.success?
4087
+
4088
+ branch = stdout.strip
4089
+ branch.empty? ? nil : branch
4090
+ end
4091
+
4092
+ # Scope the check to the branch being released. Consulting the whole
4093
+ # branch stack here would report every already-published branch as
4094
+ # having unreleased changes whenever any other branch in the stack does.
4095
+ def unreleased_changes_pending?(member, branch: nil)
4096
+ results = ReleaseStateCheck.new(
4097
+ members: [member],
4098
+ config: config,
4099
+ branches: branch ? [branch] : nil
4100
+ ).results
4017
4101
  return true unless results.all?(&:ok?)
4018
4102
 
4019
4103
  results.any? { |result| result.state.fetch("unreleased_entries", true) }
@@ -4023,12 +4107,42 @@ module Kettle
4023
4107
  "v#{version}"
4024
4108
  end
4025
4109
 
4026
- def released_version_current_head?(member, tag)
4027
- return true unless git_work_tree?(member.root)
4110
+ # Post-release maintenance commits (checksums, lockfile reconciliation,
4111
+ # docs) land after the tag by design, so "HEAD != tag" cannot mean
4112
+ # "needs a release". Only a change to the gemspec, version file, or
4113
+ # changelog between the tag and HEAD makes the published version stale.
4114
+ def no_releasable_changes_since_tag?(member, tag)
4115
+ return false unless git_work_tree?(member.root)
4028
4116
 
4029
4117
  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
4118
+ return false if tag_sha.to_s.empty?
4119
+ return true if tag_sha == git_rev_parse(member.root, "HEAD")
4120
+
4121
+ releasable_changes_since_tag(member, tag).empty?
4122
+ end
4123
+
4124
+ def releasable_changes_since_tag(member, tag)
4125
+ paths = releasable_paths(member)
4126
+ return [] if paths.empty?
4127
+
4128
+ stdout, _stderr, status = Open3.capture3(
4129
+ "git", "diff", "--name-only", "#{tag}^{}", "HEAD", "--", *paths, chdir: member.root
4130
+ )
4131
+ # An unreadable diff is not evidence of "nothing to release".
4132
+ return [tag] unless status.success?
4133
+
4134
+ stdout.lines.map(&:strip).reject(&:empty?)
4135
+ end
4136
+
4137
+ def releasable_paths(member)
4138
+ paths = [member.gemspec_path, member.version_file, config.changelog_full_path(member)]
4139
+ paths.compact.filter_map do |path|
4140
+ next unless File.file?(path)
4141
+
4142
+ Pathname.new(path).relative_path_from(Pathname.new(member.root)).to_s
4143
+ rescue ArgumentError
4144
+ path.to_s
4145
+ end.uniq
4032
4146
  end
4033
4147
 
4034
4148
  def git_work_tree?(root)
@@ -4535,8 +4649,16 @@ module Kettle
4535
4649
  total
4536
4650
  end
4537
4651
 
4652
+ # Progress denominator for one member's release. The base four are
4653
+ # check, published_version_check, release_changelog and release_build.
4654
+ #
4655
+ # This must stay in step with what append_release_internal_checks emits:
4656
+ # Progress#increment_member_count clamps the count to the total, so a
4657
+ # total that is too low silently saturates the progress display instead
4658
+ # of failing. Adding a release-internal phase without raising this base
4659
+ # would leave every member showing its final count early.
4538
4660
  def release_phase_total(member = nil)
4539
- total = 3
4661
+ total = 4
4540
4662
  if member
4541
4663
  normalize = normalize_release_lockfiles?(member)
4542
4664
  total += 2 if normalize
@@ -4634,7 +4756,9 @@ module Kettle
4634
4756
  def emit_release_progress_summary(results, progress:)
4635
4757
  return unless progress
4636
4758
 
4637
- release_results = results.select { |result| result.phase == release_phase || result.phase == "release_skip" }
4759
+ release_results = results.select do |result|
4760
+ result.phase == release_phase || result.phase == "release_skip" || result.phase == "release_blocked"
4761
+ end
4638
4762
  progress.stop
4639
4763
  progress.summary("release summary: #{release_results.count(&:ok?)}/#{release_results.length} members ok")
4640
4764
  end
@@ -5659,8 +5783,14 @@ module Kettle
5659
5783
  end
5660
5784
 
5661
5785
  def release_lockfile_readiness_would_fail?(member)
5662
- result = ReadinessCheck.call(member: member, config: config, allowed_local_path_roots: release_allowed_local_path_roots)
5663
- result.stdout.lines.any? { |line| line.start_with?("release lockfile has local path remote at ") }
5786
+ readiness = ReadinessCheck.call(
5787
+ member: member,
5788
+ config: config,
5789
+ allowed_local_path_roots: release_allowed_local_path_roots
5790
+ )
5791
+ return true if readiness.stdout.lines.any? { |line| line.start_with?("release lockfile has local path remote at ") }
5792
+
5793
+ !PublishedVersionCheck.call(member: member).ok?
5664
5794
  end
5665
5795
 
5666
5796
  def release_lockfile_has_local_path_remote?(member)
@@ -5911,11 +6041,14 @@ module Kettle
5911
6041
  end
5912
6042
 
5913
6043
  def bundle_update_lockfile_diagnostics(member)
5914
- ReadinessCheck.call(
6044
+ readiness = ReadinessCheck.call(
5915
6045
  member: member,
5916
6046
  config: config,
5917
6047
  allowed_local_path_roots: bundle_update_allowed_local_path_roots
5918
- ).stdout.lines.grep(/^release lockfile has local path remote at /).map do |line|
6048
+ ).stdout.lines.grep(/^release lockfile has local path remote at /)
6049
+ unpublished = PublishedVersionCheck.call(member: member).stdout.lines
6050
+
6051
+ (readiness + unpublished).map do |line|
5919
6052
  line.sub("release lockfile", "bundle update lockfile").chomp
5920
6053
  end
5921
6054
  end
data/lib/kettle/family.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "family/command_result"
5
5
  require_relative "family/release_state_result"
6
6
  require_relative "family/command_runner"
7
7
  require_relative "family/readiness_check"
8
+ require_relative "family/published_version_check"
8
9
  require_relative "family/changelog_check"
9
10
  require_relative "family/git_status"
10
11
  require_relative "family/branch_lane_audit"
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.4
4
+ version: 1.3.6
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.5
97
+ version: 3.1.8
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.5
107
+ version: 3.1.8
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:
@@ -436,6 +436,7 @@ files:
436
436
  - lib/kettle/family/nomono_bootstrap.rb
437
437
  - lib/kettle/family/orderer.rb
438
438
  - lib/kettle/family/paths.rb
439
+ - lib/kettle/family/published_version_check.rb
439
440
  - lib/kettle/family/readiness_check.rb
440
441
  - lib/kettle/family/release_graph.rb
441
442
  - lib/kettle/family/release_reconciler.rb
@@ -459,10 +460,10 @@ licenses:
459
460
  - AGPL-3.0-only
460
461
  metadata:
461
462
  homepage_uri: https://kettle-family.galtzo.com
462
- source_code_uri: https://github.com/kettle-dev/kettle-family/tree/v1.3.4
463
- changelog_uri: https://github.com/kettle-dev/kettle-family/blob/v1.3.4/CHANGELOG.md
463
+ source_code_uri: https://github.com/kettle-dev/kettle-family/tree/v1.3.6
464
+ changelog_uri: https://github.com/kettle-dev/kettle-family/blob/v1.3.6/CHANGELOG.md
464
465
  bug_tracker_uri: https://github.com/kettle-dev/kettle-family/issues
465
- documentation_uri: https://www.rubydoc.info/gems/kettle-family/1.3.4
466
+ documentation_uri: https://www.rubydoc.info/gems/kettle-family/1.3.6
466
467
  funding_uri: https://github.com/sponsors/pboling
467
468
  wiki_uri: https://github.com/kettle-dev/kettle-family/wiki
468
469
  news_uri: https://www.railsbling.com/tags/kettle-family
metadata.gz.sig CHANGED
Binary file