diff --git a/.github/workflows/install-script.yml b/.github/workflows/install-script.yml new file mode 100644 index 0000000..2c8f7df --- /dev/null +++ b/.github/workflows/install-script.yml @@ -0,0 +1,122 @@ +name: Install Script + +# Exercises install.sh (the `curl … | sh` Linux installer) against the real +# GitHub Releases on every change to it, and weekly so a change to the +# release assets (names, archive layout, checksums.txt) that breaks the +# installer is caught without waiting for a user report. + +on: + pull_request: + paths: + - install.sh + - .github/workflows/install-script.yml + push: + branches: + - main + paths: + - install.sh + - .github/workflows/install-script.yml + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + shellcheck: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Shellcheck install.sh + run: shellcheck -s sh install.sh + + install: + strategy: + fail-fast: false + matrix: + runner: [ubuntu-latest, ubuntu-24.04-arm] + image: ["ubuntu:24.04", "debian:12", "fedora:41"] + runs-on: ${{ matrix.runner }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve latest release + id: latest + env: + GH_TOKEN: ${{ github.token }} + run: echo "version=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName -q .tagName | sed 's/^v//')" >> "$GITHUB_OUTPUT" + + # Inside a stock container, as a non-root user: the default install + # goes to ~/.local/bin with completions, a pinned version is honored, + # and --system installs the distro package. + - name: Install in ${{ matrix.image }} + env: + IMAGE: ${{ matrix.image }} + LATEST: ${{ steps.latest.outputs.version }} + run: | + docker run --rm -e LATEST -v "$PWD/install.sh:/install.sh:ro" "$IMAGE" sh -euc ' + if command -v apt-get >/dev/null; then + apt-get update -qq && apt-get install -y -qq curl ca-certificates >/dev/null + else + dnf install -y -q util-linux shadow-utils >/dev/null # su, useradd + fi + useradd -m tester + + su tester -c "sh /install.sh" + su tester -c "~/.local/bin/dci --version" | grep -F "version $LATEST" + su tester -c "test -f ~/.local/share/bash-completion/completions/dci && test -f ~/.config/fish/completions/dci.fish && test -f ~/.local/share/zsh/site-functions/_dci" + + su tester -c "sh /install.sh --version 2.7.5" + su tester -c "~/.local/bin/dci --version" | grep -F "version 2.7.5" + + sh /install.sh --system + /usr/bin/dci --version | grep -F "version $LATEST" + ' + + edge-systems: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Alpine gets a clear glibc error + run: | + set +e + out=$(docker run --rm -v "$PWD/install.sh:/install.sh:ro" alpine:3.20 sh -c 'apk add -q curl && sh /install.sh' 2>&1) + status=$? + set -e + echo "$out" + test "$status" -ne 0 + echo "$out" | grep -F "dci needs glibc" + + # A musl loader on a glibc host (Debian's musl-tools) must not trip the + # glibc check. + - name: Debian with musl-tools installs + run: | + docker run --rm -v "$PWD/install.sh:/install.sh:ro" debian:12 sh -euc ' + apt-get update -qq && apt-get install -y -qq curl ca-certificates musl-tools >/dev/null + ls /lib/ld-musl-* + sh /install.sh + ~/.local/bin/dci --version + ' + + # Too-old glibc: a clear error naming the floor, and an existing dci is + # left in place rather than replaced by a binary that cannot start. + - name: Old glibc keeps the existing dci + run: | + docker run --rm -v "$PWD/install.sh:/install.sh:ro" ubuntu:20.04 sh -euc ' + apt-get update -qq && apt-get install -y -qq curl ca-certificates >/dev/null + mkdir -p ~/.local/bin && printf "#!/bin/sh\necho old dci\n" > ~/.local/bin/dci && chmod +x ~/.local/bin/dci + if out=$(sh /install.sh 2>&1); then echo "$out"; echo "install unexpectedly succeeded"; exit 1; fi + echo "$out" + echo "$out" | grep -F "needs glibc 2.34 or newer" + test "$(~/.local/bin/dci)" = "old dci" + test -z "$(ls -A ~/.local/bin | grep -v "^dci$")" + ' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0c4f26..841bc86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,6 +75,10 @@ jobs: run: | gh workflow run "Sync Package Manifests" --field tag="$RELEASE_TAG" gh workflow run "Post-release Verify" --field tag="$RELEASE_TAG" + # install.sh reads the latest release's asset names and archive + # layout; GITHUB_TOKEN-published releases trigger no workflows, so + # run its live test against the new release explicitly. + gh workflow run "Install Script" # Help Center changelog delivery (CHANGELOG-SPEC.md §7): hand the tag to # doiteng/scribe, whose deterministic job renders CHANGELOG.md into the diff --git a/DISTRIBUTION.md b/DISTRIBUTION.md index 368716d..dee9aba 100644 --- a/DISTRIBUTION.md +++ b/DISTRIBUTION.md @@ -7,6 +7,7 @@ This document covers how `dci` releases are built, published, and distributed ac | Channel | Install command | |------------------|---------------------------------------------------------------------------------| | GitHub Releases | Download from [Releases](https://github.com/doitintl/dci-cli/releases/latest) | +| Linux installer | `curl -fsSL https://raw.githubusercontent.com/doitintl/dci-cli/main/install.sh \| sh` | | Homebrew (macOS) | `brew install doitintl/dci-cli/dci` | | Scoop (Windows) | `scoop bucket add doitintl https://github.com/doitintl/dci-cli && scoop install dci` | | WinGet (Windows) | `winget install DoiT.dci` | @@ -28,6 +29,7 @@ This document covers how `dci` releases are built, published, and distributed ac - **`release.yml`** — builds binaries, archives, `.deb`/`.rpm` packages, and publishes a GitHub Release. - **`sync-manifests.yml`** — renders and commits Homebrew and Scoop manifests to this repo, and submits a PR to [microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs) for WinGet. - **`post-release-verify.yml`** — smoke-tests Homebrew, `.deb`, `.rpm`, and Windows zip installs. + - **`install-script.yml`** (not tag-triggered) — runs `install.sh` against the live releases on every change to it and weekly; see [Linux installer](#linux-installer). ## Release Artifacts @@ -62,6 +64,40 @@ Shell completions: - `post-release-verify.yml` asserts the completion files land in the Homebrew, `.deb`, `.rpm`, and Windows zip installs. +## Linux Installer + +`install.sh` at the repo root is the one-command Linux install, served straight +from `main` via raw.githubusercontent.com. It is not part of the release build: +it reads whatever the latest release publishes. + +- **Version and checksum come from `checksums.txt`.** With no `--version`, the + script downloads `releases/latest/download/checksums.txt` and takes the + version from the archive name in it, so it never calls the GitHub API (whose + unauthenticated limit shared Cloud Shell and CI IPs hit). A checksum mismatch + or a missing checksum tool is a hard failure. +- **It depends on the release asset names** `dci__linux_.tar.gz` + / `.deb` / `.rpm` and on the archive layout (`dci` at the root, + `completions/`). Renaming either in `.goreleaser.yaml` breaks the installer; + `install-script.yml` catches that weekly at the latest. +- **Default install is `~/.local/bin`** (no root; the only location that + survives a Cloud Shell restart). `dci update` classifies it as a standalone + install and replaces it in place. `--system` installs the `.deb`/`.rpm` + instead, which `dci update` then upgrades through the package. +- **glibc only.** The binary links against glibc (2.34+, the newest `GLIBC_` + symbol version in the cgo build), so the script refuses systems without + glibc (Alpine) up front, detecting glibc by its presence rather than by a + musl loader (Debian's `musl-tools` installs one on glibc hosts). It also + runs the staged binary before replacing anything, so a too-old glibc gets + a clear error and keeps the existing `dci`. If the build image's glibc + moves, update `GLIBC_HINT` in the script and the README. +- **Re-tested after every release.** `release.yml` dispatches + `install-script.yml` alongside the other post-release workflows (a + `GITHUB_TOKEN`-published release triggers none on its own). +- **Truncation-safe.** All logic is in functions and `main "$@"` is the last + line, so a partial download of the script cannot run. +- Changes must pass `shellcheck -s sh install.sh` (POSIX sh: it runs under + dash on Debian/Ubuntu). + ## Repository Layout (Distribution Files) ``` @@ -69,6 +105,8 @@ Shell completions: .github/workflows/release.yml # Tag-triggered release .github/workflows/sync-manifests.yml .github/workflows/post-release-verify.yml +.github/workflows/install-script.yml # Tests install.sh against live releases +install.sh # One-command Linux installer (served from main) packaging/render.sh # Template renderer for manifests packaging/completions.sh # Generates shell completion scripts packaging/homebrew/dci.rb.tmpl diff --git a/README.md b/README.md index 32ce1f3..4a57078 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,10 @@ ## Installation ```bash -# macOS (Homebrew) +# Linux (glibc 2.34+: Ubuntu 22.04+, Debian 12+, RHEL 9+, Fedora, Cloud Shell, WSL) +curl -fsSL https://raw.githubusercontent.com/doitintl/dci-cli/main/install.sh | sh + +# macOS (Homebrew; also works with Homebrew on Linux) brew install doitintl/dci-cli/dci # Windows (WinGet) @@ -24,6 +27,19 @@ sudo dpkg -i dci_*_linux_amd64.deb sudo rpm -i dci_*_linux_amd64.rpm ``` +The Linux install command downloads the release for your machine, verifies its checksum, and installs `dci` to `~/.local/bin` with tab completion for bash, zsh, and fish. It needs no `sudo`, and it is the right choice for Google Cloud Shell, which keeps only your home directory when the session restarts. Options go after `sh -s --`: + +```bash +# A specific version +curl -fsSL https://raw.githubusercontent.com/doitintl/dci-cli/main/install.sh | sh -s -- --version 2.8.0 + +# A different directory +curl -fsSL https://raw.githubusercontent.com/doitintl/dci-cli/main/install.sh | sh -s -- --dir ~/bin + +# The .deb/.rpm package, system-wide (uses sudo) +curl -fsSL https://raw.githubusercontent.com/doitintl/dci-cli/main/install.sh | sh -s -- --system +``` + Prebuilt binaries for all platforms are available on the [Releases](https://github.com/doitintl/dci-cli/releases/latest) page. ## Getting Started @@ -147,9 +163,10 @@ DCI_NO_RESOLVE=1 dci ... # turn name resolution off entirely (for scripts ## Shell Completion -Tab completion for commands and flags is installed automatically by Homebrew -and the `.deb`/`.rpm` packages (bash, zsh, and fish). The release archives also -ship the scripts under `completions/`. +Tab completion for commands and flags is installed automatically by Homebrew, +the `.deb`/`.rpm` packages, and the Linux install command (bash, zsh, and fish; +for zsh, the install command prints the one `fpath` line to add to `~/.zshrc`). +The release archives also ship the scripts under `completions/`. On Windows (WinGet or Scoop), add this line to your PowerShell profile: @@ -333,7 +350,7 @@ Run `dci update` (alias: `dci upgrade`) to update in place on any OS. It knows h - **Homebrew / Scoop / WinGet** installs run the package manager's own upgrade command for you. - **`.deb`/`.rpm`** installs download the new package, verify its checksum, and run the install step with `sudo` (you'll be asked for your password). -- **Standalone binaries** (tarball, `~/bin`, CI) are replaced directly after validating the release checksum. +- **Standalone binaries** (the Linux install command, tarball, `~/bin`, CI) are replaced directly after validating the release checksum. Useful flags: `--check` only reports whether an update is available, `--yes` skips the confirmation, and `--version vX.Y.Z` pins or rolls back a standalone install to a specific release. In agent mode the command never installs without `--yes` and reports a JSON result. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ed54a3e --- /dev/null +++ b/install.sh @@ -0,0 +1,333 @@ +#!/bin/sh +# +# install.sh -- Install the DoiT Cloud Intelligence CLI (dci) on Linux. +# +# curl -fsSL https://raw.githubusercontent.com/doitintl/dci-cli/main/install.sh | sh +# +# Downloads the release archive for this machine from GitHub Releases, +# verifies it against the release's checksums.txt, and installs the binary to +# ~/.local/bin (no root needed). That location survives Cloud Shell restarts, +# where only $HOME persists, and `dci update` upgrades it in place. +# +# Options (pass after `sh -s --`, e.g. `... | sh -s -- --version 2.8.0`): +# --version install a specific version instead of the latest (env DCI_VERSION) +# --dir install directory, default ~/.local/bin (env DCI_INSTALL_DIR) +# --system install the .deb/.rpm package system-wide with sudo instead +# --help show this help +# +# Everything lives in functions and main runs on the last line, so a download +# cut short can never execute half a script. + +set -eu + +REPO="doitintl/dci-cli" +RELEASES="https://github.com/$REPO/releases" + +say() { printf '%s\n' "$*" >&2; } +fail() { + say "error: $*" + exit 1 +} + +# usage is spelled out rather than read back from "$0": when piped into sh +# there is no script file to read. +usage() { + cat >&2 <] [--dir ] [--system] + + --version install a specific version instead of the latest (env DCI_VERSION) + --dir install directory, default ~/.local/bin (env DCI_INSTALL_DIR) + --system install the .deb/.rpm package system-wide with sudo instead + --help show this help + +Piped from curl, pass options after "sh -s --": + curl -fsSL https://raw.githubusercontent.com/$REPO/main/install.sh | sh -s -- --version 2.8.0 +EOF +} + +has() { command -v "$1" >/dev/null 2>&1; } + +# download URL FILE -- curl or wget, failing on HTTP errors. +download() { + if has curl; then + curl -fsSL --retry 3 --proto '=https' --tlsv1.2 -o "$2" "$1" + elif has wget; then + wget -q --https-only -O "$2" "$1" + else + fail "curl or wget is required" + fi +} + +sha256_of() { + if has sha256sum; then + sha256sum "$1" | cut -d ' ' -f 1 + elif has shasum; then + shasum -a 256 "$1" | cut -d ' ' -f 1 + elif has openssl; then + openssl dgst -sha256 -r "$1" | cut -d ' ' -f 1 + else + fail "sha256sum, shasum, or openssl is required to verify the download" + fi +} + +detect_arch() { + case "$(uname -m)" in + x86_64 | amd64) echo amd64 ;; + aarch64 | arm64) echo arm64 ;; + *) fail "unsupported CPU architecture '$(uname -m)' -- dci ships for x86_64 and arm64; see $RELEASES" ;; + esac +} + +check_os() { + case "$(uname -s)" in + Linux) ;; + Darwin) + say "On macOS, install dci with Homebrew:" + say " brew install doitintl/dci-cli/dci" + exit 1 + ;; + *) fail "this installer supports Linux only -- for other systems see $RELEASES/latest" ;; + esac +} + +# GLIBC_HINT names the glibc floor of the release builds (their newest +# GLIBC_x.y symbol version) for error messages only; the authoritative check +# is running the downloaded binary before it replaces anything. +GLIBC_HINT="glibc 2.34 or newer (e.g. Ubuntu 22.04+, Debian 12+, RHEL 9+, any current Fedora)" + +glibc_version() { + getconf GNU_LIBC_VERSION 2>/dev/null | sed 's/^glibc //' +} + +# check_libc ARCH -- the binary links against glibc; without it (Alpine and +# other musl systems) it would fail with a baffling "not found" at first run. +# Glibc is detected by its presence, not by the absence of musl: Debian's +# musl-tools package puts a musl loader on perfectly good glibc hosts. +check_libc() { + [ -n "$(glibc_version)" ] && return 0 + case "$1" in + amd64) loader=/lib64/ld-linux-x86-64.so.2 ;; + arm64) loader=/lib/ld-linux-aarch64.so.1 ;; + esac + [ -e "$loader" ] && return 0 + fail "dci needs glibc, which this system doesn't have (musl-based systems such as Alpine aren't supported) -- use a glibc-based image such as Debian, Ubuntu, or Fedora" +} + +# cannot_run WHAT OUTCOME -- explain a binary that won't start: almost always +# a glibc older than the build needs. +cannot_run() { + have=$(glibc_version) + fail "$1 doesn't run on this system, so $2. dci needs $GLIBC_HINT${have:+; this system has glibc $have}." +} + +# fetch_checksums VERSION FILE -- the release's checksums.txt. With no version +# it comes from releases/latest, which also tells us the latest version +# without the rate-limited GitHub API (shared Cloud Shell and CI IPs hit it). +fetch_checksums() { + if [ -n "$1" ]; then + download "$RELEASES/download/v$1/checksums.txt" "$2" || + fail "version $1 not found -- see $RELEASES" + else + download "$RELEASES/latest/download/checksums.txt" "$2" || + fail "could not reach GitHub Releases -- check your network or proxy settings" + fi +} + +# asset_line CHECKSUMS PATTERN -- the " " line for one asset. +asset_line() { + grep -E " $2\$" "$1" | head -n 1 +} + +# verify FILE EXPECTED_SHA256 +verify() { + actual=$(sha256_of "$1") + [ "$actual" = "$2" ] || fail "checksum mismatch for $(basename "$1") (expected $2, got $actual) -- not installing" +} + +path_contains() { + case ":$PATH:" in + *":$1:"*) return 0 ;; + *) return 1 ;; + esac +} + +install_completions() { + src="$1/completions" + [ -d "$src" ] || return 0 + data="${XDG_DATA_HOME:-$HOME/.local/share}" + config="${XDG_CONFIG_HOME:-$HOME/.config}" + # bash-completion 2.x and fish load these user directories on their own. + mkdir -p "$data/bash-completion/completions" "$config/fish/completions" "$data/zsh/site-functions" + cp "$src/dci.bash" "$data/bash-completion/completions/dci" + cp "$src/dci.fish" "$config/fish/completions/dci.fish" + cp "$src/dci.zsh" "$data/zsh/site-functions/_dci" + zsh_completions="$data/zsh/site-functions" +} + +install_user() { + version="$1" + dir="$2" + arch=$(detect_arch) + check_libc "$arch" + tmp="$3" + + fetch_checksums "$version" "$tmp/checksums.txt" + line=$(asset_line "$tmp/checksums.txt" "dci_[0-9][^ ]*_linux_${arch}\\.tar\\.gz") + [ -n "$line" ] || fail "no linux/$arch archive in this release -- see $RELEASES" + expected=${line%% *} + archive=${line##* } + + say "Downloading $archive..." + download "$RELEASES/download/v$(echo "$archive" | sed -E 's/^dci_(.*)_linux_.*$/\1/')/$archive" "$tmp/$archive" + verify "$tmp/$archive" "$expected" + + mkdir -p "$tmp/x" + tar -xzf "$tmp/$archive" -C "$tmp/x" + [ -f "$tmp/x/dci" ] || fail "archive $archive has no dci binary" + + mkdir -p "$dir" || fail "cannot create $dir" + # Stage next to the target, prove it runs, then rename: atomic, safe while + # an older dci is running, and a binary that can't start never replaces a + # working one. Staging in $dir (not $tmp) also sidesteps a noexec /tmp. + staged="$dir/.dci.new.$$" + cp "$tmp/x/dci" "$staged" + chmod 0755 "$staged" + # --version prints " version X.Y.Z"; keep only the number, since + # argv0 here is the staging name. + if ! installed=$("$staged" --version 2>/dev/null); then + rm -f "$staged" + cannot_run "the downloaded dci" "nothing was installed" + fi + installed=${installed##* } + mv -f "$staged" "$dir/dci" + + zsh_completions="" + install_completions "$tmp/x" + + say "" + say "Installed dci $installed to $dir/dci" + + other=$(command -v dci 2>/dev/null || true) + if [ -n "$other" ] && [ "$other" != "$dir/dci" ]; then + say "" + say "Note: another dci at $other comes first on your PATH. Remove it, or put $dir earlier in PATH." + fi + if ! path_contains "$dir"; then + say "" + say "$dir is not on your PATH yet. Add it with:" + case "${SHELL:-}" in + */zsh) say " echo 'export PATH=\"$dir:\$PATH\"' >> ~/.zshrc && export PATH=\"$dir:\$PATH\"" ;; + */fish) say " fish_add_path $dir" ;; + *) say " echo 'export PATH=\"$dir:\$PATH\"' >> ~/.bashrc && export PATH=\"$dir:\$PATH\"" ;; + esac + fi + case "${SHELL:-}" in + */zsh) + if [ -n "$zsh_completions" ]; then + say "" + say "For zsh tab completion, add before compinit in ~/.zshrc:" + say " fpath=($zsh_completions \$fpath)" + fi + ;; + esac +} + +install_system() { + version="$1" + tmp="$2" + arch=$(detect_arch) + check_libc "$arch" + if has dpkg; then + format=deb + elif has rpm; then + format=rpm + else + fail "--system needs dpkg or rpm; run without --system to install to ~/.local/bin" + fi + if [ "${CLOUD_SHELL:-}" = "true" ]; then + say "Warning: Cloud Shell keeps only your home directory -- a system-wide install disappears when the VM restarts." + fi + sudo="" + if [ "$(id -u)" -ne 0 ]; then + has sudo || fail "--system needs root or sudo" + sudo="sudo" + fi + + fetch_checksums "$version" "$tmp/checksums.txt" + line=$(asset_line "$tmp/checksums.txt" "dci_[0-9][^ ]*_linux_${arch}\\.${format}") + [ -n "$line" ] || fail "no linux/$arch .$format package in this release -- see $RELEASES" + expected=${line%% *} + package=${line##* } + + say "Downloading $package..." + download "$RELEASES/download/v$(echo "$package" | sed -E 's/^dci_(.*)_linux_.*$/\1/')/$package" "$tmp/$package" + verify "$tmp/$package" "$expected" + + if [ "$format" = deb ]; then + $sudo dpkg -i "$tmp/$package" + else + # --oldpackage lets a pinned --version roll back, as dpkg -i does. + $sudo rpm -U --replacepkgs --oldpackage "$tmp/$package" + fi + installed=$(/usr/bin/dci --version 2>/dev/null) || + cannot_run "the installed /usr/bin/dci" "remove it with your package manager" + say "" + say "Installed dci ${installed##* } system-wide." +} + +main() { + version="${DCI_VERSION:-}" + dir="${DCI_INSTALL_DIR:-$HOME/.local/bin}" + system="" + while [ $# -gt 0 ]; do + case "$1" in + --version) + [ $# -ge 2 ] || fail "--version needs a value" + version="$2" + shift 2 + ;; + --version=*) + version="${1#*=}" + shift + ;; + --dir) + [ $# -ge 2 ] || fail "--dir needs a value" + dir="$2" + shift 2 + ;; + --dir=*) + dir="${1#*=}" + shift + ;; + --system) + system=1 + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) fail "unknown option '$1' (see --help)" ;; + esac + done + version="${version#v}" + + check_os + has tar || fail "tar is required" + + tmp=$(mktemp -d 2>/dev/null || mktemp -d -t dci-install) + trap 'rm -rf "$tmp"' EXIT + trap 'exit 130' INT TERM + + if [ -n "$system" ]; then + install_system "$version" "$tmp" + else + install_user "$version" "$dir" "$tmp" + fi + say "" + say "Get started: dci login" +} + +main "$@"