ruby-gem-release 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b08ae237496cfc6c20681b49de00754fa64855ca860d47f294cfc32db6a41bc6
4
+ data.tar.gz: de74d06f4cd776ae788843d29e6b9af1d5bb44cfd3db5a754cae007f895d8dd1
5
+ SHA512:
6
+ metadata.gz: 3adf38a074fb0ecc9f260ee4ed1b5554b73e3b197b222e582fed079d4587fe82967e2bbad44979654d9bafa004a394990c378157cd6760c46c2602c250d7100a
7
+ data.tar.gz: 78cefbdc700cc788584fdbcbb9965e27afacd165d0b91452e133e858a7212eb2f3ca2a8f192c0f92d95d02edf454482dd3f43e29bd6e08d867611ba0c6ef48b5
data/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026/11/07)
4
+
5
+ - [#2](https://github.com/dblock/ruby-gem-release/pull/2): Add reusable gem release workflow and version management Rake tasks - [@dblock](https://github.com/dblock).
6
+ - [#5](https://github.com/dblock/ruby-gem-release/pull/5): Add a ruby release logo - [@dblock](https://github.com/dblock).
7
+ - [#6](https://github.com/dblock/ruby-gem-release/pull/6): Add release callbacks for project-specific changelog updates - [@dblock](https://github.com/dblock).
8
+ - [#8](https://github.com/dblock/ruby-gem-release/pull/8): Add modular version and changelog updates, including comparison reference links, with a shared release context - [@dblock](https://github.com/dblock).
9
+ - [#9](https://github.com/dblock/ruby-gem-release/pull/9): Add package, Git, and GitHub Release classes with dry-run-aware publication callbacks, environment-based lifecycle tasks, configurable workflow commands, and Git commit identity inputs - [@dblock](https://github.com/dblock).
10
+ - [#10](https://github.com/dblock/ruby-gem-release/pull/10): Organize release classes, documentation, and tests by area; consolidate README configuration and add a reusable workflow reference - [@dblock](https://github.com/dblock).
11
+ - [#11](https://github.com/dblock/ruby-gem-release/pull/11): Add a standard Ruby gem workflow with explicit strategies and configurable objects, and move manual callback usage into detailed documentation - [@dblock](https://github.com/dblock).
12
+ - [#12](https://github.com/dblock/ruby-gem-release/pull/12): Explain release automation benefits and requirements, clarify version incrementing, simplify the caller example, and move configuration details out of README - [@dblock](https://github.com/dblock).
13
+ - [#13](https://github.com/dblock/ruby-gem-release/pull/13): Add a manual project release caller using the standard workflow, with dry runs enabled by default - [@dblock](https://github.com/dblock).
14
+ - [#14](https://github.com/dblock/ruby-gem-release/pull/14): Support owner-bound OIDC exchanges for first gem publication with pending trusted publishers - [@dblock](https://github.com/dblock).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Doubrovkine
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,100 @@
1
+ <img src="assets/logo.png" alt="A ruby gemstone launching like a rocket" width="160">
2
+
3
+ # Ruby Gem Release
4
+
5
+ [![Test](https://github.com/dblock/ruby-gem-release/actions/workflows/test.yml/badge.svg)](https://github.com/dblock/ruby-gem-release/actions/workflows/test.yml) [![Coverage Status](https://coveralls.io/repos/github/dblock/ruby-gem-release/badge.svg?branch=master)](https://coveralls.io/github/dblock/ruby-gem-release?branch=master)
6
+
7
+ **Release your Ruby gem from GitHub Actions—without maintaining custom release scripts or storing a RubyGems API key.**
8
+
9
+ Update your changelog, validate and publish your gem, create its GitHub Release, and prepare the next development version with one workflow dispatch. Choose your version and changelog formats; the standard workflow handles the release sequence.
10
+
11
+ - **No stored RubyGems API key:** Authenticate with RubyGems Trusted Publishing using OpenID Connect (OIDC).
12
+ - **Try it before publishing:** Dry runs exercise local commits, builds, and tags without uploading or pushing.
13
+ - **Keep your conventions:** Use the standard workflow or compose Ruby objects and callbacks for custom releases.
14
+
15
+ Requires Ruby 3.3+, GitHub Actions, and RubyGems Trusted Publishing.
16
+
17
+ ## Usage
18
+
19
+ ### 1. Configure Your Gem
20
+
21
+ Add `ruby-gem-release` to the development dependencies and install the standard workflow in your Rakefile. Choose your version file and changelog format explicitly:
22
+
23
+ ```ruby
24
+ require 'ruby-gem-release/workflow/ruby_gem'
25
+ require 'ruby-gem-release/changelog'
26
+
27
+ RubyGemRelease::Workflow::RubyGem.new(
28
+ version: RubyGemRelease::Version::RubyConstant.new(path: 'lib/example/version.rb', constant: 'VERSION'),
29
+ changelog: RubyGemRelease::Changelog::KeepAChangelog.new
30
+ ).install_tasks
31
+ ```
32
+
33
+ The standard workflow handles changelog updates, publication, and the next development version, selecting the gemspec and repository using unambiguous defaults. See [Detailed Usage](docs/usage.md) for file examples and custom release callbacks.
34
+
35
+ ### 2. Add the Release Workflow
36
+
37
+ Add `.github/workflows/release.yml` using the [sample workflow](examples/.github/workflows/release.yml), replacing the Ruby version with your project's value:
38
+
39
+ ```yaml
40
+ name: Release
41
+
42
+ on:
43
+ workflow_dispatch:
44
+ inputs:
45
+ rubygems-owner:
46
+ description: RubyGems account ID for first publication; omit for an existing gem.
47
+ type: string
48
+ required: false
49
+
50
+ permissions:
51
+ contents: write
52
+ id-token: write
53
+
54
+ jobs:
55
+ release:
56
+ uses: dblock/ruby-gem-release/.github/workflows/release.yml@master
57
+ with:
58
+ ruby-version: '3.3'
59
+ rubygems-owner: ${{ inputs.rubygems-owner }}
60
+ ```
61
+
62
+ ### 3. Enable Trusted Publishing
63
+
64
+ In your GitHub repository, open **Settings → Environments** and create an environment named `release`. Alternatively, from your repository directory, use `gh`:
65
+
66
+ ```sh
67
+ gh api --method PUT repos/{owner}/{repo}/environments/release
68
+ ```
69
+
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`.
73
+
74
+ ### 4. Make a Release
75
+
76
+ Your branch rules must permit release commits and tags to be pushed; see [branch permissions](RELEASING.md#one-time-setup). Review the changes and dispatch the workflow from your default branch:
77
+
78
+ **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
+
80
+ ```sh
81
+ gh workflow run release.yml -f rubygems-owner=YOUR_ACCOUNT_ID
82
+ ```
83
+
84
+ **Subsequent releases:** Omit `rubygems-owner` once the gem exists. Its trusted publisher is still required, but the account ID is no longer needed:
85
+
86
+ ```sh
87
+ gh workflow run release.yml
88
+ ```
89
+
90
+ ## Configuration
91
+
92
+ See [Detailed Usage](docs/usage.md#configuration) for workflow customization, callbacks, and available classes, and the [Actions Reference](docs/actions/release.md) for inputs and defaults.
93
+
94
+ ## Contributing
95
+
96
+ See [CONTRIBUTING.md](CONTRIBUTING.md), [DESIGN.md](DESIGN.md), [CHANGELOG.md](CHANGELOG.md), and [RELEASING.md](RELEASING.md).
97
+
98
+ ## License
99
+
100
+ Distributed under the [MIT License](LICENSE.txt).
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'commonmarker'
5
+
6
+ module RubyGemRelease
7
+ module Changelog
8
+ class Base
9
+ def initialize(path: 'CHANGELOG.md')
10
+ @path = path
11
+ end
12
+
13
+ def release!(release)
14
+ update_release(version: release.version, date: release.date)
15
+ end
16
+
17
+ def prepare_next!(release)
18
+ update_next(version: release.next_version, released_version: release.version)
19
+ end
20
+
21
+ private
22
+
23
+ def heading(content, pattern)
24
+ matches = heading_positions(content, pattern)
25
+ raise ArgumentError, "expected exactly one matching heading in #{@path}" unless matches.one?
26
+
27
+ matches.first
28
+ end
29
+
30
+ def heading_positions(content, pattern)
31
+ lines = content.lines
32
+ offsets = line_offsets(lines)
33
+ matches = []
34
+ document(content).each do |node|
35
+ next unless node.type == :heading && node.header_level == 2
36
+
37
+ index = node.source_position[:start_line] - 1
38
+ matches << offsets[index] if pattern.match?(lines[index].chomp)
39
+ end
40
+ matches
41
+ end
42
+
43
+ def editable_lines(content)
44
+ excluded = excluded_lines(content)
45
+ offset = 0
46
+ content.each_line.with_index(1) do |line, number|
47
+ yield line, offset unless excluded.include?(number)
48
+ offset += line.length
49
+ end
50
+ end
51
+
52
+ def document(content)
53
+ Commonmarker.parse(content, options: { extension: { shortcodes: false } })
54
+ end
55
+
56
+ def line_offsets(lines)
57
+ offset = 0
58
+ lines.map do |line|
59
+ position = offset
60
+ offset += line.length
61
+ position
62
+ end
63
+ end
64
+
65
+ def excluded_lines(content)
66
+ excluded = Set.new
67
+ document(content).walk do |node|
68
+ next unless %i[code_block html_block block_quote].include?(node.type)
69
+
70
+ position = node.source_position
71
+ excluded.merge(position[:start_line]..position[:end_line])
72
+ end
73
+ excluded
74
+ end
75
+
76
+ def newline(content)
77
+ raise ArgumentError, "mixed or unsupported line endings in #{@path}" if (content.match?(/(?<!\r)\n/) && content.include?("\r\n")) || content.match?(/\r(?!\n)/)
78
+
79
+ content.include?("\r\n") ? "\r\n" : "\n"
80
+ end
81
+
82
+ def validate_version(version)
83
+ raise ArgumentError, "invalid version: #{version.inspect}" unless version.is_a?(String) && /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\z/.match?(version)
84
+ end
85
+
86
+ def validate_release(version, date)
87
+ validate_version(version)
88
+ raise ArgumentError, 'date must be a Date' unless date.instance_of?(Date)
89
+ end
90
+
91
+ def check_absent(content, pattern, message)
92
+ raise ArgumentError, "#{message} in #{@path}" unless heading_positions(content, pattern).empty?
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyGemRelease
4
+ module Changelog
5
+ class Intridea < Base
6
+ UNRELEASED = /\A## \d+\.\d+\.\d+ \(Next\)\z/
7
+ PLACEHOLDER = '- Your contribution here.'
8
+
9
+ def initialize(path: 'CHANGELOG.md', placeholder: PLACEHOLDER)
10
+ raise ArgumentError, 'placeholder must be a single nonempty line' unless placeholder.is_a?(String) && !placeholder.empty? && !placeholder.match?(/[\r\n]/)
11
+
12
+ super(path: path)
13
+ @placeholder = placeholder
14
+ end
15
+
16
+ private
17
+
18
+ def update_release(version:, date:)
19
+ validate_release(version, date)
20
+ content = File.read(@path)
21
+ separator = newline(content)
22
+ position = heading(content, UNRELEASED)
23
+ check_release_absent(content, version)
24
+ line_end = line_end(content, position, separator)
25
+ section_end = heading_positions(content, /\A## /).find { |offset| offset > position } || content.length
26
+ content[position...section_end] = "## #{version} (#{date.strftime('%Y/%m/%d')})" +
27
+ remove_placeholder(content[line_end...section_end])
28
+ File.write(@path, content)
29
+ end
30
+
31
+ def update_next(version:, released_version:)
32
+ validate_version(version)
33
+ validate_version(released_version)
34
+ content = File.read(@path)
35
+ separator = newline(content)
36
+ pattern = %r{\A## #{Regexp.escape(released_version)} \(\d{4}/\d{2}/\d{2}\)\z}
37
+ position = heading(content, pattern)
38
+ check_absent(content, UNRELEASED, 'unreleased heading already exists')
39
+
40
+ File.write(@path, content.insert(position, ["## #{version} (Next)", '', @placeholder, '', ''].join(separator)))
41
+ end
42
+
43
+ def check_release_absent(content, version)
44
+ check_absent(content, %r{\A## #{Regexp.escape(version)} \(\d{4}/\d{2}/\d{2}\)\z}, 'release heading already exists')
45
+ end
46
+
47
+ def line_end(content, position, separator)
48
+ content.index(separator, position) || raise(ArgumentError, "heading must end with a newline in #{@path}")
49
+ end
50
+
51
+ def remove_placeholder(section)
52
+ positions = []
53
+ editable_lines(section) { |line, offset| positions << offset if line.chomp == @placeholder }
54
+ raise ArgumentError, "expected exactly one placeholder in #{@path}" unless positions.one?
55
+
56
+ position = positions.first
57
+ line_end = section.index("\n", position)
58
+ section.slice!(position, (line_end ? line_end + 1 : section.length) - position)
59
+ section
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyGemRelease
4
+ module Changelog
5
+ class KeepAChangelog < Base
6
+ UNRELEASED = /\A## \[Unreleased\]\z/
7
+
8
+ private
9
+
10
+ def update_release(version:, date:)
11
+ validate_release(version, date)
12
+ content = File.read(@path)
13
+ separator = newline(content)
14
+ position = heading(content, UNRELEASED)
15
+ check_absent(content, /\A## \[#{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] = "## [#{version}] - #{date.strftime('%Y-%m-%d')}"
20
+ release_references(content, version, separator)
21
+ File.write(@path, content)
22
+ end
23
+
24
+ def update_next(version:, released_version:)
25
+ validate_version(version)
26
+ validate_version(released_version)
27
+ content = File.read(@path)
28
+ separator = newline(content)
29
+ pattern = /\A## \[#{Regexp.escape(released_version)}\] - \d{4}-\d{2}-\d{2}\z/
30
+ position = heading(content, pattern)
31
+ check_absent(content, UNRELEASED, 'unreleased heading already exists')
32
+ check_next_reference(content, released_version)
33
+
34
+ File.write(@path, content.insert(position, ['## [Unreleased]', '', ''].join(separator)))
35
+ end
36
+
37
+ def references(content, label)
38
+ return [] unless reference_defined?(content, label)
39
+
40
+ pattern = /\A[ \t]{0,3}\[#{Regexp.escape(label)}\]:[ \t]*(.*)\z/i
41
+ matches = []
42
+ editable_lines(content) do |line, offset|
43
+ match = pattern.match(line.chomp)
44
+ matches << [offset, line.chomp, match[1]] if match
45
+ end
46
+ matches
47
+ end
48
+
49
+ def reference_defined?(content, label)
50
+ node = document("#{content}\n\n[#{label}]\n").last_child.first_child
51
+ node&.type == :link
52
+ end
53
+
54
+ def unreleased_reference(content)
55
+ matches = references(content, 'Unreleased')
56
+ raise ArgumentError, 'duplicate Unreleased reference links' if matches.size > 1
57
+
58
+ matches.first
59
+ end
60
+
61
+ def comparison(url)
62
+ match = %r{\A(https?://[^\s<>]+/compare/)([^/\s]*?)(\d+\.\d+\.\d+)\.\.\.([^\s<>]+)\z}.match(url)
63
+ raise ArgumentError, 'Unreleased reference must be a comparison URL ending in VERSION...BRANCH' unless match
64
+
65
+ match
66
+ end
67
+
68
+ def release_references(content, version, separator)
69
+ reference = unreleased_reference(content)
70
+ return unless reference
71
+
72
+ raise ArgumentError, 'release reference already exists' unless references(content, version).empty?
73
+
74
+ offset, line, url = reference
75
+ match = comparison(url)
76
+ updated = "[Unreleased]: #{match[1]}#{match[2]}#{version}...#{match[4]}"
77
+ released = "[#{version}]: #{match[1]}#{match[2]}#{match[3]}...#{match[2]}#{version}"
78
+ content[offset, line.length] = "#{updated}#{separator}#{released}"
79
+ end
80
+
81
+ def check_next_reference(content, released_version)
82
+ reference = unreleased_reference(content)
83
+ return unless reference
84
+
85
+ match = comparison(reference[2])
86
+ raise ArgumentError, 'Unreleased comparison must start at the released version' unless match[3] == released_version
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'changelog/base'
4
+ require_relative 'changelog/intridea'
5
+ require_relative 'changelog/keep_a_changelog'
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module RubyGemRelease
6
+ module Hosting
7
+ class GitHubRelease
8
+ def initialize(repository: nil)
9
+ repository ||= ENV.fetch('GITHUB_REPOSITORY', nil)
10
+ raise ArgumentError, 'repository must be OWNER/REPO' unless repository.nil? || (repository.is_a?(String) && %r{\A[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\z}.match?(repository))
11
+
12
+ @repository = repository
13
+ end
14
+
15
+ def publish!(release, package: nil, artifacts: [])
16
+ version = release.version
17
+ raise ArgumentError, 'release version must be MAJOR.MINOR.PATCH' unless version.is_a?(String) && /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\z/.match?(version)
18
+
19
+ tag = "v#{version}"
20
+ paths = artifact_paths(package ? [package.artifact(release)] : artifacts)
21
+ return if release.dry_run?
22
+
23
+ output, error, status = Open3.capture3(*command(tag), *paths)
24
+ raise "GitHub Release creation failed: #{error.strip}" unless status.success?
25
+
26
+ output.strip
27
+ end
28
+
29
+ private
30
+
31
+ def command(tag)
32
+ arguments = ['gh', 'release', 'create', tag]
33
+ arguments.push('--repo', @repository) if @repository
34
+ arguments.push('--verify-tag', '--title', tag, '--generate-notes')
35
+ end
36
+
37
+ def artifact_paths(artifacts)
38
+ raise ArgumentError, 'artifacts must be an array of file paths' unless artifacts.is_a?(Array)
39
+
40
+ artifacts.map do |path|
41
+ raise ArgumentError, 'artifact must name an existing file without # or line breaks' unless path.is_a?(String) && !path.match?(/[#\r\n]/) && File.file?(path)
42
+
43
+ File.expand_path(path)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubygems/package'
4
+ require 'open3'
5
+ require 'json'
6
+ require 'digest'
7
+ require 'fileutils'
8
+ require 'tmpdir'
9
+
10
+ module RubyGemRelease
11
+ module Package
12
+ class RubyGem
13
+ attr_reader :directory, :gemspec
14
+
15
+ def initialize(gemspec: nil, directory: nil)
16
+ @gemspec = resolve_gemspec(gemspec || ENV.fetch('GEMSPEC', nil))
17
+ @directory = directory || ENV['RUNNER_TEMP'] || Dir.mktmpdir('ruby-gem-release-')
18
+ end
19
+
20
+ 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
+ end
24
+
25
+ def build!(release)
26
+ FileUtils.mkdir_p(@directory)
27
+ run(Gem.ruby, '-S', 'gem', 'build', @gemspec, '--output', archive)
28
+ @manifest = build_manifest(release)
29
+ File.write(manifest_path, JSON.generate(@manifest))
30
+ artifact(release)
31
+ end
32
+
33
+ def artifact(release)
34
+ name = verified_manifest(release).fetch('name')
35
+ raise 'Invalid package name' unless /\A[A-Za-z0-9][A-Za-z0-9_.-]*\z/.match?(name)
36
+
37
+ path = File.join(@directory, "#{name}-#{release.version}.gem")
38
+ FileUtils.cp(archive, path)
39
+ path
40
+ end
41
+
42
+ def publish!(release)
43
+ path = artifact(release)
44
+ return path if release.dry_run?
45
+
46
+ run(Gem.ruby, '-S', 'gem', 'push', path)
47
+ end
48
+
49
+ private
50
+
51
+ def build_manifest(release)
52
+ spec = Gem::Package.new(archive).spec
53
+ raise "Built version #{spec.version} does not match #{release.version}" unless spec.version.to_s == release.version
54
+
55
+ { 'name' => spec.name, 'version' => spec.version.to_s, 'sha256' => Digest::SHA256.file(archive).hexdigest }
56
+ end
57
+
58
+ def verified_manifest(release)
59
+ manifest = @manifest || JSON.parse(File.read(manifest_path))
60
+ raise 'Package version does not match release' unless manifest.fetch('version') == release.version
61
+ raise 'Built artifact changed; refusing publication' unless Digest::SHA256.file(archive).hexdigest == manifest.fetch('sha256')
62
+
63
+ manifest
64
+ end
65
+
66
+ def archive
67
+ File.join(@directory, 'release.gem')
68
+ end
69
+
70
+ def manifest_path
71
+ File.join(@directory, 'release.json')
72
+ end
73
+
74
+ def resolve_gemspec(path)
75
+ paths = path.nil? || path.empty? ? Dir['*.gemspec'] : [path]
76
+ raise ArgumentError, 'Expected exactly one root gemspec; specify gemspec' unless paths.one?
77
+ raise ArgumentError, 'gemspec must name an existing file without line breaks' unless File.file?(paths.first) && !paths.first.match?(/[\r\n]/)
78
+
79
+ File.expand_path(paths.first)
80
+ end
81
+
82
+ def run(*command)
83
+ output, error, status = Open3.capture3(*command)
84
+ raise "Package command failed: #{error.strip}\n#{output.strip}" unless status.success?
85
+
86
+ output
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module RubyGemRelease
6
+ class Release
7
+ attr_reader :version, :previous_version, :next_version, :date
8
+
9
+ def initialize(date:, version:, previous_version:, next_version: nil, dry_run: false)
10
+ raise ArgumentError, 'dry_run must be true or false' unless [true, false].include?(dry_run)
11
+
12
+ validate_versions(version, previous_version, next_version)
13
+ raise ArgumentError, 'date must be a Date' unless date.instance_of?(Date)
14
+
15
+ @version = version.dup.freeze
16
+ @previous_version = previous_version.dup.freeze
17
+ @next_version = next_version&.dup&.freeze
18
+ @date = date.dup.freeze
19
+ @dry_run = dry_run
20
+ freeze
21
+ end
22
+
23
+ def with_version(version)
24
+ self.class.new(version: version, previous_version: @previous_version, date: @date, next_version: @next_version, dry_run: @dry_run)
25
+ end
26
+
27
+ def with_next_version(version)
28
+ self.class.new(version: @version, previous_version: @previous_version, date: @date, next_version: version, dry_run: @dry_run)
29
+ end
30
+
31
+ def dry_run?
32
+ @dry_run
33
+ end
34
+
35
+ private
36
+
37
+ def validate_versions(version, previous_version, next_version)
38
+ raise ArgumentError, 'release versions must be strings' unless [version, previous_version].all?(String) && (next_version.nil? || next_version.is_a?(String))
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'tmpdir'
5
+ require 'fileutils'
6
+
7
+ module RubyGemRelease
8
+ module SourceControl
9
+ class Git
10
+ def initialize(branch: nil, remote: 'origin', directory: nil)
11
+ @remote = remote
12
+ @branch = branch || ENV.fetch('DEFAULT_BRANCH', nil)
13
+ @directory = directory || ENV['RUNNER_TEMP'] || Dir.mktmpdir('ruby-gem-release-git-')
14
+ end
15
+
16
+ def configure!(name: 'github-actions[bot]', email: 'github-actions[bot]@users.noreply.github.com')
17
+ [name, email].each do |value|
18
+ raise ArgumentError, 'Git identity must be nonempty and contain no line breaks' unless value.is_a?(String) && !value.strip.empty? && !value.match?(/[\r\n]/)
19
+ end
20
+ run('config', '--local', 'user.name', name)
21
+ run('config', '--local', 'user.email', email)
22
+ end
23
+
24
+ def head
25
+ run('rev-parse', 'HEAD').strip
26
+ end
27
+
28
+ def check_tag!(version)
29
+ output, _error, status = Open3.capture3('git', 'show-ref', '--verify', '--quiet', "refs/tags/v#{version}")
30
+ raise "Release tag already exists: v#{version}" if status.success?
31
+ raise "Cannot check release tag: #{output}" unless status.exitstatus == 1
32
+ end
33
+
34
+ def check_current!(release, expected: head)
35
+ run('fetch', @remote, branch, '--tags')
36
+ return if release.dry_run?
37
+
38
+ raise 'Default branch advanced; start a new release' unless remote_head == expected
39
+ end
40
+
41
+ def commit!(release)
42
+ @release = release
43
+ @initial_commit = ENV['RELEASE_INITIAL_COMMIT'] || head
44
+ commit_changes("Prepare release #{release.version}", required: false)
45
+ end
46
+
47
+ def commit_next!(release)
48
+ @release = release
49
+ @next_commit_parent = head
50
+ commit_changes("Prepare next development version after #{release.version}", required: true)
51
+ end
52
+
53
+ def tag!(release)
54
+ @release = release
55
+ ensure_clean!
56
+ check_current!(release, expected: @initial_commit || ENV.fetch('RELEASE_INITIAL_COMMIT'))
57
+ check_tag!(release.version)
58
+ run('tag', "v#{release.version}")
59
+ FileUtils.mkdir_p(@directory)
60
+ run('bundle', 'create', File.join(@directory, 'release.bundle'), 'HEAD', "refs/tags/v#{release.version}")
61
+ end
62
+
63
+ def push!(release = @release)
64
+ raise ArgumentError, 'a release context is required before pushing' unless release
65
+ return if release.dry_run?
66
+
67
+ if @next_commit_parent
68
+ check_current!(release, expected: @next_commit_parent)
69
+ run('push', @remote, "HEAD:refs/heads/#{branch}")
70
+ else
71
+ check_current!(release, expected: @initial_commit || ENV.fetch('RELEASE_INITIAL_COMMIT'))
72
+ run('push', '--atomic', @remote, "HEAD:refs/heads/#{branch}", "refs/tags/v#{release.version}")
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def branch
79
+ @branch ||= run('symbolic-ref', "refs/remotes/#{@remote}/HEAD").strip.delete_prefix("refs/remotes/#{@remote}/")
80
+ end
81
+
82
+ def remote_head
83
+ run('rev-parse', "refs/remotes/#{@remote}/#{branch}").strip
84
+ end
85
+
86
+ def ensure_clean!
87
+ raise 'Gem build changed the working tree after the release commit' unless run('status', '--porcelain', '--untracked-files=all').empty?
88
+ end
89
+
90
+ def commit_changes(message, required:)
91
+ run('add', '-A')
92
+ output = run('diff', '--cached', '--name-only')
93
+ raise 'Next-version preparation did not change any files' if required && output.empty?
94
+ return head if output.empty?
95
+
96
+ run('commit', '-m', message)
97
+ head
98
+ end
99
+
100
+ def run(*)
101
+ output, error, status = Open3.capture3('git', *)
102
+ raise "Git command failed: #{error.strip}\n#{output.strip}" unless status.success?
103
+
104
+ output
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake'
4
+ require_relative 'release'
5
+ require_relative 'version/ruby_constant'
6
+ require_relative 'workflow/context'
7
+ require_relative 'workflow/ruby_gems_credentials'
8
+
9
+ module RubyGemRelease
10
+ class Tasks
11
+ include Rake::DSL
12
+
13
+ def self.install
14
+ instance = new
15
+ yield instance if block_given?
16
+ instance.install
17
+ end
18
+
19
+ def install
20
+ namespace :release do
21
+ install_callback_tasks
22
+ install_publish_task
23
+ install_workflow_tasks
24
+ end
25
+ end
26
+
27
+ def before_release(&block)
28
+ raise ArgumentError, 'before_release requires a block' unless block
29
+ raise ArgumentError, 'before_release already configured' if @before_release
30
+
31
+ @before_release = block
32
+ end
33
+
34
+ def after_release(&block)
35
+ raise ArgumentError, 'after_release requires a block' unless block
36
+ raise ArgumentError, 'after_release already configured' if @after_release
37
+
38
+ @after_release = block
39
+ end
40
+
41
+ def publish(&block)
42
+ raise ArgumentError, 'publish requires a block' unless block
43
+ raise ArgumentError, 'publish already configured' if @publish
44
+
45
+ @publish = block
46
+ end
47
+
48
+ private
49
+
50
+ def install_workflow_tasks
51
+ namespace :workflow do
52
+ task(:capture) { Workflow::Context.new.capture! }
53
+ task(:resolve) { Workflow::Context.new.resolve! }
54
+ task(:credentials) { Workflow::RubyGemsCredentials.new(owner: ENV.fetch('RUBYGEMS_OWNER')).configure! }
55
+ end
56
+ end
57
+
58
+ def install_publish_task
59
+ desc 'Run the release publication callback'
60
+ task :publish do
61
+ @publish&.call(context)
62
+ end
63
+ end
64
+
65
+ def install_callback_tasks
66
+ desc 'Run the before-release callback'
67
+ task(:before_release) do
68
+ release = context(before_release: true)
69
+ @before_release&.call(release)
70
+ end
71
+ desc 'Run the next-version preparation callback'
72
+ task(:after_release) do
73
+ release = context
74
+ @after_release&.call(release)
75
+ end
76
+ end
77
+
78
+ def context(before_release: false)
79
+ previous_version = ENV.fetch('RELEASE_PREVIOUS_VERSION')
80
+ version = before_release ? previous_version : ENV.fetch('RELEASE_VERSION')
81
+ date = ENV.fetch('RELEASE_DATE')
82
+ validate_context_versions(version, previous_version)
83
+ raise ArgumentError, 'release date is required in YYYY-MM-DD format' unless date.match?(/\A\d{4}-\d{2}-\d{2}\z/)
84
+
85
+ Release.new(version: version, previous_version: previous_version, date: Date.iso8601(date), dry_run: ENV.fetch('DRY_RUN', 'false') == 'true')
86
+ end
87
+
88
+ def validate_context_versions(*versions)
89
+ pattern = Version::RubyConstant::PATTERN
90
+ versions.compact.each do |value|
91
+ raise ArgumentError, "invalid version: #{value.inspect}; expected MAJOR.MINOR.PATCH" unless value.is_a?(String) && pattern.match?(value)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyGemRelease
4
+ module Version
5
+ class RubyConstant
6
+ PATTERN = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\z/
7
+
8
+ def initialize(path:, constant:)
9
+ raise ArgumentError, 'constant must be a Ruby constant name' unless constant.is_a?(String) && /\A[A-Z]\w*\z/.match?(constant)
10
+
11
+ @path = path
12
+ @assignment = /^([ \t]*#{Regexp.escape(constant)}[ \t]*=[ \t]*)(['"])([^'"\r\n]+)\2/
13
+ end
14
+
15
+ def read
16
+ value = assignment(File.read(@path))[3]
17
+ parse(value)
18
+ value
19
+ end
20
+
21
+ def set(value)
22
+ parse(value)
23
+ content = File.read(@path)
24
+ match = assignment(content)
25
+ content[match.begin(3)...match.end(3)] = value
26
+ File.write(@path, content)
27
+ value
28
+ end
29
+
30
+ def increment!(part: :patch)
31
+ parts = parse(read)
32
+ index = { 'major' => 0, 'minor' => 1, 'patch' => 2 }.fetch(part.to_s) do
33
+ raise ArgumentError, 'bump part must be major, minor, or patch'
34
+ end
35
+ parts[index] += 1
36
+ ((index + 1)...parts.size).each { |i| parts[i] = 0 }
37
+ set(parts.join('.'))
38
+ end
39
+
40
+ private
41
+
42
+ def parse(value)
43
+ match = PATTERN.match(value) if value.is_a?(String)
44
+ raise ArgumentError, "invalid version: #{value.inspect}; expected MAJOR.MINOR.PATCH" unless match
45
+
46
+ match.captures.map(&:to_i)
47
+ end
48
+
49
+ def assignment(content)
50
+ matches = content.to_enum(:scan, @assignment).map { Regexp.last_match }
51
+ raise ArgumentError, "expected exactly one version assignment in #{@path}" unless matches.one?
52
+
53
+ matches.first
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyGemRelease
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../package/ruby_gem'
4
+ require_relative '../source_control/git'
5
+ require_relative '../release'
6
+ require_relative '../version/ruby_constant'
7
+
8
+ module RubyGemRelease
9
+ module Workflow
10
+ class Context
11
+ def capture!
12
+ dry_run = ENV.fetch('DRY_RUN', 'false') == 'true'
13
+ check_branch(dry_run)
14
+ package = Package::RubyGem.new(gemspec: ENV.fetch('INPUT_GEMSPEC', nil))
15
+ release = initial_release(package, dry_run: dry_run)
16
+ git = configured_git
17
+ git.check_current!(release)
18
+ export('GEMSPEC' => package.gemspec, 'RELEASE_PREVIOUS_VERSION' => release.version,
19
+ 'RELEASE_DATE' => release.date.iso8601, 'RELEASE_INITIAL_COMMIT' => git.head)
20
+ end
21
+
22
+ def resolve!
23
+ version = Package::RubyGem.new.version
24
+ validate_version(version)
25
+ expected = ENV.fetch('INPUT_VERSION', '')
26
+ raise "Prepared version #{version} does not match expected version #{expected}" unless expected.empty? || version == expected
27
+
28
+ SourceControl::Git.new.check_tag!(version)
29
+ export('RELEASE_VERSION' => version)
30
+ end
31
+
32
+ private
33
+
34
+ def initial_release(package, dry_run:)
35
+ version = package.version
36
+ validate_version(version)
37
+ validate_version(ENV.fetch('INPUT_VERSION')) unless ENV.fetch('INPUT_VERSION', '').empty?
38
+ Release.new(version: version, previous_version: version, date: Time.now.utc.to_date, dry_run: dry_run)
39
+ end
40
+
41
+ def configured_git
42
+ SourceControl::Git.new.tap do |git|
43
+ git.configure!(name: ENV.fetch('GIT_USER_NAME', 'github-actions[bot]'),
44
+ email: ENV.fetch('GIT_USER_EMAIL', 'github-actions[bot]@users.noreply.github.com'))
45
+ end
46
+ end
47
+
48
+ def check_branch(dry_run)
49
+ return if dry_run
50
+ return if ENV.fetch('GITHUB_REF') == "refs/heads/#{ENV.fetch('DEFAULT_BRANCH')}"
51
+
52
+ raise 'Releases must run from the default branch'
53
+ end
54
+
55
+ def validate_version(version)
56
+ raise ArgumentError, 'Version must be MAJOR.MINOR.PATCH without leading zeros' unless Version::RubyConstant::PATTERN.match?(version)
57
+ end
58
+
59
+ def export(values)
60
+ File.open(ENV.fetch('GITHUB_ENV'), 'a') do |file|
61
+ values.each do |key, value|
62
+ raise ArgumentError, 'Workflow values cannot contain line breaks' if value.match?(/[\r\n]/)
63
+
64
+ file.puts "#{key}=#{value}"
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../tasks'
4
+ require_relative '../package/ruby_gem'
5
+ require_relative '../source_control/git'
6
+ require_relative '../hosting/github_release'
7
+
8
+ module RubyGemRelease
9
+ module Workflow
10
+ class RubyGem
11
+ def initialize(version:, changelog:, package: Package::RubyGem.new, git: SourceControl::Git.new, hosting: Hosting::GitHubRelease.new)
12
+ @version = version
13
+ @changelog = changelog
14
+ @package = package
15
+ @git = git
16
+ @hosting = hosting
17
+ end
18
+
19
+ def install_tasks
20
+ Tasks.install do |tasks|
21
+ tasks.before_release { |release| prepare_release(release) }
22
+ tasks.publish { |release| publish(release) }
23
+ tasks.after_release { |release| prepare_next(release) }
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def prepare_release(release)
30
+ @changelog&.release!(release.with_version(@version.read))
31
+ end
32
+
33
+ def publish(release)
34
+ @git.commit!(release)
35
+ @package.build!(release)
36
+ @git.tag!(release)
37
+ @package.publish!(release)
38
+ @git.push!(release)
39
+ @hosting&.publish!(release, package: @package)
40
+ end
41
+
42
+ def prepare_next(release)
43
+ next_release = release.with_next_version(@version.increment!)
44
+ @changelog&.prepare_next!(next_release)
45
+ @git.commit_next!(release)
46
+ @git.push!
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module RubyGemRelease
8
+ module Workflow
9
+ class RubyGemsCredentials
10
+ def initialize(owner:)
11
+ raise ArgumentError, 'RubyGems owner must be a positive numeric account ID' unless /\A[1-9]\d*\z/.match?(owner.to_s)
12
+
13
+ @owner = owner.to_s
14
+ end
15
+
16
+ def configure!
17
+ token = oidc_token
18
+ mask(token)
19
+ key = exchange_token(token)
20
+ mask(key)
21
+ File.open(ENV.fetch('GITHUB_ENV'), 'a') do |file|
22
+ %w[RUBYGEMS_API_KEY BUNDLE_GEM__PUSH_KEY GEM_HOST_API_KEY].each { |name| file.puts "#{name}=#{key}" }
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def oidc_token
29
+ uri = URI(ENV.fetch('ACTIONS_ID_TOKEN_REQUEST_URL'))
30
+ raise ArgumentError, 'OIDC request URL must use HTTPS' unless uri.is_a?(URI::HTTPS)
31
+
32
+ query = URI.decode_www_form(uri.query || '').reject { |name, _value| name == 'audience' } # rubocop:disable Style/HashExcept -- Query pairs may contain duplicate keys.
33
+ uri.query = URI.encode_www_form(query + [['audience', 'rubygems.org']])
34
+ request = Net::HTTP::Get.new(uri)
35
+ request['Authorization'] = "Bearer #{ENV.fetch('ACTIONS_ID_TOKEN_REQUEST_TOKEN')}"
36
+ credential(response(uri, request), 'value')
37
+ end
38
+
39
+ def exchange_token(token)
40
+ uri = URI('https://rubygems.org/api/v1/oidc/trusted_publisher/exchange_token')
41
+ request = Net::HTTP::Post.new(uri)
42
+ request['Content-Type'] = 'application/json'
43
+ request['Accept'] = 'application/json'
44
+ request.body = JSON.generate(jwt: token, owner: @owner)
45
+ credential(response(uri, request), 'rubygems_api_key')
46
+ end
47
+
48
+ def response(uri, request)
49
+ result = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30, max_retries: 0) do |http|
50
+ http.request(request)
51
+ end
52
+ raise "Credential request failed (HTTP #{result.code}); verify owner ID and pending trusted publisher" unless result.is_a?(Net::HTTPSuccess)
53
+
54
+ JSON.parse(result.body)
55
+ rescue JSON::ParserError
56
+ raise 'Credential response was not valid JSON'
57
+ end
58
+
59
+ def credential(body, name)
60
+ value = body[name] if body.is_a?(Hash)
61
+ raise 'Credential response did not contain a valid token' unless value.is_a?(String) && !value.empty? && !value.match?(/[\r\n]/)
62
+
63
+ value
64
+ end
65
+
66
+ def mask(value)
67
+ puts "::add-mask::#{value.gsub('%', '%25')}"
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ruby-gem-release/version'
4
+ require_relative 'ruby-gem-release/tasks'
5
+
6
+ module RubyGemRelease
7
+ end
metadata ADDED
@@ -0,0 +1,90 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby-gem-release
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Daniel Doubrovkine
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: commonmarker
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.10'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.10'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '13'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '13'
40
+ description: Reusable GitHub Actions workflow and Rake tasks for Ruby gem releases.
41
+ email:
42
+ - dblock@users.noreply.github.com
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - CHANGELOG.md
48
+ - LICENSE.txt
49
+ - README.md
50
+ - lib/ruby-gem-release.rb
51
+ - lib/ruby-gem-release/changelog.rb
52
+ - lib/ruby-gem-release/changelog/base.rb
53
+ - lib/ruby-gem-release/changelog/intridea.rb
54
+ - lib/ruby-gem-release/changelog/keep_a_changelog.rb
55
+ - lib/ruby-gem-release/hosting/github_release.rb
56
+ - lib/ruby-gem-release/package/ruby_gem.rb
57
+ - lib/ruby-gem-release/release.rb
58
+ - lib/ruby-gem-release/source_control/git.rb
59
+ - lib/ruby-gem-release/tasks.rb
60
+ - lib/ruby-gem-release/version.rb
61
+ - lib/ruby-gem-release/version/ruby_constant.rb
62
+ - lib/ruby-gem-release/workflow/context.rb
63
+ - lib/ruby-gem-release/workflow/ruby_gem.rb
64
+ - lib/ruby-gem-release/workflow/ruby_gems_credentials.rb
65
+ homepage: https://github.com/dblock/ruby-gem-release
66
+ licenses:
67
+ - MIT
68
+ metadata:
69
+ bug_tracker_uri: https://github.com/dblock/ruby-gem-release/issues
70
+ changelog_uri: https://github.com/dblock/ruby-gem-release/blob/master/CHANGELOG.md
71
+ rubygems_mfa_required: 'true'
72
+ source_code_uri: https://github.com/dblock/ruby-gem-release
73
+ rdoc_options: []
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '3.3'
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ requirements: []
87
+ rubygems_version: 4.0.20
88
+ specification_version: 4
89
+ summary: Reusable release workflow and Rake tasks for Ruby gems.
90
+ test_files: []