toys-release 0.8.2 → 0.9.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: 573b7d3f7188a3f500acad829f4a1ff7b638659a7ab599d2aee0ec5f2658eec7
4
- data.tar.gz: a704f1f772e03130705f2ce6d508936fbd1158a1b8d18c30f6f9e2ce2091d403
3
+ metadata.gz: 4b45ad6653e6cf95dc462d60917fec18dc4cab282eb44fa6ea28952aa982bf5d
4
+ data.tar.gz: 8d00cbe1a53a9c6f31b982919660e04d1d10559e579293fe32f0ecd963d7e696
5
5
  SHA512:
6
- metadata.gz: ed1092a3679da88cc40525e692100388578f9433c38e21863dcbcbb2ecacffc7cc7b24ff7fbb7531d4a49ba115399d66c2a517c8e9de14b26e9fa759414e2256
7
- data.tar.gz: e0c3aa955c45e64676e1f516f885b8f8bc4adb4ca147109b98ee805908c17593813bc51df3dc9bce844740fce1d76b2070b27e17bba8b0e64f0ef9d70dec35e3
6
+ metadata.gz: '0583c614ebdba3ac5cf9e0988dbab656cfd49e437e5543ec3dafab95fc0cd1506cbfb42c85f72627da3895561957e40b6f07d1f22c1cd28eb3944971f3f064b0'
7
+ data.tar.gz: 0e53dbb9821ea6b5e9df0f33814fc18d0da48ba6a0330b5e941e7c9b0b5cf93c8778527b5bd9b90ea32ea36183f2f594c852f37b76a6a5d436c070b8217fca13
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Release History
2
2
 
3
+ ### v0.9.0 / 2026-03-23
4
+
5
+ * ADDED: Provided a `version_from_code` setting that allows the current VERSION constant to determine the release version
6
+ * FIXED: The gen-gh-pages and gen-workflows tools no longer ask for confirmation if a file to generate is unchanged
7
+ * FIXED: Release versions can now omit version fields beyond major, e.g. version "1" is now legal
8
+ * FIXED: Minor UI improvements for the gen-gh-pages tool
9
+ * DOCS: Minor fixes to the toys-release guide
10
+
3
11
  ### v0.8.2 / 2026-03-18
4
12
 
5
13
  * FIXED: Tighten version constant regex
data/docs/guide.md CHANGED
@@ -70,14 +70,14 @@ If you do not have Ruby or Toys installed locally, do so first. Install
70
70
  Ruby 2.7 or later, and then install the Toys RubyGem using:
71
71
 
72
72
  ```sh
73
- gem install toys
73
+ $ gem install toys
74
74
  ```
75
75
 
76
76
  Toys-Release requires Toys 0.20 or later. If you have an older version of Toys,
77
77
  update it using:
78
78
 
79
79
  ```sh
80
- toys system update
80
+ $ toys system update
81
81
  ```
82
82
 
83
83
  Finally, you also need the GitHub command line tool, `gh`. Find installation
@@ -85,28 +85,37 @@ instructions at https://cli.github.com/. If you are running on MacOS, for
85
85
  example, the easiest way to install it is via homebrew:
86
86
 
87
87
  ```sh
88
- brew install gh
88
+ $ brew install gh
89
89
  ```
90
90
 
91
91
  ### Install the release tool
92
92
 
93
- The Toys-Release tool needs to be installed in your repository, as a Toys tool
94
- loaded from the [toys-release](https://rubygems.org/gems/toys-release) gem.
93
+ The Toys-Release tool needs to be installed in your repository. This means
94
+ creating a Toys tool whose implementation is provided by the
95
+ [toys-release](https://rubygems.org/gems/toys-release) gem.
95
96
 
96
97
  Create `.toys/release.rb` (note the leading period in the directory name) in
97
98
  your git repository. Use the following content:
98
99
 
99
100
  ```ruby
101
+ # frozen_string_literal: true
100
102
  load_gem "toys-release"
101
103
  ```
102
104
 
103
- This will cause Toys-Release to use the latest version of Toys-Release. You can
104
- also pin to a specific version of Toys-Release by specifying version
105
- requirements similar to how those requirements are specified in RubyGems or
106
- Bundler:
105
+ Test the install by displaying the online help for toys-release:
106
+
107
+ ```sh
108
+ $ toys release --help
109
+ ```
110
+
111
+ If you do not already have the toys-release gem installed, this will install it
112
+ for you (after asking for permission.) By default, it will install the latest
113
+ version, but you can pin to a specific version of the gem by specifying version
114
+ requirements similar to how you would in RubyGems or Bundler:
107
115
 
108
116
  ```ruby
109
- load_gem "toys-release", "~> 0.3"
117
+ # frozen_string_literal: true
118
+ load_gem "toys-release", "~> 0.8"
110
119
  ```
111
120
 
112
121
  Commit and push this change to your repository.
@@ -124,7 +133,7 @@ release tool is installed as described above, you can run this from your local
124
133
  repository clone directory:
125
134
 
126
135
  ```sh
127
- toys release gen-config
136
+ $ toys release gen-config
128
137
  ```
129
138
 
130
139
  This will analyze your repository and generate an initial configuration file
@@ -146,13 +155,13 @@ line using the release tool.
146
155
  To create the GitHub repo labels, run this from your local repo clone directory:
147
156
 
148
157
  ```sh
149
- toys release create-labels
158
+ $ toys release create-labels
150
159
  ```
151
160
 
152
161
  Then, to generate the GitHub Actions workflows, run:
153
162
 
154
163
  ```sh
155
- toys release gen-workflows
164
+ $ toys release gen-workflows
156
165
  ```
157
166
 
158
167
  This will generate files in a `.github/workflows` directory in your repository.
@@ -287,15 +296,15 @@ appending the version to the component name, separated by a colon.
287
296
  For example, to request releases of the `toys` and `toys-release` components,
288
297
  you can enter the following text into "Components to release":
289
298
 
290
- ```sh
299
+ ```
291
300
  toys toys-release
292
301
  ```
293
302
 
294
- To make the above request but specifically request version 0.3.0 of the
303
+ To make the above request but specifically request version 1.0.0 of the
295
304
  `toys-release` component:
296
305
 
297
- ```sh
298
- toys toys-release:0.3.0
306
+ ```
307
+ toys toys-release:1.0.0
299
308
  ```
300
309
 
301
310
  ### Managing release pull requests
@@ -444,7 +453,7 @@ To set up documentation, do the following:
444
453
  * Create a starting gh-pages branch by running:
445
454
 
446
455
  ```sh
447
- toys release gen-gh-pages
456
+ $ toys release gen-gh-pages
448
457
  ```
449
458
 
450
459
  This will generate the gh-pages branch and push some key files to it,
@@ -536,6 +545,49 @@ and affect the behavior of that and other commits.
536
545
  no-touch-component: my_gem
537
546
  ```
538
547
 
548
+ ### Using code-specified versions
549
+
550
+ By default, Toys-Release determines the version to release by analyzing the
551
+ conventional commit history since the last release. For each component, it
552
+ selects the semver bump implied by the commits and applies it to the last
553
+ released version.
554
+
555
+ As an alternative, you can configure a component to use the version specified
556
+ directly in the code — specifically, the `VERSION` constant in the `version.rb`
557
+ file (as identified by the **version_rb_path** setting) — as the target release
558
+ version. This is useful when you prefer to control the version number manually
559
+ or when your workflow requires the version to be set in the code before a
560
+ release is requested.
561
+
562
+ To enable this, set `version_from_code: true` in the component's configuration:
563
+
564
+ ```yaml
565
+ components:
566
+ - name: my_gem
567
+ version_from_code: true
568
+ ```
569
+
570
+ With this setting, when a release is requested, Toys-Release reads the version
571
+ constant from the code at the tip of the release branch. If this version is
572
+ newer than the last released version, it is used as the release version.
573
+
574
+ If the code version is not newer than the last released version (for example,
575
+ if you forgot to update it before requesting the release), the release request
576
+ will fail with an error by default. You can configure the optional `bump`
577
+ sub-setting to specify a fallback semver bump level to apply to the last
578
+ released version instead:
579
+
580
+ ```yaml
581
+ components:
582
+ - name: my_gem
583
+ version_from_code:
584
+ bump: patch2
585
+ ```
586
+
587
+ With `bump: patch2` (or `patch`, `minor`, or `major`), if the code version is
588
+ not newer than the last release, the last released version is bumped by the
589
+ given level to produce a releasable version.
590
+
539
591
  ### Running on the command line
540
592
 
541
593
  The implementation of Toys-Release is done via Toys (i.e. command line) tools.
@@ -1177,6 +1229,41 @@ The **name** key is required. The others are optional.
1177
1229
  libraries up to date. If this setting is not present, automatic updating is
1178
1230
  not performed for this component.
1179
1231
 
1232
+ * **version_from_code**: *boolean or dictionary* (optional) --
1233
+ When set, uses the version constant in the component's code (as identified
1234
+ by the **version_rb_path** setting) as the version to release, rather than
1235
+ computing it automatically from the conventional commit history.
1236
+
1237
+ Set this to `true` to enable the feature with default settings:
1238
+
1239
+ ```yaml
1240
+ components:
1241
+ - name: my_gem
1242
+ version_from_code: true
1243
+ ```
1244
+
1245
+ Or set it to a dictionary to provide additional configuration:
1246
+
1247
+ ```yaml
1248
+ components:
1249
+ - name: my_gem
1250
+ version_from_code:
1251
+ bump: patch
1252
+ ```
1253
+
1254
+ The dictionary supports the following optional key:
1255
+
1256
+ * **bump**: *string* (optional) --
1257
+ What to do if the version specified in the code has already been
1258
+ released (i.e. the code version is not newer than the most recently
1259
+ released version). Possible values are `patch2`, `patch`, `minor`,
1260
+ `major`, and `none`. If `none` (the default), the release request will
1261
+ fail with an error. If a semver bump level is given, the last released
1262
+ version is bumped by that level to produce a releasable version.
1263
+
1264
+ See also [Using code-specified versions](#using-code-specified-versions)
1265
+ for a conceptual overview.
1266
+
1180
1267
  * **version_rb_path**: *string* (optional) --
1181
1268
  The path to a Ruby file that contains the current version of the component.
1182
1269
  This file *must* include Ruby code that looks like this:
@@ -1200,20 +1287,20 @@ released with updated dependency versions, due to one or more of those
1200
1287
  dependencies being released. It is typically used to keep "kitchen sink"
1201
1288
  libraries up to date.
1202
1289
 
1203
- For example, consider two components "foo_a" and "foo_b", and a "kitchen sink"
1204
- component "foo_all" that depends on both the others. Suppose whenever a patch
1205
- or greater release of either "foo_a" or "foo_b" happens, we also want "foo_all"
1290
+ For example, consider two components "gem_a" and "gem_b", and a "kitchen sink"
1291
+ component "gem_all" that depends on both the others. Suppose whenever a patch
1292
+ or greater release of either "gem_a" or "gem_b" happens, we also want "gem_all"
1206
1293
  to be released with its corresponding dependency bumped to the same version. We
1207
1294
  might set up the configuration like so:
1208
1295
 
1209
1296
  ```yaml
1210
1297
  components:
1211
- - name: foo_a
1212
- - name: foo_b
1213
- - name: foo_all
1298
+ - name: gem_a
1299
+ - name: gem_b
1300
+ - name: gem_all
1214
1301
  update_dependencies:
1215
1302
  dependency_semver_threshold: patch
1216
- dependencies: [foo_a, foo_b]
1303
+ dependencies: [gem_a, gem_b]
1217
1304
  ```
1218
1305
 
1219
1306
  The update-dependencies configuration for a kitchen sink component can include
@@ -6,6 +6,6 @@ module Toys
6
6
  # Current version of the Toys release system.
7
7
  # @return [String]
8
8
  #
9
- VERSION = "0.8.2"
9
+ VERSION = "0.9.0"
10
10
  end
11
11
  end
@@ -162,7 +162,7 @@ module Toys
162
162
  def suggested_version(last)
163
163
  raise "ChangeSet not finished" unless finished?
164
164
  return nil unless semver.significant?
165
- semver.bump(last)
165
+ semver.bump(last, minimum_fill: Semver::PATCH, prevent_bump_to_v1: true)
166
166
  end
167
167
 
168
168
  ##
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "fileutils"
5
+
6
+ module Toys
7
+ module Release
8
+ ##
9
+ # Logic for generating and updating gh-pages documentation site files.
10
+ #
11
+ class GhPagesLogic
12
+ ##
13
+ # Create a GhPagesLogic instance.
14
+ #
15
+ # @param repo_settings [Toys::Release::RepoSettings] Repository settings
16
+ #
17
+ def initialize(repo_settings)
18
+ @enabled_component_settings = repo_settings.all_component_settings.select(&:gh_pages_enabled)
19
+ raise ::ArgumentError, "No components have gh-pages enabled" if @enabled_component_settings.empty?
20
+ @url_base_path = "#{repo_settings.repo_owner}.github.io/#{repo_settings.repo_name}"
21
+ @default_redirect_url = "https://#{component_base_path(@enabled_component_settings.first)}/latest"
22
+ end
23
+
24
+ ##
25
+ # Clean up non-index files from the v0 subdirectory of each gh-pages-
26
+ # enabled component. The given block is called for each component whose
27
+ # v0 directory contains files other than index.html, and receives the
28
+ # directory path and the list of files to remove. The block should return
29
+ # true to remove the files, or false to skip. If no block is given, files
30
+ # are removed unconditionally.
31
+ #
32
+ # @param gh_pages_dir [String] Path to the gh-pages working tree
33
+ # @yieldparam directory [String] Path relative to gh_pages_dir of the v0 dir
34
+ # @yieldparam children [Array<String>] Non-index filenames to remove
35
+ # @yieldreturn [boolean] Whether to remove the files
36
+ # @return [Array<Hash>] Results, one per enabled component, each with
37
+ # keys :directory (relative path), :children, and :removed
38
+ #
39
+ def cleanup_v0_directories(gh_pages_dir, &confirm)
40
+ @enabled_component_settings.map do |comp_settings|
41
+ cleanup_component_v0(gh_pages_dir, comp_settings, &confirm)
42
+ end
43
+ end
44
+
45
+ ##
46
+ # Generate all gh-pages scaffold files into the given directory.
47
+ # The given block is called for each file that needs to be created or
48
+ # overwritten (but NOT for unchanged files), and receives the destination
49
+ # path, the status (:new or :overwrite), and the existing file type
50
+ # (only meaningful for :overwrite). The block should return true to
51
+ # write the file, or false to skip.
52
+ #
53
+ # @param gh_pages_dir [String] Path to the gh-pages working tree
54
+ # @param template_dir [String] Path to the directory containing ERB
55
+ # templates for gh-pages files
56
+ # @yieldparam destination [String] Path relative to gh_pages_dir of the destination file
57
+ # @yieldparam status [Symbol] :new or :overwrite
58
+ # @yieldparam existing_ftype [String,nil] The ftype of the existing entry
59
+ # @yieldreturn [boolean] Whether to write the file
60
+ # @return [Array<Hash>] Results, one per file considered, each with
61
+ # keys :destination (relative path) and :outcome (:wrote, :skipped, or :unchanged)
62
+ #
63
+ def generate_files(gh_pages_dir, template_dir, &confirm)
64
+ results = []
65
+ @enabled_component_settings.each do |comp_settings|
66
+ generate_component_files(gh_pages_dir, template_dir, comp_settings, results, &confirm)
67
+ end
68
+ generate_toplevel_files(gh_pages_dir, template_dir, results, &confirm)
69
+ generate_html404(gh_pages_dir, template_dir, results, &confirm)
70
+ results
71
+ end
72
+
73
+ ##
74
+ # Update the 404 page and redirect index pages for a new component
75
+ # release. The optional block is called with a warning message when a
76
+ # required file is not found.
77
+ #
78
+ # @param gh_pages_dir [String] Path to the gh-pages working tree
79
+ # @param component_settings [Toys::Release::ComponentSettings] Settings
80
+ # for the component being released
81
+ # @param version [Gem::Version] The new version being released
82
+ # @yieldparam warning [String] A warning message for a missing file
83
+ #
84
+ def update_version_pages(gh_pages_dir, component_settings, version, &on_warning)
85
+ update_404_page(gh_pages_dir, component_settings, version, &on_warning)
86
+ update_index_pages(gh_pages_dir, component_settings, version, &on_warning)
87
+ end
88
+
89
+ private
90
+
91
+ # Context object for ERB template rendering
92
+ class ErbContext
93
+ def initialize(data)
94
+ data.each { |name, value| instance_variable_set("@#{name}", value) }
95
+ freeze
96
+ end
97
+
98
+ # @private
99
+ def self.get(data)
100
+ new(data).instance_eval { binding }
101
+ end
102
+ end
103
+ private_constant :ErbContext
104
+
105
+ # Struct carrying info about a component for the 404 template
106
+ CompInfo = ::Struct.new(:base_path, :regexp_source, :version_var)
107
+ private_constant :CompInfo
108
+
109
+ # Cleans up a single component's v0 directory, yielding to the caller for confirmation.
110
+ def cleanup_component_v0(gh_pages_dir, comp_settings)
111
+ relative_dir = simplifying_join(comp_settings.gh_pages_directory, "v0")
112
+ directory = ::File.expand_path(relative_dir, gh_pages_dir)
113
+ ::FileUtils.mkdir_p(directory)
114
+ children = ::Dir.children(directory) - ["index.html"]
115
+ removed = false
116
+ if !children.empty? && (!block_given? || yield(relative_dir, children))
117
+ children.each { |child| ::FileUtils.remove_entry(::File.join(directory, child), true) }
118
+ removed = true
119
+ end
120
+ {directory: relative_dir, children: children, removed: removed}
121
+ end
122
+
123
+ # Returns the URL base path for a component, incorporating its gh_pages_directory if set.
124
+ def component_base_path(comp_settings)
125
+ simplifying_join(@url_base_path, comp_settings.gh_pages_directory)
126
+ end
127
+
128
+ # Scans the component's gh-pages directory and returns the highest existing released version,
129
+ # or "0" if no versioned subdirectories exist yet.
130
+ def current_component_version(gh_pages_dir, comp_settings)
131
+ base_dir = ::File.expand_path(comp_settings.gh_pages_directory, gh_pages_dir)
132
+ latest = ::Gem::Version.new("0")
133
+ return latest unless ::File.directory?(base_dir)
134
+ ::Dir.children(base_dir).each do |child|
135
+ next unless /^v\d+(\.\d+)*$/.match?(child)
136
+ next unless ::File.directory?(::File.join(base_dir, child))
137
+ version = ::Gem::Version.new(child[1..])
138
+ latest = version if version > latest
139
+ end
140
+ latest
141
+ end
142
+
143
+ # Renders an ERB template from template_dir with the given data hash and returns the result.
144
+ def render_template(template_dir, template_name, data)
145
+ template_path = ::File.join(template_dir, template_name)
146
+ raise "Unable to find template #{template_name}" unless ::File.file?(template_path)
147
+ erb = ::ERB.new(::File.read(template_path))
148
+ erb.result(ErbContext.get(data))
149
+ end
150
+
151
+ # Updates the version variable assignment in 404.html for the given component.
152
+ def update_404_page(gh_pages_dir, component_settings, version)
153
+ path = ::File.join(gh_pages_dir, "404.html")
154
+ unless ::File.file?(path)
155
+ yield "404.html not found. Skipping." if block_given?
156
+ return
157
+ end
158
+ content = ::File.read(path)
159
+ version_var = component_settings.gh_pages_version_var
160
+ content.sub!(/#{::Regexp.escape(version_var)} = "[\w.]+";/,
161
+ "#{version_var} = \"#{version}\";")
162
+ ::File.write(path, content)
163
+ end
164
+
165
+ # Updates the redirect URLs in index.html and latest/index.html to point at the new version.
166
+ def update_index_pages(gh_pages_dir, component_settings, version)
167
+ redirect_url = "https://#{component_base_path(component_settings)}/v#{version}"
168
+ ["index.html", "latest/index.html"].each do |filename|
169
+ relative_path = simplifying_join(component_settings.gh_pages_directory, filename)
170
+ absolute_path = ::File.expand_path(relative_path, gh_pages_dir)
171
+ unless ::File.file?(absolute_path)
172
+ yield "#{relative_path} not found. Skipping." if block_given?
173
+ next
174
+ end
175
+ content = ::File.read(absolute_path)
176
+ content.gsub!(/ href="[^"]+"/, " href=\"#{redirect_url}\"")
177
+ content.gsub!(/ content="0; url=[^"]+"/, " content=\"0; url=#{redirect_url}\"")
178
+ content.gsub!(/window\.location\.replace\("[^"]+"\)/, "window.location.replace(\"#{redirect_url}\")")
179
+ ::File.write(absolute_path, content)
180
+ end
181
+ end
182
+
183
+ # Returns File.lstat for path, or nil if the path does not exist.
184
+ def safe_lstat(path)
185
+ ::File.lstat(path)
186
+ rescue ::SystemCallError
187
+ nil
188
+ end
189
+
190
+ # Returns the contents of path as a string, or nil if the file cannot be read.
191
+ def safe_read(path)
192
+ ::File.read(path)
193
+ rescue ::SystemCallError
194
+ nil
195
+ end
196
+
197
+ # Joins paths, simplifying if either argument is "."
198
+ def simplifying_join(path1, path2)
199
+ if path1 == "."
200
+ path2
201
+ elsif path2 == "."
202
+ path1
203
+ else
204
+ "#{path1}/#{path2}"
205
+ end
206
+ end
207
+
208
+ # Writes content to a relative destination, appending to results. Skips unchanged files
209
+ # without calling the block; calls the block for new or overwrite cases to confirm.
210
+ def write_file(gh_pages_dir, relative_destination, content, results, &confirm)
211
+ destination = ::File.expand_path(relative_destination, gh_pages_dir)
212
+ stat = safe_lstat(destination)
213
+ if stat
214
+ if stat.file? && safe_read(destination) == content
215
+ results << {destination: relative_destination, outcome: :unchanged}
216
+ return
217
+ end
218
+ status = :overwrite
219
+ ftype = stat.ftype
220
+ else
221
+ status = :new
222
+ ftype = nil
223
+ end
224
+ proceed = confirm ? confirm.call(relative_destination, status, ftype) : true
225
+ if proceed
226
+ ::FileUtils.mkdir_p(::File.dirname(destination))
227
+ ::FileUtils.remove_entry(destination, true) if stat
228
+ ::File.write(destination, content)
229
+ results << {destination: relative_destination, outcome: :wrote}
230
+ else
231
+ results << {destination: relative_destination, outcome: :skipped}
232
+ end
233
+ end
234
+
235
+ # Generates the v0 placeholder, component index, and latest/index redirect for one component.
236
+ def generate_component_files(gh_pages_dir, template_dir, comp_settings, results, &confirm)
237
+ version = current_component_version(gh_pages_dir, comp_settings)
238
+ redirect_url = "https://#{component_base_path(comp_settings)}/v#{version}"
239
+ subdir = comp_settings.gh_pages_directory
240
+
241
+ write_file(gh_pages_dir, simplifying_join(subdir, "v0/index.html"),
242
+ render_template(template_dir, "empty.html.erb", {name: comp_settings.name}),
243
+ results, &confirm)
244
+ write_file(gh_pages_dir, simplifying_join(subdir, "index.html"),
245
+ render_template(template_dir, "redirect.html.erb", {redirect_url: redirect_url}),
246
+ results, &confirm)
247
+ write_file(gh_pages_dir, simplifying_join(subdir, "latest/index.html"),
248
+ render_template(template_dir, "redirect.html.erb", {redirect_url: redirect_url}),
249
+ results, &confirm)
250
+ end
251
+
252
+ # Generates .nojekyll, .gitignore, and (when no root component exists) the root index redirect.
253
+ def generate_toplevel_files(gh_pages_dir, template_dir, results, &confirm)
254
+ write_file(gh_pages_dir, ".nojekyll", "", results, &confirm)
255
+ write_file(gh_pages_dir, ".gitignore", render_template(template_dir, "gitignore.erb", {}), results, &confirm)
256
+
257
+ return if @enabled_component_settings.any? { |s| s.gh_pages_directory == "." }
258
+
259
+ write_file(gh_pages_dir, "index.html",
260
+ render_template(template_dir, "redirect.html.erb", {redirect_url: @default_redirect_url}),
261
+ results, &confirm)
262
+ end
263
+
264
+ # Generates 404.html with version variables and redirect-replacement regexps for all components.
265
+ def generate_html404(gh_pages_dir, template_dir, results, &confirm)
266
+ version_vars = {}
267
+ replacement_info = @enabled_component_settings.map do |comp_settings|
268
+ version_vars[comp_settings.gh_pages_version_var] =
269
+ current_component_version(gh_pages_dir, comp_settings)
270
+ base_path = component_base_path(comp_settings)
271
+ regexp_source = "//#{::Regexp.escape(base_path)}/latest(/|$)"
272
+ CompInfo.new(base_path, regexp_source, comp_settings.gh_pages_version_var)
273
+ end
274
+ template_params = {
275
+ default_redirect_url: @default_redirect_url,
276
+ version_vars: version_vars,
277
+ replacement_info: replacement_info,
278
+ }
279
+ write_file(gh_pages_dir, "404.html", render_template(template_dir, "404.html.erb", template_params),
280
+ results, &confirm)
281
+ end
282
+ end
283
+ end
284
+ end
@@ -170,6 +170,36 @@ module Toys
170
170
  end
171
171
  end
172
172
 
173
+ ##
174
+ # This configuration is present for a component if version_from_code is
175
+ # requested, i.e. the version to release is the version in the code.
176
+ #
177
+ class VersionFromCodeSettings
178
+ # @private
179
+ def initialize(info, errors)
180
+ unless info.is_a?(::Hash)
181
+ errors << "version_from_code expected to be true or a dictionary" unless info == true
182
+ info = {}
183
+ end
184
+ bump_name = info.delete("bump") || "none"
185
+ @bump = Semver.for_name(bump_name)
186
+ unless @bump
187
+ errors << "Unrecognized semver bump value: #{bump_name.inspect}"
188
+ @bump = Semver::NONE
189
+ end
190
+ info.each_key do |key|
191
+ errors << "Unknown key #{key.inspect} for version_from_code"
192
+ end
193
+ end
194
+
195
+ ##
196
+ # @return [Semver] What semver field to bump if the requested version has
197
+ # already been released. If this is {Semver::NONE} and the requested
198
+ # version is already released, the release request will error out.
199
+ #
200
+ attr_reader :bump
201
+ end
202
+
173
203
  ##
174
204
  # Configuration of a single component
175
205
  #
@@ -191,6 +221,7 @@ module Toys
191
221
  read_steps_info(info, repo_settings)
192
222
  read_commit_tag_info(info, repo_settings)
193
223
  read_update_deps(info, repo_settings)
224
+ read_version_from_code(info, repo_settings)
194
225
  check_problems(info, repo_settings)
195
226
  end
196
227
 
@@ -292,6 +323,13 @@ module Toys
292
323
  #
293
324
  attr_reader :update_dependencies
294
325
 
326
+ ##
327
+ # @return [VersionFromCodeSettings,nil] Configuration for using
328
+ # the version in code as the version to release instead of
329
+ # interpreting conventional commit tags.
330
+ #
331
+ attr_reader :version_from_code
332
+
295
333
  ##
296
334
  # @return [StepSettings,nil] The unique step with the given name
297
335
  #
@@ -376,6 +414,14 @@ module Toys
376
414
  end
377
415
  end
378
416
 
417
+ def read_version_from_code(info, repo_settings)
418
+ vfc_info = info.delete("version_from_code")
419
+ @version_from_code =
420
+ if vfc_info
421
+ VersionFromCodeSettings.new(vfc_info, repo_settings.errors)
422
+ end
423
+ end
424
+
379
425
  def camelize(str)
380
426
  str.to_s
381
427
  .sub(/^_/, "")
@@ -249,13 +249,38 @@ module Toys
249
249
  @utils.log("Creating #{component.name} changeset from #{latest_tag || 'start'} to #{@release_sha}")
250
250
  changeset = component.make_change_set(from: latest_tag, to: @release_sha)
251
251
  unless requested_version
252
- cur_suggested_version = requested_bump&.bump(last_version) || changeset.suggested_version(last_version)
252
+ cur_suggested_version = determine_suggested_version(component, changeset, requested_bump, last_version)
253
253
  if !best_suggested_version || (cur_suggested_version && cur_suggested_version > best_suggested_version)
254
254
  best_suggested_version = cur_suggested_version
255
255
  end
256
256
  end
257
257
  [ResolvedComponent.new(component.name, changeset, last_version, nil), best_suggested_version]
258
258
  end
259
+
260
+ def determine_suggested_version(component, changeset, requested_bump, last_version)
261
+ vfc_settings = component.settings.version_from_code
262
+ if vfc_settings
263
+ requested_version = component.current_constant_version(at: @release_sha)
264
+ if requested_version.nil?
265
+ @utils.error("Unable to read code-specified version for #{component.name}")
266
+ elsif last_version.nil? || requested_version > last_version
267
+ @utils.log("Using code-specified version for #{component.name}")
268
+ requested_version
269
+ elsif vfc_settings.bump == Semver::NONE
270
+ @utils.error("Requested #{component.name} #{requested_version} but #{last_version} is the latest.")
271
+ else
272
+ @utils.log("Requested #{component.name} #{requested_version} but #{last_version} is the latest. " \
273
+ "Bumping to get a releasable version.")
274
+ vfc_settings.bump.bump(last_version, minimum_fill: Semver::PATCH, prevent_bump_to_v1: true)
275
+ end
276
+ elsif requested_bump
277
+ @utils.log("Using requested semver bump of #{requested_bump} for #{component.name}")
278
+ requested_bump.bump(last_version, minimum_fill: Semver::PATCH, prevent_bump_to_v1: true)
279
+ else
280
+ @utils.log("Using changeset-suggested version for #{component.name}")
281
+ changeset.suggested_version(last_version)
282
+ end
283
+ end
259
284
  end
260
285
  end
261
286
  end
@@ -74,19 +74,30 @@ module Toys
74
74
  #
75
75
  # @param version [::Gem::Version] The original version. If nil is passed
76
76
  # in, it is treated as synonymous with `0.0.0`.
77
+ # @param minimum_fill [Semver] Remove zeros down to this length. Default
78
+ # is NONE, indicating to leave the length the same as the input.
79
+ # @param prevent_bump_to_v1 [boolean] If set to true, major version bumps
80
+ # prior to 1.0 will actually bump the minor version, preventing a
81
+ # normal bump to version 1.0.
77
82
  # @return [::Gem::Version] The new version
78
83
  #
79
- def bump(version)
84
+ def bump(version, minimum_fill: Semver::NONE, prevent_bump_to_v1: false)
80
85
  version ||= ::Gem::Version.new("0.0.0")
81
86
  return version if segment.nil?
82
87
  bump_seg = segment
83
- version_segs = version.segments
84
- max_seg = bump_seg
85
- max_seg = 2 if max_seg < 2
86
- version_segs = version_segs[0..max_seg]
87
- bump_seg = 1 if bump_seg.zero? && version_segs[0].zero?
88
- version_segs[bump_seg] += 1
89
- ::Gem::Version.new(version_segs.fill(0, bump_seg + 1).join("."))
88
+ version_segs = version.segments.dup
89
+ bump_seg = 1 if prevent_bump_to_v1 && bump_seg.zero? && version_segs[0].zero?
90
+ if version_segs.size > bump_seg
91
+ fill_seg = minimum_fill.segment || (version_segs.size - 1)
92
+ fill_seg = bump_seg if fill_seg < bump_seg
93
+ version_segs.slice!((fill_seg + 1)..)
94
+ version_segs[bump_seg] += 1
95
+ version_segs.fill(0, bump_seg + 1)
96
+ else
97
+ version_segs.concat(::Array.new(bump_seg - version_segs.size, 0))
98
+ version_segs << 1
99
+ end
100
+ ::Gem::Version.new(version_segs.join("."))
90
101
  end
91
102
 
92
103
  ##
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "toys/release/gh_pages_logic"
4
5
  require "toys/utils/gems"
5
6
 
6
7
  module Toys
@@ -300,8 +301,12 @@ module Toys
300
301
  dest_dir = ::File.join(component_dir, "v#{step_context.release_version}")
301
302
  check_existence(step_context, dest_dir)
302
303
  copy_docs_dir(step_context, dest_dir)
303
- update_404_page(step_context, gh_pages_dir)
304
- update_index_pages(step_context, gh_pages_dir)
304
+ logic = ::Toys::Release::GhPagesLogic.new(step_context.repository.settings)
305
+ logic.update_version_pages(
306
+ gh_pages_dir,
307
+ step_context.component.settings,
308
+ step_context.release_version
309
+ ) { |msg| step_context.warning(msg) }
305
310
  push_docs_to_git(step_context, gh_pages_dir)
306
311
  end
307
312
 
@@ -341,39 +346,6 @@ module Toys
341
346
  ::FileUtils.cp_r(source_dir, dest_dir)
342
347
  end
343
348
 
344
- def update_404_page(step_context, gh_pages_dir)
345
- path = ::File.join(gh_pages_dir, "404.html")
346
- unless ::File.file?(path)
347
- step_context.warning("404.html not found. Skipping.")
348
- return
349
- end
350
- content = ::File.read(path)
351
- version_var = step_context.component.settings.gh_pages_version_var
352
- content.sub!(/#{Regexp.escape(version_var)} = "[\w.]+";/,
353
- "#{version_var} = \"#{step_context.release_version}\";")
354
- ::File.write(path, content)
355
- end
356
-
357
- def update_index_pages(step_context, gh_pages_dir)
358
- subdir = step_context.component.settings.gh_pages_directory
359
- dir_suffix = subdir == "." ? "" : "/#{subdir}"
360
- settings = step_context.repository.settings
361
- version = step_context.release_version
362
- redirect_url = "https://#{settings.repo_owner}.github.io/#{settings.repo_name}#{dir_suffix}/v#{version}"
363
- ["index.html", "latest/index.html"].each do |filename|
364
- path = "#{gh_pages_dir}#{dir_suffix}/#{filename}"
365
- unless ::File.file?(path)
366
- step_context.warning("#{path} not found. Skipping.")
367
- next
368
- end
369
- content = ::File.read(path)
370
- content.gsub!(/ href="[^"]+"/, " href=\"#{redirect_url}\"")
371
- content.gsub!(/ content="0; url=[^"]+"/, " content=\"0; url=#{redirect_url}\"")
372
- content.gsub!(/window\.location\.replace\("[^"]+"\)/, "window.location.replace(\"#{redirect_url}\")")
373
- ::File.write(path, content)
374
- end
375
- end
376
-
377
349
  def push_docs_to_git(step_context, gh_pages_dir)
378
350
  ::Dir.chdir(gh_pages_dir) do
379
351
  step_context.repository.git_commit("Generated docs for #{step_context.release_description}",
@@ -50,7 +50,7 @@ module Toys
50
50
  #
51
51
  def update_version(version)
52
52
  @utils.log("Updating #{path} to set VERSION=#{version}")
53
- new_content = content.sub(/(?<=\W)VERSION\s*=\s*(["'])(\d+(?:\.[a-zA-Z0-9]+)+)\1/,
53
+ new_content = content.sub(/(?<=\W)VERSION\s*=\s*(["'])(\d+(?:\.[a-zA-Z0-9]+)*)\1/,
54
54
  "VERSION = \\1#{version}\\1")
55
55
  ::File.write(path, new_content)
56
56
  self
@@ -64,7 +64,7 @@ module Toys
64
64
  # @return [nil] if no version was found
65
65
  #
66
66
  def self.current_version_from_content(content)
67
- match = /(?<=\W)VERSION\s*=\s*(["'])(\d+(?:\.[a-zA-Z0-9]+)+)\1/.match(content)
67
+ match = /(?<=\W)VERSION\s*=\s*(["'])(\d+(?:\.[a-zA-Z0-9]+)*)\1/.match(content)
68
68
  match ? ::Gem::Version.new(match[2]) : nil
69
69
  end
70
70
  end
data/toys/gen-gh-pages.rb CHANGED
@@ -21,18 +21,6 @@ flag :dry_run
21
21
  include :exec
22
22
  include :terminal, styled: true
23
23
 
24
- # Context for ERB templates
25
- class ErbContext
26
- def initialize(data)
27
- data.each { |name, value| instance_variable_set("@#{name}", value) }
28
- freeze
29
- end
30
-
31
- def self.get(data)
32
- new(data).instance_eval { binding }
33
- end
34
- end
35
-
36
24
  def run
37
25
  setup
38
26
  generate_gh_pages
@@ -41,10 +29,9 @@ def run
41
29
  end
42
30
 
43
31
  def setup
44
- require "erb"
45
- require "fileutils"
46
32
  require "toys/release/artifact_dir"
47
33
  require "toys/release/environment_utils"
34
+ require "toys/release/gh_pages_logic"
48
35
  require "toys/release/repo_settings"
49
36
  require "toys/release/repository"
50
37
 
@@ -57,129 +44,47 @@ def setup
57
44
  branch: "gh-pages", remote: git_remote, dir: @artifact_dir.get("gh-pages"),
58
45
  gh_token: ::ENV["GITHUB_TOKEN"], create: true
59
46
  )
60
- @relevant_component_settings = @settings.all_component_settings.find_all(&:gh_pages_enabled)
61
- if @relevant_component_settings.empty?
47
+ if @settings.all_component_settings.none?(&:gh_pages_enabled)
62
48
  puts "No components have gh-pages enabled", :red, :bold
63
49
  exit(1)
64
50
  end
51
+ @template_dir = find_data("gh-pages", type: :directory)
52
+ raise "Fatal: Unable to find gh-pages template data directory" unless @template_dir
53
+ @logic = Toys::Release::GhPagesLogic.new(@settings)
65
54
  end
66
55
 
67
56
  def cleanup
68
57
  @artifact_dir.cleanup
69
58
  end
70
59
 
71
- CompInfo = ::Struct.new(:base_path, :regexp_source, :version_var)
72
-
73
60
  def generate_gh_pages
74
- ::Dir.chdir(@gh_pages_dir) do
75
- @relevant_component_settings.each do |component_settings|
76
- generate_component_files(component_settings)
77
- end
78
- generate_toplevel_files
79
- generate_html404
61
+ @logic.cleanup_v0_directories(@gh_pages_dir) do |directory, _children|
62
+ puts "Non-index files exist in #{directory}.", :yellow, :bold
63
+ yes || confirm("Remove? ", default: true)
80
64
  end
81
- puts "Files generated into #{@gh_pages_dir}", :bold
82
- end
83
-
84
- def generate_component_files(comp_settings)
85
- prepare_v0_directory("#{comp_settings.gh_pages_directory}/v0")
86
- generate_file("#{comp_settings.gh_pages_directory}/v0/index.html",
87
- "empty.html.erb", {name: comp_settings.name})
88
- component_redirect_url = "https://#{component_base_path(comp_settings)}/v#{current_component_version(comp_settings)}"
89
- generate_file("#{comp_settings.gh_pages_directory}/index.html",
90
- "redirect.html.erb", {redirect_url: component_redirect_url})
91
- generate_file("#{comp_settings.gh_pages_directory}/latest/index.html",
92
- "redirect.html.erb", {redirect_url: component_redirect_url})
93
- end
94
-
95
- def prepare_v0_directory(directory)
96
- ::FileUtils.mkdir_p(directory)
97
- children = ::Dir.children(directory) - ["index.html"]
98
- return if children.empty?
99
- puts "Non-index files exist in #{directory}.", :yellow, :bold
100
- return unless yes || confirm("Remove? ", default: true)
101
- children.each do |child|
102
- ::FileUtils.remove_entry(::File.join(directory, child), true)
103
- end
104
- end
105
-
106
- def current_component_version(comp_settings)
107
- base_dir = comp_settings.gh_pages_directory
108
- latest = ::Gem::Version.new("0")
109
- return latest unless ::File.directory?(base_dir)
110
- ::Dir.children(base_dir).each do |child|
111
- next unless /^v\d+(\.\d+)*$/.match?(child)
112
- next unless ::File.directory?(::File.join(base_dir, child))
113
- version = ::Gem::Version.new(child[1..])
114
- latest = version if version > latest
115
- end
116
- latest
117
- end
118
-
119
- def generate_toplevel_files
120
- ::File.write(".nojekyll", "")
121
- generate_file(".gitignore", "gitignore.erb", {})
122
- unless @relevant_component_settings.any? { |settings| settings.gh_pages_directory == "." }
123
- generate_file("index.html", "redirect.html.erb", {redirect_url: default_redirect_url})
124
- end
125
- end
126
-
127
- def generate_html404
128
- version_vars = {}
129
- replacement_info = @relevant_component_settings.map do |comp_settings|
130
- version_vars[comp_settings.gh_pages_version_var] = current_component_version(comp_settings)
131
- base_path = component_base_path(comp_settings)
132
- regexp_source = "//#{::Regexp.escape(base_path)}/latest(/|$)"
133
- CompInfo.new(base_path, regexp_source, comp_settings.gh_pages_version_var)
134
- end
135
- template_params = {
136
- default_redirect_url: default_redirect_url,
137
- version_vars: version_vars,
138
- replacement_info: replacement_info,
139
- }
140
- generate_file("404.html", "404.html.erb", template_params)
141
- end
142
-
143
- def url_base_path
144
- @url_base_path ||= "#{@settings.repo_owner}.github.io/#{@settings.repo_name}"
145
- end
146
-
147
- def component_base_path(component_settings)
148
- if component_settings.gh_pages_directory == "."
149
- url_base_path
150
- else
151
- "#{url_base_path}/#{component_settings.gh_pages_directory}"
152
- end
153
- end
154
-
155
- def default_redirect_url
156
- @default_redirect_url ||= "https://#{component_base_path(@relevant_component_settings.first)}/latest"
157
- end
158
-
159
- def generate_file(destination, template, data)
160
- return unless file_generation_confirmations(destination)
161
- template_path = find_data("gh-pages/#{template}")
162
- raise "Unable to find template #{template}" unless template_path
163
- erb = ::ERB.new(::File.read(template_path))
164
- content = erb.result(ErbContext.get(data))
165
- ::FileUtils.mkdir_p(::File.dirname(destination))
166
- ::File.write(destination, content)
167
- puts "Wrote #{destination}.", :green
168
- end
169
-
170
- def file_generation_confirmations(destination)
171
- if ::File.exist?(destination)
172
- if ::File.directory?(destination)
173
- puts "Destination #{destination} exists and is a DIRECTORY.", :yellow, :bold
65
+ results = @logic.generate_files(@gh_pages_dir, @template_dir) do |destination, status, existing_ftype|
66
+ if status == :overwrite
67
+ puts "Destination #{destination} exists (type: #{existing_ftype})", :yellow, :bold
68
+ yes || confirm("Overwrite? ", default: true)
174
69
  else
175
- puts "Destination file #{destination} exists.", :yellow, :bold
70
+ yes || confirm("Create file #{destination}? ", default: true)
71
+ end
72
+ end
73
+ output_results(results)
74
+ end
75
+
76
+ def output_results(results)
77
+ results.each do |r|
78
+ case r[:outcome]
79
+ when :wrote
80
+ puts "Wrote #{r[:destination]}.", :green
81
+ when :unchanged
82
+ puts "Unchanged: #{r[:destination]}.", :green
83
+ when :skipped
84
+ puts "Skipped: #{r[:destination]}.", :yellow
176
85
  end
177
- return false unless yes || confirm("Overwrite? ", default: true)
178
- ::FileUtils.remove_entry(destination)
179
- else
180
- return false unless yes || confirm("Create file #{destination}? ", default: true)
181
86
  end
182
- true
87
+ puts "Files generated into #{@gh_pages_dir}", :bold
183
88
  end
184
89
 
185
90
  def push_gh_pages
@@ -69,20 +69,37 @@ def generate_all_files
69
69
  end
70
70
 
71
71
  def generate_file(name)
72
+ template_path = find_data("templates/#{name}.erb")
73
+ raise "Unable to find template #{name}.erb" unless template_path
74
+ erb = ::ERB.new(::File.read(template_path))
75
+ content = erb.result(ErbContext.get(@settings))
76
+
72
77
  destination = ::File.join(workflows_dir, name)
73
- if ::File.readable?(destination)
74
- puts "Destination file #{destination} exists.", :yellow
78
+ stat = safe_lstat(destination)
79
+ if stat
80
+ if stat.file? && safe_read(destination) == content
81
+ puts "Unchanged: #{destination}.", :green
82
+ return
83
+ end
84
+ puts "Destination #{destination} exists (type: #{stat.ftype})", :yellow, :bold
75
85
  return unless yes || confirm("Overwrite? ", default: true)
76
86
  else
77
87
  return unless yes || confirm("Create file #{destination}? ", default: true)
78
88
  end
79
89
 
80
- template_path = find_data("templates/#{name}.erb")
81
- raise "Unable to find template #{name}.erb" unless template_path
82
- erb = ::ERB.new(::File.read(template_path))
90
+ ::FileUtils.remove_entry(destination, true)
91
+ ::File.write(destination, content)
92
+ puts "Wrote #{destination}.", :green
93
+ end
83
94
 
84
- ::File.open(destination, "w") do |file|
85
- file.write(erb.result(ErbContext.get(@settings)))
86
- puts "Wrote #{destination}.", :green
87
- end
95
+ def safe_lstat(path)
96
+ ::File.lstat(path)
97
+ rescue ::SystemCallError
98
+ nil
99
+ end
100
+
101
+ def safe_read(path)
102
+ ::File.read(path)
103
+ rescue ::SystemCallError
104
+ nil
88
105
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: toys-release
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.2
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Azuma
@@ -76,6 +76,7 @@ files:
76
76
  - toys/.lib/toys/release/component.rb
77
77
  - toys/.lib/toys/release/environment_utils.rb
78
78
  - toys/.lib/toys/release/gemspec_file.rb
79
+ - toys/.lib/toys/release/gh_pages_logic.rb
79
80
  - toys/.lib/toys/release/performer.rb
80
81
  - toys/.lib/toys/release/pipeline.rb
81
82
  - toys/.lib/toys/release/pull_request.rb
@@ -101,10 +102,10 @@ homepage: https://github.com/dazuma/toys
101
102
  licenses:
102
103
  - MIT
103
104
  metadata:
104
- changelog_uri: https://dazuma.github.io/toys/gems/toys-release/v0.8.2/file.CHANGELOG.html
105
- source_code_uri: https://github.com/dazuma/toys/tree/toys-release/v0.8.2/toys-release
105
+ changelog_uri: https://dazuma.github.io/toys/gems/toys-release/v0.9.0/file.CHANGELOG.html
106
+ source_code_uri: https://github.com/dazuma/toys/tree/toys-release/v0.9.0/toys-release
106
107
  bug_tracker_uri: https://github.com/dazuma/toys/issues
107
- documentation_uri: https://dazuma.github.io/toys/gems/toys-release/v0.8.2
108
+ documentation_uri: https://dazuma.github.io/toys/gems/toys-release/v0.9.0
108
109
  rdoc_options: []
109
110
  require_paths:
110
111
  - lib