| #!/usr/bin/env python3 |
| """Publishes Fuchsia VIM3 firmware source code. |
| |
| This has three parts: |
| 1. build firmware (split into fuchsia_build.py) |
| 2. create YAML files for CIPD package |
| 3. 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. |
| |
| In case CIPD is not available it is expected that all necessary tools would be |
| in PATH. |
| """ |
| |
| import argparse |
| import json |
| import logging |
| import os |
| import shutil |
| import subprocess |
| import sys |
| import tempfile |
| import textwrap |
| from pathlib import Path |
| from typing import Optional |
| from fuchsia_build import fuchsia_build, ensure_deps, cipd_dep_dir_clean |
| |
| _MY_DIR = Path(__file__).parent.resolve() |
| |
| _REPO_NAME = "u-boot" |
| _UPSTREAM_BRANCH = "origin/vim3" |
| _THIRD_PARTY_REPO_URL = f"https://third-party-mirror.googlesource.com/{_REPO_NAME}/" |
| |
| |
| _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") |
| |
| # 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 = _MY_DIR / "build" / _BUILD_ARTIFACT_NAME |
| |
| _CIPD_FILES_DIR = _MY_DIR / "cipd_out" |
| _CIPD_YAML_FILE_NAME = _CIPD_FILES_DIR / "cipd.yaml" |
| _CIPD_PACKAGE = "fuchsia/prebuilt/third_party/firmware/vim3" |
| |
| _JAVABIN = "java" |
| _COPYBARA_PATH = "copybara" |
| |
| |
| def setup_gob_auth() -> bool: |
| """Attempts to set up Git authentication for GoB using LUCI credential helper. |
| |
| This configures the local Git repository to use the 'luci' credential helper |
| (git-credential-luci) for GoB hosts (*.googlesource.com). |
| |
| The credentials (OAuth tokens) are managed by the ambient LUCI/Swarming bot |
| environment. The helper retrieves these tokens dynamically from the local |
| LUCI authentication service (communicating via the socket/file pointed to |
| by the LUCI_CONTEXT environment variable). |
| |
| This setup assumes that: |
| 1. git-credential-luci is available on PATH (typically part of depot_tools). |
| 2. The script is running within an active LUCI task context that has been |
| configured with a service account (handled automatically by the LUCI |
| builder configuration). No manual key registration is required. |
| |
| Returns: |
| True if authentication was successfully set up, False otherwise. |
| """ |
| print("Attempting to set up GoB authentication using LUCI credential helper...") |
| os.environ["GIT_TERMINAL_PROMPT"] = "0" |
| credential_helper_str = "credential.https://*.googlesource.com.helper" |
| |
| # Check if git-credential-luci is available on PATH |
| if not shutil.which("git-credential-luci"): |
| print( |
| "git-credential-luci helper not found on PATH. Cannot configure GoB auth." |
| ) |
| return False |
| |
| try: |
| # Clear any existing helper for this pattern to avoid conflicts/multi-value errors |
| subprocess.run( |
| ["git", "config", "--local", "--unset-all", credential_helper_str], |
| check=False, |
| ) |
| |
| # Configure git to use the 'luci' helper for googlesource.com |
| subprocess.run( |
| ["git", "config", "--local", credential_helper_str, "luci"], check=True |
| ) |
| print( |
| "Successfully configured Git to use LUCI credential helper for googlesource.com" |
| ) |
| |
| # Verify auth |
| print("Verifying authentication by running git ls-remote...") |
| # Note: We must NOT bypass credential helper here because we want to test it. |
| try: |
| res = subprocess.run( |
| ["git", "ls-remote", _THIRD_PARTY_REPO_URL], |
| capture_output=True, |
| text=True, |
| timeout=10, |
| ) |
| if res.returncode == 0: |
| print("Auth verification SUCCESS") |
| return True |
| else: |
| print(f"Auth verification FAILED: {res.stderr}") |
| return False |
| except subprocess.TimeoutExpired: |
| print("Auth verification TIMEOUT (10s). git ls-remote hung.") |
| return False |
| |
| except Exception as e: |
| print(f"Error configuring GoB auth: {e}") |
| return False |
| |
| |
| 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 = _MY_DIR / "Licenses" |
| |
| def _add_license(name: str): |
| contents = (license_dir / name).read_text() |
| # No need to duplicate licenses if they're the exact same. |
| if contents not in licenses: |
| licenses.append(contents) |
| |
| dir_contents = sorted(item.name for item in license_dir.iterdir() if item.is_file()) |
| 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(repo_dir: Path, upstream_branch: str) -> 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: |
| repo_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=repo_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 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 = "https://turquoise-internal.googlesource.com/third_party/u-boot", |
| ref = "{revision}", |
| ), |
| destination = git.destination( |
| url = "{_THIRD_PARTY_REPO_URL}", |
| push = "vim3", |
| ), |
| mode = "ITERATIVE", |
| authoring = authoring.pass_thru( |
| default = "Fuchsia firmware team <fuchsia-firmware@google.com>" |
| ), |
| ) |
| """ |
| ) |
| |
| return contents |
| |
| |
| def create_cipd_files(manifest: str, revision: Optional[str]): |
| """Create CIPD yaml files |
| |
| Args |
| manifest: filename for YAML manifest file provided by CIPD builder |
| revision: u-boot source revision, or None for ToT |
| """ |
| shutil.rmtree(_CIPD_FILES_DIR, ignore_errors=True) |
| _CIPD_FILES_DIR.mkdir(parents=True, exist_ok=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": "fuchsia-firmware@google.com", |
| "manifest.json": json.dumps({_REPO_NAME: revision}, indent=2, sort_keys=True), |
| "LICENSE": license_file_contents(), |
| } |
| |
| for name, contents in metadata_files.items(): |
| (_CIPD_FILES_DIR / name).write_text(contents) |
| |
| shutil.copyfile(_BUILD_ARTIFACT_PATH, _CIPD_FILES_DIR / _BUILD_ARTIFACT_NAME) |
| |
| # Create CIPD yaml file |
| with open(_CIPD_YAML_FILE_NAME, "w") as cipd_yaml_file: |
| logging.info("Writing CIPD yaml file: `%s`", _CIPD_YAML_FILE_NAME) |
| cipd_yaml_file.write( |
| textwrap.dedent( |
| f"""\ |
| package: {_CIPD_PACKAGE} |
| description: vim3 firmware image |
| install_mode: copy |
| data: |
| """ |
| ) |
| ) |
| |
| cipd_yaml_file.write(f" - file: {_BUILD_ARTIFACT_NAME}\n") |
| for name in metadata_files: |
| cipd_yaml_file.write(f" - file: {name}\n") |
| |
| create_cipd_yaml_manifest(revision, manifest) |
| |
| |
| def create_cipd_yaml_manifest(revision: Optional[str], filename: str): |
| """Creates YAML manifest for CIPD. |
| |
| Args: |
| revision: u-boot source revision to publish, or None for ToT. |
| filename: name for the file to write to. |
| """ |
| |
| # When dry-running with local changes, our local revision doesn't exist on |
| # the host. In this case just use the tip of tree. |
| if not revision: |
| logging.warning("No source revision given, using 'vim3' ToT") |
| revision = "vim3" |
| |
| contents = json.dumps( |
| [ |
| { |
| "path": str(_CIPD_YAML_FILE_NAME.relative_to(_MY_DIR)), |
| "tags": {_REPO_NAME: revision}, |
| } |
| ], |
| indent=4, |
| sort_keys=True, |
| ) |
| |
| with open(filename, "w") as yaml_manifest_file: |
| logging.info("Writing YAML file: `%s`", filename) |
| yaml_manifest_file.write(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) |
| |
| config_path = Path(tempfile.mkdtemp()) / "copy.bara.sky" |
| config_path.write_text(config) |
| |
| # CIPD binary version has `copybara.jar` that depends on build time Java environment. |
| # That is not available in builders. And `copybara_deploy.jar` that includes all dependencies. |
| # In order to use second one '--singlejar' option is required |
| command = [ |
| copybara_path, |
| "--singlejar", |
| str(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. |
| try: |
| subprocess.run(command, env=dict(os.environ, JAVABIN=_JAVABIN), check=True) |
| except subprocess.CalledProcessError as error: |
| if error.returncode == 4: # NO_OP |
| logging.info("Copybara reported nothing to do") |
| else: |
| raise |
| |
| |
| 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( |
| "--cipd-yaml-manifest", |
| default="", |
| help="Write YAML manifest for CIPD in specified file", |
| ) |
| 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.", |
| ) |
| parser.add_argument( |
| "--clean-cipd", |
| action="store_true", |
| help="Clear CIPD dependencies dir before start", |
| ) |
| |
| return parser.parse_args() |
| |
| |
| def is_on_builder() -> bool: |
| """Checks if the script is running on a LUCI/Swarming builder.""" |
| return "SWARMING_TASK_ID" in os.environ or "BUILDBUCKET_BUILDER" in os.environ |
| |
| |
| def _main() -> int: |
| logging.basicConfig(level=logging.INFO) |
| args = _parse_args() |
| |
| if is_on_builder(): |
| setup_gob_auth() |
| |
| if args.clean_cipd: |
| cipd_dep_dir_clean() |
| |
| # Versions are pinned to current integration version in most cases. |
| # It is done to prevent unexpected breaking when integration moves forward. |
| # It is required to do manual testing before bumping the versions. |
| list_of_packages = [ |
| ( |
| "copybara", |
| "infra/3pp/tools/copybara", |
| "version:3@2450737ba36adafb13b5794da8948edfcb78afe3.cr1", |
| "copybara", |
| ), |
| ("java", "flutter/java/openjdk/linux-amd64", "version:21", "bin"), |
| ] |
| ensure_deps(args.cipd, list_of_packages) |
| |
| 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, |
| ) |
| |
| try: |
| fuchsia_build( |
| cipd=args.cipd, |
| clean_cipd=False, |
| ) |
| except subprocess.CalledProcessError as e: |
| logging.error(f"Build failed with exit code {e.returncode}") |
| return e.returncode |
| |
| if args.cipd_yaml_manifest: |
| create_cipd_files(manifest=args.cipd_yaml_manifest, revision=revision) |
| |
| return 0 |
| |
| |
| if __name__ == "__main__": |
| sys.exit(_main()) |