class Project

Includes
Shell

GitHub operations invoked by local tasks and repository workflows.

Definitions

def initialize(root)

Load the reviewed repository release policy.

Signature

parameter root String

The repository root containing config/release.yaml.

raises RuntimeError

If the configuration schema is unsupported.

Implementation

def initialize(root)
	@root = File.expand_path(root)
	@config = YAML.safe_load_file(File.join(@root, "config/release.yaml"))
	raise "Unsupported release configuration." unless @config.fetch("schema") == 1
	@repository = @config.fetch("repository")
	@release = Release.new(@root)
end

attr_reader :config

Signature

attribute Hash

Reviewed desired policy.

def api(path)

Execute a GitHub API read. Failures never imply that a resource is absent.

Signature

parameter path String

An API path relative to this repository.

returns Hash | Array

The decoded GitHub response, retaining string keys.

raises Bake::Gem::CommandExecutionError

If the GitHub request fails.

Implementation

def api(path)
	JSON.parse(readlines("gh", "api", "repos/#{@repository}/#{path}", chdir: @root).join)
end

def prepare(context, bump, refresh: false)

Prepare a release through core Bake tasks, then push and create its pull request.

The process must already be in the repository root because Bake::Gem::Helper evaluates its gemspec. Refresh preserves the previous release commit before replacing the remote branch with an explicit push lease.

Signature

parameter context Bake::Context

The consumer context used to invoke core release tasks.

parameter bump String

The stable version increment: patch, minor, or major.

parameter refresh Boolean

Whether to regenerate an existing release from the current base.

returns String

The new or existing release PR URL.

raises RuntimeError

If the checkout, existing PR, or generated release content is unsuitable.

raises Bake::Gem::CommandExecutionError

If a Git or GitHub operation fails, including a conflicting push.

Implementation

def prepare(context, bump, refresh: false)
	Release::BUMPS.fetch(bump)
	helper = Helper.new(@root)
	helper.guard_clean
	
	branch = @config.fetch("branch")
	raise "Prepare releases from #{branch}." unless helper.current_branch == branch
	system("git", "fetch", "origin", branch, "--tags", chdir: @root)
	raise "Local branch differs from origin/#{branch}." unless @release.resolve("HEAD") == @release.resolve("origin/#{branch}")
	
	pull_request = find_release_pull_request(branch)
	version = Version.new(helper.gemspec.version.segments, nil).increment(Release::BUMPS.fetch(bump)).join
	release_branch = "releases/v#{version}"
	if pull_request && pull_request.fetch("headRefName") != release_branch
		raise "Existing release PR uses #{pull_request.fetch('headRefName')}; use its bump type or close it first."
	end
	
	base = @release.resolve("HEAD")
	release_ref = "refs/heads/#{release_branch}"
	remote_commit = fetch_release_branch(release_ref)
	candidate = remote_commit || local_release_commit(release_branch)
	
	if candidate && refresh
		candidate = refresh_release(candidate, base: base, bump: bump, version: version)
	elsif !candidate
		context.lookup("gem:release:branch:#{bump}").call
		candidate = @release.resolve("HEAD")
	end
	
	metadata = validate(base: base, candidate: candidate, optional: false)
	raise "Release branch does not contain the requested version #{version}." unless metadata.fetch(:version) == version
	
	push("--force-with-lease=#{release_ref}:#{remote_commit}", "#{candidate}:#{release_ref}")
	return pull_request.fetch("url") if pull_request
	
	return create_release_pull_request(
		helper.gemspec.name, version,
		branch: branch, release_branch: release_branch, base: base,
	)
end

def validate(base:, candidate: "HEAD", optional: true)

Validate PR content and require each proposed release to contain exactly one commit.

Signature

parameter base String

The current target commit.

parameter candidate String

The proposed PR head.

parameter optional Boolean

Accept ordinary PRs without a version change.

returns Hash | Nil

Release metadata, or nil for an ordinary PR.

raises RuntimeError

If the release content is invalid or the release contains multiple commits.

Implementation

def validate(base:, candidate: "HEAD", optional: true)
	metadata = @release.validate(base: base, candidate: candidate, optional: optional)
	if metadata
		range = "#{metadata.fetch(:base)}..#{metadata.fetch(:commit)}"
		count = readlines("git", "rev-list", "--count", range, chdir: @root).join.strip
		raise "Release PRs must contain exactly one commit. Amend or regenerate the release commit instead of adding commits." unless count == "1"
	end
	
	return metadata
end

def merged(number)

Resolve a merged PR through GitHub, and require its actual merge commit in default-branch history.

Signature

parameter number String | Integer

The positive PR number.

returns Hash

GitHub PR data with string keys, including number and merge_commit_sha.

raises RuntimeError

If the PR is invalid, unmerged, or targets another repository or branch.

raises Bake::Gem::CommandExecutionError

If its commit is outside the default branch history or a command fails.

Implementation

def merged(number)
	raise "Expected a PR number." unless number.to_s.match?(/\A[1-9]\d*\z/)
	pr = api("pulls/#{number}")
	raise "PR must be merged into the configured branch." unless pr["merged"] && pr.dig("base", "ref") == @config.fetch("branch") && pr.dig("base", "repo", "full_name") == @repository
	
	commit = pr.fetch("merge_commit_sha")
	raise "Invalid merged commit." unless commit.match?(/\A[0-9a-f]{40,64}\z/)
	
	system("git", "fetch", "origin", @config.fetch("branch"), "--tags", chdir: @root)
	system("git", "merge-base", "--is-ancestor", commit, "origin/#{@config.fetch('branch')}", chdir: @root)
	
	return pr
end

def inspect_release(number, commit: nil)

Resolve release identity; ordinary merged PRs do not publish.

Signature

parameter number String | Integer

The merged PR number.

parameter commit String | Nil

The expected merge commit, when resolving a push event.

returns Hash | Nil

Release metadata with symbol keys, or nil for an ordinary PR. Includes name, version, commit, base, bump, repository, pull_request, merged_by, and pull_request_url.

raises RuntimeError

If the merged source does not match the independently generated release.

Implementation

def inspect_release(number, commit: nil)
	pr = merged(number)
	raise "PR merge commit does not match the pushed commit." if commit && pr.fetch("merge_commit_sha") != commit
	
	commit = pr.fetch("merge_commit_sha")
	metadata = @release.validate(base: "#{commit}^1", candidate: commit, optional: true)
	if metadata
		return metadata.merge(
			repository: @repository,
			pull_request: pr.fetch("number"),
			merged_by: pr.dig("merged_by", "login"),
			pull_request_url: pr.fetch("html_url"),
		)
	end
end

def inspect_commit(commit)

Resolve a pushed release commit to the PR which merged it into the configured branch.

Signature

parameter commit String

The full commit SHA from the push event.

returns Hash | Nil

Release metadata from Bake::Gem::GitHub::Project#inspect_release, or nil for an ordinary change.

raises RuntimeError

If the commit is invalid or a release has no unique matching merged PR.

raises Bake::Gem::CommandExecutionError

If Git or GitHub cannot verify the release.

Implementation

def inspect_commit(commit)
	raise "Expected a full pushed commit SHA." unless commit.match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/)
	responses = readlines(
		"gh", "api", "repos/#{@repository}/commits/#{commit}/pulls?per_page=100",
		"--paginate", "--slurp", chdir: @root,
	)
	pulls = JSON.parse(responses.join).flatten(1).select do |pr|
		pr["merged_at"] && pr["merge_commit_sha"] == commit &&
			pr.dig("base", "ref") == @config.fetch("branch") && pr.dig("base", "repo", "full_name") == @repository
	end
	
	if pulls.empty?
		return nil unless @release.validate(base: "#{commit}^1", candidate: commit, optional: true)
		raise "Release commit has no matching merged PR."
	end
	raise "Multiple merged PRs match the pushed commit." if pulls.size > 1
	
	return inspect_release(pulls.first.fetch("number"), commit: commit)
end

def doctor

Return a read-only comparison of managed settings and current repository settings.

Signature

returns Hash

Desired rules, existing rules, environments, optional environment changes, and expected Trusted Publisher settings. This does not verify RubyGems ownership or publisher configuration.

raises RuntimeError

If a reviewer team is secret or belongs to another organization.

Implementation

def doctor
	{
		desired_rules: Setup.rules(@config),
		existing_rules: api("rulesets?per_page=100"),
		environments: api("environments"),
		environment_changes: environment_changes,
		trusted_publisher: {
			repository_owner: @repository.split("/").first,
			repository_name: @repository.split("/").last,
			workflow_filename: "release-publish.yaml",
			environment: @config.fetch("environment"),
		}
	}
end

def apply

Apply the named rulesets and configured reviewers for an existing environment. Invoke after reviewing doctor output. Preserves the environment's wait timer, self-review prevention, administrator bypass, and branch restrictions. Reads back updated environment settings to verify GitHub retained them.

Signature

returns Hash

The desired ruleset payloads after successful application.

raises RuntimeError

If a reviewer team is unsuitable, managed rulesets are ambiguous, or environment verification fails. Earlier updates may already have completed.

raises Bake::Gem::CommandExecutionError

If an API operation fails; earlier updates may already have completed.

Implementation

def apply
	changes = environment_changes
	existing = api("rulesets?per_page=100")
	rules = Setup.rules(@config)
	rules.each_value do |rule|
		matches = existing.select{|current| current.fetch("name") == rule.fetch(:name)}
		raise "Multiple rulesets match #{rule[:name]}." if matches.size > 1
		current = matches.first
		path = "repos/#{@repository}/rulesets"
		path += "/#{current.fetch('id')}" if current
		
		write_api(path, rule, method: current ? "PUT" : "POST")
	end
	
	if changes && changes.fetch(:current) != changes.fetch(:desired)
		write_api("repos/#{@repository}/#{environment_path}", changes.fetch(:desired), method: "PUT")
		unless environment_settings == changes.fetch(:desired)
			raise "GitHub did not retain the requested settings for environment #{changes.fetch(:name)}. Check its reviewers and protection rules in GitHub, then rerun gem:github:setup:plan."
		end
	end
	
	return rules
end

def environment_changes

Resolve reviewers before making any changes, and preserve unrelated environment settings.

Implementation

def environment_changes
	return nil unless @config.key?("reviewers")
	Setup.validate_reviewers(@config["reviewers"])
	
	current = environment_settings
	reviewers = @config.fetch("reviewers").map{|name| resolve_reviewer(name)}.uniq.sort_by{|reviewer| reviewer.values_at(:type, :id)}
	
	return {name: @config.fetch("environment"), current: current, desired: current.merge(reviewers: reviewers)}
end

def environment_settings

Read the environment settings, comparing reviewer identities independently of their order.

Implementation

def environment_settings
	environment = api(environment_path)
	protections = environment.fetch("protection_rules").to_h{|rule| [rule.fetch("type"), rule]}
	reviews = protections.fetch("required_reviewers", {})
	reviewers = reviews.fetch("reviewers", []).map{|entry| {type: entry.fetch("type"), id: entry.fetch("reviewer").fetch("id")}}
	
	return {
		wait_timer: protections.fetch("wait_timer", {}).fetch("wait_timer", 0),
		prevent_self_review: reviews.fetch("prevent_self_review", false),
		can_admins_bypass: environment.fetch("can_admins_bypass"),
		deployment_branch_policy: environment.fetch("deployment_branch_policy"),
		reviewers: reviewers.uniq.sort_by{|reviewer| reviewer.values_at(:type, :id)},
	}
end

def find_release_pull_request(branch)

Find the repository's sole release PR, excluding forks.

Implementation

def find_release_pull_request(branch)
	response = readlines(
		"gh", "pr", "list", "--repo", @repository, "--base", branch,
		"--state", "open", "--json", "headRefName,url,isCrossRepository",
		"--limit", "1000", chdir: @root,
	)
	pull_requests = JSON.parse(response.join).select do |pull_request|
		!pull_request["isCrossRepository"] && pull_request.fetch("headRefName").start_with?("releases/v")
	end
	raise "Multiple release PRs are open; select one before preparing another release." if pull_requests.size > 1
	
	return pull_requests.first
end

def fetch_release_branch(reference)

Fetch the remote branch and return the commit used for the push lease.

Implementation

def fetch_release_branch(reference)
	remote = readlines("git", "ls-remote", "--heads", "origin", reference, chdir: @root)
	return nil if remote.empty?
	
	system("git", "fetch", "origin", reference, chdir: @root)
	
	return @release.resolve("FETCH_HEAD")
end

def local_release_commit(branch)

Locate preparation which stopped before pushing its branch.

Implementation

def local_release_commit(branch)
	return nil if readlines("git", "branch", "--list", branch, chdir: @root).empty?
	
	return @release.resolve("refs/heads/#{branch}")
end

def refresh_release(candidate, base:, bump:, version:)

Preserve the previous release before regenerating it from the current base.

Implementation

def refresh_release(candidate, base:, bump:, version:)
	backup_ref = "refs/heads/release-backups/v#{version}/#{candidate}"
	push("#{candidate}:#{backup_ref}")
	
	return @release.worktree(base) do |path|
		@release.bake(path, "gem:release:version:#{bump}")
		readlines("git", "rev-parse", "HEAD", chdir: path).join.strip
	end
end

def create_release_pull_request(name, version, branch:, release_branch:, base:)

Open a PR for the already validated and pushed release branch.

Implementation

def create_release_pull_request(name, version, branch:, release_branch:, base:)
	body = <<~BODY
		Release #{name} #{version}.

		Prepared from #{base}. The complete release tree is regenerated during validation. \
		Merging publishes the resulting commit through release-publish.yaml after native \
		reviews and required CI (or explicit administrator bypass).
	BODY
	
	return Tempfile.create("release-pr") do |file|
		file.write(body)
		file.flush
		
		readlines(
			"gh", "pr", "create", "--repo", @repository, "--base", branch,
			"--head", release_branch, "--title", "Release v#{version}",
			"--body-file", file.path, chdir: @root,
		).join.strip
	end
end