From d054a9cb346c8fb0d5c815b02027388d695d8db6 Mon Sep 17 00:00:00 2001 From: Jeremy Nimmer Date: Tue, 5 May 2026 12:17:24 -0700 Subject: [PATCH] [setup] Rewrite dpkg_install_from_wget in Python This removes the special case logic for "remove bazel first", but (1) now that we're installing with apt-get instead of dpkg, hopefully that doesn't matter in practice, and (2) upstream bazel hasn't shipped a new deb in several years and their apt site only goes up to 22.04, so hopefully this isn't a problem anymore. --- setup/BUILD.bazel | 1 + setup/install_prereqs.py | 248 ++++++++++++++++-- setup/test/install_prereqs_test.py | 169 +++++++++++- setup/ubuntu/install_prereqs.sh | 71 ----- setup/ubuntu/packages.json | 35 +++ .../bazelisk_internal/repository.bzl | 11 +- 6 files changed, 419 insertions(+), 116 deletions(-) create mode 100644 setup/ubuntu/packages.json diff --git a/setup/BUILD.bazel b/setup/BUILD.bazel index ac8671e04fdc..e6ee254bc9af 100644 --- a/setup/BUILD.bazel +++ b/setup/BUILD.bazel @@ -9,6 +9,7 @@ package(default_visibility = ["//visibility:private"]) PREREQUISITES_DATA = glob([ "**/Brewfile*", "**/*.lock", + "**/*.json", "**/*.toml", "**/*.txt", ]) diff --git a/setup/install_prereqs.py b/setup/install_prereqs.py index c694e86fc5c4..720816c93f65 100644 --- a/setup/install_prereqs.py +++ b/setup/install_prereqs.py @@ -8,6 +8,8 @@ import argparse import functools +import hashlib +import json import logging import os from pathlib import Path @@ -16,7 +18,10 @@ import shlex import subprocess import sys +import tempfile import textwrap +import urllib.parse +import urllib.request _MY_DIR: Path = Path(__file__).parent """The directory containing this script, used to locate our data assets.""" @@ -46,17 +51,43 @@ def _is_ubuntu() -> bool: return False +@functools.cache +def _os_codename() -> str: + if platform.system() == "Linux": + return platform.freedesktop_os_release()["VERSION_CODENAME"] + raise NotImplementedError(platform.system()) + + +def _check_sudo() -> None: + """Checks that 'sudo' has sufficient credentials.""" + # If sudo is already usable, then we're done. + process = _run(args=["sudo", "-n", "/bin/true"], check=False, quiet=True) + if process.returncode == 0: + return + # If not, then we need to refresh the credentials. N.B. This doesn't work in + # our CI environment, but the prior check should have passed in that case. + subprocess.check_call(["sudo", "-v"]) + + def _run( *, args: list, cwd: Path | None = None, + check: bool = True, + superuser: bool = False, quiet: bool = False, -) -> None: - """Runs a subprocess command given by `args`. Failure of the command is an - `_error`. When `quiet` is true, the command line will not be printed by - default. - """ + interactive: bool = False, +) -> subprocess.CompletedProcess: + """Runs a subprocess command given by `args`. When `check` is true, failure + of the command is an `_error`. When `superuser` is true, the command will + be run under 'sudo' unless the euid is already root. When `quiet` is + true, the command line will not be printed by default. When `interactive` + is true, input is allowed and output is unbuffered. Returns the completed + process object.""" command = args[0] + if superuser and os.geteuid() != 0: + _check_sudo() + args = ["sudo"] + args logging.log( msg=f"Running: {shlex.join(args)} ...", level=logging.DEBUG if quiet else logging.INFO, @@ -64,20 +95,183 @@ def _run( process = subprocess.run( args, cwd=cwd, - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL if not interactive else None, + stdout=subprocess.PIPE if not interactive else None, + stderr=subprocess.STDOUT if not interactive else None, text=True, ) - problem = process.returncode != 0 - for line in process.stdout.splitlines(): - logging.log( - msg=f"... from {command}: {line}", - level=logging.INFO if problem else logging.DEBUG, - ) + problem = check and (process.returncode != 0) + if process.stdout is not None: + for line in process.stdout.splitlines(): + logging.log( + msg=f"... from {command}: {line}", + level=logging.INFO if problem else logging.DEBUG, + ) logging.debug(f"... finished {command}.") if problem: _error(f"{command} failed with returncode {process.returncode}") + return process + + +def _get_dpkg_versions(package_names: list[str]) -> dict[str, str]: + """Returns the installed version of packages. The input is a list of + package names, and the return value is a dict mapping all of those + names to their installed version (or None, if not installed).""" + assert package_names + result = {} + for name in package_names: + result[name] = None + process = _run( + args=[ + "dpkg-query", + "--show", + "--showformat=${Package} ${db:Status-Abbrev} ${Version}\n", + ] + + package_names, + check=False, + quiet=True, + ) + for line in process.stdout.splitlines(): + tokens = line.split() + if len(tokens) != 3: + continue + name, status, version = tokens + if status == "ii": + result[name] = version + logging.debug(f"dpkg_versions = {result!r}") + return result + + +def _apt_install(*, package_names: list[str], yes: bool) -> None: + """Installs the given packages using 'apt-get'. + The `yes` flag is passed along to apt as `--yes`.""" + assert package_names + args = [ + "apt-get", + "install", + "--no-install-recommends", + ] + if yes: + args.append("--yes") + args.extend(package_names) + process = _run(args=args, superuser=True, check=yes) + if process.returncode == 0: + return + # We can only reach here when yes=False (i.e., check=False). The apt-get + # command didn't work, and the most likely reason is it needs Y/n input + # from the user, so we'll try it again allowing for user input. + _run(args=args, superuser=True, interactive=True, quiet=True) + + +def _download(*, temp_dir: Path, package: dict) -> Path: + """Downloads a `*.deb` package a denoted by the given `package` entry loaded + from the setup/ubuntu/packages.json file. Returns its path inside temp_dir. + """ + name = package["name"] + version = package["version"] + urls = package["urls"] + sha256 = package["sha256"] + + logging.info(f"Downloading {name} {version} ...") + + # Try each url in turn. + errors = [] + for url in urls: + logging.debug(f"Trying {url} ...") + basename = urllib.parse.urlparse(url).path.split("/")[-1] + temp_filename = temp_dir / basename + hasher = hashlib.sha256() + with temp_filename.open("wb") as f: + try: + with urllib.request.urlopen(url=url, timeout=30) as response: + while True: + data = response.read(4096) + if not data: + break + hasher.update(data) + f.write(data) + except OSError as e: + errors.append(f"Candidate {url} failed:\n{e}") + continue + download_sha256 = hasher.hexdigest() + if download_sha256 == sha256: + return temp_filename + errors.append( + f"Candidate {url} failed:\n" + f"Checksum mismatch; was {download_sha256} but wanted {sha256}." + ) + + # No downloads succeeded. + messages = "\n\n".join(errors) + _error(f"All downloads failed:\n\n{messages}") + + +def _install_downloaded_debs(*, yes: bool) -> None: + """Downloads and installs required debs for --developer that are not + available in Ubuntu's apt site. + The `yes` flag is passed along to apt as `--yes`.""" + deb_arch = { + "x86_64": "amd64", + "aarch64": "arm64", + }[platform.machine().lower()] + + # Load the list of packages and filter for the relevant ones. + json_filename = _MY_DIR / "ubuntu/packages.json" + packages = {} + for package in json.loads(json_filename.read_text(encoding="utf-8")): + assert package["type"] == "download_deb" + name = package["name"] + if deb_arch not in package["arches"]: + continue + if _os_codename() not in package["codenames"]: + continue + assert name not in packages, name + packages[name] = package + if not packages: + return + + # Check what's already installed in case we can skip some. + all_names = list(packages.keys()) + for name, installed_version in _get_dpkg_versions(all_names).items(): + desired_version = packages[name]["version"] + if installed_version is None: + # The package is missing; we will need to install it. + continue + + # Check if already installed at the exact version. + if installed_version == desired_version: + logging.debug(f"{name} already installed at the desired version.") + del packages[name] + continue + + # Check if already installed at a newer version. + comparison = _run( + args=[ + "dpkg", + "--compare-versions", + installed_version, + "gt", + desired_version, + ], + quiet=True, + check=False, + ) + if comparison.returncode == 0: + logging.info( + f"Not downgrading {name} from {installed_version=} " + f"to {desired_version=}." + ) + del packages[name] + continue + + # Download and install the necessary file(s). + if packages: + with tempfile.TemporaryDirectory(prefix="drake_prereqs_") as temp: + paths = [ + str(_download(temp_dir=Path(temp), package=package)) + for package in packages.values() + ] + _apt_install(package_names=paths, yes=yes) def _setup_user_environment(): @@ -92,12 +286,9 @@ def _setup_user_environment(): # Compute the bazel rcfile snippet. This is always created, but only needs # content for Drake Developers on Linux. bazelrc_content = "" - if sys.platform == "linux": - os_release = platform.freedesktop_os_release() + if _is_ubuntu(): developer_txt = ( - _MY_DIR - / "ubuntu" - / f"packages-{os_release['VERSION_CODENAME']}-developer.txt" + _MY_DIR / "ubuntu" / f"packages-{_os_codename()}-developer.txt" ) clang_re = re.compile("^clang-([0-9]+)$") for line in developer_txt.read_text(encoding="utf-8").splitlines(): @@ -191,12 +382,17 @@ def main(): "but don't install any system-wide packages." ), ) - for name in ("--without-update", "-y"): - parser.add_argument( - name, - action="store_true", - help="Ignored for forwards compatibility.", - ) + parser.add_argument( + "--without-update", + action="store_true", + help="Ignored for forwards compatibility.", + ) + parser.add_argument( + "-y", + action="store_true", + dest="yes", + help="Install without prompting for confirmation.", + ) parser.add_argument( "--verbose", action="store_true", @@ -208,6 +404,8 @@ def main(): # We are in the process of migrating our bash setup code into this file. # Anything not set up here was already setup by install_prereqs.sh. + if _is_ubuntu() and args.developer: + _install_downloaded_debs(yes=args.yes) if args.developer or args.user_environment_only: _setup_user_environment() if args.developer: diff --git a/setup/test/install_prereqs_test.py b/setup/test/install_prereqs_test.py index b71788b019e8..3236581c9984 100644 --- a/setup/test/install_prereqs_test.py +++ b/setup/test/install_prereqs_test.py @@ -1,7 +1,9 @@ +from collections.abc import Callable import logging import os from pathlib import Path import pickle +import re import subprocess import sys import tempfile @@ -11,6 +13,9 @@ from python import runfiles +EXPECTED_BAZELISK = "1.28.1" +EXPECTED_KCOV = "43+dfsg-1" + class InstallPrereqsActor: def __init__(self, *, test_case): @@ -81,6 +86,9 @@ def __init__(self, *, test_case): # Tests can use add_to_path() and remove_from_path() to fine-tune this. allowed = [ "bazel", + "dpkg", + "dpkg-query", + "sudo", ] for program in allowed: self.add_to_path(program) @@ -90,6 +98,13 @@ def __init__(self, *, test_case): self.returncode = None self.stdout = None + # Track whether the setup program has performed these actions yet. + self._did_sudo_check = False + + # The list of currently-installed packages to report to install_prereqs; + # a mapping of name => version number. + self.installed_packages = {} + def _set_up_source(self) -> Path: """Prepares a source-tree-like writable temporary directory that contains the install_prereqs script and its data dependencies. @@ -169,15 +184,32 @@ def finish(self): logging.info(f" [stdout] {line}") self.returncode = self._process.returncode - def expect_call(self, *, exact=None, stdout="", returncode=0): - """Between `start()` and `finish()`, wait for install_prereqs to call - out to a subprocess and mock up the effects of that call. The mocked - call will return the given `returncode` and `stdout` content. The mocked - command line is specified as an `exact` list of arguments (where the - first argument is the command name). + def expect_call( + self, + expected_argv: list[str], + *, + stdout: str | Callable[[list[str]], str] = "", + returncode: int = 0, + ) -> list[str]: + """Between `start()` and `finish()`, waits for install_prereqs to call + out to a subprocess and mocks up the effects of that call. + + The expected command line is given by `expected_argv`; the first element + is the command name. The actual arguments passed by install_prereqs must + match `expected_argv` with one exception: if the last item in is "...", + then only the arguments prior to that must match. + + The mocked call will print the given `stdout` content, which can either + be a `str` or a callable that is given the argv and returns a `str`. + + The mocked call will exit with the given `returncode`. + + This method returns the mocked call's actual argv. """ # Print now in case we get stuck. - command = exact[0] + command = expected_argv[0] + if command == "sudo" and expected_argv[1][0] != "-": + command = " ".join(expected_argv[:2]) logging.info(f"Waiting for subprocess call to {command} ...") # Wait for the "stubby" subprocess to dump its argv. @@ -187,7 +219,7 @@ def expect_call(self, *, exact=None, stdout="", returncode=0): self.finish() self._test_case.fail("install_prereqs terminated unexpectedly") try: - argv = pickle.loads((self._io / "argv.pkl").read_bytes()) + actual_argv = pickle.loads((self._io / "argv.pkl").read_bytes()) break except Exception: time.sleep(0.1) @@ -195,7 +227,11 @@ def expect_call(self, *, exact=None, stdout="", returncode=0): raise TimeoutError() (self._io / "argv.pkl").unlink() - # Tell it what to do. + # Compute stdout if necessary. + if callable(stdout): + stdout = stdout(actual_argv) + + # Tell stubby what to do. result = dict( stdout=stdout, returncode=returncode, @@ -203,10 +239,43 @@ def expect_call(self, *, exact=None, stdout="", returncode=0): (self._io / "result.pkl").write_bytes(pickle.dumps(result)) # Strip the useless directory name off of the actual command. - argv[0] = argv[0].split("/")[-1] + actual_argv[0] = actual_argv[0].split("/")[-1] # Validate the called program and its arguments. - self._test_case.assertEqual(argv, exact) + if expected_argv[-1] == "...": + expected_prefix = expected_argv[:-1] + actual_prefix = actual_argv[: len(expected_prefix)] + self._test_case.assertEqual(actual_prefix, expected_prefix) + else: + self._test_case.assertEqual(actual_argv, expected_argv) + + return actual_argv + + def expect_sudo_check_if_not_yet_checked(self): + if self._did_sudo_check: + return + self.expect_call(["sudo", "-n", "/bin/true"]) + self._did_sudo_check = True + + def expect_dpkg_query(self): + def _reply(argv): + stdout = "" + for arg in argv[1:]: + if arg.startswith("-"): + # Skip over flags. + continue + if arg in self.installed_packages: + version = self.installed_packages[arg] + stdout += f"{arg} ii {version}\n" + return stdout + + self.expect_call(["dpkg-query", "..."], stdout=_reply) + + def expect_apt_install(self): + self.expect_sudo_check_if_not_yet_checked() + argv = self.expect_call(["sudo", "apt-get", "install", "..."]) + package_names = [arg for arg in argv[3:] if not arg.startswith("-")] + return package_names class InstallPrereqsTest(unittest.TestCase): @@ -229,10 +298,82 @@ def test_user_environment_only(self): self.assertTrue((dut.source() / "gen/environment.bazelrc").exists()) self.assertEqual(dut.returncode, 0) - def test_developer(self): + def test_developer_bootstrap(self): + """Check --developer with nothing installed yet.""" dut = InstallPrereqsActor(test_case=self) - dut.start(args=["--developer"]) - dut.expect_call(exact=["bazel", "version"]) + dut.start(args=["--developer", "-y"]) + + if sys.platform != "darwin": + # The DUT should install bazelisk and maybe kcov (after confirming + # that they are missing). + dut.expect_dpkg_query() + paths = dut.expect_apt_install() + filenames = sorted([x.split("/")[-1] for x in paths]) + names = set([re.split("[-_]", x)[0] for x in filenames]) + self.assertIn(names, ({"bazelisk"}, {"bazelisk", "kcov"})) + + # The DUT prefetches bazel. + dut.expect_call(["bazel", "version"]) + + dut.finish() + self.assertRegex(dut.stdout, "Writing.*gen/python_version.txt") + self.assertRegex(dut.stdout, "Writing.*gen/environment.bazelrc") + self.assertRegex(dut.stdout, "Pre-fetching bazel") + self.assertTrue((dut.source() / "gen/python_version.txt").exists()) + self.assertTrue((dut.source() / "gen/environment.bazelrc").exists()) + self.assertEqual(dut.returncode, 0) + + def test_developer_bump(self): + """Check --developer with some things already installed, but at too-old + versions.""" + dut = InstallPrereqsActor(test_case=self) + dut.installed_packages = { + "bazelisk": "0.0.0", + "kcov": EXPECTED_KCOV, + } + dut.start(args=["--developer", "-y"]) + + if sys.platform != "darwin": + # The DUT should install bazelisk (after confirming the current + # version is too old). + dut.expect_dpkg_query() + dut.expect_call( + ["dpkg", "--compare-versions", "..."], + returncode=1, + ) + paths = dut.expect_apt_install() + self.assertEqual(len(paths), 1) + name = paths[0].split("/")[-1].split("-")[0] + self.assertEqual(name, "bazelisk") + + # The DUT prefetches bazel. + dut.expect_call(["bazel", "version"]) + + dut.finish() + self.assertRegex(dut.stdout, "Writing.*gen/python_version.txt") + self.assertRegex(dut.stdout, "Writing.*gen/environment.bazelrc") + self.assertRegex(dut.stdout, "Pre-fetching bazel") + self.assertTrue((dut.source() / "gen/python_version.txt").exists()) + self.assertTrue((dut.source() / "gen/environment.bazelrc").exists()) + self.assertEqual(dut.returncode, 0) + + def test_developer_completed(self): + """Check --developer when everything is already installed (as if a + prior run had already succeeded).""" + dut = InstallPrereqsActor(test_case=self) + dut.installed_packages = { + "bazelisk": EXPECTED_BAZELISK, + "kcov": EXPECTED_KCOV, + } + dut.start(args=["--developer", "-y"]) + + if sys.platform != "darwin": + # The DUT confirms that bazelisk (etc) is already installed. + dut.expect_dpkg_query() + + # The DUT prefetches bazel. + dut.expect_call(["bazel", "version"]) + dut.finish() self.assertRegex(dut.stdout, "Writing.*gen/python_version.txt") self.assertRegex(dut.stdout, "Writing.*gen/environment.bazelrc") diff --git a/setup/ubuntu/install_prereqs.sh b/setup/ubuntu/install_prereqs.sh index dba4b574e758..858b19a21e9d 100644 --- a/setup/ubuntu/install_prereqs.sh +++ b/setup/ubuntu/install_prereqs.sh @@ -36,47 +36,6 @@ while [ "${1:-}" != "" ]; do shift done -# ================================== Functions ================================= - -dpkg_install_from_wget() { - package="$1" - version="$2" - url="$3" - checksum="$4" - - # Skip the install if we're already at the exact version. - installed=$(dpkg-query --showformat='${Version}\n' --show "${package}" 2>/dev/null || true) - if [[ "${installed}" == "${version}" ]]; then - echo "${package} is already at the desired version ${version}" - return - fi - - # If installing our desired version would be a downgrade, ask the user first. - if dpkg --compare-versions "${installed}" gt "${version}"; then - echo "This system has ${package} version ${installed} installed." - echo "Drake suggests downgrading to version ${version}, our supported version." - read -r -p 'Do you want to downgrade? [Y/n] ' reply - if [[ ! "${reply}" =~ ^([yY][eE][sS]|[yY])*$ ]]; then - echo "Skipping ${package} ${version} installation." - return - fi - fi - - # Download and verify. - tmpdeb="/tmp/${package}_${version}-$(dpkg-architecture -qDEB_HOST_ARCH).deb" - wget -O "${tmpdeb}" "${url}" - if echo "${checksum} ${tmpdeb}" | sha256sum -c -; then - echo # Blank line between checkout output and dpkg output. - else - echo "ERROR: The ${package} deb does NOT have the expected SHA256. Not installing." >&2 - exit 2 - fi - - # Install. - ${maybe_sudo} dpkg -i "${tmpdeb}" - rm "${tmpdeb}" -} - # =============================== Binary prereqs =============================== # Dependencies that are installed by the following sourced script that are @@ -88,8 +47,6 @@ source "${BASH_SOURCE%/*}/install_prereqs_binary.sh" "${binary_args[@]}" readonly workspace_dir="$(cd "$(dirname "${BASH_SOURCE}")/../.." && pwd)" -${maybe_sudo} apt-get install ${maybe_yes} --no-install-recommends wget - packages=$(cat "${BASH_SOURCE%/*}/packages-${VERSION_CODENAME}-build.txt") ${maybe_sudo} apt-get install ${maybe_yes} --no-install-recommends ${packages} @@ -126,34 +83,6 @@ fi if [[ "${developer}" -eq 1 ]]; then packages=$(cat "${BASH_SOURCE%/*}/packages-${VERSION_CODENAME}-developer.txt") ${maybe_sudo} apt-get install ${maybe_yes} --no-install-recommends ${packages} - - # Install bazelisk. - # If bazel.deb is already installed, we'll need to remove it first because - # the Debian package of bazelisk will take over the `/usr/bin/bazel` path. - ${maybe_sudo} apt-get remove bazel || true - if [[ $(arch) = "aarch64" ]]; then - dpkg_install_from_wget \ - bazelisk 1.28.1 \ - https://github.com/bazelbuild/bazelisk/releases/download/v1.28.1/bazelisk-arm64.deb \ - 47f787fe814c1bbc3b414ec5a876de706fd12fd0fa62f51549f3487575a827f3 - else - dpkg_install_from_wget \ - bazelisk 1.28.1 \ - https://github.com/bazelbuild/bazelisk/releases/download/v1.28.1/bazelisk-amd64.deb \ - 16c2d58a2e78171cf8db21bb4ae7908d91d9cf54ba62efb140d2a42731a3ed60 - fi - - # Install kcov. - if [[ "${VERSION_CODENAME}" == "noble" ]]; then - # Because Noble does not offer kcov natively, this file was - # mirrored from Ubuntu 25.04 Plucky at https://packages.ubuntu.com/plucky/kcov. - if [[ $(arch) = "x86_64" ]]; then - dpkg_install_from_wget \ - kcov 43+dfsg-1 \ - https://drake-mirror.csail.mit.edu/ubuntu/pool/universe/k/kcov/kcov_43%2Bdfsg-1_amd64.deb \ - d192fd3cfd0d63e95f13a1b2120d0603a31b3a034b82c618d5e18205517d5cbb - fi - fi fi # ================================== Finished ================================== diff --git a/setup/ubuntu/packages.json b/setup/ubuntu/packages.json new file mode 100644 index 000000000000..1021104aa781 --- /dev/null +++ b/setup/ubuntu/packages.json @@ -0,0 +1,35 @@ +[ + { + "type": "download_deb", + "name": "bazelisk", + "version": "1.28.1", + "arches": ["amd64"], + "codenames": ["noble", "resolute"], + "sha256": "16c2d58a2e78171cf8db21bb4ae7908d91d9cf54ba62efb140d2a42731a3ed60", + "urls": [ + "https://github.com/bazelbuild/bazelisk/releases/download/v1.28.1/bazelisk-amd64.deb" + ] + }, + { + "type": "download_deb", + "name": "bazelisk", + "version": "1.28.1", + "arches": ["arm64"], + "codenames": ["noble", "resolute"], + "sha256": "47f787fe814c1bbc3b414ec5a876de706fd12fd0fa62f51549f3487575a827f3", + "urls": [ + "https://github.com/bazelbuild/bazelisk/releases/download/v1.28.1/bazelisk-arm64.deb" + ] + }, + { + "type": "download_deb", + "name": "kcov", + "version": "43+dfsg-1", + "arches": ["amd64"], + "codenames": ["noble"], + "sha256": "d192fd3cfd0d63e95f13a1b2120d0603a31b3a034b82c618d5e18205517d5cbb", + "urls": [ + "https://drake-mirror.csail.mit.edu/ubuntu/pool/universe/k/kcov/kcov_43%2Bdfsg-1_amd64.deb" + ] + } +] diff --git a/tools/workspace/bazelisk_internal/repository.bzl b/tools/workspace/bazelisk_internal/repository.bzl index a805212a7e47..941058fb9817 100644 --- a/tools/workspace/bazelisk_internal/repository.bzl +++ b/tools/workspace/bazelisk_internal/repository.bzl @@ -15,12 +15,11 @@ def bazelisk_internal_repository( bazel-drake/external/+internal_repositories+bazelisk_internal/LICENSE \\ bazel-drake/external/+internal_repositories+bazelisk_internal/bazelisk.py - Additionally, you must manually update the bazelisk version number in - setup/ubuntu/install_prereqs.sh - and adjust the expected checksums accordingly. - - To calculate the checksums, download the deb files specifed in - install_prereqs.sh and use: + Additionally, you must manually update the version number(s) in + setup/ubuntu/packages.json + and adjust the expected checksum(s) accordingly. + To calculate a new checksum, download the deb file specifed in the json + and use: shasum -a 256 'xxx.deb' To fully test, a Linux uprovisioned job must be launched from the