From df8d299ecaa345036a769f5d2c3405a53e6f328c Mon Sep 17 00:00:00 2001 From: reuben olinsky Date: Mon, 14 Sep 2026 01:28:42 -0700 Subject: [PATCH 1/4] feat(install): add curl-able install script for release binaries Adds scripts/install/install.sh, which installs brush from official GitHub releases: picks the target (glibc >= 2.34 or the static musl build on Linux), resolves "latest" to a tag once, verifies the SHA-256 checksum and, when gh >= 2.68 is available, the build attestation (--require-attestation makes that mandatory). The script never touches gh's auth state; if gh reports it has no credentials (exit status 4), verification is skipped with a note pointing out the stronger check an authenticated gh would provide. Installs to $XDG_BIN_HOME if set, else ~/.local/bin. Stages to a mktemp name next to the destination, checks the binary runs, then renames atomically. Notes when the install dir isn't on PATH or is shadowed. Includes a pytest suite exercising real releases with tool shims, run in CI (via uvx, pytest pinned to 8.x) across Linux/macOS plus Alpine and AlmaLinux 8 containers and weekly, plus README instructions. The CD workflow gains a comment listing everything the installer depends on, including the glibc floor. Assisted-By: Claude Fable 5.1 --- .github/workflows/cd.yaml | 7 + .github/workflows/install-script.yaml | 77 +++++++ README.md | 9 +- scripts/install/install.sh | 269 +++++++++++++++++++++++ scripts/install/tests/test_install.py | 302 ++++++++++++++++++++++++++ 5 files changed, 663 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/install-script.yaml create mode 100755 scripts/install/install.sh create mode 100644 scripts/install/tests/test_install.py 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..4628ed94a --- /dev/null +++ b/scripts/install/tests/test_install.py @@ -0,0 +1,302 @@ +"""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"'), +} + +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 + + +@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 + + +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_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) + assert "note:" not in result.stdout + + +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" From 4a7b6fa73877a9424c6a4ce147bc4c818e0d3bbf Mon Sep 17 00:00:00 2001 From: reuben olinsky Date: Mon, 14 Sep 2026 12:44:59 -0700 Subject: [PATCH 2/4] test(install): allow attestation note in symlink PATH test The test asserted no note at all, but on hosts without gh the installer always emits the attestation note. Only PATH-related notes matter here. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01E4iK6r1LQ9y3fX53cyqRR8 --- scripts/install/tests/test_install.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/install/tests/test_install.py b/scripts/install/tests/test_install.py index 4628ed94a..aa065c20d 100644 --- a/scripts/install/tests/test_install.py +++ b/scripts/install/tests/test_install.py @@ -222,7 +222,8 @@ def test_no_note_when_dir_is_in_path_via_symlink(install, tmp_path): path = os.pathsep.join([str(link), os.environ["PATH"]]) result = install("--version", VERSION, "--dir", dest, PATH=path) assert_succeeded(result) - assert "note:" not in result.stdout + # 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): From 80972eb5eb11ed99ac3c6ece339b5f6f7cbce857 Mon Sep 17 00:00:00 2001 From: reuben olinsky Date: Mon, 14 Sep 2026 13:29:16 -0700 Subject: [PATCH 3/4] test(install): rename symlink PATH test; cover the missing-gh note Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01E4iK6r1LQ9y3fX53cyqRR8 --- scripts/install/tests/test_install.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/install/tests/test_install.py b/scripts/install/tests/test_install.py index aa065c20d..2345122d4 100644 --- a/scripts/install/tests/test_install.py +++ b/scripts/install/tests/test_install.py @@ -185,6 +185,14 @@ def test_verifies_real_attestation(install, tmp_path): 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) @@ -215,7 +223,7 @@ def test_notes_when_another_brush_comes_first_in_path(install, tmp_path): assert f"note: running 'brush' will run {earlier / 'brush'}, not {dest / 'brush'}" in result.stdout -def test_no_note_when_dir_is_in_path_via_symlink(install, tmp_path): +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) From 7008210ea39612449ba4d054c75da6f6f875aa60 Mon Sep 17 00:00:00 2001 From: reuben olinsky Date: Mon, 14 Sep 2026 19:12:50 -0700 Subject: [PATCH 4/4] test(install): cover gnu build selection at the glibc minimum The suite only asserted the musl fallback, so a broken or inverted version comparison that sent every Linux install to the musl build would have passed unnoticed. Shim getconf to report exactly glibc 2.34 and assert the gnu archive is chosen; skip where the host glibc can't run it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01E4iK6r1LQ9y3fX53cyqRR8 --- scripts/install/tests/test_install.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/install/tests/test_install.py b/scripts/install/tests/test_install.py index 2345122d4..b2327a2aa 100644 --- a/scripts/install/tests/test_install.py +++ b/scripts/install/tests/test_install.py @@ -73,6 +73,7 @@ """, ), "old-glibc": ("getconf", 'echo "glibc 2.17"'), + "minimum-glibc": ("getconf", 'echo "glibc 2.34"'), } GH_INSTALLED = shutil.which("gh") is not None @@ -176,6 +177,20 @@ def test_old_glibc_falls_back_to_musl(install, tmp_path): 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")