lib/release_tools/public_release/release.rb (155 lines of code) (raw):
# frozen_string_literal: true
module ReleaseTools
module PublicRelease
# rubocop: disable Metrics/ModuleLength
# An interface for API release classes.
#
# This interface/module provides a variety of default methods, and methods
# that an including class must implement. By implementing these methods,
# release classes can reuse behaviour (such as creating a tag) that is the
# same for all release classes, while still being able to override behaviour
# where necessary.
#
# This module must not dictate the implementation of releases, beyond
# requiring some methods that have to be implemented to use the methods
# provided by this module.
#
# For example, the `execute` method in this module is deliberately left
# empty (besides raising an error). If this method were to instead call
# other methods (e.g. a method for compiling a changelog), releases classes
# would have to start overwriting methods the moment they want to change
# this behaviour. Such tight coupling can make for code that is hard to
# debug, understand, test, and extend.
#
# In other words, all this module should do is:
#
# 1. Provide a public interface (but _not_ an implementation) for release
# classes.
# 2. Provide some default methods that most release classes will need. As a
# rule of thumb, don't add a method here unless it's used by three or
# more release classes.
module Release
PRODUCTION_ENVIRONMENT = 'gprd'
STAGING_ENVIRONMENT = 'gstg'
DifferentCommitsError = Class.new(StandardError)
def self.included(into)
into.include(::SemanticLogger::Loggable)
end
def release_metadata
raise NotImplementedError
end
def version
raise NotImplementedError
end
def project
raise NotImplementedError
end
def client
raise NotImplementedError
end
def execute
raise NotImplementedError
end
def project_path
project.canonical_or_security_path
end
def tag_name
version.tag
end
def source_for_target_branch
last_production_commit
end
def target_branch
version.stable_branch
end
def create_target_branch
source = source_for_target_branch
logger.info(
'Creating target branch',
project: project_path,
source: source,
branch: target_branch
)
unless SharedStatus.dry_run?
client.find_or_create_branch(target_branch, source, project_path)
end
notify_stable_branch_creation
end
def notify_slack(project, version)
path = project.canonical_or_security_path
tag = omnibus?(project) ? version : version.tag
logger.info('Notifying Slack', project: path, version: version)
Slack::TagNotification.release(project, tag)
end
def omnibus?(project)
project == Project::OmnibusGitlab
end
# Fetches the current production commit for a GitLab component, it uses two
# sources for this:
#
# - Deployment tracker through `last_production_commit_deployments`, and,
# - release/metadata through `last_production_commit_metadata`.
#
# The release/metadata is being rolled out on
# https://gitlab.com/gitlab-com/gl-infra/delivery/-/issues/2607, and until
# it's safely tested this method returns the sha from the deployment tracker.
#
# Changes under the release/metadata implementation are under a feature flag.
def last_production_commit
sha_deployments = last_production_commit_deployments
return sha_deployments unless Feature.enabled?(:release_metadata_as_source)
sha_metadata = last_production_commit_metadata
if sha_metadata == sha_deployments
logger.info('Commits are the same', sha_metadata: sha_metadata, sha_deployments: sha_deployments)
else
logger.error('Commits are not the same', sha_metadata: sha_metadata, sha_deployments: sha_deployments)
raise DifferentCommitsError
end
sha_deployments
rescue DifferentCommitsError
sha_deployments
end
def last_production_commit_deployments
env = PRODUCTION_ENVIRONMENT
# Branches are to always be created on the public projects, using the
# deployments tracked in those projects. To ensure this we use the
# public project path. This way even during a patch release we fetch
# this data from the public project.
sha = client
.deployments(project.path, env, status: 'success').first&.sha
unless sha
raise "The project #{project_path} has no deployments for #{env}. " \
'If this project does not use GitLab deployments, re-define the ' \
'source_for_target_branch instance method to return the source ' \
'branch name'
end
logger.info('Last production commit via project deployments', project: project.path, sha: sha)
sha
end
def last_production_commit_metadata
sha = current_gprd_product_version[project.metadata_project_name].sha
logger.info('Last production commit via metadata', project: project.path, sha: sha)
sha
end
# Update one or more VERSION files.
#
# The `versions` argument is a `Hash` that maps version file names (e.g.
# `VERSION` or `GITALY_SERVER_VERSION`) to their new version identifiers.
# For example:
#
# { 'VERSION' => '1.2.3', 'GITALY_SERVER_VERSION' => '2.3.4' }
#
# By default this method will trigger pipelines for the commit it creates,
# as this is the safest default. To disable this, set `skip_ci` to `true`.
# rubocop: disable Metrics/ParameterLists
def commit_version_files(
branch,
versions,
message: 'Update VERSION files',
project: project_path,
skip_ci: false,
skip_merge_train: false
)
actions = versions.each_with_object([]) do |(file, version), memo|
action =
begin
# We strip newlines from both the old file contents and the new
# contents. This ensures that if the old file contains a trailing
# newline, and the new content does as well, we don't create empty
# commits. This _does_ happen if we compare the existing stripped
# content with the new content as-is, provided said new content
# contains a trailing newline.
if client.file_contents(project, file, branch).strip != version.strip
'update'
end
rescue Gitlab::Error::NotFound
'create'
end
next unless action
logger.info(
"Setting version file #{file} to #{version.tr("\n", ' ')}",
project: project,
action: action,
file: file,
version: version,
branch: branch
)
memo << { action: action, file_path: file, content: version }
end
return if actions.empty?
tags = []
tags << '[ci skip]' if skip_ci
tags << '[merge-train skip]' if skip_merge_train
message += "\n\n#{tags.join("\n")}" if tags.any?
client.create_commit(project, branch, message, actions)
end
# rubocop: enable Metrics/ParameterLists
# Returns the GitLab components metadata based on the last successful
# production deployment tracked in the release/metadata project in OPS. The
# metadata is retrieved via ProductVersion
def current_gprd_product_version
ops_client = GitlabOpsClient
release_metadata = Project::Release::Metadata
last_deployment = ops_client
.deployments(
release_metadata.path,
PRODUCTION_ENVIRONMENT,
status: 'success'
)
.first
ProductVersion.from_metadata_sha(last_deployment.sha)
end
def notify_stable_branch_creation
logger.info('Checking if project and version are eligible for stable branch creation notification', project: project, version: version)
# We only want to notify projects under Managed Versioning.
return unless ReleaseTools::ManagedVersioning::PROJECTS.include?(project)
# Stable branches are created when the RC for the monthly release is created.
return unless version.rc?
stable_branch_name = ReleaseTools::ReleaseManagers::Schedule
.new
.active_version
.stable_branch(ee: project.ee_branch?)
logger.info('Checking if target branch is the stable branch for current monthly release', stable_branch_name: stable_branch_name, target_branch: target_branch)
return unless target_branch == stable_branch_name
# Make sure the new stable branch exists
return unless GitlabClient.find_branch(stable_branch_name, project_path)
Security::NotifyStableBranchCreation
.new(Security::IssueCrawler.new, project, stable_branch_name)
.execute
end
end
# rubocop: enable Metrics/ModuleLength
end
end