steep 2.0.0 → 2.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c7bef651d95df5415538e6d1a288b29395b2e49bdbabe20f3a79c51669760f7
4
- data.tar.gz: a60d0fddf36b2e962d8c02802ab9f24424a2a428b07e63b5eb6c40c744fc0e71
3
+ metadata.gz: 3836d60ec7ec072655011df06539859dbbca90170c94a15a6f636537c486eba4
4
+ data.tar.gz: '01394e50e3759284559f635e5f2ef34d79a5b4716a6bbebbf07c383cffd14c41'
5
5
  SHA512:
6
- metadata.gz: 66f4985fee2186d7e9a4166b8a54f34272c3d156df4fc315d56ae4246361ba7fb44a77ebd8cf0f85860abe4bc75f5b5be5fad3c36a6183be2a4060cdbdacbc7a
7
- data.tar.gz: ccf6e2b72945cb200fe448532887750f1b89e08a8ad3926cdc50711164aced9fb9fec693a0981825d604592889f46f3cb4bc94e8c23e437e368955e5187c4c8d
6
+ metadata.gz: 66603b9a15c9a6a5f34c1812969d32967d2755c60fe5470d225e13426bfec93f2ff6174a6675c15278fe99fe7d2fec17ae5dbf32d6ab4d9abc6c8bd96ba14166
7
+ data.tar.gz: 0c41cbcdf631c3d5528681b990cad4f36f1dce2ae029aa97ea6e6bbb6199b7af890470c4ca9358cd5152f267cf7f1797d10482e5d5f1e667ae2dce2434fd181f
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`: Standard library dependencies
102
- - `collection_config`: External RBS definitions
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
@@ -29,11 +29,11 @@ Edit the `Steepfile`:
29
29
  target :app do
30
30
  check "lib"
31
31
  signature "sig"
32
-
33
- library "pathname"
34
32
  end
35
33
  ```
36
34
 
35
+ 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.
36
+
37
37
  ### 1. Declare Types
38
38
 
39
39
  Declare types in `.rbs` files in `sig` directory.
@@ -247,7 +247,7 @@ Use `bundle exec rake -T` to see all available tasks.
247
247
 
248
248
  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.
249
249
 
250
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
250
+ 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).
251
251
 
252
252
  ## Contributing
253
253
 
data/Rakefile CHANGED
@@ -29,210 +29,413 @@ namespace :test do
29
29
  end
30
30
  end
31
31
 
32
- Rake::Task[:release].enhance do
33
- Rake::Task[:"release:note"].invoke
34
- Rake::Task[:"release:github"].invoke
35
- Rake::Task[:"release:release-prs"].invoke
36
- end
32
+ # Pull requests with one of these labels are omitted from the changelog.
33
+ CHANGELOG_SKIP_LABELS = ["skip-changelog"]
34
+
35
+ # The tags a release proper starts *after*, rather than at.
36
+ CHANGELOG_PRERELEASE_TAGS = ["v*.pre*", "v*.dev*"]
37
+
38
+ # Resolves the commit-ish the changelog of the release being prepared starts from.
39
+ #
40
+ # `version` is a version number, a tag, or any commit-ish. When it is omitted, the base follows
41
+ # `Steep::VERSION`:
42
+ #
43
+ # * `X.Y.Z.pre.N` documents what changed since `X.Y.Z.pre.N-1`, so it starts from the latest tag.
44
+ # * `X.Y.Z` documents the whole cycle, the prereleases included, so it skips the prerelease tags
45
+ # in between and starts from the previous release proper.
46
+ #
47
+ # This is the step that is easy to get wrong by hand: on a release proper the latest tag is a
48
+ # prerelease, so the obvious default would produce only the tail of the cycle. Passing a version
49
+ # explicitly overrides all of it.
50
+ #
51
+ def changelog_base(version)
52
+ require "open3"
37
53
 
38
- desc "Generate changelog template from GH pull requests"
39
- task :changelog do
40
- major, minor, patch, pre = Steep::VERSION.split(".", 4)
41
- major = major.to_i
42
- minor = minor.to_i
43
- patch = patch.to_i
54
+ from =
55
+ if version
56
+ # `2.1.0` and `v2.1.0` both mean the tag `v2.1.0`, while `master` or a SHA is used as is.
57
+ version.match?(/\A\d/) ? "v#{version}" : version
58
+ else
59
+ command = ["git", "describe", "--tags", "--match", "v*", "--abbrev=0"]
60
+ unless Gem::Version.new(Steep::VERSION).prerelease?
61
+ CHANGELOG_PRERELEASE_TAGS.each { |glob| command.push("--exclude", glob) }
62
+ end
44
63
 
45
- if patch == 0
46
- milestone = "Steep #{major}.#{minor}"
47
- else
48
- milestone = "Steep #{major}.#{minor}.x"
49
- end
64
+ output, status = Open3.capture2(*command)
65
+ raise "🚨 Cannot detect the tag the changelog starts from. Give the previous version explicitly." unless status.success?
66
+ output.chomp
67
+ end
50
68
 
51
- puts "🔍 Finding pull requests that is associated to milestone `#{milestone}`..."
69
+ _, status = Open3.capture2("git", "rev-parse", "--verify", "--quiet", "#{from}^{commit}")
70
+ raise "🚨 No such commit-ish: `#{from}`" unless status.success?
52
71
 
53
- command = [
54
- "gh",
55
- "pr",
56
- "list",
57
- "--json",
58
- "url,title,number",
59
- "--limit=100",
60
- "--search" ,
61
- "milestone:\"#{milestone}\" is:merged sort:updated-desc -label:Released"
62
- ]
72
+ from
73
+ end
63
74
 
75
+ # Runs a GraphQL query against the repository of the working directory.
76
+ #
77
+ # `body` is the selection set inside `repository`, so a query can use the `$owner` and `$name`
78
+ # variables. Returns the contents of `data.repository`.
79
+ #
80
+ def changelog_graphql(body)
64
81
  require "open3"
65
- output, status = Open3.capture2(*command)
66
- raise status.inspect unless status.success?
67
-
68
82
  require "json"
69
- json = JSON.parse(output, symbolize_names: true)
70
83
 
71
- unless json.empty?
72
- puts
73
- json.each do |line|
74
- puts "* #{line[:title]} ([##{line[:number]}](#{line[:url]}))"
84
+ @changelog_repository ||=
85
+ begin
86
+ output, status = Open3.capture2("gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner")
87
+ raise status.inspect unless status.success?
88
+ output.chomp.split("/", 2)
75
89
  end
76
- else
77
- puts " (🤑 There is no *unreleased* pull request associated to the milestone.)"
78
- end
90
+ owner, name = @changelog_repository
91
+
92
+ query = <<~GRAPHQL
93
+ query($owner: String!, $name: String!) {
94
+ repository(owner: $owner, name: $name) {
95
+ #{body}
96
+ }
97
+ }
98
+ GRAPHQL
99
+
100
+ output, status = Open3.capture2(
101
+ "gh", "api", "graphql",
102
+ "-f", "query=#{query}",
103
+ "-f", "owner=#{owner}",
104
+ "-f", "name=#{name}",
105
+ binmode: true
106
+ )
107
+ raise status.inspect unless status.success?
108
+
109
+ # GitHub always answers in UTF-8, while the default external encoding follows the locale. Without
110
+ # this, a pull request body with an emoji fails to parse under `LANG=C`, as in GitHub Actions.
111
+ JSON.parse(output.force_encoding(Encoding::UTF_8), symbolize_names: true).dig(:data, :repository)
79
112
  end
80
113
 
81
- namespace :release do
82
- desc "Ensure release note"
83
- task :note do
84
- version = Gem::Version.new(Steep::VERSION)
85
- major, minor, patch, pre = Steep::VERSION.split(".", 4)
86
- major = major.to_i
87
- minor = minor.to_i
88
- patch = patch.to_i
114
+ # Lists the commits between `from` and `HEAD`, newest first.
115
+ #
116
+ def changelog_commits(from)
117
+ require "open3"
89
118
 
90
- wiki_url = "https://github.com/soutaro/steep/wiki/Release-Note-#{major}.#{minor}"
119
+ output, status = Open3.capture2("git", "log", "--format=%H", "#{from}..HEAD")
120
+ raise status.inspect unless status.success?
91
121
 
92
- puts "🧐 Checking if Release note page already exists..."
122
+ output.lines.map(&:chomp).reject(&:empty?)
123
+ end
93
124
 
94
- unless `curl --silent -o/dev/null --head #{wiki_url} -w '%{http_code}'` == "200"
95
- pre_flag = version.prerelease? ? " --pre" : ""
96
- pre_requirement = version.prerelease? ? ", '~> #{major}.#{minor}.#{patch}.#{pre}'" : ""
125
+ # What `git cherry-pick -x` appends to the message of the commit it creates.
126
+ CHERRY_PICK_ORIGIN = /^\(cherry picked from commit ([0-9a-f]{40})\)$/
127
+
128
+ # Maps the commits that record where they were cherry-picked from to that commit.
129
+ #
130
+ # A backport is a cherry-pick, so on a release branch it is the recorded origin, not the commit
131
+ # itself, that leads to the pull request the change was written and reviewed in. Without this a
132
+ # backported change is attributed to the pull request that carried the backport, which says
133
+ # nothing about the change and is the same for every commit it brought over.
134
+ #
135
+ # A commit backported twice -- the development line, then a release branch -- carries one line
136
+ # per hop, appended in order, so the first one is where the change started.
137
+ #
138
+ def changelog_origins(commits)
139
+ return {} if commits.empty?
97
140
 
98
- puts "----"
141
+ require "open3"
99
142
 
100
- puts <<~PREFIX if patch
101
- **The latest version of Steep #{major}.#{minor} is `#{pre}`.**
143
+ # `--no-walk` prints these commits and nothing else. NUL delimiters keep a commit message --
144
+ # which can contain anything, including what this format looks like -- from being read as the
145
+ # format itself.
146
+ output, status = Open3.capture2("git", "log", "--no-walk", "--format=%H%x00%B%x00", *commits, binmode: true)
147
+ raise status.inspect unless status.success?
102
148
 
103
- PREFIX
149
+ # Commit messages are UTF-8, while the default external encoding follows the locale. Without
150
+ # this, splitting a message that is not ASCII fails under `LANG=C`, as in GitHub Actions.
151
+ output.force_encoding(Encoding::UTF_8)
104
152
 
105
- puts <<~TEMPLATE
106
- Some of the highlights in Steep #{major}.#{minor} are:
153
+ output.split("\0").each_slice(2).each_with_object({}) do |(commit, message), origins|
154
+ commit = commit.to_s.strip
155
+ next if commit.empty?
107
156
 
108
- * New feature 1 (URL)
109
- * New feature 2 (URL)
110
- * New feature 3 (URL)
157
+ origin = message.to_s[CHERRY_PICK_ORIGIN, 1] or next
158
+ origins[commit] = origin
159
+ end
160
+ end
111
161
 
112
- You can install it with `$ gem install steep#{pre_flag}` or using Bundler.
162
+ # Asks GitHub which pull requests each commit came from, so that any merge strategy -- merge
163
+ # commit, squash, or rebase -- is handled without parsing commit messages.
164
+ #
165
+ # Returns `{ oid => [pull request, ...] }`, with an empty array for the commits GitHub has no
166
+ # merged pull request for, including the ones it does not know at all.
167
+ #
168
+ def changelog_associated_pull_requests(oids)
169
+ oids.uniq.each_slice(50).each_with_object({}) do |slice, found|
170
+ aliases = slice.map.with_index do |oid, index|
171
+ <<~GRAPHQL
172
+ c#{index}: object(oid: "#{oid}") {
173
+ ... on Commit {
174
+ associatedPullRequests(first: 10) {
175
+ nodes {
176
+ number title url merged
177
+ labels(first: 100) { nodes { name } }
178
+ }
179
+ }
180
+ }
181
+ }
182
+ GRAPHQL
183
+ end
113
184
 
114
- ```rb
115
- gem 'steep', require: false#{pre_requirement}
116
- ```
185
+ response = changelog_graphql(aliases.join("\n"))
117
186
 
118
- See the [CHANGELOG](https://github.com/soutaro/steep/blob/master/CHANGELOG.md) for the details.
187
+ slice.each_with_index do |oid, index|
188
+ nodes = response.dig(:"c#{index}", :associatedPullRequests, :nodes) || []
119
189
 
120
- ## New feature 1
190
+ found[oid] = nodes.select { |pr| pr[:merged] }.map do |pr|
191
+ { number: pr[:number], title: pr[:title], url: pr[:url], labels: pr.dig(:labels, :nodes).map { |label| label[:name] } }
192
+ end
193
+ end
194
+ end
195
+ end
121
196
 
122
- ## New feature 2
197
+ # Finds the pull requests the commits came from, keeping the order of `commits`.
198
+ #
199
+ # Returns the pull requests for the changelog and the ones omitted by `skip_labels`.
200
+ #
201
+ def changelog_pull_requests(commits, skip_labels: CHANGELOG_SKIP_LABELS)
202
+ origins = changelog_origins(commits)
203
+ found = changelog_associated_pull_requests(commits.map { |commit| origins[commit] || commit })
204
+
205
+ # An origin that leads nowhere -- a commit cherry-picked from a fork, or one that went to the
206
+ # default branch without a pull request -- falls back to the commit in this history, which is
207
+ # at least the backport that brought it here.
208
+ fallbacks = commits.select { |commit| origins[commit] && found.fetch(origins[commit], []).empty? }
209
+ found.update(changelog_associated_pull_requests(fallbacks)) unless fallbacks.empty?
210
+
211
+ pull_requests = {}
212
+ skipped = {}
213
+
214
+ commits.each do |commit|
215
+ prs = found.fetch(origins[commit] || commit, [])
216
+ prs = found.fetch(commit, []) if prs.empty?
217
+
218
+ prs.each do |pr|
219
+ if (pr[:labels] & skip_labels).empty?
220
+ pull_requests[pr[:number]] ||= pr
221
+ else
222
+ skipped[pr[:number]] ||= pr
223
+ end
224
+ end
225
+ end
123
226
 
124
- ## New feature 3
227
+ [pull_requests.values, skipped.values]
228
+ end
125
229
 
126
- ## Diagnostics updates
230
+ # Fetches the details that help classifying the pull requests: the changed files and the body.
231
+ #
232
+ def changelog_pull_request_details(pull_requests)
233
+ pull_requests.each_slice(50).flat_map do |slice|
234
+ aliases = slice.map do |pr|
235
+ <<~GRAPHQL
236
+ p#{pr[:number]}: pullRequest(number: #{pr[:number]}) {
237
+ body
238
+ author { login }
239
+ files(first: 100) {
240
+ nodes { path }
241
+ pageInfo { hasNextPage }
242
+ }
243
+ }
244
+ GRAPHQL
245
+ end
127
246
 
128
- ## Updating Steep
247
+ details = changelog_graphql(aliases.join("\n"))
129
248
 
130
- TEMPLATE
131
- puts "----"
132
- puts
249
+ slice.map do |pr|
250
+ detail = details[:"p#{pr[:number]}"] or next pr
133
251
 
134
- puts " ⏩️ Create the release note with the template: #{wiki_url}"
135
- else
136
- if patch == 0 || version.prerelease?
137
- puts " ⏩️ Open the release note and update it at: #{wiki_url}"
138
- else
139
- puts " ✅ Release note is ready!"
140
- end
252
+ pr.merge(
253
+ author: detail.dig(:author, :login),
254
+ # The body is a hint for writing the changelog, not a copy source. Keep it short.
255
+ body: detail[:body].to_s.strip.slice(0, 1000),
256
+ files: detail.dig(:files, :nodes).map { |file| file[:path] },
257
+ files_truncated: detail.dig(:files, :pageInfo, :hasNextPage)
258
+ )
141
259
  end
142
- puts
143
260
  end
261
+ end
144
262
 
145
- desc "Create GitHub release automatically"
146
- task :github do
147
- version = Gem::Version.new(Steep::VERSION)
148
- major, minor, patch, *_ = Steep::VERSION.split(".")
149
- major = major.to_i
150
- minor = minor.to_i
151
- patch = patch.to_i
263
+ # Reports the pull requests omitted by their label, so that they do not disappear silently.
264
+ #
265
+ def warn_skipped_pull_requests(skipped, skip_labels)
266
+ return if skipped.empty?
152
267
 
153
- puts "✏️ Making a draft release on GitHub..."
268
+ numbers = skipped.map { |pr| "##{pr[:number]}" }
269
+ numbers = numbers.take(20).push("and #{numbers.size - 20} more") if numbers.size > 20
154
270
 
155
- content = File.read(File.join(__dir__, "CHANGELOG.md"))
156
- changelog = content.scan(/^## \d.*?(?=^## \d)/m)[0]
157
- changelog = changelog.sub(/^.*\n^.*\n/, "").rstrip
271
+ $stderr.puts
272
+ $stderr.puts " (⏭️ Skipped #{skipped.size} pull request(s) labeled #{skip_labels.map { |label| "`#{label}`" }.join(" or ")}: #{numbers.join(", ")})"
273
+ end
158
274
 
159
- notes = <<NOTES
160
- [Release note](https://github.com/soutaro/steep/wiki/Release-Note-#{major}.#{minor})
275
+ # Prints the changelog template listing the pull requests merged between `from` and `HEAD`.
276
+ #
277
+ # The changelog goes to STDOUT and everything else goes to STDERR, so that the output can be
278
+ # piped to another command: `rake gem:changelog | pbcopy`
279
+ #
280
+ def print_changelog(from, skip_labels: CHANGELOG_SKIP_LABELS)
281
+ $stderr.puts "🔍 Finding pull requests merged between `#{from}` and `HEAD`..."
282
+
283
+ commits = changelog_commits(from)
284
+ if commits.empty?
285
+ $stderr.puts " (🤔 There is no commit after `#{from}`.)"
286
+ return
287
+ end
161
288
 
162
- #{changelog}
163
- NOTES
289
+ pull_requests, skipped = changelog_pull_requests(commits, skip_labels: skip_labels)
164
290
 
165
- command = [
166
- "gh",
167
- "release",
168
- "create",
169
- "--draft",
170
- "v#{Steep::VERSION}",
171
- "--title=#{Steep::VERSION}",
172
- "--notes=#{notes}"
173
- ]
174
-
175
- if version.prerelease?
176
- command << "--prerelease"
291
+ if pull_requests.empty?
292
+ $stderr.puts " (🤔 No pull request is associated to the commits after `#{from}`.)"
293
+ else
294
+ $stderr.puts
295
+ pull_requests.each do |pr|
296
+ puts "* #{pr[:title]} ([##{pr[:number]}](#{pr[:url]}))"
177
297
  end
298
+ $stdout.flush
299
+ end
178
300
 
179
- require "open3"
180
- output, status = Open3.capture2(*command)
181
- if status.success?
182
- puts " ⏩️ Done! Open #{output.chomp} and publish the release!"
183
- puts
301
+ warn_skipped_pull_requests(skipped, skip_labels)
302
+ end
303
+
304
+ # Prints the same pull requests as `print_changelog` as JSON, with the details that help
305
+ # classifying them into the sections of CHANGELOG.md.
306
+ #
307
+ # This is the input for the release automation, so it always prints a valid JSON document.
308
+ #
309
+ def print_changelog_json(from, skip_labels: CHANGELOG_SKIP_LABELS)
310
+ require "json"
311
+
312
+ $stderr.puts "🔍 Finding pull requests merged between `#{from}` and `HEAD`..."
313
+
314
+ commits = changelog_commits(from)
315
+ pull_requests, skipped = changelog_pull_requests(commits, skip_labels: skip_labels)
316
+ pull_requests = changelog_pull_request_details(pull_requests)
317
+
318
+ $stderr.puts " (📋 #{pull_requests.size} pull request(s))"
319
+
320
+ puts JSON.pretty_generate(
321
+ {
322
+ from: from,
323
+ to: "HEAD",
324
+ pull_requests: pull_requests,
325
+ skipped: skipped
326
+ }
327
+ )
328
+ $stdout.flush
329
+
330
+ warn_skipped_pull_requests(skipped, skip_labels)
331
+ end
332
+
333
+ namespace :gem do
334
+ desc "Generate changelog template from GH pull requests merged since the previous release"
335
+ task :changelog, [:version] do |_task, args|
336
+ print_changelog(changelog_base(args[:version]))
337
+ end
338
+
339
+ namespace :changelog do
340
+ desc "Print the pull requests of `gem:changelog` as JSON, with the changed files and body of each"
341
+ task :json, [:version] do |_task, args|
342
+ print_changelog_json(changelog_base(args[:version]))
184
343
  end
185
344
  end
186
345
 
187
- desc "Add `Released` labels to pull requests associated to the version"
188
- task "release-prs" do
189
- major, minor, patch, pre = Steep::VERSION.split(".", 4)
190
- major = major.to_i
191
- minor = minor.to_i
192
- patch = patch.to_i
346
+ # There are three kinds of release: `X.Y.Z`, `X.Y.Z.pre.N`, and `X.Y.Z.dev.N`. The
347
+ # `.dev.N` ones are cut from the development line for people who need a specific
348
+ # change early; they are not written up in the changelog, so there are no notes to
349
+ # publish and nothing worth announcing.
350
+ def dev_release?(version)
351
+ Gem::Version.new(version).segments.include?("dev")
352
+ end
353
+
354
+ # The body of the topmost section of CHANGELOG.md, which is the release being
355
+ # prepared, minus its own heading.
356
+ #
357
+ # The encoding is explicit because the default external encoding follows the
358
+ # locale, and the changelog is not ASCII.
359
+ #
360
+ def changelog_section(version)
361
+ content = File.read(File.join(__dir__, "CHANGELOG.md"), encoding: Encoding::UTF_8)
362
+ section = content.scan(/^## \d.*?(?=^## \d)/m)[0] or raise "🚨 Cannot find a release section in CHANGELOG.md"
363
+ heading, _, body = section.partition("\n")
364
+ heading.include?(version) or raise "🚨 CHANGELOG.md starts with `#{heading.strip}`, which is not #{version}"
365
+ body.strip
366
+ end
193
367
 
194
- if patch == 0
195
- milestone = "Steep #{major}.#{minor}"
368
+ desc "Check that the working tree is ready to be released as the given version"
369
+ task :check_release, [:version] do |_task, args|
370
+ version = args[:version] or raise "🚨 Pass the version being released: `rake 'gem:check_release[2.1.0]'`"
371
+ Gem::Version.correct?(version) or raise "🚨 `#{version}` is not a version number."
372
+
373
+ # The version being released and the version the commit declares are stated
374
+ # separately -- one by whoever starts the release, one by the commit itself --
375
+ # so that releasing the wrong commit, or releasing the right one under the wrong
376
+ # name, fails here rather than on RubyGems.
377
+ version == Steep::VERSION or
378
+ raise "🚨 Releasing #{version}, but this commit declares `Steep::VERSION = #{Steep::VERSION.inspect}`."
379
+
380
+ if dev_release?(version)
381
+ puts "✅ #{version} is the version of this commit. It is a dev release, so CHANGELOG.md is not checked."
196
382
  else
197
- milestone = "Steep #{major}.#{minor}.x"
383
+ changelog_section(version)
384
+ puts "✅ #{version} is the version of this commit, and CHANGELOG.md documents it."
198
385
  end
386
+ end
387
+
388
+ desc "Create and push the `vX.Y.Z` tag for Steep::VERSION"
389
+ task :tag do
390
+ tag = "v#{Steep::VERSION}"
391
+
392
+ # Annotated, so that the tag carries its own author and date rather than
393
+ # borrowing the tagged commit's.
394
+ sh "git", "tag", "--annotate", "--message", "Steep #{Steep::VERSION}", tag
395
+ sh "git", "push", "origin", tag
396
+
397
+ puts "🏷️ Pushed #{tag}."
398
+ end
399
+
400
+ desc "Publish the GitHub release for Steep::VERSION, unless it is a `.dev.` version"
401
+ task :gh_release do
402
+ require "open3"
403
+
404
+ version = Gem::Version.new(Steep::VERSION)
405
+ major, minor, *_ = Steep::VERSION.split(".")
406
+ tag = "v#{Steep::VERSION}"
199
407
 
200
- if pre =~ /dev/
201
- puts "🔍 Skipping `Released` tags because `dev` release: `#{pre}`"
408
+ if dev_release?(Steep::VERSION)
409
+ puts "⏭️ #{Steep::VERSION} is a dev release, so there is no GitHub release to publish."
202
410
  next
203
411
  end
204
412
 
205
- puts "🔍 Finding pull requests that is associated to milestone `#{milestone}`..."
413
+ # The release is created against an existing tag, so that the artifacts and the
414
+ # notes describe a commit that is already immutable.
415
+ _, status = Open3.capture2("git", "rev-parse", "--verify", "--quiet", "#{tag}^{commit}")
416
+ raise "🚨 No such tag: `#{tag}`. Tag the release before creating the GitHub release." unless status.success?
206
417
 
418
+ notes = <<~NOTES
419
+ [Release note](https://github.com/soutaro/steep/wiki/Release-Note-#{major}.#{minor})
420
+
421
+ #{changelog_section(Steep::VERSION)}
422
+ NOTES
423
+
424
+ # Published rather than drafted: the notes are the changelog section that was
425
+ # already reviewed in the release pull request, so there is nothing left to edit.
207
426
  command = [
208
- "gh",
209
- "pr",
210
- "list",
211
- "--json",
212
- "url,title,number",
213
- "--search" ,
214
- "milestone:\"#{milestone}\" is:merged sort:updated-desc -label:Released"
427
+ "gh", "release", "create", tag,
428
+ "--title=#{Steep::VERSION}",
429
+ "--notes=#{notes}"
215
430
  ]
431
+ command << "--prerelease" if version.prerelease?
216
432
 
217
- require "open3"
218
433
  output, status = Open3.capture2(*command)
219
- raise status.inspect unless status.success?
434
+ raise "🚨 `gh release create` failed: #{status.inspect}" unless status.success?
220
435
 
221
- require "json"
222
- json = JSON.parse(output, symbolize_names: true)
223
-
224
- puts " ✅ Found #{json.size} PRs..."
225
-
226
- json.each do |pr|
227
- puts "🧐 Updating #{pr[:url]}..."
228
- output, status = Open3.capture2("gh", "pr", "edit", pr[:number].to_s, "--add-label", "Released")
229
- raise status.inspect unless status.success?
230
- puts " ✅ Done!"
231
- sleep 0.5
232
- end
436
+ puts "📝 Released #{tag}: #{output.chomp}"
233
437
  end
234
438
  end
235
-
236
439
  namespace :rbs do
237
440
  task :watch do
238
441
  require "listen"