diff --git a/.github/workflows/install-script.yml b/.github/workflows/install-script.yml new file mode 100644 index 0000000..0873fec --- /dev/null +++ b/.github/workflows/install-script.yml @@ -0,0 +1,71 @@ +name: Install script + +permissions: + contents: read + +on: + push: + branches: [main] + paths: + - install.sh + - .github/workflows/install-script.yml + pull_request: + paths: + - install.sh + - .github/workflows/install-script.yml + +jobs: + shellcheck: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run shellcheck + run: shellcheck --shell=sh install.sh + + # Runs against published releases, so it can't catch a rename of the archives + # in .goreleaser.yaml until a release with the new names ships. + install: + strategy: + fail-fast: false + matrix: + runner: [ubuntu-latest, ubuntu-24.04-arm, macos-latest] + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install latest + run: sh install.sh + env: + QCLOUD_INSTALL_DIR: ${{ runner.temp }}/bin + + - name: Run qcloud version + run: '"$RUNNER_TEMP/bin/qcloud" version' + + - name: Install pinned version + run: sh install.sh + env: + QCLOUD_VERSION: 0.31.0 + QCLOUD_INSTALL_DIR: ${{ runner.temp }}/pinned + + - name: Check pinned version + run: '"$RUNNER_TEMP/pinned/qcloud" version | grep -Fx "qcloud 0.31.0"' + + # BusyBox userland: wget instead of curl, no bash. The common case for + # containers and sandboxed agents. + install-alpine: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install in Alpine + run: docker run --rm -v "$PWD:/src:ro" alpine:3.22 sh -c 'sh /src/install.sh && /root/.local/bin/qcloud version' diff --git a/README.md b/README.md index 470c452..cb008a1 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,24 @@ ## Installation +### From Homebrew + +On macOS and Linux: + +```sh +brew install qdrant/tap/qcloud +``` + +### From the install script + +For systems without Homebrew, such as CI runners, containers, and sandboxed agents: + +```sh +curl -fsSL https://raw.githubusercontent.com/qdrant/qcloud-cli/main/install.sh | sh +``` + +The script installs the latest release for your OS and CPU architecture to `~/.local/bin`, after checking the archive against the release's `checksums.txt`. Set `QCLOUD_VERSION` (for example `v0.32.0`) to pin a release, or `QCLOUD_INSTALL_DIR` to install somewhere else. It needs `curl` or `wget`, `sha256sum` or `shasum`, and `tar`. + ### From GitHub Releases Download the latest release from [GitHub Releases](https://github.com/qdrant/qcloud-cli/releases). diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..76789cd --- /dev/null +++ b/install.sh @@ -0,0 +1,105 @@ +#!/bin/sh +# Install qcloud, the Qdrant Cloud CLI, from GitHub Releases. +# +# curl -fsSL https://raw.githubusercontent.com/qdrant/qcloud-cli/main/install.sh | sh +# +# For macOS and Linux on x86-64 or ARM64. On Windows, download the zip from +# https://github.com/qdrant/qcloud-cli/releases instead. +# +# Environment variables: +# QCLOUD_VERSION release to install, such as v0.32.0 (default: latest) +# QCLOUD_INSTALL_DIR directory for the binary (default: $HOME/.local/bin) +# +# The archive is verified against the release's checksums.txt before anything +# is installed. Needs curl or wget, sha256sum or shasum, and tar. +set -eu + +repo="qdrant/qcloud-cli" +version="${QCLOUD_VERSION:-latest}" + +fail() { + echo "qcloud install: $*" >&2 + exit 1 +} + +# Everything runs from main, called on the last line, so a download of this +# script cut off midway runs nothing instead of half an install. +main() { + case "$(uname -s)" in + Linux) os=linux ;; + Darwin) os=darwin ;; + *) fail "unsupported OS $(uname -s). Download a release from https://github.com/$repo/releases" ;; + esac + + case "$(uname -m)" in + x86_64 | amd64) arch=amd64 ;; + arm64 | aarch64) arch=arm64 ;; + *) fail "unsupported architecture $(uname -m). Download a release from https://github.com/$repo/releases" ;; + esac + # A shell under Rosetta reports x86_64 on Apple Silicon; install the native build. + if [ "$os" = darwin ] && [ "$arch" = amd64 ] && [ "$(sysctl -n hw.optional.arm64 2>/dev/null)" = 1 ]; then + arch=arm64 + fi + + case "$version" in + latest) base="https://github.com/$repo/releases/latest/download" ;; + v*) base="https://github.com/$repo/releases/download/$version" ;; + *) base="https://github.com/$repo/releases/download/v$version" ;; + esac + + if command -v curl >/dev/null 2>&1; then + fetch() { curl -fsSL -o "$2" "$1"; } + elif command -v wget >/dev/null 2>&1; then + fetch() { wget -q -O "$2" "$1"; } + else + fail "needs curl or wget" + fi + + if command -v sha256sum >/dev/null 2>&1; then + sha256() { sha256sum "$1" | cut -d ' ' -f 1; } + elif command -v shasum >/dev/null 2>&1; then + sha256() { shasum -a 256 "$1" | cut -d ' ' -f 1; } + else + fail "needs sha256sum or shasum to verify the download" + fi + + archive="qcloud-$os-$arch.tar.gz" + tmp=$(mktemp -d) + # dash skips EXIT traps on signals, so turn them into an exit. + trap 'rm -rf "$tmp"' EXIT + trap 'exit 1' INT TERM + + echo "Downloading $archive ($version)" + fetch "$base/$archive" "$tmp/$archive" || fail "could not download $base/$archive" + fetch "$base/checksums.txt" "$tmp/checksums.txt" || fail "could not download $base/checksums.txt" + + expected=$(awk -v f="$archive" '$2 == f { print $1 }' "$tmp/checksums.txt") + [ -n "$expected" ] || fail "$archive is not listed in checksums.txt" + actual=$(sha256 "$tmp/$archive") + [ "$expected" = "$actual" ] || fail "checksum mismatch for $archive: expected $expected, got $actual" + + tar -xzf "$tmp/$archive" -C "$tmp" qcloud + install_dir="${QCLOUD_INSTALL_DIR:-$HOME/.local/bin}" + # A quoted ~ from Dockerfile ENV or YAML env: isn't expanded by the shell. + case "$install_dir" in \~/*) install_dir="$HOME/${install_dir#\~/}" ;; esac + mkdir -p "$install_dir" + # Absolute, without a trailing slash, so the PATH check below matches. + install_dir=$(CDPATH='' cd -- "$install_dir" && pwd) + # Copy next to the target, then rename over it: a running qcloud is replaced + # atomically instead of failing with "text file busy". + cp "$tmp/qcloud" "$install_dir/.qcloud.new" + chmod 755 "$install_dir/.qcloud.new" + mv -f "$install_dir/.qcloud.new" "$install_dir/qcloud" + + echo "Installed $("$install_dir/qcloud" version) to $install_dir/qcloud" + + case ":$PATH:" in + *":$install_dir:"*) ;; + *) + echo "$install_dir is not on your PATH. Add it, for example:" + echo " export PATH=\"$install_dir:\$PATH\"" + ;; + esac +} + +main "$@"