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 +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE.txt +21 -0
- data/README.md +100 -0
- data/lib/ruby-gem-release/changelog/base.rb +96 -0
- data/lib/ruby-gem-release/changelog/intridea.rb +63 -0
- data/lib/ruby-gem-release/changelog/keep_a_changelog.rb +90 -0
- data/lib/ruby-gem-release/changelog.rb +5 -0
- data/lib/ruby-gem-release/hosting/github_release.rb +48 -0
- data/lib/ruby-gem-release/package/ruby_gem.rb +90 -0
- data/lib/ruby-gem-release/release.rb +41 -0
- data/lib/ruby-gem-release/source_control/git.rb +108 -0
- data/lib/ruby-gem-release/tasks.rb +95 -0
- data/lib/ruby-gem-release/version/ruby_constant.rb +57 -0
- data/lib/ruby-gem-release/version.rb +5 -0
- data/lib/ruby-gem-release/workflow/context.rb +70 -0
- data/lib/ruby-gem-release/workflow/ruby_gem.rb +50 -0
- data/lib/ruby-gem-release/workflow/ruby_gems_credentials.rb +71 -0
- data/lib/ruby-gem-release.rb +7 -0
- metadata +90 -0
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
|
+
[](https://github.com/dblock/ruby-gem-release/actions/workflows/test.yml) [](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,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,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
|
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: []
|