Files
gitlabhq/scripts/backport_fix_to_stable_branch
2025-06-28 03:10:17 +00:00

372 lines
9.6 KiB
Ruby
Executable File

#!/usr/bin/env ruby
# frozen_string_literal: true
require 'active_support/core_ext/object/to_query'
require 'optparse'
require 'open3'
require 'rainbow/refinement'
require 'tty-prompt'
using Rainbow
class Backport
SECURITY_REPO_URLS = {
ssh: 'git@gitlab.com:gitlab-org/security/gitlab.git',
http: 'https://gitlab.com/gitlab-org/security/gitlab.git'
}.freeze
DEFAULT_OPTIONS = {
base: {
version: nil, branch: nil, sha: nil, merge_request: false, stable_branch_suffix: 'stable'
},
security: {
branch_prefix: 'security',
new_merge_request_url: 'https://gitlab.com/gitlab-org/security/gitlab/-/merge_requests/new',
merge_request_template: 'Security Fix'
},
bugfix: {
branch_prefix: 'backport',
new_merge_request_url: 'https://gitlab.com/gitlab-org/gitlab/-/merge_requests/new',
merge_request_template: 'Stable Branch'
}
}.freeze
BACKPORT_TYPE_CHOICES = [
{ name: 'Security Fix', value: :security }.freeze,
{ name: 'Bug Fix', value: :bugfix }.freeze
].freeze
def initialize
@prompt = TTY::Prompt.new(help_color: :cyan)
@options = build_options
end
attr_reader :prompt
def create!
correct_repository_check!
return dry_run if dry_run?
pick_commits
push_commits
end
private
def correct_repository_check!
if security_backport? && !pushing_to_security_remote?
abort('⛔️ Can only push security backports to the security repository ⛔️'.red)
elsif !security_backport? && pushing_to_security_remote?
abort('⛔️ Bugfixes should be pushed to the canonical repository ⛔️'.red)
end
end
def pushing_to_security_remote?
@options[:remote] == security_remote_name
end
def build_options
options = DEFAULT_OPTIONS[:base].dup
parse_options(options)
options[:sha] ||= git_head_sha
options[:branch] ||= git_current_branch
options[:remote] ||= select_git_remote
options[:version] ||= select_version
options.merge(DEFAULT_OPTIONS[confirmed_backport_type!])
end
def confirmed_backport_type!
backport_type = prompt.select('⚠️ What type of fix are you backporting? ⚠️'.red, BACKPORT_TYPE_CHOICES)
@security_backport = backport_type == :security
backport_type
end
def security_backport?
@security_backport
end
def add_security_remote
puts "⚠️ You do not have the security remote configured ⚠️".red
return attempt_to_add_security_remote if prompt.yes?('Would you like me to add the remote for you now?')
print_security_remote_config_instructions_and_exit
end
def attempt_to_add_security_remote
security_repo_url = prompt.select('Which remote format do you normally use?', git_remote_style_choices)
stdin, stdout, stderr, wait_thr = Open3.popen3("git remote add security #{security_repo_url}")
if wait_thr.value.success? # rubocop:disable Cop/LineBreakAroundConditionalBlock -- https://gitlab.com/gitlab-org/ruby/gems/gitlab-styles/-/merge_requests/258
@security_remote_name = 'security'
puts '✅ Successfully created `security` remote'.white
else
puts "⛔️ Could not create `security` remote ⛔️".red
puts ('-' * 80).red
puts "\n#{stderr.read}".red
puts ('-' * 80).red
print_security_remote_config_instructions_and_exit
end
ensure
stdin.close
stdout.close
stderr.close
end
def print_security_remote_config_instructions_and_exit
puts "Please add the gitlab security remote to git with:".white
puts "git remote add security #{SECURITY_REPO_URLS[:ssh]}".cyan
puts "or".white
puts "git remote add security #{SECURITY_REPO_URLS[:http]}".cyan
puts "and then try again".white
exit 1
end
def parse_options(options)
parser = OptionParser.new do |opts|
opts.banner = <<~BANNER
Usage: #{$PROGRAM_NAME} [options]
This tool requires confirmation for the backport type and will prompt
for the remote and version unless specified.
BANNER
opts.on('-v', '--version 10.0', 'Version to target (opens prompt if not passed)') do |version|
options[:version] = version&.tr('.', '-')
end
opts.on('-r', '--remote dev', "Git remote name of repository (opens prompt if not passed)") do |remote|
options[:remote] = remote
end
opts.on('-b', '--branch branch-name', 'Original branch name (optional, defaults to current branch)') do |branch|
options[:branch] = branch
end
opts.on('-s', '--sha abcd', 'SHA or SHA range to cherry pick (optional, defaults to HEAD SHA)') do |sha|
options[:sha] = sha
end
opts.on('--mr', '--merge-request', 'Create a Merge Request targeting the stable branch') do
options[:merge_request] = true
end
opts.on('-d', '--dry-run', 'Display the Git commands this script will run without calling them') do
options[:try] = true
end
opts.on('-h', '--help', 'Displays Help') do
puts opts
exit
end
end
parser.parse!
end
def git_head_sha
`git rev-parse HEAD`.strip
end
def git_current_branch
`git rev-parse --abbrev-ref HEAD`.strip
end
def select_git_remote
prompt.select("Which remote do you want to push to?", remote_choices)
end
def git_remotes
@git_remotes ||= `git remote -v`.strip.split("\n").each_with_object({}) do |line, output|
name, url, _type = line.split(/\s+/)
output[name] = url
end
end
def security_remote_name
return @security_remote_name if defined?(@security_remote_name)
@security_remote_name = git_remotes.find { |_k, url| SECURITY_REPO_URLS.value?(url) }&.first
add_security_remote if @security_remote_name.nil?
@security_remote_name
end
# Sorts git remotes so the security remote is first in the list
def remote_choices
[security_remote_name] + (git_remotes.keys - [security_remote_name])
end
def git_remote_style_choices
SECURITY_REPO_URLS.map { |style, url| { name: "#{style}: #{url}", value: url } }
end
def select_version
prompt.select('Which version are you targeting?', version_choices)
end
def current_version
version_filepath = File.join(File.dirname($PROGRAM_NAME), '../VERSION')
full_version = File.read(version_filepath)
major, minor, _rest = full_version.split('.')
[major.to_i, minor.to_i]
end
def version_choices
major, minor = current_version
Array.new(3) do
minor -= 1
if minor < 0
major -= 1
minor = 11
end
{ name: "#{major}.#{minor}", value: "#{major}-#{minor}" }
end
end
def dry_run?
@options[:try] == true
end
def dry_run
puts "\nGit commands:".blue
puts git_commands.join("\n")
puts "\nMerge request URL:".blue
puts new_merge_request_url
end
def pick_commits
cmd = git_pick_commands.join(' && ')
stdin, stdout, stderr, wait_thr = Open3.popen3(cmd)
puts stdout.read.green
puts stderr.read.red
unless wait_thr.value.success? # rubocop:disable Cop/LineBreakAroundConditionalBlock -- https://gitlab.com/gitlab-org/ruby/gems/gitlab-styles/-/merge_requests/258
puts <<~MSG
It looks like cherry pick failed!
Open a new terminal and fix the conflicts.
Once fixed run `git cherry-pick --continue`
After you are done, return here and continue. (Press n to cancel)
Ready to continue? (Y/n)
MSG
unless ['', 'Y', 'y'].include?(gets.chomp)
puts "\nRemaining git commands:".blue
puts 'git cherry-pick --continue'
puts git_push_commands.join("\n")
exit 1
end
end
ensure
stdin.close
stdout.close
stderr.close
end
def push_commits
cmd = git_push_commands.join(' && ')
stdin, stdout, stderr, wait_thr = Open3.popen3(cmd)
puts stdout.read.green
puts stderr.read.red
puts new_merge_request_url.blue if wait_thr.value.success? && !@options[:merge_request]
ensure
stdin.close
stdout.close
stderr.close
end
def git_pick_commands
[
fetch_stable_branch,
create_backport_branch,
cherry_pick_commit
]
end
def git_push_commands
[
push_to_remote,
checkout_original_branch
]
end
def git_commands
git_pick_commands + git_push_commands
end
def fetch_stable_branch
"git fetch #{@options[:remote]} #{stable_branch}"
end
def create_backport_branch
"git checkout -B #{source_branch} #{@options[:remote]}/#{stable_branch} --no-track"
end
def cherry_pick_commit
"git cherry-pick #{@options[:sha]}"
end
def push_to_remote
[
"git push #{@options[:remote]} #{source_branch} --no-verify",
*merge_request_push_options
].join(' ')
end
def checkout_original_branch
"git checkout #{@options[:branch]}"
end
def gitlab_params
{
issuable_template: @options[:merge_request_template],
merge_request: {
source_branch: source_branch,
target_branch: stable_branch
}
}
end
def merge_request_push_options
return [] unless @options[:merge_request]
[
"-o mr.create",
"-o mr.target='#{stable_branch}'",
"-o mr.description='Please apply `#{@options[:merge_request_template]}` template.'",
"-o mr.milestone='#{@options[:version].tr('-', '.')}'"
]
end
def source_branch
branch = "#{@options[:branch]}-#{@options[:version]}"
branch = "#{@options[:branch_prefix]}-#{branch}" unless branch.start_with?("#{@options[:branch_prefix]}-")
branch
end
def stable_branch
"#{@options[:version]}-#{@options[:stable_branch_suffix]}-ee"
end
def new_merge_request_url
"#{@options[:new_merge_request_url]}?#{gitlab_params.to_query}"
end
end
Backport.new.create!