diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 909638e..82e929f 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -84,3 +84,24 @@ jobs: distribution: goreleaser version: "~> v2" args: release --snapshot --clean --skip=sign,sbom + - name: shellcheck install.sh + run: shellcheck --shell=sh install.sh + - name: install.sh installs the snapshot + run: | + version=$(jq -r .version dist/metadata.json) + DEVLYFT_DOWNLOAD_BASE="file://$PWD/dist" DEVLYFT_VERSION="$version" \ + DEVLYFT_INSTALL_DIR="$RUNNER_TEMP/bin" sh install.sh + "$RUNNER_TEMP/bin/devlyft" version | grep -F "devlyft $version" + - name: install.sh rejects a tampered archive + run: | + version=$(jq -r .version dist/metadata.json) + bad="$RUNNER_TEMP/tampered" + mkdir -p "$bad" + cp dist/devlyft_"$version"_* "$bad"/ + cp "dist/devlyft_${version}_linux_arm64.tar.gz" "$bad/devlyft_${version}_linux_amd64.tar.gz" + if DEVLYFT_DOWNLOAD_BASE="file://$bad" DEVLYFT_VERSION="$version" \ + DEVLYFT_INSTALL_DIR="$RUNNER_TEMP/bad-bin" sh install.sh; then + echo "install.sh accepted a tampered archive" >&2 + exit 1 + fi + test ! -e "$RUNNER_TEMP/bad-bin/devlyft" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbe6f11..0b29db4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,6 +39,9 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Write access to devlyft/homebrew-tap only. When unset, the cask + # is built but not pushed. + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: | diff --git a/.goreleaser.yaml b/.goreleaser.yaml index f000978..a5b7bce 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -62,6 +62,41 @@ signs: - "--yes" output: true +# Homebrew: `brew install devlyft/tap/devlyft`. Casks replace the deprecated +# `brews` formulae. The cask is pushed to devlyft/homebrew-tap with a token +# scoped to that repository only; without the token (for example before the +# tap exists) the upload is skipped instead of failing the release. +homebrew_casks: + - name: devlyft + ids: [devlyft] + binaries: [devlyft] + description: Deploy and operate apps on DevLyft + homepage: https://github.com/devlyft/cli + license: Apache-2.0 + repository: + owner: devlyft + name: homebrew-tap + token: '{{ envOrDefault "HOMEBREW_TAP_GITHUB_TOKEN" "" }}' + directory: Casks + commit_author: + name: devlyft-release-bot + email: noreply@devlyft.io + commit_msg_template: "Brew cask update for {{ .ProjectName }} version {{ .Tag }}" + skip_upload: '{{ if not (envOrDefault "HOMEBREW_TAP_GITHUB_TOKEN" "") }}true{{ end }}' + # Runs `devlyft completion ` at install time. + generate_completions_from_executable: + executable: devlyft + args: [completion] + shells: [bash, zsh, fish] + hooks: + post: + # The binaries are not notarized (see docs/releasing.md); without + # this, Gatekeeper refuses to run a cask-installed binary. + install_steps: | + on_macos do + run "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "{{ .StagedPath }}/devlyft"] + end + # release-please writes the changelog and the release notes. changelog: disable: true diff --git a/README.md b/README.md index 6b45aa7..4effec3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,30 @@ # cli The official command-line interface for DevLyft +## Install + +**Homebrew** (macOS and Linux): + +```sh +brew install devlyft/tap/devlyft +``` + +**Install script** (macOS and Linux). It verifies the download's checksum and, if [cosign](https://docs.sigstore.dev/cosign/system_config/installation/) is installed, the release signature: + +```sh +curl -fsSL https://raw.githubusercontent.com/devlyft/cli/main/install.sh | sh +``` + +Set `DEVLYFT_VERSION` to pin a version, `DEVLYFT_INSTALL_DIR` to choose where the binary goes (default `/usr/local/bin` if writable, otherwise `~/.local/bin`), and `DEVLYFT_REQUIRE_SIGNATURE=1` to refuse to install without a verified signature. + +**Go**: + +```sh +go install github.com/devlyft/cli/cmd/devlyft@latest +``` + +Or download an archive from [Releases](https://github.com/devlyft/cli/releases); see [docs/releasing.md](docs/releasing.md#verifying-a-download) to verify it. + ## Development Requires Go 1.26 or later. diff --git a/docs/releasing.md b/docs/releasing.md index eb0283a..6ba048a 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -13,9 +13,30 @@ Releases are automated. Nobody tags by hand. - write `devlyft__checksums.txt` covering every archive and SBOM; - sign the checksum file with [cosign](https://docs.sigstore.dev/) keyless signing, using the workflow's GitHub OIDC identity (no stored keys), producing `.sig`, `.pem` and `.sigstore.json`; - attach everything to the release. -5. The workflow then records a GitHub build-provenance attestation for the archives and the checksum file. +5. goreleaser also generates a Homebrew cask and pushes it to [devlyft/homebrew-tap](https://github.com/devlyft/homebrew-tap) (`Casks/devlyft.rb`), so `brew install devlyft/tap/devlyft` picks up the release. The push uses the `HOMEBREW_TAP_GITHUB_TOKEN` secret; when that secret is not set, the cask is built but not pushed and the release still succeeds. The cask installs bash, zsh and fish completions by running `devlyft completion `, and on macOS clears the quarantine attribute because the binary is not notarized. +6. The workflow then records a GitHub build-provenance attestation for the archives and the checksum file. -Every pull request also runs `checks / release-snapshot`, which builds all targets with the same config but skips signing, SBOMs and publishing. Run the same thing locally with `make snapshot`; the output lands in `dist/`. +Every pull request also runs `checks / release-snapshot`, which builds all targets with the same config but skips signing, SBOMs and publishing, then runs `install.sh` against the snapshot (and checks it rejects a tampered archive). Run the same thing locally with `make snapshot`; the output lands in `dist/`. + +### Setting up the Homebrew tap + +One-off, before the first release that should reach Homebrew: + +1. Create the public repository `devlyft/homebrew-tap` with a README and a `Casks/` directory. Homebrew maps `devlyft/tap` to `github.com/devlyft/homebrew-tap`. +2. Create a credential that can write only to that repository: a fine-grained personal access token owned by a bot account (or a GitHub App installation token) with **Contents: read and write** on `devlyft/homebrew-tap` and nothing else. +3. Store it as the `HOMEBREW_TAP_GITHUB_TOKEN` secret in `devlyft/cli`, ideally in a protected environment used only by the release workflow. + +## The install script + +`install.sh` (served from `main` via raw.githubusercontent.com) installs on Linux and macOS: + +1. Works out the OS and architecture, and the version: `DEVLYFT_VERSION`, or the latest release. +2. Downloads the archive and `devlyft__checksums.txt`. +3. If cosign is installed, downloads `devlyft__checksums.txt.sigstore.json` and verifies it with the same identity and issuer as below; any failure, including a missing bundle, aborts. Without cosign it warns and continues, unless `DEVLYFT_REQUIRE_SIGNATURE=1`, in which case it aborts. +4. Checks the archive's sha256 against the checksum file and aborts on a mismatch. +5. Installs `devlyft` into `DEVLYFT_INSTALL_DIR`, `/usr/local/bin` if writable, or `~/.local/bin`. It never calls sudo. + +`DEVLYFT_DOWNLOAD_BASE` points it at another directory of release assets (a mirror, or `file://$PWD/dist` for testing) and requires `DEVLYFT_VERSION`. ## Verifying a download @@ -54,4 +75,5 @@ To cut 1.0, merge a commit containing `Release-As: 1.0.0` in its body; release-p ## Known gaps - macOS binaries are not notarized and Windows binaries are not code-signed, so browser downloads trigger Gatekeeper and SmartScreen warnings. Installing through Homebrew or the install script avoids this. -- Homebrew, Scoop, deb and rpm channels are tracked separately (devlyft/devlyft#258, devlyft/devlyft#260). +- Scoop, deb and rpm channels are tracked separately (devlyft/devlyft#260). +- The install script supports Linux and macOS only; Windows users install with Scoop or the zip archive. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..a067626 --- /dev/null +++ b/install.sh @@ -0,0 +1,165 @@ +#!/bin/sh +# Install the DevLyft CLI on Linux or macOS. +# +# curl -fsSL https://raw.githubusercontent.com/devlyft/cli/main/install.sh | sh +# +# The script downloads a release archive, checks it against the release's +# sha256 checksum file and, when cosign is installed, verifies the keyless +# signature on that checksum file before installing anything. It never uses +# sudo. +# +# Environment variables: +# DEVLYFT_VERSION version to install, e.g. 0.3.1 or v0.3.1 +# (default: the latest release) +# DEVLYFT_INSTALL_DIR where to put the binary (default: /usr/local/bin +# if writable, otherwise ~/.local/bin) +# DEVLYFT_REQUIRE_SIGNATURE set to 1 to fail when the signature cannot be +# verified (for example cosign is missing) +# DEVLYFT_DOWNLOAD_BASE URL of the directory holding the release assets, +# for mirrors and testing (requires DEVLYFT_VERSION) + +set -eu + +REPO="devlyft/cli" +CERT_IDENTITY_REGEXP='^https://github\.com/devlyft/cli/\.github/workflows/release\.yml@refs/tags/v' +OIDC_ISSUER="https://token.actions.githubusercontent.com" + +say() { printf '%s\n' "$*" >&2; } +warn() { printf 'warning: %s\n' "$*" >&2; } +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +has() { command -v "$1" >/dev/null 2>&1; } + +download() { + # download URL DEST + if has curl; then + curl -fsSL --proto '=https,file' --tlsv1.2 -o "$2" "$1" + elif has wget; then + wget -q --https-only -O "$2" "$1" + else + fail "curl or wget is required" + fi +} + +latest_version() { + has curl || fail "set DEVLYFT_VERSION, or install curl to find the latest release" + url=$(curl -fsSLI --proto '=https' --tlsv1.2 -o /dev/null -w '%{url_effective}' "https://github.com/$REPO/releases/latest") || + fail "could not look up the latest release" + tag=${url##*/} + case $tag in + v[0-9]*) printf '%s\n' "${tag#v}" ;; + *) fail "could not find the latest release (got $url)" ;; + esac +} + +detect_os() { + case $(uname -s) in + Linux) echo linux ;; + Darwin) echo darwin ;; + MINGW* | MSYS* | CYGWIN*) fail "on Windows, install with Scoop or download the zip from https://github.com/$REPO/releases" ;; + *) fail "unsupported operating system: $(uname -s)" ;; + esac +} + +detect_arch() { + case $(uname -m) in + x86_64 | amd64) echo amd64 ;; + aarch64 | arm64) echo arm64 ;; + *) fail "unsupported architecture: $(uname -m)" ;; + esac +} + +sha256() { + if has sha256sum; then + sha256sum "$1" | cut -d ' ' -f 1 + elif has shasum; then + shasum -a 256 "$1" | cut -d ' ' -f 1 + else + fail "sha256sum or shasum is required to verify the download" + fi +} + +verify_signature() { + # verify_signature DIR CHECKSUMS BASE + if ! has cosign; then + if [ "${DEVLYFT_REQUIRE_SIGNATURE:-}" = 1 ]; then + fail "cosign is not installed and DEVLYFT_REQUIRE_SIGNATURE=1; install cosign (https://docs.sigstore.dev/cosign/system_config/installation/)" + fi + warn "cosign is not installed: checked the sha256 checksum but not the release signature" + return 0 + fi + if ! download "$3/$2.sigstore.json" "$1/$2.sigstore.json"; then + fail "could not download the signature bundle $2.sigstore.json" + fi + cosign verify-blob \ + --bundle "$1/$2.sigstore.json" \ + --certificate-identity-regexp "$CERT_IDENTITY_REGEXP" \ + --certificate-oidc-issuer "$OIDC_ISSUER" \ + "$1/$2" >/dev/null 2>&1 || + fail "signature verification failed for $2: do not use this download" + say "Verified the release signature." +} + +choose_install_dir() { + if [ -n "${DEVLYFT_INSTALL_DIR:-}" ]; then + echo "$DEVLYFT_INSTALL_DIR" + elif [ -d /usr/local/bin ] && [ -w /usr/local/bin ]; then + echo /usr/local/bin + else + echo "$HOME/.local/bin" + fi +} + +main() { + os=$(detect_os) + arch=$(detect_arch) + + version=${DEVLYFT_VERSION:-} + if [ -n "${DEVLYFT_DOWNLOAD_BASE:-}" ] && [ -z "$version" ]; then + fail "DEVLYFT_DOWNLOAD_BASE requires DEVLYFT_VERSION" + fi + if [ -z "$version" ]; then + version=$(latest_version) + fi + version=${version#v} + + base=${DEVLYFT_DOWNLOAD_BASE:-"https://github.com/$REPO/releases/download/v$version"} + archive="devlyft_${version}_${os}_${arch}.tar.gz" + checksums="devlyft_${version}_checksums.txt" + + tmp=$(mktemp -d 2>/dev/null || mktemp -d -t devlyft) + trap 'rm -rf "$tmp"' EXIT INT TERM + + say "Downloading devlyft $version for $os/$arch..." + download "$base/$archive" "$tmp/$archive" || fail "could not download $base/$archive" + download "$base/$checksums" "$tmp/$checksums" || fail "could not download $base/$checksums" + + verify_signature "$tmp" "$checksums" "$base" + + want=$(awk -v f="$archive" '$2 == f || $2 == "*" f { print $1 }' "$tmp/$checksums") + [ -n "$want" ] || fail "$archive is not listed in $checksums" + got=$(sha256 "$tmp/$archive") + [ "$got" = "$want" ] || fail "checksum mismatch for $archive: expected $want, got $got" + + tar -xzf "$tmp/$archive" -C "$tmp" devlyft || fail "could not extract $archive" + + dir=$(choose_install_dir) + mkdir -p "$dir" || fail "could not create $dir; set DEVLYFT_INSTALL_DIR to a writable directory" + [ -w "$dir" ] || fail "$dir is not writable; set DEVLYFT_INSTALL_DIR, or rerun with the permissions to write there" + # Install via a temporary name and rename, so a running devlyft is never + # left half-written. + cp "$tmp/devlyft" "$dir/.devlyft.tmp.$$" + chmod 0755 "$dir/.devlyft.tmp.$$" + mv -f "$dir/.devlyft.tmp.$$" "$dir/devlyft" + + say "Installed $("$dir/devlyft" version) to $dir/devlyft" + case ":$PATH:" in + *":$dir:"*) ;; + *) warn "$dir is not on your PATH; add it, for example: export PATH=\"$dir:\$PATH\"" ;; + esac +} + +main "$@"