blob: 652a3f8fb0d0939f4504e61d40911bcfdd98cc08 [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
- Prepare-branch: Short-circuit after ensuring project's branch exists.
- Dryrun: Run recipe without modifying anything remotely.
"""
from 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
DEPS = [
"fuchsia/checkout",
"fuchsia/gerrit",
"fuchsia/git",
"fuchsia/jiri",
"fuchsia/release",
"fuchsia/sso",
"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 cherry-pick {change_urls}"
"\n\n{commit_msgs}"
)
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 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=(
f"target branch {props.target_branch} does not exist"
),
status=common_pb2.FAILURE,
)
manifest = props.manifest
# The default checkout doesn't include the recipes repo, so it's necessary
# to use a non-default manifest that does include the recipes repo in order
# for recipes cherry-picks to work.
# TODO(olivernewman): This is a hack, the better solution would be to add a
# new Jiri manifest to integration.git that includes all existing repos as
# well as the recipes manifest, and configure all builders that use this
# recipe to use that manifest.
if props.target_project == "infra/recipes":
manifest = "infra/recipes"
# 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,
host=urlparse(https_remote).hostname,
),
),
project=props.project,
manifest=manifest,
remote=props.remote,
# Skip for faster checkout.
fetch_packages=False,
)
integration_root = checkout.project_path(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,
candidate_version_increment=props.candidate_version_increment,
)
if api.git.get_remote_tag(url=https_remote, tag=release_version.tag_name):
return RawResult(
summary_markdown=(
f"release version conflict: attempting increment would "
f"conflict with existing tag {release_version.tag_name}, "
f"indicating branch {props.target_branch} has already "
f"moved forward"
),
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 info in project_list:
if api.sso.sso_to_https(info["remote"]) == props.target_remote:
project_info = info
break
if not project_info:
return RawResult(
summary_markdown=(
f"invalid project input: project {props.target_project} "
f"with remote {props.target_remote} is not in the checkout"
),
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",
}
),
).json.output
change_revision = change_details["current_revision"]
change_info.change_url = (
f"{gerrit_host}/c/{props.target_project}/+/{change_info.change_id}"
)
if change_info.revision != change_revision:
return RawResult(
summary_markdown=(
f"revision integrity check failed: specified revision "
f"{change_info.revision} is not equal to the resolved "
f"change revision {change_revision}, indicating that "
f"{change_info.change_url} may have been updated "
f"recently"
),
status=common_pb2.FAILURE,
)
with api.step.nest(
f"apply {pluralize('cherry-pick', len(props.changes))}"
), api.context(cwd=api.path.abs_to_path(project_info["path"])):
commit_msgs = []
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:
api.git.cherry_pick(change_info.revision)
# On failure, raise a helpful exception.
except api.step.StepFailure:
return RawResult(
summary_markdown=f"cannot cleanly cherry-pick {change_info.change_url} revision {change_info.revision} onto base revision {base_revision} on branch {props.target_branch}",
status=common_pb2.FAILURE,
)
commit_msgs.append(
api.git.get_commit_message(commit=change_info.revision)
)
api.git.push(
refs=[f"HEAD:refs/heads/{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),
change_urls=", ".join((c.change_url for c in props.changes)),
commit_msgs="\n\n".join(commit_msgs),
)
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=[
f"HEAD:refs/heads/{props.target_branch}",
f"refs/tags/{release_version.tag_name}",
],
atomic=True,
dryrun=props.dryrun,
)
api.release.set_output_properties(
presentation,
release_revision,
release_version,
https_remote,
)
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_target_project = "test-project"
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", api.raw_io.stream_output_text("")
)
def properties(**kwargs):
defaults = {
"bug": "123456",
"changes": [{"revision": "foo", "change_id": "Ia123b"}],
"target_project": default_target_project,
"target_remote": "https://fuchsia.googlesource.com/test-project",
"target_branch": "releases/targetbranch",
"project": "integration",
"manifest": "integration",
"remote": "sso://fuchsia/integration",
}
return api.properties(**{**defaults, **kwargs})
project_info = api.jiri.read_manifest_element(
default_target_project,
test_output={
"manifest": "fuchsia/manifest",
"path": default_target_project,
"gerrithost": "https://fuchsia-review.googlesource.com",
},
nesting="get project info",
)
merge_conflict = api.step_data(
"apply 1 cherry-pick.Ia123b.git cherry-pick", retcode=1
)
yield (api.test("missing_changes", status="FAILURE") + properties(changes=None))
yield (
api.test("missing_target_project", status="FAILURE")
+ properties(target_project=None)
)
yield (
api.test("missing_target_remote", status="FAILURE")
+ properties(target_remote=None)
)
yield (
api.test("invalid_branch_input", status="FAILURE")
+ properties(
target_branch="invalidbranch",
)
)
yield (
api.test("branch_does_not_exist", status="FAILURE")
+ properties()
+ api.step_data(
"check inputs.get target branch HEAD",
api.raw_io.stream_output_text(""),
)
)
yield (
api.test("release_version_conflict", status="FAILURE")
+ properties()
+ branch_version
)
yield (
api.test("invalid_project_input", status="FAILURE")
+ properties()
+ branch_version
+ version_does_not_exist
+ api.step_data("get project info.jiri project", api.jiri.project([]))
)
yield (
api.test("failed_integrity_check", status="FAILURE")
+ properties(
changes=[{"revision": "notcurrent", "change_id": "Ia123b"}],
)
+ branch_version
+ version_does_not_exist
+ project_info
)
yield (
api.test("cherry_pick")
+ properties(downstream_builders=["a/b/c"])
+ branch_version
+ version_does_not_exist
+ project_info
)
yield (
api.test("cherry_pick_recipes")
+ properties(
target_project="infra/recipes",
target_remote="https://fuchsia.googlesource.com/infra/recipes",
)
+ branch_version
+ version_does_not_exist
+ api.jiri.read_manifest_element(
"infra/recipes",
test_output={
"manifest": "fuchsia/manifest",
"path": "infra/recipes",
"gerrithost": "https://fuchsia-review.googlesource.com",
},
nesting="get project info",
)
)
yield (
api.test("merge_conflict", status="FAILURE")
+ properties()
+ branch_version
+ version_does_not_exist
+ project_info
+ merge_conflict
)