diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60a6d3c..ed3b46a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,10 @@ jobs: - run: make coverage - run: ./scripts/check-ci-policy.sh - run: ./scripts/check-fixtures.sh + - name: Lint the shell installer + run: | + sh -n install.sh + shellcheck -s sh install.sh cross-platform: strategy: diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..a65f2e1 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,61 @@ +name: Pages + +# Publishes install.sh at https://getlago.github.io/lago-cli/install.sh, the endpoint +# behind `curl -fsSL https://getlago.github.io/lago-cli/install.sh | sh`. The script is +# linted and run against the latest release before it is deployed, so a broken script +# is never the one users download. +on: + push: + branches: [main] + paths: + - install.sh + - .github/workflows/pages.yml + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# One deployment at a time, and never cancel one in flight: a cancelled Pages deploy +# can leave the site serving a stale or partial artifact. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + deploy: + if: github.repository == 'getlago/lago-cli' + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + - name: Lint the installer + run: | + sh -n install.sh + shellcheck -s sh install.sh + - uses: sigstore/cosign-installer@v3 + - name: The installer installs the latest release before it is published + run: | + LAGO_INSTALL_DIR="$RUNNER_TEMP/bin" sh install.sh + "$RUNNER_TEMP/bin/lago" version --output json + - name: Assemble the site + run: | + mkdir -p site + cp install.sh site/install.sh + cat > site/index.html <<'HTML' + + + Lago CLI installer +

Install the Lago CLI without a Go toolchain:

+
curl -fsSL https://getlago.github.io/lago-cli/install.sh | sh
+

The script downloads the prebuilt release for your platform and verifies its checksum. See the README for the other install channels and how to verify a release.

+ HTML + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: site + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31c2848..0ada730 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,8 +37,8 @@ jobs: GITHUB_TOKEN: ${{ github.token }} HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} - # Two supported channels, two smoke tests. A channel with no post-release smoke test - # is an untested channel, so this job gates the release rather than reporting on it. + # Three supported channels, three smoke tests. A channel with no post-release smoke test + # is an untested channel, so these jobs gate the release rather than reporting on it. # # The tap publishes a formula (see brews in .goreleaser.yml), so the same install # line is smoke-tested on macOS and on Linuxbrew. @@ -100,3 +100,31 @@ jobs: run: | "$(go env GOPATH)/bin/lago" version --output json "$(go env GOPATH)/bin/lago" doctor --output json || test $? -eq 3 + + # The shell installer is fetched from the endpoint users are told to use, not from the + # checkout, so a Pages deployment that never happened fails here rather than on a + # user's machine. cosign is installed first so the signature branch of the script is + # the one exercised. On ubuntu /usr/local/bin needs sudo and on macOS it does not, so + # both install branches run. + smoke-install-script: + needs: release + if: always() && (needs.release.result == 'success' || github.event_name == 'workflow_dispatch') + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: sigstore/cosign-installer@v3 + - name: Install from the published installer + env: + LAGO_INSTALL_VERSION: ${{ github.event_name == 'push' && github.ref_name || format('v{0}', inputs.version) }} + run: curl -fsSL https://getlago.github.io/lago-cli/install.sh | sh + - name: Installed version is the released one + run: | + expected="${{ github.event_name == 'push' && github.ref_name || format('v{0}', inputs.version) }}" + test "v$(lago version --output json | jq -r .version)" = "$expected" + - name: Installed where the script says it installs + run: test -x /usr/local/bin/lago + - name: Doctor runs and exits 3 with no credentials + run: lago doctor --output json || test $? -eq 3 diff --git a/.goreleaser.yml b/.goreleaser.yml index 4dce019..d8497b3 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -7,8 +7,9 @@ before: - go run ./internal/gen -check - go test ./... -# The full os/arch matrix stays. Only the install *channels* were reduced to Homebrew -# and `go install`; re-adding a channel later is a publish-and-docs change, not a port. +# The full os/arch matrix stays. The install *channels* are Homebrew, `go install` and the +# shell installer (install.sh, which downloads these archives); re-adding another channel +# later is a publish-and-docs change, not a port. builds: - id: lago main: ./cmd/lago @@ -43,9 +44,9 @@ sboms: # Keyless signing over checksums.txt. The certificate is published alongside the # signature so a consumer can run `cosign verify-blob` unaided: a signature with no -# certificate cannot be verified. Nothing in the two supported channels verifies it -# automatically today, so this is material for auditors and for anyone installing an -# archive by hand. +# certificate cannot be verified. install.sh verifies it whenever cosign is on the +# user's PATH; Homebrew and `go install` never do, so for them this is material for +# auditors and for anyone installing an archive by hand. signs: - cmd: cosign artifacts: checksum diff --git a/CHANGELOG.md b/CHANGELOG.md index 97f788d..e43e630 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes are generated from conventional commits at release time. Thi ## Unreleased +- Shell installer: `curl -fsSL https://getlago.github.io/lago-cli/install.sh | sh` installs the prebuilt binary for macOS and Linux on amd64 and arm64 with no Go toolchain, verifying the release checksum and, when `cosign` is installed, the release signature. The script is served from GitHub Pages out of this repository and smoke-tested from that URL on every release. `lago upgrade` recognises a script install and prints that line. - Release workflow: Homebrew is put on PATH on the Linux smoke runner (the 1.0.0 run failed there with `brew: command not found` after publishing), the smoke test checks the installed version is the released one, and the smoke jobs can be re-run against an existing release with `workflow_dispatch`. ## 1.0.0 (2026-09-04) diff --git a/DECISIONS.md b/DECISIONS.md index 713dd99..78a9a6f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -99,6 +99,8 @@ change requiring a major version, which is exactly why it is being made now. ## 2026-09-01 — Two install channels for 1.0 +*Superseded in part on 2026-09-16: the shell installer is back, served from GitHub Pages. See "Shell installer, hosted on GitHub Pages" below.* + QA returned that the CLI should ship through Homebrew and `go install` only. Both are now documented, smoke-tested on every release, and the only channels that exist. The shell installer, the PowerShell installer, the GHCR image, Scoop and Winget are removed @@ -454,3 +456,51 @@ match the release workflow identity fails before anyone is told to install it. GoReleaser marks `brews` deprecated in favour of casks; it still works in the pinned v2.18.0 and the release is not blocked by the warning. If a later GoReleaser removes it, the fallback is to commit the formula to the tap directly. + +## 2026-09-16 — Shell installer, hosted on GitHub Pages + +The shell installer parked on 2026-09-01 is back, as `install.sh` in the repository root, +served at `https://getlago.github.io/lago-cli/install.sh`. The four re-enable criteria in +`dist-channels/parked/README.md` were met in order: a maintainer asked for a +`curl | sh` install that needs no Go toolchain; GitHub Pages for `getlago/lago-cli` is an +endpoint Lago controls through the `getlago` organization, and unlike `getlago.com` it +needs no website deployment to ship a file; the release workflow gained a +`smoke-install-script` job that installs from that URL on ubuntu and macOS with cosign +present and fails the release when it cannot; and the README documents the line. + +**Why GitHub Pages and not `getlago.com/install.sh`.** The parked script pointed at +`getlago.com`, which is owned by the website, not this repository, so publishing meant a +cross-team deploy for every change to a shell script. Pages deploys from this repository +on push to `main`, so the script users run is the one that is checked in, reviewed, and +tested by the same CI. If a `getlago.com` alias is wanted later it can redirect here; the +Pages URL stays canonical so the `lago upgrade` output and the smoke test have one URL. + +**What the script verifies.** The SHA-256 of the archive against `checksums.txt`, always. +The cosign signature of `checksums.txt` against the exact release-workflow identity +(`release.yml@refs/tags/v…`), whenever `cosign` is on the PATH, and it says plainly when it +is not. It refuses any `LAGO_INSTALL_REPOSITORY` outside `getlago/*`, pins TLS 1.2 and +https-only redirects, and runs entirely from a `main` function so a truncated download +executes nothing. `latest` resolves by following the `releases/latest` redirect instead of +the REST API, which is rate-limited per IP and fails in CI and shared offices; that +redirect never points at a prerelease, so `latest` is always stable. + +**The script is published before it is used, and tested before it is published.** The +Pages workflow lints the script and installs the latest release with it before deploying, +so a broken script never becomes the served one. The release smoke job fetches from the +Pages URL rather than the checkout, so a Pages deployment that silently never happened +fails the release, which is the exact failure mode that parked the channel. + +**`lago upgrade` still does not self-update.** The 2026-09-01 entry made self-update a +fifth condition for any script channel. That condition is withdrawn rather than met: the +installer is idempotent, so re-running it is the upgrade, and a CLI that downloads and +swaps its own binary is a second copy of the download-verify-replace logic to keep in +step with the first. `upgrade` now detects a script install (the script's default +`/usr/local/bin`, its `LAGO_INSTALL_DIR` override, or `~/.local/bin`) and prints the +`curl | sh` line; an unrecognised install prints all three commands. Homebrew is checked +first because Intel Homebrew's prefix is `/usr/local`, and the executable path is +symlink-resolved so a brew-linked `/usr/local/bin/lago` reads as its Cellar path. + +The `test/docs` guardrail changed shape with it: `install.sh` left the parked-pattern +list, the README must now document three channels, and a new test pins the one URL across +the README, the script, the Pages workflow, the release smoke job, and the upgrade +command, so the endpoint cannot drift in one place only. diff --git a/README.md b/README.md index 7a67a9a..4e2a558 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ https://github.com/user-attachments/assets/b66ed65c-348d-4b6c-9361-6bb029089aad ## Install -Two supported channels. +Three supported channels. None of them needs a Go toolchain except `go install`. Homebrew, on macOS and Linux. Installs the binary, the man page (`man lago`) and the bash, zsh and fish completions: @@ -16,7 +16,26 @@ Homebrew, on macOS and Linux. Installs the binary, the man page (`man lago`) and $ brew install getlago/tap/lago ``` -`go install`, anywhere Go runs (binary only; run `lago completion --help` for completions): +Shell installer, on macOS and Linux (binary only; run `lago completion --help` for completions). It downloads the prebuilt release archive for your platform, verifies its SHA-256 against the release's `checksums.txt`, verifies the cosign signature of that file when `cosign` is installed, and installs `lago` into `/usr/local/bin`, asking for `sudo` only if that directory is not writable: + +```console +$ curl -fsSL https://getlago.github.io/lago-cli/install.sh | sh +``` + +Set `LAGO_INSTALL_DIR` to install somewhere else, and `LAGO_INSTALL_VERSION` to pin a release: + +```console +$ curl -fsSL https://getlago.github.io/lago-cli/install.sh | LAGO_INSTALL_DIR=$HOME/.local/bin LAGO_INSTALL_VERSION=1.0.0 sh +``` + +The script is served from GitHub Pages out of this repository, so what you run is what is checked in as `install.sh`, and every release smoke-tests it from that URL. Read it before you pipe it to `sh`, if you prefer: + +```console +$ curl -fsSL https://getlago.github.io/lago-cli/install.sh -o install-lago.sh +$ less install-lago.sh && sh install-lago.sh +``` + +`go install`, anywhere Go runs (binary only): ```console $ go install github.com/getlago/lago-cli/cmd/lago@latest @@ -26,7 +45,7 @@ $ go install github.com/getlago/lago-cli/cmd/lago@latest Release archives are built for macOS, Linux, and Windows on amd64 and arm64, and CI compiles and smoke-tests that matrix on every pull request. On Windows, use `go install` or the zip archive from the [releases page](https://github.com/getlago/lago-cli/releases). -Neither channel self-updates. `lago upgrade` checks for a newer release and prints the command that matches how your binary was installed: +No channel self-updates. `lago upgrade` checks for a newer release and prints the command that matches how your binary was installed. For the shell installer that command is the same `curl … | sh` line, which is idempotent: ```console $ lago upgrade @@ -37,7 +56,7 @@ Lago CLI 1.1.0 is available (installed: 1.0.0). ### Verify a release -Every release is built by the `release.yml` workflow in this repository, checksummed, and signed with keyless [cosign](https://docs.sigstore.dev). Homebrew and `go install` do not verify the signature for you; in a sensitive environment, verify before installing from an archive: +Every release is built by the `release.yml` workflow in this repository, checksummed, and signed with keyless [cosign](https://docs.sigstore.dev). The shell installer verifies the checksum always and the signature whenever `cosign` is on your PATH. Homebrew and `go install` do not verify the signature for you; in a sensitive environment, verify before installing from an archive: ```console $ v=1.0.0 diff --git a/dist-channels/parked/README.md b/dist-channels/parked/README.md index 43ed5bf..cbcbe8e 100644 --- a/dist-channels/parked/README.md +++ b/dist-channels/parked/README.md @@ -4,9 +4,10 @@ Nothing in this directory is built, published, tested, or referenced by CI. Thes the install channels Lago decided not to support for the CLI's first release, kept here so re-enabling one is a docs-and-publish change rather than rewriting it from scratch. -The supported channels are exactly two, documented in the README: +The supported channels are exactly three, documented in the README: - `brew install getlago/tap/lago` +- `curl -fsSL https://getlago.github.io/lago-cli/install.sh | sh` - `go install github.com/getlago/lago-cli/cmd/lago@latest` This is a reduction in **channels**, not in platform support. The release still builds @@ -17,7 +18,6 @@ full matrix, and `go install` works anywhere Go runs, Windows included. | File | Channel | Why it is parked | | --- | --- | --- | -| `install.sh` | `curl -fsSL https://getlago.com/install.sh \| sh` | The endpoint was never published. A `curl \| sh` against a missing URL installs nothing and exits 0, so the channel was a silent failure waiting for its first user. | | `install.ps1` | Windows script install | Same missing-endpoint problem, on a platform with no smoke coverage. | | `Dockerfile.release` | `ghcr.io/getlago/lago-cli` | A CLI whose whole job is to hold your billing credentials and read your config file is a poor fit for a container. Nobody asked for it. | @@ -25,24 +25,28 @@ Scoop and Winget had no files of their own: they were `scoops:` and `winget:` bl `.goreleaser.yml` and token wiring in `.github/workflows/release.yml`. Recovering them means reading those blocks out of the commit that removed them. +The shell installer was parked here from 2026-09-01 to 2026-09-16 and is back at +`install.sh` in the repository root, served from GitHub Pages and smoke-tested from that +URL on every release. It was parked for a missing endpoint, not for a defect; see +DECISIONS.md, "Shell installer, hosted on GitHub Pages", for how the criteria below were +met. + ## Re-enable criteria A channel moves back only when all four hold, in this order: 1. Someone asked. A channel with no demonstrated demand is maintenance with no user. -2. The endpoint or repository is live and Lago controls it. For `install.sh` that means - `https://getlago.com/install.sh` returning HTTP 200 from a Lago-controlled domain; - `get.lago.com` is not Lago's and must never appear in an artifact. +2. The endpoint or repository is live and Lago controls it. For `install.ps1` that means + a URL under a Lago-controlled domain or the `getlago` GitHub organization returning + HTTP 200; `get.lago.com` is not Lago's and must never appear in an artifact. 3. A post-release smoke job installs from the real endpoint on every release and fails the release when it cannot. A channel with no smoke test is an untested channel. 4. The README documents it only after that job has passed once. Publish, verify, document, in that order. -Self-update is a fifth condition for any script channel. `lago upgrade` no longer -replaces the running binary: with only Homebrew and `go install` supported, there is no -install that the CLI itself owns, so `upgrade` prints the command for how the binary was -installed. Restoring a script channel means restoring the download, checksum-verify and -atomic-replace path that was removed with it, and the signature verification that path -depended on. +`lago upgrade` does not replace the running binary for any channel, the shell installer +included: it prints the command for how the binary was installed, and for a script +install that command is the installer itself, which is idempotent. A PowerShell channel +would follow the same rule rather than restoring the removed self-replace path. See DECISIONS.md, "Two install channels for 1.0". diff --git a/dist-channels/parked/install.sh b/dist-channels/parked/install.sh deleted file mode 100755 index 1ccc016..0000000 --- a/dist-channels/parked/install.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/bin/sh -set -eu - -# Only Lago-owned repositories may be installed from. Without this the override -# turned `curl … | sh` into an arbitrary-binary installer for anyone able to set -# one environment variable, for example inside a compromised CI job. -repo=${LAGO_INSTALL_REPOSITORY:-getlago/lago-cli} -case $repo in - getlago/*) ;; - *) echo "lago installer: refusing to install from $repo; only getlago/* is allowed" >&2; exit 1 ;; -esac -version=${LAGO_INSTALL_VERSION:-latest} -install_dir=${LAGO_INSTALL_DIR:-/usr/local/bin} - -case $(uname -s) in - Darwin) os=darwin ;; - Linux) os=linux ;; - *) echo "lago installer: unsupported operating system" >&2; exit 1 ;; -esac - -case $(uname -m) in - x86_64|amd64) arch=amd64 ;; - arm64|aarch64) arch=arm64 ;; - *) echo "lago installer: unsupported architecture" >&2; exit 1 ;; -esac - -if [ "$version" = latest ]; then - version=$(curl -fsSL --proto '=https' --tlsv1.2 "https://api.github.com/repos/$repo/releases/latest" | sed -n 's/.*"tag_name":[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/p' | head -1) -fi -[ -n "$version" ] || { echo "lago installer: could not resolve a release version" >&2; exit 1; } -version=${version#v} - -archive="lago_${version}_${os}_${arch}.tar.gz" -base="https://github.com/$repo/releases/download/v${version}" -tmp_dir=$(mktemp -d) -trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM - -curl -fsSL --proto '=https' --tlsv1.2 "$base/$archive" -o "$tmp_dir/$archive" -curl -fsSL --proto '=https' --tlsv1.2 "$base/checksums.txt" -o "$tmp_dir/checksums.txt" -expected=$(awk -v file="$archive" '$2 == file { print $1 }' "$tmp_dir/checksums.txt") -[ -n "$expected" ] || { echo "lago installer: release checksum is missing" >&2; exit 1; } - -if command -v sha256sum >/dev/null 2>&1; then - actual=$(sha256sum "$tmp_dir/$archive" | awk '{print $1}') -else - actual=$(shasum -a 256 "$tmp_dir/$archive" | awk '{print $1}') -fi -[ "$actual" = "$expected" ] || { echo "lago installer: checksum verification failed" >&2; exit 1; } - -# The checksum file travels the same path as the artifact, so on its own it proves -# integrity, not authenticity: whoever can serve a bad archive can serve a matching -# checksums.txt. The release pipeline cosign-signs checksums.txt, so verify that -# signature when cosign is available and say plainly when it is not. -if command -v cosign >/dev/null 2>&1; then - if curl -fsSL --proto '=https' --tlsv1.2 "$base/checksums.txt.sig" -o "$tmp_dir/checksums.txt.sig" && - curl -fsSL --proto '=https' --tlsv1.2 "$base/checksums.txt.pem" -o "$tmp_dir/checksums.txt.pem"; then - cosign verify-blob \ - --certificate "$tmp_dir/checksums.txt.pem" \ - --signature "$tmp_dir/checksums.txt.sig" \ - --certificate-identity-regexp "^https://github.com/$repo/" \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - "$tmp_dir/checksums.txt" || - { echo "lago installer: signature verification failed" >&2; exit 1; } - else - echo "lago installer: no signature published for this release" >&2 - exit 1 - fi -else - echo "lago installer: cosign not found; verified checksum only, not signature" >&2 - echo "lago installer: install cosign for full supply-chain verification" >&2 -fi - -tar -xzf "$tmp_dir/$archive" -C "$tmp_dir" lago -mkdir -p "$install_dir" -if [ -w "$install_dir" ]; then - install -m 0755 "$tmp_dir/lago" "$install_dir/lago" -else - sudo install -m 0755 "$tmp_dir/lago" "$install_dir/lago" -fi -echo "Installed lago $version to $install_dir/lago" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..d4aeeb7 --- /dev/null +++ b/install.sh @@ -0,0 +1,166 @@ +#!/bin/sh +# Lago CLI installer. +# +# curl -fsSL https://getlago.github.io/lago-cli/install.sh | sh +# +# Downloads the prebuilt release archive for this machine from GitHub Releases, +# verifies its SHA-256 against the release's checksums.txt (and the cosign +# signature over that file when cosign is installed), and installs the `lago` +# binary. No Go toolchain is needed. +# +# Environment: +# LAGO_INSTALL_VERSION release to install, with or without the leading v +# (default: the latest stable release) +# LAGO_INSTALL_DIR directory to install into (default: /usr/local/bin) +# +# The script is published from the getlago/lago-cli repository by the +# .github/workflows/pages.yml workflow and smoke-tested on every release. +set -eu + +main() { + # Only Lago-owned repositories may be installed from. Without this the override + # turned `curl … | sh` into an arbitrary-binary installer for anyone able to set + # one environment variable, for example inside a compromised CI job. + repo=${LAGO_INSTALL_REPOSITORY:-getlago/lago-cli} + case $repo in + getlago/*) ;; + *) fail "refusing to install from $repo; only getlago/* is allowed" ;; + esac + version=${LAGO_INSTALL_VERSION:-latest} + install_dir=${LAGO_INSTALL_DIR:-/usr/local/bin} + + need curl + need tar + + case $(uname -s) in + Darwin) os=darwin ;; + Linux) os=linux ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) + fail "Windows is not supported by this installer. Use 'go install github.com/getlago/lago-cli/cmd/lago@latest' or the zip archive from https://github.com/$repo/releases" ;; + *) fail "unsupported operating system: $(uname -s)" ;; + esac + + case $(uname -m) in + x86_64|amd64) arch=amd64 ;; + arm64|aarch64) arch=arm64 ;; + *) fail "unsupported architecture: $(uname -m)" ;; + esac + + if [ "$version" = latest ]; then + version=$(resolve_latest "$repo") + fi + version=${version#v} + [ -n "$version" ] || fail "could not resolve a release version" + + archive="lago_${version}_${os}_${arch}.tar.gz" + base="https://github.com/$repo/releases/download/v${version}" + tmp_dir=$(mktemp -d) + trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM + + say "Downloading lago $version for $os/$arch" + download "$base/$archive" "$tmp_dir/$archive" || fail "no release archive at $base/$archive; check the version and https://github.com/$repo/releases" + download "$base/checksums.txt" "$tmp_dir/checksums.txt" || fail "release v$version publishes no checksums.txt" + + verify_checksum "$tmp_dir/checksums.txt" "$tmp_dir/$archive" "$archive" + verify_signature "$base" "$tmp_dir" "$repo" + + tar -xzf "$tmp_dir/$archive" -C "$tmp_dir" lago + install_binary "$tmp_dir/lago" "$install_dir" + + say "Installed lago $version to $install_dir/lago" + case ":$PATH:" in + *":$install_dir:"*) ;; + *) say "note: $install_dir is not on your PATH; add it to your shell profile to run 'lago' directly" ;; + esac + say "Run 'lago init' to connect it to your Lago organization. Shell completions: 'lago completion --help'." +} + +# resolve_latest follows the GitHub "latest release" redirect instead of calling the +# REST API: the redirect is not subject to the 60 requests/hour unauthenticated API +# limit that CI runners and shared offices hit. /releases/latest never points at a +# prerelease, so `latest` is always a stable release. +resolve_latest() { + location=$(curl -fsSLI --proto '=https' --tlsv1.2 -o /dev/null -w '%{url_effective}' "https://github.com/$1/releases/latest") || + fail "could not reach https://github.com/$1/releases/latest" + tag=${location##*/} + case $tag in + v[0-9]*) printf '%s\n' "$tag" ;; + *) fail "no published release found for $1" ;; + esac +} + +download() { + curl -fsSL --proto '=https' --tlsv1.2 --retry 3 "$1" -o "$2" +} + +verify_checksum() { + checksums=$1 file=$2 name=$3 + expected=$(awk -v file="$name" '$2 == file { print $1 }' "$checksums") + [ -n "$expected" ] || fail "checksums.txt has no entry for $name" + if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$file" | awk '{print $1}') + elif command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$file" | awk '{print $1}') + else + fail "neither sha256sum nor shasum is available to verify the download" + fi + [ "$actual" = "$expected" ] || fail "checksum verification failed for $name" +} + +# The checksum file travels the same path as the archive, so on its own it proves +# integrity, not authenticity: whoever can serve a bad archive can serve a matching +# checksums.txt. The release pipeline cosign-signs checksums.txt with the identity of +# the release workflow, so when cosign is installed the signature is verified against +# that exact identity, and the script says plainly when it cannot. +verify_signature() { + base=$1 dir=$2 repo=$3 + if ! command -v cosign >/dev/null 2>&1; then + say "cosign not found: verified the checksum only, not the release signature (install cosign to verify it)" + return 0 + fi + if ! download "$base/checksums.txt.sig" "$dir/checksums.txt.sig" || + ! download "$base/checksums.txt.pem" "$dir/checksums.txt.pem"; then + fail "release publishes no signature for checksums.txt" + fi + if ! output=$(cosign verify-blob \ + --certificate "$dir/checksums.txt.pem" \ + --signature "$dir/checksums.txt.sig" \ + --certificate-identity-regexp "^https://github.com/$repo/.github/workflows/release.yml@refs/tags/v" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + "$dir/checksums.txt" 2>&1); then + printf '%s\n' "$output" >&2 + fail "signature verification failed: checksums.txt was not signed by the $repo release workflow" + fi + say "Verified release signature" +} + +install_binary() { + binary=$1 dir=$2 + if [ -d "$dir" ] && [ -w "$dir" ]; then + install -m 0755 "$binary" "$dir/lago" + elif [ ! -e "$dir" ] && mkdir -p "$dir" 2>/dev/null; then + install -m 0755 "$binary" "$dir/lago" + elif command -v sudo >/dev/null 2>&1; then + say "Installing to $dir requires sudo" + sudo mkdir -p "$dir" + sudo install -m 0755 "$binary" "$dir/lago" + else + fail "$dir is not writable and sudo is not available; set LAGO_INSTALL_DIR to a directory you own, for example LAGO_INSTALL_DIR=\$HOME/.local/bin" + fi +} + +need() { + command -v "$1" >/dev/null 2>&1 || fail "$1 is required but not installed" +} + +say() { + printf 'lago installer: %s\n' "$1" >&2 +} + +fail() { + printf 'lago installer: %s\n' "$1" >&2 + exit 1 +} + +# Everything runs from main so a partially downloaded script does nothing. +main "$@" diff --git a/internal/cli/builtin.go b/internal/cli/builtin.go index 3337683..86aa99a 100644 --- a/internal/cli/builtin.go +++ b/internal/cli/builtin.go @@ -60,10 +60,11 @@ func newVersionCommand(app *App) *cobra.Command { // newUpgradeCommand prints the upgrade command for how this binary was installed. // -// It does not replace the running binary. Lago CLI ships through Homebrew and -// `go install`, and neither is self-updating: Homebrew owns its Cellar, and `go install` -// rebuilds from source. Replacing a Homebrew-managed binary in place would leave brew -// reporting a version it no longer has. See dist-channels/parked/README.md. +// It does not replace the running binary. Lago CLI ships through Homebrew, `go install` +// and the shell installer, and none is self-updating: Homebrew owns its Cellar, `go install` +// rebuilds from source, and re-running the installer is its upgrade. Replacing a +// Homebrew-managed binary in place would leave brew reporting a version it no longer +// has. See DECISIONS.md, "Shell installer, hosted on GitHub Pages". func newUpgradeCommand(app *App) *cobra.Command { var channel string cmd := &cobra.Command{ @@ -96,15 +97,16 @@ func newUpgradeCommand(app *App) *cobra.Command { } fmt.Fprintf(app.Out, "Lago CLI %s is available (installed: %s).\n", check.Latest, app.Version) switch method { - case cliupdate.Homebrew, cliupdate.GoInstall: + case cliupdate.Homebrew, cliupdate.GoInstall, cliupdate.Script: fmt.Fprintf(app.Out, "\n %s\n", command) default: - // Neither channel owns this binary, so both commands are printed rather - // than guessing one. Sending someone to `brew upgrade` for a binary - // Homebrew does not manage produces a brew error, not an upgrade. + // No channel owns this binary, so every command is printed rather than + // guessing one. Sending someone to `brew upgrade` for a binary Homebrew + // does not manage produces a brew error, not an upgrade. fmt.Fprintln(app.Out, "\nLago CLI does not self-update. Run whichever command matches how you installed it:") - fmt.Fprintln(app.Out, "\n brew upgrade getlago/tap/lago") - fmt.Fprintln(app.Out, " go install github.com/getlago/lago-cli/cmd/lago@latest") + fmt.Fprintf(app.Out, "\n %s\n", cliupdate.HomebrewCommand) + fmt.Fprintf(app.Out, " %s\n", cliupdate.GoInstallCommand) + fmt.Fprintf(app.Out, " %s\n", cliupdate.ScriptCommand) } return nil }, diff --git a/internal/cli/upgrade_test.go b/internal/cli/upgrade_test.go index e39432f..eab4be8 100644 --- a/internal/cli/upgrade_test.go +++ b/internal/cli/upgrade_test.go @@ -22,9 +22,10 @@ func releaseAPI(t *testing.T, latest string) *httptest.Server { return server } -// `lago upgrade` no longer replaces the running binary: with only Homebrew and -// `go install` supported, no install is one the CLI itself owns. It must print the -// command for the channel that installed it, and never claim to have upgraded anything. +// `lago upgrade` does not replace the running binary: Homebrew, `go install` and the +// shell installer are all upgraded by re-running their own command, so no install is one +// the CLI itself owns. It must print the command for the channel that installed it, and +// never claim to have upgraded anything. func TestUpgradePrintsACommandAndNeverSelfInstalls(t *testing.T) { setCleanEnvironment(t) t.Setenv("LAGO_CONFIG_FILE", filepath.Join(t.TempDir(), "missing.toml")) @@ -37,13 +38,17 @@ func TestUpgradePrintsACommandAndNeverSelfInstalls(t *testing.T) { if !strings.Contains(stdout, "9.9.9") { t.Errorf("upgrade did not report the available release: %q", stdout) } - // The test binary is not brew- or go-install-managed, so both commands are printed. - for _, want := range []string{"brew upgrade getlago/tap/lago", "go install github.com/getlago/lago-cli/cmd/lago@latest"} { + // The test binary is not brew-, go-install- or script-managed, so every command is printed. + for _, want := range []string{ + "brew upgrade getlago/tap/lago", + "go install github.com/getlago/lago-cli/cmd/lago@latest", + "curl -fsSL https://getlago.github.io/lago-cli/install.sh | sh", + } { if !strings.Contains(stdout, want) { t.Errorf("upgrade did not print %q:\n%s", want, stdout) } } - for _, forbidden := range []string{"Upgraded Lago CLI", "install.sh", "scoop", "winget", "Scoop", "Winget"} { + for _, forbidden := range []string{"Upgraded Lago CLI", "install.ps1", "scoop", "winget", "Scoop", "Winget"} { if strings.Contains(stdout, forbidden) { t.Errorf("upgrade output references the removed self-update path or a parked channel (%q):\n%s", forbidden, stdout) } diff --git a/internal/update/update.go b/internal/update/update.go index f32ec77..45401b5 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -101,12 +101,12 @@ func Latest(ctx context.Context, current, channel, userAgent, apiBase string) (C // down when only the update check failed. Every failure to fetch release metadata is a // network-class error (ExitNetwork), with a suggestion that names the likely cause. func releaseAPIError(statusCode int, status string) *apperr.Error { - suggestion := "Retry later, or upgrade with the command that matches your install: `brew upgrade getlago/tap/lago` or `go install github.com/getlago/lago-cli/cmd/lago@latest`." + suggestion := "Retry later, or upgrade with the command that matches your install: `brew upgrade getlago/tap/lago`, `go install github.com/getlago/lago-cli/cmd/lago@latest`, or re-run the installer from https://getlago.github.io/lago-cli/install.sh." switch statusCode { case http.StatusNotFound: - suggestion = "No published release was found. The repository may be private or have no release yet; upgrade with `brew upgrade getlago/tap/lago` or `go install github.com/getlago/lago-cli/cmd/lago@latest`." + suggestion = "No published release was found. The repository may be private or have no release yet; upgrade with `brew upgrade getlago/tap/lago`, `go install github.com/getlago/lago-cli/cmd/lago@latest`, or re-run the installer from https://getlago.github.io/lago-cli/install.sh." case http.StatusForbidden, http.StatusTooManyRequests: - suggestion = "GitHub refused or rate-limited the request, often because of a proxy or too many unauthenticated calls. Retry later, or upgrade with `brew upgrade getlago/tap/lago` or `go install github.com/getlago/lago-cli/cmd/lago@latest`." + suggestion = "GitHub refused or rate-limited the request, often because of a proxy or too many unauthenticated calls. Retry later, or upgrade with `brew upgrade getlago/tap/lago`, `go install github.com/getlago/lago-cli/cmd/lago@latest`, or re-run the installer from https://getlago.github.io/lago-cli/install.sh." } return &apperr.Error{ExitCode: apperr.ExitNetwork, Status: statusCode, Message: "GitHub release API returned " + status, Suggestion: suggestion} } @@ -125,17 +125,25 @@ type Method string const ( Homebrew Method = "homebrew" GoInstall Method = "go-install" + Script Method = "script" Unknown Method = "unknown" ) +// Commands are the upgrade commands per channel, in the order they are documented. +const ( + HomebrewCommand = "brew upgrade getlago/tap/lago" + GoInstallCommand = "go install github.com/getlago/lago-cli/cmd/lago@latest" + ScriptCommand = "curl -fsSL https://getlago.github.io/lago-cli/install.sh | sh" +) + // UpgradeCommand reports how the running binary was installed and the exact command // that upgrades it. // -// Lago CLI ships through two channels, Homebrew and `go install`, and neither is -// self-updating: Homebrew owns its Cellar and `go install` rebuilds from source. So -// `lago upgrade` prints a command instead of replacing the binary. The download, -// checksum-verify and atomic-replace path this replaced belonged to the parked script -// installer; see dist-channels/parked/README.md. +// Lago CLI ships through three channels, Homebrew, `go install` and the shell +// installer, and none is self-updating: Homebrew owns its Cellar, `go install` rebuilds +// from source, and the installer is idempotent, so re-running it is the upgrade. So +// `lago upgrade` prints a command instead of replacing the binary; the binary never +// downloads and swaps itself. See DECISIONS.md, "Shell installer, hosted on GitHub Pages". func UpgradeCommand() (Method, string, error) { executable, err := os.Executable() if err != nil { @@ -145,22 +153,53 @@ func UpgradeCommand() (Method, string, error) { executable = resolved } method := Detect(executable) + return method, CommandFor(method, filepath.Dir(executable)), nil +} + +// CommandFor is the upgrade command for a binary installed by method into directory. +// +// A script install outside the installer's default directory gets the same line with +// LAGO_INSTALL_DIR set, so re-running it replaces the binary that is actually on the +// PATH instead of leaving a second copy in /usr/local/bin. Unknown yields no command so +// the caller knows to print them all. +func CommandFor(method Method, directory string) string { switch method { case Homebrew: - return method, "brew upgrade getlago/tap/lago", nil + return HomebrewCommand case GoInstall: - return method, "go install github.com/getlago/lago-cli/cmd/lago@latest", nil + return GoInstallCommand + case Script: + if filepath.ToSlash(filepath.Clean(directory)) == defaultScriptInstallDir { + return ScriptCommand + } + return strings.Replace(ScriptCommand, "| sh", "| LAGO_INSTALL_DIR="+shellQuote(directory)+" sh", 1) default: - return method, "", nil + return "" } } +// shellQuote single-quotes a path when it contains anything a shell would interpret, +// so a directory with a space in it survives a paste into a terminal. +func shellQuote(value string) string { + if strings.IndexFunc(value, func(r rune) bool { + return !(r == '/' || r == '.' || r == '_' || r == '-' || r == '~' || + (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) + }) < 0 { + return value + } + return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" +} + // Detect classifies an executable path into the channel that installed it. // // Homebrew is identified by its Cellar or its prefix; `go install` by GOBIN, GOPATH/bin, -// or a path ending in go/bin. Anything else is Unknown, and Unknown prints both commands -// rather than guessing: telling someone to run `brew upgrade` on a binary Homebrew does -// not own produces a confusing Homebrew error instead of an upgrade. +// or a path ending in go/bin; the shell installer by its default /usr/local/bin, the +// LAGO_INSTALL_DIR override, or ~/.local/bin. Homebrew is checked first because on +// Intel macOS its prefix is /usr/local, and the executable path is symlink-resolved +// before it gets here, so a brew-linked /usr/local/bin/lago reads as its Cellar path. +// Anything else is Unknown, and Unknown prints every command rather than guessing: +// telling someone to run `brew upgrade` on a binary Homebrew does not own produces a +// confusing Homebrew error instead of an upgrade. func Detect(executable string) Method { path := filepath.ToSlash(executable) lower := strings.ToLower(path) @@ -179,9 +218,31 @@ func Detect(executable string) Method { if strings.HasSuffix(directory, "/go/bin") { return GoInstall } + for _, candidate := range scriptInstallDirs() { + if directory == filepath.ToSlash(filepath.Clean(candidate)) { + return Script + } + } return Unknown } +// scriptInstallDirs are the directories install.sh installs into: its default, the +// override it honours, and the directory its error message tells a user without sudo +// to pick. A binary copied there by hand from a release archive is upgraded the same +// way, by re-running the installer, so the heuristic is right for that case too. +const defaultScriptInstallDir = "/usr/local/bin" + +func scriptInstallDirs() []string { + dirs := []string{defaultScriptInstallDir} + if override := os.Getenv("LAGO_INSTALL_DIR"); override != "" { + dirs = append(dirs, override) + } + if home, err := os.UserHomeDir(); err == nil { + dirs = append(dirs, filepath.Join(home, ".local", "bin")) + } + return dirs +} + func goPathBin() string { if gopath := os.Getenv("GOPATH"); gopath != "" { // GOPATH may be a list; only its first element receives `go install` output. diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 6ec94b0..596af87 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -51,12 +51,17 @@ func TestDetectClassifiesTheInstallingChannel(t *testing.T) { {name: "default gopath bin", path: filepath.Join(mustHome(t), "go", "bin", "lago"), want: GoInstall}, {name: "explicit GOBIN", path: "/srv/tools/lago", env: map[string]string{"GOBIN": "/srv/tools"}, want: GoInstall}, {name: "explicit GOPATH", path: "/w/gopath/bin/lago", env: map[string]string{"GOPATH": "/w/gopath"}, want: GoInstall}, - {name: "manual install", path: "/usr/local/bin/lago", want: Unknown}, + {name: "script default dir", path: "/usr/local/bin/lago", want: Script}, + {name: "script LAGO_INSTALL_DIR", path: "/srv/lago/bin/lago", env: map[string]string{"LAGO_INSTALL_DIR": "/srv/lago/bin"}, want: Script}, + {name: "script user-local dir", path: filepath.Join(mustHome(t), ".local", "bin", "lago"), want: Script}, + {name: "intel homebrew link resolved to cellar", path: "/usr/local/Cellar/lago/1.0.0/bin/lago", want: Homebrew}, + {name: "unpacked archive elsewhere", path: "/opt/tools/lago", want: Unknown}, {name: "windows manual install", path: `C:\\Program Files\\lago\\lago.exe`, want: Unknown}, } { t.Run(testCase.name, func(t *testing.T) { t.Setenv("GOBIN", "") t.Setenv("GOPATH", "") + t.Setenv("LAGO_INSTALL_DIR", "") for name, value := range testCase.env { t.Setenv(name, value) } @@ -68,7 +73,7 @@ func TestDetectClassifiesTheInstallingChannel(t *testing.T) { } // Each recognised channel maps to exactly one command, and an unrecognised install -// yields no command so the caller knows to print both. +// yields no command so the caller knows to print them all. func TestUpgradeCommandNamesOneChannel(t *testing.T) { method, command, err := UpgradeCommand() if err != nil { @@ -83,6 +88,10 @@ func TestUpgradeCommandNamesOneChannel(t *testing.T) { if command != "go install github.com/getlago/lago-cli/cmd/lago@latest" { t.Errorf("go install command = %q", command) } + case Script: + if !strings.HasPrefix(command, "curl -fsSL https://getlago.github.io/lago-cli/install.sh | ") || !strings.HasSuffix(command, " sh") { + t.Errorf("script command = %q", command) + } case Unknown: if command != "" { t.Errorf("unknown install returned a command: %q", command) @@ -92,6 +101,29 @@ func TestUpgradeCommandNamesOneChannel(t *testing.T) { } } +// A script install outside /usr/local/bin must be told to re-run the installer into +// the directory it lives in; otherwise the upgrade lands in /usr/local/bin and the old +// binary, earlier on the PATH, keeps running. +func TestCommandForScriptInstallNamesItsDirectory(t *testing.T) { + for _, testCase := range []struct { + method Method + directory string + want string + }{ + {Homebrew, "/opt/homebrew/Cellar/lago/1.0.0/bin", "brew upgrade getlago/tap/lago"}, + {GoInstall, "/home/u/go/bin", "go install github.com/getlago/lago-cli/cmd/lago@latest"}, + {Script, "/usr/local/bin", "curl -fsSL https://getlago.github.io/lago-cli/install.sh | sh"}, + {Script, "/usr/local/bin/", "curl -fsSL https://getlago.github.io/lago-cli/install.sh | sh"}, + {Script, "/home/u/.local/bin", "curl -fsSL https://getlago.github.io/lago-cli/install.sh | LAGO_INSTALL_DIR=/home/u/.local/bin sh"}, + {Script, "/Users/u/my tools/bin", "curl -fsSL https://getlago.github.io/lago-cli/install.sh | LAGO_INSTALL_DIR='/Users/u/my tools/bin' sh"}, + {Unknown, "/opt/tools", ""}, + } { + if got := CommandFor(testCase.method, testCase.directory); got != testCase.want { + t.Errorf("CommandFor(%q, %q) = %q, want %q", testCase.method, testCase.directory, got, testCase.want) + } + } +} + func mustHome(t *testing.T) string { t.Helper() home, err := os.UserHomeDir() diff --git a/test/docs/channels_test.go b/test/docs/channels_test.go index f98e63c..b550e78 100644 --- a/test/docs/channels_test.go +++ b/test/docs/channels_test.go @@ -23,7 +23,6 @@ var parkedChannels = []struct { channel string pattern *regexp.Regexp }{ - {"shell installer", regexp.MustCompile(`install\.sh`)}, {"PowerShell installer", regexp.MustCompile(`install\.ps1`)}, {"Docker image", regexp.MustCompile(`ghcr\.io/getlago|docker\s+run.*lago-cli|dockers_v2|Dockerfile\.release`)}, {"Scoop", regexp.MustCompile(`(?i)scoop\s+(install|update|bucket)|scoops:|scoop-bucket`)}, @@ -65,7 +64,7 @@ func TestNoParkedChannelIsDocumentedOrPublished(t *testing.T) { } } -// The two supported channels must both be documented. A channel that is supported but +// The three supported channels must all be documented. A channel that is supported but // undocumented fails the same way a documented-but-parked one does: the user cannot // install the tool the way the project intends. func TestSupportedChannelsAreDocumented(t *testing.T) { @@ -77,6 +76,7 @@ func TestSupportedChannelsAreDocumented(t *testing.T) { for _, required := range []string{ "brew install getlago/tap/lago", "go install github.com/getlago/lago-cli/cmd/lago@latest", + "curl -fsSL https://getlago.github.io/lago-cli/install.sh | sh", } { if !strings.Contains(string(readme), required) { t.Errorf("README does not document the supported install command %q", required) @@ -89,13 +89,43 @@ func TestSupportedChannelsAreDocumented(t *testing.T) { func TestParkedFilesAreNotReferenced(t *testing.T) { root := repositoryRoot(t) parked := filepath.Join(root, "dist-channels", "parked") - for _, name := range []string{"install.sh", "install.ps1", "Dockerfile.release", "README.md"} { + for _, name := range []string{"install.ps1", "Dockerfile.release", "README.md"} { if _, err := os.Stat(filepath.Join(parked, name)); err != nil { t.Errorf("dist-channels/parked/%s is missing; parked channel code must stay recoverable: %v", name, err) } } - if _, err := os.Stat(filepath.Join(root, "scripts", "install.sh")); err == nil { - t.Error("scripts/install.sh is back in the build tree") + if _, err := os.Stat(filepath.Join(parked, "install.sh")); err == nil { + t.Error("dist-channels/parked/install.sh exists, but the shell installer is a supported channel living at install.sh") + } +} + +// The shell installer is served from GitHub Pages by .github/workflows/pages.yml and +// smoke-tested from that URL by the release workflow. The script, its deploy workflow +// and its smoke job must all exist, and the URL the README gives must be the one the +// deploy workflow publishes and the release workflow tests: three surfaces, one URL. +func TestShellInstallerIsPublishedAndSmokeTested(t *testing.T) { + root := repositoryRoot(t) + const endpoint = "https://getlago.github.io/lago-cli/install.sh" + if _, err := os.Stat(filepath.Join(root, "install.sh")); err != nil { + t.Fatalf("install.sh is missing from the repository root: %v", err) + } + for path, wants := range map[string][]string{ + "README.md": {"curl -fsSL " + endpoint + " | sh"}, + "install.sh": {"curl -fsSL " + endpoint + " | sh"}, + ".github/workflows/pages.yml": {"cp install.sh site/install.sh", "actions/deploy-pages"}, + ".github/workflows/release.yml": {"smoke-install-script:", "curl -fsSL " + endpoint + " | sh"}, + "internal/update/update.go": {"curl -fsSL " + endpoint + " | sh"}, + } { + // #nosec G304 -- paths are fixed repository files. + content, err := os.ReadFile(filepath.Join(root, path)) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + for _, want := range wants { + if !strings.Contains(string(content), want) { + t.Errorf("%s does not contain %q", path, want) + } + } } }