steep-relaxed 2.0.0.0 → 2.1.0.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 +4 -4
- data/CHANGELOG.md +35 -0
- data/CLAUDE.md +2 -2
- data/README.md +3 -3
- data/Rakefile +352 -143
- data/Steepfile +2 -0
- data/bin/shape_warmup_bench.rb +402 -0
- data/bin/steep-check.rb +4 -4
- data/bin/subtyping_cache_bench.rb +436 -0
- data/bin/typecheck_bench.rb +145 -0
- data/doc/release.md +264 -0
- data/lib/steep/ast/types/helper.rb +0 -1
- data/lib/steep/ast/types/literal.rb +1 -1
- data/lib/steep/ast/types/name.rb +1 -1
- data/lib/steep/cli.rb +32 -5
- data/lib/steep/daemon/server.rb +1 -3
- data/lib/steep/daemon.rb +49 -9
- data/lib/steep/diagnostic/ruby.rb +4 -4
- data/lib/steep/drivers/check.rb +0 -1
- data/lib/steep/drivers/checkfile.rb +4 -12
- data/lib/steep/drivers/init.rb +4 -3
- data/lib/steep/drivers/langserver.rb +34 -5
- data/lib/steep/drivers/query.rb +40 -2
- data/lib/steep/drivers/stats.rb +0 -1
- data/lib/steep/drivers/worker.rb +0 -7
- data/lib/steep/interface/builder.rb +194 -28
- data/lib/steep/interface/shape.rb +28 -14
- data/lib/steep/node_helper.rb +2 -2
- data/lib/steep/server/base_worker.rb +27 -30
- data/lib/steep/server/change_buffer.rb +0 -32
- data/lib/steep/server/command_socket.rb +181 -0
- data/lib/steep/server/custom_methods.rb +22 -22
- data/lib/steep/server/goto_resolver.rb +200 -0
- data/lib/steep/server/interaction_worker.rb +32 -12
- data/lib/steep/server/master.rb +709 -261
- data/lib/steep/server/rbs_entry_builder.rb +237 -0
- data/lib/steep/server/type_check_controller.rb +73 -27
- data/lib/steep/server/type_check_database.rb +406 -0
- data/lib/steep/server/type_check_worker.rb +118 -352
- data/lib/steep/server/worker_process.rb +8 -22
- data/lib/steep/services/hover_provider/content.rb +1 -1
- data/lib/steep/services/hover_provider/ruby.rb +1 -1
- data/lib/steep/services/signature_help_provider.rb +1 -1
- data/lib/steep/services/stats_calculator.rb +16 -35
- data/lib/steep/services/symbol_provider.rb +191 -0
- data/lib/steep/services/type_check_service.rb +10 -21
- data/lib/steep/source.rb +9 -10
- data/lib/steep/subtyping/cache.rb +16 -4
- data/lib/steep/subtyping/check.rb +83 -16
- data/lib/steep/subtyping/stats.rb +356 -0
- data/lib/steep/tagged_logging.rb +4 -1
- data/lib/steep/type_construction.rb +39 -17
- data/lib/steep/type_inference/case_when.rb +2 -2
- data/lib/steep/type_inference/logic_type_interpreter.rb +6 -3
- data/lib/steep/type_inference/send_args.rb +9 -5
- data/lib/steep/typing.rb +3 -3
- data/lib/steep/version.rb +1 -1
- data/lib/steep.rb +6 -1
- data/steep-relaxed.gemspec +2 -8
- metadata +15 -6
- data/lib/steep/services/goto_service.rb +0 -644
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 62c5e1172e3ea8417b507a37c99cdd291db4ee4ab223e84939a47d7f6287800b
|
|
4
|
+
data.tar.gz: 740ec1d1b15cdfcf9e102379ec0b3a55f68dc710a7ab83fbaec43025f86bd506
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d1fbbb8bfb247b14a98a31a3be90ab92303452cbf60a0f9317ca7fe6e6a58351ba59ede725659d0e1e7b9fe284976f5c2bd561ea89966a9d8e4e71f8b0cd86e0
|
|
7
|
+
data.tar.gz: e52922fcc78b233f8adaf23a771065dc62895367cf4a053b87f405e2ff1f6fcb188c94e8ee863921b2c4a763edc1203651a17c0790dc38897d1c632afdcb349b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## 2.1.0 (2026-08-27)
|
|
4
|
+
|
|
5
|
+
Steep 2.1 opens the running server to commands other than the editor's. `steep langserver` now binds
|
|
6
|
+
the per-project UNIX socket that `steep server` serves, so `steep check` and `steep query` reach the
|
|
7
|
+
language server the editor is already running instead of needing a daemon of their own, and the new
|
|
8
|
+
`steep query diagnostics` reads the diagnostics that server has already computed rather than starting
|
|
9
|
+
a type check of its own. Both are aimed at tools that edit files on disk and want fast feedback,
|
|
10
|
+
coding agents in particular.
|
|
11
|
+
|
|
12
|
+
The type checker gains support for Ruby 3.4's `it` block parameter, and RBS is updated to 4.2.0.
|
|
13
|
+
|
|
14
|
+
### Type checker core
|
|
15
|
+
|
|
16
|
+
* Infer tuple types for array literals against pair interface hints like `Hash::_Pair` ([#2253](https://github.com/soutaro/steep/pull/2253))
|
|
17
|
+
* Accept a type alias as the type of a lambda's block parameter ([#2263](https://github.com/soutaro/steep/pull/2263))
|
|
18
|
+
* Build expensive log tags only when something is logged ([#2271](https://github.com/soutaro/steep/pull/2271))
|
|
19
|
+
* Accept block-pass arguments for optional blocks ([#2261](https://github.com/soutaro/steep/pull/2261))
|
|
20
|
+
* feat: support the `it` block parameter ([#2238](https://github.com/soutaro/steep/pull/2238))
|
|
21
|
+
* Fix `UnreachableBranch` on `==` against literal supertype (`Symbol` / `Integer` / `String`) ([#2223](https://github.com/soutaro/steep/pull/2223))
|
|
22
|
+
|
|
23
|
+
### Commandline tool
|
|
24
|
+
|
|
25
|
+
* Stop `steep query definition` from hanging on inline sources ([#2276](https://github.com/soutaro/steep/pull/2276))
|
|
26
|
+
* Serve steep query/check through the language server's UNIX socket ([#2273](https://github.com/soutaro/steep/pull/2273))
|
|
27
|
+
* Add `steep query diagnostics` ([#2274](https://github.com/soutaro/steep/pull/2274))
|
|
28
|
+
* Point the Steepfile template, README and CLAUDE.md at rbs collection ([#2269](https://github.com/soutaro/steep/pull/2269))
|
|
29
|
+
|
|
30
|
+
### Miscellaneous
|
|
31
|
+
|
|
32
|
+
* Update RBS to 4.2.0 ([#2277](https://github.com/soutaro/steep/pull/2277))
|
|
33
|
+
* Fix `bin/steep-check.rb` for the current API ([#2270](https://github.com/soutaro/steep/pull/2270))
|
|
34
|
+
* Update RBS to 4.2.0.pre.1 ([#2267](https://github.com/soutaro/steep/pull/2267))
|
|
35
|
+
* Fix Ruby warnings printed while running tests ([#2266](https://github.com/soutaro/steep/pull/2266))
|
|
36
|
+
* Run test subprocesses outside the parent Bundler env ([#2225](https://github.com/soutaro/steep/pull/2225))
|
|
37
|
+
|
|
3
38
|
## 2.0.0 (2026-04-14)
|
|
4
39
|
|
|
5
40
|
### Summary
|
data/CLAUDE.md
CHANGED
|
@@ -98,8 +98,8 @@ bundle exec rake build
|
|
|
98
98
|
**Steepfile**: Defines type checking targets
|
|
99
99
|
- `check`: Directories to type check
|
|
100
100
|
- `signature`: RBS signature directories
|
|
101
|
-
- `library`:
|
|
102
|
-
- `collection_config`:
|
|
101
|
+
- `library`: RBSs that rbs collection doesn't manage
|
|
102
|
+
- `collection_config`: Path to `rbs_collection.yaml` (the project root one is read by default)
|
|
103
103
|
- Multiple targets supported (app, test, bin)
|
|
104
104
|
|
|
105
105
|
**RBS Collection**: Manages external type definitions via `rbs_collection.steep.yaml`
|
data/README.md
CHANGED
|
@@ -34,11 +34,11 @@ Edit the `Steepfile`:
|
|
|
34
34
|
target :app do
|
|
35
35
|
check "lib"
|
|
36
36
|
signature "sig"
|
|
37
|
-
|
|
38
|
-
library "pathname"
|
|
39
37
|
end
|
|
40
38
|
```
|
|
41
39
|
|
|
40
|
+
RBSs for the gems in `Gemfile.lock` are loaded via [rbs collection](https://github.com/ruby/rbs/blob/master/docs/collection.md), which you set up with `rbs collection init` and `rbs collection install`. Add a `library` call only for an RBS that rbs collection doesn't manage.
|
|
41
|
+
|
|
42
42
|
### 1. Declare Types
|
|
43
43
|
|
|
44
44
|
Declare types in `.rbs` files in `sig` directory.
|
|
@@ -253,7 +253,7 @@ Use `bundle exec rake -T` to see all available tasks.
|
|
|
253
253
|
|
|
254
254
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
255
255
|
|
|
256
|
-
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
256
|
+
To install this gem onto your local machine, run `bundle exec rake install`. Releases are cut by the `Release gem` workflow rather than from a working copy -- see [doc/release.md](doc/release.md).
|
|
257
257
|
|
|
258
258
|
## Contributing
|
|
259
259
|
|
data/Rakefile
CHANGED
|
@@ -36,204 +36,413 @@ end
|
|
|
36
36
|
# Rake::Task[:"release:release-prs"].invoke
|
|
37
37
|
# end
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
39
|
+
# Pull requests with one of these labels are omitted from the changelog.
|
|
40
|
+
CHANGELOG_SKIP_LABELS = ["skip-changelog"]
|
|
41
|
+
|
|
42
|
+
# The tags a release proper starts *after*, rather than at.
|
|
43
|
+
CHANGELOG_PRERELEASE_TAGS = ["v*.pre*", "v*.dev*"]
|
|
44
|
+
|
|
45
|
+
# Resolves the commit-ish the changelog of the release being prepared starts from.
|
|
46
|
+
#
|
|
47
|
+
# `version` is a version number, a tag, or any commit-ish. When it is omitted, the base follows
|
|
48
|
+
# `Steep::VERSION`:
|
|
49
|
+
#
|
|
50
|
+
# * `X.Y.Z.pre.N` documents what changed since `X.Y.Z.pre.N-1`, so it starts from the latest tag.
|
|
51
|
+
# * `X.Y.Z` documents the whole cycle, the prereleases included, so it skips the prerelease tags
|
|
52
|
+
# in between and starts from the previous release proper.
|
|
53
|
+
#
|
|
54
|
+
# This is the step that is easy to get wrong by hand: on a release proper the latest tag is a
|
|
55
|
+
# prerelease, so the obvious default would produce only the tail of the cycle. Passing a version
|
|
56
|
+
# explicitly overrides all of it.
|
|
57
|
+
#
|
|
58
|
+
def changelog_base(version)
|
|
59
|
+
require "open3"
|
|
51
60
|
|
|
52
|
-
|
|
61
|
+
from =
|
|
62
|
+
if version
|
|
63
|
+
# `2.1.0` and `v2.1.0` both mean the tag `v2.1.0`, while `master` or a SHA is used as is.
|
|
64
|
+
version.match?(/\A\d/) ? "v#{version}" : version
|
|
65
|
+
else
|
|
66
|
+
command = ["git", "describe", "--tags", "--match", "v*", "--abbrev=0"]
|
|
67
|
+
unless Gem::Version.new(Steep::VERSION).prerelease?
|
|
68
|
+
CHANGELOG_PRERELEASE_TAGS.each { |glob| command.push("--exclude", glob) }
|
|
69
|
+
end
|
|
53
70
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"--json",
|
|
59
|
-
"url,title,number",
|
|
60
|
-
"--limit=100",
|
|
61
|
-
"--search" ,
|
|
62
|
-
"milestone:\"#{milestone}\" is:merged sort:updated-desc -label:Released"
|
|
63
|
-
]
|
|
71
|
+
output, status = Open3.capture2(*command)
|
|
72
|
+
raise "🚨 Cannot detect the tag the changelog starts from. Give the previous version explicitly." unless status.success?
|
|
73
|
+
output.chomp
|
|
74
|
+
end
|
|
64
75
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
raise status.inspect unless status.success?
|
|
76
|
+
_, status = Open3.capture2("git", "rev-parse", "--verify", "--quiet", "#{from}^{commit}")
|
|
77
|
+
raise "🚨 No such commit-ish: `#{from}`" unless status.success?
|
|
68
78
|
|
|
79
|
+
from
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Runs a GraphQL query against the repository of the working directory.
|
|
83
|
+
#
|
|
84
|
+
# `body` is the selection set inside `repository`, so a query can use the `$owner` and `$name`
|
|
85
|
+
# variables. Returns the contents of `data.repository`.
|
|
86
|
+
#
|
|
87
|
+
def changelog_graphql(body)
|
|
88
|
+
require "open3"
|
|
69
89
|
require "json"
|
|
70
|
-
json = JSON.parse(output, symbolize_names: true)
|
|
71
90
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
91
|
+
@changelog_repository ||=
|
|
92
|
+
begin
|
|
93
|
+
output, status = Open3.capture2("gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner")
|
|
94
|
+
raise status.inspect unless status.success?
|
|
95
|
+
output.chomp.split("/", 2)
|
|
76
96
|
end
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
97
|
+
owner, name = @changelog_repository
|
|
98
|
+
|
|
99
|
+
query = <<~GRAPHQL
|
|
100
|
+
query($owner: String!, $name: String!) {
|
|
101
|
+
repository(owner: $owner, name: $name) {
|
|
102
|
+
#{body}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
GRAPHQL
|
|
106
|
+
|
|
107
|
+
output, status = Open3.capture2(
|
|
108
|
+
"gh", "api", "graphql",
|
|
109
|
+
"-f", "query=#{query}",
|
|
110
|
+
"-f", "owner=#{owner}",
|
|
111
|
+
"-f", "name=#{name}",
|
|
112
|
+
binmode: true
|
|
113
|
+
)
|
|
114
|
+
raise status.inspect unless status.success?
|
|
115
|
+
|
|
116
|
+
# GitHub always answers in UTF-8, while the default external encoding follows the locale. Without
|
|
117
|
+
# this, a pull request body with an emoji fails to parse under `LANG=C`, as in GitHub Actions.
|
|
118
|
+
JSON.parse(output.force_encoding(Encoding::UTF_8), symbolize_names: true).dig(:data, :repository)
|
|
80
119
|
end
|
|
81
120
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
major, minor, patch, pre = Steep::VERSION.split(".", 4)
|
|
87
|
-
major = major.to_i
|
|
88
|
-
minor = minor.to_i
|
|
89
|
-
patch = patch.to_i
|
|
121
|
+
# Lists the commits between `from` and `HEAD`, newest first.
|
|
122
|
+
#
|
|
123
|
+
def changelog_commits(from)
|
|
124
|
+
require "open3"
|
|
90
125
|
|
|
91
|
-
|
|
126
|
+
output, status = Open3.capture2("git", "log", "--format=%H", "#{from}..HEAD")
|
|
127
|
+
raise status.inspect unless status.success?
|
|
92
128
|
|
|
93
|
-
|
|
129
|
+
output.lines.map(&:chomp).reject(&:empty?)
|
|
130
|
+
end
|
|
94
131
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
132
|
+
# What `git cherry-pick -x` appends to the message of the commit it creates.
|
|
133
|
+
CHERRY_PICK_ORIGIN = /^\(cherry picked from commit ([0-9a-f]{40})\)$/
|
|
134
|
+
|
|
135
|
+
# Maps the commits that record where they were cherry-picked from to that commit.
|
|
136
|
+
#
|
|
137
|
+
# A backport is a cherry-pick, so on a release branch it is the recorded origin, not the commit
|
|
138
|
+
# itself, that leads to the pull request the change was written and reviewed in. Without this a
|
|
139
|
+
# backported change is attributed to the pull request that carried the backport, which says
|
|
140
|
+
# nothing about the change and is the same for every commit it brought over.
|
|
141
|
+
#
|
|
142
|
+
# A commit backported twice -- the development line, then a release branch -- carries one line
|
|
143
|
+
# per hop, appended in order, so the first one is where the change started.
|
|
144
|
+
#
|
|
145
|
+
def changelog_origins(commits)
|
|
146
|
+
return {} if commits.empty?
|
|
98
147
|
|
|
99
|
-
|
|
148
|
+
require "open3"
|
|
100
149
|
|
|
101
|
-
|
|
102
|
-
|
|
150
|
+
# `--no-walk` prints these commits and nothing else. NUL delimiters keep a commit message --
|
|
151
|
+
# which can contain anything, including what this format looks like -- from being read as the
|
|
152
|
+
# format itself.
|
|
153
|
+
output, status = Open3.capture2("git", "log", "--no-walk", "--format=%H%x00%B%x00", *commits, binmode: true)
|
|
154
|
+
raise status.inspect unless status.success?
|
|
103
155
|
|
|
104
|
-
|
|
156
|
+
# Commit messages are UTF-8, while the default external encoding follows the locale. Without
|
|
157
|
+
# this, splitting a message that is not ASCII fails under `LANG=C`, as in GitHub Actions.
|
|
158
|
+
output.force_encoding(Encoding::UTF_8)
|
|
105
159
|
|
|
106
|
-
|
|
107
|
-
|
|
160
|
+
output.split("\0").each_slice(2).each_with_object({}) do |(commit, message), origins|
|
|
161
|
+
commit = commit.to_s.strip
|
|
162
|
+
next if commit.empty?
|
|
108
163
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
164
|
+
origin = message.to_s[CHERRY_PICK_ORIGIN, 1] or next
|
|
165
|
+
origins[commit] = origin
|
|
166
|
+
end
|
|
167
|
+
end
|
|
112
168
|
|
|
113
|
-
|
|
169
|
+
# Asks GitHub which pull requests each commit came from, so that any merge strategy -- merge
|
|
170
|
+
# commit, squash, or rebase -- is handled without parsing commit messages.
|
|
171
|
+
#
|
|
172
|
+
# Returns `{ oid => [pull request, ...] }`, with an empty array for the commits GitHub has no
|
|
173
|
+
# merged pull request for, including the ones it does not know at all.
|
|
174
|
+
#
|
|
175
|
+
def changelog_associated_pull_requests(oids)
|
|
176
|
+
oids.uniq.each_slice(50).each_with_object({}) do |slice, found|
|
|
177
|
+
aliases = slice.map.with_index do |oid, index|
|
|
178
|
+
<<~GRAPHQL
|
|
179
|
+
c#{index}: object(oid: "#{oid}") {
|
|
180
|
+
... on Commit {
|
|
181
|
+
associatedPullRequests(first: 10) {
|
|
182
|
+
nodes {
|
|
183
|
+
number title url merged
|
|
184
|
+
labels(first: 100) { nodes { name } }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
GRAPHQL
|
|
190
|
+
end
|
|
114
191
|
|
|
115
|
-
|
|
116
|
-
gem 'steep', require: false#{pre_requirement}
|
|
117
|
-
```
|
|
192
|
+
response = changelog_graphql(aliases.join("\n"))
|
|
118
193
|
|
|
119
|
-
|
|
194
|
+
slice.each_with_index do |oid, index|
|
|
195
|
+
nodes = response.dig(:"c#{index}", :associatedPullRequests, :nodes) || []
|
|
120
196
|
|
|
121
|
-
|
|
197
|
+
found[oid] = nodes.select { |pr| pr[:merged] }.map do |pr|
|
|
198
|
+
{ number: pr[:number], title: pr[:title], url: pr[:url], labels: pr.dig(:labels, :nodes).map { |label| label[:name] } }
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
122
203
|
|
|
123
|
-
|
|
204
|
+
# Finds the pull requests the commits came from, keeping the order of `commits`.
|
|
205
|
+
#
|
|
206
|
+
# Returns the pull requests for the changelog and the ones omitted by `skip_labels`.
|
|
207
|
+
#
|
|
208
|
+
def changelog_pull_requests(commits, skip_labels: CHANGELOG_SKIP_LABELS)
|
|
209
|
+
origins = changelog_origins(commits)
|
|
210
|
+
found = changelog_associated_pull_requests(commits.map { |commit| origins[commit] || commit })
|
|
211
|
+
|
|
212
|
+
# An origin that leads nowhere -- a commit cherry-picked from a fork, or one that went to the
|
|
213
|
+
# default branch without a pull request -- falls back to the commit in this history, which is
|
|
214
|
+
# at least the backport that brought it here.
|
|
215
|
+
fallbacks = commits.select { |commit| origins[commit] && found.fetch(origins[commit], []).empty? }
|
|
216
|
+
found.update(changelog_associated_pull_requests(fallbacks)) unless fallbacks.empty?
|
|
217
|
+
|
|
218
|
+
pull_requests = {}
|
|
219
|
+
skipped = {}
|
|
220
|
+
|
|
221
|
+
commits.each do |commit|
|
|
222
|
+
prs = found.fetch(origins[commit] || commit, [])
|
|
223
|
+
prs = found.fetch(commit, []) if prs.empty?
|
|
224
|
+
|
|
225
|
+
prs.each do |pr|
|
|
226
|
+
if (pr[:labels] & skip_labels).empty?
|
|
227
|
+
pull_requests[pr[:number]] ||= pr
|
|
228
|
+
else
|
|
229
|
+
skipped[pr[:number]] ||= pr
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
124
233
|
|
|
125
|
-
|
|
234
|
+
[pull_requests.values, skipped.values]
|
|
235
|
+
end
|
|
126
236
|
|
|
127
|
-
|
|
237
|
+
# Fetches the details that help classifying the pull requests: the changed files and the body.
|
|
238
|
+
#
|
|
239
|
+
def changelog_pull_request_details(pull_requests)
|
|
240
|
+
pull_requests.each_slice(50).flat_map do |slice|
|
|
241
|
+
aliases = slice.map do |pr|
|
|
242
|
+
<<~GRAPHQL
|
|
243
|
+
p#{pr[:number]}: pullRequest(number: #{pr[:number]}) {
|
|
244
|
+
body
|
|
245
|
+
author { login }
|
|
246
|
+
files(first: 100) {
|
|
247
|
+
nodes { path }
|
|
248
|
+
pageInfo { hasNextPage }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
GRAPHQL
|
|
252
|
+
end
|
|
128
253
|
|
|
129
|
-
|
|
254
|
+
details = changelog_graphql(aliases.join("\n"))
|
|
130
255
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
puts
|
|
256
|
+
slice.map do |pr|
|
|
257
|
+
detail = details[:"p#{pr[:number]}"] or next pr
|
|
134
258
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
259
|
+
pr.merge(
|
|
260
|
+
author: detail.dig(:author, :login),
|
|
261
|
+
# The body is a hint for writing the changelog, not a copy source. Keep it short.
|
|
262
|
+
body: detail[:body].to_s.strip.slice(0, 1000),
|
|
263
|
+
files: detail.dig(:files, :nodes).map { |file| file[:path] },
|
|
264
|
+
files_truncated: detail.dig(:files, :pageInfo, :hasNextPage)
|
|
265
|
+
)
|
|
142
266
|
end
|
|
143
|
-
puts
|
|
144
267
|
end
|
|
268
|
+
end
|
|
145
269
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
major = major.to_i
|
|
151
|
-
minor = minor.to_i
|
|
152
|
-
patch = patch.to_i
|
|
153
|
-
|
|
154
|
-
puts "✏️ Making a draft release on GitHub..."
|
|
270
|
+
# Reports the pull requests omitted by their label, so that they do not disappear silently.
|
|
271
|
+
#
|
|
272
|
+
def warn_skipped_pull_requests(skipped, skip_labels)
|
|
273
|
+
return if skipped.empty?
|
|
155
274
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
changelog = changelog.sub(/^.*\n^.*\n/, "").rstrip
|
|
275
|
+
numbers = skipped.map { |pr| "##{pr[:number]}" }
|
|
276
|
+
numbers = numbers.take(20).push("and #{numbers.size - 20} more") if numbers.size > 20
|
|
159
277
|
|
|
160
|
-
|
|
161
|
-
|
|
278
|
+
$stderr.puts
|
|
279
|
+
$stderr.puts " (⏭️ Skipped #{skipped.size} pull request(s) labeled #{skip_labels.map { |label| "`#{label}`" }.join(" or ")}: #{numbers.join(", ")})"
|
|
280
|
+
end
|
|
162
281
|
|
|
163
|
-
#
|
|
164
|
-
|
|
282
|
+
# Prints the changelog template listing the pull requests merged between `from` and `HEAD`.
|
|
283
|
+
#
|
|
284
|
+
# The changelog goes to STDOUT and everything else goes to STDERR, so that the output can be
|
|
285
|
+
# piped to another command: `rake gem:changelog | pbcopy`
|
|
286
|
+
#
|
|
287
|
+
def print_changelog(from, skip_labels: CHANGELOG_SKIP_LABELS)
|
|
288
|
+
$stderr.puts "🔍 Finding pull requests merged between `#{from}` and `HEAD`..."
|
|
289
|
+
|
|
290
|
+
commits = changelog_commits(from)
|
|
291
|
+
if commits.empty?
|
|
292
|
+
$stderr.puts " (🤔 There is no commit after `#{from}`.)"
|
|
293
|
+
return
|
|
294
|
+
end
|
|
165
295
|
|
|
166
|
-
|
|
167
|
-
"gh",
|
|
168
|
-
"release",
|
|
169
|
-
"create",
|
|
170
|
-
"--draft",
|
|
171
|
-
"v#{Steep::VERSION}",
|
|
172
|
-
"--title=#{Steep::VERSION}",
|
|
173
|
-
"--notes=#{notes}"
|
|
174
|
-
]
|
|
296
|
+
pull_requests, skipped = changelog_pull_requests(commits, skip_labels: skip_labels)
|
|
175
297
|
|
|
176
|
-
|
|
177
|
-
|
|
298
|
+
if pull_requests.empty?
|
|
299
|
+
$stderr.puts " (🤔 No pull request is associated to the commits after `#{from}`.)"
|
|
300
|
+
else
|
|
301
|
+
$stderr.puts
|
|
302
|
+
pull_requests.each do |pr|
|
|
303
|
+
puts "* #{pr[:title]} ([##{pr[:number]}](#{pr[:url]}))"
|
|
178
304
|
end
|
|
305
|
+
$stdout.flush
|
|
306
|
+
end
|
|
179
307
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
308
|
+
warn_skipped_pull_requests(skipped, skip_labels)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Prints the same pull requests as `print_changelog` as JSON, with the details that help
|
|
312
|
+
# classifying them into the sections of CHANGELOG.md.
|
|
313
|
+
#
|
|
314
|
+
# This is the input for the release automation, so it always prints a valid JSON document.
|
|
315
|
+
#
|
|
316
|
+
def print_changelog_json(from, skip_labels: CHANGELOG_SKIP_LABELS)
|
|
317
|
+
require "json"
|
|
318
|
+
|
|
319
|
+
$stderr.puts "🔍 Finding pull requests merged between `#{from}` and `HEAD`..."
|
|
320
|
+
|
|
321
|
+
commits = changelog_commits(from)
|
|
322
|
+
pull_requests, skipped = changelog_pull_requests(commits, skip_labels: skip_labels)
|
|
323
|
+
pull_requests = changelog_pull_request_details(pull_requests)
|
|
324
|
+
|
|
325
|
+
$stderr.puts " (📋 #{pull_requests.size} pull request(s))"
|
|
326
|
+
|
|
327
|
+
puts JSON.pretty_generate(
|
|
328
|
+
{
|
|
329
|
+
from: from,
|
|
330
|
+
to: "HEAD",
|
|
331
|
+
pull_requests: pull_requests,
|
|
332
|
+
skipped: skipped
|
|
333
|
+
}
|
|
334
|
+
)
|
|
335
|
+
$stdout.flush
|
|
336
|
+
|
|
337
|
+
warn_skipped_pull_requests(skipped, skip_labels)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
namespace :gem do
|
|
341
|
+
desc "Generate changelog template from GH pull requests merged since the previous release"
|
|
342
|
+
task :changelog, [:version] do |_task, args|
|
|
343
|
+
print_changelog(changelog_base(args[:version]))
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
namespace :changelog do
|
|
347
|
+
desc "Print the pull requests of `gem:changelog` as JSON, with the changed files and body of each"
|
|
348
|
+
task :json, [:version] do |_task, args|
|
|
349
|
+
print_changelog_json(changelog_base(args[:version]))
|
|
185
350
|
end
|
|
186
351
|
end
|
|
187
352
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
353
|
+
# There are three kinds of release: `X.Y.Z`, `X.Y.Z.pre.N`, and `X.Y.Z.dev.N`. The
|
|
354
|
+
# `.dev.N` ones are cut from the development line for people who need a specific
|
|
355
|
+
# change early; they are not written up in the changelog, so there are no notes to
|
|
356
|
+
# publish and nothing worth announcing.
|
|
357
|
+
def dev_release?(version)
|
|
358
|
+
Gem::Version.new(version).segments.include?("dev")
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# The body of the topmost section of CHANGELOG.md, which is the release being
|
|
362
|
+
# prepared, minus its own heading.
|
|
363
|
+
#
|
|
364
|
+
# The encoding is explicit because the default external encoding follows the
|
|
365
|
+
# locale, and the changelog is not ASCII.
|
|
366
|
+
#
|
|
367
|
+
def changelog_section(version)
|
|
368
|
+
content = File.read(File.join(__dir__, "CHANGELOG.md"), encoding: Encoding::UTF_8)
|
|
369
|
+
section = content.scan(/^## \d.*?(?=^## \d)/m)[0] or raise "🚨 Cannot find a release section in CHANGELOG.md"
|
|
370
|
+
heading, _, body = section.partition("\n")
|
|
371
|
+
heading.include?(version) or raise "🚨 CHANGELOG.md starts with `#{heading.strip}`, which is not #{version}"
|
|
372
|
+
body.strip
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
desc "Check that the working tree is ready to be released as the given version"
|
|
376
|
+
task :check_release, [:version] do |_task, args|
|
|
377
|
+
version = args[:version] or raise "🚨 Pass the version being released: `rake 'gem:check_release[2.1.0]'`"
|
|
378
|
+
Gem::Version.correct?(version) or raise "🚨 `#{version}` is not a version number."
|
|
379
|
+
|
|
380
|
+
# The version being released and the version the commit declares are stated
|
|
381
|
+
# separately -- one by whoever starts the release, one by the commit itself --
|
|
382
|
+
# so that releasing the wrong commit, or releasing the right one under the wrong
|
|
383
|
+
# name, fails here rather than on RubyGems.
|
|
384
|
+
version == Steep::VERSION or
|
|
385
|
+
raise "🚨 Releasing #{version}, but this commit declares `Steep::VERSION = #{Steep::VERSION.inspect}`."
|
|
194
386
|
|
|
195
|
-
if
|
|
196
|
-
|
|
387
|
+
if dev_release?(version)
|
|
388
|
+
puts "✅ #{version} is the version of this commit. It is a dev release, so CHANGELOG.md is not checked."
|
|
197
389
|
else
|
|
198
|
-
|
|
390
|
+
changelog_section(version)
|
|
391
|
+
puts "✅ #{version} is the version of this commit, and CHANGELOG.md documents it."
|
|
199
392
|
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
desc "Create and push the `vX.Y.Z` tag for Steep::VERSION"
|
|
396
|
+
task :tag do
|
|
397
|
+
tag = "v#{Steep::VERSION}"
|
|
398
|
+
|
|
399
|
+
# Annotated, so that the tag carries its own author and date rather than
|
|
400
|
+
# borrowing the tagged commit's.
|
|
401
|
+
sh "git", "tag", "--annotate", "--message", "Steep #{Steep::VERSION}", tag
|
|
402
|
+
sh "git", "push", "origin", tag
|
|
403
|
+
|
|
404
|
+
puts "🏷️ Pushed #{tag}."
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
desc "Publish the GitHub release for Steep::VERSION, unless it is a `.dev.` version"
|
|
408
|
+
task :gh_release do
|
|
409
|
+
require "open3"
|
|
410
|
+
|
|
411
|
+
version = Gem::Version.new(Steep::VERSION)
|
|
412
|
+
major, minor, *_ = Steep::VERSION.split(".")
|
|
413
|
+
tag = "v#{Steep::VERSION}"
|
|
200
414
|
|
|
201
|
-
if
|
|
202
|
-
puts "
|
|
415
|
+
if dev_release?(Steep::VERSION)
|
|
416
|
+
puts "⏭️ #{Steep::VERSION} is a dev release, so there is no GitHub release to publish."
|
|
203
417
|
next
|
|
204
418
|
end
|
|
205
419
|
|
|
206
|
-
|
|
420
|
+
# The release is created against an existing tag, so that the artifacts and the
|
|
421
|
+
# notes describe a commit that is already immutable.
|
|
422
|
+
_, status = Open3.capture2("git", "rev-parse", "--verify", "--quiet", "#{tag}^{commit}")
|
|
423
|
+
raise "🚨 No such tag: `#{tag}`. Tag the release before creating the GitHub release." unless status.success?
|
|
424
|
+
|
|
425
|
+
notes = <<~NOTES
|
|
426
|
+
[Release note](https://github.com/soutaro/steep/wiki/Release-Note-#{major}.#{minor})
|
|
427
|
+
|
|
428
|
+
#{changelog_section(Steep::VERSION)}
|
|
429
|
+
NOTES
|
|
207
430
|
|
|
431
|
+
# Published rather than drafted: the notes are the changelog section that was
|
|
432
|
+
# already reviewed in the release pull request, so there is nothing left to edit.
|
|
208
433
|
command = [
|
|
209
|
-
"gh",
|
|
210
|
-
"
|
|
211
|
-
"
|
|
212
|
-
"--json",
|
|
213
|
-
"url,title,number",
|
|
214
|
-
"--search" ,
|
|
215
|
-
"milestone:\"#{milestone}\" is:merged sort:updated-desc -label:Released"
|
|
434
|
+
"gh", "release", "create", tag,
|
|
435
|
+
"--title=#{Steep::VERSION}",
|
|
436
|
+
"--notes=#{notes}"
|
|
216
437
|
]
|
|
438
|
+
command << "--prerelease" if version.prerelease?
|
|
217
439
|
|
|
218
|
-
require "open3"
|
|
219
440
|
output, status = Open3.capture2(*command)
|
|
220
|
-
raise status.inspect unless status.success?
|
|
441
|
+
raise "🚨 `gh release create` failed: #{status.inspect}" unless status.success?
|
|
221
442
|
|
|
222
|
-
|
|
223
|
-
json = JSON.parse(output, symbolize_names: true)
|
|
224
|
-
|
|
225
|
-
puts " ✅ Found #{json.size} PRs..."
|
|
226
|
-
|
|
227
|
-
json.each do |pr|
|
|
228
|
-
puts "🧐 Updating #{pr[:url]}..."
|
|
229
|
-
output, status = Open3.capture2("gh", "pr", "edit", pr[:number].to_s, "--add-label", "Released")
|
|
230
|
-
raise status.inspect unless status.success?
|
|
231
|
-
puts " ✅ Done!"
|
|
232
|
-
sleep 0.5
|
|
233
|
-
end
|
|
443
|
+
puts "📝 Released #{tag}: #{output.chomp}"
|
|
234
444
|
end
|
|
235
445
|
end
|
|
236
|
-
|
|
237
446
|
namespace :rbs do
|
|
238
447
|
task :watch do
|
|
239
448
|
require "listen"
|