class Release

Includes
Shell: #system, #execute, #readlines

Regenerates release content from its base without modifying the checkout.

Definitions

BUMPS = {"patch" => [nil, nil, 1], "minor" => [nil, 1, 0], "major" => [1, 0, 0]}.freeze

Supported stable version increments.

def initialize(root)

Signature

parameter root String

The repository containing the commits to compare.

Implementation

def initialize(root)
	@root = File.expand_path(root)
end

def resolve(reference)

Resolve a commit before passing it to other Git commands.

Implementation

def resolve(reference)
	git("rev-parse", "--verify", "--end-of-options", "#{reference}^{commit}").strip
end

def worktree(reference)

Yield a temporary detached worktree and remove it even on failure.

Implementation

def worktree(reference)
	commit = resolve(reference)
	Dir.mktmpdir("bake-gem-release-") do |directory|
		path = File.join(directory, "source")
		system("git", "worktree", "add", "--detach", path, commit, chdir: @root, out: File::NULL)
		begin
			yield path
		ensure
			system("git", "worktree", "remove", "--force", path, chdir: @root, out: File::NULL)
		end
	end
end

def bake(path, *arguments, **options)

Run Bake tasks in a fresh interpreter, avoiding cached version constants.

Signature

parameter path String

The checkout in which to run the tasks.

parameter arguments Array(String)

Task names and command line arguments.

parameter options Hash

Task options; nil values use the task defaults.

Implementation

def bake(path, *arguments, **options)
	Dir.mktmpdir("bake-gem-result-") do |directory|
		result = File.join(directory, "result.json")
		options.each{|key, value| arguments << "#{key}=#{value}" unless value.nil?}
		system("bake", *arguments, "output", "file=#{result}", "format=json", chdir: path)
		JSON.parse(File.read(result), symbolize_names: true)
	end
end

def metadata(reference)

Inspect a committed gem in isolation.

Implementation

def metadata(reference)
	worktree(reference) {|path| bake(path, "gem:metadata")}
end

def bump(previous, target)

Validate an ordinary patch, minor, or major transition.

Implementation

def bump(previous, target)
	unless previous.match?(/\A\d+\.\d+\.\d+\z/) && target.match?(/\A\d+\.\d+\.\d+\z/)
		raise "Release validation supports stable three-part versions only."
	end
	BUMPS.each do |name, increment|
		version = Version.new(previous.split(".").map(&:to_i), nil).increment(increment)
		return name if version.join == target
	end
	raise "Unsupported release transition: #{previous} -> #{target}."
end

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

Compare an independently generated release tree to the candidate, including added and deleted files.

Signature

parameter base String

Current target commit, or the merged commit's first parent when publishing.

parameter candidate String

Proposed or merged release commit.

parameter optional Boolean

Allow ordinary PRs which do not change the version.

Implementation

def validate(base:, candidate: "HEAD", optional: false)
	base = resolve(base)
	candidate = resolve(candidate)
	proposed = metadata(candidate)
	worktree(base) do |path|
		previous = bake(path, "gem:metadata")
		raise "Release changes the gem name." unless proposed[:name] == previous[:name]
		return nil if optional && proposed[:version] == previous[:version]
		increment = bump(previous[:version], proposed[:version])
		generated = bake(path, "gem:release:version:increment", BUMPS.fetch(increment).join(","))
		raise "Generated version does not match proposal." unless generated[:version] == proposed[:version]
		system("git", "add", "--all", chdir: path)
		expected = readlines("git", "write-tree", chdir: path).join.strip
		diff = git("diff", "--no-ext-diff", "--no-textconv", candidate, expected, "--")
		raise "Release content is stale or contains unrelated changes. Expected changes:\n#{diff}" unless diff.empty?
		return proposed.merge(base: base, commit: candidate, bump: increment)
	end
end