kettle-family 1.3.6 → 1.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +64 -1
- data/README.md +1 -1
- data/lib/kettle/family/blocker_cleanup.rb +250 -0
- data/lib/kettle/family/clean_installed.rb +210 -0
- data/lib/kettle/family/cli.rb +26 -1
- data/lib/kettle/family/command_result.rb +34 -2
- data/lib/kettle/family/gem_uninstall_support.rb +245 -0
- data/lib/kettle/family/local_install.rb +2 -53
- data/lib/kettle/family/member_loader.rb +70 -0
- data/lib/kettle/family/published_version_check.rb +34 -8
- data/lib/kettle/family/release_pre_clean.rb +126 -0
- data/lib/kettle/family/report.rb +41 -1
- data/lib/kettle/family/selection.rb +37 -0
- data/lib/kettle/family/unreleased_gem_cleanup.rb +26 -50
- data/lib/kettle/family/version.rb +1 -1
- data/lib/kettle/family/workflow.rb +55 -0
- data/lib/kettle/family.rb +5 -0
- data.tar.gz.sig +0 -0
- metadata +39 -14
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f74d2fca80b37d1577e7212df092067958426dc780d1e07594b545d057a7321e
|
|
4
|
+
data.tar.gz: 23f68c9dd77d68828708ecfea9d64c87af8b12182689e9c9c15e0dfddc6648d5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 327d0135be9e3ee4fb1abea1794d3acf2e714f791b3e83063bb6290a5466ada57b0d49b969f85cbde18050499c34bb57a2334cd7b2a98857f411fe4b015147d9
|
|
7
|
+
data.tar.gz: 21892de4191d48f57e7b9183d6a45bbb7d6ef5b3ba1b1a3a4922ba42b39da09ddb0f3d8ba30d3e6c86730a86c876023e405b1d93617896f369916245b6b45718
|
checksums.yaml.gz.sig
CHANGED
|
Binary file
|
data/CHANGELOG.md
CHANGED
|
@@ -30,6 +30,65 @@ Please file a bug if you notice a violation of semantic versioning.
|
|
|
30
30
|
|
|
31
31
|
### Security
|
|
32
32
|
|
|
33
|
+
## [1.3.9] - 2026-10-10
|
|
34
|
+
|
|
35
|
+
- TAG: [v1.3.9][1.3.9t]
|
|
36
|
+
- COVERAGE: 93.43% -- 7068/7565 lines in 43 files
|
|
37
|
+
- BRANCH COVERAGE: 77.95% -- 2832/3633 branches in 43 files
|
|
38
|
+
- 35.42% documented
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- New `clean-blockers` command uninstalls locally installed gems, from any family, whose unreleased versions are pinned by members’ lockfiles. Unlike `clean-unreleased`, which can only remove a family sibling newer than its latest release, it removes cross-family offenders: a kettle-dev member blocked by unreleased StructuredMerge gems, or vice versa.
|
|
43
|
+
|
|
44
|
+
- Add clean-installed: the deterministic inverse of install. Uninstalls each selected member's current source version (plus configured install local_dependencies, which install also covers) when that version is unreleased, including cached .gem files. The other two cleanups cannot see this pollution class: clean-unreleased derives candidates from release state and can miss versions it judges released, and clean-blockers only sees lockfile pins, so an installed-but-unpinned gem (observed: commonmarker-merge 7.1.10) survives and then breaks gem activation with a dangling exact pin once the rest of its series is removed. Release state comes from the same ReleaseStateCheck clean-unreleased uses; members at a released source version are left alone. Also extracts MemberLoader from LocalInstall so install and clean-installed construct members identically.
|
|
45
|
+
|
|
46
|
+
- Add a release-mode pre-clean to bup, bupb, and update-bundler: before a registry-only re-resolution, installed gems whose versions no registry publishes are removed, including cross-family gems named in member lockfiles. Previously the release-mode update only detected such pins after the fact (PublishedVersionCheck) and its advice to re-run the update was circular, because bundler resolves against the local gem dir and >= floors always re-pin the installed unreleased version. Removal up front turns that opaque post-update failure into a resolved-before-it-mattered cleanup. Fails open when the registry is unreachable: a network blip never removes gems. Also adds the standalone clean-installed command, the deterministic inverse of install, which uninstalls each member's unreleased source version (plus install local_dependencies) including cached .gem files.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- [kc] kettle-jem/prepare: updated 2 project files:
|
|
51
|
+
- dependencies (2)
|
|
52
|
+
|
|
53
|
+
- [kc] kettle-jem/template: updated 3 project files:
|
|
54
|
+
- code and tests (1)
|
|
55
|
+
- documentation (1)
|
|
56
|
+
- other (1)
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
|
|
60
|
+
- Add a structured CommandResult#diagnostics channel carrying complete, machine-readable findings. stdout is a human summary truncated to its last 20 lines, so a JSON consumer parsing it to decide what to act on silently acted on an incomplete set: a real cross-family release reported 28 unpublished lockfile pins and the JSON carried only 20, losing 8 gems. PublishedVersionCheck now emits each pin as gem, version, remote, member, and lockfile data alongside its message.
|
|
61
|
+
|
|
62
|
+
- clean-blockers (and clean-unreleased, sharing GemUninstallSupport): two uninstall bugs fixed. The batched gem uninstall now retries once with --ignore-dependencies when RubyGems aborts on a dependent outside the batch (Gem::DependencyRemovalException), re-batching only what survived the abort; the dependency refusal previously left a partial cleanup reported as failure. And the cleanup now removes the cached .gem files as well as the installed specifications -- gem uninstall leaves cache/<name>-<version>.gem behind, and the next bundle install was reinstalling the same unreleased versions from that cache without touching the network, so the cleanup did not hold.
|
|
63
|
+
|
|
64
|
+
- release: stop handing member kettle-release children the family-root dev bundle via BUNDLE_GEMFILE in release mode. That bundle resolves the family's own members, which are unreleased while any member is being released, so the child's kettle-release died booting (observed: "Could not find kettle-family-1.3.9 ... in locally installed gems" for every member). In release mode the child now resolves kettle-release from its own released dependency; local release tooling (KETTLE_DEV_DEV set) still pins the local kettle-dev bundle.
|
|
65
|
+
|
|
66
|
+
## [1.3.7] - 2026-10-09
|
|
67
|
+
|
|
68
|
+
- TAG: [v1.3.7][1.3.7t]
|
|
69
|
+
- COVERAGE: 93.30% -- 6778/7265 lines in 38 files
|
|
70
|
+
- BRANCH COVERAGE: 77.95% -- 2757/3537 branches in 38 files
|
|
71
|
+
- 34.47% documented
|
|
72
|
+
|
|
73
|
+
### Changed
|
|
74
|
+
|
|
75
|
+
- Reject --start-at for template and test instead of silently dropping members. Those commands dispatch in dependency waves and a concurrent queue, so the set of members that still need work is not a suffix of the configured order and no positional value can select it. The flag now exits 1 with a message pointing at the equivalent --only selection. release is deliberately left accepting --start-at: it uses MEMBER@BRANCH to select a branch lane via release_target_branches, and its own resume hints already use --only rather than --start-at. Sequentially dispatched commands (lint, bup, check, docs, install, sync, push, pull, bump) keep positional --start-at, which is correct because they abort at the first failure so pending genuinely is a suffix.
|
|
76
|
+
|
|
77
|
+
- [kc] kettle-jem/prepare: updated 5 project files:
|
|
78
|
+
- dependencies (5)
|
|
79
|
+
|
|
80
|
+
- [kc] kettle-jem/template: updated 2 project files:
|
|
81
|
+
- code and tests (1)
|
|
82
|
+
- other (1)
|
|
83
|
+
|
|
84
|
+
### Fixed
|
|
85
|
+
|
|
86
|
+
- Fix clean-unreleased missing locally installed unreleased versions. Under `bundle exec`, every RubyGems enumeration API (find_all_by_name, stubs_for, Specification.all) reports only the versions the active bundle resolves, not everything installed in the gem home. An unreleased version that was built and installed but is not pinned by the lockfile was therefore invisible, so cleanup reported "no unreleased installed versions found" with outcome success while leaving the offender installed -- blind in exactly the state it exists to correct, since re-resolving the lockfile to released versions is what hides the offender. Installed versions are now read by scanning the specification directories under each Gem.path root, which sees every installed version regardless of bundle activation and still excludes PATH/git source checkouts. Verified against the kettle-dev family: the fix now surfaces 10 unreleased members (kettle-rb 0.1.16, kettle-test 2.0.24, token-resolver 2.0.13, and others) that the previous implementation reported as none, while correctly leaving the two released members (kettle-dev 3.1.8, kettle-family 1.3.6) alone.
|
|
87
|
+
|
|
88
|
+
- Fix clean-unreleased --execute reporting success when gem uninstall removes nothing. RubyGems exits 0 and prints "Gem 'name' is not installed" when the target is absent from GEM_HOME, so an exit-status check could not distinguish a completed cleanup from one that silently did nothing -- the same silent-success failure mode as the enumeration bug. The batch outcome is now verified by re-enumerating installed versions afterwards, and any survivor turns the result into a failure that names it. Investigated and deliberately not implemented: `gem uninstall -i <root>` is not a workaround for a gem home other than GEM_HOME, because it fails with the same "not installed in GEM_HOME" error even when the path equals GEM_HOME. That case already fails loudly with a non-zero exit rather than being reported as success.
|
|
89
|
+
|
|
90
|
+
- Fix the resume hint for template and test silently dropping members that never ran. Those commands do not dispatch positionally: template groups members into dependency waves derived from gemspec dependencies, test uses a concurrent work queue, and in both a failure only sets a stop flag that prevents threads from popping further work -- members already in flight finish while later waves never run. The pending set is therefore interleaved through the configured order rather than being a suffix of it, so the emitted positional `--start-at <failed member>` dropped every pending member ordered ahead of the failure. Measured on the kettle-dev family: nomono failed in template wave 1 and left kettle-dev, kettle-changelog, kettle-family and kettle-soup-cover pending at ordered positions 6, 8, 9 and 11 while kettle-drift at 7 and kettle-wash at 10 had succeeded, so `--start-at nomono` resumed only nomono and token-resolver and reported outcome success having never templated the other four. Resume hints for these commands are now set-based, mirroring release: one `--only` hint per failure plus a combined `--only` hint naming every member that never ran. Sequentially dispatched commands keep positional `--start-at`, which remains correct because they abort at the first failure. A failed family-root phase still restarts the whole command, since it aborts before member dispatch. Note that template and test ignore the configured `release.waves`, which only affect release ordering.
|
|
91
|
+
|
|
33
92
|
## [1.3.6] - 2026-10-07
|
|
34
93
|
|
|
35
94
|
- TAG: [v1.3.6][1.3.6t]
|
|
@@ -2905,7 +2964,11 @@ Please file a bug if you notice a violation of semantic versioning.
|
|
|
2905
2964
|
- Fixed CI load failures on engines without compatible `pty` support by falling back to Open3 for interactive release commands.
|
|
2906
2965
|
- Fixed Ruby 3.2 version-bump support by loading Prism lazily and wiring the Prism gem only for MRI versions that need it.
|
|
2907
2966
|
|
|
2908
|
-
[Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.3.
|
|
2967
|
+
[Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.3.9...HEAD
|
|
2968
|
+
[1.3.9]: https://github.com/kettle-dev/kettle-family/compare/v1.3.7...v1.3.9
|
|
2969
|
+
[1.3.9t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.9
|
|
2970
|
+
[1.3.7]: https://github.com/kettle-dev/kettle-family/compare/v1.3.6...v1.3.7
|
|
2971
|
+
[1.3.7t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.7
|
|
2909
2972
|
[1.3.6]: https://github.com/kettle-dev/kettle-family/compare/v1.3.5...v1.3.6
|
|
2910
2973
|
[1.3.6t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.6
|
|
2911
2974
|
[1.3.5]: https://github.com/kettle-dev/kettle-family/compare/v1.3.4...v1.3.5
|
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.
|
|
1195
|
+
[🧮kloc-img]: https://img.shields.io/badge/KLOC-7.565-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
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "kettle/dev/lockfile_reset"
|
|
4
|
+
|
|
5
|
+
module Kettle
|
|
6
|
+
module Family
|
|
7
|
+
# Removes locally installed gem versions that block a command, including gems
|
|
8
|
+
# belonging to OTHER families.
|
|
9
|
+
#
|
|
10
|
+
# Why this exists alongside +UnreleasedGemCleanup+ (`clean-unreleased`): that
|
|
11
|
+
# command derives candidates from family members' release state, so it can
|
|
12
|
+
# only ever remove a member of its own family whose installed version exceeds
|
|
13
|
+
# the latest release. The gems that actually block a release are usually
|
|
14
|
+
# cross-family, and each family is blocked by the other's legitimately
|
|
15
|
+
# installed development versions. Measured live:
|
|
16
|
+
#
|
|
17
|
+
# kettle-dev 28 pins ast-crispr 7.1.10, ast-merge, tree_haver, ...
|
|
18
|
+
# kettle-drift 22 pins (all structuredmerge gems)
|
|
19
|
+
# kettle-family 28 pins (all structuredmerge gems)
|
|
20
|
+
# kettle-changelog 2 pins nomono 1.1.7, token-resolver 2.0.13
|
|
21
|
+
# kettle-jem 3 pins kettle-test 2.0.24, nomono, token-resolver
|
|
22
|
+
# smorg-rb 3 pins (all kettle-dev gems)
|
|
23
|
+
#
|
|
24
|
+
# Neither family's `clean-unreleased` can see the other's offenders, which is
|
|
25
|
+
# what forced the kettle-dev 3.1.9 and kettle-family 1.3.7 releases to be
|
|
26
|
+
# finished manually after `gem push`: the tag, checksum and GitHub Release
|
|
27
|
+
# steps all run through a release-mode dependency update, and that cannot
|
|
28
|
+
# resolve while cross-family unreleased pins are present.
|
|
29
|
+
#
|
|
30
|
+
# The blocker set comes from calling PublishedVersionCheck directly on each
|
|
31
|
+
# selected member and reading its structured diagnostics, NOT from running the
|
|
32
|
+
# blocked command and parsing its report. A dry-run release cannot surface
|
|
33
|
+
# them: workflow.rb:2522-2526 replaces the member result with a synthetic
|
|
34
|
+
# "release readiness requires lockfile normalization" entry before
|
|
35
|
+
# append_release_internal_checks runs, so published_version_check never
|
|
36
|
+
# appears and every diagnostics array is empty. The findings are computed
|
|
37
|
+
# inside release_lockfile_readiness_would_fail? (workflow.rb:5785-5794) and
|
|
38
|
+
# then discarded in favour of a boolean. Running with --execute to obtain them
|
|
39
|
+
# would run the release, which is what we are trying to unblock.
|
|
40
|
+
#
|
|
41
|
+
# PublishedVersionCheck is read-only, so this is safe to run without
|
|
42
|
+
# --execute; only the uninstall itself is gated.
|
|
43
|
+
class BlockerCleanup
|
|
44
|
+
PHASE = "clean_blockers"
|
|
45
|
+
BLOCKER_KIND = "unpublished_lockfile_pin"
|
|
46
|
+
NO_BLOCKERS_MESSAGE = "no unpublished lockfile pins found"
|
|
47
|
+
# A member in local-gem mode resolves its family siblings through PATH
|
|
48
|
+
# remotes, so its lockfile has no GEM-section specs to check. Reporting
|
|
49
|
+
# "no blockers" for it would claim a clean bill of health that was never
|
|
50
|
+
# established, so it is reported separately.
|
|
51
|
+
NOT_INSPECTABLE_MESSAGE = "lockfile has no registry-resolved pins; nothing inspectable"
|
|
52
|
+
NOT_REMOVABLE_PREFIX = "blockers pinned but not installed in a gem home (source checkout, not removable): "
|
|
53
|
+
|
|
54
|
+
include GemUninstallSupport
|
|
55
|
+
|
|
56
|
+
def initialize(members:, execute: false, runner: nil)
|
|
57
|
+
@members = members
|
|
58
|
+
@execute = execute
|
|
59
|
+
@runner = runner || CommandRunner.new(execute: execute)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Array<CommandResult>]
|
|
63
|
+
def results
|
|
64
|
+
findings = members.map { |member| member_blockers(member) }
|
|
65
|
+
removable = removable_candidates(findings)
|
|
66
|
+
annotated = annotate_contributors(findings, removable)
|
|
67
|
+
informational = informational_results(annotated)
|
|
68
|
+
return informational if removable.empty?
|
|
69
|
+
|
|
70
|
+
informational + batched_cleanup_results(annotated, removable)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
attr_reader :members, :execute, :runner
|
|
76
|
+
|
|
77
|
+
def uninstall_phase
|
|
78
|
+
PHASE
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# One member's blockers, from the check's structured diagnostics.
|
|
82
|
+
#
|
|
83
|
+
# @return [Hash] member, blockers (deduped [name, version, remote]),
|
|
84
|
+
# inspectable (whether the lockfile had registry pins at all)
|
|
85
|
+
def member_blockers(member)
|
|
86
|
+
lockfile = File.join(member.root, "Gemfile.lock")
|
|
87
|
+
return {member: member, blockers: [], inspectable: false} unless File.file?(lockfile)
|
|
88
|
+
|
|
89
|
+
pins = registry_pins(lockfile)
|
|
90
|
+
result = PublishedVersionCheck.call(member: member)
|
|
91
|
+
blockers = result.diagnostics
|
|
92
|
+
.select { |diagnostic| diagnostic["kind"] == BLOCKER_KIND }
|
|
93
|
+
.map { |diagnostic| [diagnostic["gem"], diagnostic["version"], diagnostic["remote"]] }
|
|
94
|
+
.uniq
|
|
95
|
+
|
|
96
|
+
{member: member, blockers: blockers, inspectable: !pins.empty?}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def registry_pins(lockfile)
|
|
100
|
+
Kettle::Dev::LockfileReset.registry_gem_specs_from_source(File.read(lockfile))
|
|
101
|
+
rescue
|
|
102
|
+
[]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Blockers that are actually installed in a gem home and can therefore be
|
|
106
|
+
# removed, deduplicated across members by [name, version].
|
|
107
|
+
#
|
|
108
|
+
# A blocker that is not installed in a gem home is a PATH or git source
|
|
109
|
+
# checkout: `gem uninstall` cannot remove it, and including one would abort
|
|
110
|
+
# the whole batch. Those are reported rather than attempted.
|
|
111
|
+
#
|
|
112
|
+
# The version is normalized by stripping any platform suffix, because a
|
|
113
|
+
# lockfile pins `nokogiri (1.19.4-x86_64-linux-gnu)` while gem homes store
|
|
114
|
+
# the spec as `nokogiri-1.19.4-x86_64-linux-gnu.gemspec` and
|
|
115
|
+
# #installed_versions records it as 1.19.4.
|
|
116
|
+
def removable_candidates(findings)
|
|
117
|
+
findings.flat_map { |finding| normalized_blockers(finding) }
|
|
118
|
+
.uniq
|
|
119
|
+
.select { |name, version| installed_versions(name).include?(version) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A finding's blockers as [name, Gem::Version] pairs, minus any whose version
|
|
123
|
+
# cannot be parsed. Shared by #removable_candidates and #annotate_contributors
|
|
124
|
+
# so both see identical pairs and cannot drift apart.
|
|
125
|
+
def normalized_blockers(finding)
|
|
126
|
+
finding.fetch(:blockers).filter_map do |name, version, _remote|
|
|
127
|
+
normalized = normalize_version(version)
|
|
128
|
+
[name, normalized] unless normalized.nil?
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Strips any platform suffix and parses, rejecting blanks.
|
|
133
|
+
#
|
|
134
|
+
# The blank guard matters: `Gem::Version.new("")` returns version "0" rather
|
|
135
|
+
# than raising, so an empty version from a malformed diagnostic would become
|
|
136
|
+
# a bogus `name:0` uninstall argument instead of being skipped.
|
|
137
|
+
#
|
|
138
|
+
# @return [Gem::Version, nil]
|
|
139
|
+
def normalize_version(version)
|
|
140
|
+
text = version.to_s.split("-", 2).first.to_s.strip
|
|
141
|
+
return nil if text.empty?
|
|
142
|
+
|
|
143
|
+
parse_gem_version(text)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Adds `:own` — the subset of the global removable set this member pinned.
|
|
147
|
+
#
|
|
148
|
+
# Computed once here rather than on demand: three call sites need it
|
|
149
|
+
# (host selection, contributing filter, per-member reporting), and each
|
|
150
|
+
# would otherwise re-run the same normalization and membership test.
|
|
151
|
+
def annotate_contributors(findings, removable)
|
|
152
|
+
findings.map do |finding|
|
|
153
|
+
own = normalized_blockers(finding).select { |candidate| removable.include?(candidate) }.uniq
|
|
154
|
+
finding.merge(own: own)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Per-member reporting. Members that contributed removable blockers get the
|
|
159
|
+
# shared batch outcome; the rest get a plain informational result.
|
|
160
|
+
def informational_results(annotated)
|
|
161
|
+
annotated.filter_map do |finding|
|
|
162
|
+
next if finding.fetch(:own).any?
|
|
163
|
+
|
|
164
|
+
member = finding.fetch(:member)
|
|
165
|
+
message = if finding.fetch(:blockers).any?
|
|
166
|
+
unremovable_blockers_message(finding)
|
|
167
|
+
elsif finding.fetch(:inspectable)
|
|
168
|
+
NO_BLOCKERS_MESSAGE
|
|
169
|
+
else
|
|
170
|
+
NOT_INSPECTABLE_MESSAGE
|
|
171
|
+
end
|
|
172
|
+
informational_result(member: member, message: message)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def contributors(annotated)
|
|
177
|
+
annotated.select { |finding| finding.fetch(:own).any? }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Names the blockers this member pinned that cannot be removed here, and
|
|
181
|
+
# why, so a cross-family offender is attributable rather than mysterious.
|
|
182
|
+
def unremovable_blockers_message(finding)
|
|
183
|
+
names = finding.fetch(:blockers).map { |name, version, _remote| "#{name} #{version}" }
|
|
184
|
+
"#{NOT_REMOVABLE_PREFIX}#{names.join(", ")}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Runs the batch once, then reports that one outcome against every member
|
|
188
|
+
# that contributed to it. See the class comment for why a single batched
|
|
189
|
+
# invocation is required rather than one per gem.
|
|
190
|
+
def batched_cleanup_results(annotated, removable)
|
|
191
|
+
contributing = contributors(annotated)
|
|
192
|
+
# Defensive: `removable` is derived from findings' own blockers, so a
|
|
193
|
+
# non-empty set always has at least one contributor. Guarded anyway
|
|
194
|
+
# because a nil host would raise NoMethodError inside the uninstall.
|
|
195
|
+
host = contributing.first&.fetch(:member)
|
|
196
|
+
return [] if host.nil?
|
|
197
|
+
|
|
198
|
+
command = batched_uninstall_command(removable)
|
|
199
|
+
# gem uninstall does not depend on the working directory, so the first
|
|
200
|
+
# contributing member provides a real directory to execute in.
|
|
201
|
+
outcome = if execute
|
|
202
|
+
verified_uninstall_outcome(candidates: removable, host: host, command: command)
|
|
203
|
+
else
|
|
204
|
+
dry_run_uninstall_outcome(command: command)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
contributing.map { |finding| member_result(finding, outcome) }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# One member's view of the shared batch outcome, naming the gems that member
|
|
211
|
+
# pinned so a failure is attributable rather than mysterious.
|
|
212
|
+
#
|
|
213
|
+
# The member's own contribution comes from the annotated finding:
|
|
214
|
+
# PublishedVersionCheck hits the registry, so re-invoking it during
|
|
215
|
+
# reporting would add a network round-trip per member.
|
|
216
|
+
def member_result(finding, outcome)
|
|
217
|
+
member = finding.fetch(:member)
|
|
218
|
+
own = finding.fetch(:own)
|
|
219
|
+
own_reason = describe_gem_versions(own)
|
|
220
|
+
reason = if outcome.ok? || own.empty?
|
|
221
|
+
outcome.reason
|
|
222
|
+
else
|
|
223
|
+
"batched gem uninstall including #{own_reason} failed"
|
|
224
|
+
end
|
|
225
|
+
outcome.dup.tap do |copy|
|
|
226
|
+
copy.member_name = member.name
|
|
227
|
+
copy.workdir = member.root
|
|
228
|
+
copy.stdout = outcome.skipped ? "would uninstall #{own_reason}" : outcome.stdout
|
|
229
|
+
copy.reason = reason
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def informational_result(member:, message:)
|
|
234
|
+
CommandResult.new(
|
|
235
|
+
member_name: member.name,
|
|
236
|
+
phase: PHASE,
|
|
237
|
+
command: ["internal", PHASE],
|
|
238
|
+
workdir: member.root,
|
|
239
|
+
status: 0,
|
|
240
|
+
success: true,
|
|
241
|
+
stdout: message,
|
|
242
|
+
stderr: "",
|
|
243
|
+
elapsed_seconds: 0.0,
|
|
244
|
+
skipped: false,
|
|
245
|
+
reason: nil
|
|
246
|
+
)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kettle
|
|
4
|
+
module Family
|
|
5
|
+
# Uninstalls exactly what `install` installs: each selected member's current
|
|
6
|
+
# source version, plus the cached .gem files.
|
|
7
|
+
#
|
|
8
|
+
# This is the deterministic inverse of `install`, and it exists because the
|
|
9
|
+
# other two cleanups cannot see this class of pollution:
|
|
10
|
+
#
|
|
11
|
+
# - +UnreleasedGemCleanup+ (`clean-unreleased`) removes installed versions
|
|
12
|
+
# newer than the latest release, but it derives candidates per member from
|
|
13
|
+
# release state and can miss versions it judges released.
|
|
14
|
+
# - +BlockerCleanup+ (`clean-blockers`) removes what members' lockfiles pin,
|
|
15
|
+
# so an installed-but-unpinned gem is invisible to it. Observed live:
|
|
16
|
+
# commonmarker-merge 7.1.10 survived a blocker cleanup (no release
|
|
17
|
+
# lockfile pinned it), then broke gem activation family-wide with its
|
|
18
|
+
# dangling exact pin on markdown-merge = 7.1.10 once the rest of the
|
|
19
|
+
# series was removed.
|
|
20
|
+
#
|
|
21
|
+
# Candidates are the members `install` would act on -- the selected members
|
|
22
|
+
# plus any configured install local_dependencies -- restricted to those whose
|
|
23
|
+
# source version is unreleased (a member already at its released version has
|
|
24
|
+
# nothing to remove; uninstalling it would only churn the bundles that
|
|
25
|
+
# resolve it). Release state comes from the same ReleaseStateCheck
|
|
26
|
+
# +UnreleasedGemCleanup+ uses.
|
|
27
|
+
#
|
|
28
|
+
# The uninstall itself is the shared GemUninstallSupport batch: one
|
|
29
|
+
# invocation, a documented --ignore-dependencies retry when an out-of-batch
|
|
30
|
+
# dependent aborts it, cache removal, and post-uninstall re-enumeration.
|
|
31
|
+
#
|
|
32
|
+
# Note for the family running it: when this command is executed from an
|
|
33
|
+
# installed kettle-family, the batch includes kettle-family itself. The
|
|
34
|
+
# uninstall runs in a subprocess, so self-removal mid-run is safe; the
|
|
35
|
+
# executable falls back to the latest released install afterwards.
|
|
36
|
+
class CleanInstalled
|
|
37
|
+
PHASE = "clean_installed"
|
|
38
|
+
RELEASED_MESSAGE = "source version is released; nothing to uninstall"
|
|
39
|
+
NOT_INSTALLED_MESSAGE = "source version is not installed"
|
|
40
|
+
UNKNOWN_VERSION_MESSAGE = "source version is unknown; nothing to uninstall"
|
|
41
|
+
UNKNOWN_RELEASED_MESSAGE = "latest released version is unknown; no cleanup attempted"
|
|
42
|
+
|
|
43
|
+
include GemUninstallSupport
|
|
44
|
+
include MemberLoader
|
|
45
|
+
|
|
46
|
+
def initialize(config:, members:, execute: false, runner: nil)
|
|
47
|
+
@config = config
|
|
48
|
+
@members = members
|
|
49
|
+
@execute = execute
|
|
50
|
+
@runner = runner || CommandRunner.new(execute: execute)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Array<CommandResult>]
|
|
54
|
+
def results
|
|
55
|
+
findings = member_findings
|
|
56
|
+
removable = findings.filter_map { |finding| finding[:candidate] }.uniq
|
|
57
|
+
annotated = findings.map do |finding|
|
|
58
|
+
candidate = finding[:candidate]
|
|
59
|
+
own = (candidate && removable.include?(candidate)) ? [candidate] : []
|
|
60
|
+
finding.merge(own: own)
|
|
61
|
+
end
|
|
62
|
+
informational = annotated.filter_map { |finding| informational_for(finding) }
|
|
63
|
+
return informational if removable.empty?
|
|
64
|
+
|
|
65
|
+
informational + batched_cleanup_results(annotated.select { |finding| finding[:own].any? }, removable)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
attr_reader :config, :members, :execute, :runner
|
|
71
|
+
|
|
72
|
+
def uninstall_phase
|
|
73
|
+
PHASE
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The members `install` would act on: selected members plus configured
|
|
77
|
+
# install local_dependencies, deduplicated by name.
|
|
78
|
+
def candidate_members
|
|
79
|
+
local = config.install_local_dependencies.map { |path| member_from_path(path) }
|
|
80
|
+
seen = {}
|
|
81
|
+
(local + members).each_with_object([]) do |member, memo|
|
|
82
|
+
next if seen.key?(member.name)
|
|
83
|
+
|
|
84
|
+
seen[member.name] = true
|
|
85
|
+
memo << member
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def release_state_by_member
|
|
90
|
+
ReleaseStateCheck.new(config: config, members: candidate_members).results.each_with_object({}) do |result, memo|
|
|
91
|
+
memo[result.member_name] = result
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# One member's candidate: its source version, when that version is
|
|
96
|
+
# unreleased and installed. [name, Gem::Version] or nil, plus the reason
|
|
97
|
+
# there is nothing to do.
|
|
98
|
+
def member_findings
|
|
99
|
+
states = release_state_by_member
|
|
100
|
+
candidate_members.map do |member|
|
|
101
|
+
state = states[member.name]
|
|
102
|
+
candidate, message = candidate_for(member, state)
|
|
103
|
+
{member: member, candidate: candidate, message: message, state: state}
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def candidate_for(member, state)
|
|
108
|
+
return [nil, nil] unless state&.ok?
|
|
109
|
+
|
|
110
|
+
latest = latest_released_version(state.state)
|
|
111
|
+
return [nil, nil] unless latest
|
|
112
|
+
|
|
113
|
+
source = parse_gem_version(member.version.to_s)
|
|
114
|
+
return [nil, UNKNOWN_VERSION_MESSAGE] unless source
|
|
115
|
+
return [nil, RELEASED_MESSAGE] unless source > latest
|
|
116
|
+
return [[member.name, source], nil] if installed_versions(member.name).include?(source)
|
|
117
|
+
|
|
118
|
+
[nil, NOT_INSTALLED_MESSAGE]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def latest_released_version(state)
|
|
122
|
+
value = state.fetch("latest_released", nil).to_s
|
|
123
|
+
return nil if value.empty? || value == "unknown"
|
|
124
|
+
|
|
125
|
+
Gem::Version.new(value.delete_prefix("v"))
|
|
126
|
+
rescue ArgumentError
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Per-member outcomes that are not part of the batch: unreadable release
|
|
131
|
+
# state (a failure naming its member), or nothing to do (informational).
|
|
132
|
+
def informational_for(finding)
|
|
133
|
+
member = finding.fetch(:member)
|
|
134
|
+
state = finding[:state]
|
|
135
|
+
return release_state_failure(member: member, release_state: state) unless state&.ok?
|
|
136
|
+
|
|
137
|
+
message = finding[:message]
|
|
138
|
+
return nil if message.nil? && finding[:own].any?
|
|
139
|
+
return informational_result(member: member, message: UNKNOWN_RELEASED_MESSAGE) if message.nil?
|
|
140
|
+
|
|
141
|
+
informational_result(member: member, message: message)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Runs the batch once, then reports that one outcome against every member
|
|
145
|
+
# whose gem was in it, mirroring UnreleasedGemCleanup: the command and
|
|
146
|
+
# exit state are shared, but each member reports only its own gem so a
|
|
147
|
+
# failure stays attributable.
|
|
148
|
+
def batched_cleanup_results(contributors, removable)
|
|
149
|
+
host = contributors.first.fetch(:member)
|
|
150
|
+
command = batched_uninstall_command(removable)
|
|
151
|
+
outcome = if execute
|
|
152
|
+
verified_uninstall_outcome(candidates: removable, host: host, command: command)
|
|
153
|
+
else
|
|
154
|
+
dry_run_uninstall_outcome(command: command)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
contributors.map do |finding|
|
|
158
|
+
member_result(finding.fetch(:member), outcome, finding.fetch(:own))
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def member_result(member, outcome, own_candidates)
|
|
163
|
+
own = describe_gem_versions(own_candidates)
|
|
164
|
+
reason = if outcome.ok? || own_candidates.empty?
|
|
165
|
+
outcome.reason
|
|
166
|
+
else
|
|
167
|
+
"batched gem uninstall including #{own} failed"
|
|
168
|
+
end
|
|
169
|
+
outcome.dup.tap do |copy|
|
|
170
|
+
copy.member_name = member.name
|
|
171
|
+
copy.workdir = member.root
|
|
172
|
+
copy.stdout = outcome.skipped ? "would uninstall #{own}" : outcome.stdout
|
|
173
|
+
copy.reason = reason
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def informational_result(member:, message:)
|
|
178
|
+
CommandResult.new(
|
|
179
|
+
member_name: member.name,
|
|
180
|
+
phase: PHASE,
|
|
181
|
+
command: ["internal", PHASE],
|
|
182
|
+
workdir: member.root,
|
|
183
|
+
status: 0,
|
|
184
|
+
success: true,
|
|
185
|
+
stdout: message,
|
|
186
|
+
stderr: "",
|
|
187
|
+
elapsed_seconds: 0.0,
|
|
188
|
+
skipped: false,
|
|
189
|
+
reason: nil
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def release_state_failure(member:, release_state:)
|
|
194
|
+
CommandResult.new(
|
|
195
|
+
member_name: member.name,
|
|
196
|
+
phase: PHASE,
|
|
197
|
+
command: ["internal", PHASE],
|
|
198
|
+
workdir: member.root,
|
|
199
|
+
status: release_state&.status || 1,
|
|
200
|
+
success: false,
|
|
201
|
+
stdout: "",
|
|
202
|
+
stderr: release_state&.stderr.to_s,
|
|
203
|
+
elapsed_seconds: 0.0,
|
|
204
|
+
skipped: false,
|
|
205
|
+
reason: "release state unavailable"
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
data/lib/kettle/family/cli.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Kettle
|
|
|
11
11
|
class CLI < CommandKit::Command
|
|
12
12
|
include CommandKit::Commands
|
|
13
13
|
|
|
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
|
|
14
|
+
COMMANDS = %w[version mise-trust discover plan report metadata check clean-blockers clean-installed 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
15
|
WORKFLOW_COMMANDS = %w[check reset test lint docs template gha-sha-pins bup bupb update-bundler bex release push pull sync up].freeze
|
|
16
16
|
|
|
17
17
|
command_name "kettle-family"
|
|
@@ -353,6 +353,18 @@ module Kettle
|
|
|
353
353
|
description "Uninstall locally installed family gem versions newer than the latest released version."
|
|
354
354
|
end
|
|
355
355
|
|
|
356
|
+
class CleanBlockers < WorkflowCommand
|
|
357
|
+
command_name "clean-blockers"
|
|
358
|
+
usage "[options]"
|
|
359
|
+
description "Uninstall locally installed gems, from any family, whose unreleased versions are pinned by members' lockfiles."
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
class CleanInstalled < WorkflowCommand
|
|
363
|
+
command_name "clean-installed"
|
|
364
|
+
usage "[options]"
|
|
365
|
+
description "Uninstall the exact gem versions install installs: each member's current source version, when unreleased, including cached .gem files."
|
|
366
|
+
end
|
|
367
|
+
|
|
356
368
|
class ReconcileReleases < BaseCommand
|
|
357
369
|
include ExecutionOptions
|
|
358
370
|
|
|
@@ -657,6 +669,8 @@ module Kettle
|
|
|
657
669
|
command "report", ReportCommand
|
|
658
670
|
command Metadata
|
|
659
671
|
command Check
|
|
672
|
+
command CleanBlockers
|
|
673
|
+
command CleanInstalled
|
|
660
674
|
command CleanUnreleased
|
|
661
675
|
command ReconcileReleases
|
|
662
676
|
command Reset
|
|
@@ -715,6 +729,7 @@ module Kettle
|
|
|
715
729
|
def build_report(command, options)
|
|
716
730
|
config = Config.load(root: options[:root], path: options[:config])
|
|
717
731
|
start_at = parse_start_at(options[:start_at])
|
|
732
|
+
Selection.validate_positional_start_at!(command, start_at.member)
|
|
718
733
|
effective_only = default_only_filter(command: command, only: options[:only])
|
|
719
734
|
discovery = Discovery.new(
|
|
720
735
|
config: config,
|
|
@@ -869,6 +884,8 @@ module Kettle
|
|
|
869
884
|
return bump_version_results(config: config, members: members, options: options, phase: command, release_state_results: release_state_results) if %w[bump bump-version].include?(command)
|
|
870
885
|
return add_changelog_results(members: members, options: options) if command == "add-changelog"
|
|
871
886
|
return clean_unreleased_results(config: config, members: members, options: options) if command == "clean-unreleased"
|
|
887
|
+
return clean_blockers_results(members: members, options: options) if command == "clean-blockers"
|
|
888
|
+
return clean_installed_results(config: config, members: members, options: options) if command == "clean-installed"
|
|
872
889
|
return reconcile_release_results(config: config, members: members, options: options) if command == "reconcile-releases"
|
|
873
890
|
return branch_lane_results(config: config, members: members) if command == "branch-lanes"
|
|
874
891
|
return release_state_results(config: config, members: members, jobs: options[:jobs], event_handler: state_event_handler) if command == "release-state"
|
|
@@ -1360,6 +1377,14 @@ module Kettle
|
|
|
1360
1377
|
UnreleasedGemCleanup.new(config: config, members: members, execute: options[:execute]).results
|
|
1361
1378
|
end
|
|
1362
1379
|
|
|
1380
|
+
def clean_blockers_results(members:, options:)
|
|
1381
|
+
BlockerCleanup.new(members: members, execute: options[:execute]).results
|
|
1382
|
+
end
|
|
1383
|
+
|
|
1384
|
+
def clean_installed_results(config:, members:, options:)
|
|
1385
|
+
CleanInstalled.new(config: config, members: members, execute: options[:execute]).results
|
|
1386
|
+
end
|
|
1387
|
+
|
|
1363
1388
|
def reconcile_release_results(config:, members:, options:)
|
|
1364
1389
|
ReleaseReconciler.new(config: config, members: members, execute: options[:execute]).results
|
|
1365
1390
|
end
|