backup-utils/script/release

498 строки
15 KiB
Ruby
Executable File

#!/usr/bin/env ruby
# frozen_string_literal: true
#/ Usage: release [--dry-run] [--skip-version-bump-check] <version> [min_version]
#/
#/ Publish a backup-utils release:
#/ * Updates the package changelog
#/ * Bumps the backup-utils version if required
#/ * Creates the release pull request
#/ * Merges the release pull request
#/ * Creates the release draft
#/ * Tags the release
#/ * Builds the release assets and uploads them
#/
#/ Notes:
#/ * Needs GH_RELEASE_TOKEN and GH_AUTHOR set in the environment.
#/ * Export GH_OWNER and GH_REPO if you want to use a different owner/repo
#/ * Only pull requests labeled with bug, feature or enhancement will show up in the
#/ release page and the changelog.
#/ * If this is a X.Y.0 release, a minimum supported version needs to be supplied too.
#/
require 'json'
require 'net/http'
require 'time'
require 'erb'
require 'English'
API_HOST = ENV['GH_HOST'] || 'api.github.com'
API_PORT = 443
GH_REPO = ENV['GH_REPO'] || 'backup-utils'
GH_OWNER = ENV['GH_OWNER'] || 'github'
GH_AUTHOR = ENV['GH_AUTHOR']
DEB_PKG_NAME = 'github-backup-utils'
GH_BASE_BRANCH = ENV['GH_BASE_BRANCH'] || 'master' # TODO: should we even allow a default or require all params get set explicitly?
GH_STABLE_BRANCH = ""
CHANGELOG_TMPL = '''<%= package_name %> (<%= package_version %>) UNRELEASED; urgency=medium
<%- changes.each do |ch| -%>
* <%= ch.strip.chomp %>
<% end -%>
-- <%= GH_AUTHOR %> <%= Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S %z") %>
'''
# Override Kernel.warn
def warn(msg)
Kernel.warn msg unless @no_warn
end
def client(host = API_HOST, port = API_PORT)
@http ||= begin
c = Net::HTTP.new(host, port)
c.use_ssl = true
c
end
end
def get(path)
req = Net::HTTP::Get.new(path)
req['Authorization'] = "token #{release_token}"
client.request(req)
end
def post(path, body)
req = Net::HTTP::Post.new(path)
req['Authorization'] = "token #{release_token}"
req.body = body
client.request(req)
end
def post_file(path, body)
req = Net::HTTP::Post.new(path)
req['Authorization'] = "token #{release_token}"
req['Content-Type'] = path.match?(/.*\.tar\.gz$/) ? 'application/tar+gzip' : 'application/vnd.debian.binary-package'
req.body = body
client.request(req)
end
def put(path, body)
req = Net::HTTP::Put.new(path)
req['Authorization'] = "token #{release_token}"
req.body = body
client.request(req)
end
def patch(path, body)
req = Net::HTTP::Patch.new(path)
req['Authorization'] = "token #{release_token}"
req.body = body
client.request(req)
end
def release_token
token = ENV['GH_RELEASE_TOKEN']
raise 'GH_RELEASE_TOKEN environment variable not set' if token.nil?
token
end
# Create a lightweight tag
def tag(name, sha)
body = {
"ref": "refs/tags/#{name}",
"sha": sha
}.to_json
res = post("/repos/#{GH_OWNER}/#{GH_REPO}/git/refs", body)
raise "Creating tag ref failed (#{res.code})" unless res.is_a? Net::HTTPSuccess
end
def bug_or_feature?(issue_hash)
return true if issue_hash['labels'].find { |label| ['bug', 'feature', 'enhancement'].include?(label['name']) }
false
end
def issue_from(issue)
res = get("/repos/#{GH_OWNER}/#{GH_REPO}/issues/#{issue}")
raise "Issue ##{issue} not found in #{GH_OWNER}/#{GH_REPO}" unless res.is_a? Net::HTTPSuccess
JSON.parse(res.body)
end
def beautify_changes(changes)
out = []
changes.each do |chg|
next unless chg =~ /#(\d+)/
begin
issue = issue_from Regexp.last_match(1)
out << "#{issue['title']} ##{Regexp.last_match(1)}" if bug_or_feature?(issue)
rescue => e
warn "Warning: #{e.message}"
end
end
out
end
def changelog
puts "building changelog by comparing origin/#{GH_STABLE_BRANCH}...origin/#{GH_BASE_BRANCH}"
changes = `git log --pretty=oneline origin/#{GH_STABLE_BRANCH}...origin/#{GH_BASE_BRANCH} --reverse --grep "Merge pull request" | sort -t\# -k2`.lines.map(&:strip)
raise 'Building the changelog failed' if $CHILD_STATUS != 0
changes
end
def build_changelog(changes, package_name, package_version)
ERB.new(CHANGELOG_TMPL, nil, '-').result(binding)
end
def update_changelog(changes, name, version, path = 'debian/changelog')
raise 'debian/changelog not found' unless File.exist?(path)
File.open("#{path}.new", 'w') do |f|
f.puts build_changelog changes, name, version
f.puts(File.read(path))
end
File.rename("#{path}.new", path)
end
def create_release(tag_name, branch, rel_name, rel_body, draft = true)
body = {
'tag_name': tag_name,
'target_commitish': branch,
'name': rel_name,
'body': rel_body,
'draft': draft,
'prerelease': false
}.to_json
res = post("/repos/#{GH_OWNER}/#{GH_REPO}/releases", body)
raise "Failed to create release (#{res.code})" unless res.is_a? Net::HTTPSuccess
JSON.parse(res.body)
end
def publish_release(release_id)
body = {
'draft': false
}.to_json
res = patch("/repos/#{GH_OWNER}/#{GH_REPO}/releases/#{release_id}", body)
raise "Failed to update release (#{res.code})" unless res.is_a? Net::HTTPSuccess
end
def list_releases
res = get("/repos/#{GH_OWNER}/#{GH_REPO}/releases")
raise 'Failed to retrieve releases' unless res.is_a? Net::HTTPSuccess
JSON.parse(res.body)
end
def release_available?(tag_name)
return true if list_releases.find { |r| r['tag_name'] == tag_name }
false
end
def bump_version(new_version, min_version = nil, path = 'share/github-backup-utils/version')
current_version = Gem::Version.new(File.read(path).strip.chomp)
if !@skip_version_bump_check && (Gem::Version.new(new_version) < current_version)
raise "New version should be newer than #{current_version}"
end
File.open("#{path}.new", 'w') { |f| f.puts new_version }
File.rename("#{path}.new", path)
unless min_version.nil?
content = File.read('bin/ghe-host-check')
new_content = content.gsub(/supported_minimum_version="[0-9]\.[0-9]+\.0"/, "supported_minimum_version=\"#{min_version}\"")
File.open('bin/ghe-host-check', 'w') {|file| file.puts new_content }
content = File.read('test/testlib.sh')
new_content = content.gsub(/GHE_TEST_REMOTE_VERSION:=[0-9]\.[0-9]+\.0/,"GHE_TEST_REMOTE_VERSION:=#{new_version}")
File.open('test/testlib.sh', 'w') {|file| file.puts new_content }
end
end
def push_release_branch(version)
unless (out = `git checkout --quiet -b release-#{version}`)
raise "Creating release branch failed:\n\n#{out}"
end
unless (out = `git commit --quiet -m 'Bump version: #{version} [ci skip]' debian/changelog share/github-backup-utils/version bin/ghe-host-check test/testlib.sh script/cibuild`)
raise "Error committing changelog and version:\n\n#{out}"
end
unless (out = `git push --quiet origin release-#{version}`)
raise "Failed pushing the release branch:\n\n#{out}"
end
end
def update_stable_branch
`git checkout --quiet #{GH_STABLE_BRANCH}`
unless (out = `git merge --quiet --ff-only origin/#{GH_BASE_BRANCH}`)
warn "Merging #{GH_BASE_BRANCH} into #{GH_STABLE_BRANCH} failed:\n\n#{out}"
end
unless (out = `git push --quiet origin #{GH_STABLE_BRANCH}`)
warn "Failed pushing the #{GH_STABLE_BRANCH} branch:\n\n#{out}"
end
end
def create_release_pr(version, release_body)
body = {
'title': "Bump version: #{version}",
'body': release_body,
'head': "release-#{version}",
'base': GH_BASE_BRANCH
}.to_json
res = post("/repos/#{GH_OWNER}/#{GH_REPO}/pulls", body)
raise "Creating release PR failed (#{res.code})" unless res.is_a? Net::HTTPSuccess
JSON.parse(res.body)
end
def merge_pr(number, sha, version)
body = {
'commit_title': "Merge pull request ##{number} from github/release-#{version}",
'commit_message': "Bump version: #{version}",
'sha': sha,
'merge_method': 'merge'
}.to_json
pr_mergeable? number
res = put("/repos/#{GH_OWNER}/#{GH_REPO}/pulls/#{number}/merge", body)
raise "Merging PR failed (#{res.code})" unless res.is_a? Net::HTTPSuccess
JSON.parse(res.body)
end
class RetryError < StandardError
end
def pr_mergeable?(number)
begin
retries ||= 5
res = get("/repos/#{GH_OWNER}/#{GH_REPO}/pulls/#{number}")
raise RetryError if JSON.parse(res.body)['mergeable'].nil?
mergeable = JSON.parse(res.body)['mergeable']
rescue RetryError
sleep 1
retry unless (retries -= 1).zero?
raise 'PR is unmergable.'
end
mergeable || false
end
def can_auth?
!ENV['GH_RELEASE_TOKEN'].nil?
end
def repo_exists?
res = get("/repos/#{GH_OWNER}/#{GH_REPO}")
res.is_a? Net::HTTPSuccess
end
def can_build_deb?
system('which debuild > /dev/null 2>&1')
end
def package_tarball
unless (out = `script/package-tarball 2>&1`)
raise "Failed to package tarball:\n\n#{out}"
end
out
end
def package_deb
unless (out = `DEB_BUILD_OPTIONS=nocheck script/package-deb 2>&1`)
raise "Failed to package Debian package:\n\n#{out}"
end
out
end
def attach_assets_to_release(upload_url, release_id, files)
@http = nil
client(URI(upload_url.gsub(/{.*}/, '')).host)
begin
files.each do |file|
raw_file = File.open(file).read
res = post_file("/repos/#{GH_OWNER}/#{GH_REPO}/releases/#{release_id}/assets?name=#{File.basename(file)}", raw_file)
raise "Failed to attach #{file} to release (#{res.code})" unless res.is_a? Net::HTTPSuccess
end
rescue => e
raise e
end
@http = nil
end
def clean_up(version)
`git checkout --quiet #{GH_BASE_BRANCH}`
`git fetch --quiet origin --prune`
`git pull --quiet origin #{GH_BASE_BRANCH} --prune`
`git branch --quiet -D release-#{version} >/dev/null 2>&1`
`git push --quiet origin :release-#{version} >/dev/null 2>&1`
`git branch --quiet -D tmp-packaging >/dev/null 2>&1`
end
def is_base_branch_valid?(branch)
if branch == "master" || branch.match(/^\d+\.\d+-main$/)
return true
else
return false
end
end
def get_stable_branch_name(branch)
## derive the proper stable branch. if the base branch is "master" the stable branch is just "stable"
## if the base branch is a release branch, the stable branch will be "x.y-stable"
result = ""
if branch == "master"
result = "stable"
else
result = branch.gsub(/-main$/, "-stable")
end
result
end
#### All the action starts ####
if $PROGRAM_NAME == __FILE__
begin
## validate base branch. this must either be "master" or a release branch which will match the pattern "x.y-main"
raise "The branch #{GH_BASE_BRANCH} is not valid for releasing backup-utils. branch name must be master or match x.y-main" if !is_base_branch_valid?(GH_BASE_BRANCH)
GH_STABLE_BRANCH = get_stable_branch_name(GH_BASE_BRANCH)
puts "base branch = " + GH_BASE_BRANCH
puts "stable branch = " + GH_STABLE_BRANCH
args = ARGV.dup
dry_run = false
skip_version_bump_check = false
if args.include?('--dry-run')
dry_run = true
args.delete '--dry-run'
end
if args.include?('--no-warn')
@no_warn = true
args.delete '--no-warn'
end
if args.include?('--skip-version-bump-check')
@skip_version_bump_check = true
args.delete '--skip-version-bump-check'
end
raise 'Usage: release [--dry-run] [--skip-version-bump-check] <version> [min_version]' if args.empty?
begin
version = Gem::Version.new(args[0])
min_version = args[1] ? args[1] : nil
rescue ArgumentError
raise "Error parsing version #{args[0]}"
end
raise "Minimum supported version is required for X.Y.0 releases\n\nUsage: release [--dry-run] <version> [min_version]" if /[0-9]\.[0-9]+\.0/ =~ version.to_s && min_version.nil?
raise "The repo #{GH_REPO} does not exist for #{GH_OWNER}" unless repo_exists?
raise 'GH_AUTHOR environment variable is not set' if GH_AUTHOR.nil?
release_changes = []
release_changes = beautify_changes changelog if can_auth?
release_a = false
release_a = release_available? "v#{version}"
puts "Bumping version to #{version}..."
bump_version version, min_version
if dry_run
puts "Existing release?: #{release_a}"
puts "New version: #{version}"
puts "Min version: #{min_version}" unless min_version.nil?
puts "Owner: #{GH_OWNER}"
puts "Repo: #{GH_REPO}"
puts "Author: #{GH_AUTHOR}"
puts "Token: #{ENV['GH_RELEASE_TOKEN'] && 'set' || 'unset'}"
puts "Base branch: #{GH_BASE_BRANCH}"
puts 'Changelog:'
if release_changes.empty?
puts ' => No new bug fixes, enhancements or features.'
else
release_changes.each { |c| puts " * #{c}" }
end
puts "Changes:"
puts `git diff --color`
`git checkout -- share/github-backup-utils/version`
`git checkout -- bin/ghe-host-check`
`git checkout -- test/testlib.sh`
exit
end
raise 'Unable to build Debian pkg: "debuild" not found.' unless can_build_deb?
raise "Release #{version} already exists." if release_a
`git fetch --quiet origin --prune`
branches = `git branch --all | grep release-#{version}$`
unless branches.empty?
out = "Release branch release-#{version} already exists. "
out += 'Branches found:'
branches.each_line { |l| out += "\n* #{l.strip.chomp}" }
raise out
end
puts 'Updating changelog...'
update_changelog release_changes, DEB_PKG_NAME, version
release_body = "Includes general improvements & bug fixes"
release_body += " and support for GitHub Enterprise Server v#{version}" unless min_version.nil?
release_changes.each do |c|
release_body += "\n* #{c}"
end
puts 'Pushing release branch and creating release PR...'
push_release_branch version
res = create_release_pr(version, "#{release_body}\n\n/cc @github/backup-utils")
puts 'Merging release PR...'
res = merge_pr res['number'], res['head']['sha'], version
puts 'Tagging and publishing release...'
tag "v#{version}", res['sha']
puts 'Creating release...'
release_title = "GitHub Enterprise Server Backup Utilities v#{version}"
res = create_release "v#{version}", GH_BASE_BRANCH, release_title, release_body, true
# Tidy up before building tarball and deb pkg
clean_up version
puts 'Building release tarball...'
package_tarball
puts 'Building Debian pkg...'
package_deb
puts 'Attaching Debian pkg and tarball to release...'
base_dir = File.expand_path(File.join(File.dirname(__FILE__), '..'))
attach_assets_to_release res['upload_url'], res['id'], ["#{base_dir}/dist/#{DEB_PKG_NAME}-v#{version}.tar.gz"]
attach_assets_to_release res['upload_url'], res['id'], ["#{base_dir}/dist/#{DEB_PKG_NAME}_#{version}_all.deb"]
puts 'Publishing release...'
publish_release res['id']
puts 'Cleaning up...'
clean_up version
puts "Updating #{GH_STABLE_BRANCH} branch..."
update_stable_branch
puts 'Released!'
rescue RuntimeError => e
$stderr.puts "Error: #{e}"
exit 1
end
end