release_ceremony 1.0.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 +28 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/exe/release_ceremony +11 -0
- data/lib/release_ceremony/changelog.rb +91 -0
- data/lib/release_ceremony/cli.rb +31 -0
- data/lib/release_ceremony/command.rb +28 -0
- data/lib/release_ceremony/error.rb +5 -0
- data/lib/release_ceremony/prepare.rb +118 -0
- data/lib/release_ceremony/project.rb +173 -0
- data/lib/release_ceremony/publish.rb +174 -0
- data/lib/release_ceremony/pull_request_finder.rb +80 -0
- data/lib/release_ceremony/runner.rb +76 -0
- data/lib/release_ceremony/semver.rb +50 -0
- data/lib/release_ceremony/tag_inspector.rb +76 -0
- data/lib/release_ceremony/transactional_writer.rb +100 -0
- data/lib/release_ceremony/version.rb +5 -0
- data/lib/release_ceremony/workflow_finder.rb +86 -0
- data/lib/release_ceremony.rb +16 -0
- metadata +67 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e73ee4ce1ed15cf0e500cf1599d8cd2c288ca37cffd4ef8c5f2267dcfb9f9653
|
|
4
|
+
data.tar.gz: 87d220e6796d3acd7455651f4340215e7b0f5e4ad6927f8343dd373b8ca84b74
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 797b5474be23312a9d7f430338a57744f525e5c2226a7122a98dedc5c48fff62af7cc4a9132e7ff3edf67ef1c4777066300181b06e46312105990a18ad0cc708
|
|
7
|
+
data.tar.gz: d9dba2117e6ba35a3ec8060c036e58f22eee3ffed0d56492b7708b8ff50e34998566c93db23a65bf6d0cf0da146eae94fdce190bf1f35af46475fb189b459b2d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to
|
|
7
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [1.0.0] - 2026-07-25
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `release_ceremony prepare VERSION`: update the version file and changelog on
|
|
16
|
+
the `release-vVERSION` branch and run the verification commands, without
|
|
17
|
+
committing or pushing. Extracted from `url_canonicalize`'s
|
|
18
|
+
`bin/release/prepare`.
|
|
19
|
+
- `release_ceremony publish VERSION`: validate the merged release pull
|
|
20
|
+
request, tag its merge commit, watch the tag-triggered release workflow, and
|
|
21
|
+
confirm the gem appears on RubyGems. Extracted from `url_canonicalize`'s
|
|
22
|
+
`bin/release/publish`.
|
|
23
|
+
- Project auto-detection from the repository's gemspec, with optional
|
|
24
|
+
overrides in `.release_ceremony.yml` (release file paths, verification
|
|
25
|
+
commands, workflow file, default branch, and remote).
|
|
26
|
+
|
|
27
|
+
[Unreleased]: https://github.com/dominicsayers/release_ceremony/compare/v1.0.0...HEAD
|
|
28
|
+
[1.0.0]: https://github.com/dominicsayers/release_ceremony/compare/v0.0.0...v1.0.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dominic Sayers
|
|
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,120 @@
|
|
|
1
|
+
# release_ceremony
|
|
2
|
+
|
|
3
|
+
A strict, verified release ceremony for RubyGems projects hosted on GitHub.
|
|
4
|
+
|
|
5
|
+
`release_ceremony` provides two commands:
|
|
6
|
+
|
|
7
|
+
- **`release_ceremony prepare VERSION`** — on the `release-vVERSION` branch,
|
|
8
|
+
updates the version file, moves the changelog's Unreleased notes under a
|
|
9
|
+
dated release heading, rewrites the comparison links, and runs the
|
|
10
|
+
verification commands. It never commits or pushes.
|
|
11
|
+
- **`release_ceremony publish VERSION`** — after the release pull request has
|
|
12
|
+
merged, validates the merge commit, tags it `vVERSION`, pushes the tag,
|
|
13
|
+
watches the tag-triggered GitHub Actions release workflow, and confirms the
|
|
14
|
+
new version appears on RubyGems.
|
|
15
|
+
|
|
16
|
+
Both commands validate aggressively and fail loudly: every transformation is
|
|
17
|
+
exact-match, file writes are transactional, and nothing is tagged or pushed
|
|
18
|
+
until you type the confirmation phrase.
|
|
19
|
+
|
|
20
|
+
## The ceremony
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
git switch -c release-v1.2.3
|
|
24
|
+
release_ceremony prepare 1.2.3
|
|
25
|
+
# inspect the changes, then commit and push the branch
|
|
26
|
+
# open the pull request and merge it
|
|
27
|
+
release_ceremony publish 1.2.3
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Publication itself happens in CI: the pushed tag triggers your release
|
|
31
|
+
workflow, which should publish to RubyGems (for example with
|
|
32
|
+
[Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) and the
|
|
33
|
+
`rubygems/release-gem` action). `publish` only tags, watches, and verifies.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
Add the gem to your Gemfile:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
group :build do
|
|
41
|
+
gem 'release_ceremony', require: false
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## What it assumes
|
|
46
|
+
|
|
47
|
+
- The gem's version lives in `lib/<gem_name>/version.rb` as exactly one
|
|
48
|
+
single-quoted `VERSION = 'X.Y.Z'` assignment.
|
|
49
|
+
- `CHANGELOG.md` follows [Keep a Changelog](https://keepachangelog.com/):
|
|
50
|
+
exactly one `## [Unreleased]` heading with notes, dated release headings,
|
|
51
|
+
and reference-style comparison links at the bottom.
|
|
52
|
+
- Versions are strict `X.Y.Z` semantic versions (no prereleases), tagged
|
|
53
|
+
`vX.Y.Z`.
|
|
54
|
+
- Release work happens on a branch named `release-vX.Y.Z`, merged to `main`
|
|
55
|
+
on the `origin` remote via exactly one pull request.
|
|
56
|
+
- The repository is on GitHub, with the `gh` CLI installed and authenticated,
|
|
57
|
+
and has a `release.yml` workflow triggered by pushing the tag.
|
|
58
|
+
|
|
59
|
+
The gem name and repository URL are read from the repository's gemspec
|
|
60
|
+
(`metadata['source_code_uri']`, falling back to `homepage`). Everything else
|
|
61
|
+
above is a convention that `.release_ceremony.yml` can override.
|
|
62
|
+
|
|
63
|
+
## Configuration
|
|
64
|
+
|
|
65
|
+
Create `.release_ceremony.yml` in the repository root to override any of the
|
|
66
|
+
defaults:
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
69
|
+
gem_name: my_gem # default: the gemspec's name
|
|
70
|
+
repo_url: https://github.com/acme/my_gem # default: the gemspec's source_code_uri or homepage
|
|
71
|
+
version_file: lib/my_gem/version.rb # default: lib/<gem_name>/version.rb
|
|
72
|
+
changelog_file: CHANGELOG.md # default: CHANGELOG.md
|
|
73
|
+
workflow_file: release.yml # default: release.yml
|
|
74
|
+
default_branch: main # default: main
|
|
75
|
+
remote: origin # default: origin
|
|
76
|
+
verification_commands: # default: the four commands below
|
|
77
|
+
- bundle install
|
|
78
|
+
- bundle exec rspec
|
|
79
|
+
- bundle exec rubocop
|
|
80
|
+
- bundle exec rake build
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`verification_commands` are split shell-style into words and run directly
|
|
84
|
+
(no shell), in order, from the repository root. A project that manages its
|
|
85
|
+
toolchain with [mise](https://mise.jdx.dev/), for example, would use
|
|
86
|
+
`mise exec -- bundle exec rspec`.
|
|
87
|
+
|
|
88
|
+
## Comparison with create_github_release
|
|
89
|
+
|
|
90
|
+
[`create_github_release`](https://github.com/main-branch/create_github_release)
|
|
91
|
+
automates the same job — releasing a gem hosted on GitHub through a release
|
|
92
|
+
branch and pull request — but makes the opposite choice at almost every step:
|
|
93
|
+
|
|
94
|
+
| | `release_ceremony` | `create_github_release` |
|
|
95
|
+
| --- | --- | --- |
|
|
96
|
+
| Committing and pushing | You commit, push, and open the PR yourself | One command commits, tags, pushes, and opens the PR |
|
|
97
|
+
| Tag timing | Tags the merge commit after the PR merges, validating it first | Tags the release branch before merging, requiring a manual fast-forward merge |
|
|
98
|
+
| Changelog | Promotes your curated Keep a Changelog `Unreleased` notes | Generates release notes from the commit list |
|
|
99
|
+
| Verification | Runs configurable verification commands during `prepare` | None |
|
|
100
|
+
| Publishing | Tag-triggered CI publishes; `publish` watches the workflow and confirms the version on RubyGems | Manual `rake release:rubygem_push` from your machine |
|
|
101
|
+
| Version | Explicit `X.Y.Z` only | Computes major/minor/patch bumps, supports pre-releases |
|
|
102
|
+
| GitHub release object | Not created | Created |
|
|
103
|
+
|
|
104
|
+
Choose `create_github_release` if you want the release automated away in one
|
|
105
|
+
command. Choose `release_ceremony` if you want curated release notes, a
|
|
106
|
+
human inspecting and merging the release pull request, publication only from
|
|
107
|
+
CI, and verification at every step.
|
|
108
|
+
|
|
109
|
+
## Development
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
mise install
|
|
113
|
+
bundle install
|
|
114
|
+
bundle exec rspec # tests
|
|
115
|
+
bundle exec rubocop # static code analysis
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ReleaseCeremony
|
|
4
|
+
# Transforms a Keep a Changelog file for a release: the Unreleased notes
|
|
5
|
+
# move under a new dated heading and the comparison links are rewritten.
|
|
6
|
+
class Changelog
|
|
7
|
+
UNRELEASED_HEADING = /^## \[Unreleased\][ \t]*$/
|
|
8
|
+
VERSION_HEADING = /^## \[[^\]]+\].*$/
|
|
9
|
+
REFERENCE_DEFINITION = /^\[[^\]]+\]:[ \t]+.*$/
|
|
10
|
+
ANY_UNRELEASED_LINK = /^\[Unreleased\]:.*$/
|
|
11
|
+
|
|
12
|
+
def initialize(contents:, current_version:, version:, today:, compare_base:, changelog_file:)
|
|
13
|
+
@contents = contents
|
|
14
|
+
@current_version = current_version
|
|
15
|
+
@version = version
|
|
16
|
+
@today = today
|
|
17
|
+
@compare_base = compare_base
|
|
18
|
+
@changelog_file = changelog_file
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def transform
|
|
22
|
+
heading = unreleased_heading
|
|
23
|
+
ensure_unreleased_notes!(heading)
|
|
24
|
+
ensure_requested_version_absent!
|
|
25
|
+
link = unreleased_link
|
|
26
|
+
ensure_previous_version_matches!(link)
|
|
27
|
+
|
|
28
|
+
insert_release_heading(heading).sub(link[0], replacement_links)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :changelog_file, :compare_base, :contents, :current_version, :today, :version
|
|
34
|
+
|
|
35
|
+
def unreleased_link_pattern
|
|
36
|
+
/^\[Unreleased\]: #{Regexp.escape(compare_base)}v(?<previous>\d+\.\d+\.\d+)\.\.\.HEAD[ \t]*$/
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def unreleased_heading
|
|
40
|
+
headings = contents.to_enum(:scan, UNRELEASED_HEADING).map { Regexp.last_match.dup }
|
|
41
|
+
raise Error, "#{changelog_file} must contain exactly one Unreleased heading" unless headings.one?
|
|
42
|
+
|
|
43
|
+
headings.first
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def ensure_unreleased_notes!(heading)
|
|
47
|
+
following_text = contents[heading.end(0)..]
|
|
48
|
+
boundary = [VERSION_HEADING, REFERENCE_DEFINITION]
|
|
49
|
+
.filter_map { |pattern| following_text.match(pattern) }
|
|
50
|
+
.min_by { |match| match.begin(0) }
|
|
51
|
+
notes = boundary ? following_text[0...boundary.begin(0)] : following_text
|
|
52
|
+
raise Error, "#{changelog_file} Unreleased section has no notes" if notes.strip.empty?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def ensure_requested_version_absent!
|
|
56
|
+
heading_pattern = /^## \[#{Regexp.escape(version.to_s)}\].*$/
|
|
57
|
+
link_pattern = /^\[#{Regexp.escape(version.to_s)}\]:.*$/
|
|
58
|
+
raise Error, "#{changelog_file} heading for #{version} already exists" if contents.match?(heading_pattern)
|
|
59
|
+
raise Error, "#{changelog_file} comparison link for #{version} already exists" if contents.match?(link_pattern)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def unreleased_link
|
|
63
|
+
pattern = unreleased_link_pattern
|
|
64
|
+
candidates = contents.to_enum(:scan, ANY_UNRELEASED_LINK).map { Regexp.last_match.dup }
|
|
65
|
+
valid = candidates.select { |candidate| pattern.match?(candidate[0]) }
|
|
66
|
+
unless candidates.one? && valid.one?
|
|
67
|
+
raise Error, "#{changelog_file} must contain exactly one Unreleased comparison link"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
pattern.match(valid.first[0])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def ensure_previous_version_matches!(link)
|
|
74
|
+
return if link[:previous] == current_version.to_s
|
|
75
|
+
|
|
76
|
+
raise Error,
|
|
77
|
+
"#{changelog_file} Unreleased link compares from v#{link[:previous]}, but VERSION is #{current_version}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def insert_release_heading(heading)
|
|
81
|
+
release_heading = "#{heading[0]}\n\n## [#{version}] - #{today.strftime('%Y-%m-%d')}"
|
|
82
|
+
contents.sub(heading[0], release_heading)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def replacement_links
|
|
86
|
+
unreleased = "[Unreleased]: #{compare_base}v#{version}...HEAD"
|
|
87
|
+
release = "[#{version}]: #{compare_base}v#{current_version}...v#{version}"
|
|
88
|
+
"#{unreleased}\n#{release}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ReleaseCeremony
|
|
4
|
+
# Command-line entry point that dispatches to the ceremony commands.
|
|
5
|
+
class CLI
|
|
6
|
+
USAGE = 'Usage: release_ceremony prepare|publish VERSION'
|
|
7
|
+
HELP = <<~TEXT.freeze
|
|
8
|
+
#{USAGE}
|
|
9
|
+
|
|
10
|
+
Commands:
|
|
11
|
+
prepare VERSION Update the release files on the release branch and run the checks.
|
|
12
|
+
publish VERSION Tag the merged release pull request and watch the release workflow.
|
|
13
|
+
|
|
14
|
+
Run release_ceremony COMMAND --help for details of each command.
|
|
15
|
+
TEXT
|
|
16
|
+
COMMANDS = { 'prepare' => Prepare, 'publish' => Publish }.freeze
|
|
17
|
+
|
|
18
|
+
def self.run(arguments, runner: Runner.new, **options)
|
|
19
|
+
command, *command_arguments = arguments
|
|
20
|
+
if command == '--help'
|
|
21
|
+
runner.puts(HELP)
|
|
22
|
+
return true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
handler = COMMANDS.fetch(command) do
|
|
26
|
+
raise Error, command ? "Unknown command #{command.inspect}\n#{USAGE}" : USAGE
|
|
27
|
+
end
|
|
28
|
+
handler.run(command_arguments, runner: runner, **options)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ReleaseCeremony
|
|
4
|
+
# Shared entry point behaviour for the ceremony commands: --help handling
|
|
5
|
+
# and strict VERSION argument parsing.
|
|
6
|
+
module Command
|
|
7
|
+
def run(arguments, runner: Runner.new, **options)
|
|
8
|
+
if arguments == ['--help']
|
|
9
|
+
runner.puts(self::HELP)
|
|
10
|
+
return true
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
new(version: parse_version(arguments), runner: runner, **options).call
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def parse_version(arguments)
|
|
19
|
+
raise Error, self::USAGE unless arguments.length == 1
|
|
20
|
+
|
|
21
|
+
Semver.parse(arguments.first)
|
|
22
|
+
rescue Error => e
|
|
23
|
+
raise if e.message == self::USAGE
|
|
24
|
+
|
|
25
|
+
raise Error, "#{e.message}\n#{self::USAGE}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'shellwords'
|
|
5
|
+
|
|
6
|
+
module ReleaseCeremony
|
|
7
|
+
# Prepares a release on its release-vVERSION branch: updates the version
|
|
8
|
+
# file, dates the changelog, and runs the verification commands. Never
|
|
9
|
+
# commits or pushes.
|
|
10
|
+
class Prepare
|
|
11
|
+
extend Command
|
|
12
|
+
|
|
13
|
+
USAGE = 'Usage: release_ceremony prepare VERSION'
|
|
14
|
+
HELP = <<~TEXT.freeze
|
|
15
|
+
#{USAGE}
|
|
16
|
+
|
|
17
|
+
Prepare VERSION on its release-vVERSION branch, update the release files,
|
|
18
|
+
and run the release checks. This command does not commit or push changes.
|
|
19
|
+
TEXT
|
|
20
|
+
VERSION_DECLARATION = /^[ \t]*VERSION[ \t]*=/
|
|
21
|
+
VERSION_ASSIGNMENT = /^(?<indent>[ \t]*)VERSION = '(?<current>[^'\r\n]+)'[ \t]*$/
|
|
22
|
+
|
|
23
|
+
def initialize(version:, runner:, project: nil, today: Date.today, writer: TransactionalWriter.new)
|
|
24
|
+
@version = version
|
|
25
|
+
@runner = runner
|
|
26
|
+
@project = project || Project.discover(runner: runner)
|
|
27
|
+
@today = today
|
|
28
|
+
@writer = writer
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call
|
|
32
|
+
Dir.chdir(project.root) { prepare_release }
|
|
33
|
+
print_next_steps
|
|
34
|
+
true
|
|
35
|
+
rescue SystemCallError => e
|
|
36
|
+
raise Error, e.message
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
attr_reader :project, :runner, :today, :version, :writer
|
|
42
|
+
|
|
43
|
+
def prepare_release
|
|
44
|
+
announce('Checking the release branch and working tree')
|
|
45
|
+
guard_repository!
|
|
46
|
+
announce("Updating #{project.version_file} and #{project.changelog_file}")
|
|
47
|
+
version_contents, current_version = transformed_version_file
|
|
48
|
+
write_release_files(version_contents, transformed_changelog(current_version))
|
|
49
|
+
run_verification_commands
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def announce(message)
|
|
53
|
+
runner.puts("#{message}...")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def guard_repository!
|
|
57
|
+
branch = runner.capture('git', 'symbolic-ref', '--quiet', '--short', 'HEAD').strip
|
|
58
|
+
expected_branch = "release-v#{version}"
|
|
59
|
+
raise Error, "Current branch must be #{expected_branch}, not #{branch}" unless branch == expected_branch
|
|
60
|
+
|
|
61
|
+
status = runner.capture('git', 'status', '--porcelain=v1')
|
|
62
|
+
raise Error, 'Working tree must be clean before preparing a release' unless status.empty?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def transformed_version_file
|
|
66
|
+
contents = File.binread(project.version_file)
|
|
67
|
+
assignment = version_assignment(contents)
|
|
68
|
+
current_version = parse_current_version(assignment[:current])
|
|
69
|
+
raise Error, "Version #{version} must be greater than #{current_version}" unless version > current_version
|
|
70
|
+
|
|
71
|
+
replacement = "#{assignment[:indent]}VERSION = '#{version}'"
|
|
72
|
+
[contents.sub(VERSION_ASSIGNMENT, replacement), current_version]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def version_assignment(contents)
|
|
76
|
+
declarations = contents.lines.grep(VERSION_DECLARATION)
|
|
77
|
+
assignment = VERSION_ASSIGNMENT.match(declarations.first.to_s)
|
|
78
|
+
message = "#{project.version_file} must contain exactly one VERSION assignment"
|
|
79
|
+
raise Error, message unless declarations.one? && assignment
|
|
80
|
+
|
|
81
|
+
assignment
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def parse_current_version(value)
|
|
85
|
+
Semver.parse(value)
|
|
86
|
+
rescue Error
|
|
87
|
+
raise Error, "Current VERSION must use the X.Y.Z format, not #{value.inspect}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def transformed_changelog(current_version)
|
|
91
|
+
Changelog.new(
|
|
92
|
+
contents: File.binread(project.changelog_file),
|
|
93
|
+
current_version: current_version,
|
|
94
|
+
version: version,
|
|
95
|
+
today: today,
|
|
96
|
+
compare_base: project.compare_base,
|
|
97
|
+
changelog_file: project.changelog_file
|
|
98
|
+
).transform
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def write_release_files(version_contents, changelog_contents)
|
|
102
|
+
writer.write(project.version_file => version_contents, project.changelog_file => changelog_contents)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def run_verification_commands
|
|
106
|
+
project.verification_commands.each do |command|
|
|
107
|
+
announce("Running: #{Shellwords.join(command)}")
|
|
108
|
+
runner.run(*command)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def print_next_steps
|
|
113
|
+
runner.puts("Prepared version #{version} and completed the release checks.")
|
|
114
|
+
runner.puts('Inspect the changes, then commit and push the release branch.')
|
|
115
|
+
runner.puts("Open and merge the pull request before running release_ceremony publish #{version}.")
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
module ReleaseCeremony
|
|
7
|
+
# The repository-specific facts the release ceremony needs: the gem name,
|
|
8
|
+
# the release file paths, the GitHub repository URL, and the verification
|
|
9
|
+
# commands. Project.load reads overrides from .release_ceremony.yml and
|
|
10
|
+
# falls back to the repository's gemspec and conventional defaults.
|
|
11
|
+
class Project
|
|
12
|
+
CONFIG_FILE = '.release_ceremony.yml'
|
|
13
|
+
CONFIG_KEYS = %w[gem_name repo_url version_file changelog_file verification_commands
|
|
14
|
+
workflow_file default_branch remote].freeze
|
|
15
|
+
REPO_URL_PATTERN = %r{\Ahttps://github\.com/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+\z}
|
|
16
|
+
DEFAULT_VERIFICATION_COMMANDS = [
|
|
17
|
+
%w[bundle install],
|
|
18
|
+
%w[bundle exec rspec],
|
|
19
|
+
%w[bundle exec rubocop],
|
|
20
|
+
%w[bundle exec rake build]
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :root, :gem_name, :repo_url, :version_file, :changelog_file,
|
|
24
|
+
:verification_commands, :workflow_file, :default_branch, :remote
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def discover(runner: Runner.new)
|
|
28
|
+
load(root: runner.capture('git', 'rev-parse', '--show-toplevel').strip)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def load(root:)
|
|
32
|
+
config = Config.read(root)
|
|
33
|
+
gemspec = GemspecFacts.new(root)
|
|
34
|
+
overrides = config.slice('version_file', 'changelog_file', 'verification_commands',
|
|
35
|
+
'workflow_file', 'default_branch', 'remote').transform_keys(&:to_sym)
|
|
36
|
+
new(
|
|
37
|
+
root: root,
|
|
38
|
+
gem_name: config.fetch('gem_name') { gemspec.name },
|
|
39
|
+
repo_url: config.fetch('repo_url') { gemspec.repo_url },
|
|
40
|
+
**overrides
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(root:, gem_name:, repo_url:, version_file: nil, changelog_file: nil,
|
|
46
|
+
verification_commands: nil, workflow_file: nil, default_branch: nil, remote: nil)
|
|
47
|
+
@root = validated_string(:root, root)
|
|
48
|
+
@gem_name = validated_string(:gem_name, gem_name)
|
|
49
|
+
@repo_url = validated_repo_url(repo_url)
|
|
50
|
+
@version_file = validated_string(:version_file, version_file || default_version_file)
|
|
51
|
+
@changelog_file = validated_string(:changelog_file, changelog_file || 'CHANGELOG.md')
|
|
52
|
+
@verification_commands = validated_commands(verification_commands || DEFAULT_VERIFICATION_COMMANDS)
|
|
53
|
+
@workflow_file = validated_string(:workflow_file, workflow_file || 'release.yml')
|
|
54
|
+
@default_branch = validated_string(:default_branch, default_branch || 'main')
|
|
55
|
+
@remote = validated_string(:remote, remote || 'origin')
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def compare_base
|
|
59
|
+
"#{repo_url}/compare/"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def actions_run_url(run_id)
|
|
63
|
+
"#{repo_url}/actions/runs/#{run_id}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def default_version_file
|
|
69
|
+
"lib/#{gem_name.tr('-', '/')}/version.rb"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validated_string(name, value)
|
|
73
|
+
raise Error, "Project #{name} must be a non-empty string" unless value.is_a?(String) && !value.empty?
|
|
74
|
+
|
|
75
|
+
value
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validated_repo_url(value)
|
|
79
|
+
validated_string(:repo_url, value)
|
|
80
|
+
unless REPO_URL_PATTERN.match?(value)
|
|
81
|
+
raise Error, "Project repo_url must look like https://github.com/OWNER/REPO, not #{value.inspect}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
value
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def validated_commands(commands)
|
|
88
|
+
valid = commands.is_a?(Array) && !commands.empty? &&
|
|
89
|
+
commands.all? { |command| command.is_a?(Array) && !command.empty? && command.all?(String) }
|
|
90
|
+
raise Error, 'Project verification_commands must be a list of commands, each a list of strings' unless valid
|
|
91
|
+
|
|
92
|
+
commands
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Reads and validates the optional .release_ceremony.yml overrides.
|
|
96
|
+
module Config
|
|
97
|
+
extend self
|
|
98
|
+
|
|
99
|
+
def read(root)
|
|
100
|
+
path = File.join(root, CONFIG_FILE)
|
|
101
|
+
return {} unless File.exist?(path)
|
|
102
|
+
|
|
103
|
+
config = parse(path)
|
|
104
|
+
unknown = config.keys - CONFIG_KEYS
|
|
105
|
+
raise Error, "#{CONFIG_FILE} has unknown keys: #{unknown.join(', ')}" unless unknown.empty?
|
|
106
|
+
|
|
107
|
+
parse_verification_commands(config)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def parse(path)
|
|
113
|
+
config = YAML.safe_load_file(path)
|
|
114
|
+
raise Error, "#{CONFIG_FILE} must contain a YAML mapping" unless config.is_a?(Hash)
|
|
115
|
+
|
|
116
|
+
config
|
|
117
|
+
rescue Psych::Exception => e
|
|
118
|
+
raise Error, "#{CONFIG_FILE} is not valid YAML: #{e.message}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def parse_verification_commands(config)
|
|
122
|
+
return config unless config.key?('verification_commands')
|
|
123
|
+
|
|
124
|
+
commands = config['verification_commands']
|
|
125
|
+
valid = commands.is_a?(Array) && commands.all?(String)
|
|
126
|
+
raise Error, "#{CONFIG_FILE} verification_commands must be a list of command strings" unless valid
|
|
127
|
+
|
|
128
|
+
config.merge('verification_commands' => commands.map { |command| split_command(command) })
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def split_command(command)
|
|
132
|
+
Shellwords.split(command)
|
|
133
|
+
rescue ArgumentError => e
|
|
134
|
+
raise Error, "#{CONFIG_FILE} verification_commands entry #{command.inspect} is invalid: #{e.message}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Lazily extracts facts from the repository's gemspec, so a fully
|
|
139
|
+
# configured project never needs to load it.
|
|
140
|
+
class GemspecFacts
|
|
141
|
+
def initialize(root)
|
|
142
|
+
@root = root
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def name
|
|
146
|
+
specification.name
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def repo_url
|
|
150
|
+
url = specification.metadata['source_code_uri'] || specification.homepage
|
|
151
|
+
url.to_s.chomp('/')
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def specification
|
|
157
|
+
@specification ||= load_specification
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def load_specification
|
|
161
|
+
path = gemspec_path
|
|
162
|
+
Gem::Specification.load(path) || raise(Error, "Could not load the gemspec at #{path}")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def gemspec_path
|
|
166
|
+
paths = Dir.glob('*.gemspec', base: @root).sort
|
|
167
|
+
raise Error, "Expected exactly one gemspec in #{@root}; found #{paths.length}" unless paths.one?
|
|
168
|
+
|
|
169
|
+
File.join(@root, paths.first)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|