gemchain 0.2.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cfa66ea15e541d38928f3f19469437e021e4e40566af6dbc68c17bdda991c986
4
+ data.tar.gz: c9870c9615acd4f464302d282bf50ba722f098d5ed6e4e1da3c4ef60c46ab25e
5
+ SHA512:
6
+ metadata.gz: 0e4133d289e5b13d797ec17a8b57093e1cb0a4d74b5b65c865f9b7317d9e6714461e2b7d15cd873cbf0890fec66ec8cb7b1bd3c5290ebe247ffd63ea9c8bc9f4
7
+ data.tar.gz: 0e00b7f943cf3ba4f39f831eebe8b128a4a5e6582d101f9b69cf46663ebfe8b639d74a858eb39921aa6d4c31eb7b336c6b8492a3aa016e15aca7389559611a70
data/CHANGELOG.md ADDED
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0] — 2026-08-10
4
+
5
+ ### Added
6
+
7
+ - **Cascade execution pipeline** — `gemchain update` now executes the plan
8
+ instead of printing it:
9
+ - `:bump` — rewrites the `VERSION` constant (quote style preserved,
10
+ idempotent)
11
+ - `:update_dependency` — rewrites the gemspec constraint
12
+ (`add_dependency`/`add_runtime_dependency`, both quote styles, adds a
13
+ constraint when missing)
14
+ - `:test` — `bundle install` + `bundle update <gem>` + `bundle exec rake
15
+ test` in each gem dir
16
+ - `:release` — patch/minor/major bump for dependents (configurable via
17
+ `bump:` in cascade.yml), git commit, `gem build`, `gem push`, git tag,
18
+ push — with a [y/N] confirmation per release unless `--yes` is given
19
+ - Source gem: if it isn't already at the target version, it is bumped,
20
+ tested, and released first; if it is (post-release flow), only
21
+ dependents are processed
22
+ - Any test failure stops the cascade immediately and reports; nothing
23
+ after the failure is touched
24
+ - `--test-only` flag — run dependents' test suites only, editing or
25
+ releasing nothing
26
+ - Executor is fully injectable (command runner, confirmation, output), so
27
+ the whole pipeline is tested without touching bundler, git, or RubyGems
28
+
29
+ ## [0.1.0] — 2026-07-26
30
+
31
+ ### Added
32
+
33
+ - Initial release
34
+ - `gemchain check` — visualize gem ecosystem and dependencies
35
+ - `gemchain guard <gem>` — pre-release warnings about dependents
36
+ - `gemchain update <gem> <version>` — cascade plan with dry-run support
37
+ - `gemchain link` — generate Gemfile path references for local development
38
+ - Config via `cascade.yml` with glob include/exclude and explicit gem paths
39
+ - Dependency graph with topological sort and cycle detection
40
+ - Full test suite
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaka Ruto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # gemchain
2
+
3
+ **Cascade gem updates through interdependent gem ecosystems.**
4
+
5
+ When you maintain multiple gems that depend on each other, releasing an update
6
+ to one gem means testing and releasing every gem that depends on it. Gemchain
7
+ automates this — it warns you before you release, and can cascade updates
8
+ through the entire dependency chain.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ gem install gemchain
14
+ ```
15
+
16
+ Or add to your Gemfile:
17
+
18
+ ```ruby
19
+ gem "gemchain"
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ### 1. Create a config file
25
+
26
+ ```yaml
27
+ # cascade.yml
28
+ workspace: .
29
+ include: ask-*
30
+ ```
31
+
32
+ Or specify gems explicitly:
33
+
34
+ ```yaml
35
+ # cascade.yml
36
+ workspace: .
37
+ gems:
38
+ ask-core: ./ask-core
39
+ ask-agent: ./ask-agent
40
+ ```
41
+
42
+ ### 2. Check your ecosystem
43
+
44
+ ```bash
45
+ gemchain check
46
+ ```
47
+
48
+ Shows every gem, its version, its dependencies, and how many gems depend on it.
49
+
50
+ ### 3. Guard a release
51
+
52
+ ```bash
53
+ gemchain guard ask-core
54
+ ```
55
+
56
+ ```
57
+ ⚠ Pre-release Guard: ask-core v0.7.0
58
+
59
+ 7 gem(s) directly depend on ask-core:
60
+ • ask-llm-providers (0.10.0) — requires: ask-core >= 0.1
61
+ • ask-state-providers (0.3.0) — requires: ask-core >= 0.1
62
+ ...
63
+
64
+ Before releasing ask-core, make sure these gems still work:
65
+ ⬜ ask-llm-providers (0.10.0)
66
+ ⬜ ask-state-providers (0.3.0)
67
+ ...
68
+ ```
69
+
70
+ ### 4. Cascade an update
71
+
72
+ ```bash
73
+ gemchain update ask-core 0.8.0 --dry-run # show the plan, change nothing
74
+ gemchain update ask-core 0.8.0 --test-only # run dependents' suites, change nothing
75
+ gemchain update ask-core 0.8.0 # execute: bump, test, release in order
76
+ gemchain update ask-core 0.8.0 --yes # skip per-release confirmations
77
+ ```
78
+
79
+ Execution is end to end: if the source gem isn't already at the target
80
+ version it is bumped, tested, and released first (dependents resolve the new
81
+ version from RubyGems); then each dependent gets its constraint updated,
82
+ tested, and released in topological order — commit, `gem build`, `gem push`,
83
+ git tag, push. Every release asks for confirmation on stdin unless `--yes`
84
+ is given. If a test suite fails, the cascade stops immediately and reports —
85
+ nothing after the failure is touched.
86
+
87
+ If the source gem is already released at the target version (the common
88
+ post-release flow — `gemchain update ask-tools-shell 0.5.1` right after
89
+ releasing it), only the dependents are processed.
90
+
91
+ ### 5. Generate local dev links
92
+
93
+ ```bash
94
+ gemchain link
95
+ ```
96
+
97
+ Prints Gemfile content to add `path:` references for all workspace gems.
98
+
99
+ ## Configuration
100
+
101
+ ### `cascade.yml` options
102
+
103
+ | Option | Description |
104
+ |---|---|
105
+ | `workspace` | Base directory for relative paths |
106
+ | `include` | Glob pattern(s) for gem directories |
107
+ | `exclude` | Glob pattern(s) to exclude |
108
+ | `gems` | Explicit gem name → path mappings |
109
+ | `bump` | Version bump level for dependent releases: `patch`, `minor`, or `major` (default `patch`) |
110
+
111
+ ## How it works
112
+
113
+ 1. Discovers gems in your workspace (via glob patterns or explicit paths)
114
+ 2. Loads each gem's gemspec to build a dependency graph
115
+ 3. When you check or guard a gem, it shows all dependents
116
+ 4. When you cascade, the executor runs the plan in topological order:
117
+ - Rewrite the version constraint in the dependent's gemspec
118
+ - `bundle install`, `bundle update <gem>`, and `bundle exec rake test`
119
+ - If tests pass: bump the dependent's version, commit, `gem build`,
120
+ `gem push`, git tag, push
121
+ - If tests fail: stop and report — nothing after the failure is touched
122
+
123
+ ## Development
124
+
125
+ ```bash
126
+ git clone https://github.com/ask-rb/gemchain
127
+ cd gemchain
128
+ bundle install
129
+ bundle exec rake test
130
+ ```
131
+
132
+ ## License
133
+
134
+ MIT
data/exe/gemchain ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/gemchain"
5
+
6
+ Gemchain::CLI.start
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemchain
4
+ class Cascade
5
+ Result = Struct.new(:success, :message, :details, keyword_init: true)
6
+
7
+ def initialize(workspace)
8
+ @workspace = workspace
9
+ end
10
+
11
+ # Pre-release guard: warns about gems that depend on +gem_name+.
12
+ # Returns a hash with dependency info, or nil if gem not found.
13
+ def guard(gem_name)
14
+ gem = @workspace.find(gem_name)
15
+ return nil unless gem
16
+
17
+ direct = @workspace.dependents_of(gem_name)
18
+ transitive = @workspace.transitive_dependents(gem_name)
19
+
20
+ {
21
+ gem: gem,
22
+ direct_dependents: direct.size,
23
+ dependents: direct,
24
+ transitive_dependents: transitive,
25
+ all_downstream: transitive.map { |n| @workspace.find(n) }.compact
26
+ }
27
+ end
28
+
29
+ # Returns a full ecosystem check.
30
+ def check
31
+ gems_info = @workspace.gems.map do |gem|
32
+ deps = @workspace.dependents_of(gem.name)
33
+ {
34
+ name: gem.name,
35
+ version: gem.version.to_s,
36
+ path: gem.path,
37
+ dependencies: gem.dependencies,
38
+ dependents: deps.map(&:name),
39
+ dependent_count: deps.size
40
+ }
41
+ end
42
+
43
+ {gems: gems_info}
44
+ end
45
+
46
+ # Performs the full cascade update:
47
+ # 1. If the source gem isn't already at +new_version+: bump, test, release it
48
+ # 2. For each dependent (topological order): update constraint, test, bump, release
49
+ #
50
+ # With test_only: true, only the dependents' :test steps are emitted —
51
+ # verify compatibility without editing or releasing anything.
52
+ def update(gem_name, new_version, dry_run: false, test_only: false)
53
+ gem = @workspace.find(gem_name)
54
+ return Result.new(success: false, message: "Gem '#{gem_name}' not found in workspace") unless gem
55
+
56
+ unless new_version.match?(/\A\d+\.\d+\.\d+\z/)
57
+ return Result.new(success: false, message: "Invalid version: #{new_version.inspect} — expected X.Y.Z")
58
+ end
59
+
60
+ steps = []
61
+ order = @workspace.cascade_order(gem_name)
62
+
63
+ if !test_only && gem.version.to_s != new_version
64
+ # The source must be released before dependents' `bundle update`
65
+ # can resolve the new version from RubyGems.
66
+ steps << {action: :bump, gem: gem_name, version: new_version}
67
+ steps << {action: :test, gem: gem_name}
68
+ steps << {action: :release, gem: gem_name, version: new_version}
69
+ end
70
+
71
+ order.each do |dep_name|
72
+ next if dep_name == gem_name
73
+ dep_gem = @workspace.find(dep_name)
74
+ next unless dep_gem
75
+ next unless dep_gem.dependencies.key?(gem_name)
76
+
77
+ if test_only
78
+ steps << {action: :test, gem: dep_name, dependency: gem_name}
79
+ else
80
+ steps << {action: :update_dependency, gem: dep_name, dependency: gem_name, constraint: ">= #{new_version}"}
81
+ steps << {action: :test, gem: dep_name, dependency: gem_name}
82
+ steps << {action: :release, gem: dep_name}
83
+ end
84
+ end
85
+
86
+ {steps: steps, dry_run: dry_run, test_only: test_only}
87
+ end
88
+
89
+ # Generates Gemfile content with local path references for all workspace gems.
90
+ def link_content(workspace: nil)
91
+ workspace ||= Dir.pwd
92
+ lines = []
93
+ lines << "# Auto-generated by gemchain — local development path references"
94
+ lines << "# Replace in your Gemfile to test against local copies"
95
+ lines << ""
96
+ lines << "# Add this block to your Gemfile:"
97
+ lines << "ask_rb_root = File.expand_path(\"..\", __dir__)" if workspace
98
+ lines << ""
99
+
100
+ gem_names = @workspace.gems.map(&:name).sort
101
+ lines << "%w[#{gem_names.join(' ')}].each do |gem_name|"
102
+ lines << " gem gem_name, path: File.join(ask_rb_root, gem_name)"
103
+ lines << "end"
104
+ lines << ""
105
+
106
+ lines.join("\n")
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Gemchain
6
+ class CLI < Thor
7
+ CONFIG_FILENAME = "cascade.yml"
8
+
9
+ desc "check", "Show the gem ecosystem dependency graph and constraint status"
10
+ def check
11
+ workspace = load_workspace
12
+ return unless workspace
13
+
14
+ cascade = Cascade.new(workspace)
15
+ result = cascade.check
16
+
17
+ puts "\n📊 Gem Ecosystem Status\n#{'=' * 50}"
18
+
19
+ result[:gems].each do |info|
20
+ dep_count = info[:dependent_count]
21
+ icon = dep_count > 0 ? "⤷" : " "
22
+ puts "\n#{info[:name]} (#{info[:version]}) #{icon} #{dep_count} #{dep_count == 1 ? 'dependent' : 'dependents'}"
23
+
24
+ info[:dependencies].each do |dep_name, constraint|
25
+ puts " #{dep_name} (#{constraint})"
26
+ end
27
+ end
28
+
29
+ total = result[:gems].size
30
+ puts "\n#{'─' * 50}"
31
+ puts "#{total} #{total == 1 ? 'gem' : 'gems'} in workspace\n\n"
32
+ end
33
+
34
+ desc "guard GEM_NAME", "Check what depends on a gem before releasing it"
35
+ def guard(gem_name = nil)
36
+ unless gem_name
37
+ puts "Usage: gemchain guard GEM_NAME"
38
+ puts " e.g. gemchain guard ask-core"
39
+ return
40
+ end
41
+
42
+ workspace = load_workspace
43
+ return unless workspace
44
+
45
+ cascade = Cascade.new(workspace)
46
+ result = cascade.guard(gem_name)
47
+
48
+ unless result
49
+ puts "✖ Gem '#{gem_name}' not found in workspace."
50
+ return
51
+ end
52
+
53
+ gem = result[:gem]
54
+ puts "\n⚠ Pre-release Guard: #{gem.name} v#{gem.version}\n#{'=' * 50}"
55
+
56
+ if result[:dependents].empty?
57
+ puts " No gems in the workspace depend on #{gem.name}."
58
+ puts " Safe to release without cascade."
59
+ else
60
+ puts " #{result[:direct_dependents]} gem(s) directly depend on #{gem.name}:"
61
+ result[:dependents].each do |dep|
62
+ deps_str = dep.dependencies.map { |n, c| "#{n} #{c}" }.join(", ")
63
+ puts " • #{dep.name} (#{dep.version}) — requires: #{deps_str}"
64
+ end
65
+
66
+ puts "\n Before releasing #{gem.name}, make sure these gems still work:"
67
+ result[:all_downstream].each do |g|
68
+ puts " ⬜ #{g.name} (#{g.version})"
69
+ end
70
+
71
+ puts "\n Run: gemchain update #{gem_name} <new-version>"
72
+ puts " to cascade the update automatically."
73
+ end
74
+ puts
75
+ end
76
+
77
+ desc "update GEM_NAME NEW_VERSION", "Cascade a gem update through all dependents"
78
+ method_option :"dry-run", type: :boolean, aliases: "-n", desc: "Show what would be done without doing it"
79
+ method_option :yes, type: :boolean, aliases: "-y", desc: "Skip release confirmations"
80
+ method_option :"test-only", type: :boolean, desc: "Run dependent test suites without editing or releasing"
81
+ def update(gem_name = nil, new_version = nil)
82
+ unless gem_name && new_version
83
+ puts "Usage: gemchain update GEM_NAME NEW_VERSION"
84
+ puts " e.g. gemchain update ask-core 0.8.0"
85
+ puts " e.g. gemchain update ask-core 0.8.0 --dry-run"
86
+ puts " e.g. gemchain update ask-core 0.8.0 --test-only"
87
+ return
88
+ end
89
+
90
+ dry_run = !!options[:"dry-run"]
91
+ test_only = !!options[:"test-only"]
92
+ workspace = load_workspace
93
+ return unless workspace
94
+
95
+ cascade = Cascade.new(workspace)
96
+ plan = cascade.update(gem_name, new_version, dry_run: dry_run, test_only: test_only)
97
+
98
+ unless plan[:success] != false
99
+ puts "✖ #{plan[:message]}"
100
+ return
101
+ end
102
+
103
+ puts "\n📋 Cascade Plan for #{gem_name} → #{new_version}\n#{'=' * 50}"
104
+
105
+ if dry_run
106
+ puts " (dry run — no changes will be made)\n"
107
+ render_steps(plan[:steps])
108
+ puts "\n ✅ Dry run complete. Run without --dry-run to execute."
109
+ else
110
+ executor = Executor.new(workspace: workspace, steps: plan[:steps],
111
+ yes: options[:yes], bump: workspace.config.bump)
112
+ result = executor.run
113
+
114
+ if result.success
115
+ puts "\n ✅ Released: #{result.released.join(', ')}" unless result.released.empty?
116
+ puts " ⏭ Skipped: #{result.skipped.join(', ')}" unless result.skipped.empty?
117
+ if result.released.empty? && result.skipped.empty?
118
+ puts "\n No releases needed — dependents verified."
119
+ else
120
+ puts "\n Cascade complete."
121
+ end
122
+ else
123
+ gem, message = result.failed.first
124
+ puts "\n ✖ Cascade stopped at #{gem}:"
125
+ puts " #{message}"
126
+ puts " Released so far: #{result.released.join(', ')}" unless result.released.empty?
127
+ end
128
+ end
129
+ puts
130
+ end
131
+
132
+ desc "link", "Generate Gemfile path references for local development"
133
+ def link
134
+ workspace = load_workspace
135
+ return unless workspace
136
+
137
+ cascade = Cascade.new(workspace)
138
+ content = cascade.link_content(workspace: Dir.pwd)
139
+
140
+ puts "\n🔗 Gemfile Local Path References\n#{'=' * 50}"
141
+ puts
142
+ puts content
143
+ puts
144
+ puts "Add the block above to each gem's Gemfile to use local copies"
145
+ puts "during development instead of published versions."
146
+ puts
147
+ end
148
+
149
+ desc "version", "Show gemchain version"
150
+ def version
151
+ puts "gemchain #{Gemchain::VERSION}"
152
+ end
153
+
154
+ no_commands do
155
+ def render_steps(steps)
156
+ steps.each do |step|
157
+ case step[:action]
158
+ when :bump
159
+ puts " 1. Bump #{step[:gem]} from current → #{step[:version]}"
160
+ when :update_dependency
161
+ puts " #{'─' * 40}"
162
+ puts " #{step[:gem]}: update #{step[:dependency]} constraint to #{step[:constraint]}"
163
+ when :test
164
+ puts " #{step[:gem]}: bundle exec rake test"
165
+ when :release
166
+ puts " #{step[:gem]}: bump version & release"
167
+ end
168
+ end
169
+ end
170
+
171
+ def load_workspace
172
+ config_path = find_config
173
+ unless config_path
174
+ puts "No cascade.yml found in current directory."
175
+ puts "Create one with:"
176
+ puts " workspace: ."
177
+ puts " include: ask-*"
178
+ puts " # or specify gems explicitly:"
179
+ puts " # gems:"
180
+ puts " # ask-core: ./ask-core"
181
+ return nil
182
+ end
183
+
184
+ config = Config.load(config_path)
185
+ Workspace.new(config)
186
+ rescue Config::NotFoundError => e
187
+ puts "✖ #{e.message}"
188
+ nil
189
+ end
190
+
191
+ def find_config
192
+ path = File.join(Dir.pwd, CONFIG_FILENAME)
193
+ return path if File.exist?(path)
194
+
195
+ # Also check parent directories
196
+ dir = Dir.pwd
197
+ loop do
198
+ parent = File.dirname(dir)
199
+ break if parent == dir
200
+
201
+ path = File.join(parent, CONFIG_FILENAME)
202
+ return path if File.exist?(path)
203
+
204
+ dir = parent
205
+ end
206
+
207
+ nil
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Gemchain
6
+ class Config
7
+ class NotFoundError < Gemchain::Error; end
8
+
9
+ attr_reader :workspace, :include_patterns, :exclude_patterns, :gems, :bump
10
+
11
+ def initialize(workspace: Dir.pwd, include: [], exclude: [], gems: {}, bump: :patch)
12
+ @workspace = File.expand_path(workspace)
13
+ @include_patterns = [include].flatten.compact
14
+ @exclude_patterns = [exclude].flatten.compact
15
+ @gems = gems
16
+ @bump = (bump || :patch).to_sym
17
+ end
18
+
19
+ def self.load(path)
20
+ path = File.expand_path(path)
21
+ raise NotFoundError, "Config file not found: #{path}" unless File.exist?(path)
22
+
23
+ data = YAML.safe_load(File.read(path), permitted_classes: [Regexp]) || {}
24
+ new(
25
+ workspace: data["workspace"] || Dir.pwd,
26
+ include: data["include"] || [],
27
+ exclude: data["exclude"] || [],
28
+ gems: data["gems"] || {},
29
+ bump: data["bump"] || :patch
30
+ )
31
+ end
32
+
33
+ def gem_dirs
34
+ if @gems.any?
35
+ @gems.values.map { |g| File.expand_path(g, @workspace) }
36
+ else
37
+ dirs = Dir.glob(@include_patterns, base: @workspace).select do |entry|
38
+ full_path = File.join(@workspace, entry)
39
+ File.directory?(full_path) && gemspec_in?(full_path)
40
+ end.map { |entry| File.join(@workspace, entry) }
41
+
42
+ if @exclude_patterns.any?
43
+ excluded = Dir.glob(@exclude_patterns, base: @workspace).map { |e| File.join(@workspace, e) }
44
+ dirs -= excluded
45
+ end
46
+
47
+ dirs
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def gemspec_in?(dir)
54
+ Dir.glob(File.join(dir, "*.gemspec")).any?
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemchain
4
+ class DependencyGraph
5
+ class CircularDependencyError < Gemchain::Error; end
6
+
7
+ def initialize(gems)
8
+ @gems = gems
9
+ @graph = build_graph
10
+ end
11
+
12
+ # Gems that directly depend on +gem_name+.
13
+ def dependents_of(gem_name)
14
+ @gems.select { |g| g.dependencies.key?(gem_name) }
15
+ end
16
+
17
+ # Gems that +gem_name+ directly depends on (only workspace gems).
18
+ def dependencies_of(gem_name)
19
+ gem = find(gem_name)
20
+ return [] unless gem
21
+
22
+ gem.dependencies.keys.filter_map { |dep_name| find(dep_name) }
23
+ end
24
+
25
+ # All transitive dependents of +gem_name+, including the gem itself.
26
+ def transitive_dependents(gem_name)
27
+ result = Set.new
28
+ queue = [gem_name]
29
+
30
+ until queue.empty?
31
+ current = queue.shift
32
+ next if result.include?(current)
33
+
34
+ result.add(current)
35
+ dependents_of(current).each { |g| queue << g.name }
36
+ end
37
+
38
+ result.to_a
39
+ end
40
+
41
+ # Gems with no workspace dependencies — leaves of the dependency tree.
42
+ def leaf_gems
43
+ @gems.select { |g| dependencies_of(g.name).empty? }
44
+ end
45
+
46
+ # Topological order: dependencies before dependents.
47
+ # If +start_gem+ is given, only includes that gem and its transitive dependents.
48
+ def cascade_order(start_gem = nil)
49
+ if start_gem
50
+ relevant = transitive_dependents(start_gem)
51
+ subgraph = @graph.select { |k, _| relevant.include?(k) }
52
+ tsort(subgraph)
53
+ else
54
+ tsort(@graph)
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def find(name)
61
+ @gems.find { |g| g.name == name }
62
+ end
63
+
64
+ def build_graph
65
+ graph = {}
66
+ @gems.each do |gem|
67
+ graph[gem.name] = gem.dependencies.keys.select { |dep_name| find(dep_name) }
68
+ end
69
+ graph
70
+ end
71
+
72
+ def tsort(graph)
73
+ sorted = []
74
+ visited = Set.new
75
+ temp_marked = Set.new
76
+
77
+ graph.keys.each do |node|
78
+ ts_visit(node, graph, visited, temp_marked, sorted) unless visited.include?(node)
79
+ end
80
+
81
+ sorted
82
+ end
83
+
84
+ def ts_visit(node, graph, visited, temp_marked, sorted)
85
+ return if visited.include?(node)
86
+ raise CircularDependencyError, "Circular dependency detected involving #{node}" if temp_marked.include?(node)
87
+
88
+ temp_marked.add(node)
89
+ (graph[node] || []).each do |dep|
90
+ ts_visit(dep, graph, visited, temp_marked, sorted)
91
+ end
92
+ temp_marked.delete(node)
93
+ visited.add(node)
94
+ sorted << node
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Gemchain
6
+ class ExecutionError < Error; end
7
+
8
+ # Executes a cascade plan: bumps versions, rewrites gemspec constraints,
9
+ # runs test suites, and releases gems to RubyGems — stopping at the first
10
+ # failure. All shell interaction goes through the +runner+ so the whole
11
+ # pipeline is testable without touching bundler, git, or RubyGems.
12
+ class Executor
13
+ Result = Struct.new(:success, :released, :skipped, :failed, keyword_init: true)
14
+
15
+ # Real command runner: executes via Open3, raises ExecutionError with
16
+ # output context on any non-zero exit.
17
+ class Runner
18
+ def run(command, chdir: nil)
19
+ out, err, status = Open3.capture3(*command, chdir: chdir)
20
+ return if status.success?
21
+
22
+ detail = err.lines.first(5).join.strip
23
+ detail = out.lines.first(5).join.strip if detail.empty?
24
+ raise ExecutionError, "Command failed: #{command.join(' ')}#{detail.empty? ? '' : "\n#{detail}"}"
25
+ end
26
+ end
27
+
28
+ BUMP_LEVELS = %i[patch minor major].freeze
29
+
30
+ # @param workspace [Workspace] the gem ecosystem
31
+ # @param steps [Array<Hash>] the plan from Cascade#update
32
+ # @param runner [#run] command runner (defaults to the real one)
33
+ # @param confirm [Proc] per-release confirmation, called with a message;
34
+ # defaults to a [y/N] prompt on stdin
35
+ # @param yes [Boolean] skip confirmations entirely
36
+ # @param bump [Symbol] dependent version bump level (:patch, :minor, :major)
37
+ # @param output [#puts] progress output stream
38
+ def initialize(workspace:, steps:, runner: nil, confirm: nil, yes: false,
39
+ bump: :patch, output: $stdout)
40
+ @workspace = workspace
41
+ @steps = steps
42
+ @runner = runner || Runner.new
43
+ @confirm = confirm
44
+ @yes = yes
45
+ @bump = bump.to_sym
46
+ @output = output
47
+ raise ArgumentError, "bump must be one of #{BUMP_LEVELS.inspect}" unless BUMP_LEVELS.include?(@bump)
48
+ end
49
+
50
+ def run
51
+ released = []
52
+ skipped = []
53
+
54
+ @steps.each do |step|
55
+ begin
56
+ case step[:action]
57
+ when :bump
58
+ progress(step) { bump_version(gem_dir(step[:gem]), step[:version]) }
59
+ when :update_dependency
60
+ progress(step) { update_dependency(gem_dir(step[:gem]), step[:dependency], step[:constraint]) }
61
+ when :test
62
+ progress(step) { run_tests(gem_dir(step[:gem]), step[:dependency]) }
63
+ when :release
64
+ if release_confirmed?(step)
65
+ progress(step) { release(step) }
66
+ released << step[:gem]
67
+ else
68
+ @output.puts " ⏭ Skipped release of #{step[:gem]} (declined)"
69
+ skipped << step[:gem]
70
+ end
71
+ end
72
+ rescue ExecutionError => e
73
+ return Result.new(success: false, released: released, skipped: skipped,
74
+ failed: {step[:gem] => e.message})
75
+ end
76
+ end
77
+
78
+ Result.new(success: true, released: released, skipped: skipped, failed: nil)
79
+ end
80
+
81
+ private
82
+
83
+ def progress(step)
84
+ @output.puts " ▶ #{describe(step)}"
85
+ yield
86
+ end
87
+
88
+ def describe(step)
89
+ case step[:action]
90
+ when :bump then "Bump #{step[:gem]} → #{step[:version]}"
91
+ when :update_dependency then "#{step[:gem]}: #{step[:dependency]} constraint → #{step[:constraint]}"
92
+ when :test then "#{step[:gem]}: bundle exec rake test"
93
+ when :release then "Release #{step[:gem]}"
94
+ end
95
+ end
96
+
97
+ def gem_dir(name)
98
+ gem = @workspace.find(name)
99
+ raise ExecutionError, "Gem '#{name}' not found in workspace" unless gem
100
+
101
+ gem.path
102
+ end
103
+
104
+ # ── :bump — rewrite the VERSION constant, keeping the quote style ──
105
+
106
+ def bump_version(dir, version)
107
+ file = version_file(dir)
108
+ content = File.read(file)
109
+ updated = content.sub(/VERSION\s*=\s*(['"])[^'"]*\1/) do |match|
110
+ quote = match[/['"]/]
111
+ "VERSION = #{quote}#{version}#{quote}"
112
+ end
113
+ File.write(file, updated) unless updated == content
114
+ end
115
+
116
+ def version_file(dir)
117
+ file = Dir.glob(File.join(dir, "lib", "**", "version.rb")).find do |f|
118
+ File.read(f).match?(/VERSION\s*=\s*['"]/)
119
+ end
120
+ raise ExecutionError, "No VERSION constant found under #{dir}/lib" unless file
121
+
122
+ file
123
+ end
124
+
125
+ # ── :update_dependency — rewrite the gemspec constraint line ──
126
+
127
+ def update_dependency(dir, dependency, constraint)
128
+ file = gemspec_file(dir)
129
+ lines = File.readlines(file)
130
+ updated = lines.map do |line|
131
+ if (m = line.match(/\A(\s*)(spec\.(?:add_)?(?:runtime_)?dependency\s+)(['"])(#{Regexp.escape(dependency)})\3/))
132
+ q = m[3]
133
+ "#{m[1]}#{m[2]}#{q}#{m[4]}#{q}, #{q}#{constraint}#{q}\n"
134
+ else
135
+ line
136
+ end
137
+ end
138
+ File.write(file, updated.join) unless updated == lines
139
+ end
140
+
141
+ def gemspec_file(dir)
142
+ file = Dir.glob(File.join(dir, "*.gemspec")).first
143
+ raise ExecutionError, "No gemspec found in #{dir}" unless file
144
+
145
+ file
146
+ end
147
+
148
+ # ── :test — resolve the new dependency, then run the suite ──
149
+
150
+ def run_tests(dir, dependency = nil)
151
+ # A fresh checkout may not have its bundle installed yet; bundle
152
+ # install first so bundle update can resolve the new version.
153
+ @runner.run(["bundle", "install"], chdir: dir)
154
+ @runner.run(["bundle", "update", dependency], chdir: dir) if dependency
155
+ @runner.run(["bundle", "exec", "rake", "test"], chdir: dir)
156
+ end
157
+
158
+ # ── :release — bump, commit, build, push, tag ──
159
+
160
+ def release(step)
161
+ dir = gem_dir(step[:gem])
162
+ name = step[:gem]
163
+ version = step[:version] || bump_dependent(dir, name)
164
+
165
+ files = [gemspec_file(dir), version_file(dir), File.join(dir, "Gemfile.lock")]
166
+ .select { |f| File.exist?(f) }
167
+ @runner.run(["git", "add", *files], chdir: dir)
168
+
169
+ message = if step[:dependency]
170
+ "Cascade: #{step[:dependency]} #{step[:constraint]} (gemchain)"
171
+ else
172
+ "Bump to #{version} (gemchain)"
173
+ end
174
+ @runner.run(["git", "commit", "-m", message], chdir: dir)
175
+
176
+ @runner.run(["gem", "build", "#{name}.gemspec"], chdir: dir)
177
+ @runner.run(["gem", "push", "#{name}-#{version}.gem"], chdir: dir)
178
+ @runner.run(["git", "tag", "v#{version}"], chdir: dir)
179
+ @runner.run(["git", "push", "origin", "HEAD", "--tags"], chdir: dir)
180
+ end
181
+
182
+ # Dependents get an automatic version bump (they're being re-released
183
+ # with a tightened constraint); the source gem's version comes from the
184
+ # :bump step.
185
+ def bump_dependent(dir, name)
186
+ gem = @workspace.find(name)
187
+ raise ExecutionError, "Gem '#{name}' not found in workspace" unless gem
188
+
189
+ new_version = bump_number(gem.version.to_s, @bump)
190
+ bump_version(dir, new_version)
191
+ new_version
192
+ end
193
+
194
+ def bump_number(version, level)
195
+ parts = version.split(".").map(&:to_i)
196
+ case level
197
+ when :major then "#{parts[0] + 1}.0.0"
198
+ when :minor then "#{parts[0]}.#{parts[1] + 1}.0"
199
+ else "#{parts[0]}.#{parts[1]}.#{(parts[2] || 0) + 1}"
200
+ end
201
+ end
202
+
203
+ # ── confirmation ──
204
+
205
+ def release_confirmed?(step)
206
+ return true if @yes
207
+ return @confirm.call("Release #{step[:gem]}?") if @confirm
208
+
209
+ @output.print " Release #{step[:gem]} to RubyGems? [y/N] "
210
+ $stdin.gets&.strip&.match?(/\Ay(es)?\z/i) || false
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemchain
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemchain
4
+ class GemInfo
5
+ attr_reader :name, :version_string, :path, :dependency_map
6
+
7
+ def initialize(name:, version:, path:, dependencies: {})
8
+ @name = name
9
+ @version_string = version
10
+ @path = path
11
+ @dependency_map = dependencies
12
+ end
13
+
14
+ def version
15
+ Gem::Version.new(@version_string)
16
+ end
17
+
18
+ def dependencies
19
+ @dependency_map
20
+ end
21
+ end
22
+
23
+ class Workspace
24
+ attr_reader :gems, :graph, :config
25
+
26
+ def initialize(config)
27
+ @config = config
28
+ @gems = load_gems
29
+ @graph = DependencyGraph.new(@gems)
30
+ end
31
+
32
+ def dependents_of(gem_name)
33
+ @graph.dependents_of(gem_name)
34
+ end
35
+
36
+ def dependencies_of(gem_name)
37
+ @graph.dependencies_of(gem_name)
38
+ end
39
+
40
+ def cascade_order(start_gem = nil)
41
+ @graph.cascade_order(start_gem)
42
+ end
43
+
44
+ def find(name)
45
+ @gems.find { |g| g.name == name }
46
+ end
47
+
48
+ # All gems that directly or transitively depend on +gem_name+.
49
+ def transitive_dependents(gem_name)
50
+ @graph.transitive_dependents(gem_name)
51
+ end
52
+
53
+ private
54
+
55
+ def load_gems
56
+ @config.gem_dirs.filter_map do |dir|
57
+ gemspec_path = Dir.glob(File.join(dir, "*.gemspec")).first
58
+ next unless gemspec_path
59
+
60
+ spec = Gem::Specification.load(gemspec_path)
61
+ next unless spec
62
+
63
+ GemInfo.new(
64
+ name: spec.name,
65
+ version: spec.version.to_s,
66
+ path: dir,
67
+ dependencies: runtime_dependencies(spec)
68
+ )
69
+ end
70
+ end
71
+
72
+ def runtime_dependencies(spec)
73
+ deps = {}
74
+ spec.dependencies.each do |dep|
75
+ next if dep.type == :development
76
+ deps[dep.name] = dep.requirement.to_s
77
+ end
78
+ deps
79
+ end
80
+ end
81
+ end
data/lib/gemchain.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemchain
4
+ class Error < StandardError; end
5
+ end
6
+
7
+ require_relative "gemchain/version"
8
+ require_relative "gemchain/config"
9
+ require_relative "gemchain/dependency_graph"
10
+ require_relative "gemchain/workspace"
11
+ require_relative "gemchain/cascade"
12
+ require_relative "gemchain/executor"
13
+ require_relative "gemchain/cli"
metadata ADDED
@@ -0,0 +1,115 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gemchain
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Kaka Ruto
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: thor
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.25'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.25'
40
+ - !ruby/object:Gem::Dependency
41
+ name: mocha
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.1'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ description: When you maintain multiple gems that depend on each other, Gemchain helps
69
+ you coordinate releases. It warns you before releasing a gem that others depend
70
+ on, and can cascade updates through the dependency chain — updating constraints,
71
+ running tests, and releasing each dependent gem in order.
72
+ email:
73
+ - kaka@myrrlabs.com
74
+ executables:
75
+ - gemchain
76
+ extensions: []
77
+ extra_rdoc_files: []
78
+ files:
79
+ - CHANGELOG.md
80
+ - LICENSE
81
+ - README.md
82
+ - exe/gemchain
83
+ - lib/gemchain.rb
84
+ - lib/gemchain/cascade.rb
85
+ - lib/gemchain/cli.rb
86
+ - lib/gemchain/config.rb
87
+ - lib/gemchain/dependency_graph.rb
88
+ - lib/gemchain/executor.rb
89
+ - lib/gemchain/version.rb
90
+ - lib/gemchain/workspace.rb
91
+ homepage: https://github.com/ask-rb/gemchain
92
+ licenses:
93
+ - MIT
94
+ metadata:
95
+ homepage_uri: https://github.com/ask-rb/gemchain
96
+ source_code_uri: https://github.com/ask-rb/gemchain
97
+ changelog_uri: https://github.com/ask-rb/gemchain/blob/main/CHANGELOG.md
98
+ rdoc_options: []
99
+ require_paths:
100
+ - lib
101
+ required_ruby_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '3.2'
106
+ required_rubygems_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ requirements: []
112
+ rubygems_version: 4.0.3
113
+ specification_version: 4
114
+ summary: Cascade gem updates through interdependent gem ecosystems
115
+ test_files: []