toys-release 0.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 +7 -0
- data/.yardopts +11 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.md +21 -0
- data/README.md +87 -0
- data/docs/guide.md +7 -0
- data/lib/toys/release/version.rb +11 -0
- data/lib/toys-release.rb +23 -0
- data/toys/.data/templates/gh-pages-404.html.erb +25 -0
- data/toys/.data/templates/gh-pages-empty.html.erb +11 -0
- data/toys/.data/templates/gh-pages-gitignore.erb +1 -0
- data/toys/.data/templates/gh-pages-index.html.erb +15 -0
- data/toys/.data/templates/release-hook-on-closed.yml.erb +34 -0
- data/toys/.data/templates/release-hook-on-open.yml.erb +30 -0
- data/toys/.data/templates/release-hook-on-push.yml.erb +32 -0
- data/toys/.data/templates/release-perform.yml.erb +46 -0
- data/toys/.data/templates/release-request.yml.erb +37 -0
- data/toys/.data/templates/release-retry.yml.erb +42 -0
- data/toys/.lib/toys/release/artifact_dir.rb +70 -0
- data/toys/.lib/toys/release/change_set.rb +259 -0
- data/toys/.lib/toys/release/changelog_file.rb +136 -0
- data/toys/.lib/toys/release/component.rb +388 -0
- data/toys/.lib/toys/release/environment_utils.rb +246 -0
- data/toys/.lib/toys/release/performer.rb +346 -0
- data/toys/.lib/toys/release/pull_request.rb +154 -0
- data/toys/.lib/toys/release/repo_settings.rb +855 -0
- data/toys/.lib/toys/release/repository.rb +661 -0
- data/toys/.lib/toys/release/request_logic.rb +217 -0
- data/toys/.lib/toys/release/request_spec.rb +188 -0
- data/toys/.lib/toys/release/semver.rb +112 -0
- data/toys/.lib/toys/release/steps.rb +580 -0
- data/toys/.lib/toys/release/version_rb_file.rb +91 -0
- data/toys/.toys.rb +5 -0
- data/toys/_onclosed.rb +113 -0
- data/toys/_onopen.rb +158 -0
- data/toys/_onpush.rb +57 -0
- data/toys/create-labels.rb +115 -0
- data/toys/gen-gh-pages.rb +146 -0
- data/toys/gen-settings.rb +46 -0
- data/toys/gen-workflows.rb +70 -0
- data/toys/perform.rb +152 -0
- data/toys/request.rb +162 -0
- data/toys/retry.rb +133 -0
- metadata +106 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "semver"
|
|
4
|
+
|
|
5
|
+
module Toys
|
|
6
|
+
module Release
|
|
7
|
+
##
|
|
8
|
+
# Represents a set of changes from commit messages.
|
|
9
|
+
#
|
|
10
|
+
# Organizes the change commit messages into groups, and computes the semver
|
|
11
|
+
# release type.
|
|
12
|
+
#
|
|
13
|
+
class ChangeSet
|
|
14
|
+
##
|
|
15
|
+
# Create a new ChangeSet
|
|
16
|
+
#
|
|
17
|
+
# @param settings [RepoSettings] the repo settings
|
|
18
|
+
#
|
|
19
|
+
def initialize(settings)
|
|
20
|
+
@release_commit_tags = settings.release_commit_tags
|
|
21
|
+
@breaking_change_header = settings.breaking_change_header
|
|
22
|
+
@no_significant_updates_notice = settings.no_significant_updates_notice
|
|
23
|
+
@semver = Semver::NONE
|
|
24
|
+
@change_groups = nil
|
|
25
|
+
@inputs = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
##
|
|
29
|
+
# Add a commit.
|
|
30
|
+
#
|
|
31
|
+
# @param sha [String] The SHA for the commit.
|
|
32
|
+
# @param message [String] The commit message.
|
|
33
|
+
#
|
|
34
|
+
def add_message(sha, message)
|
|
35
|
+
raise "ChangeSet locked" if finished?
|
|
36
|
+
lines = message.split("\n")
|
|
37
|
+
return if lines.empty?
|
|
38
|
+
input = Input.new(sha)
|
|
39
|
+
lines.each { |line| analyze_line(line, input) }
|
|
40
|
+
@inputs << input
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
##
|
|
45
|
+
# Finish constructing a change set. After this method, new commit messages
|
|
46
|
+
# cannot be added.
|
|
47
|
+
#
|
|
48
|
+
def finish # rubocop:disable Metrics/AbcSize
|
|
49
|
+
raise "ChangeSet locked" if finished?
|
|
50
|
+
@semver = Semver::NONE
|
|
51
|
+
change_groups = {breaking: Group.new(@breaking_change_header)}
|
|
52
|
+
@release_commit_tags.each_value do |tag_info|
|
|
53
|
+
tag_info.all_headers.each { |header| change_groups[header] = Group.new(header) }
|
|
54
|
+
end
|
|
55
|
+
@inputs.each do |input|
|
|
56
|
+
@semver = input.semver if input.semver > @semver
|
|
57
|
+
input.changes.each do |(header, change)|
|
|
58
|
+
change_groups.fetch(header, nil)&.add([change])
|
|
59
|
+
end
|
|
60
|
+
change_groups[:breaking].add(input.breaks)
|
|
61
|
+
end
|
|
62
|
+
@change_groups = change_groups.values.find_all { |group| !group.empty? }
|
|
63
|
+
if @change_groups.empty? && @semver != Semver::NONE
|
|
64
|
+
@change_groups << Group.new(nil).add(@no_significant_updates_notice)
|
|
65
|
+
end
|
|
66
|
+
@inputs = nil
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
##
|
|
71
|
+
# Force a non-empty changeset even if there are no significant updates.
|
|
72
|
+
# May be called only on a finished changeset.
|
|
73
|
+
#
|
|
74
|
+
def force_release!
|
|
75
|
+
raise "ChangeSet not finished" unless finished?
|
|
76
|
+
if @change_groups.empty?
|
|
77
|
+
@semver = Semver::PATCH
|
|
78
|
+
@change_groups << Group.new(nil).add(@no_significant_updates_notice)
|
|
79
|
+
end
|
|
80
|
+
self
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
##
|
|
84
|
+
# @return [boolean] Whether this change set is finished.
|
|
85
|
+
#
|
|
86
|
+
def finished?
|
|
87
|
+
@inputs.nil?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
##
|
|
91
|
+
# @return [boolean] Whether this change set is empty.
|
|
92
|
+
#
|
|
93
|
+
def empty?
|
|
94
|
+
@change_groups.empty?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
##
|
|
98
|
+
# @return [Integer] The semver change.
|
|
99
|
+
#
|
|
100
|
+
attr_reader :semver
|
|
101
|
+
|
|
102
|
+
##
|
|
103
|
+
# @return [Array<Group>] An array of change groups, in order. Returns nil
|
|
104
|
+
# if the change set is not finished.
|
|
105
|
+
#
|
|
106
|
+
attr_reader :change_groups
|
|
107
|
+
|
|
108
|
+
##
|
|
109
|
+
# Suggest a next version based on the changeset's changes.
|
|
110
|
+
#
|
|
111
|
+
# @param last [::Gem::Version,nil] The last released version, or nil if
|
|
112
|
+
# no releases have happened yet.
|
|
113
|
+
# @return [::Gem::Version,nil] Suggested next version, or nil for none.
|
|
114
|
+
#
|
|
115
|
+
def suggested_version(last)
|
|
116
|
+
raise "ChangeSet not finished" unless finished?
|
|
117
|
+
return nil unless semver.significant?
|
|
118
|
+
semver.bump(last)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
##
|
|
122
|
+
# A group of changes with the same header.
|
|
123
|
+
#
|
|
124
|
+
# These changes should be rendered together in a changelog, either as a
|
|
125
|
+
# list of changes under a heading, or as a list of changes, each preceded
|
|
126
|
+
# by the header as a prefix.
|
|
127
|
+
#
|
|
128
|
+
class Group
|
|
129
|
+
def initialize(header)
|
|
130
|
+
@header = header
|
|
131
|
+
@changes = []
|
|
132
|
+
@prefixed_changes = nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
##
|
|
136
|
+
# @return [String] Header/prefix for changes in this group. May be nil
|
|
137
|
+
# for no header.
|
|
138
|
+
#
|
|
139
|
+
attr_reader :header
|
|
140
|
+
|
|
141
|
+
##
|
|
142
|
+
# @return [Array<String>] Array of individual changes, in order.
|
|
143
|
+
#
|
|
144
|
+
attr_reader :changes
|
|
145
|
+
|
|
146
|
+
##
|
|
147
|
+
# @return [Array<String>] Array of changes prefixed by the header.
|
|
148
|
+
#
|
|
149
|
+
def prefixed_changes
|
|
150
|
+
@prefixed_changes ||= changes.map { |change| header ? "#{header}: #{change}" : change }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
##
|
|
154
|
+
# @return [boolean] Whether this group is empty.
|
|
155
|
+
#
|
|
156
|
+
def empty?
|
|
157
|
+
changes.empty?
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# @private
|
|
161
|
+
def add(chs)
|
|
162
|
+
@changes.concat(Array(chs))
|
|
163
|
+
self
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# @private
|
|
167
|
+
def to_s
|
|
168
|
+
prefixed_changes.join("\n")
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# @private
|
|
173
|
+
def to_s
|
|
174
|
+
(["Semver: #{semver}"] + change_groups).join("\n")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
def analyze_line(line, input)
|
|
180
|
+
match = /^(?<tag>[\w-]+|BREAKING CHANGE)(?:\((?<scope>[^()]+)\))?(?<bang>!?):\s+(?<content>.*)$/.match(line)
|
|
181
|
+
return unless match
|
|
182
|
+
case match[:tag]
|
|
183
|
+
when /^BREAKING[\s_-]CHANGE$/
|
|
184
|
+
input.apply_breaking_change(match[:content])
|
|
185
|
+
when /^semver-change$/i
|
|
186
|
+
input.apply_semver_change(match[:content].split.first)
|
|
187
|
+
when /^revert-commit$/i
|
|
188
|
+
@inputs.delete_if { |elem| elem.sha.start_with?(match[:content].split.first) }
|
|
189
|
+
else
|
|
190
|
+
tag_info = @release_commit_tags[match[:tag]]
|
|
191
|
+
input.apply_commit(tag_info, match[:scope], match[:bang], match[:content])
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
##
|
|
196
|
+
# @private
|
|
197
|
+
# Analyzed commit info
|
|
198
|
+
#
|
|
199
|
+
class Input
|
|
200
|
+
# @private
|
|
201
|
+
def initialize(sha)
|
|
202
|
+
@sha = sha
|
|
203
|
+
@changes = []
|
|
204
|
+
@breaks = []
|
|
205
|
+
@semver = Semver::NONE
|
|
206
|
+
@semver_locked = false
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
attr_reader :sha
|
|
210
|
+
attr_reader :changes
|
|
211
|
+
attr_reader :breaks
|
|
212
|
+
attr_reader :semver
|
|
213
|
+
attr_reader :semver_locked
|
|
214
|
+
|
|
215
|
+
# @private
|
|
216
|
+
def apply_breaking_change(value)
|
|
217
|
+
@semver = Semver::MAJOR unless semver_locked
|
|
218
|
+
@breaks << normalize_description(value, delete_pr_number: true)
|
|
219
|
+
self
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# @private
|
|
223
|
+
def apply_semver_change(value)
|
|
224
|
+
semver = Semver.for_name(value)
|
|
225
|
+
if semver
|
|
226
|
+
@semver = semver
|
|
227
|
+
@semver_locked = true
|
|
228
|
+
end
|
|
229
|
+
self
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# @private
|
|
233
|
+
def apply_commit(tag_info, scope, bang, description)
|
|
234
|
+
description = normalize_description(description, delete_pr_number: true)
|
|
235
|
+
if tag_info
|
|
236
|
+
commit_header = tag_info.header(scope)
|
|
237
|
+
commit_semver = tag_info.semver(scope)
|
|
238
|
+
@changes << [commit_header, description] if commit_header
|
|
239
|
+
@semver = commit_semver if !@semver_locked && (commit_semver > @semver)
|
|
240
|
+
end
|
|
241
|
+
if bang == "!"
|
|
242
|
+
@semver = Semver::MAJOR unless @semver_locked
|
|
243
|
+
@breaks << description
|
|
244
|
+
end
|
|
245
|
+
self
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
private
|
|
249
|
+
|
|
250
|
+
def normalize_description(description, delete_pr_number: false)
|
|
251
|
+
match = /^([a-z])(.*)$/.match(description)
|
|
252
|
+
description = match[1].upcase + match[2] if match
|
|
253
|
+
description = description.gsub(/\s*\(#\d+\)$/, "") if delete_pr_number
|
|
254
|
+
description
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toys
|
|
4
|
+
module Release
|
|
5
|
+
##
|
|
6
|
+
# Represents a changelog read from a file.
|
|
7
|
+
#
|
|
8
|
+
class ChangelogFile
|
|
9
|
+
##
|
|
10
|
+
# @return [String] The default header used when there is no changelog.
|
|
11
|
+
#
|
|
12
|
+
DEFAULT_HEADER = "# Changelog\n"
|
|
13
|
+
|
|
14
|
+
##
|
|
15
|
+
# Create a changelog file object given a file path
|
|
16
|
+
#
|
|
17
|
+
# @param path [String] File path
|
|
18
|
+
# @param environment_utils [Toys::Release::EnvironmentUtils]
|
|
19
|
+
#
|
|
20
|
+
def initialize(path, environment_utils)
|
|
21
|
+
@path = path
|
|
22
|
+
@utils = environment_utils
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
##
|
|
26
|
+
# @return [String] Path to the changelog file
|
|
27
|
+
#
|
|
28
|
+
attr_reader :path
|
|
29
|
+
|
|
30
|
+
##
|
|
31
|
+
# @return [boolean] Whether the file exists
|
|
32
|
+
#
|
|
33
|
+
def exists?
|
|
34
|
+
::File.file?(path)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
##
|
|
38
|
+
# @return [String] Current contents of the changelog
|
|
39
|
+
# @return [nil] if the changelog file doesn't exist
|
|
40
|
+
#
|
|
41
|
+
def content
|
|
42
|
+
::File.file?(path) ? ::File.read(path) : nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
##
|
|
46
|
+
# @return [::Gem::Version,nil] Current latest version from the changelog
|
|
47
|
+
#
|
|
48
|
+
def current_version
|
|
49
|
+
ChangelogFile.current_version_from_content(content)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
##
|
|
53
|
+
# Reads the latest changelog entry and verifies that it accurately
|
|
54
|
+
# reflects the given version.
|
|
55
|
+
#
|
|
56
|
+
# @param version [String,::Gem::Version] Release version to verify.
|
|
57
|
+
# @return [String] The multiline changelog entry, or the empty string if
|
|
58
|
+
# there are no entries.
|
|
59
|
+
#
|
|
60
|
+
def read_and_verify_latest_entry(version) # rubocop:disable Metrics/MethodLength
|
|
61
|
+
version = version.to_s
|
|
62
|
+
@utils.log("Verifying #{path} changelog content...")
|
|
63
|
+
today = ::Time.now.strftime("%Y-%m-%d")
|
|
64
|
+
entry = []
|
|
65
|
+
state = :start
|
|
66
|
+
::File.readlines(@path).each do |line|
|
|
67
|
+
case state
|
|
68
|
+
when :start
|
|
69
|
+
case line
|
|
70
|
+
when %r{^### v#{::Regexp.escape(version)} / \d\d\d\d-\d\d-\d\d\n$}
|
|
71
|
+
entry << line
|
|
72
|
+
state = :during
|
|
73
|
+
when /^### /
|
|
74
|
+
@utils.error("The first changelog entry in #{path} isn't for version #{version}.",
|
|
75
|
+
"It should start with:",
|
|
76
|
+
"### v#{version} / #{today}",
|
|
77
|
+
"But it actually starts with:",
|
|
78
|
+
line)
|
|
79
|
+
entry << line
|
|
80
|
+
state = :during
|
|
81
|
+
end
|
|
82
|
+
when :during
|
|
83
|
+
break if line =~ /^### /
|
|
84
|
+
entry << line
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
if entry.empty?
|
|
88
|
+
@utils.error("The changelog #{path} doesn't have any entries.",
|
|
89
|
+
"The first changelog entry should start with:",
|
|
90
|
+
"### v#{version} / #{today}")
|
|
91
|
+
else
|
|
92
|
+
@utils.log("Changelog OK")
|
|
93
|
+
end
|
|
94
|
+
entry.join
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
##
|
|
98
|
+
# Append a new entry to the changelog.
|
|
99
|
+
#
|
|
100
|
+
# @param changeset [ChangeSet] The changeset.
|
|
101
|
+
# @param version [String] The release version.
|
|
102
|
+
# @param date [String] The date. If not provided, uses the current UTC.
|
|
103
|
+
#
|
|
104
|
+
def append(changeset, version, date: nil)
|
|
105
|
+
date ||= ::Time.now.utc
|
|
106
|
+
date = date.strftime("%Y-%m-%d") if date.respond_to?(:strftime)
|
|
107
|
+
new_entry = [
|
|
108
|
+
"### v#{version} / #{date}",
|
|
109
|
+
"",
|
|
110
|
+
]
|
|
111
|
+
changeset.change_groups.each do |group|
|
|
112
|
+
new_entry.concat(group.prefixed_changes.map { |line| "* #{line}" })
|
|
113
|
+
end
|
|
114
|
+
new_entry = new_entry.join("\n")
|
|
115
|
+
old_content = content || DEFAULT_HEADER
|
|
116
|
+
new_content = old_content.sub(%r{^(### v\S+ / \d\d\d\d-\d\d-\d\d)$}, "#{new_entry}\n\n\\1")
|
|
117
|
+
if new_content == old_content
|
|
118
|
+
new_content = old_content.sub(/\n+\z/, "\n\n#{new_entry}\n")
|
|
119
|
+
end
|
|
120
|
+
::File.write(path, new_content)
|
|
121
|
+
self
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
##
|
|
125
|
+
# Returns the current version from the given file content
|
|
126
|
+
#
|
|
127
|
+
# @param content [String] File contents
|
|
128
|
+
# @return [::Gem::Version] Latest version in the changelog
|
|
129
|
+
#
|
|
130
|
+
def self.current_version_from_content(content)
|
|
131
|
+
match = %r{### v(\d+(?:\.[a-zA-Z0-9]+)+) / \d\d\d\d-\d\d-\d\d}.match(content)
|
|
132
|
+
match ? ::Gem::Version.new(match[1]) : nil
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|