From 90ba396c29bc7df226fd21ec0b85e680d7499ab7 Mon Sep 17 00:00:00 2001 From: Cedric Lamalle Date: Sun, 11 Oct 2026 14:49:28 +0000 Subject: [PATCH 1/3] Adicionar instalador em uma linha TG-317 --- .github/workflows/ci.yml | 7 + README.md | 22 ++- install.sh | 184 ++++++++++++++++++++ scripts/test-install | 194 ++++++++++++++++++++++ skills/taiga-cli/references/instalacao.md | 23 ++- 5 files changed, 427 insertions(+), 3 deletions(-) create mode 100755 install.sh create mode 100755 scripts/test-install diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebc40ef..ce9cb10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,13 @@ jobs: with: version: v2.14.0 install-mode: goinstall + install: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: sudo apt-get update && sudo apt-get install -y shellcheck dash + - run: shellcheck -s sh install.sh scripts/test-install + - run: scripts/test-install dash sh bash integration: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index dc1dc06..1b9ce8b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,27 @@ Install the binary in `~/.local/bin` (it must be on your `PATH`). The commands below use `v0.4.0`, which exists only once that release is published; until then, use the latest tag on [GitHub Releases](https://github.com/BasisTI/taiga-cli/releases). -Download the tarball for your platform together with `SHA256SUMS`; the binary is installed only if its checksum matches. The block runs in a subshell with `set -eu`, so any failing step (download, a missing or failed checksum, extraction) stops it before anything is installed, and your shell stays open. On macOS, where `sha256sum` may be missing, it uses `shasum -a 256`: +One line, with `curl` (or `wget`) and no `gh`: + +```sh +curl -fsSL https://raw.githubusercontent.com/BasisTI/taiga-cli/main/install.sh | sh +``` + +[`install.sh`](install.sh) is POSIX `sh` and never uses `sudo`. It picks the latest release (no GitHub API call), downloads the tarball for your OS and architecture (Linux or macOS, amd64 or arm64) with `SHA256SUMS`, installs only if the checksum matches, and finishes by running `taiga version`. Pass options after `sh -s --`, or use the environment variables: + +```sh +# a specific version (with or without the "v") in another directory +curl -fsSL https://raw.githubusercontent.com/BasisTI/taiga-cli/main/install.sh | sh -s -- --version v0.4.0 --dir "$HOME/bin" +curl -fsSL https://raw.githubusercontent.com/BasisTI/taiga-cli/main/install.sh | TAIGA_VERSION=0.4.0 TAIGA_INSTALL_DIR="$HOME/bin" sh +``` + +It warns when the destination is not on your `PATH`. `TAIGA_INSTALL_BASE_URL` replaces the releases URL and exists only to test the installer against a local fixture (`scripts/test-install`); do not set it otherwise. + +Prefer to read the script first? Download `install.sh`, review it and run `sh install.sh`. + +### Manual install + +To do it by hand, download the tarball for your platform together with `SHA256SUMS`; the binary is installed only if its checksum matches. The block runs in a subshell with `set -eu`, so any failing step (download, a missing or failed checksum, extraction) stops it before anything is installed, and your shell stays open. On macOS, where `sha256sum` may be missing, it uses `shasum -a 256`: ```sh ( diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..2f0f97e --- /dev/null +++ b/install.sh @@ -0,0 +1,184 @@ +#!/bin/sh +# Install the taiga CLI from a GitHub release. +# +# curl -fsSL https://raw.githubusercontent.com/BasisTI/taiga-cli/main/install.sh | sh +# curl -fsSL .../install.sh | sh -s -- --version v0.4.0 --dir "$HOME/bin" +# +# Options (the environment variable is used when the option is absent): +# -v, --version VERSION release to install, with or without "v" (TAIGA_VERSION; default: latest) +# -d, --dir DIR destination directory (TAIGA_INSTALL_DIR; default: ~/.local/bin) +# -h, --help show this help +# +# TAIGA_INSTALL_BASE_URL replaces https://github.com/BasisTI/taiga-cli/releases and exists +# only so the installer can be tested against a local fixture; do not set it otherwise. +# +# The whole script lives in main(), called on the last line: a truncated download +# defines nothing and runs nothing. It never uses sudo. + +main() { + set -eu + + repo_url="https://github.com/BasisTI/taiga-cli/releases" + base_url="${TAIGA_INSTALL_BASE_URL:-$repo_url}" + base_url="${base_url%/}" + version="${TAIGA_VERSION:-}" + dir="${TAIGA_INSTALL_DIR:-${HOME:-}/.local/bin}" + + while [ $# -gt 0 ]; do + case "$1" in + -v | --version) + [ $# -ge 2 ] || die "$1 needs a value" + version="$2" + shift 2 + ;; + --version=*) + version="${1#--version=}" + shift + ;; + -d | --dir) + [ $# -ge 2 ] || die "$1 needs a value" + dir="$2" + shift 2 + ;; + --dir=*) + dir="${1#--dir=}" + shift + ;; + -h | --help) + usage + return 0 + ;; + *) + die "unknown option: $1 (try --help)" + ;; + esac + done + + [ -n "$dir" ] && [ "$dir" != "/.local/bin" ] || die "cannot work out the destination: set HOME, TAIGA_INSTALL_DIR or --dir" + while [ "${#dir}" -gt 1 ] && [ "${dir%/}" != "$dir" ]; do dir="${dir%/}"; done + + if command -v curl >/dev/null 2>&1; then + fetcher=curl + elif command -v wget >/dev/null 2>&1; then + fetcher=wget + else + die "curl or wget is required" + fi + if command -v sha256sum >/dev/null 2>&1; then + hasher="sha256sum" + elif command -v shasum >/dev/null 2>&1; then + hasher="shasum -a 256" + else + die "sha256sum or shasum is required to verify the download" + fi + command -v tar >/dev/null 2>&1 || die "tar is required" + + case "$(uname -s)" in + Linux) os=linux ;; + Darwin) os=darwin ;; + *) die "unsupported operating system: $(uname -s) (supported: Linux, Darwin)" ;; + esac + case "$(uname -m)" in + x86_64 | amd64) arch=amd64 ;; + aarch64 | arm64) arch=arm64 ;; + *) die "unsupported architecture: $(uname -m) (supported: x86_64, arm64)" ;; + esac + + if [ -z "$version" ]; then + say "Looking up the latest release" + version=$(latest_version) + [ -n "$version" ] || die "could not work out the latest release from $base_url/latest" + fi + version="${version#v}" + case "$version" in + '' | *[!0-9A-Za-z._+-]*) die "invalid version: $version" ;; + esac + + file="taiga_${version}_${os}_${arch}.tar.gz" + url="$base_url/download/v$version" + + tmp=$(mktemp -d "${TMPDIR:-/tmp}/taiga-install.XXXXXX") || die "cannot create a temporary directory" + staged="" + trap 'cleanup' EXIT + trap 'exit 1' INT TERM HUP + + say "Downloading taiga v$version for $os/$arch" + download "$url/$file" "$tmp/$file" || die "could not download $url/$file" + download "$url/SHA256SUMS" "$tmp/SHA256SUMS" || die "could not download $url/SHA256SUMS" + + expected=$(awk -v f="$file" '$2 == f { print $1; exit }' "$tmp/SHA256SUMS") + [ -n "$expected" ] || die "$file is not listed in SHA256SUMS; nothing installed" + actual=$($hasher "$tmp/$file" | awk '{ print $1 }') + if [ "$expected" != "$actual" ]; then + die "checksum mismatch for $file (expected $expected, got $actual); nothing installed" + fi + say "Checksum OK" + + tar -xzf "$tmp/$file" -C "$tmp" taiga || die "could not extract taiga from $file" + [ -f "$tmp/taiga" ] || die "$file does not contain a taiga binary" + + mkdir -p "$dir" || die "cannot create $dir" + staged="$dir/.taiga.$$" + cp "$tmp/taiga" "$staged" || die "cannot write to $dir" + chmod 0755 "$staged" + mv -f "$staged" "$dir/taiga" || die "cannot install into $dir" + staged="" + + say "Installed $dir/taiga" + "$dir/taiga" version &2 +} + +die() { + printf 'install.sh: %s\n' "$*" >&2 + exit 1 +} + +cleanup() { + [ -z "${staged:-}" ] || rm -f "$staged" + [ -z "${tmp:-}" ] || rm -rf "$tmp" +} + +download() { + if [ "$fetcher" = curl ]; then + curl -fsSL -o "$2" "$1" + else + wget -q -O "$2" "$1" + fi +} + +# Prints the latest version from the redirect of /latest (…/tag/vX.Y.Z), without the GitHub API. +latest_version() { + if [ "$fetcher" = curl ]; then + location=$(curl -fsS -o /dev/null -w '%{redirect_url}' "$base_url/latest") || return 0 + else + location=$(wget -q -S --max-redirect=0 -O /dev/null "$base_url/latest" 2>&1 | awk 'tolower($1) == "location:" { print $2; exit }' | tr -d '\r') || return 0 + fi + tag="${location##*/}" + case "$tag" in + v[0-9]*) printf '%s\n' "${tag#v}" ;; + esac +} + +main "$@" diff --git a/scripts/test-install b/scripts/test-install new file mode 100755 index 0000000..7322378 --- /dev/null +++ b/scripts/test-install @@ -0,0 +1,194 @@ +#!/bin/sh +# Tests install.sh against a local fixture (no external network). +# scripts/test-install [shell...] default shells: dash, sh +# A fake release tree (tarball + SHA256SUMS built here) is served over HTTP on +# 127.0.0.1, with /latest redirecting to /tag/v0.4.0 like github.com does. +set -eu + +root=$(cd "$(dirname "$0")/.." && pwd) +work=$(mktemp -d "${TMPDIR:-/tmp}/taiga-install-test.XXXXXX") +server_pid="" +# shellcheck disable=SC2329 # runs from the EXIT trap +cleanup() { + [ -z "$server_pid" ] || kill "$server_pid" 2>/dev/null || true + rm -rf "$work" +} +trap cleanup EXIT +trap 'exit 1' INT TERM HUP + +[ $# -gt 0 ] || set -- dash sh + +if command -v sha256sum >/dev/null 2>&1; then hasher="sha256sum"; else hasher="shasum -a 256"; fi + +# --- fixture --------------------------------------------------------------- +site="$work/site" +build="$work/build" +mkdir -p "$build" +cat > "$build/taiga" <<'BIN' +#!/bin/sh +echo "taiga version fixture" +BIN +chmod +x "$build/taiga" +for v in 0.4.0 0.3.0; do + mkdir -p "$site/download/v$v" + for p in linux_amd64 linux_arm64 darwin_amd64 darwin_arm64; do + tar -czf "$site/download/v$v/taiga_${v}_${p}.tar.gz" -C "$build" taiga + done + (cd "$site/download/v$v" && $hasher ./*.tar.gz | sed 's#\./##' > SHA256SUMS) +done +# v0.2.0: tarball exists but SHA256SUMS does not list it. +mkdir -p "$site/download/v0.2.0" +cp "$site/download/v0.4.0/taiga_0.4.0_linux_amd64.tar.gz" "$site/download/v0.2.0/taiga_0.2.0_linux_amd64.tar.gz" +cp "$site/download/v0.4.0/taiga_0.4.0_linux_arm64.tar.gz" "$site/download/v0.2.0/taiga_0.2.0_linux_arm64.tar.gz" +printf '%s other.tar.gz\n' "0000000000000000000000000000000000000000000000000000000000000000" > "$site/download/v0.2.0/SHA256SUMS" +# v0.1.0: SHA256SUMS lists a digest that does not match. +mkdir -p "$site/download/v0.1.0" +for p in linux_amd64 linux_arm64; do + cp "$site/download/v0.4.0/taiga_0.4.0_$p.tar.gz" "$site/download/v0.1.0/taiga_0.1.0_$p.tar.gz" + printf '%s taiga_0.1.0_%s.tar.gz\n' "1111111111111111111111111111111111111111111111111111111111111111" "$p" >> "$site/download/v0.1.0/SHA256SUMS" +done + +cat > "$work/server.py" <<'PY' +import http.server, os, sys + +site = sys.argv[1] + +class H(http.server.SimpleHTTPRequestHandler): + def __init__(self, *a, **k): + super().__init__(*a, directory=site, **k) + def do_GET(self): + if self.path == "/latest": + self.send_response(302) + self.send_header("Location", "/tag/v0.4.0") + self.end_headers() + return + super().do_GET() + def log_message(self, *a): + pass + +s = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H) +print(s.server_address[1], flush=True) +s.serve_forever() +PY +python3 "$work/server.py" "$site" > "$work/port" & +server_pid=$! +i=0 +while [ ! -s "$work/port" ]; do + i=$((i + 1)); [ $i -lt 100 ] || { echo "fixture server did not start" >&2; exit 1; } + sleep 0.1 +done +port=$(cat "$work/port") +export TAIGA_INSTALL_BASE_URL="http://127.0.0.1:$port" + +# Fake uname for the unsupported-platform cases. +fake="$work/fakebin" +mkdir -p "$fake" +cat > "$fake/uname" <<'UN' +#!/bin/sh +case "$1" in + -s) echo "${FAKE_UNAME_S:-Linux}" ;; + -m) echo "${FAKE_UNAME_M:-x86_64}" ;; + *) echo "${FAKE_UNAME_S:-Linux}" ;; +esac +UN +chmod +x "$fake/uname" + +# PATH with wget but without curl. +nocurl="$work/nocurl" +mkdir -p "$nocurl" +for t in awk tr uname mktemp tar gzip wget sha256sum shasum mkdir cp chmod mv rm cat sed dirname; do + if p=$(command -v "$t" 2>/dev/null) && [ -x "$p" ]; then ln -s "$p" "$nocurl/$t"; fi +done + +# --- harness --------------------------------------------------------------- +fail=0 +n=0 +check() { # check "name" condition-exit-status + n=$((n + 1)) + if [ "$2" -eq 0 ]; then echo "ok $1"; else echo "FAIL $1"; fail=1; fi +} +contains() { case "$1" in *"$2"*) return 0 ;; *) return 1 ;; esac; } + +run() { # run ; output in $out, status in $rc + sh_bin=$1; shift + rc=0 + out=$("$sh_bin" "$root/install.sh" "$@" 2>&1 /dev/null 2>&1 || { echo "skip $sh_bin (not found)"; continue; } + echo "== $sh_bin" + unset TAIGA_VERSION TAIGA_INSTALL_DIR FAKE_UNAME_S FAKE_UNAME_M || true + d="$work/dest.$sh_bin" + rm -rf "$d" + + # latest release, destination outside PATH + TAIGA_INSTALL_DIR="$d/a" run "$sh_bin" + if [ $rc -eq 0 ] && [ -x "$d/a/taiga" ] && contains "$out" "taiga version fixture" && contains "$out" "Installed $d/a/taiga" && contains "$out" "v0.4.0"; then check "latest install ($sh_bin)" 0; else check "latest install ($sh_bin)" 1; fi + if contains "$out" "is not on your PATH"; then check "warns when dir is not on PATH ($sh_bin)" 0; else check "warns when dir is not on PATH ($sh_bin)" 1; fi + if [ "$(find "$d/a" -mindepth 1 | wc -l)" -eq 1 ]; then check "no leftovers beside the binary ($sh_bin)" 0; else check "no leftovers beside the binary ($sh_bin)" 1; fi + + # destination on PATH: no warning + mkdir -p "$d/onpath" + rc=0; out=$(PATH="$d/onpath:$PATH" TAIGA_INSTALL_DIR="$d/onpath/" "$sh_bin" "$root/install.sh" -v 0.3.0 2>&1 &1) || rc=$? + if [ $rc -eq 0 ] && [ -x "$d/f/taiga" ]; then check "piped to sh -s -- ($sh_bin)" 0; else check "piped to sh -s -- ($sh_bin)" 1; fi + + # wget fallback (latest and pinned) + if [ -x "$nocurl/wget" ]; then + sh_path=$(command -v "$sh_bin") + rc=0; out=$(PATH="$nocurl" TAIGA_INSTALL_DIR="$d/w" "$sh_path" "$root/install.sh" 2>&1 "$d/a/taiga" + TAIGA_INSTALL_DIR="$d/a" run "$sh_bin" + if [ $rc -eq 0 ] && contains "$out" "taiga version fixture"; then check "overwrites an existing binary ($sh_bin)" 0; else check "overwrites an existing binary ($sh_bin)" 1; fi + + # checksum mismatch + TAIGA_VERSION=0.1.0 TAIGA_INSTALL_DIR="$d/g" run "$sh_bin" + if [ $rc -ne 0 ] && contains "$out" "checksum mismatch" && [ ! -e "$d/g/taiga" ]; then check "checksum mismatch aborts ($sh_bin)" 0; else check "checksum mismatch aborts ($sh_bin)" 1; fi + + # artifact missing from SHA256SUMS + TAIGA_VERSION=0.2.0 TAIGA_INSTALL_DIR="$d/h" run "$sh_bin" + if [ $rc -ne 0 ] && contains "$out" "not listed in SHA256SUMS" && [ ! -e "$d/h/taiga" ]; then check "artifact missing from SHA256SUMS aborts ($sh_bin)" 0; else check "artifact missing from SHA256SUMS aborts ($sh_bin)" 1; fi + + # release that does not exist + TAIGA_VERSION=9.9.9 TAIGA_INSTALL_DIR="$d/i" run "$sh_bin" + if [ $rc -ne 0 ] && [ ! -e "$d/i/taiga" ]; then check "unknown release aborts ($sh_bin)" 0; else check "unknown release aborts ($sh_bin)" 1; fi + + # unsupported platforms + rc=0; out=$(PATH="$fake:$PATH" FAKE_UNAME_S=FreeBSD TAIGA_INSTALL_DIR="$d/j" "$sh_bin" "$root/install.sh" 2>&1 &1 &1 &1) || rc=$? + if [ ! -e "$d/n" ]; then check "truncated script installs nothing ($sh_bin)" 0; else check "truncated script installs nothing ($sh_bin)" 1; fi +done + +echo "$n checks, $([ $fail -eq 0 ] && echo passed || echo FAILED)" +exit $fail diff --git a/skills/taiga-cli/references/instalacao.md b/skills/taiga-cli/references/instalacao.md index 0a992dd..deaff75 100644 --- a/skills/taiga-cli/references/instalacao.md +++ b/skills/taiga-cli/references/instalacao.md @@ -8,7 +8,26 @@ login e não edita a config do Codex: quando faltar algum destes passos, ele par Os comandos usam a `v0.4.0`, que só existe depois de publicada a release; antes disso, use a última tag de [Releases](https://github.com/BasisTI/taiga-cli/releases). -Caminho principal: o tar.gz da release, conferido pelo `SHA256SUMS`. Ajuste `P` para a +Caminho principal, uma linha, com `curl` (ou `wget`), sem `gh`: + +```sh +curl -fsSL https://raw.githubusercontent.com/BasisTI/taiga-cli/main/install.sh | sh +``` + +O `install.sh` é `sh` POSIX e nunca usa `sudo`: descobre a última release (sem a API do +GitHub), baixa o tar.gz da sua plataforma (Linux ou macOS, amd64 ou arm64) com o +`SHA256SUMS`, só instala se o checksum bater e termina rodando `taiga version`. Versão e +destino se fixam por opção ou por variável; ele avisa se o destino não está no `PATH`: + +```sh +curl -fsSL https://raw.githubusercontent.com/BasisTI/taiga-cli/main/install.sh | sh -s -- --version v0.4.0 --dir "$HOME/bin" +curl -fsSL https://raw.githubusercontent.com/BasisTI/taiga-cli/main/install.sh | TAIGA_VERSION=0.4.0 TAIGA_INSTALL_DIR="$HOME/bin" sh +``` + +`TAIGA_INSTALL_BASE_URL` existe só para testar o instalador contra uma fixture local; não +a defina fora disso. + +Alternativa manual, o tar.gz da release conferido pelo `SHA256SUMS`. Ajuste `P` para a plataforma (`linux_amd64`, `linux_arm64`, `darwin_amd64` ou `darwin_arm64`). O bloco roda num subshell com `set -eu`: qualquer passo que falhe (download, checksum ausente ou `FAILED`, extração) interrompe antes de instalar, sem fechar o seu shell. No macOS, sem @@ -35,7 +54,7 @@ num subshell com `set -eu`: qualquer passo que falhe (download, checksum ausente Sem `gh`, baixe o tar.gz e o `SHA256SUMS` pela página de releases para um diretório vazio, entre nele e rode o mesmo bloco sem as linhas do `cd` e do `gh`. -Alternativa, com Go instalado: +Outra alternativa, com Go instalado: ```sh GOBIN="$HOME/.local/bin" go install github.com/BasisTI/taiga-cli/cmd/taiga@v0.4.0 From 420695ec175e55dfe133e56c7343210d18a1dfdb Mon Sep 17 00:00:00 2001 From: Cedric Lamalle Date: Sun, 11 Oct 2026 14:53:57 +0000 Subject: [PATCH 2/3] Corrigir avisos do shellcheck no instalador TG-317 --- install.sh | 4 +++- scripts/test-install | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/install.sh b/install.sh index 2f0f97e..8d9cfe8 100755 --- a/install.sh +++ b/install.sh @@ -54,7 +54,9 @@ main() { esac done - [ -n "$dir" ] && [ "$dir" != "/.local/bin" ] || die "cannot work out the destination: set HOME, TAIGA_INSTALL_DIR or --dir" + if [ -z "$dir" ] || [ "$dir" = "/.local/bin" ]; then + die "cannot work out the destination: set HOME, TAIGA_INSTALL_DIR or --dir" + fi while [ "${#dir}" -gt 1 ] && [ "${dir%/}" != "$dir" ]; do dir="${dir%/}"; done if command -v curl >/dev/null 2>&1; then diff --git a/scripts/test-install b/scripts/test-install index 7322378..ffcb598 100755 --- a/scripts/test-install +++ b/scripts/test-install @@ -8,7 +8,7 @@ set -eu root=$(cd "$(dirname "$0")/.." && pwd) work=$(mktemp -d "${TMPDIR:-/tmp}/taiga-install-test.XXXXXX") server_pid="" -# shellcheck disable=SC2329 # runs from the EXIT trap +# shellcheck disable=SC2317,SC2329 # runs from the EXIT trap cleanup() { [ -z "$server_pid" ] || kill "$server_pid" 2>/dev/null || true rm -rf "$work" @@ -144,7 +144,7 @@ for sh_bin in "$@"; do if [ $rc -eq 0 ] && [ -x "$d/e/taiga" ]; then check "--version=/--dir= options ($sh_bin)" 0; else check "--version=/--dir= options ($sh_bin)" 1; fi # piped, as in curl | sh -s -- ... - rc=0; out=$(cat "$root/install.sh" | "$sh_bin" -s -- --version 0.3.0 --dir "$d/f" 2>&1) || rc=$? + rc=0; out=$(cat < "$root/install.sh" | "$sh_bin" -s -- --version 0.3.0 --dir "$d/f" 2>&1) || rc=$? if [ $rc -eq 0 ] && [ -x "$d/f/taiga" ]; then check "piped to sh -s -- ($sh_bin)" 0; else check "piped to sh -s -- ($sh_bin)" 1; fi # wget fallback (latest and pinned) From 4c37c8a8073cff80e2d9b91be92ee93f1e0e5718 Mon Sep 17 00:00:00 2001 From: Cedric Lamalle Date: Sun, 11 Oct 2026 15:05:15 +0000 Subject: [PATCH 3/3] =?UTF-8?q?Corrigir=20latest=20com=20BusyBox=20wget,?= =?UTF-8?q?=20truncamento=20e=20destino=20diret=C3=B3rio=20TG-317?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 5 + install.sh | 343 ++++++++++++++++++++------------------- scripts/test-install | 29 +++- 3 files changed, 206 insertions(+), 171 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce9cb10..8c02515 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,11 @@ jobs: - run: sudo apt-get update && sudo apt-get install -y shellcheck dash - run: shellcheck -s sh install.sh scripts/test-install - run: scripts/test-install dash sh bash + # Alpine has no curl and a BusyBox wget (no --max-redirect); the whole suite + # runs there so the wget fallback is exercised for real. + - run: > + docker run --rm --network host -v "$PWD:/src:ro" alpine:3.23 + sh -c 'apk add --no-cache python3 >/dev/null && sh /src/scripts/test-install sh' integration: runs-on: ubuntu-latest steps: diff --git a/install.sh b/install.sh index 8d9cfe8..015b485 100755 --- a/install.sh +++ b/install.sh @@ -12,175 +12,184 @@ # TAIGA_INSTALL_BASE_URL replaces https://github.com/BasisTI/taiga-cli/releases and exists # only so the installer can be tested against a local fixture; do not set it otherwise. # -# The whole script lives in main(), called on the last line: a truncated download -# defines nothing and runs nothing. It never uses sudo. - -main() { - set -eu - - repo_url="https://github.com/BasisTI/taiga-cli/releases" - base_url="${TAIGA_INSTALL_BASE_URL:-$repo_url}" - base_url="${base_url%/}" - version="${TAIGA_VERSION:-}" - dir="${TAIGA_INSTALL_DIR:-${HOME:-}/.local/bin}" - - while [ $# -gt 0 ]; do - case "$1" in - -v | --version) - [ $# -ge 2 ] || die "$1 needs a value" - version="$2" - shift 2 - ;; - --version=*) - version="${1#--version=}" - shift - ;; - -d | --dir) - [ $# -ge 2 ] || die "$1 needs a value" - dir="$2" - shift 2 - ;; - --dir=*) - dir="${1#--dir=}" - shift - ;; - -h | --help) - usage - return 0 - ;; - *) - die "unknown option: $1 (try --help)" - ;; +# It never uses sudo. + +# Everything below sits in one brace group, so the shell runs nothing until it has +# read the closing brace: a download cut at any point is a syntax error, not a +# partial install. +{ + main() { + set -eu + + repo_url="https://github.com/BasisTI/taiga-cli/releases" + base_url="${TAIGA_INSTALL_BASE_URL:-$repo_url}" + base_url="${base_url%/}" + version="${TAIGA_VERSION:-}" + dir="${TAIGA_INSTALL_DIR:-${HOME:-}/.local/bin}" + + while [ $# -gt 0 ]; do + case "$1" in + -v | --version) + [ $# -ge 2 ] || die "$1 needs a value" + version="$2" + shift 2 + ;; + --version=*) + version="${1#--version=}" + shift + ;; + -d | --dir) + [ $# -ge 2 ] || die "$1 needs a value" + dir="$2" + shift 2 + ;; + --dir=*) + dir="${1#--dir=}" + shift + ;; + -h | --help) + usage + return 0 + ;; + *) + die "unknown option: $1 (try --help)" + ;; + esac + done + + if [ -z "$dir" ] || [ "$dir" = "/.local/bin" ]; then + die "cannot work out the destination: set HOME, TAIGA_INSTALL_DIR or --dir" + fi + while [ "${#dir}" -gt 1 ] && [ "${dir%/}" != "$dir" ]; do dir="${dir%/}"; done + + if [ -e "$dir/taiga" ] && [ ! -f "$dir/taiga" ]; then + die "$dir/taiga exists and is not a regular file; nothing installed" + fi + + if command -v curl >/dev/null 2>&1; then + fetcher=curl + elif command -v wget >/dev/null 2>&1; then + fetcher=wget + else + die "curl or wget is required" + fi + if command -v sha256sum >/dev/null 2>&1; then + hasher="sha256sum" + elif command -v shasum >/dev/null 2>&1; then + hasher="shasum -a 256" + else + die "sha256sum or shasum is required to verify the download" + fi + command -v tar >/dev/null 2>&1 || die "tar is required" + + case "$(uname -s)" in + Linux) os=linux ;; + Darwin) os=darwin ;; + *) die "unsupported operating system: $(uname -s) (supported: Linux, Darwin)" ;; + esac + case "$(uname -m)" in + x86_64 | amd64) arch=amd64 ;; + aarch64 | arm64) arch=arm64 ;; + *) die "unsupported architecture: $(uname -m) (supported: x86_64, arm64)" ;; esac - done - - if [ -z "$dir" ] || [ "$dir" = "/.local/bin" ]; then - die "cannot work out the destination: set HOME, TAIGA_INSTALL_DIR or --dir" - fi - while [ "${#dir}" -gt 1 ] && [ "${dir%/}" != "$dir" ]; do dir="${dir%/}"; done - - if command -v curl >/dev/null 2>&1; then - fetcher=curl - elif command -v wget >/dev/null 2>&1; then - fetcher=wget - else - die "curl or wget is required" - fi - if command -v sha256sum >/dev/null 2>&1; then - hasher="sha256sum" - elif command -v shasum >/dev/null 2>&1; then - hasher="shasum -a 256" - else - die "sha256sum or shasum is required to verify the download" - fi - command -v tar >/dev/null 2>&1 || die "tar is required" - - case "$(uname -s)" in - Linux) os=linux ;; - Darwin) os=darwin ;; - *) die "unsupported operating system: $(uname -s) (supported: Linux, Darwin)" ;; - esac - case "$(uname -m)" in - x86_64 | amd64) arch=amd64 ;; - aarch64 | arm64) arch=arm64 ;; - *) die "unsupported architecture: $(uname -m) (supported: x86_64, arm64)" ;; - esac - - if [ -z "$version" ]; then - say "Looking up the latest release" - version=$(latest_version) - [ -n "$version" ] || die "could not work out the latest release from $base_url/latest" - fi - version="${version#v}" - case "$version" in - '' | *[!0-9A-Za-z._+-]*) die "invalid version: $version" ;; - esac - - file="taiga_${version}_${os}_${arch}.tar.gz" - url="$base_url/download/v$version" - - tmp=$(mktemp -d "${TMPDIR:-/tmp}/taiga-install.XXXXXX") || die "cannot create a temporary directory" - staged="" - trap 'cleanup' EXIT - trap 'exit 1' INT TERM HUP - - say "Downloading taiga v$version for $os/$arch" - download "$url/$file" "$tmp/$file" || die "could not download $url/$file" - download "$url/SHA256SUMS" "$tmp/SHA256SUMS" || die "could not download $url/SHA256SUMS" - - expected=$(awk -v f="$file" '$2 == f { print $1; exit }' "$tmp/SHA256SUMS") - [ -n "$expected" ] || die "$file is not listed in SHA256SUMS; nothing installed" - actual=$($hasher "$tmp/$file" | awk '{ print $1 }') - if [ "$expected" != "$actual" ]; then - die "checksum mismatch for $file (expected $expected, got $actual); nothing installed" - fi - say "Checksum OK" - - tar -xzf "$tmp/$file" -C "$tmp" taiga || die "could not extract taiga from $file" - [ -f "$tmp/taiga" ] || die "$file does not contain a taiga binary" - - mkdir -p "$dir" || die "cannot create $dir" - staged="$dir/.taiga.$$" - cp "$tmp/taiga" "$staged" || die "cannot write to $dir" - chmod 0755 "$staged" - mv -f "$staged" "$dir/taiga" || die "cannot install into $dir" - staged="" - - say "Installed $dir/taiga" - "$dir/taiga" version &2 -} - -die() { - printf 'install.sh: %s\n' "$*" >&2 - exit 1 -} -cleanup() { - [ -z "${staged:-}" ] || rm -f "$staged" - [ -z "${tmp:-}" ] || rm -rf "$tmp" -} + if [ -z "$version" ]; then + say "Looking up the latest release" + version=$(latest_version) + [ -n "$version" ] || die "could not work out the latest release from $base_url/latest" + fi + version="${version#v}" + case "$version" in + '' | *[!0-9A-Za-z._+-]*) die "invalid version: $version" ;; + esac -download() { - if [ "$fetcher" = curl ]; then - curl -fsSL -o "$2" "$1" - else - wget -q -O "$2" "$1" - fi -} + file="taiga_${version}_${os}_${arch}.tar.gz" + url="$base_url/download/v$version" + + tmp=$(mktemp -d "${TMPDIR:-/tmp}/taiga-install.XXXXXX") || die "cannot create a temporary directory" + staged="" + trap 'cleanup' EXIT + trap 'exit 1' INT TERM HUP + + say "Downloading taiga v$version for $os/$arch" + download "$url/$file" "$tmp/$file" || die "could not download $url/$file" + download "$url/SHA256SUMS" "$tmp/SHA256SUMS" || die "could not download $url/SHA256SUMS" + + expected=$(awk -v f="$file" '$2 == f { print $1; exit }' "$tmp/SHA256SUMS") + [ -n "$expected" ] || die "$file is not listed in SHA256SUMS; nothing installed" + actual=$($hasher "$tmp/$file" | awk '{ print $1 }') + if [ "$expected" != "$actual" ]; then + die "checksum mismatch for $file (expected $expected, got $actual); nothing installed" + fi + say "Checksum OK" + + tar -xzf "$tmp/$file" -C "$tmp" taiga || die "could not extract taiga from $file" + [ -f "$tmp/taiga" ] || die "$file does not contain a taiga binary" + + mkdir -p "$dir" || die "cannot create $dir" + staged="$dir/.taiga.$$" + cp "$tmp/taiga" "$staged" || die "cannot write to $dir" + chmod 0755 "$staged" + mv -f "$staged" "$dir/taiga" || die "cannot install into $dir" + staged="" + + say "Installed $dir/taiga" + "$dir/taiga" version &2 + } + + die() { + printf 'install.sh: %s\n' "$*" >&2 + exit 1 + } + + cleanup() { + [ -z "${staged:-}" ] || rm -f "$staged" + [ -z "${tmp:-}" ] || rm -rf "$tmp" + } + + download() { + if [ "$fetcher" = curl ]; then + curl -fsSL -o "$2" "$1" + else + wget -q -O "$2" "$1" + fi + } + + # Prints the latest version from the redirect of /latest (…/tag/vX.Y.Z), without the GitHub API. + latest_version() { + if [ "$fetcher" = curl ]; then + location=$(curl -fsS -o /dev/null -w '%{redirect_url}' "$base_url/latest") || return 0 + else + # No --max-redirect: BusyBox wget lacks it. Follow the redirect and read the + # first Location header from the -S output (GNU and BusyBox both print it). + location=$(wget -q -S -O /dev/null "$base_url/latest" 2>&1 | awk 'tolower($1) == "location:" { print $2; exit }' | tr -d '\r') || return 0 + fi + tag="${location##*/}" + case "$tag" in + v[0-9]*) printf '%s\n' "${tag#v}" ;; + esac + } -# Prints the latest version from the redirect of /latest (…/tag/vX.Y.Z), without the GitHub API. -latest_version() { - if [ "$fetcher" = curl ]; then - location=$(curl -fsS -o /dev/null -w '%{redirect_url}' "$base_url/latest") || return 0 - else - location=$(wget -q -S --max-redirect=0 -O /dev/null "$base_url/latest" 2>&1 | awk 'tolower($1) == "location:" { print $2; exit }' | tr -d '\r') || return 0 - fi - tag="${location##*/}" - case "$tag" in - v[0-9]*) printf '%s\n' "${tag#v}" ;; - esac + main "$@" } - -main "$@" diff --git a/scripts/test-install b/scripts/test-install index ffcb598..5d6f8be 100755 --- a/scripts/test-install +++ b/scripts/test-install @@ -62,6 +62,12 @@ class H(http.server.SimpleHTTPRequestHandler): self.send_header("Location", "/tag/v0.4.0") self.end_headers() return + if self.path.startswith("/tag/"): + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(b"release") + return super().do_GET() def log_message(self, *a): pass @@ -147,7 +153,7 @@ for sh_bin in "$@"; do rc=0; out=$(cat < "$root/install.sh" | "$sh_bin" -s -- --version 0.3.0 --dir "$d/f" 2>&1) || rc=$? if [ $rc -eq 0 ] && [ -x "$d/f/taiga" ]; then check "piped to sh -s -- ($sh_bin)" 0; else check "piped to sh -s -- ($sh_bin)" 1; fi - # wget fallback (latest and pinned) + # wget fallback (latest only; the pinned path uses the same download()) if [ -x "$nocurl/wget" ]; then sh_path=$(command -v "$sh_bin") rc=0; out=$(PATH="$nocurl" TAIGA_INSTALL_DIR="$d/w" "$sh_path" "$root/install.sh" 2>&1 &1) || rc=$? - if [ ! -e "$d/n" ]; then check "truncated script installs nothing ($sh_bin)" 0; else check "truncated script installs nothing ($sh_bin)" 1; fi + # truncated script: a cut at any point installs nothing + total=$(wc -c < "$root/install.sh") + main_line=$(grep -n '^ main "\$@"' "$root/install.sh" | cut -d: -f1) + last_main=$(head -n $((main_line - 1)) "$root/install.sh" | wc -c) + trunc_ok=1 + for cut in 0 100 400 $((total / 2)) $((last_main + 6)) $((last_main + 7)) $((last_main + 8)) $((total - 6)) $((total - 2)); do + rm -rf "$d/n" + rc=0; out=$(head -c "$cut" "$root/install.sh" | TAIGA_INSTALL_DIR="$d/n" "$sh_bin" 2>&1) || rc=$? + if [ -e "$d/n" ]; then echo " truncated at $cut bytes installed something" >&2; trunc_ok=0; fi + done + if [ $trunc_ok -eq 1 ]; then check "truncated script installs nothing, at 9 cut points ($sh_bin)" 0; else check "truncated script installs nothing ($sh_bin)" 1; fi + rc=0; out=$(head -c "$total" "$root/install.sh" | TAIGA_INSTALL_DIR="$d/n" "$sh_bin" 2>&1) || rc=$? + if [ $rc -eq 0 ] && [ -x "$d/n/taiga" ]; then check "the complete script piped in installs ($sh_bin)" 0; else check "the complete script piped in installs ($sh_bin)" 1; fi + + # destination taiga is a directory + mkdir -p "$d/o/taiga" + TAIGA_VERSION=0.3.0 TAIGA_INSTALL_DIR="$d/o" run "$sh_bin" + if [ $rc -ne 0 ] && contains "$out" "not a regular file" && [ "$(find "$d/o" -mindepth 1 | wc -l)" -eq 1 ]; then check "destination taiga is a directory ($sh_bin)" 0; else check "destination taiga is a directory ($sh_bin)" 1; fi done echo "$n checks, $([ $fail -eq 0 ] && echo passed || echo FAILED)"