Skip to content
Open
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
21 changes: 21 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
35 changes: 35 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <shell>` 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
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
28 changes: 25 additions & 3 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,30 @@ Releases are automated. Nobody tags by hand.
- write `devlyft_<version>_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 <shell>`, 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_<version>_checksums.txt`.
3. If cosign is installed, downloads `devlyft_<version>_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

Expand Down Expand Up @@ -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.
165 changes: 165 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
Loading