diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml index 8d27564..2ee5cad 100644 --- a/.github/workflows/pull.yml +++ b/.github/workflows/pull.yml @@ -16,7 +16,108 @@ permissions: contents: read jobs: - pull_install: + pull_install_nix: + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest, macos-latest ] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Homebrew (Ubuntu) + if: matrix.os == 'ubuntu-latest' + shell: bash + run: | + set -euxo pipefail + sudo apt-get update + sudo apt-get install -y curl build-essential procps file git + NONINTERACTIVE=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + BREW_BIN="/home/linuxbrew/.linuxbrew/bin/brew" + "$BREW_BIN" --version + # Put brew on PATH for subsequent steps + echo "$(/home/linuxbrew/.linuxbrew/bin/brew --prefix)/bin" >> "$GITHUB_PATH" + echo "HOMEBREW_PREFIX=$(/home/linuxbrew/.linuxbrew/bin/brew --prefix)" >> "$GITHUB_ENV" + + - name: Locate & export Homebrew (macOS) + if: matrix.os == 'macos-latest' + shell: bash + run: | + set -euxo pipefail + # Prefer Apple Silicon path, then Intel, then fallback + if [ -x /opt/homebrew/bin/brew ]; then BREW=/opt/homebrew/bin/brew; + elif [ -x /usr/local/bin/brew ]; then BREW=/usr/local/bin/brew; + else BREW="$(command -v brew)"; fi + "$BREW" --version + echo "$("$BREW" --prefix)/bin" >> "$GITHUB_PATH" + echo "HOMEBREW_PREFIX=$("$BREW" --prefix)" >> "$GITHUB_ENV" + + - name: Install bats + run: | + set -euxo pipefail + brew update + brew install bats-core + bats --version + brew tap bats-core/bats-core + brew install bats-support bats-assert bats-file + # Show installed addon paths for debug + brew --prefix bats-support + brew --prefix bats-assert + brew --prefix bats-file + + - name: Run bats tests + env: + ProgressPreference: SilentlyContinue + run: | + set -euxo pipefail + bats tests/install.bats + + pull_install_verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Homebrew (Ubuntu) + shell: bash + run: | + set -euxo pipefail + sudo apt-get update + sudo apt-get install -y curl build-essential procps file git + NONINTERACTIVE=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + BREW_BIN="/home/linuxbrew/.linuxbrew/bin/brew" + "$BREW_BIN" --version + # Put brew on PATH for subsequent steps + echo "$(/home/linuxbrew/.linuxbrew/bin/brew --prefix)/bin" >> "$GITHUB_PATH" + echo "HOMEBREW_PREFIX=$(/home/linuxbrew/.linuxbrew/bin/brew --prefix)" >> "$GITHUB_ENV" + + - name: Install cosign + run: | + set -euxo pipefail + brew update + brew install cosign + cosign version || true + + - name: Verify binary + env: + VERIFY_BINARY: "true" + run: | + set -euxo pipefail + BIN_DIR="$(mktemp -d)/bin" + mkdir -p "$BIN_DIR" + echo "Cosign version:" + cosign version || true + echo "Running installer with VERIFY_BINARY=true" + bash -x ./install.sh -d "$BIN_DIR" | tee install.log + echo "---- Installer log ----" + cat install.log + echo "------------------------" + test -x "$BIN_DIR/pair" + ls -l "$BIN_DIR/pair" + + pull_install_windows: runs-on: windows-latest defaults: run: diff --git a/install.sh b/install.sh index 4d53aba..85c49d6 100644 --- a/install.sh +++ b/install.sh @@ -10,7 +10,7 @@ NAME="pair" ENV="latest" BASE_URL="https://downloads.pairspaces.com/$ENV" INSTALL_DIR="/usr/local/bin" -VERIFY_CHECKSUM="${VERIFY_CHECKSUM:-false}" +VERIFY_BINARY="${VERIFY_BINARY:-false}" # ============================================================================= # UI Helpers @@ -91,7 +91,7 @@ process_args() { -) case "$OPTARG" in uninstall) UNINSTALL=true ;; - verify) VERIFY_CHECKSUM=true ;; + verify) VERIFY_BINARY=true ;; *) abort "Unknown long option --$OPTARG" ;; esac ;; @@ -134,7 +134,7 @@ download_and_install() { text_title "Downloading PairSpaces CLI" curl -LO --proto '=https' --tlsv1.2 -sSf "$DOWNLOAD_URL" - verify_checksum + verify_binary text_title "Installing PairSpaces CLI" "$INSTALL_DIR/$NAME" chmod +x "$FILENAME" @@ -179,21 +179,20 @@ remove_installed_binary() { } # ============================================================================= -# Verify checksum (Linux only) +# Verify binary (Linux only) # ============================================================================= -verify_checksum() { - if [ "$VERIFY_CHECKSUM" != "true" ] || [ "$OS" != "linux" ]; then +verify_binary() { + if [ "$VERIFY_BINARY" != "true" ] || [ "$OS" != "linux" ]; then return 0 fi - text_title "Verifying Checksum" + text_title "Verifying Binary" - local checksum_base="${BASE_URL}/pair_${VERSION}_checksums" + local binary_base="${BASE_URL}/linux/${ARCH}/pair_${VERSION}" - curl -sSfO "${checksum_base}.txt" || abort "Failed to download checksum file" - curl -sSfO "${checksum_base}.txt.pem" || abort "Failed to download PEM certificate" - curl -sSfO "${checksum_base}.txt.sig" || abort "Failed to download signature" + curl -sSfO "${binary_base}.pem" || abort "Failed to download PEM certificate" + curl -sSfO "${binary_base}.sig" || abort "Failed to download signature" if ! command -v cosign &>/dev/null; then text_title "Installing cosign" @@ -203,20 +202,11 @@ verify_checksum() { fi cosign verify-blob \ - --certificate "pair_${VERSION}_checksums.txt.pem" \ - --signature "pair_${VERSION}_checksums.txt.sig" \ - --certificate-oidc-issuer="https://token.actions.githubusercontent.com/" \ - --certificate-identity-regexp=".*" \ - "pair_${VERSION}_checksums.txt" || abort "Checksum file signature invalid" - - local actual - actual=$(sha256sum "$FILENAME" | awk '{print $1}') - local expected - expected=$(grep "linux/$ARCH/$FILENAME" "pair_${VERSION}_checksums.txt" | awk '{print $1}') - - if [ "$actual" != "$expected" ]; then - abort "Checksum mismatch: expected $expected, got $actual" - fi + --certificate "pair_${VERSION}.pem" \ + --signature "pair_${VERSION}.sig" \ + --certificate-oidc-issuer="https://token.actions.githubusercontent.com/" \ + --certificate-identity-regexp=".*" \ + "pair_${VERSION}" echo "The PairSpaces CLI was verified successfully using cosign." } diff --git a/tests/install.bats b/tests/install.bats new file mode 100644 index 0000000..f29cbeb --- /dev/null +++ b/tests/install.bats @@ -0,0 +1,283 @@ +#!/usr/bin/env bats + +load_bats_addon() { + local formula="$1" + local base + base="$(brew --prefix "$formula")" || { + echo "brew --prefix $formula failed" >&2 + exit 1 + } + # Try common locations used by Homebrew formulae + for cand in \ + "$base/libexec/$formula/load.bash" \ + "$base/share/$formula/load.bash" \ + "$base/lib/$formula/load.bash" \ + "$base/load.bash" + do + if [ -f "$cand" ]; then + load "$cand" + return 0 + fi + done + echo "Could not find load.bash for $formula under $base" >&2 + exit 1 +} + +# Replace the old load lines with these three: +load_bats_addon bats-support +load_bats_addon bats-assert +load_bats_addon bats-file + +# These tests run the installer script in a controlled sandbox with mocked tools. +# They do NOT require sudo and do NOT touch the network. + +setup() { + # Paths + REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME}")/.." && pwd)" + SCRIPT="$REPO_ROOT/install.sh" # rename if your file is not install.sh + RUN_DIR="$(mktemp -d)" + BIN_DIR="$RUN_DIR/bin" # fake install destination for -d + SHIM_DIR="$RUN_DIR/shims" # shims for uname/curl/etc. + LOG_DIR="$RUN_DIR/logs" + mkdir -p "$BIN_DIR" "$SHIM_DIR" "$LOG_DIR" + + # Log file to capture curl URLs (so we can assert URL formation) + CURL_LOG="$LOG_DIR/curl_calls.log" + : > "$CURL_LOG" + export CURL_LOG # <- critical so curl shim can see it + + # Provide a HOME so uninstall cleans the right .config folder + export HOME="$RUN_DIR/home" + mkdir -p "$HOME/.config/pair" + + # Build shims that the script will call first via PATH + _make_uname_shim # creates $SHIM_DIR/uname (we override via UNAME_S/UNAME_M) + _make_curl_shim # creates $SHIM_DIR/curl (logs URLs, fakes downloads, fakes checksums) + _make_mv_chmod_shims # creates $SHIM_DIR/mv, chmod (use system tools) + _make_sha256_cosign_shims # creates $SHIM_DIR/sha256sum & cosign default stubs + _make_getent_shim # creates $SHIM_DIR/getent for uninstall path + + # Prepend PATH with our shims so they win + export PATH="$SHIM_DIR:$PATH" + + # Ensure the script doesn’t ask for sudo; we’ll only install into -d "$BIN_DIR". + # Also force checksum OFF by default (individual tests can enable). + export VERIFY_BINARY="false" +} + +teardown() { + # Do not override /bin/rm, so Bats can still clean up even after we delete RUN_DIR + rm -rf "$RUN_DIR" +} + +# +# ---- Helpers to create shims ------------------------------------------------- +# + +_make_uname_shim() { + cat > "$SHIM_DIR/uname" <<'EOF' +#!/usr/bin/env bash +# Default passthrough unless overridden by UNAME_S / UNAME_M +case "$1" in + -s) if [[ -n "$UNAME_S" ]]; then echo "$UNAME_S"; else /usr/bin/uname -s 2>/dev/null || /bin/uname -s; fi ;; + -m) if [[ -n "$UNAME_M" ]]; then echo "$UNAME_M"; else /usr/bin/uname -m 2>/dev/null || /bin/uname -m; fi ;; + *) if [[ -n "$UNAME_S" || -n "$UNAME_M" ]]; then + echo "${UNAME_S:-$(/usr/bin/uname -s 2>/dev/null || /bin/uname -s)}" + else + (/usr/bin/uname "$@" 2>/dev/null || /bin/uname "$@") + fi + ;; +esac +EOF + chmod +x "$SHIM_DIR/uname" +} + +_make_curl_shim() { + cat > "$SHIM_DIR/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +# Log helper +log() { [[ -n "${CURL_LOG:-}" ]] && printf '%s\n' "$*" >> "$CURL_LOG" || true; } + +# Extract the last arg that looks like a URL +url="" +declare -a args=("$@") +for ((i=0; i<${#args[@]}; i++)); do + a="${args[$i]}" + case "$a" in + http*://*) url="$a" ;; + esac +done +[[ -n "$url" ]] || { echo "curl shim: no URL in args $*" >&2; exit 2; } +log "$url" + +# Flags we care about: +# -sSf : silent + fail on error +# -O : write output using remote name +# -L : follow redirects +# -LO : both of above +# We only emulate enough to satisfy the installer. + +# When requesting latest.txt, emit the test-controlled version or 9.9.9 +if [[ "$url" =~ latest\.txt$ ]]; then + printf '%s\n' "${TEST_VERSION:-9.9.9}" + exit 0 +fi + +# Generic helper to write a file named like the URL basename +download_remote_name() { + local base + base="$(basename "$url")" + : > "$base" + printf 'fake-%s\n' "$base" > "$base" +} + +# Special handling for checksum artifacts so Linux checksum test can succeed +# The installer will: +# 1) download the binary FILENAME into CWD +# 2) request "pair_${VERSION}.{pem,sig}" +# We synthesize the .txt to contain lines for both amd64/arm64 that match the +# deterministic checksum of the already-downloaded "$FILENAME". +case "$url" in + *pair_*.pem|*pair_*.sig) + download_remote_name + ;; + *) + # All other downloads (binary etc.) + download_remote_name + ;; +esac +EOF + chmod +x "$SHIM_DIR/curl" +} + +_make_mv_chmod_shims() { + # Use system chmod so the installed file really becomes executable + cat > "$SHIM_DIR/chmod" <<'EOF' +#!/usr/bin/env bash +exec /bin/chmod "$@" +EOF + chmod +x "$SHIM_DIR/chmod" + + # Use system mv so the install actually places the file + cat > "$SHIM_DIR/mv" <<'EOF' +#!/usr/bin/env bash +exec /bin/mv "$@" +EOF + chmod +x "$SHIM_DIR/mv" +} + +_make_sha256_cosign_shims() { + # sha256sum: try real sha; fallback to "pairspaces" + cat > "$SHIM_DIR/sha256sum" <<'EOF' +#!/usr/bin/env bash +file="$1" +if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1" "$2}' +elif command -v /usr/bin/sha256sum >/dev/null 2>&1; then + /usr/bin/sha256sum "$file" +else + echo "pairspaces $file" +fi +EOF + chmod +x "$SHIM_DIR/sha256sum" + + # cosign: always succeed unless COSIGN_FAIL=1 is exported + cat > "$SHIM_DIR/cosign" <<'EOF' +#!/usr/bin/env bash +[[ "${COSIGN_FAIL:-0}" = "1" ]] && exit 1 || exit 0 +EOF + chmod +x "$SHIM_DIR/cosign" +} + +_make_getent_shim() { + # Minimal getent for passwd lookups used by uninstall (works with $USER/HOME) + cat > "$SHIM_DIR/getent" <<'EOF' +#!/usr/bin/env bash +if [[ "$1" = "passwd" && -n "${2:-}" ]]; then + u="$2" + # username:x:uid:gid:gecos:home:shell + echo "${u}:x:1000:1000:${u}:${HOME}:/bin/bash" +else + exit 2 +fi +EOF + chmod +x "$SHIM_DIR/getent" +} + +# +# ---- Convenience runner ------------------------------------------------------ +# + +run_install() { + # $1: OS (linux|macos) + # $2: ARCH (amd64|arm64) + # OPTIONS... passed to installer (e.g., -d "$BIN_DIR") + export UNAME_S="$([[ $1 = macos ]] && echo Darwin || echo Linux)" + export UNAME_M="$([[ $2 = amd64 ]] && echo x86_64 || echo arm64)" + + run bash "$SCRIPT" -d "$BIN_DIR" "${@:3}" +} + +# +# ---- Tests ------------------------------------------------------------------- +# + +@test "Linux amd64: URL formation & installs into temp -d" { + export TEST_VERSION="1.2.3" + run_install linux amd64 + assert_success + + # Binary exists and is executable + assert_file_executable "$BIN_DIR/pair" + + # Check the captured curl URL used for the binary download + download_url="$(grep -E '/linux/amd64/pair_1\.2\.3$' "$CURL_LOG" || true)" + [ -n "$download_url" ] || fail "Expected a linux/amd64 download URL with version 1.2.3; got: $(cat "$CURL_LOG")" +} + +@test "macOS arm64: URL formation & installs into temp -d" { + export TEST_VERSION="9.9.9" + run_install macos arm64 + assert_success + + assert_file_executable "$BIN_DIR/pair" + + download_url="$(grep -E '/macos/arm64/pair_9\.9\.9$' "$CURL_LOG" || true)" + [ -n "$download_url" ] || fail "Expected a macos/arm64 download URL with version 9.9.9; got: $(cat "$CURL_LOG")" +} + +@test "Uninstall removes binary and ~/.config/pair (no sudo)" { + export TEST_VERSION="2.0.0" + + # First install + run_install linux amd64 + assert_success + assert_file_exists "$BIN_DIR/pair" + + # Create a fake config file to ensure uninstall cleans it + mkdir -p "$HOME/.config/pair" + echo "cfg" > "$HOME/.config/pair/config" + + # Now uninstall (use the same -d path so script removes the right binary) + run bash "$SCRIPT" -d "$BIN_DIR" --uninstall + assert_success + + assert_file_not_exists "$BIN_DIR/pair" + assert_dir_not_exists "$HOME/.config/pair" +} + +@test "Linux binary verification path succeeds when VERIFY_BINARY=true" { + export TEST_VERSION="2.4.7-build" + export VERIFY_BINARY="true" + + run_install linux amd64 + assert_success + + assert_file_executable "$BIN_DIR/pair" + + # Sanity-check that the checksum artifacts were “downloaded” + grep -q "pair_2.4.7-build.pem" "$CURL_LOG" + grep -q "pair_2.4.7-build.sig" "$CURL_LOG" +} \ No newline at end of file