diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03fd0c3..9a8fed0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,22 @@ jobs: - name: Vulnerability scan run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... + # The release pipeline only runs on a tag push, so a broken .goreleaser.yaml + # would otherwise surface at the worst moment. Same version as `task release:check`. + - name: GoReleaser config + run: go run github.com/goreleaser/goreleaser/v2@v2.12.7 check + + # Both installers are fetched raw from this branch by users, so a syntax + # error ships the moment it merges. + - name: Install scripts + run: | + shellcheck --shell=sh install/install.sh + pwsh -NoProfile -Command ' + $tokens = $null; $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile("install/install.ps1", [ref]$tokens, [ref]$errors) + if ($errors) { $errors | ForEach-Object { Write-Error $_.ToString() }; exit 1 } + ' + build: name: build runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee25174..da6504a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ on: permissions: contents: write + packages: write jobs: goreleaser: @@ -28,11 +29,25 @@ jobs: go-version: "1.25.x" cache-dependency-path: go.sum + # The arm64 image is built on an amd64 runner. + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser - version: latest + version: "~> v2" args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 5ff82d4..3ecf104 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ test-report.json # Build and release output /bin/ /dist/ +/completions/ # Dependency directories vendor/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index ccac2eb..a4e28db 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,3 +1,4 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json version: 2 project_name: kaiten @@ -5,6 +6,9 @@ project_name: kaiten before: hooks: - go mod tidy + # Completions ship in every archive and Linux package, so they are + # generated once here from the binary that is about to be released. + - sh -c 'mkdir -p completions && for shell in bash zsh fish; do go run ./cmd/kaiten completion "$shell" > "completions/kaiten.$shell"; done' builds: - id: kaiten @@ -25,10 +29,12 @@ builds: - -X main.commit={{.Commit}} - -X main.date={{.Date}} +# install/install.sh and install/install.ps1 download these by name, so the +# template is part of the install contract: kaiten__.tar.gz|zip. archives: - - formats: [tar.gz] - name_template: >- - {{ .ProjectName }}_{{ .Os }}_{{ .Arch }} + - id: archives + formats: [tar.gz] + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" format_overrides: - goos: windows formats: [zip] @@ -39,10 +45,91 @@ archives: - LICENSE - NOTICE - README.md + - completions/* checksum: name_template: "checksums.txt" +# .deb, .rpm and .apk, attached to the release next to the archives. Named +# like the archives, without the version, so that +# releases/latest/download/kaiten_linux_amd64.deb is a stable URL; the version +# lives in the package metadata, where dpkg, rpm and apk read it. +nfpms: + - id: packages + package_name: kaiten + file_name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + vendor: Kaiten + homepage: https://kaiten.sh + maintainer: Kaiten + description: Command-line interface for the Kaiten API. + license: Apache-2.0 + formats: + - deb + - rpm + - apk + bindir: /usr/bin + section: utils + contents: + - src: completions/kaiten.bash + dst: /usr/share/bash-completion/completions/kaiten + - src: completions/kaiten.zsh + dst: /usr/share/zsh/site-functions/_kaiten + - src: completions/kaiten.fish + dst: /usr/share/fish/vendor_completions.d/kaiten.fish + # The doc/license content types are rpm-only; deb and apk silently skip + # them, so each packager gets the license file in its own convention. + - src: LICENSE + dst: /usr/share/licenses/kaiten/LICENSE + type: license + packager: rpm + - src: NOTICE + dst: /usr/share/licenses/kaiten/NOTICE + type: license + packager: rpm + - src: LICENSE + dst: /usr/share/doc/kaiten/copyright + packager: deb + - src: NOTICE + dst: /usr/share/doc/kaiten/NOTICE + packager: deb + - src: LICENSE + dst: /usr/share/licenses/kaiten/LICENSE + packager: apk + - src: NOTICE + dst: /usr/share/licenses/kaiten/NOTICE + packager: apk + +# ghcr.io/kaitencloud/cli: and :latest, one multi-platform image. The +# workflow logs in to GHCR with the job's GITHUB_TOKEN. Built in the publish +# phase, so `--snapshot` produces per-platform local images and `--skip=docker` +# none at all. +dockers_v2: + - id: kaiten + ids: + - kaiten + images: + - ghcr.io/kaitencloud/cli + tags: + - "{{ .Version }}" + # A pre-release tag (v1.2.0-rc.1) does not move latest. + - "{{ if not .Prerelease }}latest{{ end }}" + platforms: + - linux/amd64 + - linux/arm64 + dockerfile: Dockerfile + extra_files: + - LICENSE + - NOTICE + labels: + org.opencontainers.image.title: "{{ .ProjectName }}" + org.opencontainers.image.description: Command-line interface for the Kaiten API + org.opencontainers.image.url: https://github.com/kaitencloud/cli + org.opencontainers.image.source: https://github.com/kaitencloud/cli + org.opencontainers.image.version: "{{ .Version }}" + org.opencontainers.image.revision: "{{ .FullCommit }}" + org.opencontainers.image.created: "{{ .Date }}" + org.opencontainers.image.licenses: Apache-2.0 + changelog: sort: asc groups: @@ -64,3 +151,22 @@ release: github: owner: kaitencloud name: cli + # A pre-release tag (v1.2.0-rc.1) is published as a GitHub pre-release, which + # also keeps install.sh's "latest" pointing at the last stable one. + prerelease: auto + footer: | + ## Install + + ```shell + # macOS and Linux + curl -fsSL https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.sh | sh -s -- {{ .Version }} + + # Windows + irm https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.ps1 | iex + + # Container + docker run --rm ghcr.io/kaitencloud/cli:{{ .Version }} version + ``` + + Every archive and package above is listed in `checksums.txt`. See the + [README](https://github.com/kaitencloud/cli#installation) for the other options. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d5a5815 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +# Built by GoReleaser from the release binaries; see dockers_v2 in +# .goreleaser.yaml. The build context holds one binary per platform under +# //, and the static distroless base carries CA certificates and a +# non-root user and nothing else. +FROM gcr.io/distroless/static-debian12:nonroot + +ARG TARGETPLATFORM +COPY $TARGETPLATFORM/kaiten /usr/local/bin/kaiten +COPY LICENSE NOTICE /usr/share/doc/kaiten/ + +ENTRYPOINT ["/usr/local/bin/kaiten"] diff --git a/README.md b/README.md index 2a42aef..ec64091 100644 --- a/README.md +++ b/README.md @@ -22,27 +22,97 @@ output and exit codes a script can branch on. ## Installation -**Pre-built binaries** for Linux, macOS and Windows (amd64 and arm64) are attached -to every release on the [releases page](https://github.com/kaitencloud/cli/releases), -with a `checksums.txt`. +Every method below installs the same binary from the +[GitHub release](https://github.com/kaitencloud/cli/releases). Check what you got with +`kaiten version`. -**With Go 1.25 or newer:** +### macOS + +```shell +curl -fsSL https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.sh | sh +``` + +The script verifies the archive against the release's `checksums.txt` and installs into +`/usr/local/bin` (`KAITEN_INSTALL_DIR` changes that). + +### Linux + +```shell +curl -fsSL https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.sh | sh +``` + +Or a package for your distribution (replace `amd64` with `arm64` as needed). Each installs +the binary and shell completions: + +```shell +# Debian, Ubuntu +curl -fsSLO https://github.com/kaitencloud/cli/releases/latest/download/kaiten_linux_amd64.deb +sudo dpkg -i kaiten_linux_amd64.deb + +# Fedora, RHEL, openSUSE +sudo rpm -i https://github.com/kaitencloud/cli/releases/latest/download/kaiten_linux_amd64.rpm + +# Alpine +curl -fsSLO https://github.com/kaitencloud/cli/releases/latest/download/kaiten_linux_amd64.apk +sudo apk add --allow-untrusted kaiten_linux_amd64.apk +``` + +### Windows + +```powershell +irm https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.ps1 | iex +``` + +The script verifies the archive against `checksums.txt`, installs `kaiten.exe` into +`%LOCALAPPDATA%\Programs\kaiten` (no administrator rights needed) and adds it to your +user `PATH`. + +### Container + +```shell +docker run --rm -e KAITEN_BASE_URL -e KAITEN_AUTH_TOKEN ghcr.io/kaitencloud/cli:latest instances list +``` + +`ghcr.io/kaitencloud/cli` is tagged `latest` and with each version, built for +`linux/amd64` and `linux/arm64`, and runs as a non-root user. Pass the configuration +through the environment: the image keeps no config file between runs. + +### Go + +With Go 1.25 or newer: ```shell go install github.com/kaitencloud/cli/cmd/kaiten@latest ``` -**From source**, with [Task](https://taskfile.dev) installed: +### Binaries + +Every release attaches `kaiten__.tar.gz` (`.zip` on Windows) for Linux, macOS and +Windows on amd64 and arm64, with a `checksums.txt` of SHA-256 sums. Unpack it and put +`kaiten` on your `PATH`. + +### A specific version ```shell -git clone https://github.com/kaitencloud/cli.git && cd cli -task build # bin/kaiten +curl -fsSL https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.sh | sh -s -- 1.2.3 +``` + +```powershell +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.ps1))) -Version 1.2.3 ``` -**Shell completion** is available for bash, zsh, fish and PowerShell: +`go install ...@v1.2.3` and the container tags pin a version directly. + +### Shell completion + +The Linux packages and the archives ship completions for bash, zsh and fish. Any other +install can generate them from the binary: ```shell kaiten completion zsh > "${fpath[1]}/_kaiten" +kaiten completion bash > /etc/bash_completion.d/kaiten +kaiten completion fish > ~/.config/fish/completions/kaiten.fish +kaiten completion powershell | Out-String | Invoke-Expression ``` ## Quick start @@ -210,8 +280,9 @@ task build # bin/kaiten task test # go test -race -shuffle=on -cover ./... task lint # golangci-lint, the version CI runs task fmt # gofumpt + gci -task vuln # govulncheck over reachable code -task release:check # validate .goreleaser.yaml without building +task vuln # govulncheck over reachable code +task release:check # validate .goreleaser.yaml and the install scripts +task release:snapshot # build every release artifact into dist/, publish nothing ``` The API client is [`github.com/kaitencloud/sdk-go`](https://github.com/kaitencloud/sdk-go); @@ -219,8 +290,19 @@ a change to a request or response shape belongs there. This repository holds the command surface: flags, input sources, output formatting, confirmation prompts and exit codes. -Releases are cut by pushing a `vX.Y.Z` tag: GoReleaser builds the six binaries, -writes the checksums and publishes the GitHub release. +### Releasing + +Pushing a `vX.Y.Z` tag runs `.github/workflows/release.yml`, and GoReleaser +(`.goreleaser.yaml`) produces everything the Installation section points at: the six +binaries and their archives with completions, `checksums.txt`, the `.deb`/`.rpm`/`.apk` +packages and the `ghcr.io/kaitencloud/cli` image. +`task release:snapshot` builds all of it locally into `dist/` without a tag, and +`task release:check` validates the configuration and the install scripts; CI runs the +latter on every pull request. + +A pre-release tag such as `v1.2.0-rc.1` is published as a GitHub pre-release: the +install scripts' "latest" and the `latest` image tag keep pointing at the last stable +version. ## Contributing diff --git a/Taskfile.yml b/Taskfile.yml index 80bb6aa..db5c226 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -52,11 +52,20 @@ tasks: - go run golang.org/x/vuln/cmd/govulncheck@{{.GOVULNCHECK_VERSION}} ./... release:check: - desc: Validate .goreleaser.yaml without building anything + desc: Validate .goreleaser.yaml and the install scripts without building anything cmds: # The release pipeline only runs on a tag push, so a broken config is otherwise # discovered at the worst possible moment. This is the same check, on demand. - go run github.com/goreleaser/goreleaser/v2@{{.GORELEASER_VERSION}} check + - shellcheck --shell=sh install/install.sh + + release:snapshot: + desc: Build every archive and package into dist/ without publishing + cmds: + # Everything a tag would release, from the working tree, without a tag or + # a registry: six binaries, the archives and .deb/.rpm/.apk. Docker is + # skipped because the arm64 image needs QEMU; CI has it, a laptop may not. + - go run github.com/goreleaser/goreleaser/v2@{{.GORELEASER_VERSION}} release --snapshot --clean --skip=publish,docker coverage: desc: Write a coverage profile to coverage.out diff --git a/install/install.ps1 b/install/install.ps1 new file mode 100644 index 0000000..6df084e --- /dev/null +++ b/install/install.ps1 @@ -0,0 +1,90 @@ +<# +.SYNOPSIS + Installs the Kaiten CLI from its GitHub releases on Windows. + +.DESCRIPTION + Latest release, into $Env:LOCALAPPDATA\Programs\kaiten (no administrator rights needed): + + irm https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.ps1 | iex + + A specific version or directory, by saving the script and running it with parameters: + + .\install.ps1 -Version 1.2.3 -InstallDir C:\tools\kaiten + + The archive's SHA-256 is verified against the release's checksums.txt before anything is installed. + +.PARAMETER Version + Version to install, with or without the leading v. Defaults to $Env:KAITEN_VERSION, then the latest release. + +.PARAMETER InstallDir + Directory to install kaiten.exe into. Defaults to $Env:KAITEN_INSTALL_DIR, then $Env:LOCALAPPDATA\Programs\kaiten. + The directory is added to the user PATH when it is not there yet. + +.PARAMETER DownloadUrl + Base URL serving the release assets, for mirrors. Defaults to $Env:KAITEN_DOWNLOAD_URL, then the GitHub release. +#> +param ( + [string]$Version = $Env:KAITEN_VERSION, + [string]$InstallDir = $(if ($Env:KAITEN_INSTALL_DIR) { $Env:KAITEN_INSTALL_DIR } else { Join-Path $Env:LOCALAPPDATA 'Programs\kaiten' }), + [string]$DownloadUrl = $Env:KAITEN_DOWNLOAD_URL +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +$Repo = 'kaitencloud/cli' +$Binary = 'kaiten.exe' + +$arch = switch ($Env:PROCESSOR_ARCHITECTURE) { + 'AMD64' { 'amd64' } + 'ARM64' { 'arm64' } + default { throw "Unsupported architecture: $Env:PROCESSOR_ARCHITECTURE (releases ship amd64 and arm64)" } +} + +$asset = "kaiten_windows_$arch.zip" +if ($DownloadUrl) { + $base = $DownloadUrl.TrimEnd('/') +} elseif ($Version) { + if (-not $Version.StartsWith('v')) { $Version = "v$Version" } + $base = "https://github.com/$Repo/releases/download/$Version" +} else { + $base = "https://github.com/$Repo/releases/latest/download" +} + +$tmp = Join-Path ([IO.Path]::GetTempPath()) ('kaiten-install-' + [IO.Path]::GetRandomFileName()) +New-Item -ItemType Directory -Path $tmp | Out-Null +try { + Write-Host "Downloading $base/$asset" + Invoke-WebRequest -UseBasicParsing -Uri "$base/$asset" -OutFile (Join-Path $tmp $asset) + Invoke-WebRequest -UseBasicParsing -Uri "$base/checksums.txt" -OutFile (Join-Path $tmp 'checksums.txt') + + $expected = Get-Content (Join-Path $tmp 'checksums.txt') | + Where-Object { ($_ -split '\s+')[1] -eq $asset } | + ForEach-Object { ($_ -split '\s+')[0] } | + Select-Object -First 1 + if (-not $expected) { throw "checksums.txt has no entry for $asset" } + + $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $tmp $asset)).Hash + if ($actual.ToLowerInvariant() -ne $expected.ToLowerInvariant()) { + throw "Checksum mismatch for ${asset}: expected $expected, got $actual" + } + + Expand-Archive -Force -Path (Join-Path $tmp $asset) -DestinationPath $tmp + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null + Copy-Item -Force (Join-Path $tmp $Binary) (Join-Path $InstallDir $Binary) +} finally { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue +} + +$exe = Join-Path $InstallDir $Binary +Write-Host "Installed $(& $exe version) to $exe" + +$userPath = [Environment]::GetEnvironmentVariable('PATH', 'User') +if (($userPath -split ';') -notcontains $InstallDir) { + $newPath = if ($userPath) { "$userPath;$InstallDir" } else { $InstallDir } + [Environment]::SetEnvironmentVariable('PATH', $newPath, 'User') + $Env:PATH = "$Env:PATH;$InstallDir" + Write-Host "Added $InstallDir to your user PATH. Open a new terminal for it to take effect." +} +Write-Host "Run 'kaiten config set base-url ' and 'kaiten doctor' to get started." diff --git a/install/install.sh b/install/install.sh new file mode 100644 index 0000000..f0e4e3e --- /dev/null +++ b/install/install.sh @@ -0,0 +1,100 @@ +#!/bin/sh +# Installs the Kaiten CLI from its GitHub releases on Linux and macOS. +# +# curl -fsSL https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.sh | sh -s -- 1.2.3 +# +# Environment: +# KAITEN_VERSION version to install, with or without the leading v (default: latest release) +# KAITEN_INSTALL_DIR directory to install into (default: /usr/local/bin, with sudo when needed) +# KAITEN_DOWNLOAD_URL base URL serving the release assets, for mirrors (default: the GitHub release) +# +# The archive's SHA-256 is verified against the release's checksums.txt before anything is installed. +set -eu + +REPO="kaitencloud/cli" +BINARY="kaiten" +INSTALL_DIR="${KAITEN_INSTALL_DIR:-/usr/local/bin}" +VERSION="${1:-${KAITEN_VERSION:-}}" + +say() { printf '%s\n' "$*"; } +fail() { printf 'install.sh: %s\n' "$*" >&2; exit 1; } +have() { command -v "$1" >/dev/null 2>&1; } + +fetch() { + if have curl; then + curl -fsSL --retry 3 -o "$2" "$1" + elif have wget; then + wget -q -O "$2" "$1" + else + fail "curl or wget is required" + fi +} + +os=$(uname -s | tr '[:upper:]' '[:lower:]') +case "$os" in + linux | darwin) ;; + mingw* | msys* | cygwin*) fail "on Windows, run: powershell -c \"irm https://raw.githubusercontent.com/$REPO/main/install/install.ps1 | iex\"" ;; + *) fail "unsupported operating system: $os" ;; +esac + +arch=$(uname -m) +case "$arch" in + x86_64 | amd64) arch=amd64 ;; + aarch64 | arm64) arch=arm64 ;; + *) fail "unsupported architecture: $arch (releases ship amd64 and arm64)" ;; +esac + +asset="${BINARY}_${os}_${arch}.tar.gz" +if [ -n "${KAITEN_DOWNLOAD_URL:-}" ]; then + base="${KAITEN_DOWNLOAD_URL%/}" +elif [ -n "$VERSION" ]; then + case "$VERSION" in v*) ;; *) VERSION="v$VERSION" ;; esac + base="https://github.com/$REPO/releases/download/$VERSION" +else + base="https://github.com/$REPO/releases/latest/download" +fi + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT INT TERM + +say "Downloading $base/$asset" +fetch "$base/$asset" "$tmp/$asset" +fetch "$base/checksums.txt" "$tmp/checksums.txt" + +expected=$(awk -v f="$asset" '$2 == f { print $1 }' "$tmp/checksums.txt") +[ -n "$expected" ] || fail "checksums.txt has no entry for $asset" +if have sha256sum; then + actual=$(sha256sum "$tmp/$asset" | awk '{ print $1 }') +elif have shasum; then + actual=$(shasum -a 256 "$tmp/$asset" | awk '{ print $1 }') +else + fail "sha256sum or shasum is required to verify the download" +fi +[ "$actual" = "$expected" ] || fail "checksum mismatch for $asset: expected $expected, got $actual" + +tar -xzf "$tmp/$asset" -C "$tmp" "$BINARY" + +as_root() { + if [ -n "$use_sudo" ]; then sudo "$@"; else "$@"; fi +} + +# Writability is decided by the closest directory that already exists, so a +# missing ~/.local/bin is created by the user and a missing /opt/x by root. +probe="$INSTALL_DIR" +while [ ! -d "$probe" ]; do probe=$(dirname "$probe"); done +use_sudo="" +if [ ! -w "$probe" ]; then + have sudo || fail "cannot write to $INSTALL_DIR; set KAITEN_INSTALL_DIR to a writable directory" + say "Installing into $INSTALL_DIR needs sudo." + use_sudo=1 +fi +as_root mkdir -p "$INSTALL_DIR" +as_root install -m 755 "$tmp/$BINARY" "$INSTALL_DIR/$BINARY" + +say "Installed $("$INSTALL_DIR/$BINARY" version) to $INSTALL_DIR/$BINARY" +case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) say "Note: $INSTALL_DIR is not in your PATH." ;; +esac +say "Run 'kaiten config set base-url ' and 'kaiten doctor' to get started."