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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +11 -0
  3. data/CHANGELOG.md +5 -0
  4. data/LICENSE.md +21 -0
  5. data/README.md +87 -0
  6. data/docs/guide.md +7 -0
  7. data/lib/toys/release/version.rb +11 -0
  8. data/lib/toys-release.rb +23 -0
  9. data/toys/.data/templates/gh-pages-404.html.erb +25 -0
  10. data/toys/.data/templates/gh-pages-empty.html.erb +11 -0
  11. data/toys/.data/templates/gh-pages-gitignore.erb +1 -0
  12. data/toys/.data/templates/gh-pages-index.html.erb +15 -0
  13. data/toys/.data/templates/release-hook-on-closed.yml.erb +34 -0
  14. data/toys/.data/templates/release-hook-on-open.yml.erb +30 -0
  15. data/toys/.data/templates/release-hook-on-push.yml.erb +32 -0
  16. data/toys/.data/templates/release-perform.yml.erb +46 -0
  17. data/toys/.data/templates/release-request.yml.erb +37 -0
  18. data/toys/.data/templates/release-retry.yml.erb +42 -0
  19. data/toys/.lib/toys/release/artifact_dir.rb +70 -0
  20. data/toys/.lib/toys/release/change_set.rb +259 -0
  21. data/toys/.lib/toys/release/changelog_file.rb +136 -0
  22. data/toys/.lib/toys/release/component.rb +388 -0
  23. data/toys/.lib/toys/release/environment_utils.rb +246 -0
  24. data/toys/.lib/toys/release/performer.rb +346 -0
  25. data/toys/.lib/toys/release/pull_request.rb +154 -0
  26. data/toys/.lib/toys/release/repo_settings.rb +855 -0
  27. data/toys/.lib/toys/release/repository.rb +661 -0
  28. data/toys/.lib/toys/release/request_logic.rb +217 -0
  29. data/toys/.lib/toys/release/request_spec.rb +188 -0
  30. data/toys/.lib/toys/release/semver.rb +112 -0
  31. data/toys/.lib/toys/release/steps.rb +580 -0
  32. data/toys/.lib/toys/release/version_rb_file.rb +91 -0
  33. data/toys/.toys.rb +5 -0
  34. data/toys/_onclosed.rb +113 -0
  35. data/toys/_onopen.rb +158 -0
  36. data/toys/_onpush.rb +57 -0
  37. data/toys/create-labels.rb +115 -0
  38. data/toys/gen-gh-pages.rb +146 -0
  39. data/toys/gen-settings.rb +46 -0
  40. data/toys/gen-workflows.rb +70 -0
  41. data/toys/perform.rb +152 -0
  42. data/toys/request.rb +162 -0
  43. data/toys/retry.rb +133 -0
  44. metadata +106 -0
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toys
4
+ module Release
5
+ ##
6
+ # Miscellaneous logic related to release requests.
7
+ #
8
+ class RequestLogic
9
+ ##
10
+ # Construct a RequestLogic
11
+ #
12
+ # @param repository [Toys::Release::Repository]
13
+ # @param request_spec [Toys::Release::RequestSpec]
14
+ # @param target_branch [String] Optional target branch. Defaults to the
15
+ # current branch.
16
+ #
17
+ def initialize(repository, request_spec, target_branch: nil)
18
+ @repository = repository
19
+ @settings = repository.settings
20
+ @utils = repository.utils
21
+ @request_spec = request_spec
22
+ @target_branch = target_branch || @repository.current_branch
23
+ end
24
+
25
+ ##
26
+ # Perform component verification, including:
27
+ #
28
+ # * That there is at least one component to release in the request spec
29
+ # * That each component has not already released the specified version
30
+ #
31
+ def verify_component_status
32
+ if @request_spec.empty?
33
+ @utils.error("No components to release.")
34
+ end
35
+ @utils.accumulate_errors("One or more components was in an inconsistent state") do
36
+ @request_spec.resolved_components.each do |component_spec|
37
+ component = @repository.component_named(component_spec.component_name)
38
+ changelog_version = component.changelog_file.current_version
39
+ if changelog_version && changelog_version >= component_spec.version
40
+ @utils.error("Cannot add version #{component_spec.version} to #{component.name} changelog because the" \
41
+ " existing changelog already contains version #{changelog_version}.")
42
+ end
43
+ constant_version = component.version_rb_file.current_version
44
+ if constant_version >= component_spec.version
45
+ @utils.error("Cannot change #{component.name} version constant to #{component_spec.version} because the" \
46
+ " existing version constant is already at #{constant_version}.")
47
+ end
48
+ end
49
+ end
50
+ self
51
+ end
52
+
53
+ ##
54
+ # Attempt to verify that no other release pull request is already open
55
+ # for this release.
56
+ # This is currently somewhat conservative. If any multi-release pull
57
+ # request is already open for the target branch, it will be noted as
58
+ # conflicting, even if it doesn't actually overlap. We don't currently
59
+ # have logic to dig into the existing pull requests and determine which
60
+ # components they actually want to release.
61
+ #
62
+ def verify_pull_request_status
63
+ @utils.accumulate_errors("One or more existing release pull requests conflicts with this release") do
64
+ existing_prs = @repository.find_release_prs(branch: @target_branch)
65
+ if @request_spec.single_component?
66
+ component_name = @request_spec.resolved_components.first.component_name
67
+ release_branch_name = @repository.release_branch_name(@target_branch, component_name)
68
+ existing_prs.each do |pr|
69
+ if pr.head_ref == release_branch_name
70
+ @utils.error("A release pull request (##{pr.number}) is already open for #{component_name}")
71
+ elsif pr.head_ref =~ %r{release/multi/\d{14}-\d{6}/#{@target_branch}}
72
+ @utils.error("A release pull request (##{pr.number}) is already open for multiple components")
73
+ end
74
+ end
75
+ else
76
+ existing_prs.each do |pr|
77
+ if pr.head_ref.end_with?("/#{@target_branch}")
78
+ @utils.error("A release pull request (##{pr.number}) is already open")
79
+ end
80
+ end
81
+ end
82
+ end
83
+ self
84
+ end
85
+
86
+ ##
87
+ # @return [String] A release branch name for this release
88
+ #
89
+ def determine_release_branch
90
+ if @request_spec.single_component?
91
+ @repository.release_branch_name(@target_branch, @request_spec.resolved_components[0].component_name)
92
+ else
93
+ @repository.multi_release_branch_name(@target_branch)
94
+ end
95
+ end
96
+
97
+ ##
98
+ # @return [String] A commit title for this release
99
+ #
100
+ def build_commit_title
101
+ if @request_spec.single_component?
102
+ "release: Release #{format_component_info(@request_spec.resolved_components[0])}"
103
+ else
104
+ "release: Release #{@request_spec.resolved_components.size} items"
105
+ end
106
+ end
107
+
108
+ ##
109
+ # @return [String] Commit details for this release
110
+ #
111
+ def build_commit_details
112
+ if @request_spec.single_component?
113
+ ""
114
+ else
115
+ lines = @request_spec.resolved_components.map do |resolved_component|
116
+ "* #{format_component_info(resolved_component)}"
117
+ end
118
+ lines.join("\n")
119
+ end
120
+ end
121
+
122
+ ##
123
+ # @return [String] Pull reqeust body for this release
124
+ #
125
+ def build_pr_body
126
+ if @settings.enable_release_automation?
127
+ build_automation_pr_body
128
+ else
129
+ build_standalone_pr_body
130
+ end
131
+ end
132
+
133
+ ##
134
+ # @return [Array<String>] The set of labels to apply to a pull request
135
+ #
136
+ def determine_pr_labels
137
+ return unless @settings.enable_release_automation?
138
+ [@settings.release_pending_label]
139
+ end
140
+
141
+ ##
142
+ # Go through and update changelog and version files for each component.
143
+ #
144
+ def change_files
145
+ @request_spec.resolved_components.each do |resolved_component|
146
+ component = @repository.component_named(resolved_component.component_name)
147
+ component.changelog_file.append(resolved_component.change_set, resolved_component.version)
148
+ component.version_rb_file.update_version(resolved_component.version)
149
+ end
150
+ end
151
+
152
+ private
153
+
154
+ def format_component_info(resolved_component, bold: false)
155
+ last_release = resolved_component.last_version ? "was #{resolved_component.last_version}" : "initial release"
156
+ decor = bold ? "**" : ""
157
+ "#{decor}#{resolved_component.component_name} #{resolved_component.version}#{decor} (#{last_release})"
158
+ end
159
+
160
+ def build_automation_pr_body
161
+ <<~STR
162
+ #{build_pr_body_header}
163
+
164
+ * To confirm this release, merge this pull request, ensuring the \
165
+ #{@settings.release_pending_label.inspect} label is set. The release \
166
+ script will trigger automatically on merge.
167
+ * To abort this release, close this pull request without merging.
168
+
169
+ #{build_pr_body_footer}
170
+ STR
171
+ end
172
+
173
+ def build_standalone_pr_body
174
+ <<~STR
175
+ #{build_pr_body_header}
176
+
177
+ You can run the `release perform` script once these changes are merged.
178
+
179
+ #{build_pr_body_footer}
180
+ STR
181
+ end
182
+
183
+ def build_pr_body_header
184
+ lines = [
185
+ "This pull request prepares new releases for the following components:",
186
+ "",
187
+ ]
188
+ @request_spec.resolved_components.each do |resolved_component|
189
+ lines << " * #{format_component_info(resolved_component, bold: true)}"
190
+ end
191
+ lines << ""
192
+ lines <<
193
+ "For each releasable component, this pull request modifies the" \
194
+ " version and provides an initial changelog entry based on" \
195
+ " [conventional commit](https://conventionalcommits.org) messages." \
196
+ " You can edit these changes before merging, to release a different" \
197
+ " version or to alter the changelog text."
198
+ lines.join("\n")
199
+ end
200
+
201
+ def build_pr_body_footer
202
+ lines = ["The generated changelog entries have been copied below:"]
203
+ @request_spec.resolved_components.each do |resolved_component|
204
+ lines << ""
205
+ lines << "----"
206
+ lines << ""
207
+ lines << "## #{resolved_component.component_name}"
208
+ lines << ""
209
+ resolved_component.change_set.change_groups.each do |group|
210
+ lines.concat(group.prefixed_changes.map { |line| " * #{line}" })
211
+ end
212
+ end
213
+ lines.join("\n")
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "semver"
4
+
5
+ module Toys
6
+ module Release
7
+ ##
8
+ # Represents a release request specification.
9
+ #
10
+ class RequestSpec
11
+ ##
12
+ # Details about the resolved request for a particular component
13
+ #
14
+ # @!attribute [r] component_name
15
+ # @return [String]
16
+ #
17
+ # @!attribute [r] change_set
18
+ # @return [Toys::Release::ChangeSet]
19
+ #
20
+ # @!attribute [r] last_version
21
+ # @return [::Gem::Version, nil]
22
+ #
23
+ # @!attribute [r] version
24
+ # @return [::Gem::Version]
25
+ #
26
+ ResolvedComponent = ::Struct.new :component_name, :change_set, :last_version, :version
27
+
28
+ ##
29
+ # Create an empty request.
30
+ #
31
+ # @param environment_utils [Toys::Release::EnvironmentUtils]
32
+ #
33
+ def initialize(environment_utils)
34
+ @utils = environment_utils
35
+ @resolved_components = nil
36
+ @requested_components = {}
37
+ @release_sha = nil
38
+ end
39
+
40
+ ##
41
+ # @return [boolean] Whether the request has been resolved.
42
+ #
43
+ def resolved?
44
+ !@resolved_components.nil?
45
+ end
46
+
47
+ ##
48
+ # @return [boolean] Whether the request is empty, i.e. has no changed
49
+ # components
50
+ #
51
+ def empty?
52
+ resolved_components.empty?
53
+ end
54
+
55
+ ##
56
+ # @return [boolean] Whether the request is for a single component
57
+ #
58
+ def single_component?
59
+ resolved_components.size == 1
60
+ end
61
+
62
+ ##
63
+ # @return [Array<ResolvedComponent>] Info about the components to release.
64
+ # Valid only after resolution.
65
+ #
66
+ attr_reader :resolved_components
67
+
68
+ ##
69
+ # @return [String] The git SHA at which the release will be cut.
70
+ # Valid only after resolution.
71
+ #
72
+ attr_reader :release_sha
73
+
74
+ ##
75
+ # Add a component and version constraint.
76
+ #
77
+ # @param component_name [String,:all] The name of the component to release
78
+ # @param version [::Gem::Version,Toys::Release::Semver,String,Symbol,nil]
79
+ # The version to release, or the kind of version bump to use. If `nil`
80
+ # (the default), infers a version bump from the changeset, and omits
81
+ # the component if no release is needed.
82
+ #
83
+ def add(component_name, version: nil)
84
+ raise "Release request already resolved" if resolved?
85
+ if !version.nil? && !version.is_a?(::Gem::Version) && !version.is_a?(Semver)
86
+ name = version.to_s
87
+ version = if name =~ /^\d/
88
+ ::Gem::Version.new(name)
89
+ else
90
+ Semver.for_name(name)
91
+ end
92
+ @utils.error("Malformed version or semver name: #{name}") unless version
93
+ end
94
+ @utils.error("Cannot release with no version change") if version == Semver::NONE
95
+ if @requested_components[component_name] && @requested_components[component_name] != version
96
+ @utils.error("Requested release of #{component_name.inspect} twice with different versions")
97
+ else
98
+ @requested_components[component_name] = version
99
+ end
100
+ self
101
+ end
102
+
103
+ ##
104
+ # Resolve which components and versions to release.
105
+ #
106
+ # @param repository [Toys::Release::Repository]
107
+ # @param release_ref [String,nil] Git ref at which the release should be
108
+ # cut. If nil, uses the current HEAD.
109
+ #
110
+ def resolve_versions(repository, release_ref: nil)
111
+ raise "Release request already resolved" if resolved?
112
+ @utils.accumulate_errors("Conflicts detected in the components and versions requested.") do
113
+ @release_sha = repository.current_sha(release_ref)
114
+ candidate_groups = determine_candidate_groups(repository)
115
+ @resolved_components = []
116
+ candidate_groups.each do |group, version|
117
+ resolved_group, version = resolve_one_group(group, version)
118
+ if version
119
+ resolved_group.each do |resolved_component|
120
+ resolved_component.version = version
121
+ resolved_component.change_set.force_release!
122
+ end
123
+ @resolved_components.concat(resolved_group)
124
+ end
125
+ end
126
+ end
127
+ self
128
+ end
129
+
130
+ private
131
+
132
+ ##
133
+ # Determines candidate groups, groups that could be released based on the
134
+ # release request alone (but we haven't yet checked commits.) Thus, the
135
+ # actual released groups will be a subset of this.
136
+ #
137
+ def determine_candidate_groups(repository)
138
+ candidate_groups = {}
139
+ if @requested_components.empty?
140
+ repository.coordination_groups.each { |group| candidate_groups[group] = nil }
141
+ else
142
+ @requested_components.each do |component_name, version|
143
+ component = repository.component_named(component_name)
144
+ unless component
145
+ @utils.error("Unknown component name #{component_name.inspect}")
146
+ next
147
+ end
148
+ group = component.coordination_group
149
+ group.each do |elem|
150
+ elem_name = elem.name
151
+ elem_version = @requested_components[elem_name]
152
+ if elem != component && version && elem_version && elem_version != version
153
+ @utils.error("#{component_name} #{version} implies #{elem_name} #{version} but " \
154
+ "#{elem_name} #{elem_version} was already requested.")
155
+ end
156
+ candidate_groups[group] ||= version
157
+ end
158
+ end
159
+ end
160
+ candidate_groups
161
+ end
162
+
163
+ ##
164
+ # Resolves one candidate group. Returns an array of resolved components
165
+ # along with the version to release for the group.
166
+ #
167
+ def resolve_one_group(group, version)
168
+ suggested_next_version = nil
169
+ resolved_group = group.map do |component|
170
+ last_version = component.latest_tag_version(ref: @release_sha)
171
+ if last_version && version.is_a?(::Gem::Version) && last_version >= version
172
+ @utils.error("Requested #{component.name} #{version} but #{last_version} is the latest.")
173
+ end
174
+ latest_tag = component.version_tag(last_version)
175
+ changeset = component.make_change_set(from: latest_tag, to: @release_sha)
176
+ unless version.is_a?(::Gem::Version)
177
+ cur_suggested = version ? version.bump(last_version) : changeset.suggested_version(last_version)
178
+ if suggested_next_version.nil? || (cur_suggested && cur_suggested > suggested_next_version)
179
+ suggested_next_version = cur_suggested
180
+ end
181
+ end
182
+ ResolvedComponent.new(component.name, changeset, last_version, nil)
183
+ end
184
+ [resolved_group, suggested_next_version || version]
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toys
4
+ module Release
5
+ ##
6
+ # A semver level
7
+ #
8
+ class Semver
9
+ class << self
10
+ ##
11
+ # Return the semver level for the given name.
12
+ #
13
+ # @param name [String,Symbol] The name
14
+ # @return [Semver] The semver level
15
+ # @return [nil] If the name is not recognized
16
+ #
17
+ def for_name(name)
18
+ @name_mapping[name.to_s.downcase]
19
+ end
20
+ end
21
+
22
+ include ::Comparable
23
+
24
+ ##
25
+ # @return [Symbol] The name of this semver level
26
+ #
27
+ attr_reader :name
28
+
29
+ ##
30
+ # @return [Integer,nil] Which version segment to bump (0 is major), or
31
+ # nil for the "none" level.
32
+ #
33
+ attr_reader :segment
34
+
35
+ ##
36
+ # @return [boolean] Whether this semver implies any change
37
+ #
38
+ def significant?
39
+ !segment.nil?
40
+ end
41
+
42
+ ##
43
+ # @return [String] The name of this semver level as a string
44
+ #
45
+ def to_s
46
+ name.to_s
47
+ end
48
+
49
+ ##
50
+ # Bump the given version.
51
+ #
52
+ # @param version [::Gem::Version] The original version
53
+ # @return [::Gem::Version] The new version
54
+ #
55
+ def bump(version)
56
+ return version if segment.nil?
57
+ bump_seg = segment
58
+ version_segs = version&.segments || [0, 0, 0]
59
+ max_seg = bump_seg
60
+ max_seg = 2 if max_seg < 2
61
+ version_segs = version_segs[0..max_seg]
62
+ bump_seg = 1 if bump_seg.zero? && version_segs[0].zero?
63
+ version_segs[bump_seg] += 1
64
+ ::Gem::Version.new(version_segs.fill(0, bump_seg + 1).join("."))
65
+ end
66
+
67
+ # @private
68
+ def initialize(name, segment)
69
+ @name = name
70
+ @segment = segment
71
+ end
72
+
73
+ # @private
74
+ def <=>(other)
75
+ (other.segment || 99) <=> (segment || 99)
76
+ end
77
+
78
+ ##
79
+ # Major version bump
80
+ #
81
+ MAJOR = new(:major, 0)
82
+
83
+ ##
84
+ # Minor version bump
85
+ #
86
+ MINOR = new(:minor, 1)
87
+
88
+ ##
89
+ # Patch version bump
90
+ #
91
+ PATCH = new(:patch, 2)
92
+
93
+ ##
94
+ # Patch2 version bump
95
+ #
96
+ PATCH2 = new(:patch2, 3)
97
+
98
+ ##
99
+ # No version bump
100
+ #
101
+ NONE = new(:none, nil)
102
+
103
+ @name_mapping = {
104
+ "major" => MAJOR,
105
+ "minor" => MINOR,
106
+ "patch" => PATCH,
107
+ "patch2" => PATCH2,
108
+ "none" => NONE,
109
+ }
110
+ end
111
+ end
112
+ end