blob: 0bf3095b4a6c70f434b16f23ec856f00e4115f4f [file] [log] [blame]
# Copyright 2020 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Cherry-pick one or more changes onto an integration.git release branch.
Inputs
- Project: The project to apply cherry-pick(s) to.
- Change(s):
- Revision: The revision to cherry-pick.
- Change-Id: The Gerrit change ID to cherry-pick.
Options
- Revert: Revert instead of cherry-picking.
- Prepare-branch: Short-circuit after ensuring project's branch exists.
- Dryrun: Run recipe without modifying anything remotely.
"""
from future.moves.urllib.parse import urlparse
from RECIPE_MODULES.fuchsia.utils import pluralize
from PB.go.chromium.org.luci.buildbucket.proto import build as build_pb2
from PB.go.chromium.org.luci.buildbucket.proto import common as common_pb2
from PB.recipe_engine.result import RawResult
from PB.recipes.fuchsia.release.cherry_pick import InputProperties
PYTHON_VERSION_COMPATIBILITY = "PY3"
DEPS = [
"fuchsia/checkout",
"fuchsia/gerrit",
"fuchsia/git",
"fuchsia/jiri",
"fuchsia/release",
"fuchsia/sso",
"fuchsia/status_check",
"fuchsia/utils",
"recipe_engine/context",
"recipe_engine/json",
"recipe_engine/path",
"recipe_engine/properties",
"recipe_engine/raw_io",
"recipe_engine/step",
]
PROPERTIES = InputProperties
COMMIT_MESSAGE = (
"{prefix} Update {project} to {revision} after {git_subcmd} {change_urls}"
"\n\n{commit_msgs}\n\nBug: {bug}"
)
def RunSteps(api, props):
with api.step.nest("check inputs"):
if not props.changes:
return RawResult(
summary_markdown="`changes` must be specified",
status=common_pb2.FAILURE,
)
if not props.target_project:
return RawResult(
summary_markdown="`target_project` must be specified",
status=common_pb2.FAILURE,
)
if not props.target_remote:
return RawResult(
summary_markdown="`target_remote` must be specified",
status=common_pb2.FAILURE,
)
if not props.bug:
return RawResult(
summary_markdown="`bug` must be specified",
status=common_pb2.FAILURE,
)
if not api.release.validate_branch(props.target_branch):
return RawResult(
summary_markdown='`target_branch` must start with "releases/"',
status=common_pb2.FAILURE,
)
# Assert target branch exists remotely.
https_remote = api.sso.sso_to_https(props.remote)
branch_revision = api.git.get_remote_branch_head(
step_name="get target branch HEAD",
url=https_remote,
branch=props.target_branch,
)
if not branch_revision:
return RawResult(
summary_markdown="target branch %s does not exist"
% props.target_branch,
status=common_pb2.FAILURE,
)
# Checkout at branch revision.
checkout = api.checkout.fuchsia_with_options(
build_input=build_pb2.Build.Input(
gitiles_commit=common_pb2.GitilesCommit(
id=branch_revision,
project=props.project,
),
),
project=props.project,
manifest=props.manifest,
remote=props.remote,
# Skip for faster checkout.
fetch_packages=False,
)
integration_root = api.release.get_project_path(checkout.root_dir, props.project)
with api.step.nest("resolve release version"):
# Attempt to increment candidate version.
release_version = api.release.get_next_candidate_version(
ref=branch_revision,
repo_path=integration_root,
)
if api.git.get_remote_tag(url=https_remote, tag=release_version.tag_name):
return RawResult(
summary_markdown="release version conflict: attempting "
"increment would conflict with existing tag %s, indicating "
"branch %s has already moved forward"
% (release_version.tag_name, props.target_branch),
status=common_pb2.FAILURE,
)
with api.step.nest("get project info"), api.context(cwd=integration_root):
project_list = api.jiri.project(
projects=[props.target_project],
list_remote=True,
).json.output
# Under most circumstances, the project name is unique, so we just use
# the first and only element in these cases. However, it is possible for
# the project name to be duplicated, so we use the `target_remote`
# property to disambiguate.
project_info = None
for project_info in project_list:
if api.sso.sso_to_https(project_info["remote"]) == props.target_remote:
break
if not project_info:
return RawResult(
summary_markdown="invalid project input: project %s with remote "
"%s is not in the checkout"
% (props.target_project, props.target_remote),
status=common_pb2.FAILURE,
)
# Run integrity checks: ensure `revision` is equivalent to the current
# revision for `change_id`, and ensure that the change is approved.
gerrit_host = api.jiri.read_manifest_element(
project_info["manifest"], "project", props.target_project
)["gerrithost"]
for change_info in props.changes:
change_details = api.gerrit.change_details(
name="get change details",
change_id=change_info.change_id,
host=urlparse(gerrit_host).netloc,
query_params=["CURRENT_REVISION"],
test_data=api.json.test_api.output(
{
"current_revision": "foo",
"labels": {
"Code-Review": {
"approved": {
"email": "approver@google.com",
}
},
},
}
),
).json.output
change_revision = change_details["current_revision"]
change_info.change_url = "%s/c/%s/+/%s" % (
gerrit_host,
props.target_project,
change_info.change_id,
)
# TODO(atyfto): Restrict this further by checking for whether the
# change is submittable. We do not yet want to enforce this because
# it will disallow cherry-picking already-merged CLs e.g. from main.
if (
not change_details.get("labels", {})
.get("Code-Review", {})
.get("approved")
):
return RawResult(
summary_markdown="%s does not have Code-Review approval"
% change_info.change_url,
status=common_pb2.FAILURE,
)
if change_info.revision != change_revision:
return RawResult(
summary_markdown="revision integrity check failed: "
"specified revision %s is not equal to the resolved change "
"revision %s, indicating that %s may have been updated "
"recently"
% (change_info.revision, change_revision, change_info.change_url),
status=common_pb2.FAILURE,
)
# Apply cherry-pick or revert.
git_subcmd = "revert" if props.revert_mode else "cherry-pick"
with api.step.nest(
"apply %s" % pluralize(git_subcmd, len(props.changes))
), api.context(cwd=api.path.abs_to_path(project_info["path"])):
commit_msgs = []
# If the project branch doesn't already exist, initialize it. This
# ensures that even if the cherry-pick has a merge conflict, the
# "cherry-pick" feature in Gerrit will not fail due to the branch not
# yet existing.
if not api.git.get_remote_branch_head(
step_name="check project branch exists",
url=api.sso.sso_to_https(project_info["remote"]),
branch=props.target_branch,
):
api.git.push(
step_name="initialize project branch",
refs=["HEAD:refs/heads/%s" % props.target_branch],
dryrun=props.dryrun,
)
# This option may be used to short-circuit execution when we only want
# to initialize the project branch without attempting to cherry-pick.
# This provides the base branch for contributors to author new changes
# on.
if props.prepare_branch:
return RawResult(
summary_markdown="%s branch %s ready to accept changes"
% (props.target_project, props.target_branch),
status=common_pb2.SUCCESS,
)
for change_info in props.changes:
with api.step.nest(str(change_info.change_id)):
base_revision = api.git.get_hash()
# Ensure revision is fetched.
api.git.fetch("origin", refspec=change_info.revision)
try:
if props.revert_mode:
api.git.revert(change_info.revision)
else:
api.git.cherry_pick(change_info.revision)
# On failure, raise a helpful exception.
except api.step.StepFailure:
return RawResult(
summary_markdown="cannot cleanly %s %s revision %s "
"onto base revision %s on branch %s"
% (
git_subcmd,
change_info.change_url,
change_info.revision,
base_revision,
props.target_branch,
),
status=common_pb2.FAILURE,
)
commit_msgs.append(
api.git.get_commit_message(
commit=change_info.revision, oneline=True
)
)
api.git.push(
refs=["HEAD:refs/heads/%s" % props.target_branch],
dryrun=props.dryrun,
)
new_revision = api.git.get_hash()
# On successful cherry-pick, update project pin in integration.
with api.context(cwd=integration_root), api.step.nest(
"update integration"
) as presentation:
if props.target_project != props.project:
api.jiri.init()
api.jiri.edit_manifest(
manifest=project_info["manifest"],
projects=[(project_info["name"], new_revision)],
)
update_message = COMMIT_MESSAGE.format(
prefix=api.release._COMMIT_MESSAGE_PREFIX,
project=props.target_project,
revision=api.release.shorten_revision(new_revision),
git_subcmd=git_subcmd,
change_urls=", ".join((c.change_url for c in props.changes)),
commit_msgs="\n".join(commit_msgs),
bug=props.bug,
)
api.git.commit(
message=update_message,
all_tracked=True,
)
with api.context(infra_steps=True):
release_revision = api.git.get_hash()
api.git.tag(release_version.tag_name, step_name="tag release")
# Push commit and release version.
api.git.push(
step_name="push release",
refs=[
"HEAD:refs/heads/%s" % props.target_branch,
"refs/tags/%s" % release_version.tag_name,
],
atomic=True,
dryrun=props.dryrun,
)
api.release.set_output_properties(
presentation, release_revision, release_version
)
if not props.dryrun and props.downstream_builders:
api.release.lookup_builds(
builders=props.downstream_builders,
revision=release_revision,
remote=https_remote,
)
def GenTests(api):
default_change_id = "Ia123b"
default_changes = [{"revision": "foo", "change_id": default_change_id}]
default_bug = "123456"
default_target_project = "test-project"
default_target_remote = "https://fuchsia.googlesource.com/test-project"
default_project = "integration"
default_manifest = "integration"
default_remote = "sso://fuchsia/integration"
default_branch = "releases/targetbranch"
empty_output = api.raw_io.stream_output_text("")
branch_does_not_exist = api.step_data(
"check inputs.get target branch HEAD", empty_output
)
branch_version = api.release.ref_to_release_version(
"releases/0.20200101.0.1",
nesting="resolve release version",
)
version_does_not_exist = api.step_data(
"resolve release version.get remote tag", empty_output
)
project_info = api.jiri.read_manifest_element(
"integration_root",
"project",
default_target_project,
test_output={
"manifest": "fuchsia/manifest",
"path": default_target_project,
"gerrithost": "https://fuchsia-review.googlesource.com",
},
nesting="get project info",
)
no_code_review = api.step_data(
"get project info.get change details",
api.json.output({"current_revision": "foo"}),
)
project_branch_does_not_exist = api.step_data(
"apply 1 cherry-pick.check project branch exists", empty_output
)
merge_conflict = api.step_data(
"apply 1 cherry-pick.Ia123b.git cherry-pick", retcode=1
)
yield (
api.status_check.test("missing_changes", status="failure") + api.properties()
)
yield (
api.status_check.test("missing_target_project", status="failure")
+ api.properties(changes=default_changes)
)
yield (
api.status_check.test("missing_target_remote", status="failure")
+ api.properties(changes=default_changes, target_project=default_target_project)
)
yield (
api.status_check.test("missing_bug_input", status="failure")
+ api.properties(
changes=default_changes,
target_project=default_target_project,
target_remote=default_target_remote,
)
)
yield (
api.status_check.test("invalid_branch_input", status="failure")
+ api.properties(
changes=default_changes,
bug=default_bug,
target_project=default_target_project,
target_remote=default_target_remote,
target_branch="invalidbranch",
)
)
yield (
api.status_check.test("branch_does_not_exist", status="failure")
+ api.properties(
changes=default_changes,
bug=default_bug,
target_project=default_target_project,
target_remote=default_target_remote,
target_branch=default_branch,
)
+ branch_does_not_exist
)
yield (
api.status_check.test("release_version_conflict", status="failure")
+ api.properties(
changes=default_changes,
bug=default_bug,
target_project=default_target_project,
target_remote=default_target_remote,
target_branch=default_branch,
project=default_project,
manifest=default_manifest,
remote=default_remote,
)
+ branch_version
)
yield (
api.status_check.test("invalid_project_input", status="failure")
+ api.properties(
changes=default_changes,
bug=default_bug,
target_project=default_target_project,
target_remote=default_target_remote,
target_branch=default_branch,
project=default_project,
manifest=default_manifest,
remote=default_remote,
)
+ branch_version
+ version_does_not_exist
+ api.step_data("get project info.jiri project", api.jiri.project([]))
)
yield (
api.status_check.test("failed_integrity_check", status="failure")
+ api.properties(
changes=[{"revision": "notcurrent", "change_id": default_change_id}],
bug=default_bug,
target_project=default_target_project,
target_remote=default_target_remote,
target_branch=default_branch,
project=default_project,
manifest=default_manifest,
remote=default_remote,
)
+ branch_version
+ version_does_not_exist
+ project_info
)
yield (
api.status_check.test("no_code_review", status="failure")
+ api.properties(
changes=default_changes,
bug=default_bug,
target_project=default_target_project,
target_remote=default_target_remote,
target_branch=default_branch,
project=default_project,
manifest=default_manifest,
remote=default_remote,
)
+ branch_version
+ version_does_not_exist
+ project_info
+ no_code_review
)
yield (
api.status_check.test("prepare_branch")
+ api.properties(
changes=default_changes,
bug=default_bug,
target_project=default_target_project,
target_remote=default_target_remote,
target_branch=default_branch,
project=default_project,
manifest=default_manifest,
remote=default_remote,
prepare_branch=True,
)
+ branch_version
+ version_does_not_exist
+ project_info
)
yield (
api.status_check.test("cherry_pick")
+ api.properties(
changes=default_changes,
bug=default_bug,
target_project=default_target_project,
target_remote=default_target_remote,
target_branch=default_branch,
project=default_project,
manifest=default_manifest,
remote=default_remote,
downstream_builders=["a/b/c"],
)
+ branch_version
+ version_does_not_exist
+ project_info
)
yield (
api.status_check.test("merge_conflict", status="failure")
+ api.properties(
changes=default_changes,
bug=default_bug,
target_project=default_target_project,
target_remote=default_target_remote,
target_branch=default_branch,
project=default_project,
manifest=default_manifest,
remote=default_remote,
)
+ branch_version
+ version_does_not_exist
+ project_info
+ project_branch_does_not_exist
+ merge_conflict
)
yield (
api.status_check.test("revert")
+ api.properties(
changes=default_changes,
bug=default_bug,
target_project=default_target_project,
target_remote=default_target_remote,
target_branch=default_branch,
project=default_project,
manifest=default_manifest,
remote=default_remote,
revert_mode=True,
)
+ branch_version
+ version_does_not_exist
+ project_info
)