#!/bin/sh # # Installs brush from official GitHub releases of reubeno/brush, or (with --canary) from the # canary builds that CI publishes for every push to its main branch. # # Usage: # curl --proto '=https' --tlsv1.2 -fsSL https://brush.sh/install.sh | sh # # To pass options, use `sh -s --`, e.g.: # curl ... | sh -s -- --version 0.4.0 --require-attestation # # For the list of options, see usage() below, or pass --help. # # 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 (or, for canary builds, for # its main branch). # # 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" # Where the release workflow publishes canary builds, on ghcr.io. CANARY_PACKAGE="reubeno/brush-canary" usage() { cat <<'EOF' Installs brush from its official GitHub releases, or from canary builds of its main branch. Usage: curl --proto '=https' --tlsv1.2 -fsSL https://brush.sh/install.sh | sh -s -- [options] Options: --version Version to install (e.g. "0.4.0"); defaults to the latest release. --canary Install the newest canary build of main instead of a release. Canary builds are unreleased and may break. --commit Install the canary build of this commit of main (a full 40-character hash); builds of the last ~100 pushes are kept. --dir Directory to install into; defaults to $XDG_BIN_HOME, or ~/.local/bin. --require-attestation Fail if the build attestation can't be verified. -h, --help Show this help. EOF } 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="" canary="" commit="" install_dir="" require_attestation="" while [ $# -gt 0 ]; do case "$1" in --version) [ -n "${2:-}" ] || die "--version requires a value" version="$2" shift ;; --canary) canary=1 ;; --commit) [ -n "${2:-}" ] || die "--commit requires a value" commit="$2" canary=1 shift ;; --dir) [ -n "${2:-}" ] || die "--dir requires a value" install_dir="$2" shift ;; --require-attestation) require_attestation=1 ;; -h | --help) usage exit 0 ;; *) die "unknown option: $1 (see --help)" ;; esac shift done if [ -n "${canary}" ] && [ -n "${version}" ]; then die "--version can't be combined with --canary or --commit" fi # The hash becomes part of a URL, so accept nothing but a full, lowercase commit hash. if [ -n "${commit}" ]; then case "${commit}" in *[!0-9a-f]*) commit="" ;; # fails the length check below esac [ "${#commit}" -eq 40 ] || die "--commit needs a full 40-character commit hash (see \`git rev-parse\`)" fi # ~/.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, and `source_ref` to its full ref. resolve_release_tag() { if [ -n "${version}" ]; then tag="brush-shell-v${version#v}" source_ref="refs/tags/${tag}" 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 source_ref="refs/tags/${tag}" } # 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" } # Prints the value of the annotation named $1 in the (whitespace-free) `manifest`, if it # matches the sed pattern $2. Values go straight to the terminal, so the patterns should # admit nothing but the expected characters. manifest_annotation() { printf '%s' "${manifest}" | sed -n "s|.*\"$1\":\"\($2\)\".*|\1|p" } # Downloads the newest canary build (or the one for `commit`) into `tmp_dir`, and checks it # against its digest in the registry, which is the SHA-256 of its content. download_canary_archive() { archive="brush-${target}.tar.gz" image_tag="${target}${commit:+-${commit}}" registry="https://ghcr.io/v2/${CANARY_PACKAGE}" source_ref="refs/heads/main" # Even public packages need a token to download, but an anonymous one does. ghcr.io refuses # one for a package that doesn't exist or isn't public. token="$(fetch "https://ghcr.io/token?scope=repository:${CANARY_PACKAGE}:pull" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')" [ -n "${token}" ] || die "could not access canary builds at ghcr.io/${CANARY_PACKAGE}" manifest="$(fetch -H "Authorization: Bearer ${token}" \ -H "Accept: application/vnd.oci.image.manifest.v1+json" \ "${registry}/manifests/${image_tag}")" || die "no canary build of brush found for ${target}${commit:+ at commit ${commit}}" manifest="$(printf '%s' "${manifest}" | tr -d ' \t\r\n')" # The archive is the manifest's only layer. digest="$(printf '%s' "${manifest}" | sed -n 's/.*"layers":\[{[^}]*"digest":"sha256:\([0-9a-f]\{64\}\)".*/\1/p')" [ -n "${digest}" ] || die "unexpected manifest for ghcr.io/${CANARY_PACKAGE}:${image_tag}" # Where the build came from, as the release workflow recorded it. Just for the user's # information: only the archive itself is covered by the checks below. built_commit="$(manifest_annotation org.opencontainers.image.revision '[0-9a-f]\{40\}')" built_at="$(manifest_annotation org.opencontainers.image.created '[0-9T:Z-]*')" say "downloading ghcr.io/${CANARY_PACKAGE}:${image_tag} (${archive})" say "canary build of commit ${built_commit:-(unknown)}, published ${built_at:-(unknown)}" fetch -H "Authorization: Bearer ${token}" -o "${tmp_dir}/${archive}" \ "${registry}/blobs/sha256:${digest}" || die "failed to download ${archive}" echo "${digest} ${archive}" >"${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 (or, for canary builds, main, # and with --commit, that exact commit). 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 # Left unquoted so it vanishes without --commit; parse_args made sure it's plain hex. gh attestation verify "${tmp_dir}/${archive}" \ --repo "${REPO}" \ --signer-workflow "${RELEASE_WORKFLOW}" \ --source-ref "${source_ref}" \ ${commit:+--source-digest ${commit}} \ --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://brush.sh/getting-started/install/ 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 if [ -z "${canary}" ]; then resolve_release_tag fi 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 if [ -n "${canary}" ]; then download_canary_archive else download_archive fi verify_attestation install_binary check_path } # Wrapped in a function so that a partially downloaded script never runs. main "$@"