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 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,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'release_ceremony'
5
+
6
+ begin
7
+ ReleaseCeremony::CLI.run(ARGV)
8
+ rescue ReleaseCeremony::Error => e
9
+ warn "Error: #{e.message}"
10
+ exit 1
11
+ end
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReleaseCeremony
4
+ class Error < StandardError; end
5
+ 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