Files
sausage/userland/brush/source/scripts/install/install.sh
T
2026-11-11 22:04:16 +00:00

374 lines
14 KiB
Bash
Executable File

#!/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> 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 <sha> Install the canary build of this commit of main (a full 40-character
hash); builds of the last ~100 pushes are kept.
--dir <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 <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 "$@"