From e417a77a4b855f18affb95fba1d156058a70614d Mon Sep 17 00:00:00 2001 From: tomflenner Date: Thu, 1 Oct 2026 10:54:44 +0200 Subject: [PATCH 1/3] feat: install scripts, Linux packages, Homebrew, Scoop and a container image install/install.sh (Linux, macOS) and install/install.ps1 (Windows) fetch the release archive for the host's platform, verify it against the release's checksums.txt and install the binary; both take a version and an install directory, and the shell script uses sudo only when the target needs it. GoReleaser now also produces .deb, .rpm and .apk packages with shell completions, a Homebrew cask and a Scoop manifest for kaitencloud/homebrew-tap and kaitencloud/scoop-bucket, and a multi-platform ghcr.io/kaitencloud/cli image on a distroless base. Package and archive names carry no version, so releases/latest/download/ is a stable URL for every one of them. The cask and the manifest are pushed only when HOMEBREW_TAP_GITHUB_TOKEN is set and the tag is not a pre-release; otherwise they are left in dist/. CI validates the GoReleaser configuration and both install scripts on every pull request, and `task release:snapshot` builds the whole release locally. Co-Authored-By: Claude Fable 5 Signed-off-by: tomflenner --- .github/workflows/ci.yml | 16 ++++ .github/workflows/release.yml | 22 ++++- .gitignore | 1 + .goreleaser.yaml | 163 +++++++++++++++++++++++++++++++++- Dockerfile | 11 +++ README.md | 123 ++++++++++++++++++++++--- Taskfile.yml | 12 ++- install/install.ps1 | 90 +++++++++++++++++++ install/install.sh | 100 +++++++++++++++++++++ 9 files changed, 521 insertions(+), 17 deletions(-) create mode 100644 Dockerfile create mode 100644 install/install.ps1 create mode 100644 install/install.sh 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..daa8ec6 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,30 @@ 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 }} + # A token that can push to kaitencloud/homebrew-tap and + # kaitencloud/scoop-bucket. GITHUB_TOKEN cannot, as it is scoped to + # this repository. Left unset, the formula and manifest are rendered + # into dist/ but not published -- see .goreleaser.yaml. + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_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..a936520 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,10 @@ project_name: kaiten before: hooks: - go mod tidy + # Completions ship in every archive and Linux package, and the Homebrew + # formula installs them, 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 +30,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 +46,139 @@ 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 + +# brew install --cask kaitencloud/tap/kaiten +# +# Publishing the cask needs a token that can push to kaitencloud/homebrew-tap; +# the workflow passes it as HOMEBREW_TAP_GITHUB_TOKEN. When it is empty, or +# the tag is a pre-release, the cask is still rendered into dist/ and simply +# not pushed, so a release never fails on a missing tap and an rc never +# becomes what `brew install` hands out. +homebrew_casks: + - name: kaiten + repository: + owner: kaitencloud + name: homebrew-tap + token: "{{ envOrDefault \"HOMEBREW_TAP_GITHUB_TOKEN\" \"\" }}" + homepage: https://kaiten.sh + description: Command-line interface for the Kaiten API + binaries: + - kaiten + completions: + bash: completions/kaiten.bash + zsh: completions/kaiten.zsh + fish: completions/kaiten.fish + skip_upload: "{{ if and (envOrDefault \"HOMEBREW_TAP_GITHUB_TOKEN\" \"\") (not .Prerelease) }}false{{ else }}true{{ end }}" + # The binaries are not signed with an Apple developer certificate, and + # Homebrew quarantines cask downloads, so without this Gatekeeper reports + # the binary as damaged on first run. + hooks: + post: + install: | + if OS.mac? + system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/kaiten"] + end + +# scoop bucket add kaitencloud https://github.com/kaitencloud/scoop-bucket +# scoop install kaiten +# +# Same token and same fallback as the Homebrew cask above. +scoops: + - name: kaiten + repository: + owner: kaitencloud + name: scoop-bucket + token: "{{ envOrDefault \"HOMEBREW_TAP_GITHUB_TOKEN\" \"\" }}" + directory: bucket + homepage: https://kaiten.sh + description: Command-line interface for the Kaiten API + license: Apache-2.0 + skip_upload: "{{ if and (envOrDefault \"HOMEBREW_TAP_GITHUB_TOKEN\" \"\") (not .Prerelease) }}false{{ else }}true{{ end }}" + +# 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 +200,24 @@ 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 }} + brew install --cask kaitencloud/tap/kaiten + + # Windows + irm https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.ps1 | iex + scoop install kaiten + + # 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..84405c0 100644 --- a/README.md +++ b/README.md @@ -22,27 +22,109 @@ 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 +brew install --cask kaitencloud/tap/kaiten +``` + +Or with the install script, which verifies the archive against the release's +`checksums.txt` and installs into `/usr/local/bin` (`KAITEN_INSTALL_DIR` changes that): + +```shell +curl -fsSL https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.sh | sh +``` + +### 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 +``` + +Homebrew on Linux works too: `brew install --cask kaitencloud/tap/kaiten`. + +### 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`. Or with [Scoop](https://scoop.sh): + +```powershell +scoop bucket add kaitencloud https://github.com/kaitencloud/scoop-bucket +scoop install kaiten +``` + +### 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: +Homebrew and Scoop upgrade with `brew upgrade --cask kaiten` and `scoop update kaiten`; +`go install ...@v1.2.3` and the container tags pin a version directly. + +### Shell completion + +Homebrew, 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 +292,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 +302,24 @@ 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, the `ghcr.io/kaitencloud/cli` image, the Homebrew cask and the Scoop manifest. +`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. + +The cask and the Scoop manifest are pushed to `kaitencloud/homebrew-tap` and +`kaitencloud/scoop-bucket` with the `HOMEBREW_TAP_GITHUB_TOKEN` repository secret, a +token that can write to both. Without it the release still succeeds and the two files are +left in `dist/` for a manual commit. + +A pre-release tag such as `v1.2.0-rc.1` is published as a GitHub pre-release: the +install scripts' "latest", the `latest` image tag, the cask and the Scoop manifest all +keep pointing at the last stable version. ## Contributing diff --git a/Taskfile.yml b/Taskfile.yml index 80bb6aa..ba1134a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -52,11 +52,21 @@ 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, package and formula into dist/ without publishing + cmds: + # Everything a tag would release, from the working tree, without a tag, a + # token or a registry: six binaries, the archives, .deb/.rpm/.apk, the + # rendered Homebrew formula and Scoop manifest. 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." From 242c44884ddccccac40684a6cab565f75b5844d0 Mon Sep 17 00:00:00 2001 From: tomflenner Date: Sat, 3 Oct 2026 11:21:58 +0200 Subject: [PATCH 2/3] chore: drop the Homebrew cask for now The tap does not exist yet, so the cask could not be published anyway. The token the release passes for package-manager repositories is now named for its one remaining user, SCOOP_BUCKET_GITHUB_TOKEN. Co-Authored-By: Claude Fable 5 Signed-off-by: tomflenner --- .github/workflows/release.yml | 9 +++---- .goreleaser.yaml | 48 +++++++---------------------------- README.md | 33 ++++++++++-------------- Taskfile.yml | 8 +++--- 4 files changed, 30 insertions(+), 68 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index daa8ec6..9fb602a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,8 +51,7 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # A token that can push to kaitencloud/homebrew-tap and - # kaitencloud/scoop-bucket. GITHUB_TOKEN cannot, as it is scoped to - # this repository. Left unset, the formula and manifest are rendered - # into dist/ but not published -- see .goreleaser.yaml. - HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + # A token that can push to kaitencloud/scoop-bucket. GITHUB_TOKEN + # cannot, as it is scoped to this repository. Left unset, the manifest + # is rendered into dist/ but not published -- see .goreleaser.yaml. + SCOOP_BUCKET_GITHUB_TOKEN: ${{ secrets.SCOOP_BUCKET_GITHUB_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a936520..882e1bf 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -6,9 +6,8 @@ project_name: kaiten before: hooks: - go mod tidy - # Completions ship in every archive and Linux package, and the Homebrew - # formula installs them, so they are generated once here from the binary - # that is about to be released. + # 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: @@ -100,53 +99,25 @@ nfpms: dst: /usr/share/licenses/kaiten/NOTICE packager: apk -# brew install --cask kaitencloud/tap/kaiten -# -# Publishing the cask needs a token that can push to kaitencloud/homebrew-tap; -# the workflow passes it as HOMEBREW_TAP_GITHUB_TOKEN. When it is empty, or -# the tag is a pre-release, the cask is still rendered into dist/ and simply -# not pushed, so a release never fails on a missing tap and an rc never -# becomes what `brew install` hands out. -homebrew_casks: - - name: kaiten - repository: - owner: kaitencloud - name: homebrew-tap - token: "{{ envOrDefault \"HOMEBREW_TAP_GITHUB_TOKEN\" \"\" }}" - homepage: https://kaiten.sh - description: Command-line interface for the Kaiten API - binaries: - - kaiten - completions: - bash: completions/kaiten.bash - zsh: completions/kaiten.zsh - fish: completions/kaiten.fish - skip_upload: "{{ if and (envOrDefault \"HOMEBREW_TAP_GITHUB_TOKEN\" \"\") (not .Prerelease) }}false{{ else }}true{{ end }}" - # The binaries are not signed with an Apple developer certificate, and - # Homebrew quarantines cask downloads, so without this Gatekeeper reports - # the binary as damaged on first run. - hooks: - post: - install: | - if OS.mac? - system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/kaiten"] - end - # scoop bucket add kaitencloud https://github.com/kaitencloud/scoop-bucket # scoop install kaiten # -# Same token and same fallback as the Homebrew cask above. +# Publishing the manifest needs a token that can push to +# kaitencloud/scoop-bucket; the workflow passes it as SCOOP_BUCKET_GITHUB_TOKEN. +# When it is empty, or the tag is a pre-release, the manifest is still rendered +# into dist/ and simply not pushed, so a release never fails on a missing bucket +# and an rc never becomes what `scoop install` hands out. scoops: - name: kaiten repository: owner: kaitencloud name: scoop-bucket - token: "{{ envOrDefault \"HOMEBREW_TAP_GITHUB_TOKEN\" \"\" }}" + token: "{{ envOrDefault \"SCOOP_BUCKET_GITHUB_TOKEN\" \"\" }}" directory: bucket homepage: https://kaiten.sh description: Command-line interface for the Kaiten API license: Apache-2.0 - skip_upload: "{{ if and (envOrDefault \"HOMEBREW_TAP_GITHUB_TOKEN\" \"\") (not .Prerelease) }}false{{ else }}true{{ end }}" + skip_upload: "{{ if and (envOrDefault \"SCOOP_BUCKET_GITHUB_TOKEN\" \"\") (not .Prerelease) }}false{{ else }}true{{ end }}" # 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 @@ -209,7 +180,6 @@ release: ```shell # macOS and Linux curl -fsSL https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.sh | sh -s -- {{ .Version }} - brew install --cask kaitencloud/tap/kaiten # Windows irm https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.ps1 | iex diff --git a/README.md b/README.md index 84405c0..4bf6497 100644 --- a/README.md +++ b/README.md @@ -28,17 +28,13 @@ Every method below installs the same binary from the ### macOS -```shell -brew install --cask kaitencloud/tap/kaiten -``` - -Or with the install script, which verifies the archive against the release's -`checksums.txt` and installs into `/usr/local/bin` (`KAITEN_INSTALL_DIR` changes that): - ```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 @@ -61,8 +57,6 @@ curl -fsSLO https://github.com/kaitencloud/cli/releases/latest/download/kaiten_l sudo apk add --allow-untrusted kaiten_linux_amd64.apk ``` -Homebrew on Linux works too: `brew install --cask kaitencloud/tap/kaiten`. - ### Windows ```powershell @@ -112,13 +106,13 @@ curl -fsSL https://raw.githubusercontent.com/kaitencloud/cli/main/install/instal & ([scriptblock]::Create((irm https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.ps1))) -Version 1.2.3 ``` -Homebrew and Scoop upgrade with `brew upgrade --cask kaiten` and `scoop update kaiten`; -`go install ...@v1.2.3` and the container tags pin a version directly. +Scoop upgrades with `scoop update kaiten`; `go install ...@v1.2.3` and the container +tags pin a version directly. ### Shell completion -Homebrew, the Linux packages and the archives ship completions for bash, zsh and fish. -Any other install can generate them from the binary: +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" @@ -307,19 +301,18 @@ exit codes. 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, the `ghcr.io/kaitencloud/cli` image, the Homebrew cask and the Scoop manifest. +packages, the `ghcr.io/kaitencloud/cli` image and the Scoop manifest. `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. -The cask and the Scoop manifest are pushed to `kaitencloud/homebrew-tap` and -`kaitencloud/scoop-bucket` with the `HOMEBREW_TAP_GITHUB_TOKEN` repository secret, a -token that can write to both. Without it the release still succeeds and the two files are -left in `dist/` for a manual commit. +The Scoop manifest is pushed to `kaitencloud/scoop-bucket` with the +`SCOOP_BUCKET_GITHUB_TOKEN` repository secret, a token that can write to it. Without it +the release still succeeds and the manifest is left in `dist/` for a manual commit. A pre-release tag such as `v1.2.0-rc.1` is published as a GitHub pre-release: the -install scripts' "latest", the `latest` image tag, the cask and the Scoop manifest all -keep pointing at the last stable version. +install scripts' "latest", the `latest` image tag and the Scoop manifest all keep +pointing at the last stable version. ## Contributing diff --git a/Taskfile.yml b/Taskfile.yml index ba1134a..13fb71c 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -60,12 +60,12 @@ tasks: - shellcheck --shell=sh install/install.sh release:snapshot: - desc: Build every archive, package and formula into dist/ without publishing + desc: Build every archive, package and manifest into dist/ without publishing cmds: # Everything a tag would release, from the working tree, without a tag, a - # token or a registry: six binaries, the archives, .deb/.rpm/.apk, the - # rendered Homebrew formula and Scoop manifest. Docker is skipped because - # the arm64 image needs QEMU; CI has it, a laptop may not. + # token or a registry: six binaries, the archives, .deb/.rpm/.apk and the + # rendered Scoop manifest. 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: From f51b27a66e72b412b536f813137489210d190aed Mon Sep 17 00:00:00 2001 From: tomflenner Date: Sat, 3 Oct 2026 12:02:35 +0200 Subject: [PATCH 3/3] chore: drop the Scoop manifest for now Same reason as the Homebrew cask: the bucket does not exist yet. With it goes the only token the release workflow needed beyond GITHUB_TOKEN. Co-Authored-By: Claude Fable 5 Signed-off-by: tomflenner --- .github/workflows/release.yml | 4 ---- .goreleaser.yaml | 21 --------------------- README.md | 20 +++++--------------- Taskfile.yml | 9 ++++----- 4 files changed, 9 insertions(+), 45 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9fb602a..da6504a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,7 +51,3 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # A token that can push to kaitencloud/scoop-bucket. GITHUB_TOKEN - # cannot, as it is scoped to this repository. Left unset, the manifest - # is rendered into dist/ but not published -- see .goreleaser.yaml. - SCOOP_BUCKET_GITHUB_TOKEN: ${{ secrets.SCOOP_BUCKET_GITHUB_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 882e1bf..a4e28db 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -99,26 +99,6 @@ nfpms: dst: /usr/share/licenses/kaiten/NOTICE packager: apk -# scoop bucket add kaitencloud https://github.com/kaitencloud/scoop-bucket -# scoop install kaiten -# -# Publishing the manifest needs a token that can push to -# kaitencloud/scoop-bucket; the workflow passes it as SCOOP_BUCKET_GITHUB_TOKEN. -# When it is empty, or the tag is a pre-release, the manifest is still rendered -# into dist/ and simply not pushed, so a release never fails on a missing bucket -# and an rc never becomes what `scoop install` hands out. -scoops: - - name: kaiten - repository: - owner: kaitencloud - name: scoop-bucket - token: "{{ envOrDefault \"SCOOP_BUCKET_GITHUB_TOKEN\" \"\" }}" - directory: bucket - homepage: https://kaiten.sh - description: Command-line interface for the Kaiten API - license: Apache-2.0 - skip_upload: "{{ if and (envOrDefault \"SCOOP_BUCKET_GITHUB_TOKEN\" \"\") (not .Prerelease) }}false{{ else }}true{{ end }}" - # 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` @@ -183,7 +163,6 @@ release: # Windows irm https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.ps1 | iex - scoop install kaiten # Container docker run --rm ghcr.io/kaitencloud/cli:{{ .Version }} version diff --git a/README.md b/README.md index 4bf6497..ec64091 100644 --- a/README.md +++ b/README.md @@ -65,12 +65,7 @@ irm https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.ps1 | 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`. Or with [Scoop](https://scoop.sh): - -```powershell -scoop bucket add kaitencloud https://github.com/kaitencloud/scoop-bucket -scoop install kaiten -``` +user `PATH`. ### Container @@ -106,8 +101,7 @@ curl -fsSL https://raw.githubusercontent.com/kaitencloud/cli/main/install/instal & ([scriptblock]::Create((irm https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.ps1))) -Version 1.2.3 ``` -Scoop upgrades with `scoop update kaiten`; `go install ...@v1.2.3` and the container -tags pin a version directly. +`go install ...@v1.2.3` and the container tags pin a version directly. ### Shell completion @@ -301,18 +295,14 @@ exit codes. 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, the `ghcr.io/kaitencloud/cli` image and the Scoop manifest. +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. -The Scoop manifest is pushed to `kaitencloud/scoop-bucket` with the -`SCOOP_BUCKET_GITHUB_TOKEN` repository secret, a token that can write to it. Without it -the release still succeeds and the manifest is left in `dist/` for a manual commit. - A pre-release tag such as `v1.2.0-rc.1` is published as a GitHub pre-release: the -install scripts' "latest", the `latest` image tag and the Scoop manifest all keep -pointing at the last stable version. +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 13fb71c..db5c226 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -60,12 +60,11 @@ tasks: - shellcheck --shell=sh install/install.sh release:snapshot: - desc: Build every archive, package and manifest into dist/ without publishing + desc: Build every archive and package into dist/ without publishing cmds: - # Everything a tag would release, from the working tree, without a tag, a - # token or a registry: six binaries, the archives, .deb/.rpm/.apk and the - # rendered Scoop manifest. Docker is skipped because the arm64 image needs - # QEMU; CI has it, a laptop may not. + # 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: