toys-release 0.6.0 → 0.7.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: f61f9d5ff06e42dbeeadb84997e7b29f439bdd44a1aefb7f1c1501da7c87d5a9
4
- data.tar.gz: 3d4e730a8fa3ae1af7faf9950a5457c046a9fdd4d16084ded260a11ad85dc708
3
+ metadata.gz: 6a183392907cf0499a8abb4df7f675146d1b52f602551f140cf12111fd809149
4
+ data.tar.gz: fbc2ca77613f5a9bc40c9d3fe89fc239d7feb0c43713cc4407f935abcf5b9a29
5
5
  SHA512:
6
- metadata.gz: 96649178b625266cbebd6afbbd68614fcb2a821086ab1d365e8ce3d8df9b20d6159e52237495da67be405421e012c2f53e2bcd4838d150185ef0cba691056a63
7
- data.tar.gz: 2cafbc40bb58e2cbcfbd8fa354289d998c82e3f598c4294d0cbf125ce5dba42ed1f141ce0b228bb64ad2fd86569719f23cc6abd3053801f20c404df1221e735d
6
+ metadata.gz: 69fb1dc0ab0870f13ab1b6dcee252a31dc19aeeabc2d318c99baed7dd3dd45c6e7c5263090e749e00827e6f8d23c1342b4d8ee982e771c85026fe690b9770323
7
+ data.tar.gz: 99cc97fd617ea40ec0c75ca3cd08ac89f362d29f530c907b3b9c9dab14452607cfc10a4bfcae9c1a694d19ab6a01e2869414fd6490a4eeadebc524483e8ffd16
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Release History
2
2
 
3
+ ### v0.7.0 / 2026-03-09
4
+
5
+ * ADDED: Support updating dependencies among released components
6
+ * ADDED: Configurable changelog release header format
7
+ * ADDED: Support optionally auto-creating release PRs on push
8
+
3
9
  ### v0.6.0 / 2026-02-11
4
10
 
5
11
  * ADDED: Support retaining and/or linkifying issue/PR numbers in commit messages
data/docs/guide.md CHANGED
@@ -851,10 +851,32 @@ rest are optional.
851
851
  This can be used to modify the default build pipeline instead of redefining
852
852
  the entire pipeline using the **steps** key.
853
853
 
854
+ * **auto_create_request_branches**: *array of string* (optional) --
855
+ A list of branch names for which release pull requests are automatically
856
+ created when a push occurs and no existing release PRs are open for the
857
+ branch. When a push is made to one of these branches and there are no
858
+ open release PRs targeting it, the system will automatically run the
859
+ release request tool to create one, including all components with
860
+ releasable changes. If no components have releasable changes, the
861
+ auto-creation is silently skipped. This setting requires
862
+ **required_checks** to be disabled (set to *false*). Defaults to an
863
+ empty array (no auto-creation).
864
+
854
865
  * **breaking_change_header**: *string* (optional) --
855
866
  A changelog entry prefix that appears when a change is marked as breaking.
856
867
  Default is `BREAKING CHANGE`.
857
868
 
869
+ * **changelog_bullet**: *string* (optional) --
870
+ The bullet character used for changelog list items. Allowed values are
871
+ `*` and `-`. Default is `*`.
872
+
873
+ * **changelog_release_header_format**: *string* (optional) --
874
+ A format string for changelog release headers. Use `%v` for the version
875
+ number and [strftime](https://docs.ruby-lang.org/en/4.0/language/strftime_formatting_rdoc.html)
876
+ directives (e.g. `%Y`, `%m`, `%d`) for date components. The format must
877
+ include `%v`. Default is `### v%v / %Y-%m-%d`, which produces headers
878
+ like `### v1.2.3 / 2026-02-16`.
879
+
858
880
  * **commit_tags**: *array of [CommitTagConfig](#commit-tag-configuration)* (optional) --
859
881
  A set of configurations defining how to interpret
860
882
  [conventional commit](https://conventionalcommits.org) tags, including how
@@ -1144,6 +1166,13 @@ The **name** key is required. The others are optional.
1144
1166
  present, the default pipeline for the entire repository is used. (See the
1145
1167
  **steps** key under [Top level configuration](#top-level-configuration).)
1146
1168
 
1169
+ * **update_dependencies**: *[UpdateDepsConfig](#update-dependencies-configuration)* (optional) --
1170
+ Set up automatic dependency updates, which causes this component to be
1171
+ updated and released if any of a specified set of dependencies is also
1172
+ present in the release. This supports automatically keeping "kitchen sink"
1173
+ libraries up to date. If this setting is not present, automatic updating is
1174
+ not performed for this component.
1175
+
1147
1176
  * **version_rb_path**: *string* (optional) --
1148
1177
  The path to a Ruby file that contains the current version of the component.
1149
1178
  This file *must* include Ruby code that looks like this:
@@ -1160,6 +1189,57 @@ The **name** key is required. The others are optional.
1160
1189
  module implied by the component name. For example, if the component (gem)
1161
1190
  name is `toys-release`, this defaults to `lib/toys/release/version.rb`.
1162
1191
 
1192
+ #### Update dependencies configuration
1193
+
1194
+ An update-dependencies configuration describes when a component should also be
1195
+ released with updated dependency versions, due to one or more of those
1196
+ dependencies being released. It is typically used to keep "kitchen sink"
1197
+ libraries up to date.
1198
+
1199
+ For example, consider two components "foo_a" and "foo_b", and a "kitchen sink"
1200
+ component "foo_all" that depends on both the others. Suppose whenever a patch
1201
+ or greater release of either "foo_a" or "foo_b" happens, we also want "foo_all"
1202
+ to be released with its corresponding dependency bumped to the same version. We
1203
+ might set up the configuration like so:
1204
+
1205
+ ```yaml
1206
+ components:
1207
+ - name: foo_a
1208
+ - name: foo_b
1209
+ - name: foo_all
1210
+ update_dependencies:
1211
+ dependency_semver_threshold: patch
1212
+ dependencies: [foo_a, foo_b]
1213
+ ```
1214
+
1215
+ The update-dependencies configuration for a kitchen sink component can include
1216
+ the following keys. The **dependencies** key is required. All others are
1217
+ optional.
1218
+
1219
+ * **dependencies**: *array of string* (required) --
1220
+ A list of names of the components this component depends on.
1221
+
1222
+ * **dependency_semver_threshold**: *string* (optional) --
1223
+ The minimum semver level of a dependency update that should trigger an
1224
+ update of the kitchen sink component. For example, a threshold of `minor`
1225
+ would trigger an update to the kitchen sink if a minor release of a
1226
+ dependency occurred, but would not trigger an update to the kitchen sink if
1227
+ a patch release occurred.
1228
+
1229
+ Allowed values are `major`, `minor`, `patch`, `patch2`, and `all`. The
1230
+ `all` value indicates that every release of a dependency should trigger an
1231
+ update to the kitchen sink. Defaults to `minor` if not specified.
1232
+
1233
+ * **pessimistic_constraint_level**: *string* (optional) --
1234
+ The highest semver level allowed to float in the pessimistic dependency
1235
+ version constraints used to specify the dependencies. For example, a
1236
+ version constraint of `~> 1.0` has level `minor` because the minor version
1237
+ number is allowed to float, whereas the major version number is pinned.
1238
+
1239
+ Allowed values are `major`, `minor`, `patch`, `patch2`, and `exact`. The
1240
+ `exact` value indicates that dependencies should require the exact release
1241
+ version. Defaults to `minor` if not specified.
1242
+
1163
1243
  ### Build step configuration
1164
1244
 
1165
1245
  A build step describes one step in the release process. Its format is a
@@ -6,6 +6,6 @@ module Toys
6
6
  # Current version of the Toys release system.
7
7
  # @return [String]
8
8
  #
9
- VERSION = "0.6.0"
9
+ VERSION = "0.7.0"
10
10
  end
11
11
  end
@@ -21,8 +21,10 @@ module Toys
21
21
  @commit_tags = component_settings.commit_tags
22
22
  @breaking_change_header = component_settings.breaking_change_header
23
23
  @no_significant_updates_notice = component_settings.no_significant_updates_notice
24
+ @update_dependency_header = component_settings.update_dependency_header
24
25
  @semver = Semver::NONE
25
26
  @change_groups = nil
27
+ @updated_dependency_versions = nil
26
28
  @inputs = []
27
29
  @significant_shas = nil
28
30
  @description_normalizer = create_description_normalizer(
@@ -49,7 +51,7 @@ module Toys
49
51
  # Finish constructing a change set. After this method, new commit messages
50
52
  # cannot be added.
51
53
  #
52
- def finish # rubocop:disable Metrics/AbcSize
54
+ def finish
53
55
  raise "ChangeSet locked" if finished?
54
56
  @semver = Semver::NONE
55
57
  change_groups = {breaking: Group.new(@breaking_change_header)}
@@ -65,24 +67,42 @@ module Toys
65
67
  change_groups[:breaking].add(input.breaks)
66
68
  end
67
69
  @change_groups = change_groups.values.find_all { |group| !group.empty? }
68
- if @change_groups.empty? && @semver.significant?
69
- @change_groups << Group.new(nil).add(@no_significant_updates_notice)
70
- end
71
70
  @significant_shas = @inputs.map(&:sha)
71
+ @updated_dependency_versions = {}
72
72
  @inputs = nil
73
73
  self
74
74
  end
75
75
 
76
+ ##
77
+ # Add a set of dependency updates.
78
+ # May be called only on a finished changeset.
79
+ #
80
+ # @param updates [Array<Toys::Release::RequestSpec::ResolvedComponent>]
81
+ # @param dependency_semver_threshold [Toys::Release::Semver] The minimum
82
+ # semver significance to trigger an update
83
+ #
84
+ def add_dependency_updates(updates, dependency_semver_threshold)
85
+ raise "ChangeSet not finished" unless finished?
86
+ existing_group = @change_groups.find { |group| group.header == @update_dependency_header }
87
+ group = existing_group || Group.new(@update_dependency_header)
88
+ updates.each do |resolved_update|
89
+ diff_semver = Toys::Release::Semver.for_diff(resolved_update.last_version, resolved_update.version)
90
+ next if diff_semver < dependency_semver_threshold
91
+ @updated_dependency_versions[resolved_update.component_name] = resolved_update.version
92
+ @semver = @semver.max(diff_semver)
93
+ group.add("Updated #{resolved_update.component_name.inspect} dependency to #{resolved_update.version}")
94
+ end
95
+ @change_groups << group unless existing_group || group.empty?
96
+ self
97
+ end
98
+
76
99
  ##
77
100
  # Force a non-empty changeset even if there are no significant updates.
78
101
  # May be called only on a finished changeset.
79
102
  #
80
103
  def force_release!
81
104
  raise "ChangeSet not finished" unless finished?
82
- if @change_groups.empty?
83
- @semver = Semver::PATCH
84
- @change_groups << Group.new(nil).add(@no_significant_updates_notice)
85
- end
105
+ @semver = Semver::PATCH unless @semver.significant?
86
106
  self
87
107
  end
88
108
 
@@ -98,7 +118,7 @@ module Toys
98
118
  # the change set is finished.
99
119
  #
100
120
  def empty?
101
- @change_groups&.empty?
121
+ change_groups&.empty?
102
122
  end
103
123
 
104
124
  ##
@@ -116,7 +136,21 @@ module Toys
116
136
  # @return [Array<Group>] An array of change groups, in order. Returns nil
117
137
  # if the change set is not finished.
118
138
  #
119
- attr_reader :change_groups
139
+ def change_groups
140
+ if !finished?
141
+ nil
142
+ elsif @change_groups.empty? && @semver.significant?
143
+ @no_significant_updates_groups ||= [Group.new(nil).add(@no_significant_updates_notice)]
144
+ else
145
+ @change_groups
146
+ end
147
+ end
148
+
149
+ ##
150
+ # @return [Hash{String=>Gem::Version}] Dependency version updates.
151
+ # Returns nil if the change set is not yet finished.
152
+ #
153
+ attr_reader :updated_dependency_versions
120
154
 
121
155
  ##
122
156
  # Suggest a next version based on the changeset's changes.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
4
+
3
5
  module Toys
4
6
  module Release
5
7
  ##
@@ -16,10 +18,12 @@ module Toys
16
18
  #
17
19
  # @param path [String] File path
18
20
  # @param environment_utils [Toys::Release::EnvironmentUtils]
21
+ # @param settings [Toys::Release::RepoSettings] Repository settings
19
22
  #
20
- def initialize(path, environment_utils)
23
+ def initialize(path, environment_utils, settings)
21
24
  @path = path
22
25
  @utils = environment_utils
26
+ @settings = settings
23
27
  end
24
28
 
25
29
  ##
@@ -46,7 +50,7 @@ module Toys
46
50
  # @return [::Gem::Version,nil] Current latest version from the changelog
47
51
  #
48
52
  def current_version
49
- ChangelogFile.current_version_from_content(content)
53
+ ChangelogFile.current_version_from_content(content, header_format)
50
54
  end
51
55
 
52
56
  ##
@@ -60,34 +64,36 @@ module Toys
60
64
  def read_and_verify_latest_entry(version) # rubocop:disable Metrics/MethodLength
61
65
  version = version.to_s
62
66
  @utils.log("Verifying #{path} changelog content...")
63
- today = ::Time.now.strftime("%Y-%m-%d")
67
+ expected_header = format_header(version, ::Time.now.utc)
68
+ version_re = ::Regexp.new("^#{ChangelogFile.header_regex(header_format, version: version)}\n$")
69
+ any_header_re = ::Regexp.new("^#{ChangelogFile.header_regex(header_format)}")
64
70
  entry = []
65
71
  state = :start
66
72
  ::File.readlines(@path).each do |line|
67
73
  case state
68
74
  when :start
69
75
  case line
70
- when %r{^### v#{::Regexp.escape(version)} / \d\d\d\d-\d\d-\d\d\n$}
76
+ when version_re
71
77
  entry << line
72
78
  state = :during
73
- when /^### /
79
+ when any_header_re
74
80
  @utils.error("The first changelog entry in #{path} isn't for version #{version}.",
75
81
  "It should start with:",
76
- "### v#{version} / #{today}",
82
+ expected_header,
77
83
  "But it actually starts with:",
78
84
  line)
79
85
  entry << line
80
86
  state = :during
81
87
  end
82
88
  when :during
83
- break if line =~ /^### /
89
+ break if any_header_re.match?(line)
84
90
  entry << line
85
91
  end
86
92
  end
87
93
  if entry.empty?
88
94
  @utils.error("The changelog #{path} doesn't have any entries.",
89
95
  "The first changelog entry should start with:",
90
- "### v#{version} / #{today}")
96
+ expected_header)
91
97
  else
92
98
  @utils.log("Changelog OK")
93
99
  end
@@ -99,15 +105,14 @@ module Toys
99
105
  #
100
106
  # @param changeset [ChangeSet] The changeset.
101
107
  # @param version [String] The release version.
102
- # @param date [String] The date. If not provided, uses the current UTC.
103
- # @param bullet [String] The bullet character for list items. Defaults to "*".
108
+ # @param date [Time,String,nil] The date. If not provided, uses the current UTC.
104
109
  #
105
- def append(changeset, version, date: nil, bullet: "*")
110
+ def append(changeset, version, date: nil)
106
111
  @utils.log("Writing version #{version} to changelog #{path}")
107
- date ||= ::Time.now.utc
108
- date = date.strftime("%Y-%m-%d") if date.respond_to?(:strftime)
112
+ bullet = @settings.changelog_bullet
113
+ header_line = format_header(version, date)
109
114
  new_entry = [
110
- "### v#{version} / #{date}",
115
+ header_line,
111
116
  "",
112
117
  ]
113
118
  changeset.change_groups.each do |group|
@@ -115,7 +120,7 @@ module Toys
115
120
  end
116
121
  new_entry = new_entry.join("\n")
117
122
  old_content = content || DEFAULT_HEADER
118
- new_content = old_content.sub(%r{^(### v\S+ / \d\d\d\d-\d\d-\d\d)$}, "#{new_entry}\n\n\\1")
123
+ new_content = old_content.sub(/^(#{ChangelogFile.header_regex(header_format)})$/, "#{new_entry}\n\n\\1")
119
124
  if new_content == old_content
120
125
  new_content = old_content.sub(/\n+\z/, "\n\n#{new_entry}\n")
121
126
  end
@@ -127,12 +132,90 @@ module Toys
127
132
  # Returns the current version from the given file content
128
133
  #
129
134
  # @param content [String] File contents
135
+ # @param header_format [String] The header format string containing
136
+ # `%v` for version and strftime directives for date components.
130
137
  # @return [::Gem::Version] Latest version in the changelog
131
138
  #
132
- def self.current_version_from_content(content)
133
- match = %r{### v(\d+(?:\.[a-zA-Z0-9]+)+) / \d\d\d\d-\d\d-\d\d}.match(content)
139
+ def self.current_version_from_content(content, header_format)
140
+ regex_str = header_regex(header_format)
141
+ match = ::Regexp.new(regex_str).match(content.to_s)
134
142
  match ? ::Gem::Version.new(match[1]) : nil
135
143
  end
144
+
145
+ ##
146
+ # @private
147
+ #
148
+ # Converts a header format into a regular expression string for
149
+ # matching changelog headers. The `%v` placeholder is replaced with
150
+ # either a specific escaped version or a general version-capturing
151
+ # pattern. Strftime directives (including those with flags and width
152
+ # specifiers) are replaced with appropriate character class patterns.
153
+ #
154
+ # TODO: This does not correctly handle escaped percent signs (`%%`)
155
+ # in the format string. A `%%` sequence (which strftime interprets
156
+ # as a literal `%`) could have its second `%` misidentified as the
157
+ # start of a strftime directive. This is unlikely in practice but
158
+ # may warrant future investigation.
159
+ #
160
+ # @param header_format [String] The header format string.
161
+ # @param version [String,nil] If provided, the regex will match only
162
+ # this specific version. If nil, the regex captures any version.
163
+ # @return [String] A regular expression string (not a Regexp object).
164
+ #
165
+ def self.header_regex(header_format, version: nil)
166
+ version_re = version ? ::Regexp.escape(version.to_s) : "(#{VERSION_PATTERN})"
167
+ ::Regexp.escape(header_format)
168
+ .gsub("%v", version_re)
169
+ .gsub(/%[-_0^#]?\d*([a-zA-Z])/) do
170
+ STRFTIME_CONVERSIONS[::Regexp.last_match(1)] || '\S+'
171
+ end
172
+ end
173
+
174
+ # @private
175
+ VERSION_PATTERN = '\d+(?:\.[a-zA-Z0-9]+)+'
176
+ private_constant :VERSION_PATTERN
177
+
178
+ # @private
179
+ # Maps strftime conversion characters to regex patterns.
180
+ # Flags and width specifiers (e.g. %-d, %02m) are handled by
181
+ # header_regex via a general pattern match.
182
+ STRFTIME_CONVERSIONS = {
183
+ "Y" => '\d{4}',
184
+ "m" => '\d{1,2}',
185
+ "d" => '\d{1,2}',
186
+ "B" => '\w+',
187
+ "b" => '\w+',
188
+ "e" => '[\d ]\d',
189
+ }.freeze
190
+ private_constant :STRFTIME_CONVERSIONS
191
+
192
+ private
193
+
194
+ ##
195
+ # Returns the configured header format string from settings.
196
+ #
197
+ # @return [String] A format string containing `%v` for version and
198
+ # strftime directives for date components.
199
+ #
200
+ def header_format
201
+ @settings.changelog_release_header_format
202
+ end
203
+
204
+ ##
205
+ # Generates a formatted header line by substituting the version and
206
+ # formatting the date using strftime directives from the header format.
207
+ #
208
+ # @param version [String] The release version string.
209
+ # @param date [Time,String,nil] The release date. If nil, defaults to
210
+ # the current UTC time. Strings are parsed via `Time.parse`.
211
+ # @return [String] The formatted header line.
212
+ #
213
+ def format_header(version, date)
214
+ date ||= ::Time.now.utc
215
+ date = ::Time.parse(date) if date.is_a?(::String)
216
+ fmt = header_format.gsub("%v", "%%v")
217
+ date.strftime(fmt).gsub("%v", version.to_s)
218
+ end
136
219
  end
137
220
  end
138
221
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "toys/release/change_set"
4
4
  require "toys/release/changelog_file"
5
+ require "toys/release/gemspec_file"
5
6
  require "toys/release/version_rb_file"
6
7
 
7
8
  module Toys
@@ -21,8 +22,9 @@ module Toys
21
22
  @repository = repository
22
23
  @settings = repository.settings.component_settings(name)
23
24
  @utils = environment_utils
24
- @changelog_file = ChangelogFile.new(changelog_path(from: :absolute), @utils)
25
+ @changelog_file = ChangelogFile.new(changelog_path(from: :absolute), @utils, repository.settings)
25
26
  @version_rb_file = VersionRbFile.new(version_rb_path(from: :absolute), @utils)
27
+ @gemspec_file = GemspecFile.new(gemspec_path(from: :absolute), @utils)
26
28
  @coordination_group = nil
27
29
  end
28
30
 
@@ -43,6 +45,12 @@ module Toys
43
45
  #
44
46
  attr_reader :version_rb_file
45
47
 
48
+ ##
49
+ # @return [Toys::Release::GemspecFile] The .gemspec file in this
50
+ # component
51
+ #
52
+ attr_reader :gemspec_file
53
+
46
54
  ##
47
55
  # @return [Array<Component>] The coordination group containing this
48
56
  # component. If this component is not coordinated, it will be part of
@@ -137,6 +145,19 @@ module Toys
137
145
  file_path(settings.version_rb_path, from: from)
138
146
  end
139
147
 
148
+ ##
149
+ # Returns the path to the gemspec. It can be returned as a relative
150
+ # path from the component directory, a relative path from the repo root
151
+ # directory, or an absolute path.
152
+ #
153
+ # @param from [:directory,:repo_root,:absolute] From where (defaults to
154
+ # `:directory`)
155
+ # @return [String] The path to the gemspec
156
+ #
157
+ def gemspec_path(from: :directory)
158
+ file_path("#{name}.gemspec", from: from)
159
+ end
160
+
140
161
  ##
141
162
  # Validates the component and reports any errors.
142
163
  #
@@ -145,15 +166,39 @@ module Toys
145
166
  path = directory(from: :absolute)
146
167
  @utils.error("Missing directory #{path} for #{name}") unless ::File.directory?(path)
147
168
  @utils.error("Missing changelog #{changelog_file.path} for #{name}") unless changelog_file.exists?
148
- if !version_rb_file.exists?
149
- @utils.error("Missing version #{version_rb_file.path} for #{name}")
150
- elsif version_rb_file.current_version.nil?
151
- @utils.error("Unable to read VERSION constant from #{version_rb_file.path} for #{name}")
152
- end
169
+ validate_version_rb_file
170
+ validate_gemspec_file
153
171
  yield if block_given?
154
172
  end
155
173
  end
156
174
 
175
+ ##
176
+ # Validates the version.rb file
177
+ #
178
+ def validate_version_rb_file
179
+ if !version_rb_file.exists?
180
+ @utils.error("Missing version #{version_rb_file.path} for #{name}")
181
+ elsif version_rb_file.current_version.nil?
182
+ @utils.error("Unable to read VERSION constant from #{version_rb_file.path} for #{name}")
183
+ end
184
+ end
185
+
186
+ ##
187
+ # Validates the gemspec file if this component updates from dependencies
188
+ #
189
+ def validate_gemspec_file
190
+ update_deps_settings = settings.update_dependencies
191
+ return unless update_deps_settings
192
+ if gemspec_file.exists?
193
+ cur_deps = gemspec_file.current_dependencies
194
+ update_deps_settings.dependencies.each do |dep_name|
195
+ @utils.error("Gemspec #{gemspec_file.path} is missing #{dep_name}") unless cur_deps.key?(dep_name)
196
+ end
197
+ else
198
+ @utils.error("Missing gemspec #{gemspec_file.path} for #{name}")
199
+ end
200
+ end
201
+
157
202
  ##
158
203
  # Returns the version of the latest release tag on the given branch.
159
204
  #
@@ -205,7 +250,9 @@ module Toys
205
250
  if at
206
251
  path = changelog_path(from: :repo_root)
207
252
  content = @utils.capture(["git", "show", "#{at}:#{path}"], e: true)
208
- return ChangelogFile.current_version_from_content(content)
253
+ return ChangelogFile.current_version_from_content(
254
+ content, @repository.settings.changelog_release_header_format
255
+ )
209
256
  end
210
257
  changelog_file.current_version
211
258
  end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toys
4
+ module Release
5
+ ##
6
+ # Represents a gemspec file
7
+ #
8
+ class GemspecFile
9
+ ##
10
+ # Transforms an input set of versions and pessimistic constraint level to
11
+ # the dependency syntax for rubygems.
12
+ #
13
+ # @param versions [Hash{String=>Gem::Version}] Mapping from component
14
+ # name to version
15
+ # @param dependency_semver_threshold [Semver] The semver significance
16
+ # threshold
17
+ # @param pessimistic_constraint_level [Semver] The pessimistic constraint
18
+ #
19
+ # @return [Hash{String=>Array<String>}] Mapping from component name to
20
+ # the rubygems version constraint syntax
21
+ #
22
+ def self.transform_version_constraints(versions,
23
+ dependency_semver_threshold,
24
+ pessimistic_constraint_level)
25
+ versions.transform_values do |version|
26
+ if pessimistic_constraint_level == Semver::NONE
27
+ ["= #{version}"]
28
+ else
29
+ segments = version.canonical_segments.dup
30
+ segments.slice!((dependency_semver_threshold.segment + 1)..) if dependency_semver_threshold.segment
31
+ pessimistic_segments =
32
+ if segments.size <= pessimistic_constraint_level.segment
33
+ segments + ::Array.new(pessimistic_constraint_level.segment - segments.size + 1, 0)
34
+ else
35
+ segments[..pessimistic_constraint_level.segment]
36
+ end
37
+ result = ["~> #{pessimistic_segments.join('.')}"]
38
+ result << ">= #{segments.join('.')}" if segments.size > pessimistic_segments.size
39
+ result
40
+ end
41
+ end
42
+ end
43
+
44
+ ##
45
+ # Create a gemspec file object given a file path
46
+ #
47
+ # @param path [String] File path
48
+ # @param environment_utils [Toys::Release::EnvironmentUtils]
49
+ #
50
+ def initialize(path, environment_utils)
51
+ @path = path
52
+ @utils = environment_utils
53
+ end
54
+
55
+ ##
56
+ # @return [String] Path to the gemspec file
57
+ #
58
+ attr_reader :path
59
+
60
+ ##
61
+ # @return [boolean] Whether the file exists
62
+ #
63
+ def exists?
64
+ path && ::File.file?(path)
65
+ end
66
+
67
+ ##
68
+ # @return [String] Current contents of the file
69
+ #
70
+ def content
71
+ @content ||= ::File.read(path)
72
+ end
73
+
74
+ ##
75
+ # Get the current rubygems version constraints for all dependencies
76
+ #
77
+ # @return [Hash{String=>Array<String>}] Map from component name to a
78
+ # possibly empty array of version constraint strings.
79
+ #
80
+ def current_dependencies
81
+ result = {}
82
+ regex = /\.add(?:_runtime)?_dependency\(?\s*["']([^"']+)["']((?:,\s*["']([^"',]+)["'])*)\s*\)?/
83
+ content.scan(regex) do |comp_name, expr|
84
+ result[comp_name] = expr.scan(/["']([^"',]+)["']/).map(&:first)
85
+ end
86
+ result
87
+ end
88
+
89
+ ##
90
+ # Update the rubygems version constraints
91
+ #
92
+ # @param constraint_updates [Hash{String=>Array<String>}] Map from
93
+ # component name to a possibly empty array of version constraint
94
+ # strings.
95
+ # @return [self]
96
+ #
97
+ def update_dependencies(constraint_updates)
98
+ constraint_updates.each do |name, exprs|
99
+ escaped_name = Regexp.escape(name)
100
+ regex = /\.(add(?:_runtime)?_dependency)(\(?\s*)(["'])#{escaped_name}["'](?:,\s*["'][^"',]+["'])*(\s*\)?)/
101
+ content.sub!(regex) do
102
+ match = ::Regexp.last_match
103
+ method_name = match[1]
104
+ opening = match[2]
105
+ quote = match[3]
106
+ closing = match[4]
107
+ exprs_str = exprs.map { |str| ", #{str.inspect}" }.join
108
+ exprs_str.tr!('"', quote) unless quote == '"'
109
+ ".#{method_name}#{opening}#{quote}#{name}#{quote}#{exprs_str}#{closing}"
110
+ end
111
+ end
112
+ ::File.write(path, content) if path
113
+ self
114
+ end
115
+
116
+ # @private
117
+ attr_writer :content
118
+ end
119
+ end
120
+ end