From 8022007b70ef16356dd65a28b72b427760eafc2c Mon Sep 17 00:00:00 2001 From: tomflenner Date: Thu, 1 Oct 2026 10:54:44 +0200 Subject: [PATCH 1/6] 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 6efcb6a..0093b65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,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 dd52539..aa2ebc2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ on: permissions: contents: write + packages: write jobs: goreleaser: @@ -32,11 +33,30 @@ jobs: check-latest: true 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 733bcd2..d404b68 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 @@ -225,8 +307,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); @@ -234,8 +317,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 d7a85bfb054ea68ee8672e73240da1a522b51f93 Mon Sep 17 00:00:00 2001 From: tomflenner Date: Sat, 3 Oct 2026 11:21:58 +0200 Subject: [PATCH 2/6] 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 aa2ebc2..7e7f6a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,8 +55,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 d404b68..5f88c30 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" @@ -322,19 +316,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 7778f03e985fd6ddcf15a6a2a6a4b1d07c989f72 Mon Sep 17 00:00:00 2001 From: tomflenner Date: Sat, 3 Oct 2026 12:02:35 +0200 Subject: [PATCH 3/6] 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 7e7f6a7..be8bb3d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,7 +55,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 5f88c30..2bded79 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 @@ -316,18 +310,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: From 3f8501fcd00dfe62deda3ebfd8f7a77da3913ca3 Mon Sep 17 00:00:00 2001 From: tomflenner Date: Tue, 13 Oct 2026 22:54:47 +0200 Subject: [PATCH 4/6] feat(release): cut releases from main with release-please Every merge to main now runs release-please, which keeps one "chore: release X.Y.Z" pull request open with the next version, computed from the Conventional Commit messages since the last release, and the CHANGELOG.md entry. Merging it tags vX.Y.Z and creates the GitHub release; the goreleaser job in the same workflow then builds and attaches the archives, packages and image. Nobody pushes a tag by hand any more, so the tag trigger goes. release-please acts as a GitHub App (RELEASE_APP_ID, RELEASE_APP_PRIVATE_KEY): a pull request opened with GITHUB_TOKEN starts no workflow, and the release pull request has to pass the required checks like any other. GoReleaser keeps using GITHUB_TOKEN. GoReleaser appends its install footer to the notes release-please wrote instead of generating a second changelog. Every archive, package and the image get a build-provenance attestation. workflow_dispatch rebuilds an existing tag if the goreleaser job fails after tagging. Pre-1.0, feat and breaking changes bump the minor, fix the patch; the first release is 0.1.0. Co-Authored-By: Claude Opus 5.5 Signed-off-by: tomflenner --- .github/workflows/release.yml | 96 +++++++++++++++++++++++++++++++++-- .goreleaser.yaml | 27 ++++------ .release-please-manifest.json | 1 + release-please-config.json | 24 +++++++++ 4 files changed, 126 insertions(+), 22 deletions(-) create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be8bb3d..f852cdf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,21 +1,84 @@ name: Release +# Releases are cut from main, never by hand-pushing a tag. +# +# Every push to main runs release-please, which reads the Conventional Commit messages +# since the last release and keeps one pull request open, "chore: release X.Y.Z", that +# bumps the version and writes CHANGELOG.md. Merging that pull request is the release: +# on that push release-please tags vX.Y.Z and creates the GitHub release, and the +# goreleaser job builds and attaches everything the README's Installation section +# points at. +# +# workflow_dispatch rebuilds the artifacts of a tag that already exists, for when the +# goreleaser job failed after release-please had tagged. on: push: - tags: - - 'v[0-9]+.[0-9]+.[0-9]+*' + branches: [ "main" ] + workflow_dispatch: + inputs: + tag: + description: Existing tag to build and publish, e.g. v0.1.0 + required: true -permissions: - contents: write - packages: write +permissions: {} + +# Two merges in quick succession must not race to tag or publish. +concurrency: + group: release + cancel-in-progress: false jobs: + release-please: + if: github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + # The release pull request must run CI: the ruleset requires lint, build, test and + # DCO before anything merges. GitHub starts no workflow for a pull request opened + # with the job's GITHUB_TOKEN, so release-please acts as a GitHub App instead. The + # App needs Contents, Pull requests and Issues (for labels) read and write on this + # repository; README's Releasing section says how to set it up. + - name: Create GitHub App token + id: app + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: Run release-please + id: release + uses: googleapis/release-please-action@v4 + with: + token: ${{ steps.app.outputs.token }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + goreleaser: + needs: release-please + # release-please is skipped on workflow_dispatch, and a skipped need skips this job + # unless the condition says otherwise. + if: >- + !cancelled() && ( + needs.release-please.outputs.release_created == 'true' || + github.event_name == 'workflow_dispatch' + ) runs-on: ubuntu-latest + permissions: + contents: write + packages: write + id-token: write + attestations: write + env: + TAG: ${{ needs.release-please.outputs.tag_name || inputs.tag }} steps: - name: Checkout code uses: actions/checkout@v5 with: + ref: ${{ env.TAG }} fetch-depth: 0 # 1.26.x, the oldest Go still patched: a released binary carries the @@ -47,6 +110,8 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # release-please has already created the release; GoReleaser finds it by tag and + # attaches the artifacts (release.mode: append in .goreleaser.yaml). - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: @@ -55,3 +120,24 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # SLSA build provenance for every archive and package, and for the image, so + # `gh attestation verify --repo kaitencloud/cli` proves an artifact was + # built here, from this tag. + - name: Attest release artifacts + uses: actions/attest-build-provenance@v3 + with: + subject-checksums: dist/checksums.txt + + - name: Resolve image digest + id: image + run: | + digest=$(docker buildx imagetools inspect "ghcr.io/kaitencloud/cli:${TAG#v}" --format '{{json .Manifest}}' | jq -r .digest) + echo "digest=$digest" >> "$GITHUB_OUTPUT" + + - name: Attest container image + uses: actions/attest-build-provenance@v3 + with: + subject-name: ghcr.io/kaitencloud/cli + subject-digest: ${{ steps.image.outputs.digest }} + push-to-registry: true diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a4e28db..3bf5537 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -130,28 +130,21 @@ dockers_v2: org.opencontainers.image.created: "{{ .Date }}" org.opencontainers.image.licenses: Apache-2.0 +# release-please writes the release notes, from the same commits, into CHANGELOG.md +# and the GitHub release; GoReleaser adds only the install footer below. changelog: - sort: asc - groups: - - title: Features - regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' - order: 0 - - title: Bug fixes - regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' - order: 1 - - title: Other - order: 999 - filters: - exclude: - - "^docs:" - - "^test:" - - "^chore:" + disable: true release: github: owner: kaitencloud name: cli - # A pre-release tag (v1.2.0-rc.1) is published as a GitHub pre-release, which + # release-please has created the release by the time GoReleaser runs: append the + # footer to its notes and attach the artifacts, rather than replace them. This is + # also why the repository must not turn on immutable releases, which forbid adding + # assets to a published release. + mode: append + # A pre-release version (1.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: | @@ -162,7 +155,7 @@ release: 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 + & ([scriptblock]::Create((irm https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.ps1))) -Version {{ .Version }} # Container docker run --rm ghcr.io/kaitencloud/cli:{{ .Version }} version diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1 @@ +{} diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..c5e5e5d --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "go", + "include-component-in-tag": false, + "initial-version": "0.1.0", + "bump-minor-pre-major": true, + "pull-request-title-pattern": "chore: release ${version}", + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug fixes" }, + { "type": "perf", "section": "Performance" }, + { "type": "revert", "section": "Reverts" }, + { "type": "docs", "section": "Documentation", "hidden": true }, + { "type": "refactor", "section": "Refactoring", "hidden": true }, + { "type": "build", "section": "Build and dependencies" }, + { "type": "chore", "section": "Chores", "hidden": true }, + { "type": "ci", "section": "CI", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "style", "section": "Style", "hidden": true } + ], + "packages": { + ".": {} + } +} From ef7a4a8f9b99ffc7568496dfa32b87eb5b38b201 Mon Sep 17 00:00:00 2001 From: tomflenner Date: Tue, 13 Oct 2026 22:54:47 +0200 Subject: [PATCH 5/6] ci: hold pull request titles to Conventional Commits release-please reads the commits that land on main, and a squash merge makes the pull request title the commit message. The PR title check runs on pull_request_target, like the DCO check, and checks no code out. CONTRIBUTING.md and the pull request template say what each type releases. Co-Authored-By: Claude Opus 5.5 Signed-off-by: tomflenner --- .github/PULL_REQUEST_TEMPLATE.md | 1 + .github/workflows/pr-title.yml | 32 ++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 19 +++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 .github/workflows/pr-title.yml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ef37213..75b1cad 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -13,6 +13,7 @@ ## Checklist - [ ] I have read `CONTRIBUTING.md`. +- [ ] The PR title follows Conventional Commits (`feat:`, `fix:`, ...); it becomes the changelog entry. - [ ] Every commit in this PR includes a valid DCO `Signed-off-by` line. - [ ] I have the right to submit all material in this PR. - [ ] I have not included secrets or confidential data. diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..6b473f3 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,32 @@ +name: PR title + +# release-please computes every version and changelog entry from the commit messages +# that land on main, so they must follow Conventional Commits: `feat:` cuts a minor +# version, `fix:` a patch, `feat!:` or a `BREAKING CHANGE:` footer a major one (a minor +# one before 1.0.0). A squash merge makes the pull request title the commit message, +# which is why the title is the thing checked here, and a single-commit pull request +# must agree with it, since GitHub uses that commit's message instead. +# +# pull_request_target, like dco.yml, so the check runs as it is on main. It is safe for +# the same reason: nothing here checks out or runs the pull request's code. +on: + pull_request_target: + branches: ["main"] + types: [opened, edited, synchronize, reopened] + +permissions: + pull-requests: read + +jobs: + title: + name: PR title + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Title follows Conventional Commits + uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + validateSingleCommit: true + validateSingleCommitMatchesPrTitle: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4708e1..1447da3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,6 +46,25 @@ git rebase --signoff origin/main git push --force-with-lease ``` +## Commit messages and pull request titles + +Versions and the changelog are computed from the commits that land on `main`, so they +follow [Conventional Commits](https://www.conventionalcommits.org/): + +```text +fix(output): keep the token plaintext under --output table +feat: add the instances restart command +feat!: rename --tenant to --customer +``` + +`fix:` releases a patch; `feat:` a minor version; `!` after the type, or a +`BREAKING CHANGE:` footer, a major one (a minor one before 1.0.0). `docs:`, `chore:`, +`ci:`, `test:`, `refactor:` and `style:` release nothing on their own. + +A squash merge uses the pull request title as the commit message, so the **PR title** +check holds the title to the same rules. A single-commit pull request's commit message +must match its title. README's Releasing section describes the rest of the release. + ## Contribution rules Please: From dba11e7462bc083276f28f308ee4517e9153089b Mon Sep 17 00:00:00 2001 From: tomflenner Date: Tue, 13 Oct 2026 22:54:47 +0200 Subject: [PATCH 6/6] docs: describe the release-please flow and its one-time setup README's Releasing section covers the release pull request, how commit types map to versions, re-running a failed publish, and the maintainer setup: the GitHub App, making the GHCR package public, keeping immutable releases off. task release:check now parses install.ps1 as CI does. Co-Authored-By: Claude Opus 5.5 Signed-off-by: tomflenner --- .github/workflows/ci.yml | 5 +++-- README.md | 47 +++++++++++++++++++++++++++++++++------- Taskfile.yml | 10 +++++++-- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0093b65..1c21697 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,8 +49,9 @@ 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`. + # The release pipeline only runs once a release pull request merges, so a broken + # .goreleaser.yaml would otherwise surface at the worst moment, after the tag + # exists. Same version as `task release:check`. - name: GoReleaser config run: go run github.com/goreleaser/goreleaser/v2@v2.12.7 check diff --git a/README.md b/README.md index 2bded79..e72dcc7 100644 --- a/README.md +++ b/README.md @@ -307,17 +307,48 @@ exit codes. ### 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 +Releases are automatic. Nobody pushes a tag. + +1. Every merge to `main` runs `.github/workflows/release.yml`. + [release-please](https://github.com/googleapis/release-please) reads the + [Conventional Commit](https://www.conventionalcommits.org/) messages since the last + release and opens, or updates, one pull request titled `chore: release X.Y.Z`. It + bumps the version in `.release-please-manifest.json` and adds the entry to + `CHANGELOG.md`. +2. Merging that pull request is the release. release-please tags `vX.Y.Z` and creates + the GitHub release with the changelog entry. GoReleaser (`.goreleaser.yaml`) then + builds and attaches 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, each with a build-provenance + attestation (`gh attestation verify --repo kaitencloud/cli`). + +The commit types decide the version. Before 1.0.0, `fix:` bumps the patch, and `feat:` +and breaking changes (`feat!:`, or a `BREAKING CHANGE:` footer) bump the minor. From +1.0.0, `feat:` bumps the minor and breaking changes the major. `perf:`, `revert:` and +`build:` (dependency bumps included) cut a patch too; `docs:`, `chore:`, `ci:`, `test:`, +`refactor:` and `style:` get no changelog entry and cut no release on their own. To force a version, pre-releases included, add a +`Release-As: 1.2.0-rc.1` footer to a commit. A pre-release is published as a GitHub +pre-release: the install scripts' "latest" and the `latest` image tag keep pointing at +the last stable version. + +If the GoReleaser job fails after the tag exists, fix the cause and run the Release +workflow by hand (**Actions → Release → Run workflow**) with that tag. + +`task release:snapshot` builds every artifact 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. +One-time setup, for maintainers: + +- A GitHub App installed on this repository with **Contents**, **Pull requests** and + **Issues** set to read and write. Its ID goes in the `RELEASE_APP_ID` Actions + variable and its private key in the `RELEASE_APP_PRIVATE_KEY` Actions secret. + release-please needs it because the release pull request has to run the required + checks, and GitHub starts no workflow for a pull request opened with `GITHUB_TOKEN`. +- After the first release, make the `cli` package public in the organization's + package settings; new GHCR packages start private. +- Leave immutable releases off: GoReleaser attaches the artifacts to the release + release-please has already published. ## Contributing diff --git a/Taskfile.yml b/Taskfile.yml index db5c226..7163d38 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -54,10 +54,16 @@ tasks: release:check: 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. + # The release pipeline only runs once a release pull request merges, so a broken + # config is otherwise discovered at the worst possible moment. These are the + # checks CI's lint job runs, on demand. - go run github.com/goreleaser/goreleaser/v2@{{.GORELEASER_VERSION}} check - 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 }' release:snapshot: desc: Build every archive and package into dist/ without publishing