tools/release/publish_release.py (453 lines of code) (raw):
#!/usr/bin/env python3
# Copyright (c) Facebook, Inc. and its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import argparse
import datetime
import logging
import logging.config
import os
import shutil
import subprocess
import sys
import tempfile
from platforms.chocolatey import build_chocolatey, publish_chocolatey
from platforms.common import ReleaseException, docker, run
from platforms.debian import build_deb
from platforms.homebrew import (
build_bottle,
log_about_manual_tap_push,
publish_tap_changes,
validate_tap,
)
from releases import (
add_assets,
create_new_release,
get_all_releases,
get_current_user,
get_release_for_tag,
get_token,
)
TARGET_MACOS_VERSION = "yosemite"
TARGET_MACOS_VERSION_SPEC = TARGET_MACOS_VERSION
def parse_args(args):
parser = argparse.ArgumentParser("Publish releases of buck to github")
parser.add_argument(
"--valid-git-upstreams",
default=(
"git@github.com:facebook/buck.git",
"https://github.com/facebook/buck.git",
),
nargs="+",
help="List of valid upstreams for the git repository in order to publish",
)
parser.add_argument(
"--github-token-file",
default=os.path.expanduser("~/.buck-github-token"),
help="A file containing the github token to use",
)
parser.add_argument(
"--github-token",
help="If provided, use this github token instead of the one in `--github-token-file`",
)
parser.add_argument(
"--repository",
default="facebook/buck",
help="The github repository to operate on",
)
parser.add_argument(
"--tap-repository", default="facebook/fb", help="The tap to use for homebrew"
)
parser.add_argument(
"--version",
default=datetime.datetime.now().strftime("%Y.%m.%d.01"),
help=(
"Version to use in git tags and github releases. This is generated "
"by default"
),
)
parser.add_argument(
"--use-existing-release",
action="store_true",
help=(
"If specified, use an existing release (specified by --version), rather "
"than pushing tags and creating a new release"
),
)
parser.add_argument(
"--release-message",
help=(
"If specified, use this for the release message. If not specified, "
"and a new release is created, user will be prompted for a message"
),
)
parser.add_argument(
"--no-prompt-for-message",
help="If set, use a default message rather than prompting for a message",
action="store_false",
dest="prompt_for_message",
)
parser.add_argument(
"--no-build-deb",
dest="build_deb",
action="store_false",
help="Do not build deb packages for this release",
)
parser.add_argument(
"--no-build-homebrew",
dest="build_homebrew",
action="store_false",
help="Do not build homebrew packages for this release",
)
parser.add_argument(
"--no-build-chocolatey",
dest="build_chocolatey",
action="store_false",
help="Do not build chocolatey packages for this release",
)
parser.add_argument(
"--deb-file",
help="Upload this file as the deb for this release. Implies --no-build-deb",
)
parser.add_argument(
"--homebrew-file",
help="Upload this file as the bottle for this release. Implies --no-build-homebrew",
)
parser.add_argument(
"--chocolatey-file",
help="Upload this file as the nupkg for this release. Implies --no-build-chocolatey",
)
parser.add_argument(
"--docker-linux-host",
help="If provided, the docker:port to connect to to build linux images",
)
parser.add_argument(
"--docker-windows-host",
help="If provided, the docker:port to connect to to build windows images",
)
parser.add_argument(
"--docker-windows-memory",
default="4g",
help="The memory argument to pass to docker for windows containers",
)
parser.add_argument(
"--docker-windows-isolation",
default="process",
help="The --isolation= argument for windows docker commands",
)
parser.add_argument(
"--keep-temp-files",
action="store_true",
help="Keep temporary files regardless of success/failure",
)
parser.add_argument(
"--no-upload-assets",
dest="upload_assets",
action="store_false",
help="Do not upload assets",
)
parser.add_argument(
"--homebrew-target-macos-version",
default=TARGET_MACOS_VERSION,
help="The target macos version to use in homebrew specs",
)
parser.add_argument(
"--homebrew-target-macos-version-spec",
default=TARGET_MACOS_VERSION_SPEC,
help="The target macos version spec to use in homebrew specs",
)
parser.add_argument(
"--no-homebrew-push-tap",
dest="homebrew_push_tap",
action="store_false",
help="Do not push the homebrew tap. A manual commit will have to be made",
)
parser.add_argument(
"--no-chocolatey-publish",
dest="chocolatey_publish",
action="store_false",
help="Do not publish to chocolatey's community stream",
)
parser.add_argument(
"--chocolatey-token",
help="If provided, use this chocolatey token instead of the one in `--chocolatey-token-file`",
)
parser.add_argument(
"--chocolatey-token-file",
default=os.path.expanduser("~/.buck-chocolatey-token"),
help="A file containing the chocolatey token to use",
)
parser.add_argument(
"--output-dir",
help=(
"If specified, artifacts will be written to this directory, instead of "
"a temporary one"
),
)
parser.add_argument(
"--homebrew-dir",
help=(
"Where homebrew is (e.g. /usr/local). If not specified, homebrew will be "
"installed in a separate, temporary directory that gets cleaned up after "
"building (unless --keep-temp-files is specified). If --output-dir is "
"specified, homebrew will be installed in a subdirectory there. This can "
"be useful to ensure that tap directories are preserved and can be "
"validated and pushed to github if a first run fails, or if a "
"--no-upload-asset run is done"
),
)
parser.add_argument(
"--insecure-chocolatey-upload",
action="store_true",
help=(
"Do less certificate verification when uploading to chocolatey. "
"This is a workaround for "
"https://github.com/chocolatey/chocolatey.org/issues/584"
),
)
parser.add_argument(
"--docker-login",
action="store_true",
help="If set, run 'docker login' using DOCKERHUB_USERNAME and DOCKERHUB_TOKEN",
)
parsed_kwargs = dict(parser.parse_args(args)._get_kwargs())
if parsed_kwargs["deb_file"]:
parsed_kwargs["build_deb"] = False
if parsed_kwargs["homebrew_file"]:
parsed_kwargs["build_homebrew"] = False
if parsed_kwargs["chocolatey_file"]:
parsed_kwargs["build_chocolatey"] = False
return argparse.Namespace(**parsed_kwargs)
def configure_logging():
# Bold message
TTY_LOGGING = " publish_release => \033[1m%(message)s\033[0m"
NOTTY_LOGGING = " publish_release => %(message)s"
msg_format = TTY_LOGGING if sys.stderr.isatty() else NOTTY_LOGGING
# Red message for errors
TTY_ERROR_LOGGING = " publish_release => \033[1;31mERROR: %(message)s\033[0m"
NOTTY_ERROR_LOGGING = " publish_release => ERROR: %(message)s"
error_msg_format = TTY_ERROR_LOGGING if sys.stderr.isatty() else NOTTY_ERROR_LOGGING
class LevelFilter(logging.Filter):
def filter(self, record):
return record.levelno < logging.ERROR
logging.config.dictConfig(
{
"version": 1,
"filters": {"lower_than_error": {"()": LevelFilter}},
"formatters": {
"info": {"format": msg_format},
"error": {"format": error_msg_format},
},
"handlers": {
"info": {
"level": "INFO",
"class": "logging.StreamHandler",
"formatter": "info",
"filters": ["lower_than_error"],
},
"error": {
"level": "ERROR",
"class": "logging.StreamHandler",
"formatter": "error",
},
},
"loggers": {"": {"handlers": ["info", "error"], "level": "INFO"}},
}
)
def validate_repo_upstream(args):
""" Make sure we're in the right repository, not a fork """
output = subprocess.check_output(
["git", "remote", "get-url", "origin"], encoding="utf-8"
).strip()
if output not in args.valid_git_upstreams:
raise ReleaseException(
"Releases may only be published from the upstream OSS buck repository"
)
def docker_login():
username = os.environ.get("DOCKERHUB_USERNAME")
token = os.environ.get("DOCKERHUB_TOKEN")
if username and token:
run(["docker", "login", "--username", username, "--password-stdin"], input=token)
else:
logging.error("Both DOCKERHUB_USERNAME and DOCKERHUB_TOKEN must be set to login to dockerhub")
def validate_environment(args):
""" Make sure we can build """
validate_repo_upstream(args)
if args.build_deb:
ret = docker(
args.docker_linux_host,
["info", "-f", "{{.OSType}}"],
check=False,
capture_output=True,
)
host = args.docker_linux_host or "localhost"
if ret.returncode != 0:
raise ReleaseException(
"docker info on linux host {} failed. debs cannot be built", host
)
host_os = ret.stdout.decode("utf-8").strip()
if host_os != "linux":
raise ReleaseException(
"docker info on host {} returned type '{}' not 'linux'. debs cannot be built",
host,
host_os,
)
if args.build_chocolatey:
ret = docker(
args.docker_windows_host,
["info", "-f", "{{.OSType}}"],
check=False,
capture_output=True,
)
host = args.docker_windows_host or "localhost"
if ret.returncode != 0:
raise ReleaseException(
"docker info on windows host {} failed. chocolatey nupkgs cannot be built",
host,
)
host_os = ret.stdout.decode("utf-8").strip()
if host_os != "windows":
raise ReleaseException(
"docker info on host {} returned type '{}' not 'windows'. chocolatey nupkgs cannot be built",
host,
host_os,
)
if args.build_homebrew:
if args.homebrew_dir:
if not os.path.exists(args.homebrew_dir):
raise ReleaseException(
"Specified homebrew path, {}, does not exist", args.homebrew_dir
)
brew_path = os.path.join(args.homebrew_dir, "bin", "brew")
try:
ret = run([brew_path, "--version"])
except Exception:
raise ReleaseException(
"{} --version failed. bottles cannot be created", brew_path
)
def build(args, output_dir, release, github_token, homebrew_dir):
deb_file = args.deb_file
chocolatey_file = args.chocolatey_file
homebrew_file = args.homebrew_file
if args.build_deb:
user = get_current_user(github_token)
releases = get_all_releases(args.repository, github_token)
deb_file = build_deb(
args.repository, release, user, releases, args.docker_linux_host, output_dir
)
if args.build_homebrew:
homebrew_file = build_bottle(
homebrew_dir,
release,
args.repository,
args.tap_repository,
args.homebrew_target_macos_version,
args.homebrew_target_macos_version_spec,
output_dir,
)
if args.build_chocolatey:
chocolatey_file = build_chocolatey(
args.repository,
release,
args.docker_windows_host,
args.docker_windows_memory,
args.docker_windows_isolation,
output_dir,
)
return deb_file, homebrew_file, chocolatey_file
def publish(
args,
release,
github_token,
chocolatey_token,
deb_file,
homebrew_file,
homebrew_dir,
chocolatey_file,
):
if args.upload_assets:
if deb_file:
add_assets(release, github_token, deb_file)
if chocolatey_file:
add_assets(release, github_token, chocolatey_file)
if args.chocolatey_publish:
publish_chocolatey(
chocolatey_file, chocolatey_token, args.insecure_chocolatey_upload
)
if homebrew_file:
add_assets(release, github_token, homebrew_file)
validate_tap(homebrew_dir, args.tap_repository, args.version)
if args.homebrew_push_tap:
publish_tap_changes(homebrew_dir, args.tap_repository, args.version, github_token)
else:
log_about_manual_tap_push(args.tap_repository)
def main():
args = parse_args(sys.argv[1:])
configure_logging()
version_tag = "v" + args.version
github_token = (
args.github_token if args.github_token else get_token(args.github_token_file)
)
if args.docker_login:
docker_login()
if args.chocolatey_publish:
chocolatey_token = (
args.chocolatey_token
if args.chocolatey_token
else get_token(args.chocolatey_token_file)
)
else:
chocolatey_token = None
temp_dir = None
temp_homebrew_dir = None
homebrew_file = None
try:
validate_environment(args)
if args.use_existing_release:
release = get_release_for_tag(args.repository, github_token, version_tag)
else:
release = create_new_release(
args.repository,
github_token,
version_tag,
args.release_message,
args.prompt_for_message,
)
if args.output_dir:
output_dir = args.output_dir
if not os.path.exists(output_dir):
logging.info("{} does not exist. Creating it".format(output_dir))
os.makedirs(output_dir, exist_ok=True)
else:
temp_dir = tempfile.mkdtemp()
output_dir = temp_dir
if args.homebrew_dir:
homebrew_dir = args.homebrew_dir
elif args.output_dir:
homebrew_dir = os.path.abspath(
os.path.join(output_dir, "homebrew_" + version_tag)
)
else:
temp_homebrew_dir = tempfile.mkdtemp()
homebrew_dir = temp_homebrew_dir
deb_file, homebrew_file, chocolatey_file = build(
args, output_dir, release, github_token, homebrew_dir
)
publish(
args,
release,
github_token,
chocolatey_token,
deb_file,
homebrew_file,
homebrew_dir,
chocolatey_file,
)
except ReleaseException as e:
logging.error(str(e))
finally:
if not args.keep_temp_files:
def remove(path):
try:
shutil.rmtree(path)
except Exception:
logging.error("Could not remove temp dir at {}".format(path))
if temp_dir:
remove(temp_dir)
if temp_homebrew_dir:
# If the person didn't want to publish, we need to keep this around
if not homebrew_file or args.homebrew_push_tap:
remove(temp_homebrew_dir)
if __name__ == "__main__":
main()