From f8a8062bfd25333527abf484a7b6eb933fbb5e2c Mon Sep 17 00:00:00 2001 From: RogerCecilpot Date: Sun, 25 Oct 2026 12:29:58 +0000 Subject: [PATCH] feat: Homebrew tap and verified install script goreleaser now builds a Homebrew cask (casks replace the deprecated brews formulae) and pushes it to devlyft/homebrew-tap when HOMEBREW_TAP_GITHUB_TOKEN is set; otherwise the upload is skipped. The cask installs completions via `devlyft completion` and clears the macOS quarantine attribute. install.sh installs on Linux and macOS: it verifies the archive's sha256 against the release checksum file and, when cosign is installed, the keyless signature on that file, failing closed on any mismatch. It never uses sudo. CI runs shellcheck and installs the snapshot build with it, including a tampered-archive case. Refs devlyft/devlyft#258 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/checks.yml | 21 +++++ .github/workflows/release.yml | 3 + .goreleaser.yaml | 35 ++++++++ README.md | 24 +++++ docs/releasing.md | 28 +++++- install.sh | 165 ++++++++++++++++++++++++++++++++++ 6 files changed, 273 insertions(+), 3 deletions(-) create mode 100755 install.sh 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 "$@"