| # 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 |
| |
| PYTHON_VERSION_COMPATIBILITY = "PY3" |
| |
| 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, |
| ) |
| |
| 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("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(""), |
| ) |
| ) |