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 +7 -0
- data/CHANGELOG.md +40 -0
- data/LICENSE +21 -0
- data/README.md +134 -0
- data/exe/gemchain +6 -0
- data/lib/gemchain/cascade.rb +109 -0
- data/lib/gemchain/cli.rb +211 -0
- data/lib/gemchain/config.rb +57 -0
- data/lib/gemchain/dependency_graph.rb +97 -0
- data/lib/gemchain/executor.rb +213 -0
- data/lib/gemchain/version.rb +5 -0
- data/lib/gemchain/workspace.rb +81 -0
- data/lib/gemchain.rb +13 -0
- metadata +115 -0
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,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
|
data/lib/gemchain/cli.rb
ADDED
|
@@ -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,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: []
|