blob: dfe3bf31195fde759ca66551adbc603c4f933fae [file] [log] [blame]
# Copyright 2021 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.
"""Publish one or more releases to channels."""
from operator import xor
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.publish import InputProperties
from RECIPE_MODULES.fuchsia.utils import pluralize
DEPS = [
"fuchsia/buildbucket_util",
"fuchsia/git",
"fuchsia/git_checkout",
"fuchsia/lkg",
"fuchsia/publish",
"fuchsia/release",
"fuchsia/sso",
"fuchsia/status_check",
"fuchsia/utils",
"recipe_engine/context",
"recipe_engine/json",
"recipe_engine/properties",
"recipe_engine/raw_io",
"recipe_engine/step",
]
PROPERTIES = InputProperties
def RunSteps(api, props):
with api.step.nest("check inputs"):
if not (
xor(bool(props.lkg_builders), bool(props.release_version))
or xor(bool(props.release_version), bool(props.target_branch))
):
api.step.empty(
"invalid version input",
step_text=(
"must specify exactly one of `lkg_builders`, "
"`release_version`, or `target_branch`"
),
status=api.step.FAILURE,
)
if (props.lkg_builders or props.target_branch) and not props.remote:
api.step.empty(
"missing remote input",
step_text=(
"`remote` must be specified if `lkg_builders` or "
"`target_branch` is"
),
status=api.step.FAILURE,
)
if props.target_branch and not api.release.validate_branch(props.target_branch):
api.step.empty(
"invalid target branch input",
step_text='`target_branch` must start with "releases/"',
status=api.step.FAILURE,
)
if props.release_version:
try:
api.release.ReleaseVersion.from_string(props.release_version)
except AssertionError as e:
raise api.step.StepFailure(str(e))
with api.step.nest("resolve checkout ref"):
checkout_ref = resolve_checkout_ref(
api,
props.lkg_builders,
props.target_branch,
props.release_version,
)
https_remote = api.sso.sso_to_https(props.remote)
checkout_dir, revision = api.git_checkout(
https_remote, revision=checkout_ref, cache=False
)
with api.step.nest("resolve release version"), api.context(cwd=checkout_dir):
# If we're computing a revision from LKG builders, compute a synthetic
# release version based on revision count (HEAD should be pointing to
# the LKG at this point). There is no use-case yet for resolving an LKG
# revision to a release version based on git tags.
if props.lkg_builders:
# TODO(fxbug.dev/89996): The synthetic release version is
# conventionally computed from rev_list_count. Move this to
# release/api.py so it can be properly shared with run_script.py.
release_version = api.release.ReleaseVersion(
major=0,
# ReleaseVersion enforces that the `date` passed to the
# constructor is 8 digits. As a hack to pass a date of 0, we
# pass a "valid" date here and then zero it by overriding the
# field afterward.
date=11111111,
release=0,
candidate=api.git.rev_list_count("HEAD"),
)
# TODO: provide a legit mechanism for creating a ReleaseVersion with
# a date of zero.
release_version.date = 0
elif props.release_version:
release_version = api.release.ReleaseVersion.from_string(
props.release_version
)
else:
release_version = api.release.ref_to_release_version(revision, checkout_dir)
if not release_version:
api.step.empty(
"invalid target branch state",
step_text="`target_branch` HEAD is not a release version",
status=api.step.FAILURE,
)
successful_channels = []
unsuccessful_channels = []
error_logs = []
step_msg = "stop rollout" if props.stop_rollout else "propose release"
for channel_info in props.channels:
step_name = "%s for %s %s to %s" % (
step_msg,
channel_info.product_shortname,
release_version,
channel_info.channel,
)
step = api.publish.release(
step_name,
channel_info.product_shortname,
release_version,
channel_info.channel,
publish_host=props.publish_host,
rollout_pct=props.rollout_pct,
urgent_update=props.urgent_update,
stop_rollout=props.stop_rollout,
switch_channel=channel_info.switch_channel,
ok_ret=(0, 1),
stderr=api.raw_io.output_text(),
)
if step.retcode:
step.presentation.status = api.step.FAILURE
unsuccessful_channels.append(channel_info)
step.presentation.logs["stderr"] = step.stderr
error_logs.append(step.stderr)
else:
step.presentation.links["proposal"] = step.json.output["proposal"]
if not props.stop_rollout:
successful_channels.append(channel_info)
# TODO(fxbug.dev/72426): Remove this behavior once go/fday no longer depends
# on it.
with api.step.nest("update channel branches"):
for channel_info in successful_channels:
update_channel_branch(
api,
https_remote,
checkout_dir,
channel_info.product_shortname,
channel_info.channel,
revision,
release_version,
)
if unsuccessful_channels:
summary_title = "failed to %s for %s, see debug logs:" % (
step_msg,
pluralize("channel", len(unsuccessful_channels)),
)
return RawResult(
summary_markdown=api.buildbucket_util.summary_message(
raw_text="\n\n".join([summary_title] + error_logs),
truncation_message="see `propose release` stderrs for full failure details",
),
status=common_pb2.FAILURE,
)
def resolve_checkout_ref(api, lkg_builders, target_branch, release_version):
if lkg_builders:
return api.lkg.revision(
step_name="get last-known-good revision",
builders=lkg_builders,
test_data="lkgrevision",
)
return target_branch or "refs/tags/releases/%s" % release_version
def update_channel_branch(
api,
https_remote,
checkout_dir,
product_shortname,
channel,
revision,
release_version,
):
"""Update channel branch under refs/heads/releases/channels/ to the given
revision. Note this will not create a new version, but merely keep a note of
the version it is equivalent to in the commit message.
Args:
https_remote (str): Remote repository in HTTPS format.
checkout_dir (Path): Path to checkout.
product_shortname (str): Product shortname.
channel (str): Channel name.
revision (str): Revision corresponding to `release_version`.
release_version (release.ReleaseVersion): Release version.
"""
product_channel = "-".join((product_shortname, channel))
channel_branch = "releases/channels/%s" % product_channel
with api.step.nest(product_channel), api.context(
cwd=checkout_dir, infra_steps=True
):
branch_head = api.git.get_remote_branch_head(
https_remote, channel_branch, step_name="get channel branch HEAD"
)
# If the channel branch exists, snap it to the current revision.
if branch_head:
api.git.fetch("origin", channel_branch)
api.git.raw_checkout(ref=branch_head)
api.git.snap_branch(
revision,
channel_branch,
"%s Snap to %s" % (api.release._COMMIT_MESSAGE_PREFIX, release_version),
path=checkout_dir,
# Use a unique temporary branch name to avoid collisions between
# snaps.
tmp_branch=product_channel,
push=False,
)
# Otherwise, create the channel branch with an empty commit.
else:
api.git.raw_checkout(ref=revision)
api.git.commit(
message="%s Initialize channel branch %s to %s"
% (api.release._COMMIT_MESSAGE_PREFIX, channel_branch, release_version),
allow_empty=True,
)
api.git.push("HEAD:refs/heads/%s" % channel_branch)
def GenTests(api):
yield (
api.status_check.test("invalid_version_input", status="failure")
+ api.properties(
channels=[{"product_shortname": "product", "channel": "channel"}],
)
)
yield (
api.status_check.test("missing_remote_input", status="failure")
+ api.properties(
channels=[{"product_shortname": "product", "channel": "channel"}],
target_branch="releases/branch",
)
)
yield (
api.status_check.test("invalid_target_branch", status="failure")
+ api.properties(
channels=[{"product_shortname": "product", "channel": "channel"}],
target_branch="invalid_branch",
remote="sso://fuchsia/integration",
)
)
yield (
api.status_check.test("invalid_target_branch_state", status="failure")
+ api.properties(
channels=[{"product_shortname": "product", "channel": "channel"}],
target_branch="releases/branch",
remote="sso://fuchsia/integration",
)
+ api.release.ref_to_release_version(
"",
nesting="resolve release version",
retcode=1,
)
)
yield (
api.status_check.test("invalid_target_version", status="failure")
+ api.properties(
release_version="1.0.0",
remote="sso://fuchsia/integration",
)
)
yield (
api.status_check.test("publish_from_target_branch")
+ api.properties(
channels=[{"product_shortname": "product", "channel": "channel"}],
rollout_pct=100,
target_branch="releases/branch",
remote="sso://fuchsia/integration",
)
+ api.release.ref_to_release_version(
"releases/1.20210101.0.1",
nesting="resolve release version",
)
+ api.step_data(
"propose release for product 1.20210101.0.1 to channel",
api.json.output({"proposal": "http://releases/proposal"}),
)
)
yield (
api.status_check.test("publish_from_lkg")
+ api.properties(
channels=[
{
"product_shortname": "product",
"channel": "channel",
}
],
lkg_builders=["project/bucket/builder"],
remote="sso://fuchsia/integration",
)
+ api.step_data(
"resolve release version.git rev-list --count",
api.raw_io.stream_output_text("1"),
)
+ api.step_data(
"propose release for product 0.0.0.1 to channel",
api.json.output({"proposal": "http://releases/proposal"}),
)
)
yield (
api.status_check.test("publish_from_release_version")
+ api.properties(
channels=[
{
"product_shortname": "product",
"channel": "channel",
"switch_channel": "switch-channel",
}
],
release_version="1.20210101.0.1",
publish_host="publish.googleapis.com",
urgent_update=True,
)
+ api.step_data(
"propose release for product 1.20210101.0.1 to channel",
api.json.output({"proposal": "http://releases/proposal"}),
)
)
yield (
api.status_check.test("stop_rollout")
+ api.properties(
channels=[{"product_shortname": "product", "channel": "channel"}],
release_version="1.20210101.0.1",
publish_host="publish.googleapis.com",
stop_rollout=True,
)
+ api.step_data(
"stop rollout for product 1.20210101.0.1 to channel",
api.json.output({"proposal": "http://releases/proposal"}),
)
)
yield (
api.status_check.test("failed_publish", status="failure")
+ api.properties(
channels=[
{"product_shortname": "product", "channel": "old-channel"},
{"product_shortname": "product", "channel": "new-channel"},
],
release_version="1.20210101.0.1",
)
+ api.step_data(
"propose release for product 1.20210101.0.1 to old-channel",
stderr=api.raw_io.output_text("old-channel not found"),
retcode=1,
)
+ api.step_data(
"propose release for product 1.20210101.0.1 to new-channel",
api.json.output({"proposal": "http://releases/proposal"}),
)
+ api.step_data(
"update channel branches.product-new-channel.get channel branch HEAD",
api.raw_io.stream_output_text(""),
)
)