ruby-gem-release 0.3.1 → 0.4.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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +20 -6
- data/lib/ruby-gem-release/changelog/base.rb +26 -0
- data/lib/ruby-gem-release/changelog/intridea.rb +3 -11
- data/lib/ruby-gem-release/changelog/keep_a_changelog.rb +2 -4
- data/lib/ruby-gem-release/package/ruby_gem.rb +31 -2
- data/lib/ruby-gem-release/tasks.rb +6 -1
- data/lib/ruby-gem-release/version/ruby_constant.rb +2 -2
- data/lib/ruby-gem-release/version.rb +1 -1
- data/lib/ruby-gem-release/workflow/context.rb +12 -5
- data/lib/ruby-gem-release/workflow/ruby_gem.rb +8 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1babfa3cbf929529a6e8d82e20d3caff5a890c65169dd9db841421e1b2e64057
|
|
4
|
+
data.tar.gz: 90a8b9b0f759a590d3ca2282d02e84aea20c9edc145af3019ee824fc6f2a05c2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 110cdc78d668d1fe44efdff779514d26bbdc83a4d1905bbb920fa9d8edaed02f1bb683694170141714363177f20b73b515629badea8179708e86fe00a3e4bc2a
|
|
7
|
+
data.tar.gz: e48613def1a278613a88e99575552425c3d6b1e8a6674e4b5f08010489ae5a608e730b09e0e9e46600b9543028c72ebf101528d1b4032e916458057e517c5773
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 (2026/11/08)
|
|
4
|
+
|
|
5
|
+
- [#24](https://github.com/dblock/ruby-gem-release/pull/24): Reject empty release notes in both built-in changelog formats before writing release updates, ignoring placeholders, category headings, comments, and reference definitions - [@dblock](https://github.com/dblock).
|
|
6
|
+
- [#25](https://github.com/dblock/ruby-gem-release/pull/25): Reject existing RubyGems versions before release publication, stop on version lookup failures, and highlight release safeguards, recovery files, and timezone support in README - [@dblock](https://github.com/dblock).
|
|
7
|
+
- [#26](https://github.com/dblock/ruby-gem-release/pull/26): Select a release version with the existing version input, update it through the configured version object before preparation, and replace the version setter API with `set!` (breaking change) - [@dblock](https://github.com/dblock).
|
|
8
|
+
- [#27](https://github.com/dblock/ruby-gem-release/pull/27): Document maintainer-approved publication with protected GitHub environments and clarify dispatch permissions - [@dblock](https://github.com/dblock).
|
|
9
|
+
|
|
3
10
|
## 0.3.1 (2026/11/07)
|
|
4
11
|
|
|
5
12
|
- [#23](https://github.com/dblock/ruby-gem-release/pull/23): Reject dirty checkouts after dependency installation and commit only staged release preparation changes, preventing generated Bundler artifacts and unrelated files from entering releases - [@dblock](https://github.com/dblock).
|
data/README.md
CHANGED
|
@@ -9,8 +9,13 @@
|
|
|
9
9
|
Update your changelog, validate and publish your gem, create its GitHub Release, and prepare the next development iteration with one workflow dispatch. Choose your version and changelog formats; the standard workflow handles the release sequence.
|
|
10
10
|
|
|
11
11
|
- **No stored RubyGems API key:** Authenticate with RubyGems Trusted Publishing using OpenID Connect (OIDC).
|
|
12
|
-
- **Let collaborators
|
|
12
|
+
- **Let collaborators request releases:** After one-time setup, collaborators with write access, including on personal repositories, can dispatch releases without repository admin access or their own RubyGems credentials. [Environment protection](RELEASING.md#maintainer-approved-publication) can require approval from a release maintainer before publication.
|
|
13
13
|
- **Keep your conventions:** Use the standard workflow or compose Ruby objects and callbacks for custom releases.
|
|
14
|
+
- **Choose the release version:** Release the checked-in version or [select a different version](docs/actions/release.md#release-version) when dispatching, without a separate version-bump commit.
|
|
15
|
+
- **Catch release mistakes:** [Check for already published RubyGems versions](docs/package/ruby_gem.md#check-version), reject existing tags, and [require actual release notes](docs/changelog/base.md#release) in both built-in changelog formats.
|
|
16
|
+
- **Keep dependency files out of release commits:** [Reject dirty checkouts after dependency installation](docs/source_control/git.md) and unexpected working tree or index changes after preparation, instead of staging unrelated files during publication.
|
|
17
|
+
- **Build once and retain recovery files:** Publish the same verified gem artifact to RubyGems and attach it to the GitHub Release. Preserve available gem, checksum manifest, and Git recovery bundle files for [recovery after failures](docs/actions/release.md#recovery-artifacts).
|
|
18
|
+
- **Use your release timezone:** [Configure release dates](docs/actions/release.md#timezone) with a named timezone such as `America/New_York`, including daylight saving changes; UTC is the default.
|
|
14
19
|
|
|
15
20
|
Requires Ruby 3.3+, GitHub Actions, and RubyGems Trusted Publishing.
|
|
16
21
|
|
|
@@ -42,6 +47,10 @@ name: Release
|
|
|
42
47
|
on:
|
|
43
48
|
workflow_dispatch:
|
|
44
49
|
inputs:
|
|
50
|
+
version:
|
|
51
|
+
description: Optional release version overriding the checked-in version.
|
|
52
|
+
type: string
|
|
53
|
+
required: false
|
|
45
54
|
rubygems-owner:
|
|
46
55
|
description: RubyGems account ID for first publication; omit for an existing gem.
|
|
47
56
|
type: string
|
|
@@ -53,9 +62,10 @@ permissions:
|
|
|
53
62
|
|
|
54
63
|
jobs:
|
|
55
64
|
release:
|
|
56
|
-
uses: dblock/ruby-gem-release/.github/workflows/release.yml@v0.3.
|
|
65
|
+
uses: dblock/ruby-gem-release/.github/workflows/release.yml@v0.3.1
|
|
57
66
|
with:
|
|
58
67
|
ruby-version: '3.3'
|
|
68
|
+
version: ${{ inputs.version }}
|
|
59
69
|
rubygems-owner: ${{ inputs.rubygems-owner }}
|
|
60
70
|
```
|
|
61
71
|
|
|
@@ -67,13 +77,11 @@ In your GitHub repository, open **Settings → Environments** and create an envi
|
|
|
67
77
|
gh api --method PUT repos/{owner}/{repo}/environments/release
|
|
68
78
|
```
|
|
69
79
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
For example, the trusted publishers page for a gem named `example` is `https://rubygems.org/gems/example/trusted_publishers`.
|
|
80
|
+
For a new gem, open the [pending trusted publishers page](https://rubygems.org/profile/oidc/pending_trusted_publishers). For an existing gem, open your existing gem's **Trusted publishers** page (e.g. `https://rubygems.org/gems/example/trusted_publishers`). Create a [trusted publisher](https://guides.rubygems.org/trusted-publishing/#using-reusable-workflows). Set the repository fields to your gem's repository, the workflow filename to `release.yml`, and the environment to `release`. Set the separate workflow repository fields to `dblock` and `ruby-gem-release`; these identify the reusable workflow RubyGems verifies in the OIDC token.
|
|
73
81
|
|
|
74
82
|
### 4. Make a Release
|
|
75
83
|
|
|
76
|
-
Your branch rules must permit release commits and tags to be pushed; see [branch permissions](RELEASING.md#one-time-setup). Dispatching
|
|
84
|
+
Your branch rules must permit release commits and tags to be pushed; see [branch permissions](RELEASING.md#one-time-setup). Dispatching starts a publishing run; when required reviewers are configured, the release job waits for approval from a designated maintainer. Run your project's tests and build checks, review the changes, and dispatch from your default branch:
|
|
77
85
|
|
|
78
86
|
**First release:** Pass the numeric RubyGems account ID shown on the Pending Trusted Publishers page as `rubygems-owner`. This declares the account that will own the gem on its first publication. Replace `YOUR_ACCOUNT_ID` below with that ID:
|
|
79
87
|
|
|
@@ -87,6 +95,8 @@ gh workflow run release.yml -f rubygems-owner=YOUR_ACCOUNT_ID
|
|
|
87
95
|
gh workflow run release.yml
|
|
88
96
|
```
|
|
89
97
|
|
|
98
|
+
To release a different version, select it in the dispatch form or pass `-f version=1.2.3`. The standard workflow updates the configured version file before preparing the changelog, then starts the next development iteration from the selected release version.
|
|
99
|
+
|
|
90
100
|
For a real-world example of adding release automation to an existing gem, see [ruby-link-checker #26](https://github.com/dblock/ruby-link-checker/pull/26). It adds the dependency, Rake tasks, reusable workflow, and release instructions while preserving the existing changelog format.
|
|
91
101
|
|
|
92
102
|
## Configuration
|
|
@@ -95,6 +105,10 @@ See [Detailed Usage](docs/usage.md#configuration) for workflow customization, ca
|
|
|
95
105
|
|
|
96
106
|
For step-based integration, see the [Composite Action](docs/actions/action.md).
|
|
97
107
|
|
|
108
|
+
### Release Access
|
|
109
|
+
|
|
110
|
+
Creating the environment without protection rules does not restrict release access. For maintainer-only publication, configure **Settings → Environments → release** with required maintainer reviewers, self-review and administrator-bypass rules, and deployment branches restricted to `main` (or your actual default branch), as described in [Maintainer-Approved Publication](RELEASING.md#maintainer-approved-publication). Other repository writers can still dispatch, but publication waits for a designated maintainer to approve; environments do not provide a workflow-dispatch allow list.
|
|
111
|
+
|
|
98
112
|
## Contributing
|
|
99
113
|
|
|
100
114
|
See [CONTRIBUTING.md](CONTRIBUTING.md), [DESIGN.md](DESIGN.md), [CHANGELOG.md](CHANGELOG.md), and [RELEASING.md](RELEASING.md).
|
|
@@ -44,6 +44,32 @@ module RubyGemRelease
|
|
|
44
44
|
content[position, 6][/\A#+/]
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
def section_end_position(content, position, level)
|
|
48
|
+
heading_positions(content, /\A\#{1,6} /, max_level: level).find { |offset| offset > position } || content.length
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def line_end(content, position, separator)
|
|
52
|
+
content.index(separator, position) || raise(ArgumentError, "heading must end with a newline in #{@path}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def check_release_notes(content, position, separator, version)
|
|
56
|
+
section_end = section_end_position(content, position, heading_prefix(content, position).length)
|
|
57
|
+
check_notes(content[line_end(content, position, separator)...section_end], version)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def check_notes(section, version)
|
|
61
|
+
return section if notes?(document(section))
|
|
62
|
+
|
|
63
|
+
raise ArgumentError, "cannot release #{version}: empty release notes in #{@path}; add release notes before retrying"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def notes?(node)
|
|
67
|
+
return false if %i[heading html_block html_inline].include?(node.type)
|
|
68
|
+
return !node.string_content.strip.empty? if %i[text code code_block].include?(node.type)
|
|
69
|
+
|
|
70
|
+
node.each.any? { |child| notes?(child) }
|
|
71
|
+
end
|
|
72
|
+
|
|
47
73
|
def editable_lines(content)
|
|
48
74
|
excluded = excluded_lines(content)
|
|
49
75
|
offset = 0
|
|
@@ -24,7 +24,7 @@ module RubyGemRelease
|
|
|
24
24
|
check_release_absent(content, version)
|
|
25
25
|
section_end = section_end_position(content, position, prefix.length)
|
|
26
26
|
content[position...section_end] = "#{prefix} #{version} (#{date.strftime('%Y/%m/%d')})" +
|
|
27
|
-
|
|
27
|
+
release_notes(content[line_end(content, position, separator)...section_end], separator, version)
|
|
28
28
|
File.write(@path, content)
|
|
29
29
|
end
|
|
30
30
|
|
|
@@ -45,22 +45,14 @@ module RubyGemRelease
|
|
|
45
45
|
check_absent(content, %r{\A\#{1,6} #{Regexp.escape(version)} \(\d{4}/\d{2}/\d{2}\)\z}, 'release heading already exists')
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
def
|
|
49
|
-
content.index(separator, position) || raise(ArgumentError, "heading must end with a newline in #{@path}")
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def section_end_position(content, position, level)
|
|
53
|
-
heading_positions(content, /\A\#{1,6} /, max_level: level).find { |offset| offset > position } || content.length
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def remove_placeholder(section, separator)
|
|
48
|
+
def release_notes(section, separator, version)
|
|
57
49
|
positions = []
|
|
58
50
|
editable_lines(section) { |line, offset| positions << offset if line.chomp == @placeholder }
|
|
59
51
|
raise ArgumentError, "expected exactly one placeholder in #{@path}" unless positions.one?
|
|
60
52
|
|
|
61
53
|
position = positions.first
|
|
62
54
|
section.slice!(position, placeholder_length(section, position, separator))
|
|
63
|
-
section
|
|
55
|
+
check_notes(section, version)
|
|
64
56
|
end
|
|
65
57
|
|
|
66
58
|
def placeholder_length(section, position, separator)
|
|
@@ -13,11 +13,9 @@ module RubyGemRelease
|
|
|
13
13
|
separator = newline(content)
|
|
14
14
|
position = heading(content, UNRELEASED)
|
|
15
15
|
check_absent(content, /\A\#{1,6} \[#{Regexp.escape(version)}\] - \d{4}-\d{2}-\d{2}\z/, 'release heading already exists')
|
|
16
|
-
line_end = content
|
|
17
|
-
raise ArgumentError, "heading must end with a newline in #{@path}" unless line_end
|
|
18
|
-
|
|
19
|
-
content[position...line_end] = "#{heading_prefix(content, position)} [#{version}] - #{date.strftime('%Y-%m-%d')}"
|
|
16
|
+
content[position...line_end(content, position, separator)] = "#{heading_prefix(content, position)} [#{version}] - #{date.strftime('%Y-%m-%d')}"
|
|
20
17
|
release_references(content, version, separator)
|
|
18
|
+
check_release_notes(content, position, separator, version)
|
|
21
19
|
File.write(@path, content)
|
|
22
20
|
end
|
|
23
21
|
|
|
@@ -6,6 +6,8 @@ require 'json'
|
|
|
6
6
|
require 'digest'
|
|
7
7
|
require 'fileutils'
|
|
8
8
|
require 'tmpdir'
|
|
9
|
+
require 'net/http'
|
|
10
|
+
require 'uri'
|
|
9
11
|
|
|
10
12
|
module RubyGemRelease
|
|
11
13
|
module Package
|
|
@@ -18,8 +20,14 @@ module RubyGemRelease
|
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def version
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
specification.fetch('version')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def check_version!(version)
|
|
27
|
+
name = specification.fetch('name')
|
|
28
|
+
raise 'Invalid package name' unless name.is_a?(String) && /\A[A-Za-z0-9][A-Za-z0-9_.-]*\z/.match?(name)
|
|
29
|
+
|
|
30
|
+
check_published_version(name, version)
|
|
23
31
|
end
|
|
24
32
|
|
|
25
33
|
def build!(release)
|
|
@@ -41,11 +49,32 @@ module RubyGemRelease
|
|
|
41
49
|
|
|
42
50
|
def publish!(release)
|
|
43
51
|
path = artifact(release)
|
|
52
|
+
check_published_version(verified_manifest(release).fetch('name'), release.version)
|
|
44
53
|
run(Gem.ruby, '-S', 'gem', 'push', path)
|
|
45
54
|
end
|
|
46
55
|
|
|
47
56
|
private
|
|
48
57
|
|
|
58
|
+
def specification
|
|
59
|
+
script = 'spec = Gem::Specification.load(ARGV.fetch(0)); abort "Cannot load gemspec" unless spec; puts JSON.generate(name: spec.name, version: spec.version.to_s)'
|
|
60
|
+
JSON.parse(run(Gem.ruby, '-rrubygems', '-rjson', '-e', script, @gemspec))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def check_published_version(name, version)
|
|
64
|
+
response = version_response(name, version)
|
|
65
|
+
raise "#{name} #{version} is already published on RubyGems; do not republish it, see RELEASING.md for recovery" if response.code == '200'
|
|
66
|
+
return if response.code == '404'
|
|
67
|
+
|
|
68
|
+
raise "Cannot check RubyGems for #{name} #{version} (HTTP #{response.code}); verify RubyGems availability before retrying"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def version_response(name, version)
|
|
72
|
+
uri = URI("https://rubygems.org/api/v2/rubygems/#{URI.encode_www_form_component(name)}/versions/#{URI.encode_www_form_component(version)}.json")
|
|
73
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30, max_retries: 0) { |http| http.get(uri.request_uri) }
|
|
74
|
+
rescue Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
|
|
75
|
+
raise "Cannot check RubyGems for #{name} #{version}: #{e.message}; verify connectivity before retrying"
|
|
76
|
+
end
|
|
77
|
+
|
|
49
78
|
def build_manifest(release)
|
|
50
79
|
spec = Gem::Package.new(archive).spec
|
|
51
80
|
raise "Built version #{spec.version} does not match #{release.version}" unless spec.version.to_s == release.version
|
|
@@ -78,7 +78,7 @@ module RubyGemRelease
|
|
|
78
78
|
|
|
79
79
|
def context(before_release: false)
|
|
80
80
|
previous_version = ENV.fetch('RELEASE_PREVIOUS_VERSION')
|
|
81
|
-
version = before_release ? previous_version : ENV.fetch('RELEASE_VERSION')
|
|
81
|
+
version = before_release ? preparation_version(previous_version) : ENV.fetch('RELEASE_VERSION')
|
|
82
82
|
date = ENV.fetch('RELEASE_DATE')
|
|
83
83
|
validate_context_versions(version, previous_version)
|
|
84
84
|
raise ArgumentError, 'release date is required in YYYY-MM-DD format' unless date.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
@@ -86,6 +86,11 @@ module RubyGemRelease
|
|
|
86
86
|
Release.new(version: version, previous_version: previous_version, date: Date.iso8601(date))
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
+
def preparation_version(previous_version)
|
|
90
|
+
selected = ENV.fetch('INPUT_VERSION', '')
|
|
91
|
+
selected.empty? ? previous_version : selected
|
|
92
|
+
end
|
|
93
|
+
|
|
89
94
|
def validate_context_versions(*versions)
|
|
90
95
|
pattern = Version::RubyConstant::PATTERN
|
|
91
96
|
versions.compact.each do |value|
|
|
@@ -18,7 +18,7 @@ module RubyGemRelease
|
|
|
18
18
|
value
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
def set(value)
|
|
21
|
+
def set!(value)
|
|
22
22
|
parse(value)
|
|
23
23
|
content = File.read(@path)
|
|
24
24
|
match = assignment(content)
|
|
@@ -34,7 +34,7 @@ module RubyGemRelease
|
|
|
34
34
|
end
|
|
35
35
|
parts[index] += 1
|
|
36
36
|
((index + 1)...parts.size).each { |i| parts[i] = 0 }
|
|
37
|
-
set(parts.join('.'))
|
|
37
|
+
set!(parts.join('.'))
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
private
|
|
@@ -28,19 +28,26 @@ module RubyGemRelease
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def resolve!
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
expected = ENV.fetch('INPUT_VERSION', '')
|
|
34
|
-
raise "Prepared version #{version} does not match expected version #{expected}" unless expected.empty? || version == expected
|
|
35
|
-
|
|
31
|
+
package = Package::RubyGem.new
|
|
32
|
+
version = prepared_version(package)
|
|
36
33
|
git = SourceControl::Git.new
|
|
37
34
|
git.check_tag!(version)
|
|
35
|
+
package.check_version!(version)
|
|
38
36
|
git.stage_changes!
|
|
39
37
|
export('RELEASE_VERSION' => version)
|
|
40
38
|
end
|
|
41
39
|
|
|
42
40
|
private
|
|
43
41
|
|
|
42
|
+
def prepared_version(package)
|
|
43
|
+
version = package.version
|
|
44
|
+
validate_version(version)
|
|
45
|
+
selected = ENV.fetch('INPUT_VERSION', '')
|
|
46
|
+
raise "Prepared version #{version} does not match selected version #{selected}" unless selected.empty? || version == selected
|
|
47
|
+
|
|
48
|
+
version
|
|
49
|
+
end
|
|
50
|
+
|
|
44
51
|
def initial_release(package)
|
|
45
52
|
version = package.version
|
|
46
53
|
validate_version(version)
|
|
@@ -28,11 +28,19 @@ module RubyGemRelease
|
|
|
28
28
|
|
|
29
29
|
def prepare_release(release)
|
|
30
30
|
@git.stage_changes! do
|
|
31
|
+
select_version!(release.version) unless ENV.fetch('INPUT_VERSION', '').empty?
|
|
31
32
|
@changelog&.release!(release.with_version(@version.read))
|
|
32
33
|
end
|
|
33
34
|
end
|
|
34
35
|
|
|
36
|
+
def select_version!(version)
|
|
37
|
+
raise ArgumentError, 'version object must implement set! when selecting a release version' unless @version.respond_to?(:set!)
|
|
38
|
+
|
|
39
|
+
@version.set!(version)
|
|
40
|
+
end
|
|
41
|
+
|
|
35
42
|
def publish(release)
|
|
43
|
+
@package.check_version!(release.version)
|
|
36
44
|
@git.commit!(release)
|
|
37
45
|
@package.build!(release)
|
|
38
46
|
@git.tag!(release)
|