| #!/usr/bin/env vpython3 |
| # Copyright 2026 The ANGLE project authors. All Rights Reserved. |
| # |
| # Use of this source code is governed by a BSD-style license |
| # that can be found in the LICENSE file in the root of the source |
| # tree. An additional intellectual property rights grant can be found |
| # in the file PATENTS. All contributing project authors may |
| # be found in the AUTHORS file in the root of the source tree. |
| """Rolls dependencies shared with Chromium. |
| |
| This is mostly DEPS entries, but some additional dependencies elsewhere in the |
| repo are also synced. |
| |
| This is a newer version of roll_chromkium_deps.py with better support for |
| less standard DEPS entries such as GCS dependencies. It is largely a copy of |
| the roller script that was created for Dawn in |
| https://dawn.googlesource.com/dawn/+/refs/heads/main/scripts/roll_chromium_deps.py |
| but with repo-specific aspects adjusted for ANGLE. |
| """ |
| |
| import abc |
| import argparse |
| import base64 |
| import dataclasses |
| import datetime |
| import functools |
| import logging |
| import pathlib |
| import posixpath |
| import re |
| import shlex |
| import subprocess |
| import sys |
| from typing import Any, Self, Type |
| |
| import requests |
| |
| ANGLE_ROOT = pathlib.Path(__file__).resolve().parents[1] |
| DEPS_FILE = ANGLE_ROOT / 'DEPS' |
| INFRA_PATH = ANGLE_ROOT / 'infra' / 'config' |
| PACKAGE_STAR = INFRA_PATH / 'PACKAGE.star' |
| |
| CHROMIUM_GOB_URL = 'https://chromium.googlesource.com' |
| CHROMIUM_SRC_URL = posixpath.join(CHROMIUM_GOB_URL, 'chromium', 'src') |
| CHROMIUM_REVISION_VAR = 'chromium_revision' |
| |
| DEFAULT_REVISION_CHARACTERS = 10 |
| |
| # GN variables that need to be synced. A map from ANGLE variable name to |
| # Chromium variable name. |
| SYNCED_VARIABLES = {} |
| |
| # DEPS entries which have dep_type = cipd. In the Chromium DEPS file, these |
| # will be prefixed with src/. |
| SYNCED_CIPD_DEPS = { |
| 'buildtools/linux64', |
| 'buildtools/mac', |
| 'buildtools/reclient', |
| 'buildtools/win', |
| 'third_party/android_build_tools/aapt2/cipd', |
| 'third_party/android_build_tools/error_prone/cipd', |
| 'third_party/android_build_tools/error_prone_javac/cipd', |
| 'third_party/android_build_tools/lint/cipd', |
| 'third_party/android_build_tools/manifest_merger/cipd', |
| 'third_party/android_build_tools/nullaway/cipd', |
| 'third_party/android_toolchain/ndk', |
| 'third_party/android_sdk/public', |
| 'third_party/android_system_sdk/cipd', |
| 'third_party/fuchsia-sdk/sdk', |
| 'third_party/jdk/current', |
| 'third_party/ninja', |
| 'third_party/r8/cipd', |
| 'third_party/r8/d8/cipd', |
| 'third_party/siso/cipd', |
| 'third_party/turbine/cipd', |
| 'tools/luci-go', |
| 'tools/skia_goldctl/linux', |
| 'tools/skia_goldctl/mac_amd64', |
| 'tools/skia_goldctl/mac_arm64', |
| 'tools/skia_goldctl/win', |
| } |
| |
| # DEPS entries which have dep_type = gcs. In the Chromium DEPS file, these will |
| # be prefixed with src/. |
| # TODO(anglebug.com/485785261): Handle tools/clang as a GCS dependency like |
| # Chromium does. |
| SYNCED_GCS_DEPS = set() |
| |
| # Repos that are independently synced by Chromium and ANGLE. A map from ANGLE |
| # names to Chromium names. None means that the names are identical. In the |
| # Chromium DEPS file, these will be prefixed with src/. |
| # The following DEPS entries would go in here except that they are synced |
| # separately from Chromium: |
| # * third_party/SwiftShader |
| # * third_party/vulkan-deps |
| # * third_party/glslang/src |
| # * third_party/spirv-cross/src |
| # * third_party/spirv-headers/src |
| # * third_party/spirv-tools/src |
| # * third_party/vulkan-headers/src |
| # * third_party/vulkan-loader/src |
| # * third_party/vulkan-tools/src |
| # * third_party/vulkan-utility-libraries/src |
| # * third_party/vulkan-validation-layers/src |
| # * third_party/vulkan_memory_allocator |
| # * third_party/wayland |
| SYNCED_REPOS = { |
| 'third_party/catapult': None, |
| 'third_party/clang-format/script': None, |
| 'third_party/colorama/src': None, |
| 'third_party/cpu_features/src': None, |
| # third_party/dawn is synced manually due to a circular dependency. |
| 'third_party/depot_tools': None, |
| 'third_party/flatbuffers/src': None, |
| 'third_party/googletest/src': None, |
| 'third_party/libdrm/src': None, |
| 'third_party/libjpeg_turbo': None, |
| 'third_party/libc++/src': None, |
| 'third_party/libc++abi/src': None, |
| 'third_party/llvm-libc/src': None, |
| 'third_party/libunwind/src': None, |
| 'third_party/nasm': None, |
| 'third_party/perfetto': None, |
| 'third_party/re2/src': None, |
| 'third_party/requests/src': None, |
| } |
| |
| # Chromium directories that are exported as pseudo-repos in |
| # chromium.googlesource.com under chromium/src/. Mapping of ANGLE path to |
| # Chromium src-relative path. None means that the names are identical. |
| EXPORTED_CHROMIUM_REPOS = { |
| 'build': None, |
| 'buildtools': None, |
| 'testing': None, |
| 'third_party/abseil-cpp': None, |
| 'third_party/android_build_tools': None, |
| 'third_party/android_deps': None, |
| 'third_party/android_platform': None, |
| 'third_party/android_sdk': None, |
| 'third_party/ijar': None, |
| 'third_party/jinja2': None, |
| # TODO(anglebug.com/40041909): Add third_party/jsoncpp/src once jsoncpp's |
| # BUILD.gn is copied into ANGLE and the source can be rolled without relying |
| # on recursedeps. |
| 'third_party/markupsafe': None, |
| 'third_party/protobuf': None, |
| 'third_party/Python-Markdown': None, |
| 'third_party/rust': None, |
| 'third_party/six': None, |
| 'third_party/zlib': None, |
| 'tools/android': None, |
| # TODO(anglebug.com/485785261): Remove tools/clang when clang is handled as |
| # a GCS dependency like is done in Chromium. |
| 'tools/clang': None, |
| 'tools/mb': None, |
| 'tools/md_browser': None, |
| 'tools/memory': None, |
| 'tools/perf': None, |
| 'tools/protoc_wrapper': None, |
| 'tools/python': None, |
| 'tools/rust': None, |
| 'tools/valgrind': None, |
| 'tools/win': None, |
| } |
| |
| |
| @dataclasses.dataclass |
| class ChangedDepsEntry(abc.ABC): |
| """Base class for all changed DEPS entries.""" |
| # The name of the dependency in ANGLE's DEPS file. |
| name: str |
| |
| @abc.abstractmethod |
| def setdep_args(self) -> list[str]: |
| """Returns 'gclient setdep'-compatible arguments. |
| |
| The returned arguments will cause 'gclient setdep' to update the DEPS |
| file content to the new version. |
| """ |
| |
| @abc.abstractmethod |
| def commit_message_lines(self) -> list[str]: |
| """Returns lines to add to the commit message.""" |
| |
| |
| @dataclasses.dataclass |
| class ChangedVariable(ChangedDepsEntry): |
| """Represents a single changed DEPS variable.""" |
| # The old version in ANGLE's DEPS file. |
| old_version: str |
| # The new version that ANGLE's DEPS file will contain. |
| new_version: str |
| |
| def setdep_args(self) -> list[str]: |
| return [ |
| '--var', |
| f'{self.name}={self.new_version}', |
| ] |
| |
| def commit_message_lines(self) -> list[str]: |
| return [ |
| f' {self.name}: {self.old_version} -> {self.new_version}', |
| ] |
| |
| |
| @dataclasses.dataclass |
| class ChangedRepo(ChangedDepsEntry): |
| """Represents a single changed DEPS repo entry.""" |
| # The URL of the repo that ANGLE depends on. |
| url: str |
| # The old revision in ANGLE's DEPS file. |
| old_revision: str |
| # The new revision that ANGLE's DEPS file will contain. |
| new_revision: str |
| |
| def setdep_args(self) -> list[str]: |
| return [ |
| '--revision', |
| f'{self.name}@{self.new_revision}', |
| ] |
| |
| def commit_message_lines(self) -> list[str]: |
| return [f' {self.name}: {self.log_link()}'] |
| |
| def revision_range(self, num_characters: int | None = None) -> str: |
| num_characters = num_characters or DEFAULT_REVISION_CHARACTERS |
| return (f'{self.old_revision[:num_characters]}' |
| f'..' |
| f'{self.new_revision[:num_characters]}') |
| |
| def log_link(self, num_characters: int | None = None) -> str: |
| return f'{self.url}/+log/{self.revision_range(num_characters)}' |
| |
| def commit_link(self, num_characters: int | None = None) -> str: |
| return f'{self.url}/+/{self.revision_range(num_characters)}' |
| |
| |
| @dataclasses.dataclass |
| class CipdPackage: |
| """Represents a single element of a CIPD DEPS entry's 'packages' list.""" |
| package: str |
| version: str |
| |
| @classmethod |
| def from_dict(cls, dict_repr: dict[str, str]) -> Self: |
| """Creates an instance from a DEPS entry dict. |
| |
| Args: |
| dict_repr: A dictionary from a GCS DEPS entry's 'packages' list. |
| |
| Returns: |
| A CipdPackage instance filled with the information contained within |
| |dict_repr|. |
| """ |
| return cls( |
| package=dict_repr['package'], |
| version=dict_repr['version'], |
| ) |
| |
| def setdep_str(self) -> str: |
| """Gets a 'gclient setdep'-compatible string representation.""" |
| # Remove any double curly braces, e.g. ${{arch}} |
| package = self.package.format() |
| return f'{package}@{self.version}' |
| |
| |
| @dataclasses.dataclass |
| class ChangedCipd(ChangedDepsEntry): |
| """Represents a single changed DEPS CIPD entry.""" |
| # The old packages in ANGLE's DEPS file |
| old_packages: list[CipdPackage] |
| # The new packages that ANGLE's DEPS file will contain. |
| new_packages: list[CipdPackage] |
| |
| def setdep_args(self) -> list[str]: |
| revisions = [f'{self.name}:{p.setdep_str()}' for p in self.new_packages] |
| return ['--revision'] + revisions |
| |
| def commit_message_lines(self) -> list[str]: |
| return [ |
| f' {self.name}', |
| ] |
| |
| |
| @dataclasses.dataclass |
| class GcsObject: |
| """Represents a single element of a GCS DEPS entry's 'objects' list.""" |
| object_name: str |
| sha256sum: str |
| size_bytes: int |
| generation: int |
| |
| @classmethod |
| def from_dict(cls, dict_repr: dict[str, str | int]) -> Self: |
| """Creates an instance from a DEPS entry dict. |
| |
| Args: |
| dict_repr: A dictionary from a GCS DEPS entry's 'objects' list. |
| |
| Returns: |
| A GcsObject instance filled with the information contained within |
| |dict_repr|. |
| """ |
| return cls( |
| object_name=dict_repr['object_name'], |
| sha256sum=dict_repr['sha256sum'], |
| size_bytes=dict_repr['size_bytes'], |
| generation=dict_repr['generation'], |
| ) |
| |
| def as_comma_separated_str(self) -> str: |
| return (f'{self.object_name},{self.sha256sum},{self.size_bytes},' |
| f'{self.generation}') |
| |
| |
| @dataclasses.dataclass |
| class ChangedGcs(ChangedDepsEntry): |
| """Represents a single changed DEPS GCS entry.""" |
| # The old objects in ANGLE's DEPS file. |
| old_objects: list[GcsObject] |
| # The new objects that ANGLE's DEPS file will contain. |
| new_objects: list[GcsObject] |
| |
| def setdep_args(self) -> list[str]: |
| comma_separated_objects = [o.as_comma_separated_str() for o in self.new_objects] |
| object_string = '?'.join(comma_separated_objects) |
| return ['--revision', f'{self.name}@{object_string}'] |
| |
| def commit_message_lines(self) -> list[str]: |
| return [ |
| f' {self.name}', |
| ] |
| |
| |
| def _parse_deps_file(deps_content: str) -> dict[str, Any]: |
| """Parses DEPS file content into a Python dict. |
| |
| Args: |
| deps_content: The content of a DEPS file. |
| |
| Returns: |
| A dict containing all content of the DEPS file. For example, the top |
| level `vars` mapping in a DEPS file can be accessed via the 'vars' item |
| in the returned dictionary. |
| """ |
| local_scope = {} |
| global_scope = { |
| 'Str': lambda str_value: str_value, |
| 'Var': lambda var_name: local_scope['vars'][var_name], |
| } |
| exec(deps_content, global_scope, local_scope) |
| return local_scope |
| |
| |
| def _add_depot_tools_to_path() -> None: |
| sys.path.append(str(ANGLE_ROOT / 'build')) |
| import find_depot_tools |
| find_depot_tools.add_depot_tools_to_path() |
| |
| |
| def _is_tree_clean() -> bool: |
| """Checks for untracked/uncommitted files. |
| |
| Returns: |
| True iff there are no untracked or uncommitted files. |
| """ |
| proc = subprocess.run(['git', 'status', '--porcelain'], |
| capture_output=True, |
| text=True, |
| check=True) |
| if not proc.stdout: |
| return True |
| |
| logging.error('Dirty or untracked files:\n%s', proc.stdout) |
| return False |
| |
| |
| def _ensure_updated_main_branch() -> None: |
| """Ensures that the main branch is checked out and up to date.""" |
| proc = subprocess.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], |
| capture_output=True, |
| text=True, |
| check=True) |
| current_branch = proc.stdout.splitlines()[0] |
| if current_branch != 'main': |
| raise RuntimeError('Please run this from the main branch') |
| |
| logging.info('Updating main branch...') |
| subprocess.run(['git', 'pull'], check=True) |
| |
| |
| def _create_roll_branch() -> None: |
| """Creates a unique branch for a roll.""" |
| # YYYY-MM-DD-HH-MM-SS |
| now = datetime.datetime.now().strftime('%Y-%m-%d-%H-%M-%S') |
| branch_name = f'roll-chromium-deps-{now}' |
| logging.info('Creating branch %s', branch_name) |
| _ = subprocess.run(['git', 'checkout', '-b', branch_name], check=True) |
| |
| |
| def _amend_commit(commit_message: str) -> None: |
| """Amends the most recent commit. |
| |
| Args: |
| commit_message: The commit message to add to the existing commit |
| message. This will be inserted towards the end of the existing |
| message, just before "Bug:". |
| """ |
| logging.info('Amending changes to local commit') |
| |
| proc = subprocess.run(['git', 'log', '-1', '--pretty=%B'], |
| capture_output=True, |
| text=True, |
| check=True) |
| old_commit_message = proc.stdout.strip() |
| logging.debug('Existing commit message:\n%s', old_commit_message) |
| |
| bug_index = old_commit_message.rfind('Bug:') |
| if bug_index == -1: |
| raise RuntimeError('"Bug:" not found in existing commit message.') |
| |
| new_commit_message = (f'{old_commit_message[:bug_index]}' |
| f'{commit_message}' |
| f'\n' |
| f'{old_commit_message[bug_index:]}') |
| |
| _ = subprocess.run(['git', 'commit', '-a', '--amend', '-m', new_commit_message], |
| capture_output=True, |
| check=True) |
| |
| |
| def _commit(commit_message: str) -> None: |
| """Commits all changed files. |
| |
| Args: |
| commit_message: The commit message to use for the new commit. |
| """ |
| # `gclient setdep` should have already staged changes and running |
| # `git add -u` actually undoes the submodule changes, so commit as-is. |
| _ = subprocess.run(['git', 'commit', '-m', commit_message], capture_output=True, check=True) |
| |
| |
| def _get_remote_head_revision(remote_url: str) -> str: |
| """Retrieves the HEAD revision for a remote git URL. |
| |
| Args: |
| remote_url: The remote git URL to get HEAD from. |
| |
| Returns: |
| The revision currently corresponding to HEAD. |
| """ |
| cmd = [ |
| 'git', |
| 'ls-remote', |
| '--branches', |
| remote_url, |
| # main and HEAD should be equivalent in this case and main allows usage |
| # of --branches, which significantly speeds this up. |
| 'main', |
| ] |
| proc = subprocess.run(cmd, check=True, capture_output=True, text=True) |
| head_revision = proc.stdout.strip().split()[0] |
| return head_revision |
| |
| |
| def _get_roll_revision_range(target_revision: str | None, angle_deps: dict) -> ChangedRepo: |
| """Determines the range being rolled. |
| |
| Args: |
| target_revision: The revision being targeted for the roll. If not |
| specified, the HEAD revision will be determined and used. |
| angle_deps: The parsed contents of the ANGLE DEPS file. |
| |
| Returns: |
| A ChangedRepo with the determined revision range. |
| """ |
| old_revision = angle_deps['vars'][CHROMIUM_REVISION_VAR] |
| new_revision = target_revision |
| if not new_revision: |
| new_revision = _get_remote_head_revision(CHROMIUM_SRC_URL) |
| logging.info('Using %s as the HEAD revision.', new_revision) |
| return ChangedRepo( |
| name='chromium/src', |
| url=CHROMIUM_SRC_URL, |
| old_revision=old_revision, |
| new_revision=new_revision) |
| |
| |
| def _read_gitiles_content(file_url: str) -> str: |
| """Reads the contents of a file from Gitiles. |
| |
| Args: |
| file_url: A URL pointing to a file to read from Gitiles. |
| |
| Returns: |
| The string content of the specified file. |
| """ |
| file_url = file_url + '?format=TEXT' |
| r = requests.get(file_url) |
| r.raise_for_status() |
| return base64.b64decode(r.text).decode('utf-8') |
| |
| |
| def _read_remote_chromium_file(src_relative_path: str, revision: str) -> str: |
| """Reads the contents of a Chromium file from Gitiles. |
| |
| Args: |
| src_relative_path: A POSIX path to the file to read relative to |
| chromium/src. |
| revision: The Chromium revision to read the file contents at. |
| """ |
| file_url = posixpath.join(CHROMIUM_SRC_URL, '+', revision, src_relative_path) |
| return _read_gitiles_content(file_url) |
| |
| |
| def _get_changed_deps_entries(angle_deps: dict, chromium_deps: dict) -> list[ChangedDepsEntry]: |
| """Gets all entries that have changed between the two provided DEPS. |
| |
| Args: |
| angle_deps: The parsed content of the ANGLE DEPS file. |
| chromium_deps: The parsed content of the Chromium DEPS file. |
| |
| Returns: |
| A list ChangedDepsEntry objects, each one corresponding to a change |
| between the two DEPS files. |
| """ |
| changed_entries = [] |
| changed_entries.extend(_get_changed_variables(angle_deps, chromium_deps)) |
| changed_entries.extend(_get_changed_cipd(angle_deps, chromium_deps)) |
| changed_entries.extend(_get_changed_gcs(angle_deps, chromium_deps)) |
| changed_entries.extend(_get_changed_non_exported_repos(angle_deps, chromium_deps)) |
| changed_entries.extend(_get_changed_exported_repos(angle_deps)) |
| return changed_entries |
| |
| |
| def _get_changed_variables(angle_deps: dict, chromium_deps: dict) -> list[ChangedVariable]: |
| """Gets all GN variables that have changed between the two provided DEPS. |
| |
| Args: |
| angle_deps: The parsed content of the ANGLE DEPS file. |
| chromium_deps: The parsed content of the Chromium DEPS file. |
| |
| Returns: |
| A list of all variable entries that have changed between the two DEPS |
| files. |
| """ |
| changed_variables = [] |
| for angle_var, chromium_var in SYNCED_VARIABLES.items(): |
| angle_value = angle_deps['vars'].get(angle_var) |
| chromium_value = chromium_deps['vars'].get(chromium_var) |
| if not angle_value: |
| raise RuntimeError(f'Could not find ANGLE GN variable {angle_var}. Was it removed?') |
| if not chromium_value: |
| raise RuntimeError(f'Could not find Chromium GN variable {chromium_var}. Was it ' |
| f'removed?') |
| changed_variables.append( |
| ChangedVariable( |
| name=angle_var, |
| old_version=angle_value, |
| new_version=chromium_value, |
| )) |
| return changed_variables |
| |
| |
| def _get_changed_cipd(angle_deps: dict, chromium_deps: dict) -> list[ChangedCipd]: |
| """Gets all CIPD entries that have changed between the two provided DEPS. |
| |
| Args: |
| angle_deps: The parsed content of the ANGLE DEPS file. |
| chromium_deps: The parsed content of the Chromium DEPS file. |
| |
| Returns: |
| A list of all CIPD DEPS entries that have changed between the two |
| DEPS files. |
| """ |
| changed_cipd = [] |
| for angle_name in SYNCED_CIPD_DEPS: |
| chromium_name = 'src/' + angle_name |
| if angle_name not in angle_deps['deps']: |
| raise RuntimeError(f'Unable to find ANGLE CIPD entry {angle_name}. Was it removed?') |
| if chromium_name not in chromium_deps['deps']: |
| raise RuntimeError(f'Unable to find Chromium CIPD entry {chromium_name}. Was it ' |
| f'removed?') |
| |
| angle_packages = [ |
| CipdPackage.from_dict(p) for p in angle_deps['deps'][angle_name]['packages'] |
| ] |
| chromium_packages = [ |
| CipdPackage.from_dict(p) for p in chromium_deps['deps'][chromium_name]['packages'] |
| ] |
| # Unlike GCS entries which provide all object content with a single |
| # --revision, CIPD entries provide one package to update per |
| # --revision. The behavior when a package within an entry disappears is |
| # not clearly defined, so just fail if we ever see that. This should |
| # rarely happen, though. |
| angle_package_names = set(p.package for p in angle_packages) |
| chromium_package_names = set(p.package for p in chromium_packages) |
| if not angle_package_names.issubset(chromium_package_names): |
| raise RuntimeError(f'Packages for CIPD entry {angle_name} appear to have changed. ' |
| f'Please manually sync the package list.') |
| if angle_packages != chromium_packages: |
| changed_cipd.append( |
| ChangedCipd( |
| name=angle_name, |
| old_packages=angle_packages, |
| new_packages=chromium_packages, |
| )) |
| return changed_cipd |
| |
| |
| def _get_changed_gcs(angle_deps: dict, chromium_deps: dict) -> list[ChangedGcs]: |
| """Gets all GCS entries that have changed between the two provided DEPS. |
| |
| Args: |
| angle_deps: The parsed content of the ANGLE DEPS file. |
| chromium_deps: The parsed content of the Chromium DEPS file. |
| |
| Returns: |
| A list of all GCS DEPS entries that have changed between the two DEPS |
| files. |
| """ |
| changed_gcs = [] |
| for angle_name in SYNCED_GCS_DEPS: |
| chromium_name = 'src/' + angle_name |
| if angle_name not in angle_deps['deps']: |
| raise RuntimeError(f'Unable to find ANGLE GCS entry {angle_name}. Was it removed?') |
| if chromium_name not in chromium_deps['deps']: |
| raise RuntimeError(f'Unable to find Chromium GCS entry {chromium_name}. Was it ' |
| f'removed?') |
| |
| angle_objects = [GcsObject.from_dict(o) for o in angle_deps['deps'][angle_name]['objects']] |
| chromium_objects = [ |
| GcsObject.from_dict(o) for o in chromium_deps['deps'][chromium_name]['objects'] |
| ] |
| if angle_objects != chromium_objects: |
| changed_gcs.append( |
| ChangedGcs( |
| name=angle_name, |
| old_objects=angle_objects, |
| new_objects=chromium_objects, |
| )) |
| return changed_gcs |
| |
| |
| def _get_changed_non_exported_repos(angle_deps: dict, chromium_deps: dict) -> list[ChangedRepo]: |
| """Gets all non-exported repos that have changed between the DEPS files. |
| |
| Args: |
| angle_deps: The parsed content of the ANGLE DEPS file. |
| chromium_deps: The parsed content of the Chromium DEPS file. |
| |
| Returns: |
| A list of all repo entries that have changed between the two DEPS files |
| that are not exported pseudo-repos from Chromium. |
| """ |
| changed_repos = [] |
| for angle_name, chromium_name in SYNCED_REPOS.items(): |
| chromium_name = chromium_name or angle_name |
| chromium_name = 'src/' + chromium_name |
| if angle_name not in angle_deps['deps']: |
| raise RuntimeError(f'Unable to find ANGLE repo {angle_name}. Was it removed?') |
| if chromium_name not in chromium_deps['deps']: |
| raise RuntimeError(f'Unable to find Chromium repo {chromium_name}. Was it ' |
| f'removed?') |
| |
| url, angle_revision = _get_url_and_revision( |
| _get_raw_url_for_dep_entry(angle_name, angle_deps), angle_deps['vars']) |
| _, chromium_revision = _get_url_and_revision( |
| _get_raw_url_for_dep_entry(chromium_name, chromium_deps), chromium_deps['vars']) |
| if angle_revision != chromium_revision: |
| changed_repos.append( |
| ChangedRepo( |
| name=angle_name, |
| url=url, |
| old_revision=angle_revision, |
| new_revision=chromium_revision, |
| )) |
| return changed_repos |
| |
| |
| def _get_changed_exported_repos(angle_deps: dict) -> list[ChangedRepo]: |
| """Gets all exported repos that have changed since the last roll. |
| |
| Args: |
| angle_deps: The parsed content of the ANGLE DEPS file. |
| |
| Returns: |
| A list of all repo entries for exported pseudo-repos from Chromium |
| whose HEAD revision is different from the revision currently used by |
| ANGLE. |
| """ |
| changed_repos = [] |
| for angle_name, chromium_path in EXPORTED_CHROMIUM_REPOS.items(): |
| chromium_path = chromium_path or angle_name |
| if angle_name not in angle_deps['deps']: |
| raise RuntimeError(f'Unable to find ANGLE repo {angle_name}. Was it removed?') |
| url, angle_revision = _get_url_and_revision( |
| _get_raw_url_for_dep_entry(angle_name, angle_deps), angle_deps['vars']) |
| head_revision = _get_remote_head_revision(url) |
| if angle_revision != head_revision: |
| changed_repos.append( |
| ChangedRepo( |
| name=angle_name, |
| url=url, |
| old_revision=angle_revision, |
| new_revision=head_revision, |
| )) |
| return changed_repos |
| |
| |
| def _get_raw_url_for_dep_entry(dep_name: str, deps: dict) -> str: |
| """Gets the URL associated with the specified DEPS entry. |
| |
| Args: |
| dep_name: The name of the DEPS entry to retrieve the URL for. |
| deps: The parsed DEPS content to extract information from. |
| |
| Returns: |
| A string containing the URL associated with |dep_name|. |
| """ |
| dep_entry = deps['deps'][dep_name] |
| # Most entries are dicts, but it's also valid for an entry to just be a |
| # git URL. |
| if isinstance(dep_entry, str): |
| return dep_entry |
| return dep_entry['url'] |
| |
| |
| def _get_url_and_revision(deps_url: str, vars: dict) -> tuple[str, str]: |
| """Extracts the repo URL and revision from a DEPS url value. |
| |
| Known substitutions are also performed on the URL, e.g. replacing |
| {chromium_git} with the actual Chromium git URL. |
| |
| Args: |
| deps_url: The URL to operate on. |
| vars: The 'vars' mapping from the parsed DEPS content that |deps_url| |
| came from. |
| |
| Returns: |
| A tuple (url, revision). |
| """ |
| url, revision = deps_url.rsplit('@', 1) |
| for var, value in vars.items(): |
| if not isinstance(value, str): |
| continue |
| search_str = f'{{{var}}}' |
| url = url.replace(search_str, value) |
| return url, revision |
| |
| |
| def _generate_commit_message(changed_entries: list[ChangedDepsEntry], |
| chromium_revision_range: ChangedRepo, autoroll: bool) -> str: |
| """Generates a commit message for a roll. |
| |
| Args: |
| changed_entries: A list of all ChangedDepsEntry for this roll. |
| chromium_revision_range: The ChangedRepo entry for Chromium for this |
| roll. |
| autoroll: Whether this roll is being done by the autoroller. |
| |
| Returns: |
| A string containing the commit message to use. |
| """ |
| commit_message_lines = [] |
| commit_message_lines.extend(_generate_chromium_section(chromium_revision_range, autoroll)) |
| commit_message_lines.append('') |
| commit_message_lines.extend(_generate_command_section()) |
| commit_message_lines.append('') |
| commit_message_lines.extend(_generate_repo_section(changed_entries)) |
| commit_message_lines.append('') |
| commit_message_lines.extend(_generate_cipd_section(changed_entries)) |
| commit_message_lines.append('') |
| commit_message_lines.extend(_generate_gcs_section(changed_entries)) |
| commit_message_lines.append('') |
| commit_message_lines.extend(_generate_variables_section(changed_entries)) |
| commit_message_lines.append('') |
| commit_message_lines.extend(_generate_footers_section(autoroll)) |
| return '\n'.join(commit_message_lines) |
| |
| |
| def _generate_chromium_section(chromium_revision_range: ChangedRepo, autoroll: bool) -> list[str]: |
| """Generates the commit message section for Chromium. |
| |
| Args: |
| chromium_revision_range: The ChangedRepo entry for Chromium for this |
| roll. |
| autoroll: Whether this roll is being done by the autoroller. |
| |
| Returns: |
| A list of strings containing lines to append to the commit message. |
| """ |
| chromium_lines = [] |
| # Autorolls already add information about chromium_revision to the commit |
| # message. |
| if autoroll: |
| return chromium_lines |
| |
| chromium_lines.extend([ |
| f'Roll chromium_revision {chromium_revision_range.revision_range()}', |
| f'', |
| f'Change log: {chromium_revision_range.log_link()}', |
| f'Full diff: {chromium_revision_range.commit_link()}', |
| ]) |
| return chromium_lines |
| |
| |
| def _generate_command_section() -> list[str]: |
| """Generates the commit message section for the script command. |
| |
| Returns: |
| A list of strings containing lines to append to the commit message. |
| """ |
| command_lines = [ |
| 'DEPS, submodule, and //infra/config changes generated by running:', |
| ] |
| script = pathlib.Path(__file__).resolve() |
| script = script.relative_to(ANGLE_ROOT) |
| relative_command = [str(script)] + sys.argv[1:] |
| command_lines.append(f' {shlex.join(relative_command)}') |
| return command_lines |
| |
| |
| def _generate_section_for_entry_type(entry_type: Type[ChangedDepsEntry], |
| changed_entries: list[ChangedDepsEntry], empty_message: str, |
| header: str) -> list[str]: |
| """Generates the commit message section for a given ChangedDepsEntry type. |
| |
| Args: |
| entry_type: The type of DEPS entry that should be included in this |
| section. |
| changed_entries: A list of all ChangedDepsEntry for this roll. |
| empty_message: The message to use if no matching entries are found. |
| header: The message to use at the beginning of the section if at least |
| one matching entry is found. |
| |
| Returns: |
| A list of strings containing lines to append to the commit message. |
| """ |
| matching_entries = [] |
| for ce in changed_entries: |
| if isinstance(ce, entry_type): |
| matching_entries.append(ce) |
| |
| if not matching_entries: |
| return [empty_message] |
| |
| matching_entries.sort(key=lambda e: e.name) |
| lines = [header] |
| for me in matching_entries: |
| lines.extend(me.commit_message_lines()) |
| return lines |
| |
| |
| def _generate_variables_section(changed_entries: list[ChangedDepsEntry]) -> list[str]: |
| """Generates the commit message section for changed variables. |
| |
| Args: |
| changed_entries: A list of all ChangedDepsEntry for this roll. |
| |
| Returns: |
| A list of strings containing lines to append to the commit message. |
| """ |
| return _generate_section_for_entry_type( |
| ChangedVariable, changed_entries, 'No explicitly synced GN variables changed in this roll', |
| 'Explicitly synced GN variables:') |
| |
| |
| def _generate_cipd_section(changed_entries: list[ChangedDepsEntry]) -> list[str]: |
| """Generates the commit message section for changed CIPD entries. |
| |
| Args: |
| changed_entries: A list of all ChangedDepsEntry for this roll. |
| |
| Returns: |
| A list of strings containing lines to append to the commit message. |
| """ |
| return _generate_section_for_entry_type(ChangedCipd, changed_entries, |
| 'No CIPD entries changed in this roll', |
| 'CIPD entries:') |
| |
| |
| def _generate_gcs_section(changed_entries: list[ChangedDepsEntry]) -> list[str]: |
| """Generates the commit message section for changed GCS entries. |
| |
| Args: |
| changed_entries: A list of all ChangedDepsEntry for this roll. |
| |
| Returns: |
| A list of strings containing lines to append to the commit message. |
| """ |
| return _generate_section_for_entry_type(ChangedGcs, changed_entries, |
| 'No GCS entries changed in this roll', 'GCS entries:') |
| |
| |
| def _generate_repo_section(changed_entries: list[ChangedDepsEntry]) -> list[str]: |
| """Generates the commit message section for change repo entries. |
| |
| Args: |
| changed_entries: A list of all ChangedDepsEntry for this roll. |
| |
| Returns: |
| A list of strings containing lines to append to the commit message. |
| """ |
| return _generate_section_for_entry_type(ChangedRepo, changed_entries, |
| 'No repo entries changed in this roll', |
| 'Repo entries:') |
| |
| |
| def _generate_footers_section(autoroll: bool) -> list[str]: |
| """Generates the commit message section for CL footers. |
| |
| Args: |
| autoroll: Whether this roll is being done by the autoroller. |
| |
| Returns: |
| A list of strings containing lines to append to the commit message. |
| """ |
| lines = [] |
| if not autoroll: |
| lines.append('Bug: None') |
| return lines |
| |
| |
| def _apply_changed_deps(changed_entries: list[ChangedDepsEntry]) -> None: |
| """Applies all changed DEPS entries to the ANGLE DEPS file. |
| |
| Args: |
| changed_entries: All calculated ChangedDepsEntry objects. |
| """ |
| cmd = [ |
| 'gclient', |
| 'setdep', |
| ] |
| for ce in changed_entries: |
| cmd.extend(ce.setdep_args()) |
| subprocess.run(cmd, check=True) |
| |
| |
| def _sync_starlark_packages(chromium_revision: str) -> list[ChangedRepo]: |
| """Syncs Starlark packages shared with Chromium. |
| |
| These are used by //infra/config and stored in |
| //infra/config/global/PACKAGE.star. |
| |
| Note: The returned entries are technically not DEPS entries (ChangedRepo |
| inherits from ChangedDepsEntry), but they similar enough that they can |
| treated as such as long as the returned ChangedRepos are not actually |
| applied to the DEPS file. |
| |
| Args: |
| chromium_revision: The Chromium revision to sync Starlark packages to. |
| |
| Returns: |
| A list of ChangedRepo specifying the old and new revisions for Starlark |
| packages. |
| """ |
| chromium_package_contents = _read_remote_chromium_file('infra/config/PACKAGE.star', |
| chromium_revision) |
| chromium_luci_package = '@chromium-luci' |
| new_chromium_luci_revision = _extract_starlark_package_revision(chromium_luci_package, |
| chromium_package_contents) |
| |
| with open(PACKAGE_STAR, encoding='utf-8') as infile: |
| angle_package_contents = infile.read() |
| angle_package_contents, old_chromium_luci_revision = ( |
| _exchange_starlark_package_revision(chromium_luci_package, angle_package_contents, |
| new_chromium_luci_revision)) |
| angle_package_contents, old_chromium_targets_revision = ( |
| _exchange_starlark_package_revision('@chromium-targets', angle_package_contents, |
| chromium_revision)) |
| with open(PACKAGE_STAR, 'w', encoding='utf-8') as outfile: |
| outfile.write(angle_package_contents) |
| |
| # Automatically stage any changes to be consistent with DEPS modifications. |
| _run_lucicfg_and_stage_changes() |
| |
| changed_packages = [ |
| ChangedRepo( |
| name='chromium-luci (Starlark)', |
| url=posixpath.join(CHROMIUM_GOB_URL, 'infra', 'chromium'), |
| old_revision=old_chromium_luci_revision, |
| new_revision=new_chromium_luci_revision), |
| # The chromium-targets package is not reported since the revision is |
| # identical to Chromium's revision, which is already reported. |
| ] |
| |
| # Only report actual changes so that unchanged packages are omitted from the |
| # CL description. |
| return [p for p in changed_packages if p.old_revision != p.new_revision] |
| |
| |
| def _run_lucicfg_and_stage_changes() -> None: |
| """Runs lucicfg on ANGLE's Starlark files and stages any changes.""" |
| subprocess.check_call( |
| ['lucicfg', 'generate', str(INFRA_PATH / 'main.star')], |
| stdout=subprocess.DEVNULL, |
| stderr=subprocess.DEVNULL) |
| |
| subprocess.check_call(['git', 'add', str(INFRA_PATH)], |
| stdout=subprocess.DEVNULL, |
| stderr=subprocess.DEVNULL) |
| |
| |
| def _exchange_starlark_package_revision(package_name: str, package_star_contents: str, |
| new_revision: str) -> tuple[str, str]: |
| """Exchanges the revision for a Starlark package. |
| |
| Args: |
| package_name: The Starlark package name to exchange the revision for, |
| including the leading @. |
| package_star_contents: The contents of a PACKAGE.star file to modify. |
| new_revision: The new revision to put in the file contents. |
| |
| Returns: |
| A tuple (exchanged_contents, old_revision). |exchanged_contents| is a |
| copy of |package_star_contents| with the revision for |package_name| |
| replaced with |new_revision|. |old_revision| is the revision for |
| |package_name| that was present before the exchange. |
| """ |
| old_revision = _extract_starlark_package_revision(package_name, package_star_contents) |
| package_star_contents = _replace_starlark_package_revision(package_name, new_revision, |
| package_star_contents) |
| return package_star_contents, old_revision |
| |
| |
| @functools.cache |
| def _get_starlark_package_regex_for(package_name: str) -> re.Pattern: |
| """Get a Pattern to match the given Starlark package definition. |
| |
| This Pattern is suitable for either extracting a revision or replacing it |
| with a new one. |
| |
| Args: |
| package_name: The Starlark package name to search for, including the |
| leading @. |
| |
| Returns: |
| A Pattern that matches |package_name| in PACKAGE.star. |
| """ |
| revision_pattern = re.compile( |
| # Group 1: Context prefix to ensure that we're matching the correct |
| # package entry. |
| rf'(name\s*=\s*"{package_name}".*?revision\s*=\s*")' |
| # Group 2: The hexadecimal revision to extract/replace. |
| r'([a-fA-F0-9]+)' |
| # Positive lookahead: Asserts that a " follows, but does not |
| # match/consume it. |
| r'(?=")', |
| re.DOTALL) |
| |
| return revision_pattern |
| |
| |
| def _extract_starlark_package_revision(package_name: str, package_star_contents: str) -> str: |
| """Extract a Starlark package revision from PACKAGE.star content. |
| |
| Args: |
| package_name: The Starlark package name to search for, including the |
| leading @. |
| package_star_contents: The contents of a PACKAGE.star file to search. |
| |
| Returns: |
| The git revision of the requested package. |
| """ |
| revision_pattern = _get_starlark_package_regex_for(package_name) |
| match = revision_pattern.search(package_star_contents) |
| if not match: |
| raise RuntimeError(f'Unable to extract {package_name} revision from PACKAGE.star ' |
| f'contents') |
| return match.group(2) |
| |
| |
| def _replace_starlark_package_revision(package_name: str, new_revision: str, |
| package_star_contents: str) -> str: |
| """Replace a Starlark package revision in PACKAGE.star content. |
| |
| package_name: The Starlark package name to search for, including the |
| leading @. |
| new_revision: The new revision to update the package to. |
| package_star_contents: The contents of a PACKAGE.star file to |
| find/replace in. |
| |
| Returns: |
| A copy of |package_star_contents| with the revision for |package_name| |
| updated to |new_revision|. |
| """ |
| revision_pattern = _get_starlark_package_regex_for(package_name) |
| # Replace the match with Group 1 (matched content before the revision) and |
| # the new revision itself. |
| updated_contents = revision_pattern.sub(rf'\g<1>{new_revision}', package_star_contents) |
| return updated_contents |
| |
| |
| def _parse_args() -> argparse.Namespace: |
| """Parses and returns command line arguments.""" |
| parser = argparse.ArgumentParser('Roll DEPS entries shared with Chromium.') |
| parser.add_argument('--verbose', '-v', action='store_true', help='Increase logging verbosity') |
| parser.add_argument('--autoroll', action='store_true', help='Run the script in autoroll mode') |
| parser.add_argument( |
| '--ignore-unclean-workdir', |
| action='store_true', |
| help='Ignore uncommitted changes and being on a non-main branch') |
| parser.add_argument( |
| '--revision', help=('A Chromium revision to roll to. If unspecified, ' |
| 'HEAD is used.')) |
| return parser.parse_args() |
| |
| |
| def main() -> None: |
| args = _parse_args() |
| if args.verbose: |
| logging.basicConfig(level=logging.DEBUG) |
| else: |
| logging.basicConfig(level=logging.INFO) |
| |
| # The autoroller does not have locally synced dependencies, so we cannot use |
| # the copy under //third_party. Instead, we have to assume that it has |
| # depot_tools in PATH already. |
| if not args.autoroll: |
| _add_depot_tools_to_path() |
| |
| if not args.ignore_unclean_workdir: |
| if not _is_tree_clean(): |
| raise RuntimeError('Uncommitted or untracked files found. Please commit them or ' |
| 'pass --ignore-unclean-workdir') |
| _ensure_updated_main_branch() |
| |
| with open(DEPS_FILE, encoding='utf-8') as infile: |
| angle_deps = _parse_deps_file(infile.read()) |
| revision_range = _get_roll_revision_range(args.revision, angle_deps) |
| chromium_deps = _parse_deps_file( |
| _read_remote_chromium_file('DEPS', revision_range.new_revision)) |
| changed_entries = _get_changed_deps_entries(angle_deps, chromium_deps) |
| |
| # We want these entries to be in the commit message, but we do not want them |
| # to be present for _apply_changed_deps() since they are not actually DEPS |
| # entries. |
| changed_packages = _sync_starlark_packages(revision_range.new_revision) |
| entries_for_commit_message = changed_entries + changed_packages |
| |
| # Create the commit message before adding the entry for the Chromium |
| # revision since Chromium information is explicitly added to the message. |
| commit_message = _generate_commit_message(entries_for_commit_message, revision_range, |
| args.autoroll) |
| # We change the variable directly instead of using ChangedRepo since |
| # 'gclient setdep --revision' does not work for repos if there is no entry |
| # in .gitmodules. |
| changed_entries.append( |
| ChangedVariable( |
| name=CHROMIUM_REVISION_VAR, |
| old_version=revision_range.old_revision, |
| new_version=revision_range.new_revision, |
| )) |
| |
| # When running as part of the autoroller, we update the existing commit |
| # on the current branch. |
| if not args.autoroll: |
| _create_roll_branch() |
| |
| _apply_changed_deps(changed_entries) |
| |
| if args.autoroll: |
| _amend_commit(commit_message) |
| else: |
| if _is_tree_clean(): |
| logging.info('No changes detected, skipping commit') |
| else: |
| _commit(commit_message) |
| logging.info('Changes committed locally and are ready for upload') |
| |
| |
| if __name__ == '__main__': |
| main() |