blob: 1ad37095a48115e4989e08f6809aa106da23aa2e [file] [log] [blame]
#!/usr/bin/env python3
"""Releases Fuchsia VIM3 firmware.
This has two parts:
1. build and upload firmware prebuilts to CIPD
2. publish source code to the open-source repo
We could potentially just develop in the open - there's nothing internal in this
codebase - except that we don't really have a good host at the moment for
developing GPL code publicly (http://b/213950490). So for now we just manually
flush code out on every release so that the CIPD binaries match the public
source code.
Intended for Googler use only - external users won't have the necessary tooling
or write-access to the CIPD/git hosts so won't be able to run this.
"""
import argparse
import json
import logging
import os
import shutil
import subprocess
import sys
import tempfile
import textwrap
from typing import Optional
_MY_DIR = os.path.dirname(__file__)
_LICENSE_SEPARATOR = ('\n===================================\n\n')
# Explicitly enumerate known license files so that if a new one comes in,
# the script will fail and notify us. Once the new file has been reviewed and
# approved we can add it here.
_KNOWN_LICENSE_FILES = ('bsd-2-clause.txt', 'bsd-3-clause.txt', 'eCos-2.0.txt',
'gpl-2.0.txt', 'ibm-pibs.txt', 'isc.txt',
'lgpl-2.0.txt', 'lgpl-2.1.txt')
# Files in the licenses dir that are informational only and don't need to be
# included in the license dump.
_INFORMATIONAL_LICENSE_FILES = ('Exceptions', 'README')
_BUILD_SCRIPT = os.path.join(_MY_DIR, "build_uboot_vim3_zircon.sh")
# u-boot.bin.unsigned is the combined BL2 + TPL format that goes in the eMMC
# boot0/boot1 partitions. Unclear why u-boot.bin.signed.encrypted doesn't work,
# but we don't need signatures for dev boards so we're OK with this.
_BUILD_ARTIFACT_NAME = "u-boot.bin.unsigned"
_BUILD_ARTIFACT_PATH = os.path.join(_MY_DIR, "build", _BUILD_ARTIFACT_NAME)
_REPO_NAME = "u-boot"
_UPSTREAM_BRANCH = "origin/vim3"
_CIPD_PACKAGE = "fuchsia/prebuilt/third_party/firmware/vim3"
# http://go/copybara-setup.
_COPYBARA_PATH = os.path.join("/", "google", "bin", "releases", "copybara",
"public", "copybara", "copybara")
def license_file_contents() -> str:
"""Creates and returns the combined license file contents.
Raises:
FileExistsError if an unexpected license file was found.
"""
licenses = []
license_dir = os.path.join(_MY_DIR, 'Licenses')
def _add_license(name: str):
with open(os.path.join(license_dir, name), 'r') as f:
contents = f.read()
# No need to duplicate licenses if they're the exact same.
if contents not in licenses:
licenses.append(contents)
dir_contents = sorted(os.listdir(license_dir))
for name in dir_contents:
if name in _KNOWN_LICENSE_FILES:
_add_license(name)
elif name in _INFORMATIONAL_LICENSE_FILES:
pass
else:
raise FileExistsError(f'Unknown u-boot license file: {name}')
return _LICENSE_SEPARATOR.join(licenses)
def ensure_git_is_clean(dir, upstream_branch) -> str:
"""Checks if the given git repo is clean.
A clean repo is one that is on the upstream branch and has no local changes.
Args:
dir: a path to the repo root dir.
upstream_branch: upstream branch we should match.
Raises:
ValueError if the given repo is not clean.
Returns:
The current clean git revision.
"""
def git(command):
return subprocess.run(["git"] + command,
cwd=dir,
check=True,
capture_output=True,
text=True).stdout.strip()
# Update the local repo.
git(["fetch"])
# Check the HEAD revision to look for committed changes.
head_revision = git(["rev-parse", "HEAD"])
upstream_revision = git(["rev-parse", upstream_branch])
if head_revision != upstream_revision:
raise ValueError(
f"HEAD {head_revision} != upstream {upstream_revision}")
# Check for any uncommitted file changes. This will print the name of
# any uncommitted files, so empty string means no local changes.
status = git(["status", "--porcelain"])
if status:
raise ValueError(f"Uncommitted local changes:\n{status}")
return head_revision
def build_and_upload(revision: Optional[str], cipd_tool: str, dry_run: bool):
"""Builds and uploads the CIPD package.
Args:
revision: u-boot source revision, or None for ToT.
cipd: path to the cipd executable.
dry_run: True to create the package but not upload it.
"""
subprocess.run([_BUILD_SCRIPT], check=True)
# Source revision is only used in metadata for CIPD so it doesn't have
# to be a real ref.
if not revision:
revision = "__local_dirty__"
metadata_files = {
"OWNERS":
"tq-firmware-team@google.com",
"manifest.json":
json.dumps({_REPO_NAME: revision}, indent=2, sort_keys=True),
"LICENSE":
license_file_contents()
}
with tempfile.TemporaryDirectory() as temp_dir:
for name, contents in metadata_files.items():
with open(os.path.join(temp_dir, name), "w") as f:
f.write(contents)
shutil.copyfile(_BUILD_ARTIFACT_PATH,
os.path.join(temp_dir, _BUILD_ARTIFACT_NAME))
upload_command = [
cipd_tool, "create", "-name", _CIPD_PACKAGE, "-in", temp_dir,
"-tag", f"{_REPO_NAME}:{revision}", "-install-mode", "copy"
]
if dry_run:
logging.info("Dry run only: `%s`", " ".join(upload_command))
logging.info("Files can be examined at %s", temp_dir)
input("Press Enter to exit")
else:
subprocess.run(upload_command, check=True)
def create_copybara_config(revision: Optional[str]):
"""Creates the Copybara config file contents.
Args:
revision: u-boot source revision to publish, or None for ToT.
Returns:
The Copybara config file contents.
"""
# When dry-running with local changes, our local revision doesn't exist on
# the host so copybara can't find it. In this case just use the tip of tree.
if not revision:
logging.warning("No source revision given, using 'vim3' ToT")
revision = "vim3"
# http://go/copybara-reference.
contents = textwrap.dedent(f"""\
core.workflow(
name = "default",
origin = git.origin(
url = "sso://turquoise-internal/third_party/u-boot/",
ref = "{revision}",
),
destination = git.destination(
url = "sso://third-party-mirror/u-boot/",
push = "vim3",
),
mode = "ITERATIVE",
authoring = authoring.pass_thru(
default = "Fuchsia firmware team <tq-firmware-team@google.com>"
),
)
""")
return contents
def publish_source(revision: Optional[str], copybara_path: str,
last_rev: Optional[str], dry_run: bool,
push_justification: str):
"""Publishes this source to the public repo.
Args:
revision: u-boot source revision to publish, or None for ToT.
copybara_path: path to the copybara executable.
last_rev: last source revision that was pushed to destination. Use this
only to initialize the repo the first time so we can tell
Copybara where to start.
dry_run: True to skip submitting anything.
push_justification: BugID for push justification.
Raises:
subprocess.CalledProcessError if copybara fails.
"""
config = create_copybara_config(revision)
with tempfile.TemporaryDirectory() as temp_dir:
config_path = os.path.join(temp_dir, "copy.bara.sky")
with open(config_path, "w") as config_file:
config_file.write(config)
command = [copybara_path, config_path, "--git-push-option", "push-justification=" + push_justification]
if last_rev:
command += ["--last-rev", last_rev]
if dry_run:
command.append("--dry-run")
logging.info("Copybara command: `%s`", ' '.join(command))
# Let stdout/sterr through to the console since this may take a while.
subprocess.run(command, check=True)
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--cipd",
default="cipd",
help="Path to CIPD tool; looks on $PATH by default")
parser.add_argument("--copybara",
default=_COPYBARA_PATH,
help=f"Path to copybara tool; default {_COPYBARA_PATH}")
parser.add_argument("--dry-run",
action="store_true",
help="Don't upload anything")
parser.add_argument(
"--last-rev",
help="The last source revision that was released."
" Only use this on the first run to tell copybara where to start.")
parser.add_argument(
"--push-justification",
default="b/302031093",
help="BugID for git push justification.")
return parser.parse_args()
def _main() -> int:
logging.basicConfig(level=logging.INFO)
args = _parse_args()
try:
revision = ensure_git_is_clean(_MY_DIR, _UPSTREAM_BRANCH)
except ValueError as exception:
if args.dry_run:
logging.warning("%s", exception)
logging.warning("Ignoring dirty local repo for dry run")
revision = None
else:
raise
publish_source(revision, args.copybara, args.last_rev, args.dry_run, args.push_justification)
build_and_upload(revision, args.cipd, args.dry_run)
return 0
if __name__ == '__main__':
sys.exit(_main())