diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index 08171736f..2817eeb96 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -1,6 +1,13 @@ # # Based on https://github.com/release-plz/release-plz/blob/a5043c478d46d051c00e4fbc85036ac22510f07e/.github/workflows/cd.yml # +# N.B. scripts/install/install.sh depends on details of this workflow: its path (attestations are +# verified against it), the `brush-shell-v*` release tags, the `brush-.tar.gz` and +# `brush-.sha256` asset names, the `brush` binary at the archive root, and the glibc +# floor of the `*-linux-gnu` builds (2.34, set by the ubuntu-22.04 runners; the script picks the +# gnu build only on glibc 2.34+ and the musl build otherwise). Changing any of these breaks the +# install script for new releases; update it (and its tests) to match. +# name: CD # Continuous Deployment run-name: CD${{ github.event_name == 'release' && ' (release)' || ' (dry run)' }} diff --git a/.github/workflows/install-script.yaml b/.github/workflows/install-script.yaml new file mode 100644 index 000000000..23d02ba0e --- /dev/null +++ b/.github/workflows/install-script.yaml @@ -0,0 +1,77 @@ +name: "Install script" + +on: + pull_request: + paths: + - "scripts/install/**" + - ".github/workflows/install-script.yaml" + push: + branches: ["main"] + paths: + - "scripts/install/**" + - ".github/workflows/install-script.yaml" + # Weekly, to catch release changes (e.g., renamed assets) that break the script. + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: null + +permissions: {} + +# Only allow one run of the workflow per branch / PR at a time. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + name: "Test (${{ matrix.name }})" + + permissions: + contents: read + attestations: read # needed for `gh attestation verify` + + strategy: + fail-fast: false + matrix: + include: + - name: "linux-x86_64" + os: ubuntu-latest + - name: "linux-aarch64" + os: ubuntu-24.04-arm + - name: "macos-aarch64" + os: macos-latest + - name: "macos-x86_64" + os: macos-15-intel + # musl + busybox userland, no gh. + - name: "alpine" + os: ubuntu-latest + container: "alpine:3" + prepare: "apk add --no-cache bash curl" + # glibc too old for the gnu build; exercises the musl fallback. + - name: "almalinux-8" + os: ubuntu-latest + container: "almalinux:8" + + runs-on: ${{ matrix.os }} + container: ${{ matrix.container }} + timeout-minutes: 15 + + steps: + - name: "Install container prerequisites" + if: ${{ matrix.prepare }} + shell: sh # bash may not be installed yet + run: ${{ matrix.prepare }} + + - name: "Checkout repository" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: "Install uv" + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + + - name: "Run tests" + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: uvx 'pytest==8.*' -p no:cacheprovider -v scripts/install/tests diff --git a/README.md b/README.md index f6c1743f1..fd52ef860 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ ### Quick start: ```console +$ curl --proto '=https' --tlsv1.2 -fsSL https://raw.githubusercontent.com/reubeno/brush/main/scripts/install/install.sh | sh $ cargo binstall brush-shell # using cargo-binstall $ brew install brush # using Homebrew $ pacman -S brush # Arch Linux @@ -140,7 +141,13 @@ cargo binstall brush-shell
🚀 Installing prebuilt binaries from GitHub -We publish prebuilt binaries of `brush` for Linux (x86_64, aarch64) and macOS (aarch64) to GitHub for official [releases](https://github.com/reubeno/brush/releases). You can manually download and extract the `brush` binary from one of the archives published there, or otherwise use the GitHub CLI to download it, e.g.: +We publish prebuilt binaries of `brush` for Linux (x86_64, aarch64) and macOS (x86_64, aarch64) to GitHub for official [releases](https://github.com/reubeno/brush/releases). The simplest way to install one is with our install script, which installs `brush` to `~/.local/bin`: + +```bash +curl --proto '=https' --tlsv1.2 -fsSL https://raw.githubusercontent.com/reubeno/brush/main/scripts/install/install.sh | sh +``` + +The script checks the download against its published SHA-256 checksum, which guards against a corrupted download but not a compromised release (the checksum comes from the same release). If an authenticated [GitHub CLI](https://cli.github.com/) is available, it also verifies the archive's build provenance attestation, confirming it was built by this repository's release workflow; pass `--require-attestation` to fail rather than proceed when that check can't be performed. See the comments at the top of [`scripts/install/install.sh`](scripts/install/install.sh) for all options. Alternatively, you can manually download and extract the `brush` binary from one of the archives published there, or otherwise use the GitHub CLI to download it, e.g.: ```bash gh release download --repo reubeno/brush --pattern "brush-x86_64-unknown-linux-gnu.*" diff --git a/scripts/install/install.sh b/scripts/install/install.sh new file mode 100755 index 000000000..899dc9f2e --- /dev/null +++ b/scripts/install/install.sh @@ -0,0 +1,269 @@ +#!/bin/sh +# +# Installs brush from official GitHub releases of reubeno/brush. +# +# Usage: +# curl --proto '=https' --tlsv1.2 -fsSL https://raw.githubusercontent.com/reubeno/brush/main/scripts/install/install.sh | sh +# +# To pass options, use `sh -s --`, e.g.: +# curl ... | sh -s -- --version 0.4.0 --require-attestation +# +# Options: +# --version Version to install (e.g. "0.4.0"); defaults to the latest release. +# --dir Directory to install into; defaults to $XDG_BIN_HOME, or ~/.local/bin. +# --require-attestation Fail if the build attestation can't be verified. +# +# The downloaded archive is always checked against its published SHA-256 checksum. +# If the GitHub CLI (gh) is installed and authenticated, the archive's build +# provenance attestation is also verified, confirming it was built by the official +# repository's release workflow for that release's tag. +# +# This script sticks to POSIX sh, so it runs under dash, busybox ash, bash, and zsh. +# + +set -eu + +REPO="reubeno/brush" +RELEASE_WORKFLOW="${REPO}/.github/workflows/cd.yaml" + +say() { + echo "brush-install: $*" >&2 +} + +die() { + say "error: $*" + exit 1 +} + +# curl with the settings every request here wants: https only (even across +# redirects), TLS 1.2 or newer, fail on HTTP errors, and retry transient failures. +fetch() { + curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSL --retry 3 "$@" +} + +download() { + url="$1" + destination="$2" + fetch -o "${destination}" "${url}" || die "failed to download ${url}" +} + +# macOS ships shasum rather than sha256sum. +if ! command -v sha256sum >/dev/null; then + sha256sum() { + shasum -a 256 "$@" + } +fi + +# Removes whatever this script has left on disk. Runs when the script exits for +# any reason, including errors and Ctrl-C. +cleanup() { + rm -rf "${tmp_dir}" + if [ -n "${staged_binary}" ]; then + rm -f "${staged_binary}" + fi +} + +parse_args() { + version="" + install_dir="" + require_attestation="" + + while [ $# -gt 0 ]; do + case "$1" in + --version) + [ -n "${2:-}" ] || die "--version requires a value" + version="$2" + shift + ;; + --dir) + [ -n "${2:-}" ] || die "--dir requires a value" + install_dir="$2" + shift + ;; + --require-attestation) + require_attestation=1 + ;; + *) + die "unknown option: $1 (options: --version , --dir , --require-attestation)" + ;; + esac + shift + done + + # ~/.local/bin is where the XDG base directory spec puts user executables. The spec + # has no variable for it, but XDG_BIN_HOME is a common extension (uv, among others). + if [ -z "${install_dir}" ] && [ -n "${XDG_BIN_HOME:-}" ]; then + install_dir="${XDG_BIN_HOME}" + elif [ -z "${install_dir}" ]; then + [ -n "${HOME:-}" ] || die "HOME is not set; use --dir " + install_dir="${HOME}/.local/bin" + fi +} + +# Sets `target` to the name of the release build for this machine. +detect_target() { + os="$(uname -s)" + arch="$(uname -m)" + + case "${arch}" in + x86_64 | amd64) + arch="x86_64" + ;; + aarch64 | arm64) + arch="aarch64" + ;; + *) + die "unsupported architecture: ${arch}" + ;; + esac + + case "${os}" in + Darwin) + target="${arch}-apple-darwin" + ;; + Linux) + # Prefer the glibc build, which needs glibc 2.34 or newer. Whenever that + # can't be confirmed (no getconf, a non-glibc libc, a sort without -V), + # fall back to the static musl build, which runs on any Linux. + glibc_version="$(getconf GNU_LIBC_VERSION 2>/dev/null | cut -d' ' -f2)" + # `sort -C` succeeds if its input is already in order; with -V that's a + # version comparison, so this asks whether 2.34 <= glibc_version. + if printf '2.34\n%s\n' "${glibc_version}" | sort -CV 2>/dev/null; then + target="${arch}-unknown-linux-gnu" + else + target="${arch}-unknown-linux-musl" + fi + ;; + *) + die "unsupported OS: ${os}" + ;; + esac +} + +# Sets `tag` to the git tag of the release to install. +resolve_release_tag() { + if [ -n "${version}" ]; then + tag="brush-shell-v${version#v}" + return + fi + + # Follow the "latest" redirect once, up front, so that every download and + # check below refers to the same release. + latest_url="$(fetch -I -o /dev/null -w '%{url_effective}' "https://github.com/${REPO}/releases/latest")" || + die "could not determine latest release" + tag="${latest_url##*/}" + case "${tag}" in + brush-shell-v*) ;; + *) die "could not determine latest release" ;; + esac +} + +# Downloads the release archive into `tmp_dir` and checks it against its +# published SHA-256 checksum. +download_archive() { + archive="brush-${target}.tar.gz" + release_url="https://github.com/${REPO}/releases/download/${tag}" + + say "downloading ${release_url}/${archive}" + download "${release_url}/${archive}" "${tmp_dir}/${archive}" + download "${release_url}/brush-${target}.sha256" "${tmp_dir}/${archive}.sha256" + + (cd "${tmp_dir}" && sha256sum -c "${archive}.sha256" >/dev/null) || die "checksum mismatch for ${archive}" + say "verified SHA-256 checksum" +} + +# Verifies the archive's build provenance attestation, when that's possible here. +# The attestation must come from the official release workflow, running on +# GitHub-hosted runners, for this release's tag. +verify_attestation() { + skip_reason="" + + if ! command -v gh >/dev/null; then + skip_reason="GitHub CLI (gh) is not installed" + elif ! gh attestation verify --help 2>/dev/null | grep -q -- --source-ref; then + skip_reason="GitHub CLI (gh) is too old (2.68 or newer is needed)" + else + gh_status=0 + gh attestation verify "${tmp_dir}/${archive}" \ + --repo "${REPO}" \ + --signer-workflow "${RELEASE_WORKFLOW}" \ + --source-ref "refs/tags/${tag}" \ + --deny-self-hosted-runners \ + >/dev/null 2>"${tmp_dir}/gh.err" || gh_status=$? + + if [ "${gh_status}" -eq 4 ]; then + # gh exits with 4 when it has no credentials; anything else is a real failure. + skip_reason="GitHub CLI (gh) is not authenticated" + elif [ "${gh_status}" -ne 0 ]; then + cat "${tmp_dir}/gh.err" >&2 + die "attestation verification failed for ${archive}" + fi + fi + + if [ -z "${skip_reason}" ]; then + say "verified GitHub build attestation" + elif [ -n "${require_attestation}" ]; then + die "cannot verify build attestation: ${skip_reason}" + else + say "note: ${skip_reason}, so the build attestation wasn't checked; an authenticated gh (2.68+) would also verify this build came from the official release workflow" + fi +} + +# Extracts the binary and moves it into place. +install_binary() { + tar -xzf "${tmp_dir}/${archive}" -C "${tmp_dir}" brush || die "failed to extract ${archive}" + + # Stage the binary next to its destination so that the final step is a rename. + # A rename is atomic, so an interrupted install never leaves a partial binary + # behind, and it works even while an older brush at that path is running + # (overwriting a running executable in place fails on Linux). Checking that the + # staged binary runs first means a build that can't run here never replaces a + # working one. + mkdir -p "${install_dir}" || die "cannot write to ${install_dir}" + staged_binary="$(mktemp "${install_dir}/.brush.tmp.XXXXXX")" || die "cannot write to ${install_dir}" + cp "${tmp_dir}/brush" "${staged_binary}" || die "cannot write to ${install_dir}" + chmod 755 "${staged_binary}" + + installed_version="$("${staged_binary}" --version)" || + die "the ${target} build failed to run on this system; see https://github.com/${REPO}#installation for other ways to install" + + mv -f "${staged_binary}" "${install_dir}/brush" || die "cannot write to ${install_dir}" + say "installed ${installed_version} to ${install_dir}/brush" +} + +# Lets the user know if running `brush` won't run what was just installed. +check_path() { + # Nothing above has run `brush` by name, so this is a fresh PATH search. + found="$(command -v brush)" || true + + # shellcheck disable=SC3013 # -ef isn't POSIX, but every sh we support has it. + if [ -z "${found}" ]; then + say "note: ${install_dir} is not in your PATH" + elif [ ! "${found}" -ef "${install_dir}/brush" ]; then + say "note: running 'brush' will run ${found}, not ${install_dir}/brush" + fi +} + +main() { + parse_args "$@" + detect_target + resolve_release_tag + + staged_binary="" + tmp_dir="$(mktemp -d)" + trap cleanup EXIT + # Some shells (dash, busybox ash) skip the EXIT trap when killed by a signal; + # exiting from the signal handler makes sure cleanup runs everywhere. The exit + # codes are the usual 128 + signal number. + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + + download_archive + verify_attestation + install_binary + check_path +} + +# Wrapped in a function so that a partially downloaded script never runs. +main "$@" diff --git a/scripts/install/tests/test_install.py b/scripts/install/tests/test_install.py new file mode 100644 index 000000000..b2327a2aa --- /dev/null +++ b/scripts/install/tests/test_install.py @@ -0,0 +1,326 @@ +"""Tests for scripts/install/install.sh, run against real GitHub releases (requires network access). + +Each test pipes the installer into `sh -s --` (and `bash -s --`), just like `curl ... | sh` would. Failure modes +are simulated with shims for curl, gh, tar, and getconf placed ahead of the real tools on PATH. + +Usage: python3 -m pytest scripts/install/tests +(Set GH_TOKEN, e.g. to `$(gh auth token)`, to also verify a real build attestation.) +""" + +import os +import platform +import shutil +import subprocess +from pathlib import Path + +import pytest + + +INSTALLER = Path(__file__).resolve().parent.parent / "install.sh" +# Deliberately not the latest release, so tests can tell pinned installs from latest ones. +VERSION = "0.3.0" + +# Shim name -> (tool it replaces, bash body). $REAL_CURL and $REAL_TAR name the real tools. +SHIMS = { + "bad-checksum": ( + "curl", + """ + "${REAL_CURL}" "$@" || exit + prev="" + for arg; do + if [[ ${prev} == -o && ${arg} == *.sha256 ]]; then + line="$(<"${arg}")" + first=0 && [[ ${line} == 0* ]] && first=1 + echo "${first}${line:1}" >"${arg}" + fi + prev="${arg}" + done + """, + ), + "bad-attestation": ( + "gh", + """ + [[ $* == *--help ]] && { echo "--source-ref"; exit 0; } + echo "simulated attestation failure" >&2 + exit 1 + """, + ), + # gh exits 4 when it has no credentials. + "gh-unauthenticated": ( + "gh", + """ + [[ $* == *--help ]] && { echo "--source-ref"; exit 0; } + echo "To get started with GitHub CLI, please run: gh auth login" >&2 + exit 4 + """, + ), + # e.g. the gh shipped by Ubuntu 24.04 and Debian 12: no `attestation` subcommand at all. + "gh-too-old": ( + "gh", + """ + echo 'unknown command "attestation" for "gh"' >&2 + exit 1 + """, + ), + "broken-binary": ( + "tar", + """ + "${REAL_TAR}" "$@" || exit + while [[ $# -gt 0 ]]; do + [[ $1 == -C ]] && printf '#!/bin/sh\\nexit 1\\n' >"$2/brush" + shift + done + """, + ), + "old-glibc": ("getconf", 'echo "glibc 2.17"'), + "minimum-glibc": ("getconf", 'echo "glibc 2.34"'), +} + +GH_INSTALLED = shutil.which("gh") is not None + +# The script is POSIX sh; test it under the system sh (dash on Debian/Ubuntu, busybox ash on +# Alpine) and under bash. On macOS, also under the ancient /bin/bash (3.2) if PATH has a newer one. +SHELLS = ["sh", "bash"] +if platform.system() == "Darwin" and Path(shutil.which("bash")).resolve() != Path("/bin/bash"): + SHELLS.append("/bin/bash") + + +@pytest.fixture(params=SHELLS) +def install(request, tmp_path): + """Runs the installer with the given args, shims, and environment overrides.""" + + def run(*args, shims=(), **env_overrides): + env = os.environ | { + "REAL_CURL": shutil.which("curl"), + "REAL_TAR": shutil.which("tar"), + } + for name in shims: + tool, body = SHIMS[name] + shim = tmp_path / "shims" / name / tool + shim.parent.mkdir(parents=True, exist_ok=True) + shim.write_text("#!/usr/bin/env bash\n" + body) + shim.chmod(0o755) + env["PATH"] = f"{shim.parent}{os.pathsep}{env['PATH']}" + for key, value in env_overrides.items(): # None unsets the variable + if value is None: + env.pop(key, None) + else: + env[key] = str(value) + + return subprocess.run( + [request.param, "-s", "--", *map(str, args)], + input=INSTALLER.read_text(), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + ) + + return run + + +def assert_succeeded(result): + assert result.returncode == 0, result.stdout + # All output should be the installer's own; anything else means some tool misbehaved. + assert all(line.startswith("brush-install: ") for line in result.stdout.splitlines()), ( + result.stdout + ) + + +def assert_failed(result, message): + assert result.returncode != 0, result.stdout + # Every failure should end with the installer's own error message. + last_line = result.stdout.splitlines()[-1] + assert last_line.startswith("brush-install: error: "), result.stdout + assert message in last_line, result.stdout + + +def brush_version(path): + return subprocess.run( + [path, "--version"], capture_output=True, text=True, check=True + ).stdout + + +def test_latest_release_to_default_dir(install, tmp_path): + home = tmp_path / "home" + # Without any other brush on PATH, so the note is about PATH rather than shadowing. + path = os.pathsep.join(d for d in os.environ["PATH"].split(os.pathsep) if not (Path(d) / "brush").exists()) + result = install(HOME=home, PATH=path, XDG_BIN_HOME=None) + assert_succeeded(result) + + bin_dir = home / ".local" / "bin" + version = brush_version(bin_dir / "brush") + assert version.startswith("brush ") + assert not version.startswith(f"brush {VERSION} ") + assert os.listdir(bin_dir) == ["brush"] + assert f"note: {bin_dir} is not in your PATH" in result.stdout + + +def test_pinned_version_replaces_existing_binary(install, tmp_path): + (tmp_path / "brush").write_text("old") + result = install("--version", f"v{VERSION}", "--dir", tmp_path) + assert_succeeded(result) + assert brush_version(tmp_path / "brush").startswith(f"brush {VERSION} ") + + +def test_xdg_bin_home_is_default_dir(install, tmp_path): + xdg_bin = tmp_path / "xdg-bin" + result = install("--version", VERSION, HOME=tmp_path / "home", XDG_BIN_HOME=xdg_bin) + assert_succeeded(result) + assert os.listdir(xdg_bin) == ["brush"] + + +@pytest.mark.skipif(platform.system() != "Linux", reason="glibc detection is Linux-only") +def test_old_glibc_falls_back_to_musl(install, tmp_path): + result = install("--version", VERSION, "--dir", tmp_path, shims=["old-glibc"]) + assert_succeeded(result) + assert "-unknown-linux-musl.tar.gz" in result.stdout + + +def host_glibc(): + name, version = platform.libc_ver() + return tuple(map(int, version.split("."))) if name == "glibc" else None + + +# The shim only steers target selection; the gnu build still has to run on the real libc here. +@pytest.mark.skipif(not host_glibc() or host_glibc() < (2, 34), reason="gnu build needs glibc 2.34+") +def test_minimum_glibc_selects_gnu_build(install, tmp_path): + # Exactly the minimum, so an off-by-one in the version comparison shows up too. + result = install("--version", VERSION, "--dir", tmp_path, shims=["minimum-glibc"]) + assert_succeeded(result) + assert "-unknown-linux-gnu.tar.gz" in result.stdout + + +@pytest.mark.skipif(not GH_INSTALLED, reason="gh is not installed") +# CI always provides GH_TOKEN, so never skip there: a missing token should fail, not skip. +@pytest.mark.skipif(not os.environ.get("GH_TOKEN") and not os.environ.get("CI"), reason="GH_TOKEN is not set") +def test_verifies_real_attestation(install, tmp_path): + result = install("--version", VERSION, "--dir", tmp_path, "--require-attestation") + assert_succeeded(result) + assert "verified GitHub build attestation" in result.stdout + + +# Runs on the CI images that deliberately lack gh (see .github/workflows/install-script.yaml). +@pytest.mark.skipif(GH_INSTALLED, reason="gh is installed") +def test_missing_gh_warns_with_reason(install, tmp_path): + result = install("--version", VERSION, "--dir", tmp_path) + assert_succeeded(result) + assert "note: GitHub CLI (gh) is not installed, so the build attestation wasn't checked" in result.stdout + + +def test_unauthenticated_gh_warns_with_reason(install, tmp_path): + result = install("--version", VERSION, "--dir", tmp_path, shims=["gh-unauthenticated"]) + assert_succeeded(result) + assert ( + "note: GitHub CLI (gh) is not authenticated, so the build attestation wasn't checked" + in result.stdout + ) + + +def test_too_old_gh_warns_with_reason(install, tmp_path): + result = install("--version", VERSION, "--dir", tmp_path, shims=["gh-too-old"]) + assert_succeeded(result) + assert ( + "note: GitHub CLI (gh) is too old (2.68 or newer is needed), so the build attestation wasn't checked" + in result.stdout + ) + + +def test_notes_when_another_brush_comes_first_in_path(install, tmp_path): + earlier, dest = tmp_path / "earlier", tmp_path / "dest" + earlier.mkdir() + (earlier / "brush").write_text("#!/bin/sh\n") + (earlier / "brush").chmod(0o755) + + path = os.pathsep.join([str(earlier), str(dest), os.environ["PATH"]]) + result = install("--version", VERSION, "--dir", dest, PATH=path) + assert_succeeded(result) + assert f"note: running 'brush' will run {earlier / 'brush'}, not {dest / 'brush'}" in result.stdout + + +def test_no_path_note_when_dir_is_in_path_via_symlink(install, tmp_path): + dest, link = tmp_path / "dest", tmp_path / "link" + link.symlink_to(dest) + + path = os.pathsep.join([str(link), os.environ["PATH"]]) + result = install("--version", VERSION, "--dir", dest, PATH=path) + assert_succeeded(result) + # Only the attestation note is allowed (gh may be missing or unauthenticated here). + assert not [line for line in result.stdout.splitlines() if "note:" in line and "attestation" not in line] + + +def test_unset_home_without_dir(install): + assert_failed(install("--version", VERSION, HOME=None, XDG_BIN_HOME=None), "HOME is not set") + + +@pytest.mark.parametrize( + "args, message", + [ + (["--bogus"], "unknown option: --bogus"), + (["--version"], "--version requires a value"), + (["--dir"], "--dir requires a value"), + ], + ids=["unknown-option", "missing-version", "missing-dir"], +) +def test_bad_arguments(install, args, message): + assert_failed(install(*args), message) + + +@pytest.mark.parametrize( + "args, shims, message", + [ + pytest.param( + ["--version", "0.0.0"], [], "failed to download", id="nonexistent-version" + ), + pytest.param( + ["--version", VERSION], ["bad-checksum"], "checksum mismatch", id="bad-checksum" + ), + pytest.param( + ["--version", VERSION], + ["bad-attestation"], + "attestation verification failed", + id="bad-attestation", + ), + pytest.param( + ["--version", VERSION, "--require-attestation"], + ["gh-unauthenticated"], + "cannot verify build attestation: GitHub CLI (gh) is not authenticated", + id="require-attestation-unauthenticated", + ), + pytest.param( + ["--version", VERSION, "--require-attestation"], + ["gh-too-old"], + "cannot verify build attestation: GitHub CLI (gh) is too old", + id="require-attestation-too-old", + ), + pytest.param( + ["--version", VERSION, "--require-attestation"], + [], + "cannot verify build attestation: GitHub CLI (gh) is not installed", + id="require-attestation-no-gh", + # A real gh can't be hidden from PATH, so this only runs where gh is absent. + marks=pytest.mark.skipif(GH_INSTALLED, reason="gh is installed"), + ), + ], +) +def test_failure_installs_nothing(install, tmp_path, args, shims, message): + dest = tmp_path / "dest" + assert_failed(install(*args, "--dir", dest, shims=shims), message) + assert not dest.exists() + + +def test_unwritable_dir(install, tmp_path): + # A path beneath a regular file can't be created, even by root (as in CI containers). + (tmp_path / "file").write_text("") + dest = tmp_path / "file" / "bin" + assert_failed(install("--version", VERSION, "--dir", dest), f"cannot write to {dest}") + + +def test_broken_binary_keeps_existing_one(install, tmp_path): + dest = tmp_path / "dest" + dest.mkdir() + (dest / "brush").write_text("old") + result = install("--version", VERSION, "--dir", dest, shims=["broken-binary"]) + assert_failed(result, "build failed to run on this system") + assert os.listdir(dest) == ["brush"] + assert (dest / "brush").read_text() == "old"