Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
61 changes: 61 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -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'
<!doctype html>
<meta charset="utf-8">
<title>Lago CLI installer</title>
<p>Install the <a href="https://github.com/getlago/lago-cli">Lago CLI</a> without a Go toolchain:</p>
<pre>curl -fsSL https://getlago.github.io/lago-cli/install.sh | sh</pre>
<p>The script downloads the prebuilt release for your platform and verifies its checksum. See the <a href="https://github.com/getlago/lago-cli#install">README</a> for the other install channels and how to verify a release.</p>
HTML
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: site
- id: deployment
uses: actions/deploy-pages@v4
32 changes: 30 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
11 changes: 6 additions & 5 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
50 changes: 50 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,34 @@ 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:

```console
$ 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
Expand All @@ -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
Expand All @@ -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
Expand Down
26 changes: 15 additions & 11 deletions dist-channels/parked/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,32 +18,35 @@ 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. |

Scoop and Winget had no files of their own: they were `scoops:` and `winget:` blocks in
`.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".
Loading
Loading