From 0ffbd7435a3ac39c085986f4abf5929068f7e654 Mon Sep 17 00:00:00 2001 From: kanungle Date: Sat, 17 Oct 2026 15:32:27 -0700 Subject: [PATCH 1/3] build: add install script for systems without homebrew install.sh downloads the latest (or QCLOUD_VERSION) release archive for the current OS and architecture, verifies it against checksums.txt, and installs the binary to ~/.local/bin (or QCLOUD_INSTALL_DIR). CI runs shellcheck and installs on Linux x86-64 and ARM64, macOS, and Alpine. The README now lists Homebrew and the script. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/install-script.yml | 64 ++++++++++++++++++++ README.md | 18 ++++++ install.sh | 89 ++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 .github/workflows/install-script.yml create mode 100755 install.sh diff --git a/.github/workflows/install-script.yml b/.github/workflows/install-script.yml new file mode 100644 index 0000000..446eb42 --- /dev/null +++ b/.github/workflows/install-script.yml @@ -0,0 +1,64 @@ +name: Install script + +permissions: + contents: read + +on: + push: + branches: [main] + paths: + - install.sh + - .goreleaser.yaml + - .github/workflows/install-script.yml + pull_request: + paths: + - install.sh + - .goreleaser.yaml + - .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 + + # Installs the latest release on each platform it supports. A change to the + # archive names in .goreleaser.yaml breaks the script, so that file runs this too. + 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 it + run: '"$RUNNER_TEMP/bin/qcloud" version' + + # 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..e5f0d13 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,24 @@ ## Installation +### With Homebrew + +On macOS and Linux: + +```sh +brew install qdrant/tap/qcloud +``` + +### With 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..c590eb2 --- /dev/null +++ b/install.sh @@ -0,0 +1,89 @@ +#!/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}" +install_dir="${QCLOUD_INSTALL_DIR:-$HOME/.local/bin}" + +fail() { + echo "qcloud install: $*" >&2 + exit 1 +} + +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 + +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) +trap 'rm -rf "$tmp"' EXIT + +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 +mkdir -p "$install_dir" +# 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 From d3137bdbc71352e9f97a3ad12d06ad802ddba25d Mon Sep 17 00:00:00 2001 From: kanungle Date: Mon, 19 Oct 2026 12:23:23 -0700 Subject: [PATCH 2/3] build: address install script review Wrap the script in main so a truncated download runs nothing, clean up the temp dir on INT/TERM, expand a quoted ~ and normalize the install dir, prefer arm64 under Rosetta, and test a pinned QCLOUD_VERSION in CI. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/install-script.yml | 17 ++-- README.md | 4 +- install.sh | 126 +++++++++++++++------------ 3 files changed, 85 insertions(+), 62 deletions(-) diff --git a/.github/workflows/install-script.yml b/.github/workflows/install-script.yml index 446eb42..0873fec 100644 --- a/.github/workflows/install-script.yml +++ b/.github/workflows/install-script.yml @@ -8,12 +8,10 @@ on: branches: [main] paths: - install.sh - - .goreleaser.yaml - .github/workflows/install-script.yml pull_request: paths: - install.sh - - .goreleaser.yaml - .github/workflows/install-script.yml jobs: @@ -28,8 +26,8 @@ jobs: - name: Run shellcheck run: shellcheck --shell=sh install.sh - # Installs the latest release on each platform it supports. A change to the - # archive names in .goreleaser.yaml breaks the script, so that file runs this too. + # 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 @@ -47,9 +45,18 @@ jobs: env: QCLOUD_INSTALL_DIR: ${{ runner.temp }}/bin - - name: Run it + - 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: diff --git a/README.md b/README.md index e5f0d13..cb008a1 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ ## Installation -### With Homebrew +### From Homebrew On macOS and Linux: @@ -31,7 +31,7 @@ On macOS and Linux: brew install qdrant/tap/qcloud ``` -### With the install script +### From the install script For systems without Homebrew, such as CI runners, containers, and sandboxed agents: diff --git a/install.sh b/install.sh index c590eb2..eaf8142 100755 --- a/install.sh +++ b/install.sh @@ -16,74 +16,90 @@ set -eu repo="qdrant/qcloud-cli" version="${QCLOUD_VERSION:-latest}" -install_dir="${QCLOUD_INSTALL_DIR:-$HOME/.local/bin}" fail() { echo "qcloud install: $*" >&2 exit 1 } -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 +# 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 + 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 + 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 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 + 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) -trap 'rm -rf "$tmp"' EXIT + 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" + 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" + 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 -mkdir -p "$install_dir" -# 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" + 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" + 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 + 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 "$@" From 4acd2e4cd350dbc1ca46bedc00b78176fbec3a28 Mon Sep 17 00:00:00 2001 From: kanungle Date: Mon, 19 Oct 2026 16:15:59 -0700 Subject: [PATCH 3/3] build: fix shellcheck warnings in install script Co-Authored-By: Claude Opus 5.5 --- install.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index eaf8142..76789cd 100755 --- a/install.sh +++ b/install.sh @@ -81,10 +81,10 @@ main() { 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 + 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) + 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"