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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb0d582f2aa91a4b7e06e0ca416a6e27a3eb12640ffe11d10f833eb6c2652ca0
4
- data.tar.gz: cf2c4a04e05ae5f4d148ff2d24fc1ff723e38e2abfb41bf0cd9ade68619ea271
3
+ metadata.gz: 1babfa3cbf929529a6e8d82e20d3caff5a890c65169dd9db841421e1b2e64057
4
+ data.tar.gz: 90a8b9b0f759a590d3ca2282d02e84aea20c9edc145af3019ee824fc6f2a05c2
5
5
  SHA512:
6
- metadata.gz: 1d1a8f271c218dbcac3b94cebfb0259559f817f7e64e6581bb51352c3aaa38fd95de13336098d4501aab8ec114d3ec04cfc56861ada30a5bbe6a16b1bfd8ce0a
7
- data.tar.gz: 2f1f8a38fa8de16d07cd5e9968b298114ae78ab478494b5d452fdbd4202e94be18f797fb27986f4f07e88772f4447d1d61b8899928a1214d7c210d6a411aba26
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 release:** After one-time setup, collaborators with write access, including on personal repositories, can dispatch releases without repository admin access or their own RubyGems credentials.
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.0
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
- On [RubyGems.org](https://rubygems.org), open your gem's **Trusted publishers** page and 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. For a new gem, use the [pending trusted publishers page](https://rubygems.org/profile/oidc/pending_trusted_publishers) instead.
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 the workflow publishes the gem. Run your project's tests and build checks, review the changes, and dispatch from your default branch:
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
- remove_placeholder(content[line_end(content, position, separator)...section_end], separator)
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 line_end(content, position, separator)
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.index(separator, position)
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
- script = 'spec = Gem::Specification.load(ARGV.fetch(0)); abort "Cannot load gemspec" unless spec; puts spec.version'
22
- run(Gem.ruby, '-rrubygems', '-e', script, @gemspec).strip
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyGemRelease
4
- VERSION = '0.3.1'
4
+ VERSION = '0.4.0'
5
5
  end
@@ -28,19 +28,26 @@ module RubyGemRelease
28
28
  end
29
29
 
30
30
  def resolve!
31
- version = Package::RubyGem.new.version
32
- validate_version(version)
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)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-gem-release
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Doubrovkine