Add Chromium autoroller rough draft

Adds the initial rough draft of the Chromium -> Dawn DEPS autorolling
script. This is missing a number of features required for actually
autorolling such as CL description generation, but is capable of
locally generating valid rolls.

Bug: 452840620
Change-Id: Ic1368a99e6f2a654625e64121cd3849ea24016ea
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/271234
Reviewed-by: dan sinclair <dsinclair@chromium.org>
Reviewed-by: Yuly Novikov <ynovikov@chromium.org>
Commit-Queue: Brian Sheedy <bsheedy@google.com>
diff --git a/DEPS b/DEPS
index 77c7e1a..ea0d3b4 100644
--- a/DEPS
+++ b/DEPS
@@ -63,6 +63,10 @@
 
   # Set to True by Chromium if syncing from a Chromium checkout.
   'build_with_chromium': False,
+
+  # NOTE: This is not currently accurate since no Chromium -> Dawn roll has
+  # been performed yet.
+  'chromium_revision': 'c2d941cd12644d6b893c305b1904e358727d597d',
 }
 
 deps = {
diff --git a/scripts/roll_chromium_deps.py b/scripts/roll_chromium_deps.py
new file mode 100755
index 0000000..a00253b
--- /dev/null
+++ b/scripts/roll_chromium_deps.py
@@ -0,0 +1,697 @@
+#!/usr/bin/env vpython3
+# Copyright 2025 The Dawn & Tint Authors
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice, this
+#    list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+#    this list of conditions and the following disclaimer in the documentation
+#    and/or other materials provided with the distribution.
+#
+# 3. Neither the name of the copyright holder nor the names of its
+#    contributors may be used to endorse or promote products derived from
+#    this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+"""Rolls DEPS entries shared with Chromium."""
+
+import abc
+import argparse
+import base64
+import dataclasses
+import logging
+import pathlib
+import posixpath
+import subprocess
+import sys
+from typing import Any, Self
+
+import requests
+
+DAWN_ROOT = pathlib.Path(__file__).resolve().parents[1]
+DEPS_FILE = DAWN_ROOT / 'DEPS'
+
+CHROMIUM_SRC_URL = 'https://chromium.googlesource.com/chromium/src'
+CHROMIUM_REVISION_VAR = 'chromium_revision'
+
+# GN variables that need to be synced. A map from Dawn 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/ninja',
+    'third_party/siso/cipd',
+}
+
+# DEPS entries which have dep_type = gcs. In the Chromium DEPS file, these will
+# be prefixed with src/.
+SYNCED_GCS_DEPS = {
+    'build/linux/debian_bullseye_arm64-sysroot',
+    'build/linux/debian_bullseye_armhf-sysroot',
+    'build/linux/debian_bullseye_i386-sysroot',
+    'build/linux/debian_bullseye_mipsel-sysroot',
+    'build/linux/debian_bullseye_mips64el-sysroot',
+    'build/linux/debian_bullseye_amd64-sysroot',
+}
+
+# Repos that are independently synced by Chromium and Dawn. A map from Dawn
+# names to Chromium names. None means that the names are identical. In the
+# Chromium DEPS file, these will be prefixed with src/.
+# The following repos are already synced by dedicated autorollers:
+# * https://autoroll.skia.org/r/angle-dawn-autoroll
+#   * third_party/angle
+# * https://autoroll.skia.org/r/swiftshader-dawn-autoroll
+#   * third_party/swiftshader
+# * https://autoroll.skia.org/r/vulkan-deps-dawn-autoroll
+#   * third_party/glslang/src
+#   * third_party/spirv-headers/src
+#   * third_party/spirv-tools/src
+#   * third_party/vulkan-deps
+#   * 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
+SYNCED_REPOS = {
+    'third_party/catapult': None,
+    'third_party/clang-format/script': None,
+    'third_party/depot_tools': None,
+    # third_party/dxheaders is technically used by both Chromium and Dawn, but
+    # for different purposes and on non-overlapping platforms. Thus, there is
+    # no need to sync their revisions.
+    'third_party/google_benchmark/src': None,
+    'third_party/googletest': 'third_party/googletest/src',
+    'third_party/jsoncpp': 'third_party/jsoncpp/source',
+    'third_party/libc++/src': None,
+    'third_party/libc++abi/src': None,
+    'third_party/libprotobuf-mutator/src': None,
+    'third_party/llvm-libc/src': None,
+    'third_party/libdrm/src': None,
+    'third_party/libFuzzer/src': None,
+    # third_party/vulkan_memory_allocator is shared with Chromium, but is
+    # manually rolled since it typically requires additional code changes in
+    # the repo.
+    # third_party/webgpu-cts is technically used by both Chromium and Dawn, but
+    # they are used for different purposes and the CTS roller needs to roll
+    # Dawn's copy in order to update expectations.
+}
+
+# Chromium directories that are exported as pseudo-repos in
+# chromium.googlesource.com under chromium/src/. Mapping of Dawn 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/jinja2': None,
+    'third_party/markupsafe': None,
+    'third_party/partition_alloc': 'base/allocator/partition_allocator',
+    'third_party/protobuf': None,
+    'third_party/zlib': None,
+    'tools/clang': None,
+    'tools/mb': None,
+    'tools/memory': None,
+    'tools/protoc_wrapper': None,
+    'tools/valgrind': None,
+    'tools/win': None,
+}
+
+
+class ChangedDepsEntry(abc.ABC):
+    """Base class for all changed DEPS entries."""
+
+    @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.
+        """
+
+
+@dataclasses.dataclass
+class ChangedVariable(ChangedDepsEntry):
+    """Represents a single changed DEPS variable."""
+    # The name of the variable in Dawn's DEPS file.
+    name: str
+    # The old version in Dawn's DEPS file.
+    old_version: str
+    # The new version that Dawn's DEPS file will contain.
+    new_version: str
+
+    def setdep_args(self) -> list[str]:
+        return [
+            '--var',
+            f'{self.name}={self.new_version}',
+        ]
+
+
+@dataclasses.dataclass
+class ChangedRepo(ChangedDepsEntry):
+    """Represents a single changed DEPS repo entry."""
+    # The name of the dependency in Dawn's DEPS file.
+    name: str
+    # The old revision in Dawn's DEPS file.
+    old_revision: str
+    # The new revision that Dawn's DEPS file will contain.
+    new_revision: str
+
+    def setdep_args(self) -> list[str]:
+        return [
+            '--revision',
+            f'{self.name}@{self.new_revision}',
+        ]
+
+
+@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 name of the dependency in Dawn's DEPS file.
+    name: str
+    # The old packages in Dawn's DEPS file
+    old_packages: list[CipdPackage]
+    # The new packages that Dawn'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
+
+
+@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 name of the dependency in Dawn's DEPS file.
+    name: str
+    # The old objects in Dawn's DEPS file.
+    old_objects: list[GcsObject]
+    # The new objects that Dawn'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 _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(DAWN_ROOT / 'build'))
+    import find_depot_tools
+    find_depot_tools.add_depot_tools_to_path()
+
+
+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',
+        remote_url,
+        'HEAD',
+    ]
+    proc = subprocess.run(cmd,
+                          check=True,
+                          text=True,
+                          stdout=subprocess.PIPE,
+                          stderr=subprocess.STDOUT)
+    head_revision = proc.stdout.strip().split()[0]
+    return head_revision
+
+
+def _get_roll_revision_range(target_revision: str | None,
+                             dawn_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.
+        dawn_deps: The parsed contents of the Dawn DEPS file.
+
+    Returns:
+        A ChangedRepo with the determined revision range.
+    """
+    old_revision = dawn_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',
+                       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(dawn_deps: dict,
+                              chromium_deps: dict) -> list[ChangedDepsEntry]:
+    """Gets all entries that have changed between the two provided DEPS.
+
+    Args:
+        dawn_deps: The parsed content of the Dawn 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(dawn_deps, chromium_deps))
+    changed_entries.extend(_get_changed_cipd(dawn_deps, chromium_deps))
+    changed_entries.extend(_get_changed_gcs(dawn_deps, chromium_deps))
+    changed_entries.extend(
+        _get_changed_non_exported_repos(dawn_deps, chromium_deps))
+    changed_entries.extend(_get_changed_exported_repos(dawn_deps))
+    return changed_entries
+
+
+def _get_changed_variables(dawn_deps: dict,
+                           chromium_deps: dict) -> list[ChangedVariable]:
+    """Gets all GN variables that have changed between the two provided DEPS.
+
+    Args:
+        dawn_deps: The parsed content of the Dawn 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 dawn_var, chromium_var in SYNCED_VARIABLES.items():
+        dawn_value = dawn_deps['vars'].get(dawn_var)
+        chromium_value = chromium_deps['vars'].get(chromium_var)
+        if not dawn_value:
+            raise RuntimeError(
+                f'Could not find Dawn GN variable {dawn_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=dawn_var,
+                old_version=dawn_value,
+                new_version=chromium_value,
+            ))
+    return changed_variables
+
+
+def _get_changed_cipd(dawn_deps: dict,
+                      chromium_deps: dict) -> list[ChangedCipd]:
+    """Gets all CIPD entries that have changed between the two provided DEPS.
+
+    Args:
+        dawn_deps: The parsed content of the Dawn 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 dawn_name in SYNCED_CIPD_DEPS:
+        chromium_name = 'src/' + dawn_name
+        if dawn_name not in dawn_deps['deps']:
+            raise RuntimeError(
+                f'Unable to find Dawn CIPD entry {dawn_name}. Was it removed?')
+        if chromium_name not in chromium_deps['deps']:
+            raise RuntimeErrror(
+                f'Unable to find Chromium CIPD entry {chromium_name}. Was it '
+                f'removed?')
+
+        dawn_packages = [
+            CipdPackage.from_dict(p)
+            for p in dawn_deps['deps'][dawn_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.
+        dawn_package_names = set(p.package for p in dawn_packages)
+        chromium_package_names = set(p.package for p in chromium_packages)
+        if not dawn_package_names.issubset(chromium_package_names):
+            raise RuntimeError(
+                f'Packages for CIPD entry {dawn_name} appear to have changed. '
+                f'Please manually sync the package list.')
+        if dawn_packages != chromium_packages:
+            changed_cipd.append(
+                ChangedCipd(
+                    name=dawn_name,
+                    old_packages=dawn_packages,
+                    new_packages=chromium_packages,
+                ))
+    return changed_cipd
+
+
+def _get_changed_gcs(dawn_deps: dict, chromium_deps: dict) -> list[ChangedGcs]:
+    """Gets all GCS entries that have changed between the two provided DEPS.
+
+    Args:
+        dawn_deps: The parsed content of the Dawn 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 dawn_name in SYNCED_GCS_DEPS:
+        chromium_name = 'src/' + dawn_name
+        if dawn_name not in dawn_deps['deps']:
+            raise RuntimeError(
+                f'Unable to find Dawn GCS entry {dawn_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?')
+
+        dawn_objects = [
+            GcsObject.from_dict(o)
+            for o in dawn_deps['deps'][dawn_name]['objects']
+        ]
+        chromium_objects = [
+            GcsObject.from_dict(o)
+            for o in chromium_deps['deps'][chromium_name]['objects']
+        ]
+        if dawn_objects != chromium_objects:
+            changed_gcs.append(
+                ChangedGcs(
+                    name=dawn_name,
+                    old_objects=dawn_objects,
+                    new_objects=chromium_objects,
+                ))
+    return changed_gcs
+
+
+def _get_changed_non_exported_repos(dawn_deps: dict,
+                                    chromium_deps: dict) -> list[ChangedRepo]:
+    """Gets all non-exported repos that have changed between the DEPS files.
+
+    Args:
+        dawn_deps: The parsed content of the Dawn 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 dawn_name, chromium_name in SYNCED_REPOS.items():
+        chromium_name = chromium_name or dawn_name
+        chromium_name = 'src/' + chromium_name
+        if dawn_name not in dawn_deps['deps']:
+            raise RuntimeError(
+                f'Unable to find Dawn repo {dawn_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?')
+
+        _, dawn_revision = _get_url_and_revision(
+            _get_raw_url_for_dep_entry(dawn_name, dawn_deps),
+            dawn_deps['vars'])
+        _, chromium_revision = _get_url_and_revision(
+            _get_raw_url_for_dep_entry(chromium_name, chromium_deps),
+            chromium_deps['vars'])
+        if dawn_revision != chromium_revision:
+            changed_repos.append(
+                ChangedRepo(
+                    name=dawn_name,
+                    old_revision=dawn_revision,
+                    new_revision=chromium_revision,
+                ))
+    return changed_repos
+
+
+def _get_changed_exported_repos(dawn_deps: dict) -> list[ChangedRepo]:
+    """Gets all exported repos that have changed since the last roll.
+
+    Args:
+        dawn_deps: The parsed content of the Dawn 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
+        Dawn.
+    """
+    changed_repos = []
+    for dawn_name, chromium_path in EXPORTED_CHROMIUM_REPOS.items():
+        chromium_path = chromium_path or dawn_name
+        if dawn_name not in dawn_deps['deps']:
+            raise RuntimeError(
+                f'Unable to find Dawn repo {dawn_name}. Was it removed?')
+        url, dawn_revision = _get_url_and_revision(
+            _get_raw_url_for_dep_entry(dawn_name, dawn_deps),
+            dawn_deps['vars'])
+        head_revision = _get_remote_head_revision(url)
+        if dawn_revision != head_revision:
+            changed_repos.append(
+                ChangedRepo(
+                    name=dawn_name,
+                    old_revision=dawn_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 _apply_changed_deps(changed_entries: list[ChangedDepsEntry]) -> None:
+    """Applies all changed DEPS entries to the Dawn 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 _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('--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.
+    if not args.autoroll:
+        _add_depot_tools_to_path()
+
+    with open(DEPS_FILE, encoding='utf-8') as infile:
+        dawn_deps = _parse_deps_file(infile.read())
+    revision_range = _get_roll_revision_range(args.revision, dawn_deps)
+    chromium_deps = _parse_deps_file(
+        _read_remote_chromium_file('DEPS', revision_range.new_revision))
+    changed_entries = _get_changed_deps_entries(dawn_deps, chromium_deps)
+    changed_entries.append(
+        ChangedVariable(
+            name=CHROMIUM_REVISION_VAR,
+            old_version=revision_range.old_revision,
+            new_version=revision_range.new_revision,
+        ))
+    _apply_changed_deps(changed_entries)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/scripts/roll_chromium_deps.py.vpython3 b/scripts/roll_chromium_deps.py.vpython3
new file mode 100644
index 0000000..e2c3812
--- /dev/null
+++ b/scripts/roll_chromium_deps.py.vpython3
@@ -0,0 +1,60 @@
+# This is a vpython "spec" file.
+#
+# It describes patterns for python wheel dependencies of the python scripts in
+# the chromium repo, particularly for dependencies that have compiled components
+# (since pure-python dependencies can be easily vendored into third_party).
+#
+# When vpython is invoked, it finds this file and builds a python VirtualEnv,
+# containing all of the dependencies described in this file, fetching them from
+# CIPD (the "Chrome Infrastructure Package Deployer" service). Unlike `pip`,
+# this never requires the end-user machine to have a working python extension
+# compilation environment. All of these packages are built using:
+#   https://chromium.googlesource.com/infra/infra/+/main/infra/tools/dockerbuild/
+#
+# All python scripts in the repo share this same spec, to avoid dependency
+# fragmentation.
+#
+# If you have depot_tools installed in your $PATH, you can invoke python scripts
+# in this repo by running them as you normally would run them, except
+# substituting `vpython` instead of `python` on the command line, e.g.:
+#   vpython path/to/script.py some --arguments
+#
+# Read more about `vpython` and how to modify this file here:
+#   https://chromium.googlesource.com/infra/infra/+/main/doc/users/vpython.md
+
+python_version: "3.11"
+
+# The default set of platforms vpython checks does not yet include mac-arm64.
+# Setting `verify_pep425_tag` to the list of platforms we explicitly must support
+# allows us to ensure that vpython specs stay mac-arm64-friendly
+verify_pep425_tag: [
+    {python: "cp311", abi: "cp311", platform: "manylinux1_x86_64"},
+    {python: "cp311", abi: "cp311", platform: "linux_arm64"},
+
+    {python: "cp311", abi: "cp311", platform: "macosx_10_10_intel"},
+    {python: "cp311", abi: "cp311", platform: "macosx_11_0_arm64"},
+
+    {python: "cp311", abi: "cp311", platform: "win32"},
+    {python: "cp311", abi: "cp311", platform: "win_amd64"}
+]
+
+wheel: <
+  name: "infra/python/wheels/requests-py3"
+  version: "version:2.32.5"
+>
+wheel: <
+  name: "infra/python/wheels/charset_normalizer-py3"
+  version: "version:2.0.12"
+>
+wheel: <
+  name: "infra/python/wheels/idna-py3"
+  version: "version:3.10"
+>
+wheel: <
+  name: "infra/python/wheels/urllib3-py3"
+  version: "version:2.5.0"
+>
+wheel: <
+  name: "infra/python/wheels/certifi-py3"
+  version: "version:2025.8.3"
+>
\ No newline at end of file